USBManager
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.
Overview
USB host support is opt-in and ESP32-S3-only (needs USB OTG):
./scripts/build_mpos.sh esp32s3 --usb
This compiles in the usb C module (c_mpos/usb/) alongside TinyUSB device mode. CDC is the default: the device boots with a USB-serial console and the OTG peripheral stays in device mode. Host mode starts only on explicit request — Settings → "USB Host Mode" (On persisted, On until reboot one-shot, Off), or USBManager.activate() — and while it is active USB-CDC is gone (console remains over UART REPL where exposed, or WebREPL over WiFi). A stale once value is normalized to Off at the next boot, so the row never shows a stale selection. Deactivating brings CDC back; the choice persists across reboots. Holding BOOT at boot forces CDC regardless of the persisted flag.
USBManager centralizes all USB-host operations in a single class with class methods:
- Unified API - Single class for display + HID management
- Clean Namespace - No scattered functions cluttering imports
- Testable - USBManager can be tested independently with a fake
usbmodule - Hotplug - The poll timer auto-switches to a ready display and auto-reverts on unplug
- HID - Mice and keyboards arm together, enabled per-kind from live device state
from mpos import USBManager
if USBManager.is_available():
USBManager.arm_display()
USBManager.arm_hid()
Architecture
USBManager is implemented as a singleton using class variables and class methods. No instance creation is needed:
class USBManager:
_usb_dev = None # usb.Display handle (the adapter)
_usb_mouse = None # USBMouse indev, enabled only while a mouse streams
_usb_keyboard = None # USBHIDKeyboard indev, enabled only while a keyboard streams
_hid_hub = None # HIDHub demux shared by both indevs
@classmethod
def arm_display(cls, width=640, height=480):
...
The layers, bottom to top:
- C module
usb(c_mpos/usb/):Displayclass (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 underc_mpos/usb/upstream/with a few clearly-commented MPOS adaptations (hub-port watchdog,lsusb, held-handle hook). - Drivers:
drivers/display/usb_display.py(USBDisplayDriver, aDisplayDriverbehind a shim bus) anddrivers/indev/usb_hid.py(parser registry +HIDHubdemux +USBMouse+USBHIDKeyboard). Report parsing is Python-side, so new device kinds never need C changes. - 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 (port-exact idle-reset skip for claimed HIDs, parked-only global suppression).
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).
HCD channel budget
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().
HID/watchdog coexistence
A healthy, enumerated HID reads exactly like a wedged adapter (connected + enabled, no bus growth), which the hub watchdog's idle auto-reset would otherwise PORT_RESET ~15s after plug. Two-part answer: open HID handles resolve to their (hub, port), so the sweep skips the auto-reset exactly on HID-owned ports and logs HID device, auto-reset skipped inline, while other ports keep healing; parked devices have no open handle and stay covered by a global suppression (restored when the park clears). The disabled-port recovery path is never suppressed.
Display switching
When host mode is active, 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.
Usage
Host mode activation
from mpos import USBManager
USBManager.activate() # leave CDC device mode, start host, arm display+HID
USBManager.deactivate() # stop host, bring CDC back (REPL rejoins automatically)
USBManager.host_mode_active() # live host state
Both persist the choice (usb_host_mode in the com.micropythonos.settings
preferences — the same key the Settings row edits, so UI, REPL and boot
share one source of truth) so reboots keep it; pass persist=False for a
one-shot switch.
mpos.main honors the persisted flag at boot. Both are idempotent and safe
to retry. On stock builds (no usb module) they return False.
Checking availability and arming
from mpos import USBManager
if USBManager.is_available():
dev = USBManager.arm_display() # construct + start host + poll timer
USBManager.arm_hid() # HID client + mouse/keyboard indevs
Both are idempotent and safe to retry. On stock builds (no usb module) they return None. In host-mode builds these run automatically: activate() calls them, and boot does too when host mode persisted.
Manual display switching
from mpos import USBManager
USBManager.switch_to_usb(timeout_s=5) # init adapter, swap UI over
USBManager.switch_to_panel() # swap back, free USB buffers
REPL inspection (on --usb builds)
import usb
print(usb.lsusb()) # Linux-style bus listing with VID:PID + product strings
usb.bus_devices() # [addr, ...] currently sensed
usb.hub_ports() # [(hub_addr, port, connected, enabled, high_speed), ...]
usb.reset_port(2, 3) # re-enumerate one hub port (never a high-speed uplink)
usb.hid_state() # [(addr, kind, vid, pid, speed), ...] streaming HIDs
usb.hid_parked() # [(vid, pid, kind, fails), ...] parked (fails=255) or cooling down
usb.hid_retry() # clear parked/cooldown and rescan now
usb.hid_poll_stats() # [(addr, kind, polls, ch_fails), ...] sample twice, diff polls
usb.hid_loop_lag() # ms since the HID task pumped events (~100 healthy)
API Reference
Class Methods
is_available()-Truewhen theusbC module is importable (--usbbuild).activate(persist=True)- Leave CDC device mode, start the host stack, arm display + HID. ReturnsFalseon stock builds or failure.deactivate(persist=True)- Stop host (UI back to panel, indevs removed), bring CDC back. ReturnsFalseon stock builds or failure.host_mode_active()- Live host state (Truebetween successful activate/deactivate).host_boot_requested()- Persisted flag honored at boot (Falsewhen BOOT held).arm_display(width=640, height=480)- Construct (once) and start the adapter handle, ensure the poll timer. Returns theusb.DisplayorNone.arm_hid()- Start the HID client, create the sharedHIDHubplusUSBMouse/USBHIDKeyboardindevs (disabled until their kind streams). Returns the mouse orNone.switch_to_usb(width=640, height=480, timeout_s=0)- Blocking-init the adapter and swap the UI to it.switch_to_panel()- Swap back to the panel display.try_init_usb_display(width=640, height=480, timeout_s=10, buf_lines=16)- Wait for READY and build theUSBDisplayDriver. RaisesRuntimeErroron timeout.
The usb C module
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).- 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). - 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). - Mode switching:
activate_host(),deactivate_host(),host_active(),cdc_inited()(diagnostic: TinyUSB initialized; needs no host attached).
Limitations
- ESP32-S3 only; DisplayLink DL-1xx only (tested on DL-165/DL-195).
- Activating host mode kills USB-CDC until deactivated (announce it before toggling on no-UART boards); UART REPL where exposed, WebREPL over WiFi, and holding BOOT at boot (forces CDC) are the ways back.
- 640x480 minimum mode; rotation fixed to
_0; RGB565 only. - Full display + mouse + keyboard combo cannot fit the S3 channel budget persistently (the keyboard parks until something unplugs); transient keyboard polling (
usb.hid_set_kbd_transient(True)) holds no persistent pipe and is the experimental fit-the-combo option. - A wedged adapter behind an externally powered hub is not recoverable by port resets — power-cut the adapter itself.
- Field upgrades across this rename need
--erase-allor a lib re-sync: the frozen C module changed name (usb_disptousb), so stale flash shadows will not fall back.