|
| 1 | +# USBManager |
| 2 | + |
| 3 | +USBManager is a singleton framework that provides a unified API for USB host support on ESP32: DisplayLink display adapters plus boot-protocol HID mice and keyboards. It owns the host lifecycle, display/panel switching, HID arming, and the 1-second poll timer that drives the whole subsystem. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +USB host support is opt-in and ESP32-S3-only (needs USB OTG): |
| 8 | + |
| 9 | +``` |
| 10 | +./scripts/build_mpos.sh esp32s3 --usb |
| 11 | +``` |
| 12 | + |
| 13 | +This compiles in the `usb` C module (`c_mpos/usb/`) and disables MicroPython's TinyUSB *device* mode (USB-serial REPL goes away; console remains over UART REPL / USB-Serial-JTAG). On stock builds `USBManager.is_available()` is `False` and every arm call is a harmless no-op. |
| 14 | + |
| 15 | +USBManager centralizes all USB-host operations in a single class with class methods: |
| 16 | + |
| 17 | +- **Unified API** - Single class for display + HID management |
| 18 | +- **Clean Namespace** - No scattered functions cluttering imports |
| 19 | +- **Testable** - USBManager can be tested independently with a fake `usb` module |
| 20 | +- **Hotplug** - The poll timer auto-switches to a ready display and auto-reverts on unplug |
| 21 | +- **HID** - Mice and keyboards arm together, enabled per-kind from live device state |
| 22 | + |
| 23 | +```python |
| 24 | +from mpos import USBManager |
| 25 | + |
| 26 | +if USBManager.is_available(): |
| 27 | + USBManager.arm_display() |
| 28 | + USBManager.arm_hid() |
| 29 | +``` |
| 30 | + |
| 31 | +## Architecture |
| 32 | + |
| 33 | +USBManager is implemented as a singleton using class variables and class methods. No instance creation is needed: |
| 34 | + |
| 35 | +```python |
| 36 | +class USBManager: |
| 37 | + _usb_dev = None # usb.Display handle (the adapter) |
| 38 | + _usb_mouse = None # USBMouse indev, enabled only while a mouse streams |
| 39 | + _usb_keyboard = None # USBHIDKeyboard indev, enabled only while a keyboard streams |
| 40 | + _hid_hub = None # HIDHub demux shared by both indevs |
| 41 | + |
| 42 | + @classmethod |
| 43 | + def arm_display(cls, width=640, height=480): |
| 44 | + ... |
| 45 | +``` |
| 46 | + |
| 47 | +The layers, bottom to top: |
| 48 | + |
| 49 | +- **C module `usb`** (`c_mpos/usb/`): `Display` class (start/poll/ready, frame upload) plus module-level host functions (`bus_devices`, `lsusb`, `hub_ports`, `reset_port`, watchdog toggles) and HID transport (`hid_start/poll/drain/state/...`). Upstream Pico_USB_Disp is vendored under `c_mpos/usb/upstream/` with a few clearly-commented MPOS adaptations (hub-port watchdog, `lsusb`, held-handle hook). |
| 50 | +- **Drivers**: `drivers/display/usb_display.py` (`USBDisplayDriver`, a `DisplayDriver` behind a shim bus) and `drivers/indev/usb_hid.py` (parser registry + `HIDHub` demux + `USBMouse` + `USBHIDKeyboard`). Report parsing is Python-side, so new device kinds never need C changes. |
| 51 | +- **USBManager** (`mpos/usb/`): boot arming (`arm_display`, `arm_hid`), the 1 s LVGL poll timer (HID pump + display state machine + auto-switch), panel/USB swapping, touch remapping, and HID/watchdog coexistence (idle-reset suppression while HID is claimed or parked). |
| 52 | + |
| 53 | +Only DisplayLink DL-1xx adapters work on ESP32-S3 (Full-Speed OTG); DL-165/DL-195 recommended. T6/MS91xx need High-Speed, i.e. ESP32-P4 only (protocol code is vendored but untested on hardware). |
| 54 | + |
| 55 | +### HCD channel budget |
| 56 | + |
| 57 | +The S3 DWC_OTG core has 8 host channels (~7 usable): one channel per USB *pipe*, held for the pipe's lifetime. Rule of thumb: **max 1 hub + 2 downstream devices** on S3. ESP32-P4/S31 have 16 channels, so **1 hub + 4 devices** fits there. Claim priority is display > mouse > keyboard; a claim that fails with channel exhaustion parks silently with backoff until a topology change or `usb.hid_retry()`. |
| 58 | + |
| 59 | +### Display switching |
| 60 | + |
| 61 | +`mpos.main` arms the display at boot without waiting; the poll timer switches the UI over automatically when a monitor becomes ready and reverts on disconnect. The swap suspends the LVGL pump, tears down activities, repoints indevs to the new display (panel touch is remapped through the panel's own proven mapping — no per-board tables), moves the topmenu, and restarts the launcher. Resolution floor is 640x480: smaller modes need a sub-25 MHz pixel clock that real monitors cannot sync to. |
| 62 | + |
| 63 | +## Usage |
| 64 | + |
| 65 | +### Checking availability and arming |
| 66 | + |
| 67 | +```python |
| 68 | +from mpos import USBManager |
| 69 | + |
| 70 | +if USBManager.is_available(): |
| 71 | + dev = USBManager.arm_display() # construct + start host + poll timer |
| 72 | + USBManager.arm_hid() # HID client + mouse/keyboard indevs |
| 73 | +``` |
| 74 | + |
| 75 | +Both are idempotent and safe to retry. On stock builds (no `usb` module) they return `None`. |
| 76 | + |
| 77 | +### Manual display switching |
| 78 | + |
| 79 | +```python |
| 80 | +from mpos import USBManager |
| 81 | + |
| 82 | +USBManager.switch_to_usb(timeout_s=5) # init adapter, swap UI over |
| 83 | +USBManager.switch_to_panel() # swap back, free USB buffers |
| 84 | +``` |
| 85 | + |
| 86 | +### REPL inspection (on `--usb` builds) |
| 87 | + |
| 88 | +```python |
| 89 | +import usb |
| 90 | + |
| 91 | +print(usb.lsusb()) # Linux-style bus listing with VID:PID + product strings |
| 92 | +usb.bus_devices() # [addr, ...] currently sensed |
| 93 | +usb.hub_ports() # [(hub_addr, port, connected, enabled, high_speed), ...] |
| 94 | +usb.reset_port(2, 3) # re-enumerate one hub port (never a high-speed uplink) |
| 95 | +usb.hid_state() # [(addr, kind, vid, pid, speed), ...] streaming HIDs |
| 96 | +usb.hid_parked() # [(vid, pid, kind, fails), ...] parked (fails=255) or cooling down |
| 97 | +usb.hid_retry() # clear parked/cooldown and rescan now |
| 98 | +usb.hid_poll_stats() # [(addr, kind, polls, ch_fails), ...] sample twice, diff polls |
| 99 | +usb.hid_loop_lag() # ms since the HID task pumped events (~100 healthy) |
| 100 | +``` |
| 101 | + |
| 102 | +## API Reference |
| 103 | + |
| 104 | +### Class Methods |
| 105 | + |
| 106 | +- `is_available()` - `True` when the `usb` C module is importable (`--usb` build). |
| 107 | +- `arm_display(width=640, height=480)` - Construct (once) and start the adapter handle, ensure the poll timer. Returns the `usb.Display` or `None`. |
| 108 | +- `arm_hid()` - Start the HID client, create the shared `HIDHub` plus `USBMouse`/`USBHIDKeyboard` indevs (disabled until their kind streams). Returns the mouse or `None`. |
| 109 | +- `switch_to_usb(width=640, height=480, timeout_s=0)` - Blocking-init the adapter and swap the UI to it. |
| 110 | +- `switch_to_panel()` - Swap back to the panel display. |
| 111 | +- `try_init_usb_display(width=640, height=480, timeout_s=10, buf_lines=16)` - Wait for READY and build the `USBDisplayDriver`. Raises `RuntimeError` on timeout. |
| 112 | + |
| 113 | +### The `usb` C module |
| 114 | + |
| 115 | +- `usb.Display(port=0, width=0, height=0, ignore_edid=False)` - Adapter handle. `start()`, `poll()` (True on READY/disconnect/mode change), `ready()`, `width()`, `height()`, `chip_name()`, `update_565(x, y, w, h, buf)`, `fill(x, y, w, h, color)`, `flush(timeout_ms=100)`, `set_mode(w, h)`, `force_reenum()` (root-port power cycle). |
| 116 | +- Host inspection: `bus_devices()`, `lsusb()`, `hub_ports()`, `reset_port(hub_addr, port[, power_cycle[, force]])`, `set_watchdog(on)`, `auto_reset_idle([on])` (bare call reads back), `set_log(on)`. |
| 117 | +- HID transport: `hid_start()`, `hid_poll()`, `hid_drain()`, `hid_state()`, `hid_claimed_addrs()`, `hid_parked()`, `hid_retry()`, `hid_poll_stats()`, `hid_loop_lag()`, `hid_verbose([on])`, `hid_set_kbd_transient([on])` (keyboards stay persistent by default; the toggle is a live A/B switch). |
| 118 | + |
| 119 | +## Limitations |
| 120 | + |
| 121 | +- ESP32-S3 only; DisplayLink DL-1xx only (tested on DL-165/DL-195). |
| 122 | +- 640x480 minimum mode; rotation fixed to `_0`; RGB565 only. |
| 123 | +- Full display + mouse + keyboard combo cannot fit the S3 channel budget persistently (the keyboard parks until something unplugs). |
| 124 | +- A wedged adapter behind an externally powered hub is not recoverable by port resets — power-cut the adapter itself. |
| 125 | +- Field upgrades across this rename need `--erase-all` or a lib re-sync: the frozen C module changed name (`usb_disp` to `usb`), so stale flash shadows will not fall back. |
0 commit comments