diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 97f104c56..a9e31d6ea 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -157,7 +157,7 @@ jobs: - name: Build LVGL MicroPython esp32s3 run: | - ./scripts/build_mpos.sh esp32s3 + ./scripts/build_mpos.sh esp32s3 --usb mv lvgl_micropython/build/lvgl_micropy_ESP32_GENERIC_S3-SPIRAM_OCT-16.bin lvgl_micropython/build/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.bin mv lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/micropython.bin lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT/MicroPythonOS_esp32s3_${{ steps.version.outputs.OS_VERSION }}.ota diff --git a/AGENTS.md b/AGENTS.md index b595eabc7..e6e8c94fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,10 @@ MicroPythonOS: GUI + OS for microcontrollers. Source: `internal_filesystem/` (1: ### lvgl_micropython submodule branching +To keep it easy to manage/sync with upsteam lvgl_micropython, we keep the changes in either: + - **Patch files** (`.patch` applied by `build_mpos.sh`): commit directly on `integration`. No topic branch needed — the patch file is the topic. -- **Direct C/C++ source edits**: must go on a `topic/` branch, then merge into `integration`. -- Branch naming: `topic/` (e.g. `topic/wifi-country-japan`). +- **Direct edits**: must go on a `topic/` branch, then merge into `integration`. Branch naming: `topic/` (e.g. `topic/wifi-country-japan`). ## Testing @@ -113,6 +114,7 @@ def _capture_task(coro): - **Comments/docstrings:** never add/remove/modify unless explicitly asked. - **Batch edits:** constrain to exact patterns. Broad edits can silently delete unrelated code. If damage occurs, restore from git and re-apply a precise script. - **Implement missing functionality** rather than working around it. +- **USB host:** DisplayLink display + HID support lives in `c_mpos/usb/` — read its `README.md` before touching USB, display, touch, or topmenu code; build with `./scripts/build_mpos.sh esp32s3 --usb`. - **Observe before fixing, not after.** When a bug involves UI state or runtime data, use `mpos-controller` (`exec`, `get_widget_tree`, `eval`) to inspect the actual running system before coding a fix. Do NOT spend more than 2 rounds of static code analysis without validating assumptions against the live process. ### Debugging shared-object / identity bugs @@ -157,7 +159,8 @@ Key methods: `exec()`, `eval()`, `startapp()`, `run_app_with_file()`, `run_test_ - `get_visible_text()`: `lv.screen_active()` only (NOT `lv.layer_top()` — misses popups/msgboxes). - `get_widget_tree()`: includes `layer_top`, returns JSON (type, text, coords in content-space, flags, states). Off-screen children included. - `click_button("text")`: matches own or child-label text, clicks center. -- `screenshot()`: use `all_layers=True` for popups. Serial takes ~40s. +- `screenshot()`/`save_screenshot()` capture `screen_active()` ONLY (no `layer_top()` — drawer/bar/popups invisible). For overlays, `exec` `save_screenshot_bmp('/tmp/x.bmp', all_layers=True)` in-target (desktop shares `/tmp` with host; on device write under `/` then `read_file()`). Serial takes ~40s. +- `drag()` on the process backend is discrete taps (`simulate_click` at interpolated points), NOT a continuous swipe — gesture/swipe tests must `exec` `simulate_drag()` in-target. (Serial `drag()` is a real drag.) - `wait_for_text("text", timeout=10)`, `expect_text("text")`. - `startapp(name, intent={...})`, `run_app_with_file(app, file)`. - Notification bar: `mpos.eval("mpos.ui.topmenu.bar_open")`, height 24px. @@ -251,6 +254,7 @@ Critical gotchas: - Bug at commit Y, worked at X: `git diff X..Y --name-only`, then trace every changed line. Don't assume the bug is in the most recent file. - PTY I/O error (`OSError 5`) = binary crash. 139=SIGSEGV, 134=SIGABRT. Run binary directly with `-c` to reproduce. - Trust visual reality over code intent with UI bugs. Use `mpos.get_widget_tree()` to inspect actual coordinates. +- A crash is ALWAYS a bug: fix the crash site first, work around it second — never avoidance alone. "Upstream code" is not immunity: this repo already patches IDF (`patches/*.patch` via `build_mpos.sh`), and a decodeable PC (`xtensa-*-elf-addr2line -e `) turns a mystery abort into a patchable line. Avoidance is only acceptable together with a fix attempt, or when the crash is in upstream code AND the workaround is genuinely easy (no permanent UX tax). ## Apps & docs diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b3d18e5..a9b116cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,45 @@ Future release (next version) ===== +Builtin Apps: +- Settings: the Notification sound picker plays each option as you tap it (via InputActivity selected_callback), so you can hear a sound before saving + +Frameworks: +- InputActivity: optional `selected_callback(value)` setting key for radiobuttons/dropdown, fired on every pick before Save (re-tapping the active radio fires again) so pickers can preview a choice live, e.g. play a sound effect. `changed_callback` semantics unchanged +- SettingsActivity: fix a TypeError ("can't convert 'int' object to str implicitly", surfaced as the "app threw an exception" dialog) when a setting's stored value is a number instead of a string + +OS: +- uaiowebsocket: stop trying to send a second pong on incoming pings (aiohttp already replies; the call raised and logged "Failed to send pong" on every relay ping, #299) + +0.19.0 +====== + +Board Support: +- ESP32-S3: add setting to (de)activate USB host mode at runtime using USB On-The-Go to allow for USB HUBs, mice, keyboards and display adapters instead of the default USB-CDC (serial console) mode +- ESP32-S3: add USB hub and hub-behind-hub support (these do count as 1 or 2 USB devices, taking away from the maximum of 3 on ESP32-S3) +- ESP32-S3: add USB display adapter support (DisplayLink DL-1xx, e.g. DL-165/DL-195 fully validated; T6/MS91xx protocol code vendored for ESP32-P4 but untested) +- ESP32-S3: add USB Human Interface Device (HID) keyboard support +- ESP32-S3: add USB Human HID mice support with theme-aware cursor + +Frameworks: +- SettingsActivity: `dont_persist` entries with a `default_value` now show '(defaults to X)' in the list instead of '(not persisted)' +- topmenu: fix swipe-up-to-close never firing — the close gesture only listened for SCROLL events (which require drawer content taller than the viewport) while real drags deliver PRESSED/PRESSING/RELEASED; the drawer now tracks the press drag and closes once it moves upward past the notification-bar height, with per-event debug logging of position and target +- topmenu: drawer now extends from below the notification bar all the way to the bottom screen edge instead of a fixed 90% height, closing the tappable strip of the underlying app that stayed visible beneath it + +OS: +- ESP32-S3: cap ESP-IDF log strings at the ERROR default (LOG_MAXIMUM_EQUALS_DEFAULT), saving ~42 KB of app flash (micropython.bin 3,666,864 → 3,624,784 B); MicroPython logging is unaffected, C logs can no longer be raised above ERROR at runtime +- ESP32-S3: disable Ethernet (ETH_ENABLED=n plus the SPI-Ethernet drivers, which would otherwise force-select it back on), saving ~24 KB of app flash (micropython.bin 3,624,784 → 3,600,688 B); no supported S3 board has an Ethernet PHY + +0.18.1 +====== + +Builtin Apps: +- AppStore: speedup loading from 18 to 6 seconds +- AppStore: load list icons lazily for visible rows only (250ms viewport loader, capped per-tick work) instead of rendering every icon upfront + +Frameworks: +- InfiniteList: dynamic initial list sizing instead of hard-coded 18 items + 0.18.0 ====== diff --git a/Makefile b/Makefile index 83d95bb99..bf4333197 100644 --- a/Makefile +++ b/Makefile @@ -30,4 +30,8 @@ build-mpos-unix: ## Build MicroPythonOS for unix .PHONY: build-mpos-unix-coverage build-mpos-unix-coverage: ## Build MicroPythonOS for unix with sys.settrace coverage support - ./scripts/build_mpos.sh unix coverage \ No newline at end of file + ./scripts/build_mpos.sh unix coverage + +.PHONY: usb-host-tests +usb-host-tests: ## Host unit tests for the USB HID C transport (no hardware) + $(MAKE) -C c_mpos/usb/tests test \ No newline at end of file diff --git a/c_mpos/usb/README.md b/c_mpos/usb/README.md new file mode 100644 index 000000000..f19e6528e --- /dev/null +++ b/c_mpos/usb/README.md @@ -0,0 +1,585 @@ +c_mpos/usb: MicroPython `usb` module for ESP32 USB host support - +DisplayLink display adapters (via Pico_USB_Disp) plus a minimal USB HID +host transport (boot-protocol mice + keyboards). + +ESP32-only: DisplayLink DL-1xx via the in-tree IDF usb_host stack. +Build with: ./scripts/build_mpos.sh esp32s3 --usb +Python driver: internal_filesystem/lib/drivers/display/usb_display/ +HID Python driver: internal_filesystem/lib/drivers/indev/usb_hid.py +Framework: internal_filesystem/lib/mpos/usb/ (USBManager) +Boot hook: internal_filesystem/lib/mpos/main.py honors persisted host_mode +(USBManager.activate()) else CDC device mode; Settings "USB Host Mode" toggle +User docs: ../docs/docs/frameworks/usb-manager.md +Design record (runtime host/CDC switching): dynamic-usb-host.md + +upstream/ holds a vendored snapshot (see upstream/VERSION). Only the +ESP32-relevant sources are used (upstream/CMakeLists.txt ESP-IDF branch): +usb_disp.cpp, usb_disp.h, usb_disp_hal.h, usb_disp_hal_esp32.cpp, +usb_disp_prot.h, usb_disp_prot_dl-1xx.cpp, usb_disp_model.h. +Deliberately excluded: hal_pico/hal_teensy/hal_libusb, lgfx/, pio/. +usb_disp_prot_t6.cpp / usb_disp_prot_ms91xx.cpp (+ jpeg header) are vendored +but only compiled on ESP32-P4 (they need USB High-Speed; empty stubs +elsewhere) to save flash. +To update: re-copy those files from a new upstream checkout, refresh +upstream/VERSION, keep upstream/LICENSE.Pico_USB_Disp, rebuild. + +Note: upstream/ is a vendored snapshot, but NOT untouched in practice: +the hub-port watchdog, lsusb(), and a held-handle hook for streaming HID +devices all live in upstream/usb_disp_hal_esp32.cpp (see git log for that +file), alongside the IDF settle patch (patches/usb_ext_port_settle.patch) +and this binding. Rationale: the alternative (reimplementing hub +recovery/inspection around an unmodified HAL) would duplicate far more +code than the surgical hooks. Rule of thumb: keep MPOS adaptations +clearly commented and separable; if upstream ever gains the same feature, +prefer re-vendoring over keeping the fork. New standalone features +(e.g. src/usb_hid.c) still go outside upstream/. + +===================================================================== +REPL API reference (all on `import usb`, --usb builds only) +===================================================================== + +Display(port=0, width=0, height=0, ignore_edid=False) - adapter handle. +Single slot on ESP32: repeated constructions reuse usb_disp_at(0) +(original resolution kept; use set_mode() to change it). +- start() - start the USB host for all registered ports (call once). +- poll() -> bool - drive WAIT -> MODE_SETUP -> READY; True on + READY/disconnect/mode change (the 1s LVGL timer calls this). +- ready() -> bool, width() -> int, height() -> int, chip_name(). +- update_565(x, y, w, h, buf) / fill(x, y, w, h, color) / flush(timeout_ms=100). +- set_mode(width, height) - resolution change at 60Hz (redraw required). +- force_reenum() - root-port power cycle: virtual replug of the whole + subtree. Recovers wedged adapters and stack-disabled ports, NOT a + wedged adapter behind externally powered hubs (power-cut the ADAPTER). + +Module-level inspection (read-only, safe any time): +- bus_devices() -> [addr] - stack address list + held display/HID + addresses re-added (claimed devices leave the idle list). +- lsusb() -> str - "Bus 001 Device 002: ID 17e9:028f DisplayLink ..." + Bus is always 001 (single OTG controller), no root-hub line. + Streaming HID devices are read through their held handles (never + reopened mid-stream); held addresses the HAL pass skipped get a + generic `HID mouse`/`HID keyboard` fallback line, deduped by address. +- hub_ports() -> [(hub_addr, port, connected, enabled, high_speed)]. + (connected=True, enabled=False) = stack gave up on this port. +- reset_port(hub_addr, port[, power_cycle[, force]]) - re-enumerate one + port, rest of chain untouched. High-speed (uplink) targets refused + unless force=True (resetting one drops the subtree and aborts the + IDF enumerator - proven crash). Never reset a parked HID's port + (see HCD channels section). +- set_watchdog(on) (default on), auto_reset_idle([on]) (default on; + bare call reads back). See the watchdog section below. + +HID (same module, same build): +- hid_start() -> bool - register the HID client; False while the host + is down (retried automatically by the poll timer). +- hid_poll() -> bool - pump setups/health; True on device-set change. +- hid_drain() -> [(addr, subclass, protocol, bytes)] - raw boot + reports since last call (consumed by drivers/indev/usb_hid.py). +- hid_state() -> [(addr, kind, vid, pid, speed)] - streaming devices + only. kind is "mouse" or "keyboard"; speed is 0=low, 1=full, + 2=high (low-speed devices behind a hub need split transactions - + decisive for TT-overload theories, so it is captured at stage time + and exposed here). +- hid_claimed_addrs() -> [addr] - held-open addresses (watchdog + exclusion + bus_devices/lsusb re-add). +- hid_parked() -> [(vid, pid, kind, fails)] - parked (fails=255) or + cooling-down devices. Non-empty + "No more HCD channels" = channel + exhaustion, not a wedged device. +- hid_retry() - clear parked/cooldown and rescan now (plug/unplug + re-arms automatically). +- hid_poll_stats() - [(addr, kind, polls, ch_fails)] for live + transiently-polled keyboards (see polling note above). +- hid_verbose([on]) - per-tick debug flag, off by default (bare call + reads it back). When on, each transient tick logs [HID][V] + claim/submit/wait outcomes. Opt-in only: at ~20 ticks/s it would + drown the REPL (and any file transfer) otherwise. +- hid_loop_lag() - ms since the HID client task last pumped stack + events. Reads ~100 in steady state; seconds indicate event delivery + (completions, teardowns, rescans) is stalled - e.g. app-thread + control traffic (watchdog sweep, lsusb opens, setup ctrls) with + multi-second timeouts serializing shared stack locks. If teardown + ever lags a flagged error by seconds, read this first: it separates + a stalled event loop from a wedged bus. +- hid_set_kbd_transient([on]) - keyboard transport experiment switch. + Default persistent (False): keyboards claim a standing interrupt + pipe like mice, exactly pre-Phase-A behavior. True selects + transient per-tick polling (needed under display channel pressure). + Bare call reads back. Live keyboards re-stage on flip, so both modes + are A/B-testable on one firmware without reflashing. + +Mode switching (same module, --usb builds only; CDC is the default boot): +- activate_host() - leave CDC device mode: tud_deinit() + delete the + device PHY. The host stack installs itself on first use below + (Display.start() / hid_start()); stack bringup stays the legacy + Python sequence so no port/slot is ever registered twice. Idempotent. +- deactivate_host() - stop HID + display clients, uninstall the host + stack, recreate the device PHY, restart TinyUSB. Idempotent; the REPL + rejoins CDC automatically. See "Host-mode exit" below. +- host_active() - True while the host stack is up. +- cdc_inited() - True while the TinyUSB device stack is initialized. + Diagnostic for the deactivate path (needs no host attached). + +===================================================================== +What it took to get hotplug / hot-unplug working, per level +===================================================================== + +--- USB level (adapter, hubs, monitor) --- +- Only DisplayLink DL-1xx works on ESP32-S3 (Full-Speed OTG). DL-165/DL-195 + recommended. T6/MS91xx need High-Speed = ESP32-P4 only. +- The adapter holds the framebuffer; the MCU streams span updates. Pixel + data is copied synchronously into the bulk ring, so LVGL buffers can be + reused the moment the flush call returns. +- IDF usb_host has NO external-hub support by default: without + CONFIG_USB_HOST_HUBS_SUPPORTED (+ MULTI_LEVEL for cascaded hubs), + downstream devices never enumerate (the hub itself is seen, nothing + behind it). Enabled in --usb builds only. +- Hotplug race (the big one): a DL chip boots its own firmware for 1-2s + after power-on, but the stack resets a new port within ~300ms. An early + reset wedges the adapter's EP0 until its next POWER cut (USB resets and + root-port power cycles do not recover it behind externally powered + hubs). Fixes, both in --usb builds only: + - patches/usb_ext_port_settle.patch: 2s settle before a hub port's + first reset, compiled to a no-op without -DMPOS_USB_PORT_SETTLE_MS + (same repo patch-file convention as the other .patch files). + - CONFIG_USB_HOST_DEBOUNCE_DELAY_MS=2000 covers root-port-direct + adapters (hub downstream ports are NOT covered by debounce). + - More reset attempts are NOT available: EXT_PORT_RESET_ATTEMPTS is + locked behind IDF_EXPERIMENTAL_FEATURES, and retries are spaced ~30ms + apart anyway (never spans a 1-2s boot). The enum-filter callback runs + after the descriptor read, so it cannot defer the fatal first reset. +- Slow-boot race, part 2 (replugged adapters, some hub ports): even with + the settle patch, a port read while the chip is still booting fails the + single CHECK_SHORT_DEV_DESC attempt and the port stays DISABLED forever + (IDF's own recycle path cannot recover pre-enumeration failures: + "Ext hub port recycle error: ESP_ERR_INVALID_ARG"). Linux xHCI retries + transparently, which is why the same hub+adapter works on a PC. Fix in + the HAL (usb_disp_hal_esp32.cpp, --usb builds only): a hub-port + watchdog sweeps every external hub with read-only GET_PORT_STATUS while + no display is attached and recovers ports stuck connected-but- + unenumerated: up to 3 targeted SET_FEATURE(PORT_RESET)s with growing + backoff (~4s/~12s/~28s stuck time), then one PORT_POWER off/on cycle, + then silence until the connection flaps. Episodes close as "enumerated" + only on bus-address-count growth after episode-open — never on the + enabled bit alone, which can read set on an unaddressed port when the + stack wedges mid-enumeration. Enabled ports are never touched; + change bits are never cleared (that would steal the connect + event from IDF's hub driver). Every transition and action is logged + ([HUB] lines) with per-port counters: "connected, waiting" opens an + episode, "reset N/3 (stuck Ns)" / "power cycle (stuck Ns, N resets + done)" narrate recovery, "enumerated/unplugged, episode over (stuck + Ns, N resets[+power])" closes it. A dead-silent hub (EP0 unresponsive, + first breaker trip) plus stuck ports escalates to an automatic root + power cycle (max 3 per boot, logged loudly) — proven to revive hubs + nothing else touches; fresh addresses reset all watchdog state + naturally. A port that reads enabled with no + episode gets one neutral pointer line naming its reset_port() call — + and, with auto_reset_idle (default on, usb.auto_reset_idle() + toggles it, bare call reads it back), one automatic PORT_RESET after + a 15s grace, but only if that port is not marked preexisting: marks + go onto idle ports at boot, hub plug, and display-unplug snapshots + (uplinks are always idle then), and any observed disconnect clears + them — so tracking works even when the unplug itself happens mid + display, with no dependence on catching the transient. One shot per + mark cycle; healthy devices address first and close the episode. + (An earlier blind version fired on any idle port + and deafened hubs, because an unchirped hub reads full-speed just + like a stuck adapter.) Episodes open on disabled ports only (fresh + flaps wait for the stack to attempt first); dues defer while the bus + is growing or the hub is younger than 10s, so resets never collide + with in-flight enumerations. High-speed ports are never auto-reset: + they carry cascaded hubs, and resetting one drops the whole subtree, + which aborts the IDF enumerator (control_request_string default arm) + and reboots the board — proven by four identical crash dumps. A hub + whose EP0 stays dead gets 30s/60s/120s backoffs, then a quiet 120s + probe rhythm instead of log spam. Manual equivalents for the REPL: + usb.hub_ports() lists (hub_addr, port, connected, enabled, high_speed) and + usb.reset_port(hub_addr, port[, power_cycle[, force]]) re-enumerates + one port without disturbing the rest of the chain (unlike force_reenum's + root-port power cycle); high-speed targets are refused unless + force=True (never use it on an uplink mid-enumeration). + usb.set_watchdog(False) opts out. +- Wait guidance (measured against a DL-195 that needs 1-2s to boot, + longer when browned-out by rapid VBUS cycling): judge a plug only + after ~5s hands-off (the watchdog heals slow boots by itself); + leave ~2-3s between unplug and replug (VBUS drain + disconnect + processing; instant replugs risk the stack's "gone during reset" + path). Rapid port-hopping always looks broken — every replug + cold-boots the adapter, so it is time, not the port, that heals. +- Monitor floor: HDMI/DVI needs >= 25 MHz pixel clock, so 640x480@60 + (25.175 MHz) is the smallest syncable mode. Anything smaller (e.g. + 320x240 @ 7.3 MHz) programs fine on the chip but no monitor locks it. + The helper defaults to 640x480 and documents the floor. +- Broken hub ports fail exactly like a wedged adapter (EP0 + CHECK_SHORT_DEV_DESC, port disabled after the single attempt). Each + failed attempt consumes a stack device address. When stuck, suspect the + port/cable before the firmware, and power-cut the ADAPTER (not the + board) to unwedge. +- usb_disp_force_reenum() (root-port power cycle) recovers missed events + but NOT a wedged adapter behind externally powered hubs. + +--- FreeRTOS level (host stack, tasks, coexistence) --- +- Upstream spawns usbd_daemon + usbd_client tasks; enumeration is + event-driven, but the app must still poll: usb_disp_poll() drives + WAIT -> MODE_SETUP -> READY (and reconnect/re-enumeration). +- MicroPython's TinyUSB *device* stack owns the single OTG peripheral at + boot, and --usb builds keep it that way: CDC is the default console and + the host stack starts only on explicit request (Settings "USB Host Mode" + or USBManager.activate(), persisted, BOOTSEL escape). The handover both + ways is proven on hardware (see dynamic-usb-host.md): activate does + tud_deinit() + usb_phy_deinit() (patches/usb_phy_deinit.patch exposes + it), then the legacy Python bringup; deactivate runs usb_hid_stop() + + usb_disp_hal_stop() (joined task exits, endpoint quiesce, deregister, + event drain, retried uninstall), recreates the device PHY and restarts + TinyUSB. Two hard-learned rules: stop flags clear only in start (a task + waking late must still see them set, never revive - revived zombies + once flooded the DWC2 IRQ allocator every 10ms), and TinyUSB frees its + ISR handle without NULLing it, so re-init double-frees without + patches/tinyusb_isr_double_free.patch (decoded LoadProhibited). + Console remains over UART REPL where exposed (USB-Serial-JTAG shares + the OTG pins so it is unreachable while the adapter is plugged; + WebREPL over WiFi still works). +- Upstream has no remove API and one slot on ESP32, so the binding reuses + usb_disp_at(0) once added: REPL retries, repeated constructions, and + the Settings-toggle path all share the handle (original resolution + config is kept; use set_mode() to change it). +- CFLAGS_EXTRA reaches IDF component compiles (verified in flags) — that + is how -DESP_PLATFORM and -DMPOS_USB_PORT_SETTLE_MS get to usb_host + code while staying scoped to --usb builds. + +--- LVGL level (displays, rendering, input, topmenu) --- +- The Python driver subclasses DisplayDriver behind a fake bus shim + (bytearray buffers, tx_color -> update_565 + flush, synchronous + flush_ready like the SDL bus). PARTIAL mode, RGB565, rotation fixed _0 + (adapter limitation, enforced by raising on anything else). +- Multiple displays coexist; the Ulrichscreen always keeps a loaded blank + screen: refreshing a screenless display wedges the port hard (proven by + bisection, no traceback). Never leave a display without a screen while + anything can tick. +- Touch: LVGL transforms driver points from the physical frame itself, so + the helper composes instead of configuring: it shadows each pointer + indev's _calc_coords with panel_mapping -> portrait-to-landscape + affine, reusing the panel's proven (possibly user-calibrated) mapping. + No touch-native ranges or per-board tables; only boards failing the + drag test get a one-line direction/mirror exception. Switch-back deletes + the shadow (del restores the class method); nothing is persisted. +- Topmenu moves, never recreates: bar/drawer are reparented across + displays (legal per lv_obj_set_parent source) with geometry recomputed, + because recreating would leak singleton timers bound to old labels and + crash when they fire. Focus entries only join groups while open, so the + move is group-safe; drawer lands closed; brightness slider is a no-op + on USB. +- Drawer-close by swipe-up broke on big displays: it was detected via + scroll events, which only fire when content overflows the viewport (true + on 288px panels, false on 432px USB). Now press/release net movement + closes with the same threshold; the scroll path stays alongside. + The drawer needed FLAG.CLICKABLE for empty areas to report presses. +- InputManager populations vary: fri3d registers a raw lv_indev_t keypad + next to driver wrappers, so the re-point path uses getattr with the raw + object as fallback instead of assuming wrappers. + +--- MPOS level (boot, tasks, UI lifecycle) --- +- TaskHandler pumps LVGL via machine.Timer + micropython.schedule ON THE + MAIN THREAD (there is no LVGL thread). Pump callbacks interleave + teardown bytecodes, so the swap suspends the pump first (idempotent + flag, resumed in finally blocks) — a hard freeze with dead Ctrl-C was + the symptom before this. +- No board-file changes: mpos.main honors the persisted host_mode flag + (USBManager.activate(): tud-deinit, host bringup, construct + start + + poll timer, ~1ms of Python, never blocks boot); otherwise CDC stays up + and nothing USB runs. The existing 1s poll + timer is the whole event system (no asyncio watcher needed — the stack + is event-driven and poll() just advances the state machine). On a READY + transition with the panel active it auto-switches; on disconnect with + USB active it auto-reverts. Event-gated with a bounded level retry + (every ~5s, max 6 per episode, budget reset by any bus event), so a + switch that fails transiently mid-boot still lands without a + retry-storm. The _auto_switch flag gates panel auto-switching; the + Settings "USB Host Mode" row (On / On-until-reboot / Off) gates host + mode itself, persisted in the shared settings prefs. +- Swap order (all validated on hardware): disable pump -> disable indevs + -> remove_and_stop_all_activities() -> blank old display -> blank handled + -> set_default + reassign main_display -> repoint indevs (+ touch wrap) + -> DisplayMetrics -> move topmenu -> recreate gesture zones -> start + launcher -> re-enable. The panel object is never deleted (switch-back + needs no board re-init); the USB object is deleted on switch-back to + free its buffers. +- Backlight restore must go through set_backlight, never trust + get_backlight: fri3d overrides set_backlight with an expander lambda + while get_backlight reads a nonexistent pin (-1), which silently skipped + the restore and left the panel dark. Fall back to the + display_brightness setting (default 100), the same source the drawer + slider persists. +- Flash budget is structural: 3.5 MiB app partition, hard size check. + Room came from skipping the frozen usb-device framework in host builds + (MPOS_NO_USBDEV, ~6.3 KiB, nothing imports it) and P4-gating the HS + protocols — not from shaving. Real headroom needs partition or + build-system work, not more trimming. + +--- USB HID level (boot-protocol mice + keyboards) --- +- Transport is minimal on purpose (no espressif/usb_host_hid managed + component: ~51 KB flash + a managed `usb` override that would shadow + the in-tree component our settle patch targets). src/usb_hid.c owns a + second usb_host client + task on the shared stack (one device may be + opened by both clients at once). One upstream hook: lsusb reuses our + held handle for streaming HID devices instead of reopening them + mid-stream (same reason the display HAL keeps its own handle). +- Two-stage setup avoids a deadlock: the client task only opens/stages + candidates; blocking SET_PROTOCOL/SET_IDLE run in hid_poll() on the + app thread (same pending_probe pattern as the display). Interrupt + callbacks only memcpy into a 64-entry SPSC ring and resubmit. +- Parsing is Python-side (drivers/indev/usb_hid.py parser registry + + HIDHub demux: mouse events queue, keyboard keeps the latest 8-byte + report), so new device kinds never need C changes. USBHIDKeyboard + subclasses Fri3dCommunicatorKeyboard unchanged (same HID->LVGL table, + repeat logic, ESC/arrows nav hooks); USBMouse + keyboard share one + hub, armed together by USBManager.arm_hid(), enabled per-kind from + hid_state() (kind-aware _sync_usb_hid). +- Transient keyboard polling (Phase A experiment): keyboards hold NO + persistent interrupt pipe. A poll task ticks each keyboard at a calm + 50ms floor (bInterval above that is respected up to 100ms): claim -> + submit one IN transfer -> wait (30ms timeout) -> copy any report to + the ring -> free -> release, then sleep. The 10ms original ticked + claim/CLEAR_FEATURE/submit/halt/flush/release 100x/sec and knocked + cheap hub TTs off the bus (whole-hub re-enumeration every few + seconds, taking mouse+keyboard down together); 50ms keeps typing + responsive while staying an order of magnitude quieter. Toggle + resync (CLEAR_FEATURE: fresh pipes start DATA0 while the device + kept its sequence) runs on every tick - without it only the first + report after each resync lands and releases are lost, freezing the + Python key state at the last press (endless typematic repeat), so + per-tick resync is load-bearing, not hygiene. Steady state holds hub + display + mouse + pipes only, so the full combo fits the S3 budget with margin to + spare. Boot reports are level-state, so tick-rate sampling loses + nothing vs native polling (only a press+release inside one tick is + invisible - same as hardware bInterval sampling). Mice keep + persistent pipes for now (Phase B would move them too). + Teardown choreography per tick (the hard-learned part): a halted + transfer must be reaped (halt -> flush -> bounded wait for the + CANCELED completion -> clear) BEFORE its memory is freed and the + interface released. Freeing early strands the endpoint object in the + stack ("EP already allocated" on the next claim, every ~10ms + forever) and risks use-after-free when the late completion fires - + exactly the failure the first Phase A firmware showed. A slow answer + arriving between timeout and halt is kept, not dropped. + (Caveat from the revert-test verdict below: correct per-tick + hygiene does not make coexistence safe - the abort churn itself, + not its bookkeeping, is what the hub chokes on next to a standing + periodic pipe.) + A wait that expires with no data is NEUTRAL (idle keyboard NAKs), + never a failure: reap, free, next tick. Only genuine errors feed + backoff/parking, and a completed tick (with or without data) resets + the consecutive-failure count. Rationale for the short 30ms bound: + responsiveness comes from the tick rate, not the pend length - a + keypress just after a timeout is caught by the next tick - while a + long pend would hold a channel doing nothing, defeating the point. + Lifecycle lines (the only polling logs by default): + "keyboard polling started (addr=N, every Mms)" on first keyboard, + "keyboard polling stopped (N polls performed, M channel-fails)" when + the last one goes away. Per-tick failures are silent counters; + usb.hid_poll_stats() returns [(addr, kind, polls, ch_fails)] + per live keyboard - sample twice and diff polls for the effective + rate. 50 consecutive channel failures parks (see below); other + tick failures use the normal transient backoff. +- Watchdog coexistence: a healthy/enumerated HID reads exactly like a + wedged adapter (connected + enabled, no bus growth), which the idle + auto-reset would PORT_RESET ~15s after plug/park. Two-part answer (see + hid-disables-auto-reset.md): claimed devices are skipped port-exactly + in C (usb_hid_owns_idle_port resolves any open handle via the + device_info parent chain; the sweep logs "HID device, auto-reset + skipped" inline, rechecked at fire time), while parked devices (no + handle, unresolvable) keep the Python-side global suppression with + prior-value restore. The disabled-port episode path is unaffected + either way. +- LVGL: USBMouse subclasses PointerDriver with identity _calc_coords + (absolute positions, no TouchCalData side effects) and + __usb_absolute__ so the panel->USB touch wrap skips it. Cursor is an + lv.image set via indev.set_cursor (LVGL reparents it to the sys + layer); reparented + re-set on display swaps via the generic + _on_display_changed hook in _repoint_indevs. Wheel scrolls the + object under the cursor best-effort. Cursor tint follows the theme + (black on light, white on dark) via image-recolor, re-synced on + every indev read. +- lsusb() prints the descriptive string-descriptor line whenever the + HAL pass covers the address and appends a generic `HID mouse` / + `HID keyboard` line only for held addresses the HAL pass skipped + (dedup by address; never double-prints). + +--- Mouse+keyboard collapse experiment (revert-test): VERDICT REACHED --- +- A/B/A result, same firmware/hub/devices throughout: persistent + + persistent soaked stable (extended run, zero collapses) -> flip to + transient collapsed within ~2s with the identical mouse-first + signature, repeating -> flip back restored stability. The abort + interaction is convicted: transient abort churn (per-tick + halt/flush/free/claim) next to a standing periodic pipe collapses + this hub; neither half fails alone, which is why every + single-device cell (kbd alone, mouse+display, kbd+display) is green. + Power ruled out (lightest combo fails, heaviest holds); channels + ruled out (0 fails, ~6 pipes, no display in the failing topology). +- Mechanism, as narrowed: standing-periodic + transient-abort + coexistence disturbs shared TT/scheduler state (both HIDs are + low-speed: every transfer is a split transaction). The mouse URB + erroring first every cycle is the standing pipe sampling shared + disturbed state, not mouse guilt. +- Experiment shape (kept for future hubs): keyboards persistent by + default (this switch); flip live with hid_set_kbd_transient(True) + to re-enable transient without reflashing. +- End-state note: persistent keyboards cannot serve the required + display+mouse+keyboard combo in a 7-usable world (8 pipes), so this + verdict argues for pressure-adaptive transport (persistent when + channels allow, transient+parking under display pressure), not for + deleting the transient path. + +--- Ruled-out options log (do not re-litigate without new evidence) --- +- Option 1 (free the hub status pipe): repriced Low -> High. Frees one + channel by forking hub.c (the pipe belongs to the hub driver; freeing + it out from under the driver faults continuously) and rebuilds + hot-plug detection as app-level port-status polling + manual + reset_port() driving - around a driver that still thinks it owns the + hub. Gains a single channel for zero margin (enumeration transients + need a free channel per the official docs), and addresses channel + count while every observed collapse happens with free channels. + Shelved. +- Option 3 (EP0 pooling in usbh.c): Very High effort + permanent IDF + fork + control-path crash risk (a bug here means no USB at all, not + degraded USB), for 3 channels nobody is currently denied. Last + resort only, gated on ever seeing NOT_SUPPORTED claims as the + binding constraint again. +- Phase B (mouse transient too): on hold, not dead. It doubles the + convicted operation class (abort churn), so it runs against the + verdict above; revive only if a hub without the abort interaction + still fails two concurrent periodics (i.e. the trigger turns out to + be standingness after all, not aborts). + +--- Safe teardown (the StoreProhibited lesson) --- +- hid_teardown() used to free transfer structs and close the device + with no regard for in-flight URBs. That is safe for dead devices + (the stack cancels everything promptly) but use-after-free for a + LIVE streaming slot: completions landing after free() corrupt the + heap, and the crash surfaces seconds later in unrelated code + (decoded once: StoreProhibited in TLSF malloc from a touch read, + ~5s after toggling a healthy streaming keyboard into teardown). +- Rule since: no transfer memory is freed while a completion for it + can still arrive. Live teardowns go through retire + + HID_SLOT_CLOSING, owned solely by the app thread: stop new submits + first (state flip), wait boundedly for in-flight count + tick pass + to drain (completions keep pumping on the client task - waiting + there would deadlock), then halt/flush/clear, free, release, close. + Wedged transfers that never complete are leaked deliberately, never + freed (a late completion into leaked memory is harmless; into freed + memory is a crash). Per-slot in-flight accounting (increment on + submit, decrement in callback) is what makes the wait decidable. +- Ownership split, lock-free by design: client task only tears down + dead (gone/errored, non-retiring) slots and skips retiring ones; + the tick only touches POLLED non-retired slots; setup only touches + STAGED; health-check skips retiring. The retire path is the only + one that blocks, and only on the app thread. +- Fixed wart (was: retiring a healthy idle streaming slot always took the + 3s forced path, because the drain-wait ran before halt/flush while + standing URBs correctly never complete on their own): halt+flush now run + BEFORE the bounded drain-wait, so the halt forces CANCELED completions + that reap promptly. Same outcome, ~3s faster; the forced path and its + loud log remain for genuinely wedged transfers. + +--- HCD channels (the hard silicon limit, ESP32-S3) --- +- Rule of thumb first, math after: on ESP32-S3 in USB host mode you + get 8 channels, which in practice means **max 1 hub + 2 downstream + devices** (each device costs its EP0 pipe plus one pipe per claimed + endpoint; the hub itself costs its EP0 pipe plus its status pipe; + and enumeration transiently needs a free channel, so a nominally + "full" budget still fails intermittently). The ESP32-P4 and ESP32-S31 + have 16 channels, so **1 hub + 4 devices** fits there with margin. +- The S3 DWC_OTG core has 8 host channels (~7 usable; one is reserved + per the HCD's own test). One channel is consumed per USB *pipe* and + held for the pipe's lifetime - transfers (URBs) multiplex on their + pipe's channel, so URB counts do not matter. Official doc: + esp-usb "USB Host" -> "Downstream Port Configuration" -> + "Host Channels" ("Supported amount of channels for ESP32-S3 is 8 ... + When there are no more free Host channels available, the device could + not be enumerated and its interface cannot be claimed"). + (The "more than 4" page sometimes cited is the *Device* stack - + ESP32-as-peripheral endpoints. Different mode, unrelated limit.) +- Pipe budget per setup: 1 default pipe (EP0) per enumerated device + (stack-held) + 1 interrupt pipe per external hub (hub driver) + 1 per + claimed endpoint (display bulk, HID interrupts). So hub + display + + keyboard + mouse = 4 + 1 + 1 + 2 = 8 pipes > ~7 channels: the full + combo can NEVER fit on S3, with zero leaks required. Whoever claims + last loses (E (xxx) HCD DWC: No more HCD channels available -> + EP Alloc error -> Claiming interface error). +- S3 policy (src/usb_hid.c): claim in priority order, display > + mouse > keyboard (the display claims through its own client and + always wins; among staged HIDs, mice set up before keyboards). A + claim failing with ESP_ERR_NOT_SUPPORTED (the channel-exhaustion + signature) parks immediately with one explanatory line; transient + failures back off 4s/12s/28s, then park. Parked devices stay silent + until a bus topology change (plug/unplug/reenum, checked every + hid_poll) or usb.hid_retry(). Unplug clears the device's + failure history. usb.hid_parked() lists [(vid, pid, kind, + fails)] with fails=255 for parked. +- Phase A changes the math: keyboards hold no persistent pipe (see + polling note above), so steady state is 4 defaults + hub + bulk + + mouse = 7 pipes, +1 transiently during each keyboard tick. The full + combo now fits whenever a free channel exists at tick time; a tick + that finds none just skips (counted, silent) and parks after 50 + consecutive misses. If transient polling proves out, Phase B moves + mice to the same scheme (steady state 6). +- Persistent interrupt errors log the URB status code: + `[HID] addr=N intr status=S actual=A` with S: 0=completed, 1=error + (no response/CRC - TT/split faults land here), 2=timed-out, + 3=canceled, 4=stall, 5=overflow, 6=skipped, 7=no-device (surprise + removal). Status=7 arriving on a standing pipe seconds-to-a-minute + before a hub renumber is an early warning of the coming drop, not + noise. A mouse whose standing transfer errors first every collapse + while a second periodic pipe is being aborted next to it points at + scheduler/TT interaction, not at either device. +- Debugging channel pressure: hid_parked() non-empty with fails=255 + plus the "No more HCD channels" lines = exhausted, not wedged. Do NOT reset_port() + parked devices (they are healthy and enumerated; a reset just burns + a bus address and re-parks). Unplug something, or hid_retry() after + freeing a device. + +--- Debugging notes --- +- bus_devices() (stack address list) separates "nothing sensed" + (cable/power/stack) from "hubs only" (adapter missing/wedged) from + "adapter present, failing" in one call. print(usb.lsusb()) shows + the same bus Linux-style with VID:PID and product strings. +- Delayed teardown puzzle (open): twice observed, a flagged transfer + error with no teardown for 12s / 47s despite the <=100ms code path, + never reproduced since. Unknown whether event-delivery stall or + log-side artifact. Decider: usb.hid_loop_lag() sampled DURING + such a gap (all readings so far were taken in healthy windows). +- Poll-rate excursions (open, non-load-bearing): one 119/s and one + 0.3/s episode against the 20/s design, also never reproduced; + collapse behavior never depended on them. hid_poll_stats() deltas + would catch a recurrence. +- Single EP0 STALL datapoint (keyboard, mid-collapse session only, + never keyboard-alone): filed as collapse fallout, not device guilt + - consistent with everything else pointing at interaction, not at + either device. +- Zombie devices (address persists with dead EP0 long after unplug, + `Unknown device` in lsusb): suspect a dropped DEV_GONE in a burst — + our client queue is 32 deep for that reason. Discriminator: unplug, + hands off 60 s; vanishes = was live, persists = leaked (hub replug + clears it). hub_ports() goes one deeper: + a (connected=True, enabled=False) port is one the stack gave up on — + reset_port() it, or wait ~5s for the watchdog's [HUB] lines. +- Dead Ctrl-C + dead UART + alive USB tasks = main thread wedged in C; + bisect with refr_now() per display (screenless refresh was the killer). +- If the adapter renumbers (new bus address) behind the display client, + poll() can sit at False forever without reclaiming: the client does not + always follow the new address. force_reenum() (root-port power cycle) + recovers it (proven 2026-09-14: reset_port renumbered 3->5, poll stuck + False, force_reenum re-enumerated + claimed + auto-switched). +- The full swap runs clean on the desktop unix build between two SDL + displays (tmp/run_swap_repro.py via mpos-controller) — use it to + separate pure-LVGL bugs (gdb-speed) from device-specific ones. + +Build lessons (do not regress): +- Do NOT target_link_libraries() idf:: component aliases here. usermod.cmake + recurses INTERFACE_LINK_LIBRARIES into MICROPY_INC_USERMOD, which drags + every transitive IDF include dir (including relative ones) into main's + idf_component_register and breaks configure. Include dirs are propagated + lcd_bus-style via idf_component_get_property instead. +- ESP_PLATFORM must be an INTERFACE compile definition, not just a + CFLAGS_EXTRA flag: the esp32 port also compiles usermod sources into the + top-level micropython.elf target (verified in the link map), which + ignores MICROPY_CPP_FLAGS. Per-source -Os matters for the same reason. diff --git a/c_mpos/usb/dynamic-usb-host.md b/c_mpos/usb/dynamic-usb-host.md new file mode 100644 index 000000000..1b43ea4b8 --- /dev/null +++ b/c_mpos/usb/dynamic-usb-host.md @@ -0,0 +1,178 @@ +# Dynamic USB host activation / CDC restore plan + +**Status: implemented (P1).** Spike probes replaced by +`usb.activate_host()` / `usb.deactivate_host()` + `usb_hid_stop()` + +`usb_disp_hal_stop()`; `USBManager.activate()` / `deactivate()` with +persisted `host_mode` pref; boot honors the flag with BOOTSEL escape; +Settings → "USB Host Mode" toggle; host harness lifecycle tests. +Remaining: device matrix verification (P2 in the original plan numbering). + +## Problem + +With `--usb`, the build used to pass `-DMICROPY_HW_ENABLE_USBDEV=0`, which +compiled out TinyUSB device mode entirely. This freed the OTG peripheral for +the IDF `usb_host` stack, but on boards where the only exposed serial port is +USB-CDC (no UART on GPIO43/44) the REPL disappeared after flashing and users +could not access the device. + +Goal: keep USB-CDC alive by default; switch the same OTG PHY to USB host mode +only when the user explicitly enables "USB Host Mode" (Settings toggle or REPL +`USBManager.activate()`), and switch it back to CDC when disabled +(`USBManager.deactivate()`). Persist the choice across reboots. + +## Constraints + +- ESP32-S3 has one OTG PHY and one Serial/JTAG controller, often wired to the + same USB connector on dev boards. There is no separate Serial/JTAG rescue + port we can rely on. +- The IDF `usb_host` stack owns the PHY in host mode; TinyUSB/CDC owns it in + device mode. Runtime mode switching requires tearing one down before bringing + the other up. +- TinyUSB 0.19 (vendored) has `tud_deinit(rhport)` which fully releases the + device controller: IRQ freed, disconnected, de-inited, class drivers torn + down, `_usbd_rhport` invalidated. `mp_usbd_init()` is restart-safe (checks + `_usbd_rhport`, calls `tusb_init`, `tud_connect`). +- `usb_host_install/uninstall` also create/delete the PHY inside the HAL. The + display HAL `usb_disp_hal_start()` calls `usb_host_install()`; there is no + matching stop yet. +- Both directions must work without reboot for the feature to be worth building. + If either direction wedges the core, the plan falls back to a persisted flag + plus reboot. + +## Proposed end state + +### User-facing behavior + +- Default boot: CDC enabled, REPL over USB. `USBManager.is_available()` still + returns `True` because the `usb` C module is built in; it just reports no host + stack running. +- Settings → "USB Host Mode" (`On` persisted, `On until reboot` one-shot, + `Off`): the framework persists the choice into the same + `com.micropythonos.settings` / `usb_host_mode` key USBManager boots from, + so UI, REPL and boot share one source of truth; the callback only + switches modes via `USBManager.activate(persist=False)` / + `deactivate(persist=False)`. + - Stops TinyUSB CDC. + - Starts IDF USB host. + - Arms display + HID as today. + - The `activate(persist=True)` default writes `"on"` to the shared key. + - CDC gone until deactivated. UART/WebREPL still work if configured. +- Toggle OFF: calls `USBManager.deactivate(persist=True)` (writes `"off"`). + - Stops host (HID client, display client, `usb_host_uninstall`). + - Reinitializes TinyUSB PHY and `mp_usbd_init()`. + - CDC returns; REPL rejoins automatically (`mphalport.c` polls/writes CDC + dynamically). +- Persisted boot: `mpos/main.py` boots into host mode only when the shared + key reads `"on"`. BOOT held forces CDC regardless of the stored value. + +### C additions + +- `usb.activate_host()` module function: + - If already active, return `False`. + - `tud_deinit(0)`. + - Call existing host initialization path (the one currently run from + `arm_usb_display()`): `usb_disp_hal_start()` + HID client register. + - Return `True`. +- `usb.deactivate_host()` module function: + - If not active, return `False`. + - New `usb_hid_stop()`: deregister HID client, delete task, free + resources. + - New `usb_disp_hal_stop()`: signal daemon/client tasks to exit, wait briefly, + deregister display client, call `usb_host_uninstall()` (which deletes the + host PHY). + - `usb_phy_init()` from `usb.c` to recreate the PHY in device mode. + - `mp_usbd_init()` to bring TinyUSB/CDC back up. + - Return `True`. + +### Python additions + +- `USBManager.activate(persist=False)`: + - `usb.activate_host()`. + - `arm_display()`, `arm_hid()`. + - Optionally persist. +- `USBManager.deactivate(persist=False)`: + - Stop poll timer, reset `_usb_dev/_usb_display/_usb_mouse/_usb_keyboard`. + - `usb.deactivate_host()`. + - Optionally clear persist. +- `mpos/main.py`: + - Read preference after launcher setup. + - If `host_mode` and not BOOTSEL: `USBManager.activate()`. + - Else: CDC stays up; `arm_display()`/`arm_hid()` are NOT called (they would + fail anyway without host mode). + +### Build/script changes + +- `--usb` build no longer passes `-DMICROPY_HW_ENABLE_USBDEV=0`. The `usb` C + module and TinyUSB device stack coexist. +- Keep `MPOS_NO_USBDEV` in `manifests/manifest.py` to exclude the frozen + `usb-device` Python framework (runtime `machine.USBDevice` is still dead + weight; measure flash and decide whether to disable it too). +- Size budget: current headroom ~42 KB. Adding TinyUSB back may consume most of + it. If needed, disable `MICROPY_HW_ENABLE_USB_RUNTIME_DEVICE=0` and/or MSC. + +## P0 — spike (RESULTS, 2026-09-15) + +**Verdict: the runtime transition is proven feasible, full stack included.** + +With clean bench power, `usb._spike_host()` (`tud_deinit` + `usb_phy_deinit` ++ `usb_disp_init`/`hal_add`/`hal_start` + `usb_hid_start`) enumerates the +whole bench through the transition: hub + mouse staged + DisplayLink +descriptor walk + `bulk OUT configured`, `bus_devices()` = `[2, 3, 1]`, +`hub_ports()` correct. No crash, no hang. + +Earlier failures in this session were environmental, not code: +- OTG backfeed power (externally powered hub feeding VBUS back) browned + out the board on replugs: serial vanishing, reboots, phantom single-shot + `hcd_port_command(RESET)` failures, 50 s silences that were reboot + windows. Removing OTG external power fixed all of it. +- The console `/dev/ttyUSB0` is a CH340 **USB VM forward**: it can vanish + independently of the board. If lost: halt and ask the user to reconnect, + do not thrash. +- The `usbh_devs_addr_list_fill` LoadProhibited was a spike bug + (`hal_start` with `s_nhal == 0` early-returns, leaving the stack + uninstalled), not a transition bug. + +**CDC restore: root-caused, fix designed (P1).** The naive +`usb_host_uninstall()` + `usb_phy_init()` + `mp_usbd_init()` crashes in +TinyUSB re-init: `dwc2_int_set` → `esp_intr_free` on a stale handle → +`esp_intr_disable` LoadProhibited (+0x20). TinyUSB's DCD frees its ISR +handle on deinit without NULLing it, so re-init double-frees. Fix: 4-line +patch NULL-guarding both `usb_ih` statics (dcd_esp32sx.c single + +dwc2_esp32.h array), plus proper teardown order in our path (deregister +clients, stop daemon/client/HID tasks, then uninstall) — the spike skipped +all teardown, which also contributed. + +**Submodule note:** `usb_phy_deinit()` lives in `patches/usb_phy_deinit.patch` +(applied by `build_mpos.sh`, same convention as the other usb patches), not +as a submodule edit. + +## Known P0 issues → P1 tasks + +1. ~~Hub enumeration~~ CLOSED (environmental, proven working). +2. **CDC restore** — tinyusb ISR-handle patch + proper client/task teardown + order (`usb_hid_stop()`, `usb_disp_hal_stop()`), then re-verify the + round-trip. +3. ~~Display HAL init~~ CLOSED (spike bug, `hal_add` before `hal_start`). + +## P1 — implementation (after P0 success) + +1. `usb_hid_stop()` in `usb_hid.c`. +2. `usb_disp_hal_stop()` in `usb_disp_hal_esp32.cpp`. +3. Proper `usb.activate_host()` / `usb.deactivate_host()` module functions. +4. `USBManager.activate/deactivate`, preference handling, Settings toggle. +5. `mpos/main.py` boot-time preference + BOOTSEL escape. +6. Host harness: fake `tud_deinit`, `mp_usbd_init`, `usb_phy_init`, + `usb_host_uninstall`, lifecycle tests. +7. Build/script/manifest cleanup; docs; changelog. + +## P2 — verification + +- Flash size check. +- `make lint`, syntax, harness, desktop USB tests. +- Device matrix: + - Boot CDC, activate host, hub enumerates, display ready. + - Deactivate host, CDC returns, REPL usable. + - Persist ON, reboot, auto-activates host. + - Persist OFF, reboot, stays CDC. + - BOOTSEL held at boot overrides persist ON. + - Settings toggle round-trip. diff --git a/c_mpos/usb/hid-disables-auto-reset.md b/c_mpos/usb/hid-disables-auto-reset.md new file mode 100644 index 000000000..106d88a19 --- /dev/null +++ b/c_mpos/usb/hid-disables-auto-reset.md @@ -0,0 +1,88 @@ +# Why HID presence disables the hub-watchdog idle auto-reset + +## The problem + +The watchdog's idle auto-reset heals ports that read **connected + enabled +but never produce a device** (slow-booting DisplayLink adapters, ~15s grace, +one `PORT_RESET`). A healthy, enumerated HID mouse or keyboard reads +**exactly the same way**: connected + enabled, no bus growth (it created its +address long ago and makes no new ones). Port status alone cannot tell +"wedged adapter, idle" apart from "healthy mouse, idle". + +Without suppression, ~15s after plugging a mouse the watchdog would +`PORT_RESET` the mouse's own port: yanking a healthy device off the bus, +forcing re-enumeration, dropping input — and on ganged-power hubs browning +out the siblings. The mouse re-enumerates, reads idle again, and eats +another reset on the next mark cycle. The watchdog would DDoS our own input +devices. Parked keyboards are the same signature (enumerated but silent); +resetting one burns a bus address and re-parks it, since it can never claim +until something unplugs anyway. + +## The rule (v2: port-exact skip) + +Port status cannot discriminate, so the layer that knows the HID ports +tells the watchdog. Two mechanisms, split by resolvability: + +- **Claimed devices (open handle): port-exact skip in C.** + `usb_hid_owns_idle_port(hub, port)` resolves any open HID handle to its + (hub, port) via the `device_info()` parent chain (cached reads, no EP0 + traffic). The sweep skips the quiet auto-reset exactly on HID-owned + idle ports and logs the reason inline: + `[HUB] addr=1 port=3 HID device, auto-reset skipped`. Other ports keep + healing. A recheck at fire time closes the claimed-during-grace race. + Any non-empty slot counts (streaming, polled, staged, closing). +- **Parked devices (no handle): global suppression in Python.** + `USBManager` forces `auto_reset_idle` off while the parked list is + non-empty and restores the prior value after (a manual user setting is + never forced back on). Parked ports are unresolvable, so this stays + global. Previously claimed devices also forced it off; they no longer + need to. + +Explicitly NOT suppressed either way: the disabled-port episode path +(targeted resets, backoff, VBUS power cycle). Only the *enabled*-idle +quiet healer yields. The judgment call stands: resetting a healthy mouse +on a loop is worse than a stuck adapter needing a manual `reset_port()` +while a parked HID is present. + +## The cost (observed 2026-09-14) + +The worst case stacks three independent things: (1) a hub port genuinely +failing descriptor reads (same-port replug never heals, different-port +heals in ~10s — hardware, suspect port/cable first), (2) a mouse plugged +in, suppressing the quiet healer, (3) a `switched to panel` event marking +the idle port preexisting. Any one alone, the system heals. All three, and +the log shows `enabled but idle` pointer lines with no auto-switch, forever. +This was misread as a refactor regression; normalized diffs of the +Python move (`board/usb_display.py` -> `mpos/usb/usbmanager.py`) and the C +split (`usb_disp_mpy.c` -> `usb_mpy.c` + `usb_display_mpy.c`) show zero +logic changes (3c32b029). + +## Discriminators (live REPL, 30 seconds) + +```python +import usb +from mpos import USBManager +usb.auto_reset_idle() # bare call reads the toggle: False = suppressed or disabled +USBManager._hid_idle_prev # non-None = suppression currently active (holds the saved value) +usb.hid_claimed_addrs() # non-empty = a HID is holding suppression on +usb.hid_parked() # non-empty = a parked device is holding suppression on +``` + +`_hid_idle_prev is not None` with something claimed/parked = suppression +working as designed. Toggle `False` with `_hid_idle_prev is None` and +nothing claimed/parked = real bug (toggle stuck off). Toggle `True`, +nothing claimed/parked, port unmarked, still never fires = episode-path +bug. + +## Options (all P1-or-later, none needed for the exoneration) + +- ~~Narrow suppression to HID-owned ports only~~ DONE (v2 above): + the feared hub-driver introspection was unnecessary, the + `device_info()` parent chain resolves it. No spike was needed. +- ~~Class-aware skip~~ DROPPED: needs the same port resolution *plus* + descriptor fetches on potentially-sick ports (the observed + `CHECK_SHORT_DEV_DESC` failure mode) *plus* interface walks (mice are + class-0 at device level). All risk for a win the port-exact skip + already delivers, without teaching new device classes. +- ~~Visibility line~~ DONE (v2 above): the skip reason prints inline at + the sweep site, so the log explains itself. diff --git a/c_mpos/usb/micropython.cmake b/c_mpos/usb/micropython.cmake new file mode 100644 index 000000000..0a44b822c --- /dev/null +++ b/c_mpos/usb/micropython.cmake @@ -0,0 +1,76 @@ +# MicroPython USER_C_MODULE for USB host support on ESP32: display adapters +# (Pico_USB_Disp, MIT) plus a minimal USB HID host transport (src/usb_hid.c: +# boot-protocol mice + keyboards on raw usb_host, own client + task, no +# upstream/ changes). ESP32-only: DisplayLink DL-1xx via the in-tree IDF +# usb_host stack. +# +# Pass as: +# USER_C_MODULE=/c_mpos/usb/micropython.cmake +# ...to make.py when building for esp32. Requires CFLAGS_EXTRA to contain +# -DMICROPY_HW_ENABLE_USBDEV=0 so the OTG peripheral is free for USB Host +# (TinyUSB device mode would otherwise own it). + +add_library(usermod_c_usb INTERFACE) + +set(C_USB_UPSTREAM ${CMAKE_CURRENT_LIST_DIR}/upstream) + +set(C_USB_SOURCES + ${C_USB_UPSTREAM}/usb_disp.cpp + ${C_USB_UPSTREAM}/usb_disp_prot_dl-1xx.cpp + ${C_USB_UPSTREAM}/usb_disp_hal_esp32.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/usb_mpy.c + ${CMAKE_CURRENT_LIST_DIR}/src/usb_display_mpy.c + ${CMAKE_CURRENT_LIST_DIR}/src/usb_hid.c +) + +# T6/MS91xx need USB High-Speed (ESP32-P4 only); on S2/S3 they compile to +# empty stubs, so leave them out entirely to save flash. +if(IDF_TARGET STREQUAL "esp32p4") + list(APPEND C_USB_SOURCES + ${C_USB_UPSTREAM}/usb_disp_prot_t6.cpp + ${C_USB_UPSTREAM}/usb_disp_prot_ms91xx.cpp + ) +endif() + +target_sources(usermod_c_usb INTERFACE ${C_USB_SOURCES}) + +# Optimize the vendored sources for size: the esp32 port compiles usermod +# sources into the top-level micropython.elf with -O2, and the DL protocol +# code is not timing-critical (USB Full-Speed is the bottleneck). +# set_source_files_properties applies wherever the sources get compiled. +set_source_files_properties(${C_USB_SOURCES} PROPERTIES COMPILE_OPTIONS -Os) + +# ESP_PLATFORM is required by upstream's usb_disp.h platform detection. +# It must be an INTERFACE definition (not just CFLAGS_EXTRA) because the +# esp32 port also compiles usermod sources into the top-level micropython.elf +# target, which does not inherit MICROPY_CPP_FLAGS/CFLAGS_EXTRA — but it does +# inherit INTERFACE properties through the usermod -> main -> elf link chain. +target_compile_definitions(usermod_c_usb INTERFACE ESP_PLATFORM) + +target_include_directories(usermod_c_usb INTERFACE + ${C_USB_UPSTREAM} + ${CMAKE_CURRENT_LIST_DIR}/src +) + +# Propagate the in-tree IDF `usb` (usb_host) and `esp_timer` component +# include dirs, lcd_bus-style. NOTE: do NOT target_link_libraries() the +# idf:: aliases here: usermod.cmake recurses INTERFACE_LINK_LIBRARIES +# into MICROPY_INC_USERMOD, which drags every transitive IDF include dir +# (including relative ones like esp_hw_support's) into main's +# idf_component_register and breaks configure. All component libs link into +# the final app image anyway, so symbols resolve at final link. +foreach(_comp usb esp_timer) + idf_component_get_property(_comp_includes ${_comp} INCLUDE_DIRS) + idf_component_get_property(_comp_dir ${_comp} COMPONENT_DIR) + set(_comp_abs_includes "") + foreach(_inc ${_comp_includes}) + if(IS_ABSOLUTE ${_inc}) + list(APPEND _comp_abs_includes ${_inc}) + else() + list(APPEND _comp_abs_includes ${_comp_dir}/${_inc}) + endif() + endforeach() + target_include_directories(usermod_c_usb INTERFACE ${_comp_abs_includes}) +endforeach() + +target_link_libraries(usermod INTERFACE usermod_c_usb) diff --git a/c_mpos/usb/src/usb_display_mpy.c b/c_mpos/usb/src/usb_display_mpy.c new file mode 100644 index 000000000..f13c006a3 --- /dev/null +++ b/c_mpos/usb/src/usb_display_mpy.c @@ -0,0 +1,203 @@ +// Display class of the `usb` module: Pico_USB_Disp display adapters +// (DisplayLink USB display adapters) on ESP32 native USB OTG. +// +// ESP32-only. Single display use is expected (USB_DISP_MAX == 1 on ESP32) +// but the type supports handles generally. +// +// Pins are fixed on ESP32 (native USB OTG, GPIO19/20), so only the port +// number is needed. Resolution 0x0 means EDID automatic selection. + +#include + +#include "py/obj.h" +#include "py/runtime.h" + +#include "usb_disp.h" + +typedef struct _mp_obj_usb_display_t { + mp_obj_base_t base; + usb_disp_t *disp; +} mp_obj_usb_display_t; + +const mp_obj_type_t mp_type_usb_display; + +static bool s_usb_display_inited = false; + +static mp_obj_usb_display_t *usb_display_get_self(mp_obj_t self_in) { + mp_obj_usb_display_t *self = MP_OBJ_TO_PTR(self_in); + if (self->disp == NULL) { + mp_raise_ValueError(MP_ERROR_TEXT("display not added")); + } + return self; +} + +// Display(port=0, width=0, height=0, ignore_edid=False) +static mp_obj_t usb_display_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *args) { + enum { ARG_port, ARG_width, ARG_height, ARG_ignore_edid }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_port, MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_width, MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_height, MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_ignore_edid, MP_ARG_BOOL, {.u_bool = false} }, + }; + mp_arg_val_t parsed[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, args, MP_ARRAY_SIZE(allowed_args), allowed_args, parsed); + + if (!s_usb_display_inited) { + usb_disp_init(); + s_usb_display_inited = true; + } + + // Upstream has no remove API and ESP32 allows a single display, so a + // slot once added is taken forever. Reuse it: this makes REPL retries + // after a boot-time timeout and repeated constructions work. The + // original width/height config is kept; use set_mode() to change it. + usb_disp_t *d; + if (usb_disp_count() > 0) { + d = usb_disp_at(0); + } else { + d = usb_disp_add( + (uint8_t)parsed[ARG_port].u_int, + 0, 0, + (uint16_t)parsed[ARG_width].u_int, + (uint16_t)parsed[ARG_height].u_int, + parsed[ARG_ignore_edid].u_bool); + } + if (d == NULL) { + mp_raise_msg(&mp_type_RuntimeError, MP_ERROR_TEXT("usb_disp_add failed")); + } + + mp_obj_usb_display_t *self = mp_obj_malloc(mp_obj_usb_display_t, type); + self->disp = d; + return MP_OBJ_FROM_PTR(self); +} + +// start() - start the USB host for all registered ports (call once) +static mp_obj_t usb_display_start(mp_obj_t self_in) { + (void)self_in; + usb_disp_start(); + return mp_const_none; +} + +// poll() - drive enumeration/mode setup; True on READY/disconnect/mode change +static mp_obj_t usb_display_poll(mp_obj_t self_in) { + mp_obj_usb_display_t *self = usb_display_get_self(self_in); + return mp_obj_new_bool(usb_disp_poll(self->disp)); +} + +static mp_obj_t usb_display_ready(mp_obj_t self_in) { + mp_obj_usb_display_t *self = usb_display_get_self(self_in); + return mp_obj_new_bool(usb_disp_ready(self->disp)); +} + +static mp_obj_t usb_display_width(mp_obj_t self_in) { + mp_obj_usb_display_t *self = usb_display_get_self(self_in); + return mp_obj_new_int(usb_disp_width(self->disp)); +} + +static mp_obj_t usb_display_height(mp_obj_t self_in) { + mp_obj_usb_display_t *self = usb_display_get_self(self_in); + return mp_obj_new_int(usb_disp_height(self->disp)); +} + +// update_565(x, y, w, h, buf) - queue an RGB565 rectangle; pixels are copied +// synchronously into the bulk ring, buf may be reused right after return. +static mp_obj_t usb_display_update_565(size_t n_args, const mp_obj_t *args) { + mp_obj_usb_display_t *self = usb_display_get_self(args[0]); + uint16_t x = (uint16_t)mp_obj_get_int(args[1]); + uint16_t y = (uint16_t)mp_obj_get_int(args[2]); + uint16_t w = (uint16_t)mp_obj_get_int(args[3]); + uint16_t h = (uint16_t)mp_obj_get_int(args[4]); + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(args[5], &bufinfo, MP_BUFFER_READ); + if (bufinfo.len < (size_t)w * h * 2) { + mp_raise_ValueError(MP_ERROR_TEXT("buffer too small")); + } + bool ok = usb_disp_update_565(self->disp, x, y, w, h, (const uint16_t *)bufinfo.buf, w); + return mp_obj_new_bool(ok); +} + +// fill(x, y, w, h, color) - solid RGB565 fill +static mp_obj_t usb_display_fill(size_t n_args, const mp_obj_t *args) { + mp_obj_usb_display_t *self = usb_display_get_self(args[0]); + bool ok = usb_disp_fill(self->disp, + (uint16_t)mp_obj_get_int(args[1]), + (uint16_t)mp_obj_get_int(args[2]), + (uint16_t)mp_obj_get_int(args[3]), + (uint16_t)mp_obj_get_int(args[4]), + (uint16_t)mp_obj_get_int(args[5])); + (void)n_args; + return mp_obj_new_bool(ok); +} + +// flush(timeout_ms=100) - submit queued data and wait for completion +static mp_obj_t usb_display_flush(size_t n_args, const mp_obj_t *args) { + mp_obj_usb_display_t *self = usb_display_get_self(args[0]); + uint32_t timeout = 100; + if (n_args > 1) { + timeout = (uint32_t)mp_obj_get_int(args[1]); + } + return mp_obj_new_bool(usb_disp_flush(self->disp, timeout)); +} + +// set_mode(width, height) - change resolution at 60Hz (redraw required) +static mp_obj_t usb_display_set_mode(mp_obj_t self_in, mp_obj_t w_in, mp_obj_t h_in) { + mp_obj_usb_display_t *self = usb_display_get_self(self_in); + bool ok = usb_disp_set_mode(self->disp, (uint16_t)mp_obj_get_int(w_in), (uint16_t)mp_obj_get_int(h_in)); + return mp_obj_new_bool(ok); +} + +static mp_obj_t usb_display_chip_name(mp_obj_t self_in) { + mp_obj_usb_display_t *self = usb_display_get_self(self_in); + const char *name = usb_disp_chip_name(self->disp); + if (name == NULL) { + return mp_const_none; + } + return mp_obj_new_str(name, strlen(name)); +} + +// force_reenum() - root-port power cycle: virtual replug of the whole USB +// subtree. Recovers wedged adapters and stack-disabled ports, and +// re-triggers enumeration (e.g. after the adapter finished booting). +static mp_obj_t usb_display_force_reenum(mp_obj_t self_in) { + mp_obj_usb_display_t *self = usb_display_get_self(self_in); + usb_disp_force_reenum(self->disp); + return mp_const_none; +} + +static MP_DEFINE_CONST_FUN_OBJ_1(usb_display_start_obj, usb_display_start); +static MP_DEFINE_CONST_FUN_OBJ_1(usb_display_poll_obj, usb_display_poll); +static MP_DEFINE_CONST_FUN_OBJ_1(usb_display_ready_obj, usb_display_ready); +static MP_DEFINE_CONST_FUN_OBJ_1(usb_display_width_obj, usb_display_width); +static MP_DEFINE_CONST_FUN_OBJ_1(usb_display_height_obj, usb_display_height); +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(usb_display_update_565_obj, 6, 6, usb_display_update_565); +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(usb_display_fill_obj, 6, 6, usb_display_fill); +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(usb_display_flush_obj, 1, 2, usb_display_flush); +static MP_DEFINE_CONST_FUN_OBJ_3(usb_display_set_mode_obj, usb_display_set_mode); +static MP_DEFINE_CONST_FUN_OBJ_1(usb_display_chip_name_obj, usb_display_chip_name); + +static MP_DEFINE_CONST_FUN_OBJ_1(usb_display_force_reenum_obj, usb_display_force_reenum); + +static const mp_rom_map_elem_t usb_display_locals_table[] = { + { MP_ROM_QSTR(MP_QSTR_start), MP_ROM_PTR(&usb_display_start_obj) }, + { MP_ROM_QSTR(MP_QSTR_poll), MP_ROM_PTR(&usb_display_poll_obj) }, + { MP_ROM_QSTR(MP_QSTR_ready), MP_ROM_PTR(&usb_display_ready_obj) }, + { MP_ROM_QSTR(MP_QSTR_width), MP_ROM_PTR(&usb_display_width_obj) }, + { MP_ROM_QSTR(MP_QSTR_height), MP_ROM_PTR(&usb_display_height_obj) }, + { MP_ROM_QSTR(MP_QSTR_update_565), MP_ROM_PTR(&usb_display_update_565_obj) }, + { MP_ROM_QSTR(MP_QSTR_fill), MP_ROM_PTR(&usb_display_fill_obj) }, + { MP_ROM_QSTR(MP_QSTR_flush), MP_ROM_PTR(&usb_display_flush_obj) }, + { MP_ROM_QSTR(MP_QSTR_force_reenum), MP_ROM_PTR(&usb_display_force_reenum_obj) }, + { MP_ROM_QSTR(MP_QSTR_set_mode), MP_ROM_PTR(&usb_display_set_mode_obj) }, + { MP_ROM_QSTR(MP_QSTR_chip_name), MP_ROM_PTR(&usb_display_chip_name_obj) }, +}; + +static MP_DEFINE_CONST_DICT(usb_display_locals_dict, usb_display_locals_table); + +MP_DEFINE_CONST_OBJ_TYPE( + mp_type_usb_display, + MP_QSTR_Display, + MP_TYPE_FLAG_NONE, + make_new, usb_display_make_new, + locals_dict, &usb_display_locals_dict +); diff --git a/c_mpos/usb/src/usb_hid.c b/c_mpos/usb/src/usb_hid.c new file mode 100644 index 000000000..8e48150e8 --- /dev/null +++ b/c_mpos/usb/src/usb_hid.c @@ -0,0 +1,1397 @@ +// Minimal USB HID host transport (boot-protocol mice + keyboards). +// ESP32-only, rides the --usbdisplay build: shares the already-installed +// IDF usb_host stack (daemon task, hub support, settle patch, watchdog) +// but registers its OWN client + task. One small upstream hook exists: +// lsusb reuses our held handle for streaming devices (see usb_hid_held_handle). +// +// Design notes: +// - Two-stage setup avoids a deadlock: the client task (which owns event +// delivery) only opens devices and stages candidates; the blocking +// SET_PROTOCOL/SET_IDLE control transfers run from hid_poll(), i.e. the +// app thread behind Python's 1s poll timer (same pattern as the +// display HAL's pending_probe). +// - Interrupt completions arrive in the client task; the callback only +// memcpys into a lock-free SPSC ring and resubmits. Python drains it. +// - Only boot-subclass (1) mouse (proto 2) / keyboard (proto 1) +// interfaces are claimed. Anything else is closed untouched, so the +// display claim path and lsusb-style inspection never conflict: one +// device may be opened by both clients at once (different interfaces). +// - S3 HCD channel budget (verified: 8 in silicon, ~7 usable, 1 pipe = +// 1 channel, held for the pipe's lifetime): hub + display + keyboard + +// mouse need 4 (default pipes) + 1 (hub status) + 1 (display bulk) + 2 +// (HID interrupt) = 8 pipes, so the full combo can NOT fit. Policy: +// claim in priority order (display is external, mice before keyboards), +// and park losers silently with backoff instead of retry-spamming: +// ESP_ERR_NOT_SUPPORTED from interface_claim parks immediately (that +// is the channel-exhaustion signature), transient failures cool down +// 4s/12s/28s then park. Parking clears on bus topology change or +// hid_retry(). See README "HCD channels" section. + +#include +#include +#include +#include + +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "usb/usb_host.h" + +#include "usb_hid.h" + +// usb_disp_log() lives in usb_mpy.c (routes to UART REPL). +void usb_disp_log(const char *fmt, ...); + +#define USB_HID_MAX_DEV 4 +#define USB_HID_XFER_PER_DEV 2 +#define USB_HID_XFER_SIZE 64 +#define USB_HID_CTRL_TIMEOUT_MS 1500 +#define USB_HID_RING_MASK 63 // ring size 64, power of two + +typedef enum { + HID_SLOT_EMPTY = 0, + HID_SLOT_STAGED, // opened by client task, setup pending in hid_poll + HID_SLOT_STREAMING, // persistent interrupt pipe (mice) + HID_SLOT_POLLED, // Phase A: keyboard, interrupt pipe allocated per tick + HID_SLOT_CLOSING, // quiescing for a safe teardown (app thread owns it) +} hid_slot_state_t; + +typedef struct { + hid_slot_state_t state; + usb_device_handle_t dev; + uint8_t addr; + uint8_t iface; + uint8_t subclass; + uint8_t protocol; + uint8_t ep_in; + uint16_t mps; + uint8_t speed; // usb_speed_t: 0=low, 1=full, 2=high (decides TT involvement) + uint8_t interval_ms; // poll cadence, from bInterval, clamped + uint32_t poll_next_ms; // next transient tick due + uint32_t polls; // cumulative transient polls performed + uint16_t ch_total; // cumulative transient channel failures + uint8_t ch_consec; // consecutive transient channel failures + volatile bool retire; // live teardown requested: quiesce, then free. + // Set from any thread; only the app thread acts on it (see + // hid_retire_teardown). While set, no new submits may start. + volatile int inflight; // transfers submitted but not yet completed. + // Incremented on submit, decremented in the completion callback; + // the retire path waits for zero so no completion ever fires + // into freed memory (StoreProhibited crash, proven on hardware). + uint16_t vid; + uint16_t pid; + volatile bool gone; + volatile bool xfer_err; + usb_transfer_t *xfer[USB_HID_XFER_PER_DEV]; +} hid_slot_t; + +// Transient keyboard polling (Phase A experiment): after this many +// consecutive per-tick channel failures, park instead of spinning. +// ~2.5s at the 50ms tick: long enough to ride out someone else's +// enumeration burst, short enough to go quiet fast on real exhaustion. +#define HID_KBD_PARK_AFTER 50 +// Completion wait for one transient poll (submit is async even here). +// Deliberately short: responsiveness comes from the 10ms tick rate, not +// the pend length - a keypress just after a timeout is caught by the +// next tick. A short bound also limits wasted channel-hold time per +// tick, which is the whole point of transient polling. Expiry with no +// data is NEUTRAL (idle keyboard), never a failure. +#define HID_KBD_TICK_TIMEOUT_MS 30 +// Bounded wait to reap a halted transient transfer (CANCELED completion) +// before its memory may be freed and the interface released. Freeing +// early strands the endpoint object in the stack ("EP already allocated" +// on the next claim) and risks use-after-free when the late completion +// fires. Uses the same done semaphore; the client task keeps pumping. +#define HID_KBD_REAP_TIMEOUT_MS 50 + +static hid_slot_t s_slots[USB_HID_MAX_DEV]; +static usb_hid_event_t s_ring[USB_HID_RING_MASK + 1]; +static volatile uint8_t s_ring_head = 0; // producer (client task) only +static volatile uint8_t s_ring_tail = 0; // consumer (hid_drain) only +static volatile uint32_t s_dropped = 0; + +static usb_host_client_handle_t s_hid_client = NULL; +static bool s_hid_verbose = false; + +#define HID_VLOG(...) do { if (s_hid_verbose) usb_disp_log(__VA_ARGS__); } while (0) + +// Experiment switch (revert-test for the mouse+keyboard collapse): +// false = keyboards claim a PERSISTENT interrupt pipe like mice +// (pre-Phase-A behavior, zero abort churn); true = transient polling. +// Default persistent: the collapse under test never happens without +// transient aborts next to a standing pipe, and channels fit +// persistently everywhere except display-attached pressure (where the +// park policy still applies). Flippable live via hid_set_kbd_transient +// (applies to newly staged devices; live keyboards re-stage). +static bool s_kbd_transient = false; + +static bool s_hid_started = false; +static volatile bool s_scan_needed = false; +// Stop flags for host-mode exit (usb_hid_stop, app thread): the client +// task's loop and the kbd poll loop check these and self-delete. Cleared +// in usb_hid_start (NOT at the end of stop): a task waking late must still +// see the flag set and exit, never revive. Done flags let stop join +// instead of hoping a fixed delay suffices. +static volatile bool s_client_stop = false; +static volatile bool s_kbd_stop = false; +static volatile bool s_client_task_done = false; +static volatile bool s_kbd_task_done = false; +static SemaphoreHandle_t s_hid_ctrl_mutex = NULL; +static SemaphoreHandle_t s_hid_ctrl_done = NULL; +static SemaphoreHandle_t s_kbd_done = NULL; +static TaskHandle_t s_kbd_poll_task = NULL; +static volatile bool s_kbd_tick_active = false; +static volatile uint32_t s_change_gen = 0; + +// Retry deferral: per-device setup-failure accounting so a device that +// can never be claimed (no HCD channels left) parks silently instead of +// failing loudly at ~1 Hz forever. Keyed by VID:PID:protocol so it +// survives slot teardown; cleared on disconnect and on bus topology +// change. Shared client-task/app-thread without a lock (same as the +// other volatile flags here): fields are single-byte/short, ops are +// idempotent, worst case is one early or late retry. +#define HID_DEFER_MAX 4 +#define HID_DEFER_PARKED_FAILS 255 +typedef struct { + bool used; + uint16_t vid; + uint16_t pid; + uint8_t protocol; + volatile uint8_t fails; + volatile uint32_t next_due_ms; + volatile bool parked; +} hid_defer_t; + +static hid_defer_t s_defer[HID_DEFER_MAX]; + +// Last-seen bus topology (sorted addr snapshot). Any change re-arms +// parked retries: a replug may have freed channels or reordered claims. +static uint8_t s_topo_addrs[16]; +static int s_topo_n = -1; + +static uint32_t hid_now_ms(void) { + return (uint32_t)(esp_timer_get_time() / 1000); +} + +static hid_defer_t *hid_defer_lookup(uint16_t vid, uint16_t pid, uint8_t protocol, + bool create) { + for (uint8_t i = 0; i < HID_DEFER_MAX; i++) { + if (s_defer[i].used && s_defer[i].vid == vid && s_defer[i].pid == pid && + s_defer[i].protocol == protocol) { + return &s_defer[i]; + } + } + if (!create) { + return NULL; + } + for (uint8_t i = 0; i < HID_DEFER_MAX; i++) { + if (!s_defer[i].used) { + memset(&s_defer[i], 0, sizeof(s_defer[i])); + s_defer[i].used = true; + s_defer[i].vid = vid; + s_defer[i].pid = pid; + s_defer[i].protocol = protocol; + return &s_defer[i]; + } + } + return NULL; +} + +static void hid_defer_clear_entry(hid_defer_t *d) { + if (d != NULL) { + memset(d, 0, sizeof(*d)); + } +} + +static bool hid_defer_table_empty(void) { + for (uint8_t i = 0; i < HID_DEFER_MAX; i++) { + if (s_defer[i].used) { + return false; + } + } + return true; +} + +static void hid_defer_clear_all(bool log_it) { + bool had = !hid_defer_table_empty(); + memset(s_defer, 0, sizeof(s_defer)); + if (log_it && had) { + usb_disp_log("[HID] bus topology changed, parked retries re-armed"); + } +} + +// Record one setup failure. no_channels (ESP_ERR_NOT_SUPPORTED from +// interface_claim) parks immediately with the budget math; transient +// failures back off 4s/12s/28s, then park until replug/hid_retry(). +static void hid_defer_fail(hid_defer_t *d, uint16_t vid, uint16_t pid, uint8_t protocol, + uint8_t addr, bool no_channels, uint32_t now_ms) { + static const uint16_t backoff_ms[3] = {4000, 12000, 28000}; + if (d == NULL) { + return; + } + if (no_channels) { + d->fails = HID_DEFER_PARKED_FAILS; + d->parked = true; + usb_disp_log("[HID] %s %04X:%04X parked: no HCD channels free " + "(S3 fits ~7 pipes; hub+display+kbd+mouse need 8). " + "Unplug something or hid_retry().", + protocol == 2 ? "mouse" : "keyboard", vid, pid); + (void)addr; + return; + } + if (d->fails < 250) { + d->fails++; + } + if (d->fails > 3) { + d->parked = true; + usb_disp_log("[HID] %s addr=%u giving up after %u setup failures " + "(until replug/hid_retry)", + protocol == 2 ? "mouse" : "keyboard", addr, d->fails); + } else { + d->next_due_ms = now_ms + backoff_ms[d->fails - 1]; + usb_disp_log("[HID] %s addr=%u setup failed (%u/3), retry in %us", + protocol == 2 ? "mouse" : "keyboard", addr, d->fails, + backoff_ms[d->fails - 1] / 1000); + } +} + +static const char *hid_kind_str(uint8_t protocol) { + return protocol == 2 ? "mouse" : "keyboard"; +} + +static void hid_ring_push(uint8_t addr, uint8_t subclass, uint8_t protocol, + const uint8_t *data, uint8_t len) { + uint8_t head = s_ring_head; + uint8_t next = (uint8_t)((head + 1) & USB_HID_RING_MASK); + if (next == s_ring_tail) { + s_dropped++; + return; + } + usb_hid_event_t *e = &s_ring[head]; + e->addr = addr; + e->subclass = subclass; + e->protocol = protocol; + e->len = len > 8 ? 8 : len; + memcpy(e->data, data, e->len); + s_ring_head = next; +} + +static void hid_intr_cb(usb_transfer_t *xfer) { + hid_slot_t *slot = (hid_slot_t *)xfer->context; + if (slot->inflight > 0) { + slot->inflight--; + } + if (slot->retire || slot->state == HID_SLOT_CLOSING) { + // Slot is being quiesced: drop the completion silently. The + // retire path waits for inflight to reach zero before freeing. + return; + } + if (xfer->status == USB_TRANSFER_STATUS_COMPLETED) { + uint8_t n = xfer->actual_num_bytes > 255 ? 255 : (uint8_t)xfer->actual_num_bytes; + if (n > 0) { + hid_ring_push(slot->addr, slot->subclass, slot->protocol, + xfer->data_buffer, n); + } + if (slot->state == HID_SLOT_STREAMING && !slot->gone && !slot->retire) { + xfer->num_bytes = slot->mps; + if (usb_host_transfer_submit(xfer) != ESP_OK) { + slot->xfer_err = true; + } else { + slot->inflight++; + } + } + } else if (xfer->status != USB_TRANSFER_STATUS_CANCELED) { + // Visible by design (not verbose): persistent-transfer errors are + // rare, and the status code discriminates TT/split faults (ERROR) + // from surprise removal (NO_DEVICE) from stalls/overflows. + // Full list: 0=completed 1=error 2=timed-out 3=canceled + // 4=stall 5=overflow 6=skipped 7=no-device. + usb_disp_log("[HID] addr=%u intr status=%d actual=%d", slot->addr, + (int)xfer->status, xfer->actual_num_bytes); + slot->xfer_err = true; + } +} + +// Quiesced teardown for LIVE slots (retire flag), app thread only. +// Blocked waits are safe here: completions keep pumping on the client +// task. Never call from the client task itself (it owns the pump the +// reap below depends on). Bound for one retire quiesce; expiry logs +// loudly and proceeds (tearing down blind still beats leaking, and +// matches the pre-existing fast path's risk profile, minus the race). +#define HID_RETIRE_WAIT_MS 3000 +static void hid_retire_teardown(hid_slot_t *slot) { + uint8_t addr = slot->addr; + slot->state = HID_SLOT_CLOSING; // stop all new submits first of all + // Halt BEFORE the drain-wait: standing URBs never complete on their + // own, so waiting first always burns the full bound on healthy idle + // slots. Halting forces in-flight transfers to complete as CANCELED, + // and the bounded wait below reaps those completions promptly. (This + // used to run after the wait: same outcome, always 3s slower.) + usb_host_endpoint_halt(slot->dev, slot->ep_in); + usb_host_endpoint_flush(slot->dev, slot->ep_in); + uint32_t waited = 0; + while ((s_kbd_tick_active || slot->inflight > 0) && + waited < HID_RETIRE_WAIT_MS) { + vTaskDelay(pdMS_TO_TICKS(10)); + waited += 10; + } + if (s_kbd_tick_active || slot->inflight > 0) { + usb_disp_log("[HID] addr=%u retire forced with work in flight", addr); + } + // Drain completions once more before anything below is freed. + vTaskDelay(pdMS_TO_TICKS(50)); + usb_host_endpoint_clear(slot->dev, slot->ep_in); + for (uint8_t i = 0; i < USB_HID_XFER_PER_DEV; i++) { + if (slot->xfer[i] != NULL) { + usb_host_transfer_free(slot->xfer[i]); + slot->xfer[i] = NULL; + } + } + if (slot->dev != NULL) { + usb_host_interface_release(s_hid_client, slot->dev, slot->iface); + usb_host_device_close(s_hid_client, slot->dev); + slot->dev = NULL; + } + usb_disp_log("[HID] addr=%u retired", addr); + memset(slot, 0, sizeof(*slot)); + s_change_gen++; + s_scan_needed = true; +} + +static void hid_client_event_cb(const usb_host_client_event_msg_t *msg, void *arg) { + (void)arg; + if (msg->event == USB_HOST_CLIENT_EVENT_NEW_DEV) { + s_scan_needed = true; + } else if (msg->event == USB_HOST_CLIENT_EVENT_DEV_GONE) { + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + if (s_slots[i].state != HID_SLOT_EMPTY && s_slots[i].dev != NULL && + s_slots[i].dev == msg->dev_gone.dev_hdl) { + s_slots[i].gone = true; + } + } + } +} + +// Blocking control transfer on a held device handle. App-thread only +// (completion events are pumped by our client task). +static void hid_ctrl_done_cb(usb_transfer_t *xfer); + +static bool hid_ctrl(hid_slot_t *slot, uint8_t bmRequestType, uint8_t bRequest, + uint16_t wValue, uint16_t wIndex) { + if (s_hid_client == NULL || s_hid_ctrl_mutex == NULL || s_hid_ctrl_done == NULL || + slot->dev == NULL) { + return false; + } + if (xSemaphoreTake(s_hid_ctrl_mutex, pdMS_TO_TICKS(3000)) != pdTRUE) { + return false; + } + bool ok = false; + usb_transfer_t *x = NULL; + if (usb_host_transfer_alloc(8 + 64, 0, &x) != ESP_OK) { + goto out; + } + uint8_t *b = x->data_buffer; + b[0] = bmRequestType; + b[1] = bRequest; + b[2] = (uint8_t)wValue; + b[3] = (uint8_t)(wValue >> 8); + b[4] = (uint8_t)wIndex; + b[5] = (uint8_t)(wIndex >> 8); + b[6] = 0; + b[7] = 0; + x->num_bytes = 8; + x->device_handle = slot->dev; + x->bEndpointAddress = 0; + x->callback = hid_ctrl_done_cb; + x->context = NULL; + xSemaphoreTake(s_hid_ctrl_done, 0); + if (usb_host_transfer_submit_control(s_hid_client, x) != ESP_OK) { + goto out; + } + if (xSemaphoreTake(s_hid_ctrl_done, pdMS_TO_TICKS(USB_HID_CTRL_TIMEOUT_MS)) != pdTRUE) { + goto out; + } + ok = (x->status == USB_TRANSFER_STATUS_COMPLETED); +out: + if (x) { + usb_host_transfer_free(x); + } + xSemaphoreGive(s_hid_ctrl_mutex); + return ok; +} + +static void hid_ctrl_done_cb(usb_transfer_t *xfer) { + if (s_hid_ctrl_done != NULL) { + xSemaphoreGive(s_hid_ctrl_done); + } + (void)xfer; +} + +static bool hid_slot_addr_known(uint8_t addr) { + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + if (s_slots[i].state != HID_SLOT_EMPTY && s_slots[i].addr == addr) { + return true; + } + } + return false; +} + +// Walk the active config: find a boot-HID interface and its interrupt-IN EP. +// Client-task context, non-blocking (descriptor reads are cached). +static bool hid_find_boot_iface(const uint8_t *blob, uint16_t len, uint8_t *iface, + uint8_t *subclass, uint8_t *protocol, uint8_t *ep_in, + uint16_t *mps, uint8_t *interval_ms) { + const uint8_t *p = blob; + const uint8_t *end = blob + len; + uint8_t cur_iface = 0, cur_alt = 0, cur_class = 0, cur_sub = 0, cur_proto = 0; + bool cur_boot_hid = false; + while (p + 1 < end && p[0] >= 2 && p + p[0] <= end) { + uint8_t dlen = p[0], dtype = p[1]; + if (dtype == 0x04 && dlen >= 9) { + cur_iface = p[2]; + cur_alt = p[3]; + cur_class = p[5]; + cur_sub = p[6]; + cur_proto = p[7]; + cur_boot_hid = (cur_class == 0x03 && cur_sub == 0x01 && + (cur_proto == 0x01 || cur_proto == 0x02)); + } else if (dtype == 0x05 && dlen >= 7) { + uint8_t ep = p[2]; + uint8_t attr = p[3] & 0x03; + if (cur_boot_hid && cur_alt == 0 && attr == 0x03 && (ep & 0x80)) { + *iface = cur_iface; + *subclass = cur_sub; + *protocol = cur_proto; + *ep_in = ep; + *mps = (uint16_t)(p[4] | (p[5] << 8)); + if (*mps == 0 || *mps > USB_HID_XFER_SIZE) { + *mps = USB_HID_XFER_SIZE; + } + // Poll cadence for transient mode (Phase A): deliberately + // calm (50ms floor). The per-tick claim/CLEAR_FEATURE/ + // submit/halt/flush/release churn every 10ms was knocking + // cheap hub TTs off the bus, so keyboards trade latency + // (fine for typing/REPL) for bus quiet. bInterval is ms + // for low-speed, 2^(n-1) frames for full-speed; anything + // below the floor becomes 50ms, above 100ms stays capped. + uint8_t iv = p[6]; + if (iv < 50) { + iv = 50; + } else if (iv > 100) { + iv = 100; + } + *interval_ms = iv; + return true; + } + } + p += dlen; + } + return false; +} + +// Stage 1 (client task): open unknown devices, keep boot-HID candidates. +static void hid_scan(void) { + uint8_t addrs[16]; + int n = 0; + if (usb_host_device_addr_list_fill((int)sizeof(addrs), addrs, &n) != ESP_OK) { + return; + } + for (int i = 0; i < n; i++) { + uint8_t addr = addrs[i]; + if (hid_slot_addr_known(addr)) { + continue; + } + uint8_t free_idx = USB_HID_MAX_DEV; + for (uint8_t s = 0; s < USB_HID_MAX_DEV; s++) { + if (s_slots[s].state == HID_SLOT_EMPTY) { + free_idx = s; + break; + } + } + if (free_idx == USB_HID_MAX_DEV) { + return; + } + usb_device_handle_t dev = NULL; + esp_err_t open_err = usb_host_device_open(s_hid_client, addr, &dev); + if (open_err != ESP_OK) { + // ESP_ERR_INVALID_STATE = still enumerating / going away; the + // next NEW_DEV or poll rescan picks it up. Logged (not silent) + // because a stuck-open device never becomes a staged HID. + usb_disp_log("[HID] device_open failed addr=%u err=0x%X", addr, + (unsigned)open_err); + continue; + } + const usb_device_desc_t *ddesc = NULL; + const usb_config_desc_t *cdesc = NULL; + if (usb_host_get_device_descriptor(dev, &ddesc) != ESP_OK || ddesc == NULL || + usb_host_get_active_config_descriptor(dev, &cdesc) != ESP_OK || cdesc == NULL) { + usb_host_device_close(s_hid_client, dev); + continue; + } + if (ddesc->bDeviceClass == 0x09) { + usb_host_device_close(s_hid_client, dev); // hub: stack handles + continue; + } + uint8_t iface = 0, subclass = 0, protocol = 0, ep_in = 0, interval_ms = 10; + uint16_t mps = 0; + if (!hid_find_boot_iface((const uint8_t *)cdesc, cdesc->wTotalLength, &iface, + &subclass, &protocol, &ep_in, &mps, &interval_ms)) { + usb_host_device_close(s_hid_client, dev); + continue; + } + hid_slot_t *slot = &s_slots[free_idx]; + memset(slot, 0, sizeof(*slot)); + slot->state = HID_SLOT_STAGED; + { + // Speed decides TT involvement (low-speed devices behind the + // hub need split transactions; full-speed do not). Cached + // here for hid_state(); descriptor reads above are cached + // so this stays non-blocking in the client task. + usb_device_info_t dinfo; + slot->speed = 0xFF; // unknown + if (usb_host_device_info(dev, &dinfo) == ESP_OK) { + slot->speed = (uint8_t)dinfo.speed; + } + } + slot->dev = dev; + slot->addr = addr; + slot->iface = iface; + slot->subclass = subclass; + slot->protocol = protocol; + slot->ep_in = ep_in; + slot->mps = mps; + slot->interval_ms = interval_ms; + slot->vid = ddesc->idVendor; + slot->pid = ddesc->idProduct; + usb_disp_log("[HID] staged %s addr=%u if=%u ep=%02X mps=%u %04X:%04X", + hid_kind_str(protocol), addr, iface, ep_in, mps, slot->vid, + slot->pid); + } +} + +static void hid_teardown(hid_slot_t *slot, const char *why, bool dev_gone) { + usb_disp_log("[HID] addr=%u %s", slot->addr, why); + uint16_t vid = slot->vid, pid = slot->pid; + uint8_t protocol = slot->protocol; + for (uint8_t i = 0; i < USB_HID_XFER_PER_DEV; i++) { + if (slot->xfer[i] != NULL) { + usb_host_transfer_free(slot->xfer[i]); + slot->xfer[i] = NULL; + } + } + if (slot->dev != NULL) { + // Release before close; ignore errors (device may be gone). + usb_host_interface_release(s_hid_client, slot->dev, slot->iface); + usb_host_device_close(s_hid_client, slot->dev); + slot->dev = NULL; + } + memset(slot, 0, sizeof(*slot)); + if (dev_gone && (vid != 0 || pid != 0)) { + // Physical unplug: forget failure history so a replug starts + // fresh (topology tracking re-arms the rest anyway). + hid_defer_clear_entry(hid_defer_lookup(vid, pid, protocol, false)); + } + s_change_gen++; + s_scan_needed = true; +} + +static void hid_kbd_task_ensure(void); + +// Stage 2 (app thread via hid_poll): blocking setup of one staged slot. +// Parked/cooling devices are skipped silently; failures feed the defer +// table instead of retrying hot (see hid_defer_fail). +static void hid_setup_slot(hid_slot_t *slot) { + uint32_t now_ms = hid_now_ms(); + hid_defer_t *defer = + hid_defer_lookup(slot->vid, slot->pid, slot->protocol, true); + if (defer != NULL) { + if (defer->parked) { + return; + } + if ((int32_t)(now_ms - defer->next_due_ms) < 0 && defer->fails > 0) { + return; + } + } + if (!hid_ctrl(slot, 0x21, 0x0B, 0x0000, slot->iface)) { + usb_disp_log("[HID] addr=%u SET_PROTOCOL failed, continuing anyway", + slot->addr); + } + hid_ctrl(slot, 0x21, 0x0A, 0x0000, slot->iface); // SET_IDLE, best effort + if (slot->protocol == 1 && s_kbd_transient) { + // Phase A: keyboards hold NO persistent interrupt pipe. They go + // POLLED (transient claim/submit/release per tick from the poll + // task below) so hub + display + mouse + keyboard fit the S3 + // channel budget. Mice keep the persistent path for now. + // (Toggle resync is per-tick in hid_kbd_poll_once, not here: a + // once-per-episode resync leaves every later tick mismatched.) + hid_defer_clear_entry(defer); + slot->state = HID_SLOT_POLLED; + slot->poll_next_ms = now_ms + slot->interval_ms; + slot->polls = 0; + slot->ch_total = 0; + slot->ch_consec = 0; + usb_disp_log("[HID] keyboard addr=%u polled every %ums", slot->addr, + slot->interval_ms); + hid_kbd_task_ensure(); + s_change_gen++; + return; + } + esp_err_t claim_err = + usb_host_interface_claim(s_hid_client, slot->dev, slot->iface, 0); + if (claim_err != ESP_OK) { + usb_disp_log("[HID] addr=%u interface_claim err=0x%X", slot->addr, + (unsigned)claim_err); + hid_defer_fail(defer, slot->vid, slot->pid, slot->protocol, slot->addr, + claim_err == ESP_ERR_NOT_SUPPORTED, now_ms); + hid_teardown(slot, "interface_claim failed", false); + return; + } + bool xfers_ok = true; + for (uint8_t k = 0; k < USB_HID_XFER_PER_DEV; k++) { + if (usb_host_transfer_alloc(slot->mps, 0, &slot->xfer[k]) != ESP_OK) { + xfers_ok = false; + break; + } + slot->xfer[k]->device_handle = slot->dev; + slot->xfer[k]->bEndpointAddress = slot->ep_in; + slot->xfer[k]->callback = hid_intr_cb; + slot->xfer[k]->context = slot; + slot->xfer[k]->num_bytes = slot->mps; + } + if (!xfers_ok) { + hid_defer_fail(defer, slot->vid, slot->pid, slot->protocol, slot->addr, + false, now_ms); + hid_teardown(slot, "transfer alloc failed", false); + return; + } + slot->state = HID_SLOT_STREAMING; + for (uint8_t k = 0; k < USB_HID_XFER_PER_DEV; k++) { + if (usb_host_transfer_submit(slot->xfer[k]) != ESP_OK) { + slot->xfer_err = true; + } else { + slot->inflight++; + } + } + if (slot->xfer_err) { + hid_defer_fail(defer, slot->vid, slot->pid, slot->protocol, slot->addr, + false, now_ms); + hid_teardown(slot, "initial submit failed", false); + return; + } + hid_defer_clear_entry(defer); + usb_disp_log("[HID] %s addr=%u streaming", hid_kind_str(slot->protocol), + slot->addr); + s_change_gen++; +} + +// Stage 2 driver (app thread via hid_poll): two passes so mice win over +// keyboards when HCD channels are scarce (S3 policy: display > mouse > +// keyboard; the display claims through its own client and always wins). +static void hid_setup_staged(void) { + for (uint8_t pass = 0; pass < 2; pass++) { + uint8_t want_protocol = (pass == 0) ? 2 : 1; + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_slot_t *slot = &s_slots[i]; + if (slot->state != HID_SLOT_STAGED || slot->protocol != want_protocol) { + continue; + } + if (slot->dev == NULL || slot->gone) { + hid_teardown(slot, "staged device gone before setup", true); + continue; + } + hid_setup_slot(slot); + } + } +} + +// ---- Transient keyboard polling (Phase A experiment) ---- +// +// A POLLED keyboard holds its open handle but no interrupt pipe. Each +// tick allocates the pipe (claim), submits one IN transfer, waits, +// copies any report into the ring, and frees everything again. Steady +// state holds hub + display + mouse pipes only, so the full combo fits +// the S3 budget with margin to spare. Boot reports are level-state, so +// tick-rate sampling loses nothing vs native bInterval polling (only a +// press+release inside one tick window is invisible - same as hardware). +// All per-tick failures are silent counters; transitions log lines. + +typedef enum { + KBD_OK = 0, + KBD_EMPTY, // wait expired, no data (idle keyboard: neutral) + KBD_CH_FAIL, // no channel (claim/alloc failed with NOT_SUPPORTED) + KBD_OTHER_FAIL, // anything else (submit/status) + KBD_GONE, // device went away +} kbd_poll_res_t; + +static volatile uint32_t s_kbd_total_polls = 0; +static volatile uint32_t s_kbd_total_ch = 0; + +static void hid_kbd_done_cb(usb_transfer_t *xfer) { + if (s_kbd_done != NULL) { + xSemaphoreGive(s_kbd_done); + } + (void)xfer; +} + +typedef enum { + REAP_CLEAN = 0, // reaped, no data + REAP_DATA, // reaped, and a slow answer arrived: kept + REAP_WEDGED, // reap itself timed out +} reap_res_t; + +static reap_res_t hid_kbd_reap(hid_slot_t *slot, usb_transfer_t *x); + +static kbd_poll_res_t hid_kbd_poll_once(hid_slot_t *slot) { + if (slot->dev == NULL || slot->gone) { + return KBD_GONE; + } + esp_err_t cerr = + usb_host_interface_claim(s_hid_client, slot->dev, slot->iface, 0); + if (cerr != ESP_OK) { + HID_VLOG("[HID][V] addr=%u tick claim err=0x%X", slot->addr, + (unsigned)cerr); + return (cerr == ESP_ERR_NOT_SUPPORTED) ? KBD_CH_FAIL : KBD_OTHER_FAIL; + } + // Toggle resync, every tick: a fresh pipe starts at DATA0 while the + // device kept advancing its sequence across our free/realloc cycles, + // so without this only the first report after each resync lands and + // releases are lost (stuck keys). CLEAR_FEATURE(ENDPOINT_HALT) + // resets the device side to DATA0 to match. Best effort: a failure + // here must not kill the tick, the submit below will report back + // if the endpoint is really broken. Kept at the calm 50ms cadence, + // not the 10ms one that collapsed hubs. + if (!hid_ctrl(slot, 0x02, 0x01, 0x0000, slot->ep_in)) { + HID_VLOG("[HID][V] addr=%u tick resync failed, continuing", slot->addr); + } + kbd_poll_res_t res = KBD_OTHER_FAIL; + usb_transfer_t *x = NULL; + if (usb_host_transfer_alloc(slot->mps, 0, &x) == ESP_OK) { + x->device_handle = slot->dev; + x->bEndpointAddress = slot->ep_in; + x->callback = hid_kbd_done_cb; + x->context = slot; + x->num_bytes = slot->mps; + xSemaphoreTake(s_kbd_done, 0); + if (usb_host_transfer_submit(x) != ESP_OK) { + HID_VLOG("[HID][V] addr=%u tick submit failed", slot->addr); + } else { + // Paired with the reap/wait below: exactly one completion + // (data, error, or CANCELED-from-reap) settles this transfer. + // The counter lets retire-teardown wait out in-flight work + // instead of freeing under it (StoreProhibited, proven). + slot->inflight++; + // drop: deliberately leak instead of freeing. Set only when + // the transfer may still complete later (reap timed out): the + // stack would then write into freed heap. A wedged pipe is + // rare; its late completion only signals the done semaphore. + bool drop = false; + if (xSemaphoreTake(s_kbd_done, pdMS_TO_TICKS(HID_KBD_TICK_TIMEOUT_MS)) != + pdTRUE) { + // Idle keyboard (NAKs, no data): neutral, not a failure. + // Reap synchronously, then the next tick catches any + // keypress that starts right after this. + HID_VLOG("[HID][V] addr=%u tick wait timeout (idle)", slot->addr); + reap_res_t rr = hid_kbd_reap(slot, x); + slot->inflight--; + if (rr == REAP_WEDGED) { + usb_disp_log("[HID] addr=%u wedged transfer dropped (not freed)", + slot->addr); + drop = true; + } else { + slot->polls++; + s_kbd_total_polls++; + res = (rr == REAP_DATA) ? KBD_OK : KBD_EMPTY; + } + } else if (x->status != USB_TRANSFER_STATUS_COMPLETED) { + HID_VLOG("[HID][V] addr=%u tick status=%d actual=%d", slot->addr, + (int)x->status, x->actual_num_bytes); + // A completed-but-errored transfer is genuinely suspicious + // (unlike an idle timeout): reap for hygiene, but keep the + // backoff verdict so a persistently erroring endpoint parks + // loudly instead of spinning silently. + reap_res_t rr = hid_kbd_reap(slot, x); + slot->inflight--; + if (rr == REAP_WEDGED) { + usb_disp_log("[HID] addr=%u wedged transfer dropped (not freed)", + slot->addr); + drop = true; + } + } else { + if (x->actual_num_bytes > 0) { + uint8_t n = + x->actual_num_bytes > 255 ? 255 : (uint8_t)x->actual_num_bytes; + hid_ring_push(slot->addr, slot->subclass, slot->protocol, + x->data_buffer, n); + HID_VLOG("[HID][V] addr=%u tick ok bytes=%d", slot->addr, + x->actual_num_bytes); + } + slot->polls++; + s_kbd_total_polls++; + slot->inflight--; + res = KBD_OK; + } + if (!drop) { + usb_host_transfer_free(x); + } + } + } + usb_host_interface_release(s_hid_client, slot->dev, slot->iface); + return res; +} + +// Reap a halted transient transfer so its memory may be freed and the +// interface released: halt + flush, then wait (bounded) for the CANCELED +// (or late-OK) completion, then clear. Follows the display HAL's +// bulk_ep_recover choreography (halt->flush->clear). REAP_WEDGED means +// even the reap timed out: the pipe is wedged beyond a tick-level retry. +static reap_res_t hid_kbd_reap(hid_slot_t *slot, usb_transfer_t *x) { + usb_host_endpoint_halt(slot->dev, slot->ep_in); + usb_host_endpoint_flush(slot->dev, slot->ep_in); + if (xSemaphoreTake(s_kbd_done, pdMS_TO_TICKS(HID_KBD_REAP_TIMEOUT_MS)) != + pdTRUE) { + HID_VLOG("[HID][V] addr=%u reap timeout (pipe wedged)", slot->addr); + return REAP_WEDGED; + } + if (x->status == USB_TRANSFER_STATUS_COMPLETED && x->actual_num_bytes > 0) { + // Slow device answered between our timeout and the halt: keep + // the report instead of dropping it on the floor. + uint8_t n = + x->actual_num_bytes > 255 ? 255 : (uint8_t)x->actual_num_bytes; + hid_ring_push(slot->addr, slot->subclass, slot->protocol, + x->data_buffer, n); + HID_VLOG("[HID][V] addr=%u late data kept bytes=%d", slot->addr, + x->actual_num_bytes); + usb_host_endpoint_clear(slot->dev, slot->ep_in); + return REAP_DATA; + } + usb_host_endpoint_clear(slot->dev, slot->ep_in); + return REAP_CLEAN; +} + +// One scheduler pass over POLLED keyboards. Testable directly (the task +// wrapper below is a thin loop around this). +static void hid_kbd_tick(uint32_t now_ms) { + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_slot_t *slot = &s_slots[i]; + if (slot->state != HID_SLOT_POLLED) { + continue; + } + if (slot->retire) { + // Owned by hid_retire_teardown (app thread) now. + continue; + } + if (slot->dev == NULL || slot->gone) { + hid_teardown(slot, "disconnected", true); + continue; + } + if ((int32_t)(now_ms - slot->poll_next_ms) < 0) { + continue; + } + slot->poll_next_ms = now_ms + slot->interval_ms; + kbd_poll_res_t r = hid_kbd_poll_once(slot); + if (r == KBD_OK || r == KBD_EMPTY) { + // A completed tick (with or without data) proves the whole + // device + stack path works end to end. + slot->ch_consec = 0; + } else if (r == KBD_CH_FAIL) { + slot->ch_total++; + s_kbd_total_ch++; + slot->ch_consec++; + if (slot->ch_consec >= HID_KBD_PARK_AFTER) { + hid_defer_t *d = hid_defer_lookup(slot->vid, slot->pid, + slot->protocol, true); + hid_defer_fail(d, slot->vid, slot->pid, slot->protocol, + slot->addr, true, now_ms); + hid_teardown(slot, "parking: no channels for transient poll", false); + } + } else if (r == KBD_OTHER_FAIL) { + hid_defer_t *d = hid_defer_lookup(slot->vid, slot->pid, + slot->protocol, true); + hid_defer_fail(d, slot->vid, slot->pid, slot->protocol, slot->addr, + false, now_ms); + hid_teardown(slot, "transient poll failed", false); + } else { + hid_teardown(slot, "disconnected", true); + } + } +} + +static bool hid_kbd_any_polled(void) { + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + if (s_slots[i].state == HID_SLOT_POLLED) { + return true; + } + } + return false; +} + +// Start the poll task on first POLLED keyboard (idempotent). Logs the +// started line here so it is unit-testable; the stopped line lives in +// the task itself. +static void hid_kbd_poll_task(void *arg); + +static void hid_kbd_task_ensure(void) { + if (s_kbd_poll_task != NULL) { + return; + } + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_slot_t *slot = &s_slots[i]; + if (slot->state == HID_SLOT_POLLED && slot->dev != NULL) { + usb_disp_log("[HID] keyboard polling started (addr=%u, every %ums)", + slot->addr, slot->interval_ms); + break; + } + } + if (!hid_kbd_any_polled()) { + return; + } + s_kbd_task_done = false; + if (xTaskCreate(hid_kbd_poll_task, "usbhid_kbdpoll", 4096, NULL, 5, + &s_kbd_poll_task) != pdPASS) { + s_kbd_poll_task = NULL; + } +} + +static void hid_kbd_poll_task(void *arg) { + (void)arg; + uint32_t base_polls = s_kbd_total_polls; + uint32_t base_ch = s_kbd_total_ch; + while (!s_kbd_stop && hid_kbd_any_polled()) { + hid_kbd_tick(hid_now_ms()); + vTaskDelay(pdMS_TO_TICKS(5)); + } + usb_disp_log("[HID] keyboard polling stopped (%lu polls performed, %lu channel-fails)", + (unsigned long)(s_kbd_total_polls - base_polls), + (unsigned long)(s_kbd_total_ch - base_ch)); + s_kbd_poll_task = NULL; + s_kbd_task_done = true; + vTaskDelete(NULL); +} + +static volatile uint32_t s_client_last_pump_ms = 0; + +// One client-task pass over a single slot. Split out for unit tests; +// the live loop below just iterates it. +static void hid_client_task_slot(hid_slot_t *slot) { + // Retiring slots belong to the app thread (hid_retire_teardown): + // touching them here would double-free against it. + if (slot->retire) { + return; + } + if (slot->state == HID_SLOT_STREAMING && (slot->gone || slot->xfer_err)) { + // Give errored transfers a moment to complete as CANCELED. + vTaskDelay(pdMS_TO_TICKS(50)); + bool gone = slot->gone; + hid_teardown(slot, gone ? "disconnected" : "transfer error", gone); + } +} + +static void hid_client_task(void *arg) { + (void)arg; + while (!s_client_stop) { + // Heartbeat for event-delivery-stall detection (see + // usb_hid_loop_lag_ms): if this stops advancing while the device + // is alive, completions/teardowns/rescans all freeze with it. + s_client_last_pump_ms = hid_now_ms(); + usb_host_client_handle_events(s_hid_client, pdMS_TO_TICKS(100)); + // Streaming slots only: staged slots are owned by hid_poll() + // (app thread), so a replug racing setup is torn down there. + // Splitting ownership avoids double-close of the device handle. + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_client_task_slot(&s_slots[i]); + } + if (s_scan_needed) { + s_scan_needed = false; + hid_scan(); + } + } + s_client_task_done = true; + vTaskDelete(NULL); +} + +bool usb_hid_start(void) { + if (s_hid_started) { + return true; + } + memset(s_slots, 0, sizeof(s_slots)); + const usb_host_client_config_t client_cfg = { + .is_synchronous = false, + .max_num_event_msg = 8, + .async = + { + .client_event_callback = hid_client_event_cb, + .callback_arg = NULL, + }, + }; + if (usb_host_client_register(&client_cfg, &s_hid_client) != ESP_OK) { + return false; // host stack not up yet (display start runs first) + } + s_hid_ctrl_mutex = xSemaphoreCreateMutex(); + s_hid_ctrl_done = xSemaphoreCreateBinary(); + s_kbd_done = xSemaphoreCreateBinary(); + if (s_hid_ctrl_mutex == NULL || s_hid_ctrl_done == NULL || s_kbd_done == NULL) { + usb_host_client_deregister(s_hid_client); + s_hid_client = NULL; + return false; + } + s_hid_started = true; + s_scan_needed = true; + s_client_stop = false; + s_kbd_stop = false; + s_client_task_done = false; + s_kbd_task_done = false; + s_client_last_pump_ms = hid_now_ms(); + xTaskCreate(hid_client_task, "usbhid_client", 4096, NULL, 5, NULL); + usb_disp_log("[HID] client started (S3 channel policy: display > mouse > keyboard)"); + return true; +} + +// Tear down the HID client for host-mode exit (deactivate path). +// App thread only. Stops the client + kbd tasks, frees every slot +// WITHOUT retire-waiting (no completions can arrive once the tasks stop +// and the host uninstalls; waiting would burn 3s per live slot), deletes +// the semaphores, deregisters the client, resets all state. +// usb_hid_start() works again afterwards. Every public accessor stays +// safe to call while stopped (empty state, s_hid_started gate). +void usb_hid_stop(void) { + if (!s_hid_started) { + return; + } + s_hid_started = false; + // 1. Stop the kbd poll task first: an in-flight tick could submit new + // transfers at any moment (bounded by tick timeouts, ~100ms worst). + s_kbd_stop = true; + vTaskDelay(pdMS_TO_TICKS(150)); + // 2. Quiesce every endpoint while the client task still pumps: halted + // transfers complete as CANCELED, and CANCELED never resubmits, so no + // URB outlives this function. Skipping this leaves submitted URBs in + // the stack and client deregister fails (proven on hardware). + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_slot_t *slot = &s_slots[i]; + if (slot->dev != NULL) { + usb_host_endpoint_halt(slot->dev, slot->ep_in); + usb_host_endpoint_flush(slot->dev, slot->ep_in); + } + } + vTaskDelay(pdMS_TO_TICKS(100)); + // 3. Stop the client task and join (bounded, see flags above). + s_client_stop = true; + if (s_hid_client != NULL) { + usb_host_client_unblock(s_hid_client); + } + // Join on the done flags (bounded). The kbd task only exists while a + // transient keyboard is polled; a stale done=true from a normally + // exited task is fine (nothing to wait for), and task_ensure clears + // it whenever a new poll task starts. + bool need_kbd = (s_kbd_poll_task != NULL); + uint32_t waited = 0; + while ((!s_client_task_done || (need_kbd && !s_kbd_task_done)) && waited < 1000) { + vTaskDelay(pdMS_TO_TICKS(10)); + waited += 10; + } + if (!s_client_task_done || (need_kbd && !s_kbd_task_done)) { + usb_disp_log("[HID] stop: task join timed out, proceeding anyway"); + } + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_slot_t *slot = &s_slots[i]; + for (uint8_t k = 0; k < USB_HID_XFER_PER_DEV; k++) { + if (slot->xfer[k] != NULL) { + usb_host_transfer_free(slot->xfer[k]); + slot->xfer[k] = NULL; + } + } + if (slot->dev != NULL) { + usb_host_interface_release(s_hid_client, slot->dev, slot->iface); + usb_host_device_close(s_hid_client, slot->dev); + slot->dev = NULL; + } + } + memset(s_slots, 0, sizeof(s_slots)); + memset(s_defer, 0, sizeof(s_defer)); + s_ring_head = 0; + s_ring_tail = 0; + s_dropped = 0; + s_topo_n = -1; + s_scan_needed = false; + s_kbd_total_polls = 0; + s_kbd_total_ch = 0; + if (s_hid_ctrl_mutex != NULL) { + vSemaphoreDelete(s_hid_ctrl_mutex); + s_hid_ctrl_mutex = NULL; + } + if (s_hid_ctrl_done != NULL) { + vSemaphoreDelete(s_hid_ctrl_done); + s_hid_ctrl_done = NULL; + } + if (s_kbd_done != NULL) { + vSemaphoreDelete(s_kbd_done); + s_kbd_done = NULL; + } + s_kbd_poll_task = NULL; + if (s_hid_client != NULL) { + if (usb_host_client_deregister(s_hid_client) != ESP_OK) { + usb_disp_log("[HID] stop: client deregister failed"); + } + s_hid_client = NULL; + } + s_change_gen++; + usb_disp_log("[HID] client stopped"); + // Note: a kbd tick in flight across the reset above can recreate one + // defer entry (harmless phantom in hid_parked until the next topology + // change or hid_retry; 50ms race window, no crash: teardown on a + // zeroed slot is a no-op plus a generation bump). +} + +// Bus topology snapshot: any addr-list change (plug/unplug/reenum at a +// new address, or a missed NEW_DEV/DEV_GONE) re-arms parked retries and +// rescans. Runs on the app thread from hid_poll(). +static void hid_topology_check(void) { + uint8_t addrs[16]; + int n = 0; + if (usb_host_device_addr_list_fill((int)sizeof(addrs), addrs, &n) != ESP_OK) { + return; + } + bool same = (n == s_topo_n); + if (same) { + for (int i = 0; i < n && same; i++) { + bool found = false; + for (int k = 0; k < s_topo_n; k++) { + if (s_topo_addrs[k] == addrs[i]) { + found = true; + break; + } + } + same = found; + } + } + if (same) { + return; + } + if (n < (int)sizeof(s_topo_addrs)) { + memcpy(s_topo_addrs, addrs, (size_t)n); + } + s_topo_n = n; + hid_defer_clear_all(true); + s_scan_needed = true; +} + +bool usb_hid_poll(void) { + if (!s_hid_started) { + return false; + } + uint32_t before = s_change_gen; + hid_topology_check(); + // Retire-flagged live slots first: quiesced teardown (may block), + // so their restage follows in a later poll, never mid-teardown. + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_slot_t *slot = &s_slots[i]; + if (slot->retire && slot->state != HID_SLOT_EMPTY && + slot->state != HID_SLOT_CLOSING && slot->dev != NULL) { + hid_retire_teardown(slot); + } + } + hid_setup_staged(); + hid_kbd_task_ensure(); + // Health-check live claims; a dead handle means a missed DEV_GONE. + // Retiring slots are skipped: the retire path owns them already. + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_slot_t *slot = &s_slots[i]; + if ((slot->state == HID_SLOT_STREAMING || slot->state == HID_SLOT_POLLED) && + slot->dev != NULL && !slot->gone && !slot->retire) { + usb_device_info_t info; + if (usb_host_device_info(slot->dev, &info) != ESP_OK) { + slot->gone = true; + } + } + } + return s_change_gen != before; +} + +uint8_t usb_hid_claimed_addrs(uint8_t *out, uint8_t max) { + uint8_t n = 0; + if (out == NULL || max == 0) { + return 0; + } + for (uint8_t i = 0; i < USB_HID_MAX_DEV && n < max; i++) { + if (s_slots[i].state != HID_SLOT_EMPTY && s_slots[i].dev != NULL) { + out[n++] = s_slots[i].addr; + } + } + return n; +} + +uint8_t usb_hid_state(usb_hid_state_t *out, uint8_t max) { + uint8_t n = 0; + if (out == NULL || max == 0) { + return 0; + } + for (uint8_t i = 0; i < USB_HID_MAX_DEV && n < max; i++) { + hid_slot_t *slot = &s_slots[i]; + if ((slot->state != HID_SLOT_STREAMING && slot->state != HID_SLOT_POLLED) || + slot->dev == NULL) { + continue; + } + out[n].addr = slot->addr; + out[n].protocol = slot->protocol; + out[n].vid = slot->vid; + out[n].pid = slot->pid; + out[n].speed = slot->speed; + n++; + } + return n; +} + +uint8_t usb_hid_drain(usb_hid_event_t *out, uint8_t max) { + uint8_t n = 0; + if (out == NULL || max == 0) { + return 0; + } + while (n < max && s_ring_tail != s_ring_head) { + usb_hid_event_t *e = &s_ring[s_ring_tail]; + out[n].addr = e->addr; + out[n].subclass = e->subclass; + out[n].protocol = e->protocol; + out[n].len = e->len; + memcpy(out[n].data, e->data, e->len); + s_ring_tail = (uint8_t)((s_ring_tail + 1) & USB_HID_RING_MASK); + n++; + } + if (s_dropped != 0) { + usb_disp_log("[HID] dropped %lu reports (ring full)", (unsigned long)s_dropped); + s_dropped = 0; + } + return n; +} + +uint32_t usb_hid_change_gen(void) { + return s_change_gen; +} + +usb_device_handle_t usb_hid_held_handle(uint8_t addr) { + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_slot_t *slot = &s_slots[i]; + if ((slot->state == HID_SLOT_STREAMING || slot->state == HID_SLOT_POLLED) && + slot->dev != NULL && slot->addr == addr) { + return slot->dev; + } + } + return NULL; +} + +// Does one of our HID slots hold the device on (hub_addr, port)? Lets the +// hub watchdog skip its quiet auto-reset exactly on HID-owned ports +// instead of suppressing globally. device_info() resolves any open handle +// to (parent hub handle, port), and the parent's own info gives the hub +// address: pure cached stack reads, no EP0 traffic, safe from any thread. +// Any non-empty slot counts (streaming, polled, staged, closing): +// anything with an open handle is ours, including a device mid-setup or +// mid-teardown. Racy by design (a slot can tear down mid-read): the worst +// outcome is one skipped quiet reset, never a wrong reset, since this +// only ever suppresses. +bool usb_hid_owns_idle_port(uint8_t hub_addr, uint8_t port) { + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_slot_t *slot = &s_slots[i]; + usb_device_handle_t dev = slot->dev; + if (slot->state == HID_SLOT_EMPTY || dev == NULL) { + continue; + } + usb_device_info_t info; + if (usb_host_device_info(dev, &info) != ESP_OK) { + continue; + } + if (info.parent.port_num != port) { + continue; + } + usb_device_info_t hub_info; + if (usb_host_device_info(info.parent.dev_hdl, &hub_info) != ESP_OK) { + continue; + } + if (hub_info.dev_addr == hub_addr) { + return true; + } + } + return false; +} + +uint8_t usb_hid_poll_stats(usb_hid_poll_stat_t *out, uint8_t max) { + uint8_t n = 0; + if (out == NULL || max == 0) { + return 0; + } + for (uint8_t i = 0; i < USB_HID_MAX_DEV && n < max; i++) { + hid_slot_t *slot = &s_slots[i]; + if (slot->state != HID_SLOT_POLLED || slot->dev == NULL) { + continue; + } + out[n].addr = slot->addr; + out[n].protocol = slot->protocol; + out[n].polls = slot->polls; + out[n].ch_fails = slot->ch_total; + n++; + } + return n; +} + +uint8_t usb_hid_parked(usb_hid_parked_t *out, uint8_t max) { + uint8_t n = 0; + if (out == NULL || max == 0) { + return 0; + } + for (uint8_t i = 0; i < HID_DEFER_MAX && n < max; i++) { + if (!s_defer[i].used || (!s_defer[i].parked && s_defer[i].fails == 0)) { + continue; + } + out[n].vid = s_defer[i].vid; + out[n].pid = s_defer[i].pid; + out[n].protocol = s_defer[i].protocol; + out[n].fails = s_defer[i].fails; + n++; + } + return n; +} + +void usb_hid_retry(void) { + hid_defer_clear_all(false); + s_scan_needed = true; + usb_disp_log("[HID] manual retry re-armed"); +} + +void usb_hid_set_kbd_transient(bool on) { + if (s_kbd_transient == on) { + return; + } + s_kbd_transient = on; + // Live keyboards re-stage under the new mode via the retire path + // below (NOT via gone: tearing down a live streaming slot with + // in-flight URBs use-after-frees the heap - StoreProhibited, + // proven on hardware). Streaming and POLLED slots converge alike. + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_slot_t *slot = &s_slots[i]; + if ((slot->state == HID_SLOT_STREAMING || slot->state == HID_SLOT_POLLED) && + slot->protocol == 1 && slot->dev != NULL) { + slot->retire = true; + } + } + usb_disp_log("[HID] keyboard mode: %s (live keyboards re-stage)", + on ? "transient" : "persistent"); +} + +bool usb_hid_kbd_transient(void) { + return s_kbd_transient; +} + +uint32_t usb_hid_loop_lag_ms(void) { + // Unsigned wrap-safe: how long ago the client task last pumped + // events. ~100ms steady state; seconds mean event delivery (and + // with it completions, teardowns, rescans) is stalled. + return hid_now_ms() - s_client_last_pump_ms; +} + +void usb_hid_set_verbose(bool on) { + s_hid_verbose = on; +} + +bool usb_hid_verbose(void) { + return s_hid_verbose; +} diff --git a/c_mpos/usb/src/usb_hid.h b/c_mpos/usb/src/usb_hid.h new file mode 100644 index 000000000..85d0074e4 --- /dev/null +++ b/c_mpos/usb/src/usb_hid.h @@ -0,0 +1,128 @@ +// Minimal USB HID host transport (boot-protocol mice + keyboards). +// Public interface for the MicroPython binding (usb_mpy.c). +// Transport-only: report parsing lives in Python (drivers/indev/usb_hid.py), +// so swapping this for the official hid_host component later only touches +// this file, not the module API or the Python side. + +#ifndef USB_HID_H_ +#define USB_HID_H_ + +#include +#include + +#include "usb/usb_host.h" // usb_device_handle_t for held-handle sharing + +#ifdef __cplusplus +extern "C" { +#endif + +// One drained input report. data holds up to 8 raw report bytes +// (boot mouse reports are 3-4 bytes, boot keyboard 8). +typedef struct { + uint8_t addr; + uint8_t subclass; + uint8_t protocol; + uint8_t len; + uint8_t data[8]; +} usb_hid_event_t; + +// One streaming HID device. +typedef struct { + uint8_t addr; + uint8_t protocol; // 1 = keyboard, 2 = mouse (boot protocol numbers) + uint16_t vid; + uint16_t pid; + uint8_t speed; // usb_speed_t: 0=low, 1=full, 2=high, 0xFF=unknown +} usb_hid_state_t; + +// Register our own usb_host client + task. False when the host stack is +// not up yet (USBManager.arm_display() runs first in main.py); idempotent. +bool usb_hid_start(void); + +// Tear down the HID client for host-mode exit (deactivate path). +// App thread only; usb_hid_start() works again afterwards. +void usb_hid_stop(void); + +// App-thread pump: completes staged setups, health-checks claims. +// True when the device set changed (connect/disconnect). +bool usb_hid_poll(void); + +// Device addresses currently held open (never empty while held). +// bus_devices()/lsusb re-add these: claimed devices leave the stack's +// idle list, like the held display device. +uint8_t usb_hid_claimed_addrs(uint8_t *out, uint8_t max); + +// Streaming devices (for hid_state() and lsusb lines). +uint8_t usb_hid_state(usb_hid_state_t *out, uint8_t max); + +// Drain pending input reports (up to max). Single consumer only. +uint8_t usb_hid_drain(usb_hid_event_t *out, uint8_t max); + +// Monotonic generation counter, bumped on every claim/teardown. +uint32_t usb_hid_change_gen(void); + +// One parked (or cooling-down) device: setup keeps failing, so retries +// are deferred instead of spamming the log at full rate. +typedef struct { + uint16_t vid; + uint16_t pid; + uint8_t protocol; // 1 = keyboard, 2 = mouse + uint8_t fails; // consecutive setup failures (255 = parked) +} usb_hid_parked_t; + +// Parked/cooldown list for REPL introspection (usb.hid_parked()). +uint8_t usb_hid_parked(usb_hid_parked_t *out, uint8_t max); + +// Clear the parked/cooldown list and rescan now (usb.hid_retry()). +// Topology changes (plug/unplug) re-arms automatically; this is the +// manual equivalent. +void usb_hid_retry(void); + +// One transiently-polled keyboard: cumulative counters for REPL frequency +// checks (usb.hid_poll_stats()). Per-slot counters reset on +// teardown (unplug/replug); sample twice and diff for polls/sec. +typedef struct { + uint8_t addr; + uint8_t protocol; // 1 = keyboard (only keyboards are polled) + uint32_t polls; // transient polls performed + uint16_t ch_fails; // cumulative transient channel failures +} usb_hid_poll_stat_t; + +// Live polled keyboards (for usb.hid_poll_stats()). +uint8_t usb_hid_poll_stats(usb_hid_poll_stat_t *out, uint8_t max); + +// Keyboard transport mode experiment (usb.hid_set_kbd_transient()): +// false (default) = persistent interrupt pipe like mice; true = +// transient per-tick polling. Live keyboards re-stage on flip. +void usb_hid_set_kbd_transient(bool on); +bool usb_hid_kbd_transient(void); + +// ms since the client task last pumped events (usb.hid_loop_lag()). +// ~100ms in steady state; seconds mean event delivery - completions, +// teardowns, rescans - is stalled. Wrap-safe subtraction. +uint32_t usb_hid_loop_lag_ms(void); + +// Per-tick debug logging, off by default (usb.hid_verbose()). +// Gated [HID][V] lines: claim/submit/wait outcomes per tick. Opt-in +// only - at ~100 ticks/s it would drown the REPL otherwise. +void usb_hid_set_verbose(bool on); +bool usb_hid_verbose(void); + +// Open handle of a STREAMING device, if any (NULL otherwise). Lets +// lsusb-style inspection reuse the held handle instead of reopening a +// live device by address mid-stream (same reason the display HAL keeps +// its own handle for lsusb). +usb_device_handle_t usb_hid_held_handle(uint8_t addr); + +// True when one of our HID slots holds the device on (hub_addr, port). +// The hub watchdog's quiet auto-reset consults this to skip exactly the +// HID-owned idle ports while other ports keep healing (port-exact skip: +// parked devices have no open handle and stay covered by the Python-side +// global suppression instead). +bool usb_hid_owns_idle_port(uint8_t hub_addr, uint8_t port); + +#ifdef __cplusplus +} +#endif + +#endif // USB_HID_H_ diff --git a/c_mpos/usb/src/usb_mpy.c b/c_mpos/usb/src/usb_mpy.c new file mode 100644 index 000000000..78ad484fe --- /dev/null +++ b/c_mpos/usb/src/usb_mpy.c @@ -0,0 +1,452 @@ +// `usb` module: USB host support for ESP32 (DisplayLink display adapters +// plus a minimal HID host transport for boot-protocol mice + keyboards). +// Display-class code lives in usb_display_mpy.c; HID transport in +// usb_hid.c. This file owns module-level host/hub/watchdog/HID functions. + +#include +#include +#include + +#include "py/obj.h" +#include "py/runtime.h" + +#include "usb/usb_host.h" +#include "usb_disp.h" +#include "usb_disp_hal.h" +#include "usb_hid.h" + +// Runtime host/device switching (Settings "USB Host Mode"): +// TinyUSB teardown / bringup lets CDC stay alive by default and only +// switches to host mode when the user explicitly enables it. +extern bool tud_inited(void); +extern void tud_deinit(uint8_t rhport); +extern void mp_usbd_init(void); +extern void usb_phy_init(void); +extern void usb_phy_deinit(void); + +extern const mp_obj_type_t mp_type_usb_display; + +// Upstream's default usb_disp_log is a no-op outside Arduino. Route all +// library logs to the console (UART REPL during USB-host bringup) instead. +// (The name is upstream's: usb_disp.h declares it, usb_hid.c calls it.) +static bool s_usb_disp_log_on = true; + +void usb_disp_log(const char *fmt, ...) { + if (!s_usb_disp_log_on) { + return; + } + char buf[192]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + printf("%s\n", buf); +} + +static mp_obj_t mp_usb_set_log(mp_obj_t on_in) { + s_usb_disp_log_on = mp_obj_is_true(on_in); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(mp_usb_set_log_obj, mp_usb_set_log); + +// bus_devices() - list of USB device addresses currently seen by the host +// stack. Hubs and the adapter show up here once enumerated, whether or not +// our display claimed them (a claimed adapter leaves the stack's idle +// list, so it is re-added here explicitly). Empty list = nothing sensed +// (cable/power/stack). Safe to call from any thread; never raises. +static mp_obj_t mp_usb_bus_devices(void) { + uint8_t addrs[16]; + int n = 0; + mp_obj_t list = mp_obj_new_list(0, NULL); + if (usb_host_device_addr_list_fill((int)sizeof(addrs), addrs, &n) != ESP_OK) { + return list; + } + uint8_t claimed = 0; + bool have_claimed = usb_disp_hal_claimed_addr(&claimed); + uint8_t hid_addrs[8]; + uint8_t n_hid = usb_hid_claimed_addrs(hid_addrs, (uint8_t)sizeof(hid_addrs)); + for (int i = 0; i < n; i++) { + mp_obj_list_append(list, mp_obj_new_int(addrs[i])); + } + // Claimed devices (display + HID) left the stack's idle list, so + // re-add any that bus_devices() did not report. + uint8_t extra[9]; + uint8_t n_extra = 0; + if (have_claimed) { + extra[n_extra++] = claimed; + } + for (uint8_t i = 0; i < n_hid && n_extra < (uint8_t)sizeof(extra); i++) { + extra[n_extra++] = hid_addrs[i]; + } + for (uint8_t i = 0; i < n_extra; i++) { + bool seen = false; + for (int k = 0; k < n; k++) { + if (addrs[k] == extra[i]) { + seen = true; + break; + } + } + if (!seen) { + mp_obj_list_append(list, mp_obj_new_int(extra[i])); + } + } + return list; +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_bus_devices_obj, mp_usb_bus_devices); + +// hub_ports() - [(hub_addr, port, connected, enabled, high_speed), ...] +// for every external-hub port on the bus. Read-only standard hub +// requests; safe to call any time. A port stuck at (connected=True, +// enabled=False) is one the IDF stack gave up on (single-shot +// enumeration of a still-booting device) - reset_port() it or wait +// for the watchdog. high_speed ports (hub-to-hub uplinks) are never +// auto-reset: resetting one drops the whole subtree and crashes the +// IDF enumerator (abort in enum.c control_request_string). +static mp_obj_t mp_usb_hub_ports_fn(void) { + usb_disp_hub_port_t ports[32]; + uint8_t n = usb_disp_hal_hub_ports(ports, (uint8_t)sizeof(ports) / sizeof(ports[0])); + mp_obj_t list = mp_obj_new_list(0, NULL); + for (uint8_t i = 0; i < n; i++) { + mp_obj_t t[5]; + t[0] = mp_obj_new_int(ports[i].hub_addr); + t[1] = mp_obj_new_int(ports[i].port); + t[2] = mp_obj_new_bool(ports[i].connected); + t[3] = mp_obj_new_bool(ports[i].enabled); + t[4] = mp_obj_new_bool(ports[i].high_speed); + mp_obj_list_append(list, mp_obj_new_tuple(5, t)); + } + return list; +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_hub_ports_obj, mp_usb_hub_ports_fn); + +// reset_port(hub_addr, port, power_cycle=False, force=False) - re-enumerate +// one hub port without touching the rest of the chain. PORT_RESET +// (default) keeps VBUS up, so an already-booted device enumerates +// immediately; power_cycle=True drops VBUS for ~300ms first (stronger, +// but slow and may drop sibling ports on ganged-power hubs). +// High-speed ports (hub-to-hub uplinks) are refused unless force=True: +// resetting one drops the whole subtree and crashes the IDF enumerator. +// Manual recovery for a wedged port. +static mp_obj_t mp_usb_reset_port_fn(size_t n_args, const mp_obj_t *args) { + uint8_t hub_addr = (uint8_t)mp_obj_get_int(args[0]); + uint8_t port = (uint8_t)mp_obj_get_int(args[1]); + bool power_cycle = (n_args > 2) && mp_obj_is_true(args[2]); + bool force = (n_args > 3) && mp_obj_is_true(args[3]); + return mp_obj_new_bool(usb_disp_hal_reset_hub_port(hub_addr, port, power_cycle, force)); +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(mp_usb_reset_port_obj, 2, 4, mp_usb_reset_port_fn); + +// lsusb() - Linux-style USB listing ("Bus 001 Device 002: ID 17e9:028f +// DisplayLink ..."). Bus is always 001 (single OTG controller); no +// root-hub line. Read-only, safe any time. +static char s_lsusb_buf[2048]; +static mp_obj_t mp_usb_lsusb_fn(void) { + uint16_t used = usb_disp_hal_lsusb(s_lsusb_buf, sizeof(s_lsusb_buf)); + // HID-held devices usually stay in the stack's idle list, so the HAL + // pass above already printed them with full string descriptors + // ("PixArt Lenovo USB Optical Mouse" beats "HID mouse"). Append a + // generic line only for held devices the HAL pass skipped (same + // reason bus_devices() re-adds held addresses: a claimed device can + // drop out of the idle list, like the streaming display does). + uint8_t addrs[16]; + int n = 0; + bool have_list = (usb_host_device_addr_list_fill((int)sizeof(addrs), addrs, &n) == ESP_OK); + usb_hid_state_t st[4]; + uint8_t n_hid = usb_hid_state(st, 4); + for (uint8_t i = 0; i < n_hid && used + 64 < sizeof(s_lsusb_buf); i++) { + bool seen = false; + if (have_list) { + for (int k = 0; k < n; k++) { + if (addrs[k] == st[i].addr) { + seen = true; + break; + } + } + } + if (seen) { + continue; + } + const char *kind = st[i].protocol == 2 ? "mouse" : "keyboard"; + int w = snprintf(s_lsusb_buf + used, sizeof(s_lsusb_buf) - used, + "Bus 001 Device %03d: ID %04x:%04x HID %s\n", + st[i].addr, st[i].vid, st[i].pid, kind); + if (w < 0 || (uint16_t)w >= sizeof(s_lsusb_buf) - used) { + break; + } + used = (uint16_t)(used + w); + } + return mp_obj_new_str(s_lsusb_buf, used); +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_lsusb_obj, mp_usb_lsusb_fn); + +// set_watchdog(on) - enable/disable the hub-port watchdog (default on). +// The watchdog only acts while no display is attached; healthy ports +// are never touched. +static mp_obj_t mp_usb_set_watchdog_fn(mp_obj_t on_in) { + usb_disp_hal_set_watchdog(mp_obj_is_true(on_in)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(mp_usb_set_watchdog_obj, mp_usb_set_watchdog_fn); + +// set_auto_reset_idle([on]) - with no args, return the toggle state; +// with an arg, toggle automatic single PORT_RESET of idle ports that +// are not marked preexisting (default on). Marks are set for idle ports +// seen at boot, hub plug, and display-unplug snapshots, and cleared by +// any observed disconnect, so healthy uplinks are never selected while +// replugged adapters heal without hands. One shot per mark cycle. +static mp_obj_t mp_usb_set_auto_reset_idle_fn(size_t n_args, const mp_obj_t *args) { + if (n_args == 0) { + return mp_obj_new_bool(usb_disp_hal_auto_reset_idle()); + } + usb_disp_hal_set_auto_reset_idle(mp_obj_is_true(args[0])); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(mp_usb_set_auto_reset_idle_obj, 0, 1, mp_usb_set_auto_reset_idle_fn); + +// hid_start() - register the HID client (own task) on the shared host +// stack. False when the stack is not up yet (USBManager.arm_display runs +// first); idempotent, safe to retry from the 1s poll timer. +static mp_obj_t mp_usb_hid_start_fn(void) { + return mp_obj_new_bool(usb_hid_start()); +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_hid_start_obj, mp_usb_hid_start_fn); + +// hid_poll() - pump staged HID setups and claim health checks. True on +// device-set change (connect/disconnect), like Display.poll(). +static uint32_t s_hid_last_gen = 0; +static mp_obj_t mp_usb_hid_poll_fn(void) { + usb_hid_poll(); + uint32_t gen = usb_hid_change_gen(); + bool changed = (gen != s_hid_last_gen); + s_hid_last_gen = gen; + return mp_obj_new_bool(changed); +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_hid_poll_obj, mp_usb_hid_poll_fn); + +// hid_drain() - [(addr, subclass, protocol, bytes), ...] input reports +// since the last call. Raw boot reports; parsing is Python-side so new +// device kinds never need C changes. +static mp_obj_t mp_usb_hid_drain_fn(void) { + usb_hid_event_t ev[16]; + uint8_t n = usb_hid_drain(ev, (uint8_t)(sizeof(ev) / sizeof(ev[0]))); + mp_obj_t list = mp_obj_new_list(0, NULL); + for (uint8_t i = 0; i < n; i++) { + mp_obj_t t[4]; + t[0] = mp_obj_new_int(ev[i].addr); + t[1] = mp_obj_new_int(ev[i].subclass); + t[2] = mp_obj_new_int(ev[i].protocol); + t[3] = mp_obj_new_bytes(ev[i].data, ev[i].len); + mp_obj_list_append(list, mp_obj_new_tuple(4, t)); + } + return list; +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_hid_drain_obj, mp_usb_hid_drain_fn); + +// hid_state() - [(addr, kind, vid, pid, speed), ...] for streaming HID +// devices. kind is "mouse" or "keyboard"; speed is 0=low, 1=full, +// 2=high (low-speed devices behind a hub need split transactions). +static mp_obj_t mp_usb_hid_state_fn(void) { + usb_hid_state_t st[4]; + uint8_t n = usb_hid_state(st, 4); + mp_obj_t list = mp_obj_new_list(0, NULL); + for (uint8_t i = 0; i < n; i++) { + mp_obj_t t[5]; + t[0] = mp_obj_new_int(st[i].addr); + const char *kind = st[i].protocol == 2 ? "mouse" : "keyboard"; + t[1] = mp_obj_new_str(kind, strlen(kind)); + t[2] = mp_obj_new_int(st[i].vid); + t[3] = mp_obj_new_int(st[i].pid); + t[4] = mp_obj_new_int(st[i].speed); + mp_obj_list_append(list, mp_obj_new_tuple(5, t)); + } + return list; +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_hid_state_obj, mp_usb_hid_state_fn); + +// hid_claimed_addrs() - [addr, ...] held open by the HID client. The hub +// watchdog's idle auto-reset must skip these (a healthy mouse reads +// exactly like a wedged adapter: connected + enabled, no bus growth); +// bus_devices()/lsusb re-add them for the same reason as the display. +static mp_obj_t mp_usb_hid_claimed_addrs_fn(void) { + uint8_t addrs[8]; + uint8_t n = usb_hid_claimed_addrs(addrs, (uint8_t)sizeof(addrs)); + mp_obj_t list = mp_obj_new_list(0, NULL); + for (uint8_t i = 0; i < n; i++) { + mp_obj_list_append(list, mp_obj_new_int(addrs[i])); + } + return list; +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_hid_claimed_addrs_obj, mp_usb_hid_claimed_addrs_fn); + +// hid_parked() - [(vid, pid, kind, fails), ...] devices whose setup keeps +// failing and are parked (fails=255) or cooling down. Parked devices stay +// silent until a bus topology change or hid_retry(). +static mp_obj_t mp_usb_hid_parked_fn(void) { + usb_hid_parked_t p[4]; + uint8_t n = usb_hid_parked(p, 4); + mp_obj_t list = mp_obj_new_list(0, NULL); + for (uint8_t i = 0; i < n; i++) { + mp_obj_t t[4]; + t[0] = mp_obj_new_int(p[i].vid); + t[1] = mp_obj_new_int(p[i].pid); + const char *kind = p[i].protocol == 2 ? "mouse" : "keyboard"; + t[2] = mp_obj_new_str(kind, strlen(kind)); + t[3] = mp_obj_new_int(p[i].fails); + mp_obj_list_append(list, mp_obj_new_tuple(4, t)); + } + return list; +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_hid_parked_obj, mp_usb_hid_parked_fn); + +// hid_retry() - clear the parked/cooldown list and rescan now. Topology +// changes (plug/unplug) re-arm automatically; this is the manual version. +static mp_obj_t mp_usb_hid_retry_fn(void) { + usb_hid_retry(); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_hid_retry_obj, mp_usb_hid_retry_fn); + +// hid_poll_stats() - [(addr, kind, polls, ch_fails), ...] for live +// transiently-polled keyboards. Sample twice and diff polls for the +// effective poll rate; ch_fails counts channel-exhaustion ticks. +static mp_obj_t mp_usb_hid_poll_stats_fn(void) { + usb_hid_poll_stat_t ps[4]; + uint8_t n = usb_hid_poll_stats(ps, 4); + mp_obj_t list = mp_obj_new_list(0, NULL); + for (uint8_t i = 0; i < n; i++) { + mp_obj_t t[4]; + t[0] = mp_obj_new_int(ps[i].addr); + const char *kind = ps[i].protocol == 2 ? "mouse" : "keyboard"; + t[1] = mp_obj_new_str(kind, strlen(kind)); + t[2] = mp_obj_new_int(ps[i].polls); + t[3] = mp_obj_new_int(ps[i].ch_fails); + mp_obj_list_append(list, mp_obj_new_tuple(4, t)); + } + return list; +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_hid_poll_stats_obj, mp_usb_hid_poll_stats_fn); + +// hid_loop_lag() - ms since the HID client task last pumped stack +// events. Reads ~100 in steady state; seconds indicate event delivery +// (completions, teardowns, rescans) is stalled. +static mp_obj_t mp_usb_hid_loop_lag_fn(void) { + return mp_obj_new_int((mp_int_t)usb_hid_loop_lag_ms()); +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_hid_loop_lag_obj, mp_usb_hid_loop_lag_fn); + +// hid_set_kbd_transient([on]) - keyboard transport mode experiment. +// Default (persistent, False): keyboards claim like mice. True selects +// transient per-tick polling (needed under display channel pressure). +// Bare call reads back. Live keyboards re-stage on flip. +static mp_obj_t mp_usb_hid_set_kbd_transient_fn(size_t n_args, const mp_obj_t *args) { + if (n_args == 0) { + return mp_obj_new_bool(usb_hid_kbd_transient()); + } + usb_hid_set_kbd_transient(mp_obj_is_true(args[0])); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(mp_usb_hid_set_kbd_transient_obj, 0, 1, + mp_usb_hid_set_kbd_transient_fn); + +// P1: runtime host/device switching (Settings "USB Host Mode"). +// The C layer owns ONLY the mode switch (peripheral handover). Stack +// bringup stays exactly the legacy sequence in Python (Display() +// constructs = init+add, .start() = hal_start, then hid_start), so no +// port/slot is ever registered twice. +static bool s_host_active = false; + +// activate_host() - leave CDC device mode. Tears down TinyUSB and deletes +// the device-mode PHY; the host stack installs itself on first use below +// (Display.start() / hid_start()). Idempotent: True when host mode holds. +static mp_obj_t mp_usb_activate_host_fn(void) { + if (s_host_active) { + return mp_const_true; + } + tud_deinit(0); + usb_phy_deinit(); + s_host_active = true; + return mp_const_true; +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_activate_host_obj, mp_usb_activate_host_fn); + +// cdc_inited() - True while the TinyUSB device stack is initialized. +// Diagnostic for the deactivate path (False in host mode, True after a +// clean return to CDC). Needs no host attached; mounted/connected state +// additionally needs VBUS from a real USB host. +static mp_obj_t mp_usb_cdc_inited_fn(void) { + return mp_obj_new_bool(tud_inited()); +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_cdc_inited_obj, mp_usb_cdc_inited_fn); + +// host_active() - True while the host stack is up (between a successful +// activate_host and deactivate_host). Lets Python/UI show live state. +static mp_obj_t mp_usb_host_active_fn(void) { + return mp_obj_new_bool(s_host_active); +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_host_active_obj, mp_usb_host_active_fn); + +// deactivate_host() - switch from USB host mode back to CDC device mode. +// Stops HID + display clients, uninstalls the host stack (deletes its +// PHY), recreates the device PHY, restarts TinyUSB. Idempotent: True +// when CDC mode is assured (REPL rejoins automatically). +static mp_obj_t mp_usb_deactivate_host_fn(void) { + if (!s_host_active) { + return mp_const_true; + } + usb_hid_stop(); + usb_disp_hal_stop(); + usb_phy_init(); + mp_usbd_init(); + s_host_active = false; + return mp_const_true; +} +static MP_DEFINE_CONST_FUN_OBJ_0(mp_usb_deactivate_host_obj, mp_usb_deactivate_host_fn); + +// hid_verbose([on]) - with no args, return the per-tick debug flag; +// with an arg, set it. Off by default; when on, each transient tick +// logs [HID][V] claim/submit/wait outcomes (only useful while actively +// debugging input, ~100 lines/s otherwise). +static mp_obj_t mp_usb_hid_verbose_fn(size_t n_args, const mp_obj_t *args) { + if (n_args == 0) { + return mp_obj_new_bool(usb_hid_verbose()); + } + usb_hid_set_verbose(mp_obj_is_true(args[0])); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(mp_usb_hid_verbose_obj, 0, 1, mp_usb_hid_verbose_fn); + +static const mp_rom_map_elem_t usb_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_usb) }, + { MP_ROM_QSTR(MP_QSTR_Display), MP_ROM_PTR(&mp_type_usb_display) }, + { MP_ROM_QSTR(MP_QSTR_set_log), MP_ROM_PTR(&mp_usb_set_log_obj) }, + { MP_ROM_QSTR(MP_QSTR_bus_devices), MP_ROM_PTR(&mp_usb_bus_devices_obj) }, + { MP_ROM_QSTR(MP_QSTR_lsusb), MP_ROM_PTR(&mp_usb_lsusb_obj) }, + { MP_ROM_QSTR(MP_QSTR_hub_ports), MP_ROM_PTR(&mp_usb_hub_ports_obj) }, + { MP_ROM_QSTR(MP_QSTR_reset_port), MP_ROM_PTR(&mp_usb_reset_port_obj) }, + { MP_ROM_QSTR(MP_QSTR_set_watchdog), MP_ROM_PTR(&mp_usb_set_watchdog_obj) }, + { MP_ROM_QSTR(MP_QSTR_auto_reset_idle), MP_ROM_PTR(&mp_usb_set_auto_reset_idle_obj) }, + { MP_ROM_QSTR(MP_QSTR_hid_start), MP_ROM_PTR(&mp_usb_hid_start_obj) }, + { MP_ROM_QSTR(MP_QSTR_hid_poll), MP_ROM_PTR(&mp_usb_hid_poll_obj) }, + { MP_ROM_QSTR(MP_QSTR_hid_drain), MP_ROM_PTR(&mp_usb_hid_drain_obj) }, + { MP_ROM_QSTR(MP_QSTR_hid_state), MP_ROM_PTR(&mp_usb_hid_state_obj) }, + { MP_ROM_QSTR(MP_QSTR_hid_claimed_addrs), MP_ROM_PTR(&mp_usb_hid_claimed_addrs_obj) }, + { MP_ROM_QSTR(MP_QSTR_hid_parked), MP_ROM_PTR(&mp_usb_hid_parked_obj) }, + { MP_ROM_QSTR(MP_QSTR_hid_retry), MP_ROM_PTR(&mp_usb_hid_retry_obj) }, + { MP_ROM_QSTR(MP_QSTR_hid_poll_stats), MP_ROM_PTR(&mp_usb_hid_poll_stats_obj) }, + { MP_ROM_QSTR(MP_QSTR_hid_verbose), MP_ROM_PTR(&mp_usb_hid_verbose_obj) }, + { MP_ROM_QSTR(MP_QSTR_hid_loop_lag), MP_ROM_PTR(&mp_usb_hid_loop_lag_obj) }, + { MP_ROM_QSTR(MP_QSTR_hid_set_kbd_transient), MP_ROM_PTR(&mp_usb_hid_set_kbd_transient_obj) }, + { MP_ROM_QSTR(MP_QSTR_activate_host), MP_ROM_PTR(&mp_usb_activate_host_obj) }, + { MP_ROM_QSTR(MP_QSTR_cdc_inited), MP_ROM_PTR(&mp_usb_cdc_inited_obj) }, + { MP_ROM_QSTR(MP_QSTR_host_active), MP_ROM_PTR(&mp_usb_host_active_obj) }, + { MP_ROM_QSTR(MP_QSTR_deactivate_host), MP_ROM_PTR(&mp_usb_deactivate_host_obj) }, +}; + +static MP_DEFINE_CONST_DICT(usb_module_globals, usb_module_globals_table); + +const mp_obj_module_t usb_user_cmodule = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&usb_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_usb, usb_user_cmodule); diff --git a/c_mpos/usb/tests/Makefile b/c_mpos/usb/tests/Makefile new file mode 100644 index 000000000..ef9ce4fea --- /dev/null +++ b/c_mpos/usb/tests/Makefile @@ -0,0 +1,16 @@ +# Host unit tests for ../src/usb_hid.c: the real file is compiled with +# stub IDF/FreeRTOS headers (stubs/) against a scriptable fake stack +# (fake_usb_host.c). No hardware, no sleep (fake clock). +CC ?= gcc +CFLAGS ?= -std=c99 -Wall -Wextra -g -Istubs -I../src + +test_hid_host: test_hid_host.c fake_usb_host.c fake_usb_host.h ../src/usb_hid.c ../src/usb_hid.h + $(CC) $(CFLAGS) -o $@ test_hid_host.c fake_usb_host.c + +.PHONY: test +test: test_hid_host + ./test_hid_host + +.PHONY: clean +clean: + rm -f test_hid_host diff --git a/c_mpos/usb/tests/fake_usb_host.c b/c_mpos/usb/tests/fake_usb_host.c new file mode 100644 index 000000000..50fe33ab5 --- /dev/null +++ b/c_mpos/usb/tests/fake_usb_host.c @@ -0,0 +1,665 @@ +// Fake IDF usb_host + FreeRTOS + esp_timer + usb_disp_log for +// host-testing the real src/usb_hid.c. See fake_usb_host.h. +#include +#include +#include +#include + +#include "fake_usb_host.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +int64_t fake_now_us = 0; + +int64_t esp_timer_get_time(void) { + return fake_now_us; +} + +void vTaskDelay(TickType_t ticks) { + fake_now_us += (int64_t)ticks * 1000; +} + +void vTaskDelete(void *task) { + (void)task; +} + +#define FAKE_MAX_TASKS 4 +static struct { + void *fn; + const char *name; +} s_tasks[FAKE_MAX_TASKS]; +static int s_ntasks = 0; + +int xTaskCreate(TaskFunction_t fn, const char *name, unsigned stack, + void *arg, unsigned prio, TaskHandle_t *handle) { + (void)stack; + (void)arg; + (void)prio; + if (s_ntasks < FAKE_MAX_TASKS) { + s_tasks[s_ntasks].fn = (void *)fn; + s_tasks[s_ntasks].name = name; + s_ntasks++; + } + if (handle != NULL) { + *handle = (void *)0x1; + } + return pdPASS; +} + +int fake_task_count(void) { + return s_ntasks; +} + +void *fake_task_fn(int i) { + return s_tasks[i].fn; +} + +const char *fake_task_name(int i) { + return s_tasks[i].name; +} + +int xSemaphoreTake(SemaphoreHandle_t sem, TickType_t ticks) { + int *count = (int *)sem; + if (*count > 0) { + (*count)--; + return pdTRUE; + } + if (ticks == 0) { + return pdFALSE; + } + fake_now_us += (int64_t)ticks * 1000; + return pdFALSE; +} + +int xSemaphoreGive(SemaphoreHandle_t sem) { + int *count = (int *)sem; + if (*count < 1000) { + (*count)++; + } + return pdTRUE; +} + +void vSemaphoreDelete(SemaphoreHandle_t sem) { + free(sem); +} + +SemaphoreHandle_t xSemaphoreCreateBinary(void) { + int *count = (int *)calloc(1, sizeof(int)); + return (SemaphoreHandle_t)count; +} + +SemaphoreHandle_t xSemaphoreCreateMutex(void) { + int *count = (int *)calloc(1, sizeof(int)); + *count = 1; + return (SemaphoreHandle_t)count; +} + +// ---- usb_disp_log capture ---- + +#define FAKE_LOG_LINES 128 +#define FAKE_LOG_LEN 256 +static char s_log[FAKE_LOG_LINES][FAKE_LOG_LEN]; +static int s_nlog = 0; + +void usb_disp_log(const char *fmt, ...) { + char *dst = s_log[s_nlog % FAKE_LOG_LINES]; + va_list ap; + va_start(ap, fmt); + vsnprintf(dst, FAKE_LOG_LEN, fmt, ap); + va_end(ap); + s_nlog++; +} + +int fake_log_count(void) { + return s_nlog; +} + +const char *fake_log_line(int i) { + return s_log[i % FAKE_LOG_LINES]; +} + +bool fake_log_has(const char *substr) { + int n = s_nlog < FAKE_LOG_LINES ? s_nlog : FAKE_LOG_LINES; + for (int i = 0; i < n; i++) { + if (strstr(s_log[i], substr) != NULL) { + return true; + } + } + return false; +} + +void fake_log_clear(void) { + s_nlog = 0; +} + +// ---- fake device model ---- + +#define FAKE_MAX_DEV 16 +#define FAKE_CFG_MAX 128 + +typedef struct { + bool used; + bool plugged; + uint8_t addr; + uint16_t vid; + uint16_t pid; + uint8_t dev_class; + int speed; + uint8_t parent_hub; + uint8_t parent_port; + uint8_t cfg[FAKE_CFG_MAX]; + uint16_t cfg_len; + usb_device_desc_t ddesc; + esp_err_t open_err; + esp_err_t claim_err; + esp_err_t info_err; +} fake_dev_t; + +static fake_dev_t s_devs[FAKE_MAX_DEV]; + +static fake_dev_t *fake_find(uint8_t addr) { + for (int i = 0; i < FAKE_MAX_DEV; i++) { + if (s_devs[i].used && s_devs[i].addr == addr) { + return &s_devs[i]; + } + } + return NULL; +} + +static fake_dev_t *fake_find_hdl(usb_device_handle_t hdl) { + for (int i = 0; i < FAKE_MAX_DEV; i++) { + if (s_devs[i].used && (usb_device_handle_t)&s_devs[i] == hdl) { + return &s_devs[i]; + } + } + return NULL; +} + +static esp_err_t s_register_err = ESP_OK; +static esp_err_t s_submit_err = ESP_OK; +static bool s_autocomplete = true; +static uint8_t s_ac_data[8]; +static uint8_t s_ac_len = 0; +static usb_transfer_status_t s_ctrl_status = USB_TRANSFER_STATUS_COMPLETED; +static esp_err_t s_submit_ctrl_err = ESP_OK; +static bool s_nowedge = false; +static uint8_t s_reap_data[8]; +static uint8_t s_reap_len = 0; +static bool s_reap_armed = false; + +static usb_host_client_event_cb_t s_client_cb = NULL; +static void *s_client_cb_arg = NULL; +static int s_claim_allow = -1; + +#define FAKE_MAX_EV 16 +static usb_host_client_event_msg_t s_evs[FAKE_MAX_EV]; +static int s_nevs = 0; + +#define FAKE_MAX_PEND 16 +static usb_transfer_t *s_pend[FAKE_MAX_PEND]; +static int s_npend = 0; + +#define FAKE_MAX_CALLS 256 +static fake_call_t s_calls[FAKE_MAX_CALLS]; +static int s_ncalls = 0; + +static int s_live_xfers = 0; + +static void fake_log_call(fake_call_t c) { + if (s_ncalls < FAKE_MAX_CALLS) { + s_calls[s_ncalls++] = c; + } +} + +void fake_reset(void) { + fake_now_us = 0; + memset(s_devs, 0, sizeof(s_devs)); + s_register_err = ESP_OK; + s_submit_err = ESP_OK; + s_autocomplete = true; + s_ac_len = 0; + s_ctrl_status = USB_TRANSFER_STATUS_COMPLETED; + s_submit_ctrl_err = ESP_OK; + s_nowedge = false; + s_reap_len = 0; + s_reap_armed = false; + s_client_cb = NULL; + s_client_cb_arg = NULL; + s_claim_allow = -1; + s_nevs = 0; + s_npend = 0; + s_ncalls = 0; + s_live_xfers = 0; + s_ntasks = 0; + s_nlog = 0; +} + +void fake_advance_ms(uint32_t ms) { + fake_now_us += (int64_t)ms * 1000; +} + +void fake_plug(uint8_t addr, uint16_t vid, uint16_t pid, uint8_t dev_class, + int speed, const uint8_t *cfg_blob, uint16_t cfg_len) { + fake_dev_t *d = fake_find(addr); + if (d == NULL) { + for (int i = 0; i < FAKE_MAX_DEV; i++) { + if (!s_devs[i].used) { + d = &s_devs[i]; + break; + } + } + } + if (d == NULL) { + return; + } + memset(d, 0, sizeof(*d)); + d->used = true; + d->plugged = true; + d->addr = addr; + d->vid = vid; + d->pid = pid; + d->dev_class = dev_class; + d->speed = speed; + d->open_err = ESP_OK; + d->claim_err = ESP_OK; + d->info_err = ESP_OK; + if (cfg_len > FAKE_CFG_MAX) { + cfg_len = FAKE_CFG_MAX; + } + memcpy(d->cfg, cfg_blob, cfg_len); + d->cfg_len = cfg_len; + d->ddesc.bDeviceClass = dev_class; + d->ddesc.idVendor = vid; + d->ddesc.idProduct = pid; +} + +void fake_unplug(uint8_t addr) { + fake_dev_t *d = fake_find(addr); + if (d == NULL) { + return; + } + d->plugged = false; + if (s_nevs < FAKE_MAX_EV) { + s_evs[s_nevs].event = USB_HOST_CLIENT_EVENT_DEV_GONE; + s_evs[s_nevs].dev_gone.dev_hdl = (usb_device_handle_t)d; + s_nevs++; + } +} + +void fake_set_open_err(uint8_t addr, esp_err_t err) { + fake_dev_t *d = fake_find(addr); + if (d != NULL) { + d->open_err = err; + } +} +void fake_set_parent(uint8_t addr, uint8_t hub_addr, uint8_t port) { + fake_dev_t *d = fake_find(addr); + if (d != NULL) { + d->parent_hub = hub_addr; + d->parent_port = port; + } +} + +void fake_set_claim_err(uint8_t addr, esp_err_t err) { + fake_dev_t *d = fake_find(addr); + if (d != NULL) { + d->claim_err = err; + } +} + +void fake_set_info_err(uint8_t addr, esp_err_t err) { + fake_dev_t *d = fake_find(addr); + if (d != NULL) { + d->info_err = err; + } +} + +void fake_set_submit_err(esp_err_t err) { + s_submit_err = err; +} + +void fake_set_autocomplete(bool on) { + s_autocomplete = on; +} + +void fake_set_autocomplete_data(const uint8_t *data, uint8_t len) { + if (len > sizeof(s_ac_data)) { + len = sizeof(s_ac_data); + } + memcpy(s_ac_data, data, len); + s_ac_len = len; +} + +void fake_set_control_status(usb_transfer_status_t st) { + s_ctrl_status = st; +} + +void fake_set_submit_control_err(esp_err_t err) { + s_submit_ctrl_err = err; +} + +void fake_set_nowedge(bool on) { + s_nowedge = on; +} + +void fake_set_reap_data(const uint8_t *data, uint8_t len) { + if (len > sizeof(s_reap_data)) { + len = sizeof(s_reap_data); + } + memcpy(s_reap_data, data, len); + s_reap_len = len; + s_reap_armed = true; +} + +void fake_set_register_err(esp_err_t err) { + s_register_err = err; +} + +void fake_set_claim_allow(int n) { + s_claim_allow = n; +} + +void fake_queue_new_dev(uint8_t addr) { + if (s_nevs < FAKE_MAX_EV) { + s_evs[s_nevs].event = USB_HOST_CLIENT_EVENT_NEW_DEV; + s_evs[s_nevs].new_dev.address = addr; + s_nevs++; + } +} + +void fake_deliver_events(void) { + for (int i = 0; i < s_nevs; i++) { + if (s_client_cb != NULL) { + s_client_cb(&s_evs[i], s_client_cb_arg); + } + } + s_nevs = 0; +} + +usb_host_client_event_cb_t fake_client_cb(void) { + return s_client_cb; +} + +int fake_pending_count(void) { + return s_npend; +} + +usb_transfer_t *fake_pending(int i) { + return s_pend[i]; +} + +void fake_complete(usb_transfer_t *x, usb_transfer_status_t st, + const uint8_t *data, int len) { + for (int i = 0; i < s_npend; i++) { + if (s_pend[i] == x) { + memmove(&s_pend[i], &s_pend[i + 1], + (size_t)(s_npend - i - 1) * sizeof(s_pend[0])); + s_npend--; + break; + } + } + x->status = st; + if (data != NULL && len > 0) { + x->actual_num_bytes = len; + memcpy(x->data_buffer, data, (size_t)len); + } else { + x->actual_num_bytes = 0; + } + if (x->callback != NULL) { + x->callback(x); + } +} + +int fake_call_count(void) { + return s_ncalls; +} + +fake_call_t fake_call(int i) { + return s_calls[i]; +} + +void fake_call_clear(void) { + s_ncalls = 0; +} + +int fake_live_xfers(void) { + return s_live_xfers; +} + +esp_err_t usb_host_client_register(const usb_host_client_config_t *config, + usb_host_client_handle_t *out) { + if (s_register_err != ESP_OK) { + return s_register_err; + } + s_client_cb = config->async.client_event_callback; + s_client_cb_arg = config->async.callback_arg; + *out = (usb_host_client_handle_t)0xC11E47; + return ESP_OK; +} + +esp_err_t usb_host_client_deregister(usb_host_client_handle_t client) { + (void)client; + return ESP_OK; +} + +esp_err_t usb_host_client_unblock(usb_host_client_handle_t client) { + (void)client; + return ESP_OK; +} + +esp_err_t usb_host_client_handle_events(usb_host_client_handle_t client, + uint32_t timeout_ticks) { + (void)client; + fake_deliver_events(); + fake_now_us += (int64_t)timeout_ticks * 1000; + return ESP_OK; +} + +esp_err_t usb_host_device_open(usb_host_client_handle_t client, uint8_t addr, + usb_device_handle_t *out) { + (void)client; + fake_log_call(FC_OPEN); + fake_dev_t *d = fake_find(addr); + if (d == NULL || !d->plugged) { + return ESP_ERR_NOT_FOUND; + } + if (d->open_err != ESP_OK) { + return d->open_err; + } + *out = (usb_device_handle_t)d; + return ESP_OK; +} + +esp_err_t usb_host_device_close(usb_host_client_handle_t client, + usb_device_handle_t dev) { + (void)client; + (void)dev; + fake_log_call(FC_CLOSE); + return ESP_OK; +} + +esp_err_t usb_host_device_info(usb_device_handle_t dev, usb_device_info_t *info) { + fake_dev_t *d = fake_find_hdl(dev); + if (d == NULL) { + return ESP_ERR_NOT_FOUND; + } + if (d->info_err != ESP_OK) { + return d->info_err; + } + info->speed = (usb_speed_t)d->speed; + info->parent.port_num = d->parent_port; + fake_dev_t *hub = fake_find(d->parent_hub); + info->parent.dev_hdl = (hub != NULL) ? (usb_device_handle_t)hub : NULL; + info->dev_addr = d->addr; + return ESP_OK; +} + +esp_err_t usb_host_device_addr_list_fill(int size, uint8_t *addrs, int *n) { + int count = 0; + for (int i = 0; i < FAKE_MAX_DEV && count < size; i++) { + if (s_devs[i].used && s_devs[i].plugged) { + addrs[count++] = s_devs[i].addr; + } + } + *n = count; + return ESP_OK; +} + +esp_err_t usb_host_get_device_descriptor(usb_device_handle_t dev, + const usb_device_desc_t **out) { + fake_dev_t *d = fake_find_hdl(dev); + if (d == NULL) { + return ESP_ERR_NOT_FOUND; + } + *out = &d->ddesc; + return ESP_OK; +} + +esp_err_t usb_host_get_active_config_descriptor(usb_device_handle_t dev, + const usb_config_desc_t **out) { + fake_dev_t *d = fake_find_hdl(dev); + if (d == NULL || d->cfg_len == 0) { + return ESP_ERR_NOT_FOUND; + } + *out = (const usb_config_desc_t *)d->cfg; + return ESP_OK; +} + +esp_err_t usb_host_interface_claim(usb_host_client_handle_t client, + usb_device_handle_t dev, uint8_t iface, + int flags) { + (void)client; + (void)iface; + (void)flags; + fake_log_call(FC_CLAIM); + fake_dev_t *d = fake_find_hdl(dev); + if (d == NULL) { + return ESP_ERR_NOT_FOUND; + } + if (s_claim_allow == 0) { + return ESP_ERR_NOT_SUPPORTED; + } + if (s_claim_allow > 0) { + s_claim_allow--; + } + return d->claim_err; +} + +esp_err_t usb_host_interface_release(usb_host_client_handle_t client, + usb_device_handle_t dev, uint8_t iface) { + (void)client; + (void)dev; + (void)iface; + fake_log_call(FC_RELEASE); + return ESP_OK; +} + +esp_err_t usb_host_transfer_alloc(size_t num_bytes, int flags, + usb_transfer_t **out) { + (void)flags; + fake_log_call(FC_ALLOC); + usb_transfer_t *x = (usb_transfer_t *)calloc(1, sizeof(usb_transfer_t)); + if (x == NULL) { + return ESP_ERR_NO_MEM; + } + x->data_buffer = (uint8_t *)calloc(1, num_bytes > 8 ? num_bytes : 8); + if (x->data_buffer == NULL) { + free(x); + return ESP_ERR_NO_MEM; + } + s_live_xfers++; + *out = x; + return ESP_OK; +} + +esp_err_t usb_host_transfer_free(usb_transfer_t *xfer) { + fake_log_call(FC_FREE); + free(xfer->data_buffer); + free(xfer); + s_live_xfers--; + return ESP_OK; +} + +esp_err_t usb_host_transfer_submit(usb_transfer_t *xfer) { + fake_log_call(FC_SUBMIT); + if (s_submit_err != ESP_OK) { + return s_submit_err; + } + if (s_autocomplete) { + xfer->status = USB_TRANSFER_STATUS_COMPLETED; + xfer->actual_num_bytes = s_ac_len; + memcpy(xfer->data_buffer, s_ac_data, s_ac_len); + if (xfer->callback != NULL) { + xfer->callback(xfer); + } + return ESP_OK; + } + if (s_npend < FAKE_MAX_PEND) { + s_pend[s_npend++] = xfer; + } + return ESP_OK; +} + +esp_err_t usb_host_transfer_submit_control(usb_host_client_handle_t client, + usb_transfer_t *xfer) { + (void)client; + fake_log_call(FC_SUBMIT_CTRL); + if (s_submit_ctrl_err != ESP_OK) { + return s_submit_ctrl_err; + } + xfer->status = s_ctrl_status; + xfer->actual_num_bytes = 0; + if (xfer->callback != NULL) { + xfer->callback(xfer); + } + return ESP_OK; +} + +// Halt completes pending transfers of that endpoint inline as CANCELED, +// like the real stack (unless wedged). One armed reap-data completion +// lands COMPLETED instead (slow answer between timeout and halt). +static void fake_finish_pending(usb_device_handle_t dev, uint8_t ep) { + for (int i = s_npend - 1; i >= 0; i--) { + usb_transfer_t *x = s_pend[i]; + if (x->device_handle == dev && x->bEndpointAddress == ep) { + memmove(&s_pend[i], &s_pend[i + 1], + (size_t)(s_npend - i - 1) * sizeof(s_pend[0])); + s_npend--; + if (s_reap_armed) { + s_reap_armed = false; + x->status = USB_TRANSFER_STATUS_COMPLETED; + x->actual_num_bytes = s_reap_len; + memcpy(x->data_buffer, s_reap_data, s_reap_len); + } else { + x->status = USB_TRANSFER_STATUS_CANCELED; + x->actual_num_bytes = 0; + } + if (x->callback != NULL) { + x->callback(x); + } + } + } +} + +esp_err_t usb_host_endpoint_halt(usb_device_handle_t dev, uint8_t ep) { + fake_log_call(FC_HALT); + if (!s_nowedge) { + fake_finish_pending(dev, ep); + } + return ESP_OK; +} + +esp_err_t usb_host_endpoint_flush(usb_device_handle_t dev, uint8_t ep) { + (void)dev; + (void)ep; + fake_log_call(FC_FLUSH); + return ESP_OK; +} + +esp_err_t usb_host_endpoint_clear(usb_device_handle_t dev, uint8_t ep) { + (void)dev; + (void)ep; + fake_log_call(FC_CLEAR); + return ESP_OK; +} diff --git a/c_mpos/usb/tests/fake_usb_host.h b/c_mpos/usb/tests/fake_usb_host.h new file mode 100644 index 000000000..e6641b1ba --- /dev/null +++ b/c_mpos/usb/tests/fake_usb_host.h @@ -0,0 +1,91 @@ +// Scriptable fake IDF usb_host + FreeRTOS for host-testing the real +// src/usb_hid.c (compiled in via #include from test_hid_host.c). +// Single-threaded: tasks never run, vTaskDelay advances a fake clock, so +// tests never sleep. Interrupt-transfer completion is scripted: +// auto-complete inline, stay pending for fake_complete(), or wedge. +#ifndef FAKE_USB_HOST_H +#define FAKE_USB_HOST_H + +#include +#include +#include + +#include "esp_err.h" +#include "usb/usb_host.h" + +extern int64_t fake_now_us; + +void fake_reset(void); +void fake_advance_ms(uint32_t ms); + +// Virtual devices. cfg_blob is the raw config descriptor bytes starting +// at the config header (hid_find_boot_iface walks from cdesc). +void fake_plug(uint8_t addr, uint16_t vid, uint16_t pid, uint8_t dev_class, + int speed, const uint8_t *cfg_blob, uint16_t cfg_len); +void fake_unplug(uint8_t addr); +// Topology: which (hub addr, port) a device hangs off (for device_info +// parent resolution). Defaults to hub 0 / port 0 = matches nothing. +void fake_set_parent(uint8_t addr, uint8_t hub_addr, uint8_t port); +void fake_set_open_err(uint8_t addr, esp_err_t err); +void fake_set_claim_err(uint8_t addr, esp_err_t err); +void fake_set_info_err(uint8_t addr, esp_err_t err); +void fake_set_submit_err(esp_err_t err); +void fake_set_autocomplete(bool on); +void fake_set_autocomplete_data(const uint8_t *data, uint8_t len); +void fake_set_control_status(usb_transfer_status_t st); +void fake_set_submit_control_err(esp_err_t err); +// When true, halt/flush complete nothing (wedged pipe): reaps time out. +void fake_set_nowedge(bool on); +// One-shot: the next halt-completed transfer lands COMPLETED with this +// data (slow answer between timeout and halt: REAP_DATA path). +void fake_set_reap_data(const uint8_t *data, uint8_t len); +void fake_set_register_err(esp_err_t err); +// Global claim budget: the first n interface_claim calls succeed (subject +// to per-device errors), the rest fail with ESP_ERR_NOT_SUPPORTED. +// -1 = unlimited (default). Proves mice-before-keyboards claim order. +void fake_set_claim_allow(int n); + +// Stack events. fake_unplug() queues DEV_GONE itself; NEW_DEV is queued +// explicitly (or just call hid_scan() via pump in the test). +void fake_queue_new_dev(uint8_t addr); +void fake_deliver_events(void); +usb_host_client_event_cb_t fake_client_cb(void); + +// Created (never running) tasks, in creation order. +int fake_task_count(void); +void *fake_task_fn(int i); +const char *fake_task_name(int i); + +// Pending interrupt transfers. +int fake_pending_count(void); +usb_transfer_t *fake_pending(int i); +void fake_complete(usb_transfer_t *x, usb_transfer_status_t st, + const uint8_t *data, int len); + +// Lifecycle call log, in order (retire-order assertions). +typedef enum { + FC_HALT, + FC_FLUSH, + FC_CLEAR, + FC_FREE, + FC_RELEASE, + FC_CLOSE, + FC_CLAIM, + FC_SUBMIT, + FC_SUBMIT_CTRL, + FC_ALLOC, + FC_OPEN, +} fake_call_t; +int fake_call_count(void); +fake_call_t fake_call(int i); +void fake_call_clear(void); + +int fake_live_xfers(void); + +// usb_disp_log capture. +int fake_log_count(void); +const char *fake_log_line(int i); +bool fake_log_has(const char *substr); +void fake_log_clear(void); + +#endif diff --git a/c_mpos/usb/tests/stubs/esp_err.h b/c_mpos/usb/tests/stubs/esp_err.h new file mode 100644 index 000000000..e8fd317ee --- /dev/null +++ b/c_mpos/usb/tests/stubs/esp_err.h @@ -0,0 +1,15 @@ +// Host-test stub for IDF esp_err.h. Only what usb_hid.c uses. +#ifndef HOST_ESP_ERR_H +#define HOST_ESP_ERR_H + +typedef int esp_err_t; + +#define ESP_OK 0 +#define ESP_FAIL 0x101 +#define ESP_ERR_NO_MEM 0x102 +#define ESP_ERR_INVALID_ARG 0x103 +#define ESP_ERR_INVALID_STATE 0x104 +#define ESP_ERR_NOT_FOUND 0x105 +#define ESP_ERR_NOT_SUPPORTED 0x106 + +#endif diff --git a/c_mpos/usb/tests/stubs/esp_timer.h b/c_mpos/usb/tests/stubs/esp_timer.h new file mode 100644 index 000000000..375c206fa --- /dev/null +++ b/c_mpos/usb/tests/stubs/esp_timer.h @@ -0,0 +1,10 @@ +// Host-test stub for IDF esp_timer.h. Time is fake: vTaskDelay advances +// it, so tests never sleep. See fake_usb_host.c. +#ifndef HOST_ESP_TIMER_H +#define HOST_ESP_TIMER_H + +#include + +int64_t esp_timer_get_time(void); + +#endif diff --git a/c_mpos/usb/tests/stubs/freertos/FreeRTOS.h b/c_mpos/usb/tests/stubs/freertos/FreeRTOS.h new file mode 100644 index 000000000..a3bbea992 --- /dev/null +++ b/c_mpos/usb/tests/stubs/freertos/FreeRTOS.h @@ -0,0 +1,16 @@ +// Host-test stub for FreeRTOS FreeRTOS.h. Only what usb_hid.c uses. +#ifndef HOST_FREERTOS_H +#define HOST_FREERTOS_H + +#include + +typedef int BaseType_t; +typedef unsigned int UBaseType_t; +typedef unsigned int TickType_t; + +#define pdFALSE 0 +#define pdTRUE 1 +#define pdPASS 1 +#define pdMS_TO_TICKS(ms) ((TickType_t)(ms)) + +#endif diff --git a/c_mpos/usb/tests/stubs/freertos/semphr.h b/c_mpos/usb/tests/stubs/freertos/semphr.h new file mode 100644 index 000000000..ff796e82a --- /dev/null +++ b/c_mpos/usb/tests/stubs/freertos/semphr.h @@ -0,0 +1,17 @@ +// Host-test stub for FreeRTOS semphr.h. Single-threaded: Take succeeds +// when the count is available, otherwise advances the fake clock and +// fails. See fake_usb_host.c. +#ifndef HOST_SEMPHR_H +#define HOST_SEMPHR_H + +#include "freertos/FreeRTOS.h" + +typedef void *SemaphoreHandle_t; + +SemaphoreHandle_t xSemaphoreCreateBinary(void); +SemaphoreHandle_t xSemaphoreCreateMutex(void); +int xSemaphoreTake(SemaphoreHandle_t sem, TickType_t ticks); +int xSemaphoreGive(SemaphoreHandle_t sem); +void vSemaphoreDelete(SemaphoreHandle_t sem); + +#endif diff --git a/c_mpos/usb/tests/stubs/freertos/task.h b/c_mpos/usb/tests/stubs/freertos/task.h new file mode 100644 index 000000000..8fe04eb0e --- /dev/null +++ b/c_mpos/usb/tests/stubs/freertos/task.h @@ -0,0 +1,17 @@ +// Host-test stub for FreeRTOS task.h. Tasks never run: xTaskCreate only +// records the entry point for assertions. vTaskDelay advances the fake +// clock instead of sleeping. See fake_usb_host.c. +#ifndef HOST_TASK_H +#define HOST_TASK_H + +#include "freertos/FreeRTOS.h" + +typedef void *TaskHandle_t; +typedef void (*TaskFunction_t)(void *arg); + +int xTaskCreate(TaskFunction_t fn, const char *name, unsigned stack, + void *arg, unsigned prio, TaskHandle_t *handle); +void vTaskDelay(TickType_t ticks); +void vTaskDelete(void *task); + +#endif diff --git a/c_mpos/usb/tests/stubs/usb/usb_host.h b/c_mpos/usb/tests/stubs/usb/usb_host.h new file mode 100644 index 000000000..78947a37c --- /dev/null +++ b/c_mpos/usb/tests/stubs/usb/usb_host.h @@ -0,0 +1,133 @@ +// Host-test stub for IDF usb_host (usb/usb_host.h). Only the surface +// usb_hid.c uses. Behavior lives in fake_usb_host.c; this header only +// declares types. +#ifndef HOST_USB_HOST_H +#define HOST_USB_HOST_H + +#include +#include +#include + +#include "esp_err.h" + +typedef void *usb_host_client_handle_t; +typedef void *usb_device_handle_t; + +typedef enum { + USB_TRANSFER_STATUS_COMPLETED = 0, + USB_TRANSFER_STATUS_ERROR, + USB_TRANSFER_STATUS_TIMED_OUT, + USB_TRANSFER_STATUS_CANCELED, + USB_TRANSFER_STATUS_STALL, + USB_TRANSFER_STATUS_OVERFLOW, + USB_TRANSFER_STATUS_SKIPPED, + USB_TRANSFER_STATUS_NO_DEVICE, +} usb_transfer_status_t; + +typedef struct usb_transfer_s usb_transfer_t; +typedef void (*usb_transfer_cb_t)(usb_transfer_t *xfer); + +struct usb_transfer_s { + usb_transfer_status_t status; + int actual_num_bytes; + int num_bytes; + uint8_t *data_buffer; + usb_device_handle_t device_handle; + uint8_t bEndpointAddress; + usb_transfer_cb_t callback; + void *context; +}; + +typedef enum { + USB_HOST_CLIENT_EVENT_NEW_DEV = 0, + USB_HOST_CLIENT_EVENT_DEV_GONE, +} usb_host_client_event_t; + +typedef struct { + usb_host_client_event_t event; + union { + struct { + uint8_t address; + } new_dev; + struct { + usb_device_handle_t dev_hdl; + } dev_gone; + }; +} usb_host_client_event_msg_t; + +typedef void (*usb_host_client_event_cb_t)(const usb_host_client_event_msg_t *msg, + void *arg); + +typedef struct { + bool is_synchronous; + uint8_t max_num_event_msg; + struct { + usb_host_client_event_cb_t client_event_callback; + void *callback_arg; + } async; +} usb_host_client_config_t; + +typedef enum { + USB_SPEED_LOW = 0, + USB_SPEED_FULL, + USB_SPEED_HIGH, +} usb_speed_t; + +typedef struct { + uint8_t bLength; + uint8_t bDescriptorType; + uint8_t bDeviceClass; + uint16_t idVendor; + uint16_t idProduct; +} usb_device_desc_t; + +typedef struct { + uint8_t bLength; + uint8_t bDescriptorType; + uint16_t wTotalLength; +} usb_config_desc_t; + +// Mirrors the real IDF layout (names must match: production code reads +// parent.port_num / parent.dev_hdl / dev_addr for the HID port skip). +typedef struct { + usb_device_handle_t dev_hdl; + uint8_t port_num; +} usb_parent_dev_info_t; + +typedef struct { + usb_parent_dev_info_t parent; + usb_speed_t speed; + uint8_t dev_addr; +} usb_device_info_t; + +esp_err_t usb_host_client_register(const usb_host_client_config_t *config, + usb_host_client_handle_t *out); +esp_err_t usb_host_client_deregister(usb_host_client_handle_t client); +esp_err_t usb_host_client_unblock(usb_host_client_handle_t client); +esp_err_t usb_host_client_handle_events(usb_host_client_handle_t client, + uint32_t timeout_ticks); +esp_err_t usb_host_device_open(usb_host_client_handle_t client, uint8_t addr, + usb_device_handle_t *out); +esp_err_t usb_host_device_close(usb_host_client_handle_t client, + usb_device_handle_t dev); +esp_err_t usb_host_device_info(usb_device_handle_t dev, usb_device_info_t *info); +esp_err_t usb_host_device_addr_list_fill(int size, uint8_t *addrs, int *n); +esp_err_t usb_host_get_device_descriptor(usb_device_handle_t dev, + const usb_device_desc_t **out); +esp_err_t usb_host_get_active_config_descriptor(usb_device_handle_t dev, + const usb_config_desc_t **out); +esp_err_t usb_host_interface_claim(usb_host_client_handle_t client, + usb_device_handle_t dev, uint8_t iface, + int flags); +esp_err_t usb_host_interface_release(usb_host_client_handle_t client, + usb_device_handle_t dev, uint8_t iface); +esp_err_t usb_host_transfer_alloc(size_t num_bytes, int flags, usb_transfer_t **out); +esp_err_t usb_host_transfer_free(usb_transfer_t *xfer); +esp_err_t usb_host_transfer_submit(usb_transfer_t *xfer); +esp_err_t usb_host_transfer_submit_control(usb_host_client_handle_t client, + usb_transfer_t *xfer); +esp_err_t usb_host_endpoint_halt(usb_device_handle_t dev, uint8_t ep); +esp_err_t usb_host_endpoint_flush(usb_device_handle_t dev, uint8_t ep); +esp_err_t usb_host_endpoint_clear(usb_device_handle_t dev, uint8_t ep); + +#endif diff --git a/c_mpos/usb/tests/test_hid_host.c b/c_mpos/usb/tests/test_hid_host.c new file mode 100644 index 000000000..5a6d57e34 --- /dev/null +++ b/c_mpos/usb/tests/test_hid_host.c @@ -0,0 +1,783 @@ +// Host tests for the real ../src/usb_hid.c (compiled in below): claim +// policy, park/backoff, topology re-arm, retire ordering, kbd tick, +// accessors. Single-threaded against fake_usb_host.c: tasks never run, +// the fake clock jumps instead of sleeping. +// +// Build: make -C c_mpos/usb/tests (or `make usb-host-tests` at repo root) +// Run: ./c_mpos/usb/tests/test_hid_host +#include +#include +#include + +#include "fake_usb_host.h" + +#include "../src/usb_hid.c" + +static int s_checks = 0; +static int s_fails = 0; + +#define CHECK(cond) \ + do { \ + s_checks++; \ + if (!(cond)) { \ + s_fails++; \ + printf("FAIL %d: %s\n", __LINE__, #cond); \ + } \ + } while (0) + +// Reset production statics (visible: same TU via #include) + fake. +// Autocomplete defaults OFF: with it on, setup-time submits complete +// inline while the slot is already STREAMING, and the completion callback +// resubmits synchronously forever (stack overflow). Production never does +// this (completions are async). Cases opt into autocomplete explicitly. +static void test_reset(void) { + fake_reset(); + fake_set_autocomplete(false); + memset(s_slots, 0, sizeof(s_slots)); + memset(s_ring, 0, sizeof(s_ring)); + s_ring_head = 0; + s_ring_tail = 0; + s_dropped = 0; + s_hid_client = NULL; + s_hid_started = false; + s_scan_needed = false; + s_hid_ctrl_mutex = NULL; + s_hid_ctrl_done = NULL; + s_kbd_done = NULL; + s_kbd_poll_task = NULL; + s_kbd_tick_active = false; + s_change_gen = 0; + memset(s_defer, 0, sizeof(s_defer)); + memset(s_topo_addrs, 0, sizeof(s_topo_addrs)); + s_topo_n = -1; + s_client_last_pump_ms = 0; + s_kbd_total_polls = 0; + s_kbd_total_ch = 0; + s_kbd_transient = false; + s_hid_verbose = false; + s_client_stop = false; + s_kbd_stop = false; + s_client_task_done = false; + s_kbd_task_done = false; +} + +// Mirror the client-task loop body: deliver events, scan, per-slot work. +static void pump_client(void) { + fake_deliver_events(); + if (s_scan_needed) { + s_scan_needed = false; + hid_scan(); + } + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + hid_client_task_slot(&s_slots[i]); + } +} + +// Raw config descriptor bytes: config header + one boot-HID iface + one IN EP. +static int build_hid_cfg(uint8_t *b, uint8_t iface, uint8_t sub, uint8_t proto, + uint8_t ep, uint16_t mps, uint8_t iv) { + uint8_t *p = b; + *p++ = 9; + *p++ = 2; + *p++ = 25; + *p++ = 0; + *p++ = 1; + *p++ = 1; + *p++ = 0; + *p++ = 0x80; + *p++ = 50; + *p++ = 9; + *p++ = 4; + *p++ = iface; + *p++ = 0; + *p++ = 1; + *p++ = 0x03; + *p++ = sub; + *p++ = proto; + *p++ = 0; + *p++ = 7; + *p++ = 5; + *p++ = ep; + *p++ = 0x03; + *p++ = (uint8_t)mps; + *p++ = (uint8_t)(mps >> 8); + *p++ = iv; + return (int)(p - b); +} + +static void plug_mouse(uint8_t addr) { + uint8_t cfg[32]; + int len = build_hid_cfg(cfg, 0, 1, 2, 0x81, 8, 10); + fake_plug(addr, 0x17EF, 0x608D, 0x00, USB_SPEED_LOW, cfg, (uint16_t)len); +} + +static void plug_keyboard(uint8_t addr) { + uint8_t cfg[32]; + int len = build_hid_cfg(cfg, 0, 1, 1, 0x81, 8, 10); + fake_plug(addr, 0x046D, 0xC31C, 0x00, USB_SPEED_LOW, cfg, (uint16_t)len); +} + +static void plug_hub(uint8_t addr) { + fake_plug(addr, 0x1A40, 0x0101, 0x09, USB_SPEED_HIGH, NULL, 0); +} + +static hid_slot_t *find_slot(uint8_t addr) { + for (uint8_t i = 0; i < USB_HID_MAX_DEV; i++) { + if (s_slots[i].state != HID_SLOT_EMPTY && s_slots[i].addr == addr) { + return &s_slots[i]; + } + } + return NULL; +} + +// Stage + set up one mouse and one keyboard; both streaming on return. +static void bringup_mouse_kbd(void) { + CHECK(usb_hid_start()); + plug_mouse(5); + plug_keyboard(6); + fake_queue_new_dev(5); + fake_queue_new_dev(6); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(find_slot(5) != NULL && find_slot(5)->state == HID_SLOT_STREAMING); + CHECK(find_slot(6) != NULL && find_slot(6)->state == HID_SLOT_STREAMING); +} + +static void case_start(void) { + test_reset(); + CHECK(!usb_hid_poll()); + fake_set_register_err(ESP_ERR_INVALID_STATE); + CHECK(!usb_hid_start()); + test_reset(); + CHECK(usb_hid_start()); + CHECK(fake_client_cb() != NULL); + CHECK(fake_task_count() == 1); + CHECK(fake_log_has("client started")); + CHECK(usb_hid_start()); + CHECK(fake_task_count() == 1); +} + +static void case_desc_walk(void) { + test_reset(); + uint8_t cfg[32]; + uint8_t iface = 0, sub = 0, proto = 0, ep = 0, iv = 0; + uint16_t mps = 0; + int len = build_hid_cfg(cfg, 0, 1, 2, 0x81, 8, 10); + CHECK(hid_find_boot_iface(cfg, (uint16_t)len, &iface, &sub, &proto, &ep, &mps, &iv)); + CHECK(iface == 0 && sub == 1 && proto == 2 && ep == 0x81 && mps == 8); + CHECK(iv == 50); + len = build_hid_cfg(cfg, 0, 1, 1, 0x81, 8, 10); + CHECK(hid_find_boot_iface(cfg, (uint16_t)len, &iface, &sub, &proto, &ep, &mps, &iv)); + CHECK(proto == 1); + len = build_hid_cfg(cfg, 0, 1, 2, 0x81, 8, 200); + CHECK(hid_find_boot_iface(cfg, (uint16_t)len, &iface, &sub, &proto, &ep, &mps, &iv)); + CHECK(iv == 100); + len = build_hid_cfg(cfg, 0, 1, 2, 0x81, 8, 60); + CHECK(hid_find_boot_iface(cfg, (uint16_t)len, &iface, &sub, &proto, &ep, &mps, &iv)); + CHECK(iv == 60); + len = build_hid_cfg(cfg, 0, 1, 2, 0x81, 0, 10); + CHECK(hid_find_boot_iface(cfg, (uint16_t)len, &iface, &sub, &proto, &ep, &mps, &iv)); + CHECK(mps == 64); + len = build_hid_cfg(cfg, 0, 1, 2, 0x81, 1000, 10); + CHECK(hid_find_boot_iface(cfg, (uint16_t)len, &iface, &sub, &proto, &ep, &mps, &iv)); + CHECK(mps == 64); + // Mass-storage iface (class 0x08): not boot HID. + uint8_t ms[] = {9, 2, 16, 0, 1, 1, 0, 0x80, 50, 9, 4, 0, 0, 1, 0x08, + 0x06, 0x50, 0}; + CHECK(!hid_find_boot_iface(ms, sizeof(ms), &iface, &sub, &proto, &ep, &mps, &iv)); + // Boot subclass, proto 0 (none): rejected. + len = build_hid_cfg(cfg, 0, 1, 0, 0x81, 8, 10); + CHECK(!hid_find_boot_iface(cfg, (uint16_t)len, &iface, &sub, &proto, &ep, &mps, &iv)); + // OUT endpoint only: rejected. + len = build_hid_cfg(cfg, 0, 1, 2, 0x01, 8, 10); + CHECK(!hid_find_boot_iface(cfg, (uint16_t)len, &iface, &sub, &proto, &ep, &mps, &iv)); + // Truncated blob: rejected, never over-reads. + len = build_hid_cfg(cfg, 0, 1, 2, 0x81, 8, 10); + CHECK(!hid_find_boot_iface(cfg, 10, &iface, &sub, &proto, &ep, &mps, &iv)); +} + +static void case_scan(void) { + test_reset(); + CHECK(usb_hid_start()); + plug_hub(1); + plug_mouse(2); + uint8_t cfg[32]; + int len = build_hid_cfg(cfg, 0, 0xFF, 0xFF, 0x81, 8, 10); + fake_plug(3, 0x1234, 0x5678, 0x00, USB_SPEED_FULL, cfg, (uint16_t)len); + fake_plug(4, 0x1234, 0x5679, 0x00, USB_SPEED_FULL, NULL, 0); + fake_queue_new_dev(1); + fake_queue_new_dev(2); + fake_queue_new_dev(3); + fake_queue_new_dev(4); + pump_client(); + CHECK(find_slot(2) != NULL && find_slot(2)->state == HID_SLOT_STAGED); + CHECK(find_slot(1) == NULL); + CHECK(find_slot(3) == NULL); + CHECK(find_slot(4) == NULL); + hid_slot_t *m = find_slot(2); + CHECK(m->vid == 0x17EF && m->pid == 0x608D); + CHECK(m->protocol == 2 && m->ep_in == 0x81 && m->mps == 8); + CHECK(m->speed == USB_SPEED_LOW && m->interval_ms == 50); + // Open failure: skipped with a line, never staged. + test_reset(); + CHECK(usb_hid_start()); + plug_mouse(7); + fake_set_open_err(7, ESP_ERR_INVALID_STATE); + fake_queue_new_dev(7); + pump_client(); + CHECK(find_slot(7) == NULL); + CHECK(fake_log_has("device_open failed")); + // Rescan picks it up once the error clears (next NEW_DEV). + fake_set_open_err(7, ESP_OK); + fake_queue_new_dev(7); + pump_client(); + CHECK(find_slot(7) != NULL); + // No free slot: extra device ignored. + test_reset(); + CHECK(usb_hid_start()); + for (uint8_t a = 10; a < 10 + USB_HID_MAX_DEV; a++) { + plug_mouse(a); + fake_queue_new_dev(a); + } + pump_client(); + plug_mouse(30); + fake_queue_new_dev(30); + pump_client(); + CHECK(find_slot(30) == NULL); +} + +static void case_setup_order(void) { + test_reset(); + CHECK(usb_hid_start()); + // One global claim: the mouse must win it over the keyboard. + fake_set_claim_allow(1); + plug_mouse(5); + plug_keyboard(6); + fake_queue_new_dev(5); + fake_queue_new_dev(6); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(find_slot(5) != NULL && find_slot(5)->state == HID_SLOT_STREAMING); + hid_slot_t *k = find_slot(6); + CHECK(k == NULL); + usb_hid_parked_t p[4]; + CHECK(usb_hid_parked(p, 4) == 1); + CHECK(p[0].vid == 0x046D && p[0].fails == 255); + CHECK(fake_log_has("parked: no HCD channels")); + // SET_PROTOCOL + SET_IDLE ran for both setups (2 control submits each; + // the keyboard runs them before failing at claim). + int ctrls = 0; + for (int i = 0; i < fake_call_count(); i++) { + ctrls += (fake_call(i) == FC_SUBMIT_CTRL); + } + CHECK(ctrls == 4); + CHECK(fake_log_has("streaming")); + // hid_state / claimed_addrs reflect reality. + usb_hid_state_t st[4]; + CHECK(usb_hid_state(st, 4) == 1); + CHECK(st[0].addr == 5 && st[0].vid == 0x17EF); + uint8_t addrs[8]; + CHECK(usb_hid_claimed_addrs(addrs, 8) == 1 && addrs[0] == 5); + // Retry re-arms the parked keyboard once channels free up (retry clears + // the defer table; the client-task rescan re-stages; poll sets up). + fake_set_claim_allow(-1); + fake_set_claim_err(6, ESP_OK); + usb_hid_retry(); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(find_slot(6) != NULL && find_slot(6)->state == HID_SLOT_STREAMING); + CHECK(usb_hid_state(st, 4) == 2); +} + +static void case_backoff(void) { + test_reset(); + CHECK(usb_hid_start()); + plug_keyboard(6); + fake_set_claim_err(6, ESP_FAIL); + fake_queue_new_dev(6); + pump_client(); + CHECK(usb_hid_poll()); + usb_hid_parked_t p[4]; + CHECK(usb_hid_parked(p, 4) == 1 && p[0].fails == 1); + int claims = 0; + for (int i = 0; i < fake_call_count(); i++) { + claims += (fake_call(i) == FC_CLAIM); + } + CHECK(claims == 1); + // Teardown requests a rescan, but the retry only runs once the client + // task re-stages the slot: every poll below is preceded by a pump, + // mirroring the production task loop. A retry tears down (gen change, + // poll true); a skip changes nothing (poll false). + // Before the 4s due: silent skip, no new claim. + pump_client(); + CHECK(!usb_hid_poll()); + claims = 0; + for (int i = 0; i < fake_call_count(); i++) { + claims += (fake_call(i) == FC_CLAIM); + } + CHECK(claims == 1); + fake_advance_ms(3999); + pump_client(); + CHECK(!usb_hid_poll()); + claims = 0; + for (int i = 0; i < fake_call_count(); i++) { + claims += (fake_call(i) == FC_CLAIM); + } + CHECK(claims == 1); + // Due: second failure, 12s backoff. + fake_advance_ms(1); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(usb_hid_parked(p, 4) == 1 && p[0].fails == 2); + fake_advance_ms(12000); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(usb_hid_parked(p, 4) == 1 && p[0].fails == 3); + // Fourth failure parks until replug/retry (fails sticks at 4 with the + // parked flag set; only the no-channels path uses fails=255). + fake_advance_ms(28000); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(usb_hid_parked(p, 4) == 1 && p[0].fails == 4); + CHECK(fake_log_has("giving up")); + claims = 0; + for (int i = 0; i < fake_call_count(); i++) { + claims += (fake_call(i) == FC_CLAIM); + } + fake_advance_ms(60000); + pump_client(); + CHECK(!usb_hid_poll()); + int claims2 = 0; + for (int i = 0; i < fake_call_count(); i++) { + claims2 += (fake_call(i) == FC_CLAIM); + } + CHECK(claims2 == claims); +} + +static void case_unplug_clears(void) { + test_reset(); + CHECK(usb_hid_start()); + plug_keyboard(6); + fake_set_claim_err(6, ESP_ERR_NOT_SUPPORTED); + fake_queue_new_dev(6); + pump_client(); + CHECK(usb_hid_poll()); + usb_hid_parked_t p[4]; + CHECK(usb_hid_parked(p, 4) == 1); + // Physical unplug: DEV_GONE + topology change clears history. Nothing + // changes during the poll itself (the teardown already ran in pump). + fake_unplug(6); + pump_client(); + CHECK(!usb_hid_poll()); + CHECK(usb_hid_parked(p, 4) == 0); + // Replug starts fresh and streams. + fake_set_claim_err(6, ESP_OK); + plug_keyboard(6); + fake_queue_new_dev(6); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(find_slot(6) != NULL && find_slot(6)->state == HID_SLOT_STREAMING); + CHECK(usb_hid_parked(p, 4) == 0); +} + +static void case_topo_rearm(void) { + test_reset(); + CHECK(usb_hid_start()); + plug_keyboard(6); + fake_set_claim_err(6, ESP_ERR_NOT_SUPPORTED); + fake_queue_new_dev(6); + pump_client(); + CHECK(usb_hid_poll()); + usb_hid_parked_t p[4]; + CHECK(usb_hid_parked(p, 4) == 1); + // An unrelated plug changes the topology: parked retries re-arm. + fake_set_claim_err(6, ESP_OK); + plug_mouse(7); + fake_queue_new_dev(7); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(usb_hid_parked(p, 4) == 0); + CHECK(find_slot(6) != NULL && find_slot(6)->state == HID_SLOT_STREAMING); + CHECK(find_slot(7) != NULL && find_slot(7)->state == HID_SLOT_STREAMING); + CHECK(fake_log_has("re-armed")); +} + +// Index of call c at/after position from; -1 if absent. +static int call_after(fake_call_t c, int from) { + for (int i = from; i < fake_call_count(); i++) { + if (fake_call(i) == c) { + return i; + } + } + return -1; +} + +static void case_retire_fast(void) { + test_reset(); + bringup_mouse_kbd(); + hid_slot_t *m = find_slot(5); + CHECK(m != NULL); + // Precondition for the wart: standing URBs in flight, never completing + // (mouse + keyboard, 2 each). + CHECK(fake_pending_count() == 2 * USB_HID_XFER_PER_DEV); + CHECK(m->inflight == USB_HID_XFER_PER_DEV); + fake_call_clear(); + fake_log_clear(); + m->retire = true; + int64_t t0 = fake_now_us; + CHECK(usb_hid_poll()); + CHECK(find_slot(5) == NULL); + int halt = call_after(FC_HALT, 0); + int flush = call_after(FC_FLUSH, 0); + int clear = call_after(FC_CLEAR, 0); + int release = call_after(FC_RELEASE, 0); + int close = call_after(FC_CLOSE, 0); + CHECK(halt >= 0 && flush > halt && clear > flush); + CHECK(release > clear && close > release); + CHECK(fake_log_has("retired")); + CHECK(!fake_log_has("forced")); + // The wart fix: halt-first reaps promptly instead of burning 3000ms. + CHECK(fake_now_us - t0 <= 100 * 1000); + // Only the retired mouse was freed; the keyboard still streams. + CHECK(fake_live_xfers() == USB_HID_XFER_PER_DEV); + // Keyboard untouched. + CHECK(find_slot(6) != NULL && find_slot(6)->state == HID_SLOT_STREAMING); +} + +static void case_retire_forced(void) { + test_reset(); + CHECK(usb_hid_start()); + fake_set_autocomplete(false); + plug_mouse(5); + fake_queue_new_dev(5); + pump_client(); + CHECK(usb_hid_poll()); + hid_slot_t *m = find_slot(5); + CHECK(m != NULL && m->state == HID_SLOT_STREAMING); + CHECK(fake_pending_count() == USB_HID_XFER_PER_DEV); + // Wedged pipe: halt completes nothing, so the bound must burn. + fake_set_nowedge(true); + fake_call_clear(); + fake_log_clear(); + m->retire = true; + int64_t t0 = fake_now_us; + CHECK(usb_hid_poll()); + CHECK(find_slot(5) == NULL); + CHECK(fake_log_has("forced")); + CHECK(fake_now_us - t0 == (3000 + 50) * 1000); + CHECK(fake_live_xfers() == 0); +} + +static void case_ownership(void) { + test_reset(); + bringup_mouse_kbd(); + hid_slot_t *m = find_slot(5); + m->retire = true; + fake_call_clear(); + // The client task must not touch retiring slots (no double teardown). + pump_client(); + CHECK(find_slot(5) != NULL && find_slot(5)->state == HID_SLOT_STREAMING); + CHECK(call_after(FC_CLOSE, 0) < 0); + // The app-thread poll owns them. + CHECK(usb_hid_poll()); + CHECK(find_slot(5) == NULL); + // Toggle flip retires live keyboards only, never the mouse. + usb_hid_set_kbd_transient(true); + hid_slot_t *k = find_slot(6); + m = find_slot(5); + CHECK(m == NULL); // torn down above; re-check via fresh bringup below + (void)k; + test_reset(); + bringup_mouse_kbd(); + usb_hid_set_kbd_transient(true); + m = find_slot(5); + k = find_slot(6); + CHECK(m != NULL && !m->retire); + CHECK(k != NULL && k->retire); +} + +static void case_toggle(void) { + test_reset(); + bringup_mouse_kbd(); + CHECK(!usb_hid_kbd_transient()); + usb_hid_set_kbd_transient(true); + CHECK(usb_hid_kbd_transient()); + CHECK(usb_hid_poll()); + pump_client(); + CHECK(usb_hid_poll()); + hid_slot_t *k = find_slot(6); + CHECK(k != NULL && k->state == HID_SLOT_POLLED); + CHECK(fake_log_has("polled every")); + bool kbd_task = false; + for (int i = 0; i < fake_task_count(); i++) { + kbd_task |= (fake_task_fn(i) != NULL); + } + CHECK(kbd_task && fake_task_count() == 2); + usb_hid_poll_stat_t ps[4]; + CHECK(usb_hid_poll_stats(ps, 4) == 1 && ps[0].addr == 6); + // Flip back: re-stage as persistent streaming. + usb_hid_set_kbd_transient(false); + CHECK(usb_hid_poll()); + pump_client(); + CHECK(usb_hid_poll()); + k = find_slot(6); + CHECK(k != NULL && k->state == HID_SLOT_STREAMING); + // Same value twice: no-op, no new log line. + int nlog = fake_log_count(); + usb_hid_set_kbd_transient(false); + CHECK(fake_log_count() == nlog); +} + +static void case_tick(void) { + test_reset(); + bringup_mouse_kbd(); + usb_hid_set_kbd_transient(true); + CHECK(usb_hid_poll()); + pump_client(); + CHECK(usb_hid_poll()); + hid_slot_t *k = find_slot(6); + CHECK(k != NULL && k->state == HID_SLOT_POLLED); + uint32_t now = (uint32_t)(fake_now_us / 1000); + // Not due yet: silent. + fake_call_clear(); + hid_kbd_tick(now); + CHECK(call_after(FC_CLAIM, 0) < 0); + // Due with data: report lands in the ring. + fake_advance_ms(50); + now = (uint32_t)(fake_now_us / 1000); + fake_set_autocomplete(true); + uint8_t keys[] = {0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00}; + fake_set_autocomplete_data(keys, sizeof(keys)); + hid_kbd_tick(now); + usb_hid_event_t ev[4]; + CHECK(usb_hid_drain(ev, 4) == 1); + CHECK(ev[0].addr == 6 && ev[0].protocol == 1 && ev[0].len == 8); + CHECK(ev[0].data[2] == 0x04); + usb_hid_poll_stat_t ps[4]; + CHECK(usb_hid_poll_stats(ps, 4) == 1 && ps[0].polls == 1); + // Idle timeout: neutral (no failure), reap clean. Only the 30ms wait + // burns; the halt completes inline so the 50ms reap Take succeeds. + fake_set_autocomplete(false); + fake_advance_ms(50); + now = (uint32_t)(fake_now_us / 1000); + int64_t t0 = fake_now_us; + hid_kbd_tick(now); + CHECK(usb_hid_drain(ev, 4) == 0); + CHECK(usb_hid_poll_stats(ps, 4) == 1 && ps[0].polls == 2); + CHECK(fake_now_us - t0 == 30 * 1000); + k = find_slot(6); + CHECK(k != NULL && k->state == HID_SLOT_POLLED); + // Slow answer between timeout and halt: kept, not dropped. + fake_advance_ms(50); + now = (uint32_t)(fake_now_us / 1000); + fake_set_reap_data(keys, sizeof(keys)); + hid_kbd_tick(now); + CHECK(usb_hid_drain(ev, 4) == 1 && ev[0].data[2] == 0x04); + // 50 consecutive channel failures park the keyboard. + fake_set_autocomplete(true); + fake_set_claim_err(6, ESP_ERR_NOT_SUPPORTED); + for (int i = 0; i < 50; i++) { + fake_advance_ms(50); + hid_kbd_tick((uint32_t)(fake_now_us / 1000)); + } + CHECK(find_slot(6) == NULL); + CHECK(usb_hid_poll_stats(ps, 4) == 0); + usb_hid_parked_t p[4]; + CHECK(usb_hid_parked(p, 4) == 1 && p[0].fails == 255); + CHECK(fake_log_has("parking: no channels")); + // Other failures feed the defer table instead (fails=1, not parked). + test_reset(); + bringup_mouse_kbd(); + usb_hid_set_kbd_transient(true); + CHECK(usb_hid_poll()); + pump_client(); + CHECK(usb_hid_poll()); + fake_set_claim_err(6, ESP_FAIL); + fake_advance_ms(50); + hid_kbd_tick((uint32_t)(fake_now_us / 1000)); + CHECK(find_slot(6) == NULL); + CHECK(usb_hid_parked(p, 4) == 1 && p[0].fails == 1); + // Gone device: teardown clears history. + test_reset(); + bringup_mouse_kbd(); + usb_hid_set_kbd_transient(true); + CHECK(usb_hid_poll()); + pump_client(); + CHECK(usb_hid_poll()); + fake_unplug(6); + fake_deliver_events(); + k = find_slot(6); + CHECK(k != NULL && k->gone); + hid_kbd_tick((uint32_t)(fake_now_us / 1000)); + CHECK(find_slot(6) == NULL); + CHECK(usb_hid_parked(p, 4) == 0); +} + +static void case_accessors(void) { + test_reset(); + CHECK(usb_hid_drain(NULL, 0) == 0); + usb_hid_state_t st[4]; + CHECK(usb_hid_state(NULL, 0) == 0); + uint8_t addrs[8]; + CHECK(usb_hid_claimed_addrs(NULL, 0) == 0); + usb_hid_parked_t p[4]; + CHECK(usb_hid_parked(NULL, 0) == 0); + usb_hid_poll_stat_t ps[4]; + CHECK(usb_hid_poll_stats(NULL, 0) == 0); + (void)st; + (void)addrs; + (void)p; + (void)ps; + // Ring overflow: 70 reports into a 64-slot ring (63 usable: head+1 == + // tail means full) drops 7, loudly, once. + bringup_mouse_kbd(); + usb_hid_set_kbd_transient(true); + CHECK(usb_hid_poll()); + pump_client(); + CHECK(usb_hid_poll()); + fake_set_autocomplete(true); + uint8_t keys[] = {0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00}; + fake_set_autocomplete_data(keys, sizeof(keys)); + for (int i = 0; i < 70; i++) { + fake_advance_ms(50); + hid_kbd_tick((uint32_t)(fake_now_us / 1000)); + } + usb_hid_event_t ev[64]; + CHECK(usb_hid_drain(ev, 64) == 63); + CHECK(usb_hid_drain(ev, 64) == 0); + CHECK(fake_log_has("dropped 7")); + // Second drain: counter reset, no repeat. + fake_log_clear(); + CHECK(usb_hid_drain(ev, 64) == 0); + CHECK(!fake_log_has("dropped")); +} + +static void case_owns_idle_port(void) { test_reset(); + CHECK(usb_hid_start()); + plug_hub(1); + plug_mouse(2); + plug_keyboard(3); + fake_set_parent(2, 1, 1); + fake_set_parent(3, 1, 4); + fake_queue_new_dev(1); + fake_queue_new_dev(2); + fake_queue_new_dev(3); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(find_slot(2) != NULL && find_slot(2)->state == HID_SLOT_STREAMING); + CHECK(find_slot(3) != NULL && find_slot(3)->state == HID_SLOT_STREAMING); + CHECK(usb_hid_owns_idle_port(1, 1)); + CHECK(usb_hid_owns_idle_port(1, 4)); + CHECK(!usb_hid_owns_idle_port(1, 2)); + CHECK(!usb_hid_owns_idle_port(1, 3)); + CHECK(!usb_hid_owns_idle_port(9, 1)); + CHECK(!usb_hid_owns_idle_port(1, 0)); + // Unreadable device info fails safe (no match, never a wrong match). + fake_set_info_err(2, ESP_FAIL); + CHECK(!usb_hid_owns_idle_port(1, 1)); + CHECK(usb_hid_owns_idle_port(1, 4)); + fake_set_info_err(2, ESP_OK); + CHECK(usb_hid_owns_idle_port(1, 1)); + // A staged (not yet set up) slot already counts: open handle, ours. + test_reset(); + CHECK(usb_hid_start()); + plug_hub(1); + plug_mouse(2); + fake_set_parent(2, 1, 1); + fake_queue_new_dev(1); + fake_queue_new_dev(2); + pump_client(); + CHECK(find_slot(2) != NULL && find_slot(2)->state == HID_SLOT_STAGED); + CHECK(usb_hid_owns_idle_port(1, 1)); + // Torn-down slots stop matching once the handle closes. + fake_unplug(2); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(find_slot(2) == NULL); + CHECK(!usb_hid_owns_idle_port(1, 1)); +} + +static void case_lifecycle(void) { + test_reset(); + bringup_mouse_kbd(); + uint32_t gen = usb_hid_change_gen(); + // Stop: everything quiesces without waiting, state resets. Tasks never + // run in the harness, so mark them exited (the join would time out). + s_client_task_done = true; + int64_t t0 = fake_now_us; + usb_hid_stop(); + CHECK(fake_now_us - t0 < 1000 * 1000); + CHECK(usb_hid_change_gen() != gen); + CHECK(!usb_hid_poll()); + uint8_t addrs[8]; + CHECK(usb_hid_claimed_addrs(addrs, 8) == 0); + usb_hid_state_t st[4]; + CHECK(usb_hid_state(st, 4) == 0); + CHECK(find_slot(5) == NULL && find_slot(6) == NULL); + CHECK(fake_live_xfers() == 0); + // Stop is idempotent. + usb_hid_stop(); + CHECK(!usb_hid_poll()); + // Restart works: semaphores recreated, setup runs (hid_ctrl needs them). + CHECK(usb_hid_start()); + plug_mouse(5); + plug_keyboard(6); + fake_queue_new_dev(5); + fake_queue_new_dev(6); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(find_slot(5) != NULL && find_slot(5)->state == HID_SLOT_STREAMING); + CHECK(find_slot(6) != NULL && find_slot(6)->state == HID_SLOT_STREAMING); + s_client_task_done = true; + usb_hid_stop(); + CHECK(find_slot(5) == NULL && find_slot(6) == NULL); +} + +static void case_stop_timeout(void) { + // A task that never exits (missed flag window) must not wedge the + // stop: bounded join, loud warn, teardown proceeds. + test_reset(); + bringup_mouse_kbd(); + fake_log_clear(); + usb_hid_stop(); + CHECK(fake_log_has("join timed out")); + CHECK(find_slot(5) == NULL && find_slot(6) == NULL); + CHECK(fake_live_xfers() == 0); +} + +static void case_lag_gen(void) { test_reset(); + CHECK(usb_hid_start()); + uint32_t a = usb_hid_loop_lag_ms(); + fake_advance_ms(500); + CHECK(usb_hid_loop_lag_ms() == a + 500); + uint32_t gen = usb_hid_change_gen(); + CHECK(!usb_hid_poll()); + CHECK(usb_hid_change_gen() == gen); + plug_mouse(5); + fake_queue_new_dev(5); + pump_client(); + CHECK(usb_hid_poll()); + CHECK(usb_hid_change_gen() != gen); +} + +int main(void) { + case_start(); + case_desc_walk(); + case_scan(); + case_setup_order(); + case_backoff(); + case_unplug_clears(); + case_topo_rearm(); + case_retire_fast(); + case_retire_forced(); + case_ownership(); + case_toggle(); + case_tick(); + case_accessors(); + case_owns_idle_port(); + case_lifecycle(); + case_stop_timeout(); + case_lag_gen(); + printf("hid-host: %d checks, %d failures\n", s_checks, s_fails); + return s_fails != 0; +} diff --git a/c_mpos/usb/upstream/LICENSE.Pico_USB_Disp b/c_mpos/usb/upstream/LICENSE.Pico_USB_Disp new file mode 100644 index 000000000..09845e00c --- /dev/null +++ b/c_mpos/usb/upstream/LICENSE.Pico_USB_Disp @@ -0,0 +1,32 @@ +MIT License + +Copyright (c) 2026 Hideto Kikuchi / PJラボ (@pcjpnet) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Third-party notices: + +* The PIO USB programs (src/pio/usb_tx.pio, src/pio/usb_rx.pio) and the + NRZI/bit-stuffing encoder algorithm are derived from Pico-PIO-USB + (https://github.com/sekigon-gonnoc/Pico-PIO-USB), + Copyright (c) 2021 sekigon-gonnoc, MIT License. + + diff --git a/c_mpos/usb/upstream/VERSION b/c_mpos/usb/upstream/VERSION new file mode 100644 index 000000000..470b2d5ad --- /dev/null +++ b/c_mpos/usb/upstream/VERSION @@ -0,0 +1,3 @@ +Pico_USB_Disp v1.0.0 (MIT, https://github.com/htlabnet/Pico_USB_Disp) +Upstream commit 3d76208f8f191a74c88f63ee4db533479b07511f (2026-07-27) +Vendored 2026-09-09. Only the ESP32-relevant sources are copied; see ../README. diff --git a/c_mpos/usb/upstream/usb_disp.cpp b/c_mpos/usb/upstream/usb_disp.cpp new file mode 100644 index 000000000..0a42ec85b --- /dev/null +++ b/c_mpos/usb/upstream/usb_disp.cpp @@ -0,0 +1,1277 @@ +// +// ###################################################################### +// +// usb_disp - USB Display Driver Core +// +// プロトコル非依存のコア: ライフサイクル・接続ステート・モード管理 +// (内蔵タイミングテーブル / CVT-RB / EDID パース)・クリッピング・ +// シャドウFB差分更新。チップ別プロトコルは usb_disp_prot_*.cpp: +// +// usb_disp_prot_dl-1xx.cpp : DisplayLink DL-1x0/1x5 +// usb_disp_prot_t6.cpp : MCT Trigger 6 +// usb_disp_prot_ms91xx.cpp : MacroSilicon MS912x/MS913x +// +// Copyright (C) 2026 +// Hideto Kikuchi / PJラボ (@pcjpnet) - https://pc-jp.net/ +// +// ###################################################################### +// + +#include +#include +#include + +#if defined(ARDUINO) +#include // 既定ログの Serial 出力用 +#endif + +#include "usb_disp.h" +#include "usb_disp_hal.h" +#include "usb_disp_prot.h" +#include "usb_disp_model.h" // 既知製品の VID/PID 型番テーブル + +// --------------------------------------------------------------- +// ビデオモードテーブル (VESA DMT / CEA, 60Hz) +// タイミングは Linux drm_dmt_modes[] (VESA DMT 規格値) より +// ※面積降順に並べること (auto mode のフォールバックが先頭から試す) +// --------------------------------------------------------------- + +static const usb_disp_mode_t s_modes[] = { + // W H pclk hfp hsw hbp vfp vsw vbp + { 2048, 1152, 162000, 26, 80, 96, 1, 3, 44 }, // DMT 0x54 RB + { 1920, 1200, 154000, 48, 32, 80, 3, 6, 26 }, // DMT 0x44 RB + { 1920, 1080, 148500, 88, 44, 148, 4, 5, 36 }, // CEA-861 1080p60 + { 1600, 1200, 162000, 64, 192, 304, 1, 3, 46 }, // DMT 0x33 + { 1680, 1050, 119000, 48, 32, 80, 3, 6, 21 }, // DMT 0x39 RB + { 1400, 1050, 101000, 48, 32, 80, 3, 4, 23 }, // DMT 0x29 RB + { 1600, 900, 108000, 24, 80, 96, 1, 3, 96 }, // DMT 0x53 RB + { 1280, 1024, 108000, 48, 112, 248, 1, 3, 38 }, // DMT 0x23 + { 1440, 900, 88750, 48, 32, 80, 3, 6, 17 }, // DMT 0x2E RB + { 1280, 960, 108000, 96, 112, 312, 1, 3, 36 }, // DMT 0x20 + { 1366, 768, 85500, 70, 143, 213, 3, 3, 24 }, // DMT 0x56 + { 1280, 800, 71000, 48, 32, 80, 3, 6, 14 }, // DMT 0x1B RB + { 1280, 720, 74250, 110, 40, 220, 5, 5, 20 }, // CEA-861 720p60 + { 1024, 768, 65000, 24, 136, 160, 3, 6, 29 }, // DMT 0x10 + { 800, 600, 40000, 40, 128, 88, 1, 4, 23 }, // DMT 0x09 + { 640, 480, 25175, 16, 96, 48, 10, 2, 33 }, // DMT 0x04 +}; + +#define USB_DISP_MODE_COUNT \ + ((uint8_t)(sizeof(s_modes) / sizeof(s_modes[0]))) + +static usb_disp_t s_disp[USB_DISP_MAX]; +static uint8_t s_ndisp = 0; + +static void shadow_setup(usb_disp_t *d); +static uint32_t auto_max_area(usb_disp_t *d); + +#define USB_DISP_UNSUPPORTED_RETRY_MS 30000 // 非対応デバイスの周期再試行間隔 +#define USB_DISP_MODE_SETUP_WAIT_MS 2500 // EDID/モード設定を粘る時間 +#define USB_DISP_MODE_SETUP_RETRY_MS 300 // モード設定の試行間隔 +#define USB_DISP_EDID_RECHECK_MS 3000 // READY 後の EDID 遅延再チェック間隔 +#define USB_DISP_EDID_RECHECK_MAX_MS 30000 // 打ち切り + +// fill 用の共有ラインバッファ +static uint16_t s_fill_line[USB_DISP_MAX_WIDTH]; + +// 24bit カラー用の行バッファと変換 +static uint8_t s_line888[USB_DISP_MAX_WIDTH * 3]; +static uint16_t s_line565[USB_DISP_MAX_WIDTH]; + +// B,G,R 3バイト → RGB565 +static inline uint16_t px_888_565(const uint8_t *p) { + return (uint16_t)(((p[2] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | + (p[0] >> 3)); +} +static void row_888_to_565(const uint8_t *src, uint16_t *dst, uint16_t w) { + for (uint16_t i = 0; i < w; i++, src += 3) dst[i] = px_888_565(src); +} +// RGB565 → B,G,R (ビット複製で 8bit へ展開) +static void row_565_to_888(const uint16_t *src, uint8_t *dst, uint16_t w) { + for (uint16_t i = 0; i < w; i++) { + uint16_t c = src[i]; + uint8_t r5 = (uint8_t)(c >> 11); + uint8_t g6 = (uint8_t)((c >> 5) & 0x3F); + uint8_t b5 = (uint8_t)(c & 0x1F); + *dst++ = (uint8_t)((b5 << 3) | (b5 >> 2)); + *dst++ = (uint8_t)((g6 << 2) | (g6 >> 4)); + *dst++ = (uint8_t)((r5 << 3) | (r5 >> 2)); + } +} + +// ---- ログ ---- +// 既定実装 (weak, アプリの usb_disp_log 定義で完全に差し替え可) +// - 既定では何も出さない +// - usb_disp_set_log(true) で Arduino は Serial へ出力するようになる +// (Arduino 以外の既定実装は出力先が無いので set_log しても出ない。 +// pico-sdk / ESP-IDF / PC では usb_disp_log を定義して使う) +static bool s_log_on = false; + +void usb_disp_set_log(bool on) { s_log_on = on; } + +__attribute__((weak)) void usb_disp_log(const char *fmt, ...) { +#if defined(ARDUINO) + if (!s_log_on) return; + char buf[192]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + Serial.println(buf); +#else + (void)s_log_on; + (void)fmt; +#endif +} + +// --------------------------------------------------------------- +// プロトコルテーブル +// --------------------------------------------------------------- + +static const usb_disp_prot_t *const k_prots[] = { + &usb_disp_prot_dl1xx, +#if USB_DISP_PROT_HS + &usb_disp_prot_t6, + &usb_disp_prot_ms91xx, +#endif +}; +#define USB_DISP_PROT_COUNT \ + ((uint8_t)(sizeof(k_prots) / sizeof(k_prots[0]))) + +const usb_disp_prot_t *usb_disp_prot_find(uint16_t vid, uint16_t pid) { + for (uint8_t i = 0; i < USB_DISP_PROT_COUNT; i++) { + if (k_prots[i]->match(vid, pid)) return k_prots[i]; + } + return NULL; +} + +// HAL のデバイス走査用 +bool usb_disp_supported_device(uint16_t vid, uint16_t pid) { + return usb_disp_prot_find(vid, pid) != NULL; +} + +// プロトコル実装向け共有ヘルパ: コントロール転送 +bool usb_disp_prot_ctrl(usb_disp_t *d, uint8_t bmRequestType, + uint8_t bRequest, uint16_t wValue, uint16_t wIndex, + void *data, uint16_t wLength, uint16_t *actual) { + uint8_t setup[8]; + setup[0] = bmRequestType; + setup[1] = bRequest; + setup[2] = (uint8_t)wValue; + setup[3] = (uint8_t)(wValue >> 8); + setup[4] = (uint8_t)wIndex; + setup[5] = (uint8_t)(wIndex >> 8); + setup[6] = (uint8_t)wLength; + setup[7] = (uint8_t)(wLength >> 8); + return usb_disp_hal_ctrl(d->hal, setup, data, actual); +} + +// --------------------------------------------------------------- +// チップ情報 +// --------------------------------------------------------------- + +usb_disp_chip_t usb_disp_chip(usb_disp_t *d) { return d->chip; } +uint32_t usb_disp_max_area(usb_disp_t *d) { return d->max_area; } + +const char *usb_disp_chip_name(usb_disp_t *d) { + switch (d->chip) { + case USB_DISP_CHIP_DL120: return "DL-120"; + case USB_DISP_CHIP_DL160: return "DL-160"; + case USB_DISP_CHIP_DL1X0: return "DL-1x0"; + case USB_DISP_CHIP_DL115: return "DL-115"; + case USB_DISP_CHIP_DL125: return "DL-125"; + case USB_DISP_CHIP_DL165: return "DL-165"; + case USB_DISP_CHIP_DL195: return "DL-195"; + case USB_DISP_CHIP_DL1X5: return "DL-1x5"; + case USB_DISP_CHIP_T6: return "T6"; + case USB_DISP_CHIP_MS912X: return "MS912x"; + case USB_DISP_CHIP_MS913X: return "MS913x"; + default: + return d->prot ? d->prot->name : "???"; + } +} + +uint16_t usb_disp_read_edid(usb_disp_t *d, uint8_t *buf, uint16_t len) { + if (!d->prot || !d->prot->read_edid) return 0; + return d->prot->read_edid(d, 0, buf, len); +} + +uint16_t usb_disp_read_edid_at(usb_disp_t *d, uint16_t offset, uint8_t *buf, + uint16_t len) { + if (!d->prot || !d->prot->read_edid) return 0; + return d->prot->read_edid(d, offset, buf, len); +} + +// --------------------------------------------------------------- +// 製品型番判別 (VID/PID 照合) +// --------------------------------------------------------------- + +const char *usb_disp_model_name(uint16_t vid, uint16_t pid) { + for (uint8_t i = 0; i < sizeof(usb_disp_models) / + sizeof(usb_disp_models[0]); i++) { + if (usb_disp_models[i].vid == vid && usb_disp_models[i].pid == pid) + return usb_disp_models[i].name; + } + return NULL; +} + +const char *usb_disp_model(usb_disp_t *d) { + if (!d || d->vid == 0) return NULL; + return usb_disp_model_name(d->vid, d->pid); +} + +// DL-1xx プロトコル用: 実チップ確定リスト (usb_disp_model.h) を引く +usb_disp_chip_t usb_disp_model_chip(uint16_t vid, uint16_t pid) { + for (uint8_t i = 0; i < sizeof(usb_disp_model_chips) / + sizeof(usb_disp_model_chips[0]); i++) { + if (usb_disp_model_chips[i].vid == vid && + usb_disp_model_chips[i].pid == pid) + return usb_disp_model_chips[i].chip; + } + return USB_DISP_CHIP_UNKNOWN; +} + +// --------------------------------------------------------------- +// モード選択 (プロトコル非依存) +// --------------------------------------------------------------- + +// モードがチップ上限・ドライバ制限内に収まるか (実測 max_area のみ強制) +// アプリ/CLI の明示指定はここを通る = SKU 不明時は制限しない +static bool mode_fits(usb_disp_t *d, uint16_t width, uint16_t height) { + if (width == 0 || height == 0 || width > USB_DISP_MAX_WIDTH) return false; + if (d->max_area && (uint32_t)width * height > d->max_area) return false; + return true; +} + +// 自動選択用: DL の SKU 不明個体は世代内の最小 SKU 相当で保守的に制限 +// (上限超過モードを勝手に選ぶと黒画面になるため)。明示指定は対象外 +static uint32_t auto_max_area(usb_disp_t *d) { + if (d->max_area) return d->max_area; + if (d->chip == USB_DISP_CHIP_DL1X0) return 1470000; // DL-120 相当 + if (d->chip == USB_DISP_CHIP_DL1X5) return 1310720; // DL-115 相当 + return 0; // 世代も不明なら制限なし (従来動作) +} + +static bool mode_fits_auto(usb_disp_t *d, uint16_t width, uint16_t height) { + uint32_t cap = auto_max_area(d); + if (!mode_fits(d, width, height)) return false; + if (cap && (uint32_t)width * height > cap) return false; + return true; +} + +// CVT-RB (VESA Coordinated Video Timings, Reduced Blanking v1) で +// 任意解像度のタイミングを生成する +static bool cvt_rb_mode(uint16_t width, uint16_t height, uint8_t hz, + usb_disp_mode_t *out) { + if (!width || !height || !hz) return false; + uint16_t vsync; + if ((uint32_t)width * 3 == (uint32_t)height * 4) vsync = 4; + else if ((uint32_t)width * 9 == (uint32_t)height * 16) vsync = 5; + else if ((uint32_t)width * 10 == (uint32_t)height * 16) vsync = 6; + else if ((uint32_t)width * 4 == (uint32_t)height * 5) vsync = 7; + else vsync = 10; + + uint32_t frame_ns = 1000000000u / hz; + if (frame_ns <= 460000u) return false; + uint32_t h_period_ns = (frame_ns - 460000u) / height; + if (h_period_ns == 0) return false; + uint32_t vbi = 460000u / h_period_ns + 1; + uint32_t min_vbi = 3u + vsync + 6u; + if (vbi < min_vbi) vbi = min_vbi; + + uint32_t h_total = (uint32_t)width + 160u; + uint32_t v_total = (uint32_t)height + vbi; + uint32_t pclk_khz = (uint32_t)((uint64_t)h_total * v_total * hz / 1000u); + if (pclk_khz / 5 > 0xFFFF) return false; // DL レジスタ 0x1B の制約 + + out->width = width; + out->height = height; + out->pclk_khz = pclk_khz; + out->hfp = 48; out->hsync = 32; out->hbp = 80; + out->vfp = 3; out->vsync = vsync; + out->vbp = (uint16_t)(vbi - 3 - vsync); + return true; +} + +bool usb_disp_set_mode_ex(usb_disp_t *d, const usb_disp_mode_t *m) { + if (!d->prot) return false; + if (!mode_fits(d, m->width, m->height)) { + usb_disp_log("[%s] mode %ux%u exceeds chip limit (max_area=%lu)", + d->prot->name, m->width, m->height, + (unsigned long)d->max_area); + return false; + } + // 実効カラー深度を決定してからモードを組む + // プロトコルは d->depth24 を見て構成し対応できなければ false に戻してよい + d->depth24 = d->depth24_want && d->prot->update888 && + (d->prot->caps & USB_DISP_PROT_CAP_888) != 0; + if (!d->prot->set_mode(d, m)) return false; + if (d->depth24_want && !d->depth24) + usb_disp_log("[%s] 24bit not supported here - using 16bit", + d->prot->name); + d->width = m->width; + d->height = m->height; + d->cur_mode = *m; // usb_disp_current_mode / refresh_hz 用に保持 + shadow_setup(d); // 解像度が変わるので再確保・再同期 + return true; +} + +bool usb_disp_set_depth(usb_disp_t *d, uint8_t bits) { + if (!d || (bits != 16 && bits != 24)) return false; + bool want = (bits == 24); + d->depth24_want = want; + d->cfg.depth24 = want; + if (d->ready && d->cur_mode.width) { + usb_disp_mode_t m = d->cur_mode; // 同一モードで深度だけ切替 + if (!usb_disp_set_mode_ex(d, &m)) return false; + return d->depth24 == want; + } + return true; // 未接続: 接続時のモード設定で反映される +} + +uint8_t usb_disp_depth(usb_disp_t *d) { return (d && d->depth24) ? 24 : 16; } + +bool usb_disp_current_mode(usb_disp_t *d, usb_disp_mode_t *out) { + if (!d || d->cur_mode.width == 0) return false; + if (out) *out = d->cur_mode; + return true; +} + +uint16_t usb_disp_refresh_hz(usb_disp_t *d) { + if (!d || d->cur_mode.width == 0 || d->cur_mode.pclk_khz == 0) return 0; + const usb_disp_mode_t *m = &d->cur_mode; + uint32_t htotal = (uint32_t)m->width + m->hfp + m->hsync + m->hbp; + uint32_t vtotal = (uint32_t)m->height + m->vfp + m->vsync + m->vbp; + if (htotal == 0 || vtotal == 0) return 0; + uint64_t px = (uint64_t)htotal * vtotal; + return (uint16_t)(((uint64_t)m->pclk_khz * 1000 + px / 2) / px); +} + +bool usb_disp_set_mode_hz(usb_disp_t *d, uint16_t width, uint16_t height, + uint8_t refresh_hz) { + if (!d->prot) return false; + if (refresh_hz == 0) refresh_hz = 60; + if (!mode_fits(d, width, height)) { + usb_disp_log("[%s] mode %ux%u exceeds chip limit (max_area=%lu)", + d->prot->name, width, height, + (unsigned long)d->max_area); + return false; + } + // 60Hz は内蔵リスト (VESA/CEA の標準タイミング) を優先 + if (refresh_hz == 60) { + for (uint8_t i = 0; i < USB_DISP_MODE_COUNT; i++) { + if (s_modes[i].width == width && s_modes[i].height == height) + return usb_disp_set_mode_ex(d, &s_modes[i]); + } + } + usb_disp_mode_t m; + if (!cvt_rb_mode(width, height, refresh_hz, &m)) { + usb_disp_log("[%s] no timing for %ux%u@%u", d->prot->name, width, + height, refresh_hz); + return false; + } + usb_disp_log("[%s] CVT-RB %ux%u@%u: pclk=%lu kHz vblank=%u", + d->prot->name, m.width, m.height, refresh_hz, + (unsigned long)m.pclk_khz, m.vfp + m.vsync + m.vbp); + return usb_disp_set_mode_ex(d, &m); +} + +bool usb_disp_set_mode(usb_disp_t *d, uint16_t width, uint16_t height) { + return usb_disp_set_mode_hz(d, width, height, 60); +} + +uint8_t usb_disp_builtin_mode_count(void) { return USB_DISP_MODE_COUNT; } +const usb_disp_mode_t *usb_disp_builtin_mode(uint8_t idx) { + return (idx < USB_DISP_MODE_COUNT) ? &s_modes[idx] : NULL; +} + +// --------------------------------------------------------------- +// EDID (prot->read_edid が返すバイト列をパース) +// --------------------------------------------------------------- + +// EDID はベースブロック (128B) + 拡張ブロック (128B 単位) から成る。 +// 拡張ブロックの個数は byte 126 にある。CEA-861 拡張 (tag 0x02) には +// ベースブロックに載らない対応フォーマットが入っており、これを読まないと +// 「1080p を受けられるのに 480p しか申告していないように見える」中継機 +// (HDMI アナライザ / スプリッタ / KVM) で取りこぼす。 +// +// 読み出しブロック数の上限。既定 2 (= ベース + 拡張1個) で実機の大半を +// カバーする。DL-1xx はレジスタのオフセットが 8bit なのでそもそも 256B が +// ハード上限。拡張が2個以上ある表示機を T6/MS91xx で使う場合だけ増やす。 +#ifndef USB_DISP_EDID_MAX_BLOCKS + #define USB_DISP_EDID_MAX_BLOCKS 2 +#endif +#define USB_DISP_EDID_BUF_SIZE (128 * USB_DISP_EDID_MAX_BLOCKS) + +// 読み出し済み EDID のキャッシュ (全ポート共有) +static uint8_t s_edid[USB_DISP_EDID_BUF_SIZE]; +static uint16_t s_edid_len; // 有効バイト数 (0 = 未取得) +static usb_disp_t *s_edid_owner; // どのポートの EDID か + +static void edid_cache_drop(void) { + s_edid_len = 0; + s_edid_owner = NULL; +} + +// 128B ブロックのチェックサム (全バイトの和が 0) +static bool edid_block_ok(const uint8_t *b) { + uint8_t sum = 0; + for (uint8_t i = 0; i < 128; i++) sum = (uint8_t)(sum + b[i]); + return sum == 0; +} + +// EDID を拡張ブロックまで読んでキャッシュする。戻り値: 有効バイト数 (0=失敗) +// ignore_edid が有効なポートでは一切読まない +static uint16_t edid_read_all(usb_disp_t *d) { + if (s_edid_owner == d && s_edid_len) return s_edid_len; // poll 内キャッシュ + edid_cache_drop(); + if (d->cfg.ignore_edid) return 0; + + if (usb_disp_read_edid_at(d, 0, s_edid, 128) != 128) return 0; + static const uint8_t hdr[8] = {0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0}; + if (memcmp(s_edid, hdr, 8) != 0) return 0; + + uint16_t len = 128; + uint8_t ext = s_edid[126]; + if (ext > USB_DISP_EDID_MAX_BLOCKS - 1) ext = USB_DISP_EDID_MAX_BLOCKS - 1; + for (uint8_t i = 0; i < ext; i++) { + if (usb_disp_read_edid_at(d, len, s_edid + len, 128) != 128) break; + // 化けた拡張ブロックを解釈すると出鱈目なモードを拾うので検証する + // (ベースブロックはヘッダ一致で従来どおり通す) + if (!edid_block_ok(s_edid + len)) { + usb_disp_log("[DISP%d] EDID ext block %u checksum error (ignored)", + usb_disp_index(d), i + 1); + break; + } + len = (uint16_t)(len + 128); + } + + s_edid_len = len; + s_edid_owner = d; + return len; +} + + +// 18B の Detailed Timing Descriptor をパースする。 +// インタレース (byte17 bit7) は本ライブラリでは出力できないので弾く +// (CEA 拡張には 1080i が "1920x540" の DTD として載っていることがあり、 +// そのまま採用すると 540 ライン設定になってしまう) +static bool edid_dtd_parse(const uint8_t *t, usb_disp_mode_t *out) { + uint32_t pclk10 = (uint32_t)(t[0] | (t[1] << 8)); + if (pclk10 == 0) return false; + if (t[17] & 0x80) return false; + uint16_t hact = (uint16_t)(t[2] | ((t[4] >> 4) << 8)); + uint16_t hblank = (uint16_t)(t[3] | ((t[4] & 0x0F) << 8)); + uint16_t vact = (uint16_t)(t[5] | ((t[7] >> 4) << 8)); + uint16_t vblank = (uint16_t)(t[6] | ((t[7] & 0x0F) << 8)); + uint16_t hfp = (uint16_t)(t[8] | ((t[11] >> 6) << 8)); + uint16_t hsync = (uint16_t)(t[9] | (((t[11] >> 4) & 3) << 8)); + uint16_t vfp = (uint16_t)((t[10] >> 4) | (((t[11] >> 2) & 3) << 4)); + uint16_t vsync = (uint16_t)((t[10] & 0x0F) | ((t[11] & 3) << 4)); + if (!hact || !vact || hblank < hfp + hsync || vblank < vfp + vsync) + return false; + out->width = hact; + out->height = vact; + out->pclk_khz = pclk10 * 10; + out->hfp = hfp; out->hsync = hsync; + out->hbp = (uint16_t)(hblank - hfp - hsync); + out->vfp = vfp; out->vsync = vsync; + out->vbp = (uint16_t)(vblank - vfp - vsync); + return true; +} + +// CEA-861 の VIC (Video Identification Code) → アクティブ解像度。 +// 本ライブラリが出せるプログレッシブのものだけ +// 同じ解像度でリフレッシュ違いの VIC が複数あるが、 +// 判定に使うのは解像度だけなので全部同じ w/h を指す +static const struct { uint8_t vic; uint16_t w, h; } k_cea_vic[] = { + { 1, 640, 480 }, + { 2, 720, 480 }, { 3, 720, 480 }, + { 48, 720, 480 }, { 49, 720, 480 }, + { 17, 720, 576 }, { 18, 720, 576 }, + { 42, 720, 576 }, { 43, 720, 576 }, + { 4, 1280, 720 }, { 19, 1280, 720 }, { 41, 1280, 720 }, + { 47, 1280, 720 }, { 60, 1280, 720 }, { 61, 1280, 720 }, + { 62, 1280, 720 }, + { 16, 1920, 1080 }, { 31, 1920, 1080 }, { 32, 1920, 1080 }, + { 33, 1920, 1080 }, { 34, 1920, 1080 }, { 63, 1920, 1080 }, + { 64, 1920, 1080 }, +}; + +// VIC → 解像度。テーブルに無い VIC (4K/21:9 など) は false +static bool cea_vic_res(uint8_t vic, uint16_t *w, uint16_t *h) { + for (uint8_t i = 0; i < sizeof(k_cea_vic) / sizeof(k_cea_vic[0]); i++) { + if (k_cea_vic[i].vic == vic) { + *w = k_cea_vic[i].w; + *h = k_cea_vic[i].h; + return true; + } + } + return false; +} + +// SVD バイト → VIC。値 1..127 は bit7 が native フラグ、 +// 値 129..192 はバイト全体が VIC (native フラグ無し) +// 後者を 0x7F でマスクすると別の VIC に化けるので分ける +static inline uint8_t cea_svd_vic(uint8_t v) { + return (v >= 129 && v <= 192) ? v : (uint8_t)(v & 0x7F); +} + +static bool cea_vic_is(uint8_t vic, uint16_t w, uint16_t h) { + uint16_t vw, vh; + return cea_vic_res(vic, &vw, &vh) && vw == w && vh == h; +} + +// 18B ディスクリプタ列 (ベースの DTD1-4 / 拡張ブロックの DTD) から解像度一致を探す +static bool edid_dtds_list(const uint8_t *p, uint8_t count, uint16_t w, + uint16_t h) { + for (uint8_t i = 0; i < count; i++) { + usb_disp_mode_t m; + if (!edid_dtd_parse(&p[i * 18], &m)) continue; + if (m.width == w && m.height == h) return true; + } + return false; +} + +// CEA-861 拡張ブロック (128B) が w x h への対応を明示しているか +// データブロックコレクション内の Video Data Block (tag 2) の SVD とブロック後半の DTD の両方を見る +static bool edid_cea_lists_mode(const uint8_t *b, uint16_t w, uint16_t h) { + if (b[0] != 0x02) return false; // CEA-861 拡張ではない + uint8_t dtd_off = b[2]; // 最初の DTD のオフセット (0 = 無し) + + if (b[1] >= 3 && dtd_off > 4) { + // データブロックコレクション: [tag(3bit) | len(5bit)] + データ len バイト + uint8_t p = 4; + while (p < dtd_off && p < 127) { + uint8_t tag = (uint8_t)(b[p] >> 5); + uint8_t len = (uint8_t)(b[p] & 0x1F); + if (len == 0 && tag == 0) break; // パディング + if (tag == 2) { // Video Data Block + for (uint8_t i = 1; i <= len && p + i < 127; i++) { + if (cea_vic_is(cea_svd_vic(b[p + i]), w, h)) return true; + } + } + p = (uint8_t)(p + 1 + len); + } + } + + if (dtd_off >= 4 && dtd_off < 127) { + uint8_t n = (uint8_t)((127 - dtd_off) / 18); + if (edid_dtds_list(b + dtd_off, n, w, h)) return true; + } + return false; +} + +// established timings のビット割当 (ベースブロック 0x23..0x25) +static const struct { uint16_t w, h; uint8_t ofs, bit; } k_est[] = { + { 720, 400, 0x23, 7 }, { 720, 400, 0x23, 6 }, { 640, 480, 0x23, 5 }, + { 640, 480, 0x23, 4 }, { 640, 480, 0x23, 3 }, { 640, 480, 0x23, 2 }, + { 800, 600, 0x23, 1 }, { 800, 600, 0x23, 0 }, { 800, 600, 0x24, 7 }, + { 800, 600, 0x24, 6 }, { 832, 624, 0x24, 5 }, { 1024, 768, 0x24, 3 }, + { 1024, 768, 0x24, 2 }, { 1024, 768, 0x24, 1 }, { 1280, 1024, 0x24, 0 }, + { 1152, 870, 0x25, 7 }, +}; +#define USB_DISP_EST_COUNT ((uint8_t)(sizeof(k_est) / sizeof(k_est[0]))) + +// standard timing 1エントリ → 解像度 (未使用エントリは false) +static bool edid_std_res(const uint8_t *t, uint16_t *w, uint16_t *h) { + if (t[0] <= 0x01) return false; + uint16_t sw = (uint16_t)(((uint16_t)t[0] + 31) * 8); + switch (t[1] >> 6) { + case 0: *h = (uint16_t)(sw * 10 / 16); break; + case 1: *h = (uint16_t)(sw * 3 / 4); break; + case 2: *h = (uint16_t)(sw * 4 / 5); break; + default: *h = (uint16_t)(sw * 9 / 16); break; + } + *w = sw; + return true; +} + +// モニタが EDID で対応を明示している解像度か (拡張ブロック込み) +// キャッシュ済み EDID (edid_read_all) を見る +static bool edid_lists_mode(uint16_t w, uint16_t h) { + if (s_edid_len < 128) return false; + const uint8_t *e = s_edid; + + for (uint8_t i = 0; i < USB_DISP_EST_COUNT; i++) { + if (k_est[i].w == w && k_est[i].h == h && + (e[k_est[i].ofs] & (1u << k_est[i].bit))) + return true; + } + for (uint8_t i = 0; i < 8; i++) { // standard timings + uint16_t sw, sh; + if (edid_std_res(&e[0x26 + i * 2], &sw, &sh) && sw == w && sh == h) + return true; + } + if (edid_dtds_list(&e[54], 4, w, h)) return true; // ベースの DTD 1-4 + + // 拡張ブロック (CEA-861 の SVD / DTD) + for (uint16_t off = 128; off + 128 <= s_edid_len; off += 128) { + if (edid_cea_lists_mode(&e[off], w, h)) return true; + } + return false; +} + +// ---- EDID 記載の最大解像度を探す (edid_policy = MAX 用) ---- +// 候補は「DTD (実タイミング付き)」と「解像度だけの記載 +// (established / standard timings / CEA の VIC)」の2種類。 +// チップ上限 (mode_fits_auto) に収まるもののうち面積最大を採る +typedef struct { + usb_disp_t *d; + uint32_t best; // 採用中の面積 (0 = 未採用) + uint16_t w, h; + usb_disp_mode_t dtd; // is_dtd のときだけ有効 + bool is_dtd; +} edid_scan_t; + +static void edid_scan_res(edid_scan_t *s, uint16_t w, uint16_t h) { + if (!mode_fits_auto(s->d, w, h)) return; + uint32_t area = (uint32_t)w * h; + if (area <= s->best) return; + s->best = area; + s->w = w; + s->h = h; + s->is_dtd = false; +} + +static void edid_scan_dtd(edid_scan_t *s, const uint8_t *t) { + usb_disp_mode_t m; + if (!edid_dtd_parse(t, &m)) return; + if (!mode_fits_auto(s->d, m.width, m.height)) return; + uint32_t area = (uint32_t)m.width * m.height; + if (area <= s->best) return; + s->best = area; + s->w = m.width; + s->h = m.height; + s->dtd = m; + s->is_dtd = true; +} + +// EDID 全体を走査して、チップ上限に収まる最大の申告モードを探す +static bool edid_scan_max(usb_disp_t *d, edid_scan_t *s) { + if (edid_read_all(d) < 128) return false; + const uint8_t *e = s_edid; + memset(s, 0, sizeof(*s)); + s->d = d; + + for (uint8_t i = 0; i < 4; i++) edid_scan_dtd(s, &e[54 + i * 18]); + for (uint8_t i = 0; i < USB_DISP_EST_COUNT; i++) { + if (e[k_est[i].ofs] & (1u << k_est[i].bit)) + edid_scan_res(s, k_est[i].w, k_est[i].h); + } + for (uint8_t i = 0; i < 8; i++) { + uint16_t sw, sh; + if (edid_std_res(&e[0x26 + i * 2], &sw, &sh)) edid_scan_res(s, sw, sh); + } + + // 拡張ブロック (CEA-861): VDB の VIC と DTD + for (uint16_t off = 128; off + 128 <= s_edid_len; off += 128) { + const uint8_t *b = &e[off]; + if (b[0] != 0x02) continue; + uint8_t dtd_off = b[2]; + if (b[1] >= 3 && dtd_off > 4) { + uint8_t p = 4; + while (p < dtd_off && p < 127) { + uint8_t tag = (uint8_t)(b[p] >> 5); + uint8_t len = (uint8_t)(b[p] & 0x1F); + if (len == 0 && tag == 0) break; + if (tag == 2) { + for (uint8_t i = 1; i <= len && p + i < 127; i++) { + uint16_t vw, vh; + if (cea_vic_res(cea_svd_vic(b[p + i]), &vw, &vh)) + edid_scan_res(s, vw, vh); + } + } + p = (uint8_t)(p + 1 + len); + } + } + if (dtd_off >= 4 && dtd_off < 127) { + uint8_t n = (uint8_t)((127 - dtd_off) / 18); + for (uint8_t i = 0; i < n; i++) + edid_scan_dtd(s, b + dtd_off + i * 18); + } + } + return s->best != 0; +} + +// EDID からモードを決める (適用はしない) +// is_dtd が真なら dtd に実タイミングが入る (そのまま set_mode_ex できる) +static bool edid_target_mode(usb_disp_t *d, uint16_t *w, uint16_t *h, + usb_disp_mode_t *dtd, bool *is_dtd) { + if (edid_read_all(d) < 128) return false; + if (d->cfg.edid_policy == USB_DISP_EDID_POLICY_PREFERRED) { + // オフセット 54 = ベースブロック先頭の DTD + // (preferred timing = モニタのネイティブ解像度) + usb_disp_mode_t pref; + if (!edid_dtd_parse(&s_edid[54], &pref)) return false; + if (!mode_fits_auto(d, pref.width, pref.height)) return false; + *w = pref.width; + *h = pref.height; + *dtd = pref; + *is_dtd = true; + return true; + } + edid_scan_t s; + if (!edid_scan_max(d, &s)) return false; + *w = s.w; + *h = s.h; + *is_dtd = s.is_dtd; + if (s.is_dtd) *dtd = s.dtd; + return true; +} + +// edid_target_mode の結果を適用する +static bool edid_apply_mode(usb_disp_t *d, uint16_t w, uint16_t h, + const usb_disp_mode_t *dtd, bool is_dtd) { + if (is_dtd && usb_disp_set_mode_ex(d, dtd)) return true; + // DTD が無い (VIC/established/standard 由来) or DTD で失敗 → + // 内蔵タイミング表 or CVT-RB で組む + return usb_disp_set_mode_hz(d, w, h, 60); +} + +// EDID からモードを決めて設定する。 +// *have_edid には「EDID 自体は読めたか」を返す +static bool auto_mode_from_edid(usb_disp_t *d, bool *have_edid) { + uint16_t w = 0, h = 0; + usb_disp_mode_t dtd; + bool is_dtd = false; + *have_edid = (edid_read_all(d) >= 128); + if (!*have_edid) return false; + if (!edid_target_mode(d, &w, &h, &dtd, &is_dtd)) return false; + if (!edid_apply_mode(d, w, h, &dtd, is_dtd)) return false; + usb_disp_log("[%s] auto mode: EDID %s %ux%u%s", d->prot->name, + d->cfg.edid_policy == USB_DISP_EDID_POLICY_PREFERRED + ? "preferred" : "max", + w, h, is_dtd ? " (EDID timing)" : ""); + return true; +} + +// 内蔵テーブルからのフォールバック選択 (EDID 記載モード優先 → 面積降順) +static bool auto_fallback_mode(usb_disp_t *d) { + bool have_edid = edid_read_all(d) >= 128; + for (uint8_t pass = have_edid ? 0 : 1; pass < 2; pass++) { + for (uint8_t i = 0; i < USB_DISP_MODE_COUNT; i++) { + if (!mode_fits_auto(d, s_modes[i].width, s_modes[i].height)) + continue; + if (pass == 0 && + !edid_lists_mode(s_modes[i].width, s_modes[i].height)) + continue; + if (usb_disp_set_mode_ex(d, &s_modes[i])) { + usb_disp_log("[%s] auto mode: fallback %ux%u%s", + d->prot->name, s_modes[i].width, + s_modes[i].height, + pass == 0 ? " (EDID listed)" : ""); + return true; + } + } + } + return false; +} + +void usb_disp_set_ignore_edid(usb_disp_t *d, bool on) { + if (!d) return; + d->cfg.ignore_edid = on; + if (on) { + d->edid_pending = false; // 遅延再チェックも打ち切る + if (s_edid_owner == d) edid_cache_drop(); + } +} + +bool usb_disp_ignore_edid(usb_disp_t *d) { return d && d->cfg.ignore_edid; } + +void usb_disp_set_edid_policy(usb_disp_t *d, usb_disp_edid_policy_t policy) { + if (d) d->cfg.edid_policy = policy; +} + +usb_disp_edid_policy_t usb_disp_edid_policy(usb_disp_t *d) { + return d ? d->cfg.edid_policy : USB_DISP_EDID_POLICY_MAX; +} + +bool usb_disp_edid_supports(usb_disp_t *d, uint16_t width, uint16_t height) { + if (!d || d->cfg.ignore_edid || !width || !height) return false; + if (edid_read_all(d) < 128) return false; + return edid_lists_mode(width, height); +} + +uint16_t usb_disp_edid_size(usb_disp_t *d) { + if (!d) return 0; + return edid_read_all(d); +} + +bool usb_disp_edid_max_mode(usb_disp_t *d, uint16_t *width, uint16_t *height) { + if (!d || d->cfg.ignore_edid) return false; + edid_scan_t s; + if (!edid_scan_max(d, &s)) return false; + if (width) *width = s.w; + if (height) *height = s.h; + return true; +} + +bool usb_disp_set_auto_mode(usb_disp_t *d) { + if (!d->prot) return false; + bool have_edid = false; + if (auto_mode_from_edid(d, &have_edid)) return true; + if (auto_fallback_mode(d)) return true; + usb_disp_log("[%s] auto mode: no mode fits (max_area=%lu)", + d->prot->name, (unsigned long)d->max_area); + return false; +} + +// --------------------------------------------------------------- +// シャドウFB (差分更新) - スパン更新型プロトコル (CAP_SHADOW) のみ +// --------------------------------------------------------------- + +static bool prot_can_shadow(usb_disp_t *d) { + return d->prot && (d->prot->caps & USB_DISP_PROT_CAP_SHADOW); +} + +// デバイスFBを黒で塗り、シャドウを 0 クリアして同期状態にする +static bool shadow_sync(usb_disp_t *d) { + if (d->depth24) { + memset(s_line888, 0, (size_t)d->width * 3); + if (!d->prot->update888(d, 0, 0, d->width, d->height, s_line888, 0)) + return false; + memset(d->shadow, 0, d->shadow_bytes); + return d->prot->flush(d, 1000); + } + memset(s_fill_line, 0, (size_t)d->width * 2); + if (!d->prot->update(d, 0, 0, d->width, d->height, s_fill_line, 0)) + return false; + if (!d->prot->flush(d, 10000)) return false; + memset(d->shadow, 0, (size_t)d->width * d->height * 2); + return true; +} + +// モード確定後に呼ぶ。希望に応じて確保/解放し、有効ならデバイスと同期する +static void shadow_setup(usb_disp_t *d) { + uint32_t need = (uint32_t)d->width * d->height * (d->depth24 ? 3 : 2); + d->shadow_on = false; + if (!d->shadow_want || need == 0 || !prot_can_shadow(d)) { + if (d->shadow) { + usb_disp_hal_fb_free(d->shadow, d->shadow_bytes); + d->shadow = NULL; + d->shadow_bytes = 0; + } + if (d->shadow_want && d->prot && !prot_can_shadow(d)) + usb_disp_log("[%s] shadow FB not applicable (full-frame protocol)", + d->prot->name); + return; + } + if (d->shadow && d->shadow_bytes != need) { + usb_disp_hal_fb_free(d->shadow, d->shadow_bytes); + d->shadow = NULL; + d->shadow_bytes = 0; + } + if (!d->shadow) { + d->shadow = (uint16_t *)usb_disp_hal_fb_alloc(need); + if (!d->shadow) { + usb_disp_log("[DISP%d] shadow FB unavailable (%lu KB)", + usb_disp_index(d), (unsigned long)(need / 1024)); + return; + } + d->shadow_bytes = need; + } + if (!shadow_sync(d)) return; + d->shadow_on = true; + usb_disp_log("[DISP%d] shadow FB on (%ux%u, %lu KB)", usb_disp_index(d), + d->width, d->height, (unsigned long)(need / 1024)); +} + +bool usb_disp_set_shadow(usb_disp_t *d, bool on) { + d->shadow_want = on; + if (!d->ready || !d->width) return true; + shadow_setup(d); + return d->shadow_on == on; +} + +bool usb_disp_shadow_active(usb_disp_t *d) { return d->shadow_on; } + +const uint16_t *usb_disp_shadow_row(usb_disp_t *d, uint16_t row) { + if (d && d->depth24) return NULL; // 24bit 時は非対応 (3B/px のため) + if (!d->shadow_on || row >= d->height) return NULL; + return d->shadow + (uint32_t)row * d->width; +} + +// --------------------------------------------------------------- +// 描画 API (クリッピング + シャドウ差分はコア、送出はプロトコル) +// --------------------------------------------------------------- + +// 1行ぶんの 565 更新 (シャドウ有効時は差分だけ送る) +static bool core_update565_row(usb_disp_t *d, uint16_t x, uint16_t y, + uint16_t w, const uint16_t *src) { + if (!d->shadow_on) return d->prot->update(d, x, y, w, 1, src, 0); + uint16_t *sh = d->shadow + (uint32_t)y * d->width + x; + if (memcmp(sh, src, (size_t)w * 2) == 0) return true; + uint32_t a = 0, b = (uint32_t)w - 1; + while (sh[a] == src[a]) a++; + while (sh[b] == src[b]) b--; + uint16_t cw = (uint16_t)(b - a + 1); + memcpy(sh + a, src + a, (size_t)cw * 2); + return d->prot->update(d, (uint16_t)(x + a), y, cw, 1, src + a, 0); +} + +// 1行ぶんの 888 更新 (シャドウ有効時は差分だけ送る)。src = 3B/px B,G,R +static bool core_update888_row(usb_disp_t *d, uint16_t x, uint16_t y, + uint16_t w, const uint8_t *src) { + if (!d->shadow_on) return d->prot->update888(d, x, y, w, 1, src, 0); + uint8_t *sh = (uint8_t *)d->shadow + ((uint32_t)y * d->width + x) * 3; + if (memcmp(sh, src, (size_t)w * 3) == 0) return true; + uint32_t a = 0, b = (uint32_t)w - 1; + while (memcmp(sh + a * 3, src + a * 3, 3) == 0) a++; + while (memcmp(sh + b * 3, src + b * 3, 3) == 0) b--; + uint16_t cw = (uint16_t)(b - a + 1); + memcpy(sh + a * 3, src + a * 3, (size_t)cw * 3); + return d->prot->update888(d, (uint16_t)(x + a), y, cw, 1, src + a * 3, 0); +} + +bool usb_disp_update_565(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint16_t *rgb565, + uint32_t stride_px) { + if (!d->ready || d->width == 0 || !d->prot) return false; + // 実解像度でクリッピング (右端・下端のみ) + if (x >= d->width || y >= d->height) return true; // 完全に画面外 + if ((uint32_t)x + w > d->width) w = (uint16_t)(d->width - x); + if ((uint32_t)y + h > d->height) h = (uint16_t)(d->height - y); + + if (d->depth24) { + // 24bit モード: 565 入力を 888 に展開して 24bit 経路へ (行単位) + for (uint16_t row = 0; row < h; row++) { + const uint16_t *src = + stride_px ? rgb565 + (uint32_t)row * stride_px : rgb565; + row_565_to_888(src, s_line888, w); + if (!core_update888_row(d, x, (uint16_t)(y + row), w, s_line888)) + return false; + } + return true; + } + + if (!d->shadow_on) + return d->prot->update(d, x, y, w, h, rgb565, stride_px); + + // 差分更新: 変化のない行はスキップ、変化した行は先頭/末尾の一致部分を + // 削って変化区間だけを送る (スパン更新型プロトコルのみ到達) + for (uint16_t row = 0; row < h; row++) { + const uint16_t *src = + stride_px ? rgb565 + (uint32_t)row * stride_px : rgb565; + if (!core_update565_row(d, x, (uint16_t)(y + row), w, src)) + return false; + } + return true; +} + +// RGB888 版の矩形更新 (px = 3B/px, B,G,R 順) +bool usb_disp_update_888(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint8_t *px, uint32_t stride_px) { + if (!d->ready || d->width == 0 || !d->prot) return false; + if (x >= d->width || y >= d->height) return true; // 完全に画面外 + if ((uint32_t)x + w > d->width) w = (uint16_t)(d->width - x); + if ((uint32_t)y + h > d->height) h = (uint16_t)(d->height - y); + + if (d->depth24) { + if (!d->shadow_on) + return d->prot->update888(d, x, y, w, h, px, stride_px); + for (uint16_t row = 0; row < h; row++) { + const uint8_t *src = + stride_px ? px + (uint32_t)row * stride_px * 3 : px; + if (!core_update888_row(d, x, (uint16_t)(y + row), w, src)) + return false; + } + return true; + } + + // 16bit モード: 888 入力を 565 に落として通常経路へ (行単位) + for (uint16_t row = 0; row < h; row++) { + const uint8_t *src = stride_px ? px + (uint32_t)row * stride_px * 3 : px; + row_888_to_565(src, s_line565, w); + if (d->shadow_on) { + if (!core_update565_row(d, x, (uint16_t)(y + row), w, s_line565)) + return false; + } else { + if (!d->prot->update(d, x, (uint16_t)(y + row), w, 1, s_line565, 0)) + return false; + } + } + return true; +} + +// 汎用形: fmt でピクセル形式を選ぶ (565/888 のディスパッチ) +bool usb_disp_update(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const void *px, uint32_t stride_px, + usb_disp_fmt_t fmt) { + if (fmt == USB_DISP_FMT_RGB888) + return usb_disp_update_888(d, x, y, w, h, (const uint8_t *)px, + stride_px); + return usb_disp_update_565(d, x, y, w, h, (const uint16_t *)px, stride_px); +} + +bool usb_disp_copy(usb_disp_t *d, uint16_t sx, uint16_t sy, uint16_t dx, + uint16_t dy, uint16_t w, uint16_t h) { + if (!d->ready || d->width == 0 || !d->prot || !d->prot->copy) + return false; + if (w == 0 || h == 0) return true; + if ((uint32_t)sx + w > d->width || (uint32_t)dx + w > d->width || + (uint32_t)sy + h > d->height || (uint32_t)dy + h > d->height) + return false; + if (sx == dx && sy == dy) return true; + if (!d->prot->copy(d, sx, sy, dx, dy, w, h)) return false; + + // シャドウにも同じコピーを反映 + if (d->shadow_on) { + bool bottom_up = (dy > sy); + uint8_t bpp = d->depth24 ? 3 : 2; + uint8_t *sh = (uint8_t *)d->shadow; + for (uint16_t i = 0; i < h; i++) { + uint16_t row = bottom_up ? (uint16_t)(h - 1 - i) : i; + memmove(sh + ((uint32_t)(dy + row) * d->width + dx) * bpp, + sh + ((uint32_t)(sy + row) * d->width + sx) * bpp, + (size_t)w * bpp); + } + } + return true; +} + +bool usb_disp_fill(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, uint16_t color) { + if (!d->ready || w > USB_DISP_MAX_WIDTH) return false; + for (uint16_t i = 0; i < w; i++) s_fill_line[i] = color; + return usb_disp_update_565(d, x, y, w, h, s_fill_line, 0); +} + +bool usb_disp_flush(usb_disp_t *d, uint32_t timeout_ms) { + if (!d->prot) return usb_disp_hal_bulk_flush(d->hal, timeout_ms); + return d->prot->flush(d, timeout_ms); +} + +bool usb_disp_blank(usb_disp_t *d, bool on) { + if (!d->ready || !d->prot || !d->prot->blank) return false; + return d->prot->blank(d, on); +} + +// --------------------------------------------------------------- +// ライフサイクル +// --------------------------------------------------------------- + +void usb_disp_init(void) { + memset(s_disp, 0, sizeof(s_disp)); + s_ndisp = 0; +} + +usb_disp_t *usb_disp_add_cfg(const usb_disp_config_t *cfg) { + if (!cfg || s_ndisp >= USB_DISP_MAX) return NULL; + usb_disp_hal_t *hal = usb_disp_hal_add(cfg); + if (!hal) return NULL; + usb_disp_t *d = &s_disp[s_ndisp]; + memset(d, 0, sizeof(*d)); + d->in_use = true; + d->hal = hal; + d->cfg = *cfg; + d->shadow_want = cfg->shadow_fb; + d->depth24_want = cfg->depth24; + d->stage = USB_DISP_STAGE_WAIT_DEVICE; + s_ndisp++; + return d; +} + +usb_disp_t *usb_disp_add(uint8_t port, uint8_t pin_dp, uint8_t pin_dm, + uint16_t width, uint16_t height, bool ignore_edid) { + usb_disp_config_t cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.port = port; + cfg.pin_dp = pin_dp; + cfg.pin_dm = pin_dm; + cfg.width = width; + cfg.height = height; + cfg.ignore_edid = ignore_edid; + return usb_disp_add_cfg(&cfg); +} + +void usb_disp_start(void) { + usb_disp_hal_start(); +} + +void usb_disp_start_manual(void) { + usb_disp_hal_start_manual(); +} + +void usb_disp_task(void) { + usb_disp_hal_task(); +} + +uint8_t usb_disp_count(void) { return s_ndisp; } +usb_disp_t *usb_disp_at(uint8_t idx) { + return (idx < s_ndisp) ? &s_disp[idx] : NULL; +} +uint8_t usb_disp_index(usb_disp_t *d) { return (uint8_t)(d - s_disp); } + +bool usb_disp_ready(usb_disp_t *d) { return d->ready; } +uint16_t usb_disp_width(usb_disp_t *d) { return d->width; } +uint16_t usb_disp_height(usb_disp_t *d) { return d->height; } +uint16_t usb_disp_vid(usb_disp_t *d) { return d->vid; } +uint16_t usb_disp_pid(usb_disp_t *d) { return d->pid; } +uint64_t usb_disp_stat_bytes(usb_disp_t *d) { + return usb_disp_hal_stat_bytes(d->hal); +} + +#if USB_DISP_PORT_PICO +// 宣言は usb_disp_hal.h (extern "C" 内) +usb_disp_udh_host_t *usb_disp_get_host(usb_disp_t *d) { + return usb_disp_hal_pico_host(d->hal); +} +#endif + +void usb_disp_force_reenum(usb_disp_t *d) { + usb_disp_hal_request_reenum(d->hal); +} + +static void reset_device_state(usb_disp_t *d) { + if (d->prot && d->prot->detach) d->prot->detach(d); + d->ready = false; + d->vid = d->pid = 0; + d->prot = NULL; + d->chip = USB_DISP_CHIP_UNKNOWN; + d->max_area = 0; + d->width = d->height = 0; + memset(&d->cur_mode, 0, sizeof(d->cur_mode)); + d->edid_pending = false; + if (s_edid_owner == d) edid_cache_drop(); + // シャドウは同期が切れるので無効化 (メモリは保持、shadow_setup が再同期する) + d->shadow_on = false; +} + +bool usb_disp_poll(usb_disp_t *d) { + bool changed = false; + uint32_t now = usb_disp_hal_ms(); + + // EDID キャッシュは 1回の poll 内でのみ有効 + edid_cache_drop(); + + usb_disp_hal_poll(d->hal); + bool attached = usb_disp_hal_attached(d->hal); + + switch (d->stage) { + case USB_DISP_STAGE_WAIT_DEVICE: + if (!attached) break; + d->vid = usb_disp_hal_vid(d->hal); + d->pid = usb_disp_hal_pid(d->hal); + d->prot = usb_disp_prot_find(d->vid, d->pid); + if (d->prot && d->prot->attach(d)) { + usb_disp_log("[DISP%d] protocol: %s", usb_disp_index(d), + d->prot->name); + if (d->cfg.ignore_edid) + usb_disp_log("[DISP%d] ignore_edid: EDID is not read; " + "mode comes from cfg/builtin list only", + usb_disp_index(d)); + if (d->cfg.no_auto_mode) { + d->ready = true; + d->stage = USB_DISP_STAGE_READY; + changed = true; + } else { + d->mode_deadline_ms = now + USB_DISP_MODE_SETUP_WAIT_MS; + d->mode_next_ms = now; + d->stage = USB_DISP_STAGE_MODE_SETUP; + } + } else { + usb_disp_log("[DISP%d] unsupported device (VID=%04X PID=%04X)%s", + usb_disp_index(d), d->vid, d->pid, + d->prot ? " (attach failed)" : ""); + d->prot = NULL; + d->stage = USB_DISP_STAGE_FAILED; + d->failed_since_ms = now; + } + break; + + case USB_DISP_STAGE_MODE_SETUP: { + if (!attached) { + reset_device_state(d); + d->stage = USB_DISP_STAGE_WAIT_DEVICE; + break; + } + if ((int32_t)(now - d->mode_next_ms) < 0) break; + d->mode_next_ms = now + USB_DISP_MODE_SETUP_RETRY_MS; + bool expired = (int32_t)(now - d->mode_deadline_ms) >= 0; + + bool mode_ok = false; + // 1. cfg 指定解像度 (あれば最優先) + if (d->cfg.width && d->cfg.height) + mode_ok = usb_disp_set_mode_hz(d, d->cfg.width, d->cfg.height, + d->cfg.refresh_hz); + // 2. EDID から自動選択 + if (!mode_ok && !d->cfg.ignore_edid) { + bool have_edid = false; + if (auto_mode_from_edid(d, &have_edid)) { + mode_ok = true; + } else if (!have_edid && !expired) { + break; // EDID がまだ読めない → リトライ + } else if (!have_edid) { + usb_disp_log("[%s] auto mode: EDID unavailable, fallback", + d->prot->name); + d->edid_pending = true; + d->edid_next_ms = now + USB_DISP_EDID_RECHECK_MS; + d->edid_until_ms = now + USB_DISP_EDID_RECHECK_MAX_MS; + } + } + // 3. 内蔵テーブルから (EDID 記載モード優先 → 面積降順) + if (!mode_ok) { + mode_ok = auto_fallback_mode(d); + } + if (!mode_ok && !expired) break; + if (!mode_ok) + usb_disp_log("[%s] auto mode: no mode set (max_area=%lu)", + d->prot->name, (unsigned long)d->max_area); + d->ready = true; + d->stage = USB_DISP_STAGE_READY; + changed = true; + break; + } + + case USB_DISP_STAGE_READY: + if (!attached) { + if (d->ready) changed = true; + usb_disp_log("[DISP%d] disconnected", usb_disp_index(d)); + reset_device_state(d); + d->stage = USB_DISP_STAGE_WAIT_DEVICE; + break; + } + // プロトコル定期処理 (MS913x のキープアライブ等) + if (d->prot && d->prot->poll) d->prot->poll(d); + // EDID 遅延再チェック (フォールバック起動後のモード昇格) + if (d->edid_pending && !d->cfg.ignore_edid && + (int32_t)(now - d->edid_next_ms) >= 0) { + d->edid_next_ms = now + USB_DISP_EDID_RECHECK_MS; + if (edid_read_all(d) >= 128) { + d->edid_pending = false; + // 既に同じ解像度なら触らない + uint16_t tw = 0, th = 0; + usb_disp_mode_t tdtd; + bool tis_dtd = false; + if (edid_target_mode(d, &tw, &th, &tdtd, &tis_dtd) && + (tw != d->width || th != d->height)) { + usb_disp_log("[%s] EDID arrived late: switching to %ux%u", + d->prot->name, tw, th); + if (edid_apply_mode(d, tw, th, &tdtd, tis_dtd)) + changed = true; + } + } else if ((int32_t)(now - d->edid_until_ms) > 0) { + d->edid_pending = false; + } + } + break; + + case USB_DISP_STAGE_FAILED: + if (!attached) { + reset_device_state(d); + d->stage = USB_DISP_STAGE_WAIT_DEVICE; + break; + } + if (now - d->failed_since_ms >= USB_DISP_UNSUPPORTED_RETRY_MS) { + usb_disp_log("[DISP%d] periodic re-enumeration...", + usb_disp_index(d)); + reset_device_state(d); + usb_disp_hal_request_reenum(d->hal); + d->stage = USB_DISP_STAGE_WAIT_DEVICE; + } + break; + } + return changed; +} + diff --git a/c_mpos/usb/upstream/usb_disp.h b/c_mpos/usb/upstream/usb_disp.h new file mode 100644 index 000000000..4c79d5179 --- /dev/null +++ b/c_mpos/usb/upstream/usb_disp.h @@ -0,0 +1,420 @@ +// +// ###################################################################### +// +// usb_disp - USB Display Driver Core +// +// プロトコル非依存のコア: ライフサイクル・接続ステート・モード管理 +// (内蔵タイミングテーブル / CVT-RB / EDID パース)・クリッピング・ +// シャドウFB差分更新。チップ別プロトコルは usb_disp_prot_*.cpp: +// +// usb_disp_prot_dl-1xx.cpp : DisplayLink DL-1x0/1x5 +// usb_disp_prot_t6.cpp : MCT Trigger 6 +// usb_disp_prot_ms91xx.cpp : MacroSilicon MS912x/MS913x +// +// Copyright (C) 2026 +// Hideto Kikuchi / PJラボ (@pcjpnet) - https://pc-jp.net/ +// +// ###################################################################### +// + +#ifndef USB_DISP_H_ +#define USB_DISP_H_ + +#include +#include + +// ---- プラットフォーム判定 ---- +// 1. Raspberry Pi Pico (RP2040/RP2350): ARDUINO_ARCH_RP2040 / PICO_ON_DEVICE 定義 +// 2. ESP32 系 (S2/S3/P4): ESP_PLATFORM 定義 +// 3. Teensy 4.x (i.MX RT1062): __IMXRT1062__ 定義 +// 4. その他の Arduino ボード (AVR 等): 非対応 → #error で案内 +// 5. それ以外: PC / 組み込み OS → libusb バックエンド +// (_WIN32=Windows, __APPLE__=macOS, __unix__=Linux/BSD 等) + +#if defined(ARDUINO_ARCH_RP2040) || defined(PICO_ON_DEVICE) || \ + defined(PICO_RP2040) || defined(PICO_RP2350) + #define USB_DISP_PORT_ESP32 0 + #define USB_DISP_PORT_LIBUSB 0 + #define USB_DISP_PORT_PICO 1 + #define USB_DISP_PORT_TEENSY 0 +#elif defined(ESP_PLATFORM) || defined(ARDUINO_ARCH_ESP32) + #define USB_DISP_PORT_ESP32 1 + #define USB_DISP_PORT_LIBUSB 0 + #define USB_DISP_PORT_PICO 0 + #define USB_DISP_PORT_TEENSY 0 +#elif defined(__IMXRT1062__) + #define USB_DISP_PORT_ESP32 0 + #define USB_DISP_PORT_LIBUSB 0 + #define USB_DISP_PORT_PICO 0 + #define USB_DISP_PORT_TEENSY 1 +#elif defined(ARDUINO) + #error "Pico_USB_Disp: Unsupported Arduino board. Supported: Raspberry Pi Pico / Pico 2 (arduino-pico core), ESP32-S2/S3/P4 (esp32 core 3.x), or Teensy 4.x (Teensyduino). / このボードは非対応です。ツール > ボード で Raspberry Pi Pico/Pico 2 / ESP32-S2/S3/P4 / Teensy 4.x を選択してください。" +#elif defined(_WIN32) || defined(__APPLE__) || defined(__unix__) + #define USB_DISP_PORT_ESP32 0 + #define USB_DISP_PORT_LIBUSB 1 + #define USB_DISP_PORT_PICO 0 + #define USB_DISP_PORT_TEENSY 0 +#else + #error "Pico_USB_Disp: Unknown Platform. Supported: Raspberry Pi Pico/Pico 2 (RP2040/RP2350), ESP32-S2/S3/P4, Teensy 4.x, or an OS with libusb (Windows/macOS/Linux/BSD)." +#endif + +#if USB_DISP_PORT_PICO + #include "hardware/pio.h" + #include "usb_disp_udh_host.h" +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// 対応する最大水平解像度 (ラインバッファのサイズを決める) +// DL-195 の 2048x1152 まで対応。RAM の少ない RP2040 ではコンパイルオプションで小さくしてよい +#ifndef USB_DISP_MAX_WIDTH + #define USB_DISP_MAX_WIDTH 2048 +#endif + +// 同時ディスプレイ数の上限 +#if USB_DISP_PORT_PICO + #define USB_DISP_MAX USB_DISP_UDH_MAX_PORTS // PIO ブロック数 (RP2350=3, RP2040=2) +#elif USB_DISP_PORT_LIBUSB + #ifndef USB_DISP_MAX + #define USB_DISP_MAX 4 // PC: 対応デバイスを列挙し発見順に割り当て + #endif +#else + #define USB_DISP_MAX 1 // ESP32: OTG 1系統 / Teensy 4.x: EHCI ホスト1系統 +#endif + +typedef struct usb_disp usb_disp_t; + +// 判別したチップ (usb_disp_chip() で取得)。 +typedef enum { + USB_DISP_CHIP_UNKNOWN = 0, + // DisplayLink Gen1.0 "Alex" (DL-1x0) + USB_DISP_CHIP_DL120, // max 1280x1024 / 1400x1050 + USB_DISP_CHIP_DL160, // max 1600x1200 / 1680x1050 + USB_DISP_CHIP_DL1X0, // Gen1.0 だが型番不明 + // DisplayLink Gen1.5 "Ollie" (DL-1x5) + USB_DISP_CHIP_DL115, // max 1024x600 + USB_DISP_CHIP_DL125, // max 1280x1024 / 1440x900 + USB_DISP_CHIP_DL165, // max 1600x1200 / 1920x1080 + USB_DISP_CHIP_DL195, // max 1920x1200 / 2048x1152 + USB_DISP_CHIP_DL1X5, // Gen1.5 だが型番不明 + // MCT Trigger 6 (T6-688SL) + USB_DISP_CHIP_T6, + // MacroSilicon + USB_DISP_CHIP_MS912X, // MS912x 世代 (534D:6021 / 345F:9132, UYVY) + USB_DISP_CHIP_MS913X, // MS913x 世代 (345F:9133, RGB24, 要キープアライブ) +} usb_disp_chip_t; + + +// ピクセル形式 (usb_disp_update の fmt 引数 / usb_disp_set_depth の値) +typedef enum { + USB_DISP_FMT_RGB565 = 16, // 2 バイト/px (uint16_t) + USB_DISP_FMT_RGB888 = 24, // 3 バイト/px (メモリ順 B,G,R) +} usb_disp_fmt_t; + + +// 解像度の自動選択で EDID をどう使うか (usb_disp_config_t.edid_policy) +typedef enum { + // 既定: EDID が対応する解像度のうち、チップ上限に収まる最大のものを選ぶ + // CEA-861 拡張ブロックの VIC / DTD も候補に入れる + USB_DISP_EDID_POLICY_MAX = 0, + // ベースブロックの preferred timing (モニタのネイティブ解像度) を優先。 + // 拡張ブロックは見ない。パネルの実解像度で等倍表示したい場合や、 + // 表示側のスケーラを通したくない場合はこちら + USB_DISP_EDID_POLICY_PREFERRED, +} usb_disp_edid_policy_t; + + +// ビデオモード (タイミング一式) usb_disp_set_mode_ex() に渡す +typedef struct { + uint16_t width, height; // アクティブ解像度 + uint32_t pclk_khz; // ピクセルクロック [kHz] + uint16_t hfp, hsync, hbp; // 水平 front porch / sync / back porch + uint16_t vfp, vsync, vbp; // 垂直 front porch / sync / back porch +} usb_disp_mode_t; + + +// ポート設定 (usb_disp_add_cfg) +// width=0 で接続時に解像度を自動選択 +// チップのピクセル数上限に収まらなければ内蔵モードリストの大きい順にフォールバック +// +// フィールドは全プラットフォームで共通 +// port の意味はバックエンドごとに違う: +// - Pico: 専用 PIO ブロック番号 0..2 (1ポート = 1ブロック占有。RP2040 は 0..1) +// pin_dp/pin_dm も Pico のみ有効。他プラットフォームは読み飛ばし。 +// - ESP32: USB-OTG コントローラ番号。現状 0 のみ (0 以外は登録エラー)。 +// P4 の 2系統目 (1 = USB 1.1 OTG FS) は現在未対応 (今後対応予定) +// - Teensy 4.x: 0 のみ +// USB ホストポート = 4.1 のホストヘッダ / 4.0 は裏面パッド +// - PC (libusb): 対応デバイスの発見順リストの番号 (port=0 が1台目) +// チップ種別では分けず、対応する全デバイス (DL-1xx / T6 / MS91xx) +// が同じリストに混在する。DL-165 と T6 を同時に挿せば発見順に +// 0, 1 が振られる +// + +typedef struct { + uint8_t port; // ホストコントローラ番号 + uint8_t pin_dp; // [Pico] D+ の GPIO 番号 + uint8_t pin_dm; // [Pico] D- の GPIO 番号 + // pin_dp と隣接必須・順序は自由 + // 非隣接は登録エラー (NULL が返る) + uint16_t width; // 接続時に設定する解像度 (0 = 自動選択) + uint16_t height; + uint8_t refresh_hz; // リフレッシュレート (0 = 60) + bool no_auto_mode; // true:接続時のモード自動設定を行わない + // (アプリが set_mode 群を自分で呼ぶ) + bool shadow_fb; // true:接続時にシャドウFB (差分更新) を有効化 + // メモリが確保できない環境では無効のまま動く + // (usb_disp_set_shadow 参照) + bool depth24; // true:24bit カラー (RGB888) で動作する。 + // チップ/経路が非対応なら 16bit のまま動く + // (実際の深度は usb_disp_depth() で確認) + bool ignore_edid; // true:EDID をライブラリ側から一切読まない。 + // モードは width/height だけで決め、 + // EDID による自動選択を行わない。 + // 指定解像度を強制したい場合に使う。 + // アプリが明示的に呼ぶ usb_disp_read_edid() + // は影響を受けない (情報表示用に読める) + usb_disp_edid_policy_t edid_policy; // 自動選択のポリシー (既定 = MAX) +} usb_disp_config_t; + + +// ---- ライフサイクル ---- + +// サブシステム初期化 (最初に一度) +void usb_disp_init(void); + +// ディスプレイポートを登録。usb_disp_start() の前に呼ぶ +// cfg はコピーされるのでスタック上の一時変数でよい +// 戻り値: ハンドル (上限到達で NULL) +usb_disp_t *usb_disp_add_cfg(const usb_disp_config_t *cfg); + +#ifdef __cplusplus +#if USB_DISP_PORT_PICO +usb_disp_t *usb_disp_add(uint8_t port, uint8_t pin_dp, uint8_t pin_dm, + uint16_t width = 0, uint16_t height = 0, + bool ignore_edid = false); +#else +// Pico 以外はホストのピンが固定 → pin_dp/pin_dm も省略可 +usb_disp_t *usb_disp_add(uint8_t port, uint8_t pin_dp = 0, uint8_t pin_dm = 0, + uint16_t width = 0, uint16_t height = 0, + bool ignore_edid = false); +#endif +#else +// C では 6 引数フル指定 (width=0, height=0 が自動選択) +usb_disp_t *usb_disp_add(uint8_t port, uint8_t pin_dp, uint8_t pin_dm, + uint16_t width, uint16_t height, bool ignore_edid); +#endif + +// 登録済み全ポートのホストを起動 (Pico: core1 常駐)。add 群の後に一度呼ぶ。 +// arduino-pico でスケッチが setup1()/loop1() を定義している場合は core1 を +// 起動できないため、自動で手動モード (下記) に切り替わる (ログに案内が出る) +void usb_disp_start(void); + +// 手動サービスモード (Pico のみ): +// usb_disp_start() の代わりに usb_disp_start_manual() を呼び、以後 +// usb_disp_task() を専有できるコンテキストから休みなく呼び続ける。 +// arduino-pico なら +// void loop1() { usb_disp_task(); } +// が定石 (core1 で回る。loop1 に他の処理を混ぜないこと。SOF 1ms 維持が +// 必要なため、呼び出し間隔が空くとデバイスがサスペンドする)。 +// ESP32 / PC では start_manual は start と同義、task は no-op +// (共通ソースがそのままビルドできるようにするための空実装) +void usb_disp_start_manual(void); +void usb_disp_task(void); + +uint8_t usb_disp_count(void); +usb_disp_t *usb_disp_at(uint8_t idx); +uint8_t usb_disp_index(usb_disp_t *d); + +// 各ディスプレイを定期的にポーリング。接続/切断/エニュメレーションを処理。 +// READY へ変化 / 切断 / 遅延して読めた EDID による解像度切替で true を返す +// (呼び出し側で再描画等のトリガに使う) +bool usb_disp_poll(usb_disp_t *d); + +// ---- 状態 ---- +bool usb_disp_ready(usb_disp_t *d); +uint16_t usb_disp_width(usb_disp_t *d); +uint16_t usb_disp_height(usb_disp_t *d); +uint16_t usb_disp_vid(usb_disp_t *d); +uint16_t usb_disp_pid(usb_disp_t *d); +uint64_t usb_disp_stat_bytes(usb_disp_t *d); // 累積バルク送信バイト数 +#if USB_DISP_PORT_PICO +usb_disp_udh_host_t *usb_disp_get_host(usb_disp_t *d); // 統計アクセス用 (Pico のみ) +#endif + +// ---- チップ判別 (エニュメレーション完了時に自動実行) ---- +usb_disp_chip_t usb_disp_chip(usb_disp_t *d); +uint32_t usb_disp_max_area(usb_disp_t *d); // gen1.5: ピクセル数上限 (0=不明) +const char *usb_disp_chip_name(usb_disp_t *d); // "DL-165" 等 (SKU 未判別は + // "DL-1xx" 等のプロトコル名、 + // それも不明なら "???") + +// ---- 製品型番判別 (VID/PID 照合) ---- +// チップ判別とは独立に、既知製品の型番文字列 ("BUFFALO GX-DVI/U2" 等) を +// 返す。リストは usb_disp_model.h (実機確認済みの製品のみ登録)。 +// 戻り値: 型番文字列 (リストに無い組は NULL) +const char *usb_disp_model_name(uint16_t vid, uint16_t pid); +// 接続中デバイス版 (未接続・リストに無い場合は NULL) +const char *usb_disp_model(usb_disp_t *d); + +// ---- モード設定 (READY 後) ---- +// いずれもチップのピクセル数上限 (max_area) を超えるモードは拒否する +// 60Hz で内蔵リストに一致があれば VESA/CEA タイミング、無ければ +// CVT-RB (Reduced Blanking) 計算で任意解像度のタイミングを生成する +bool usb_disp_set_mode(usb_disp_t *d, uint16_t width, uint16_t height); +bool usb_disp_set_mode_hz(usb_disp_t *d, uint16_t width, uint16_t height, + uint8_t refresh_hz); +bool usb_disp_set_mode_ex(usb_disp_t *d, const usb_disp_mode_t *mode); +// 解像度の自動選択 (poll が接続時に呼ぶもの) +// edid_policy = MAX : EDID 記載の最大解像度 → 内蔵リスト大きい順 +// edid_policy = PREFERRED : EDID preferred → 内蔵リスト大きい順 +// ignore_edid = true : EDID を読まず内蔵リスト大きい順のみ +bool usb_disp_set_auto_mode(usb_disp_t *d); + +// ---- EDID の使い方 ---- +// 自動選択ポリシーの変更 (接続前でも READY 中でも可) +// 既に決まったモードは変わらないので、その場で反映したければ +// 続けて usb_disp_set_auto_mode() を呼ぶこと +void usb_disp_set_edid_policy(usb_disp_t *d, usb_disp_edid_policy_t policy); +usb_disp_edid_policy_t usb_disp_edid_policy(usb_disp_t *d); + +// usb_disp_config_t.ignore_edid と同じ設定を後から変更する +// (接続前でも READY 中でも可。READY 中に有効化しても既に決まった +// モードは変わらないので、必要なら usb_disp_set_mode() を呼び直すこと)。 +void usb_disp_set_ignore_edid(usb_disp_t *d, bool on); +bool usb_disp_ignore_edid(usb_disp_t *d); + +// 内蔵モードリスト (60Hz, VESA DMT / CEA) +uint8_t usb_disp_builtin_mode_count(void); +const usb_disp_mode_t *usb_disp_builtin_mode(uint8_t idx); + +// ---- カラー深度 (既定 16bit = RGB565) ---- +// READY 中に切り替えるとモード再設定 (画面クリア) を伴う +// 戻り値: 要求深度で動作できたか (非対応チップへの 24 要求は false で 16bit のまま) +// 実際の深度は usb_disp_depth() で確認 +bool usb_disp_set_depth(usb_disp_t *d, uint8_t bits); // 16 or 24 + // (USB_DISP_FMT_RGB565/888 をそのまま渡してもよい) +uint8_t usb_disp_depth(usb_disp_t *d); + +// 現在のモード (タイミング込み) を取得。モード未設定なら false。 +bool usb_disp_current_mode(usb_disp_t *d, usb_disp_mode_t *out); +// 出力リフレッシュレート [Hz] (タイミングから計算、四捨五入。未設定は 0) +// マイコン側の描画レートとは独立 (チップは内部FBから常時この周期で走査出力) +uint16_t usb_disp_refresh_hz(usb_disp_t *d); + +// ---- 操作 (READY 後) ---- +// update/fill は実解像度 (usb_disp_width/height) で右端・下端を +// クリッピングする: はみ出した矩形は画面内の部分だけ描かれ、 +// 完全に画面外なら何もしない (true を返す)。 +// 矩形更新は3形態: +// usb_disp_update(..., fmt) : fmt でピクセル形式を選ぶ汎用形。 +// C++ では fmt 省略可 (= RGB565) +// usb_disp_update_565(...) : RGB565 固定 (uint16_t*) +// usb_disp_update_888(...) : RGB888 固定 (uint8_t*, メモリ順 B,G,R = +// LVGL の LV_COLOR_FORMAT_RGB888 と同じ並び) +// stride_px はソース配列の1行あたりピクセル数 (矩形幅と同じなら w を渡す。 +// 0 は「全行が先頭行の繰り返し」= 1行分のバッファで単色帯などを描ける)。 +// いずれも表示側の深度 (usb_disp_set_depth) と独立に使える。 +// 深度と形式が異なる場合はライブラリが変換するので、 +// 「GUI は 565、写真領域だけ 888」のような混在も可能 +bool usb_disp_update_565(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint16_t *rgb565, + uint32_t stride_px); +bool usb_disp_update_888(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint8_t *px, uint32_t stride_px); +#ifdef __cplusplus +bool usb_disp_update(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const void *px, uint32_t stride_px, + usb_disp_fmt_t fmt = USB_DISP_FMT_RGB565); +#else +bool usb_disp_update(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const void *px, uint32_t stride_px, + usb_disp_fmt_t fmt); +#endif +bool usb_disp_fill(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, uint16_t color); +bool usb_disp_flush(usb_disp_t *d, uint32_t timeout_ms); + +// 画面内矩形コピー (DL の COPY16 コマンド、ピクセル再送なし) +// スクロールや矩形移動に。重なりは行順/分割で自動処理。読み出しは不可 +bool usb_disp_copy(usb_disp_t *d, uint16_t sx, uint16_t sy, uint16_t dx, + uint16_t dy, uint16_t w, uint16_t h); + +// EDID 読み出し (先頭から len バイト)。読めたバイト数を返す (0 = 読めず) +// EDID は 128B のベースブロック + 拡張ブロック (個数は byte 126) から成る +// 全体を取るなら len = 128 * (1 + buf[126]) で読み直すか、下の _at を使う +// DL-1xx はレジスタのオフセットが 8bit なので 256 バイトが読み出し上限 +uint16_t usb_disp_read_edid(usb_disp_t *d, uint8_t *buf, uint16_t len); +// 同上、offset バイト目から読む版 (拡張ブロックの読み出し用) +uint16_t usb_disp_read_edid_at(usb_disp_t *d, uint16_t offset, uint8_t *buf, + uint16_t len); +// EDID の総バイト数 (拡張ブロック込み。0 = 読めない / ignore_edid) +uint16_t usb_disp_edid_size(usb_disp_t *d); +// 接続先が EDID でこの解像度への対応を明示しているか +// ベースブロック (established / standard / DTD1-4) に加え、CEA-861 拡張 +// ブロックの Video Data Block (VIC) と DTD も見る +// EDID が読めない・記載が無い・ignore_edid のときは false +// 「指定解像度を出してよいか」をアプリ側で判断するのに使う +bool usb_disp_edid_supports(usb_disp_t *d, uint16_t width, uint16_t height); +// 接続先が EDID で申告するモードのうち、チップ上限に収まる最大の解像度 +// edid_policy = MAX の自動選択が選ぶものと同じ +// 「この解像度を出して大丈夫か」をアプリ側で判断するのに使う +// (これ以下なら、明示の記載が無くても表示側がスケーリングで受けられる +// ことが多い)。EDID が読めない / 収まるものが無い / ignore_edid は false +bool usb_disp_edid_max_mode(usb_disp_t *d, uint16_t *width, uint16_t *height); +bool usb_disp_blank(usb_disp_t *d, bool on); +void usb_disp_force_reenum(usb_disp_t *d); + +// ---- シャドウFB (差分更新) ---- +// スパン更新型プロトコル (DisplayLink DL-1xx) 専用。 +// フルフレーム型 (T6/MS91xx) では効果がないため有効にならない +// (READY 中の有効化要求は false を返す。接続前の要求は接続時に +// "shadow FB not applicable" をログして無効のまま)。 +// 有効にすると解像度と同サイズのシャドウFBを usb_disp_hal_fb_alloc +// (ESP32=PSRAM / PC=malloc / Pico=非対応) で確保し、update/fill は +// シャドウと比較して「変化した行の変化した区間」だけを送る +// 静止部分の多い画面で実効フレームレートが大きく向上する +// 有効化時は画面とシャドウを黒で同期する (一瞬黒になる) +// メモリが確保できなければ false を返し従来動作のまま +// モード変更時は自動で再確保・再同期される +bool usb_disp_set_shadow(usb_disp_t *d, bool on); +bool usb_disp_shadow_active(usb_disp_t *d); +// デバッグ用: シャドウFBの行先頭ポインタ (無効時 NULL。24bit 動作中は +// シャドウが 3B/px になり uint16_t ビューが合わないため常に NULL)。 +// 読み出し専用で使うこと +const uint16_t *usb_disp_shadow_row(usb_disp_t *d, uint16_t row); + +// 対応チップの VID/PID か (HAL のデバイス走査用。アプリからも使用可) +bool usb_disp_supported_device(uint16_t vid, uint16_t pid); + +// ---- ログ ---- +// 既定ではログは出ない。出したい場合 +// - Arduino: usb_disp_set_log(true) で Serial へ出るようになる +// - 全プラットフォーム共通: usb_disp_log() をアプリで定義すると +// 完全に差し替えられる (weak 上書き。この場合 set_log は無関係) +void usb_disp_set_log(bool on); +void usb_disp_log(const char *fmt, ...); + +#ifdef __cplusplus +} +#endif + +// ---- Arduino 用の自動グルー ---- +// スケッチが LovyanGFX / LVGL を include している場合に限り、追加の +// include なしで連携クラス/ヘルパーが使えるようにする: +// - LovyanGFX: LGFX_USB_Disp (パネルアダプタ) +// - LVGL v9 : usb_disp_lvgl_create() (画面/バッファ/flush_cb 一括セットアップ) +#if defined(ARDUINO) && defined(__cplusplus) + #if __has_include() + #include "lgfx/Panel_USB_Disp.hpp" + #endif + #if __has_include() + #include "usb_disp_lvgl.h" + #endif +#endif + +#endif // USB_DISP_H_ + + diff --git a/c_mpos/usb/upstream/usb_disp_hal.h b/c_mpos/usb/upstream/usb_disp_hal.h new file mode 100644 index 000000000..7a8380906 --- /dev/null +++ b/c_mpos/usb/upstream/usb_disp_hal.h @@ -0,0 +1,172 @@ +// +// ###################################################################### +// +// usb_disp_hal - USB ホストトランスポート抽象層 (HAL) +// +// プラットフォーム固有の USB ホスト実装を分離するインターフェース +// +// - usb_disp_hal_pico.cpp : [RP2040/RP2350] PIO USB FS ホスト +// - usb_disp_hal_esp32.cpp : [ESP32-S2/S3/P4] ESP-IDF usb_host +// - usb_disp_hal_teensy.cpp : [Teensy 4.x] Teensyduino USBHost_t36 +// - usb_disp_hal_libusb.cpp : [PC] libusb host +// +// Copyright (C) 2026 +// Hideto Kikuchi / PJラボ (@pcjpnet) - https://pc-jp.net/ +// +// ###################################################################### +// + +#ifndef USB_DISP_HAL_H_ +#define USB_DISP_HAL_H_ + +#include +#include + +#include "usb_disp.h" // usb_disp_config_t / プラットフォーム判定マクロ + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct usb_disp_hal usb_disp_hal_t; // バックエンド定義 + +// ポート登録 +// usb_disp_hal_start() の前に呼ぶ。失敗 (上限/ピン不正) は NULL。 +usb_disp_hal_t *usb_disp_hal_add(const usb_disp_config_t *cfg); + +// 全ポート登録後に一度だけ呼ぶ (ホストスタック/ワーカーの起動) +void usb_disp_hal_start(void); + +// MPOS: ランタイムのホストモード終了 (deactivate パス)。タスク停止、 +// デバイスクローズ、クライアント登録解除、usb_host_uninstall まで行う。 +// 冪等。後は usb_disp_hal_start() が再び使える。 +void usb_disp_hal_stop(void); + +// 手動サービスモード (Pico のみ実体あり、他は start/no-op と同義) +// start の代わりに start_manual を呼び、以後 task を専有コンテキスト +// (arduino-pico の loop1() 等) から休みなく呼び続ける +void usb_disp_hal_start_manual(void); +void usb_disp_hal_task(void); + +// 定期的に呼ぶ。接続検出・エニュメレーションを進める (ESP32 では no-op) +void usb_disp_hal_poll(usb_disp_hal_t *h); + +// エニュメレーション完了済みで ctrl/bulk が使えるか +bool usb_disp_hal_attached(usb_disp_hal_t *h); +uint16_t usb_disp_hal_vid(usb_disp_hal_t *h); +uint16_t usb_disp_hal_pid(usb_disp_hal_t *h); + +// コントロール転送。setup は 8 バイトの SETUP パケット +// data は方向に応じて送信元/受信先。actual は NULL 可。 +bool usb_disp_hal_ctrl(usb_disp_hal_t *h, const uint8_t setup[8], void *data, + uint16_t *actual); + +// バルク OUT。戻り値は受理したバイト数 (len 未満 = 失敗/タイムアウト) +uint32_t usb_disp_hal_bulk_write(usb_disp_hal_t *h, const void *data, + uint32_t len); +// 送信完了待ち +bool usb_disp_hal_bulk_flush(usb_disp_hal_t *h, uint32_t timeout_ms); +// ストリーム終端に ZLP (長さ0パケット) を置く。 +// 保留中の書き込みがあればそれを送出した上で終端する。 +// フレーム長が常に mps の倍数になるプロトコル (MS913x) 用。 +// bulk_write の後・bulk_flush の前に呼ぶ。 +// (Pico バックエンドは対応チップが無いため no-op) +bool usb_disp_hal_bulk_zlp(usb_disp_hal_t *h); + +// 転送境界: 保留中の書き込みを「ここまでで1つの転送」として送出する +// (末尾が mps 未満ならショートパケットになる)。 +// 転送単位に意味があるプロトコル (T6 の 32B セレクタ等) 用。 +// (Pico は no-op = 常時ストリーム) +bool usb_disp_hal_bulk_split(usb_disp_hal_t *h); + +// 再エニュメレーション要求 (非対応デバイスの周期再試行等。 +// スタックが自動管理するバックエンドでは no-op でよい) +void usb_disp_hal_request_reenum(usb_disp_hal_t *h); + +// ---- ハブポート回復 (低速起動デバイスの単発列挙失敗対策) ---- +// IDF の ext_port ドライバはハブポートのリセットを既定で1回しか試行 +// しないため、起動中の Full-Speed デバイスは CHECK_SHORT_DEV_DESC の +// 失敗でポートごと永続 DISABLE になる (Linux xHCI はリトライする)。 +// 下記は標準のハブクラス要求でそのポートだけ再列挙させる: +// - watchdog (usb_disp_hal_poll から自動): 接続済み・未列挙のまま +// ~4s のポートに PORT_RESET を最大3回 (backoff 付き)、それでも +// 駄目なら PORT_POWER の off/on を1回、最後まで駄目なら抜き差し +// まで沈黙する。エピソードはバスアドレス数の増加でのみ「列挙 +// 済み」として閉じる (enabled ビットだけでは閉じない — 未割当の +// まま有効化される場合があるため)。有効なポート (正常動作中の +// 機器・列挙処理中) と High-Speed ポート (下流ハブ。リセットで +// サブツリーごと落ち IDF が abort する) には一切触れない。接続 +// 中はエピソード状態を捨てる。連続失敗するハブは 30s 休む。 +// 遷移と回数は [HUB] ログに常時出す。エピソード無しの enabled +// ポートは1回だけ pointer を出し、自動では触れない (idle ポートの +// 自動リセットは uplink のハブを道連れにし得るため — 手動の +// reset_port() を使うこと)。 +// set_watchdog(false) で停止できる (既定 = 有効)。 +// - reset_hub_port: 同じ操作の手動版。power_cycle が真なら VBUS を +// 落として入れ直す (確実だが低速。ganged-power ハブでは sibling +// ポートも落ちる)。REPL からの回復用。 +// ESP32 以外のバックエンドでは未実装 (false/0 を返す) でよい。 +typedef struct { + uint8_t hub_addr; // ハブのデバイスアドレス (bus_devices と同じ) + uint8_t port; // ハブ相対ポート番号 (1 始まり) + bool connected; // デバイスが接続しているか + bool enabled; // ポートが有効か (wedged 状態では false) + bool high_speed; // High-Speed 機器 (ハブ同士の接続等) +} usb_disp_hub_port_t; +uint8_t usb_disp_hal_hub_ports(usb_disp_hub_port_t *out, uint8_t max); +bool usb_disp_hal_reset_hub_port(uint8_t hub_addr, uint8_t port, + bool power_cycle, bool force); +void usb_disp_hal_set_watchdog(bool on); +bool usb_disp_hal_watchdog(void); +void usb_disp_hal_set_auto_reset_idle(bool on); +bool usb_disp_hal_auto_reset_idle(void); + +// Linux-style bus listing ("Bus 001 Device 002: ID 17e9:028f ...") into +// out (NUL-terminated); returns bytes written. Single OTG controller, +// so the bus is always 001 and there is no root-hub line (the root +// port has no address). Read-only, safe to call any time. +uint16_t usb_disp_hal_lsusb(char *out, uint16_t maxlen); +// Address of the display device held open by this client (true), or +// false when none is held. The stack's address list only contains idle +// devices, so callers that enumerate the bus must re-add this one. +bool usb_disp_hal_claimed_addr(uint8_t *addr); + +// 単調ミリ秒カウンタ (コアのタイマー用) +uint32_t usb_disp_hal_ms(void); + +// 累積バルク送信バイト数 (統計用。シャドウFB差分更新の効果測定など) +uint64_t usb_disp_hal_stat_bytes(usb_disp_hal_t *h); + +#if USB_DISP_PORT_PICO +// Pico 固有: 統計アクセス用に udh ホストハンドルを公開 (usb_disp_get_host 用)。 +// 宣言をここに置くことで定義側 (usb_disp_hal_pico.cpp) と呼び出し側 +// (usb_disp.cpp) が同じ宣言を見る = シグネチャ不一致がコンパイルエラーになる +usb_disp_udh_host_t *usb_disp_hal_pico_host(usb_disp_hal_t *h); +#endif + +// コンフィグディスクリプタに埋め込まれた DisplayLink ベンダーディスクリプタ +// (bDescriptorType=0x5F、エニュメレーション時に捕捉) を buf へコピーして +// 長さを返す (無ければ 0)。GET_DESCRIPTOR type 0x5F 要求に応答しない個体の +// フォールバック用 (udlfb: dlfb_parse_vendor_descriptor と同じ二段構え) +uint16_t usb_disp_hal_vendor_desc(usb_disp_hal_t *h, void *buf, + uint16_t maxlen); + +// ---- 大容量フレームバッファ用メモリ (シャドウFB 等) ---- +// プラットフォームの「大きくて多少遅くてもよい」メモリから確保する: +// - Pico : 常に NULL (内蔵 RAM に FullHD 級は置けない → 機能オフ) +// - ESP32: PSRAM (MALLOC_CAP_SPIRAM)。合計確保量を +// USB_DISP_PSRAM_LIMIT_KB (コンパイル時定数, 0=無制限) の +// 範囲で管理し、超える要求には NULL を返す +// - Teensy 4.1: 増設 PSRAM (extmem_malloc)。未実装ボードでは NULL +// - PC : malloc +// 呼び出し側は NULL を「この環境では使えない」として扱うこと。 +void *usb_disp_hal_fb_alloc(uint32_t size); +void usb_disp_hal_fb_free(void *p, uint32_t size); +uint32_t usb_disp_hal_fb_used(void); // 現在の合計確保量 [byte] + +#ifdef __cplusplus +} +#endif + +#endif // USB_DISP_HAL_H_ + diff --git a/c_mpos/usb/upstream/usb_disp_hal_esp32.cpp b/c_mpos/usb/upstream/usb_disp_hal_esp32.cpp new file mode 100644 index 000000000..6531879cd --- /dev/null +++ b/c_mpos/usb/upstream/usb_disp_hal_esp32.cpp @@ -0,0 +1,1928 @@ +// +// ###################################################################### +// +// usb_disp_hal_esp32 - ESP32-S2/S3/P4 バックエンド +// +// ESP-IDF の usb_host ライブラリ (DWC_OTG ハードウェアホスト) を +// usb_disp_hal インターフェースで包む。 +// エニュメレーションは usb_host スタックが行う。 +// - デーモン/クライアントタスクの運転 +// - NEW_DEV でディスクリプタを読みバルク OUT EP を構成 +// - コントロール転送とバルク OUT リング (4 x 8KB) の提供 +// DL プロトコルはコア (usb_disp.cpp) がそのまま動く。 +// +// Copyright (C) 2026 +// Hideto Kikuchi / PJラボ (@pcjpnet) - https://pc-jp.net/ +// +// ###################################################################### +// + +#include "usb_disp_hal.h" +#include "usb_hid.h" // usb_hid_held_handle() for lsusb (MPOS HID, src/) + +#if USB_DISP_PORT_ESP32 + +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" +#include "esp_timer.h" +#include "esp_heap_caps.h" +#include "usb/usb_host.h" + +#define USB_DISP_BULK_XFER_COUNT 4 +#define USB_DISP_BULK_XFER_SIZE 8192 +#define USB_DISP_CTRL_MAX_DATA 512 +#define USB_DISP_CTRL_TIMEOUT_MS 1000 + +// ホスト起動前の切断期間 [ms] (usb_disp_hal_start のコメント参照) +#ifndef USB_DISP_ESP32_SETTLE_MS +#define USB_DISP_ESP32_SETTLE_MS 200 +#endif + +struct usb_disp_hal { + bool in_use; + + // デバイス + usb_device_handle_t dev; + volatile bool scan_needed; // NEW_DEV 通知 → バス上の全デバイス走査 + volatile bool gone; // DEV_GONE 通知 (自分のデバイス) + uint32_t scan_retry_ms; // 開けなかったデバイスの再走査時刻 (0=なし) + uint8_t scan_retry_cnt; // 再走査の残り試行回数 (NEW_DEV で補充) + volatile bool attached; + uint16_t vid, pid; + uint8_t bulk_ep; + uint16_t bulk_mps; + uint8_t iface; + bool iface_claimed; + uint32_t urb_max; // 1 URB の最大バイト (通常 USB_DISP_BULK_XFER_SIZE) + volatile bool ep_recover; // バルクEPエラー → パイプ復旧要求 + volatile uint32_t ep_recover_cnt; + volatile bool pending_probe; // 他コンフィグ探索をアプリタスクで実行 + uint8_t probe_reenum_cnt; // コンフィグ切替→再列挙の試行回数 + bool full_speed; + uint8_t vdesc[64]; // config 内 0x5F ベンダーディスクリプタ + uint8_t vdesc_len; + + // コントロール転送 (直列化) + usb_transfer_t *ctrl_xfer; + SemaphoreHandle_t ctrl_mutex; + SemaphoreHandle_t ctrl_done; + + // バルク OUT リング。単一エンドポイントのバルク転送は投入順に + // 完了するので、空きスロットはラウンドロビンで一意に決まる + usb_transfer_t *bulk_xfer[USB_DISP_BULK_XFER_COUNT]; + SemaphoreHandle_t bulk_free; // カウンティング (空きスロット数) + uint8_t next_slot; // 次に使うスロット番号 + usb_transfer_t *cur; // 書き込み中スロット (未サブミット) + uint32_t cur_fill; + + // 統計 (バルク完了コールバックで更新) + volatile uint32_t bulk_err; // status != COMPLETED の完了数 + volatile uint32_t bulk_short; // actual < num_bytes の完了数 + volatile int16_t last_err_status; // 直近のエラー status (enum 値) + uint64_t stat_bytes; // 累積バルク送信バイト数 +}; + +static struct usb_disp_hal s_hal[USB_DISP_MAX]; +static uint8_t s_nhal = 0; +static usb_host_client_handle_t s_client; +static bool s_started = false; +// MPOS runtime host-mode exit: set by usb_disp_hal_stop() to break the +// task loops below (each self-deletes). Cleared in hal_start (NOT at the +// end of stop): a task waking late must still see the flag and exit, never +// revive — revived zombies once flooded the DWC2 IRQ allocator every 10ms. +// Done flags let hal_stop join instead of hoping a fixed delay suffices. +static volatile bool s_stop_tasks = false; +static volatile bool s_daemon_task_done = false; +static volatile bool s_client_task_done = false; + +uint32_t usb_disp_hal_ms(void) { + return (uint32_t)(esp_timer_get_time() / 1000); +} + +// --------------------------------------------------------------- +// 転送コールバック (usb_host クライアントタスクのコンテキスト) +// --------------------------------------------------------------- + +static void ctrl_xfer_cb(usb_transfer_t *xfer) { + struct usb_disp_hal *h = (struct usb_disp_hal *)xfer->context; + xSemaphoreGive(h->ctrl_done); +} + +static void bulk_xfer_cb(usb_transfer_t *xfer) { + struct usb_disp_hal *h = (struct usb_disp_hal *)xfer->context; + if (xfer->status != USB_TRANSFER_STATUS_COMPLETED) { + // volatile への ++ は C++20 で非推奨 (-Wvolatile) のため読み書きを分ける + h->bulk_err = h->bulk_err + 1; + h->last_err_status = (int16_t)xfer->status; + // エラーでパイプは HALT 状態になり以後の submit が + // ESP_ERR_INVALID_STATE で全滅する → client_task に復旧を頼む + // (CANCELED は復旧処理自身の flush によるものなので除く) + if (xfer->status != USB_TRANSFER_STATUS_CANCELED) h->ep_recover = true; + if (h->bulk_err <= 16 || (h->bulk_err & 0x3F) == 0) { + usb_disp_log("[HAL] bulk error: status=%d (n=%d/%d, total err=%lu)", + (int)xfer->status, xfer->actual_num_bytes, + xfer->num_bytes, (unsigned long)h->bulk_err); + } + } else if (xfer->actual_num_bytes < xfer->num_bytes) { + h->bulk_short = h->bulk_short + 1; + usb_disp_log("[HAL] bulk short: %d/%d", xfer->actual_num_bytes, + xfer->num_bytes); + } + xSemaphoreGive(h->bulk_free); +} + +// --------------------------------------------------------------- +// コントロール転送 (内部共通。attached 前のエニュメ診断でも使う) +// --------------------------------------------------------------- + +static bool ctrl_common(struct usb_disp_hal *h, const uint8_t setup[8], + void *data, uint16_t *actual) { + if (h->ctrl_xfer == NULL) return false; + uint16_t wLength = (uint16_t)(setup[6] | (setup[7] << 8)); + if (wLength > USB_DISP_CTRL_MAX_DATA) return false; + bool dir_in = (setup[0] & 0x80) != 0; + + if (xSemaphoreTake(h->ctrl_mutex, + pdMS_TO_TICKS(USB_DISP_CTRL_TIMEOUT_MS)) != pdTRUE) + return false; + + usb_transfer_t *x = h->ctrl_xfer; + memcpy(x->data_buffer, setup, 8); + if (!dir_in && wLength && data) memcpy(x->data_buffer + 8, data, wLength); + x->num_bytes = 8 + wLength; + + xSemaphoreTake(h->ctrl_done, 0); // 念のためクリア + bool ok = (usb_host_transfer_submit_control(s_client, x) == ESP_OK); + if (ok) { + ok = (xSemaphoreTake(h->ctrl_done, + pdMS_TO_TICKS(USB_DISP_CTRL_TIMEOUT_MS)) == + pdTRUE); + } + if (ok && x->status != USB_TRANSFER_STATUS_COMPLETED) { + usb_disp_log("[HAL] ctrl error: status=%d (bReq=%02X)", (int)x->status, + setup[1]); + ok = false; + } + if (ok) { + uint16_t got = + (x->actual_num_bytes >= 8) ? (uint16_t)(x->actual_num_bytes - 8) + : 0; + if (dir_in && data && got) memcpy(data, x->data_buffer + 8, got); + if (actual) *actual = got; + } + xSemaphoreGive(h->ctrl_mutex); + return ok; +} + +static bool raw_ctrl(struct usb_disp_hal *h, uint8_t bmRequestType, + uint8_t bRequest, uint16_t wValue, uint16_t wIndex, + void *data, uint16_t wLength, uint16_t *actual) { + uint8_t setup[8]; + setup[0] = bmRequestType; + setup[1] = bRequest; + setup[2] = (uint8_t)wValue; + setup[3] = (uint8_t)(wValue >> 8); + setup[4] = (uint8_t)wIndex; + setup[5] = (uint8_t)(wIndex >> 8); + setup[6] = (uint8_t)wLength; + setup[7] = (uint8_t)(wLength >> 8); + return ctrl_common(h, setup, data, actual); +} + +// --------------------------------------------------------------- +// デバイス接続/切断 (クライアントタスクから呼ぶ) +// --------------------------------------------------------------- + +static void dump_hex(const char *tag, const uint8_t *buf, uint16_t len) { + if (len > 192) len = 192; + for (uint16_t i = 0; i < len; i += 16) { + char hex[16 * 3 + 1]; + int n = 0; + for (uint16_t j = i; j < i + 16 && j < len; j++) + n += snprintf(hex + n, sizeof(hex) - n, "%02X ", buf[j]); + usb_disp_log("[ENUM] %s %02X: %s", tag, i, hex); + } +} + +static void finish_setup(struct usb_disp_hal *h, const uint8_t *scan, + uint16_t scan_len, bool full_speed); + +// コンフィグディスクリプタ列に vendor class (0xFF) のインターフェースが +// あるか +static bool cfg_has_vendor_if(const uint8_t *blob, uint16_t len) { + const uint8_t *p = blob; + const uint8_t *end = blob + len; + while (p + 1 < end && p[0] >= 2 && p + p[0] <= end) { + if (p[1] == 0x04 && p[0] >= 9 && p[5] == 0xFF) return true; + p += p[0]; + } + return false; +} + +// コンフィグディスクリプタ列から採用するバルク OUT の EP 記述子 (7バイト)を返す +// (finish_setup と同じ方針: alt0 のみ・vendor class IF 優先) +static const uint8_t *find_bulk_out_ep(const uint8_t *blob, uint16_t len) { + const uint8_t *p = blob; + const uint8_t *end = blob + len; + const uint8_t *found = NULL; + uint8_t cur_alt = 0; + bool cur_vendor = false, got_vendor = false; + while (p + 1 < end && p[0] >= 2 && p + p[0] <= end) { + if (p[1] == 0x04 && p[0] >= 9) { // INTERFACE + cur_alt = p[3]; + cur_vendor = (p[5] == 0xFF); + } else if (p[1] == 0x05 && p[0] >= 7) { // ENDPOINT + if ((p[3] & 0x03) == 0x02 && (p[2] & 0x80) == 0 && cur_alt == 0 && + (found == NULL || (cur_vendor && !got_vendor))) { + found = p; + got_vendor = cur_vendor; + } + } + p += p[0]; + } + return found; +} + +// セットアップ前半 (client_task から呼ぶ): デバイスを開いてディスクリプタを確認する。 +// ハブ (class 09、スタックが内部処理する) と非 DisplayLink は開かずに閉じて false。 +// アクティブコンフィグに vendor class IF があれば、そのまま finish_setup まで完了する。 +// 無い場合 (DisplayLink オートインストール機は Mass Storage だけのコンフィグで列挙される) は、 +// 他コンフィグの生読みが必要になるが、コントロール転送の完了イベントを処理するのは +// client_task 自身なのでここではブロックできない → +// pending_probe を立てて usb_disp_hal_poll(アプリタスク) に後半を任せる +// 戻り値: true = このデバイスを掴んだ (h->dev != NULL のまま) +static bool device_setup(struct usb_disp_hal *h, uint8_t addr) { + esp_err_t err = usb_host_device_open(s_client, addr, &h->dev); + if (err != ESP_OK) { + // ESP_ERR_INVALID_STATE: まだ列挙中か切断処理中のデバイス。 + // (arduino-esp32 3.3.11 = IDF のハブサポート有効ビルドでは + // "usbh_devs_open error: ESP_ERR_INVALID_STATE" の E ログも出るが、 + // 一時的な状態で害はない) + // 列挙完了時の NEW_DEV で拾い直せるが、イベントを先に消費して + // しまった場合の取りこぼし対策として時限再走査も仕掛けておく + usb_disp_log("[HAL] device_open failed (addr=%u err=0x%X)", addr, + (unsigned)err); + h->dev = NULL; + if (err == ESP_ERR_INVALID_STATE && h->scan_retry_cnt > 0) { + h->scan_retry_cnt--; + h->scan_retry_ms = usb_disp_hal_ms() + 250; + } + return false; + } + const usb_device_desc_t *ddesc; + const usb_config_desc_t *cdesc; + if (usb_host_get_device_descriptor(h->dev, &ddesc) != ESP_OK || + usb_host_get_active_config_descriptor(h->dev, &cdesc) != ESP_OK) { + usb_disp_log("[HAL] descriptor read failed"); + usb_host_device_close(s_client, h->dev); + h->dev = NULL; + return false; + } + if (ddesc->bDeviceClass == 0x09) { // ハブ: スタックが下流を列挙する + usb_disp_log("[ENUM] addr=%u hub %04X:%04X -> stack handles, " + "waiting for downstream", addr, ddesc->idVendor, + ddesc->idProduct); + usb_host_device_close(s_client, h->dev); + h->dev = NULL; + return false; + } + if (!usb_disp_supported_device(ddesc->idVendor, ddesc->idProduct)) { + // 非対応デバイス (ハブ同居機器など) + usb_disp_log("[ENUM] addr=%u ignoring unsupported %04X:%04X", addr, + ddesc->idVendor, ddesc->idProduct); + usb_host_device_close(s_client, h->dev); + h->dev = NULL; + return false; + } + h->vid = ddesc->idVendor; + h->pid = ddesc->idProduct; + + usb_device_info_t dinfo; + h->full_speed = true; + if (usb_host_device_info(h->dev, &dinfo) == ESP_OK) { + h->full_speed = (dinfo.speed != USB_SPEED_HIGH); + } + usb_disp_log("[ENUM] VID=%04X PID=%04X speed=%s ncfgs=%u cfg total=%u", + h->vid, h->pid, h->full_speed ? "FS" : "HS", + ddesc->bNumConfigurations, cdesc->wTotalLength); + + // コントロール転送は EP0 なので claim 前から使える + if (usb_host_transfer_alloc(8 + USB_DISP_CTRL_MAX_DATA, 0, + &h->ctrl_xfer) != ESP_OK) { + usb_disp_log("[HAL] ctrl transfer alloc failed"); + return true; // 掴んだまま (切断まで放置) + } + h->ctrl_xfer->device_handle = h->dev; + h->ctrl_xfer->bEndpointAddress = 0; + h->ctrl_xfer->callback = ctrl_xfer_cb; + h->ctrl_xfer->context = h; + + // コンフィグディスクリプタ全体をダンプ (複合デバイスの切り分け用) + dump_hex("cfg", (const uint8_t *)cdesc, cdesc->wTotalLength); + + if (!cfg_has_vendor_if((const uint8_t *)cdesc, cdesc->wTotalLength) && + ddesc->bNumConfigurations > 1) { + usb_disp_log("[ENUM] no vendor IF in active cfg, probing others..."); + h->pending_probe = true; // 続きは usb_disp_hal_poll で + return true; + } + finish_setup(h, (const uint8_t *)cdesc, cdesc->wTotalLength, + h->full_speed); + return true; +} + +// バス上の全デバイスを走査して DisplayLink を探す (client_task から呼ぶ)。 +// ハブ経由では NEW_DEV がハブや同居デバイスにも飛ぶため、通知アドレスを +// 直接使わず毎回リストを引き直す (取り逃しも再走査で拾える) +static void scan_devices(struct usb_disp_hal *h) { + uint8_t addrs[8]; + int n = 0; + if (usb_host_device_addr_list_fill((int)sizeof(addrs), addrs, &n) != + ESP_OK) + return; + for (int i = 0; i < n && h->dev == NULL; i++) { + device_setup(h, addrs[i]); + } +} + +// セットアップ中間 (アプリタスクから呼ぶ): 全コンフィグを生読みして +// vendor class IF を持つコンフィグを探し、SET_CONFIGURATION で切り替えて +// から finish_setup する。見つからなければアクティブコンフィグのまま +static void probe_and_finish(struct usb_disp_hal *h) { + const usb_device_desc_t *ddesc; + const usb_config_desc_t *cdesc; + if (usb_host_get_device_descriptor(h->dev, &ddesc) != ESP_OK || + usb_host_get_active_config_descriptor(h->dev, &cdesc) != ESP_OK) + return; + + static uint8_t alt_cfg[256]; + const uint8_t *scan = (const uint8_t *)cdesc; + uint16_t scan_len = cdesc->wTotalLength; + for (uint8_t ci = 0; ci < ddesc->bNumConfigurations && ci < 4; ci++) { + uint16_t actual = 0; + uint8_t hdr[9]; + if (!raw_ctrl(h, 0x80, 0x06, (uint16_t)(0x0200 + ci), 0, hdr, 9, + &actual) || actual < 9) { + usb_disp_log("[ENUM] cfg[%u] read failed", ci); + continue; + } + uint16_t total = (uint16_t)(hdr[2] | (hdr[3] << 8)); + if (total > sizeof(alt_cfg)) total = sizeof(alt_cfg); + if (!raw_ctrl(h, 0x80, 0x06, (uint16_t)(0x0200 + ci), 0, alt_cfg, + total, &actual) || actual < total) { + usb_disp_log("[ENUM] cfg[%u] full read failed", ci); + continue; + } + char tag[8]; + snprintf(tag, sizeof(tag), "cfg[%u]", ci); + dump_hex(tag, alt_cfg, total); + if (cfg_has_vendor_if(alt_cfg, total)) { + uint8_t value = alt_cfg[5]; // bConfigurationValue + usb_disp_log("[ENUM] vendor IF in cfg[%u] (value=%u), " + "switching config", ci, value); + if (raw_ctrl(h, 0x00, 0x09, value, 0, NULL, 0, NULL)) { + scan = alt_cfg; + scan_len = total; + } else { + usb_disp_log("[ENUM] SET_CONFIGURATION(%u) failed", value); + } + break; + } + } + + // usb_host スタックはエニュメ時のコンフィグでしかパイプを作れないため、 + // このままでは切替先の EP へ転送できない (Get EP handle error)。 + // デバイスのタイプ別に対処する: + // a) ZeroCD 型 (一度アクティブ化すると MSC コンフィグを引っ込める): + // コンフィグ index 0 に表示 IF が見えるようになる → 再列挙して + // 正攻法 (スタックが表示コンフィグでパイプを作る) に乗せ直す + // b) コンフィグ切替型 (GX-DVI/U2AI で実測。SET_CONFIGURATION が + // アクティブ化そのもので、ディスクリプタは変わらない): + // 再列挙してもスタックはまた MSC コンフィグを選んでしまう + // (enum filter はプリコンパイル済み usb_host ライブラリで + // CONFIG_USB_HOST_ENABLE_ENUM_FILTER_CALLBACK 無効のため使えない)。 + // → スタックがキャッシュしているアクティブコンフィグ記述子の + // バルク OUT EP 記述子 (7B) を表示コンフィグのもので上書きして + // から claim する (claim はキャッシュから読むので、表示 EP の + // パイプが作られる)。MSC も EP も同サイズ記述子なので安全 + if (scan == alt_cfg) { + if (h->probe_reenum_cnt < 2) { + uint16_t actual = 0; + uint8_t hdr[9]; + vTaskDelay(pdMS_TO_TICKS(100)); // デバイス側の構成変更を待つ + static uint8_t cfg0[256]; + if (raw_ctrl(h, 0x80, 0x06, 0x0200, 0, hdr, 9, &actual) && + actual >= 9) { + uint16_t total = (uint16_t)(hdr[2] | (hdr[3] << 8)); + if (total > sizeof(cfg0)) total = sizeof(cfg0); + if (raw_ctrl(h, 0x80, 0x06, 0x0200, 0, cfg0, total, + &actual) && actual >= total && + cfg_has_vendor_if(cfg0, total)) { + usb_disp_log("[ENUM] cfg[0] now has vendor IF -> " + "re-enumerate"); + h->probe_reenum_cnt++; + usb_disp_hal_request_reenum(h); + return; // 再列挙後の device_setup が通常経路で完了 + } + } + } + const uint8_t *disp_ep = find_bulk_out_ep(alt_cfg, scan_len); + uint8_t *cache_ep = (uint8_t *)(uintptr_t)find_bulk_out_ep( + (const uint8_t *)cdesc, cdesc->wTotalLength); + if (disp_ep && cache_ep) { + usb_disp_log("[ENUM] repipe: cached EP %02X (mps=%u) <- " + "display EP %02X (mps=%u)", cache_ep[2], + cache_ep[4] | (cache_ep[5] << 8), disp_ep[2], + disp_ep[4] | (disp_ep[5] << 8)); + memcpy(cache_ep, disp_ep, 7); + } else { + usb_disp_log("[ENUM] repipe failed (disp_ep=%d cache_ep=%d)", + disp_ep != NULL, cache_ep != NULL); + } + } + finish_setup(h, scan, scan_len, h->full_speed); +} + +// セットアップ後半: EP スキャン, claim, バルク確保, attached。 +// scan/scan_len は使用するコンフィグディスクリプタ列 +// (アクティブとは限らない, probe_and_finish が切り替えた場合はそのコンフィグ) +static void finish_setup(struct usb_disp_hal *h, const uint8_t *scan, + uint16_t scan_len, bool full_speed) { + // 使用コンフィグからバルク OUT EP とそのインターフェースを探す。 + // - alt setting 0 のみ対象 (claim は alt 0 で行うため) + // - vendor class (0xFF) のインターフェースを優先 (DisplayLink の表示機能。 + // オートインストール用 Mass Storage (class 08) 等のバルク OUT を誤って掴まないように) + // - 途中の 0x5F ベンダーディスクリプタも捕捉 (チップ判別フォールバック) + h->bulk_ep = 0; + h->bulk_mps = 0; + h->vdesc_len = 0; + uint8_t cur_iface = 0, cur_alt = 0, cur_class = 0; + bool cur_vendor_if = false; // 現在の IF が vendor class か + bool got_vendor_ep = false; // 採用済み EP が vendor class IF のものか + const uint8_t *p = scan; + const uint8_t *end = p + scan_len; + while (p + 1 < end && p[0] >= 2 && p + p[0] <= end) { + uint8_t len = p[0], type = p[1]; + if (type == 0x04 && len >= 9) { // INTERFACE + cur_iface = p[2]; + cur_alt = p[3]; + cur_class = p[5]; + cur_vendor_if = (cur_class == 0xFF); + usb_disp_log("[ENUM] if=%u alt=%u class=%02X eps=%u", cur_iface, + cur_alt, cur_class, p[4]); + } else if (type == 0x05 && len >= 7) { // ENDPOINT + uint8_t ep_addr = p[2]; + uint8_t attr = p[3] & 0x03; + uint16_t mps = (uint16_t)(p[4] | (p[5] << 8)); + if (attr == 0x02 && (ep_addr & 0x80) == 0 && cur_alt == 0 && + (h->bulk_ep == 0 || (cur_vendor_if && !got_vendor_ep))) { + h->bulk_ep = ep_addr; + h->bulk_mps = mps; + h->iface = cur_iface; + got_vendor_ep = cur_vendor_if; + } + } else if (type == 0x5F && h->vdesc_len == 0) { // DL vendor desc + uint8_t n = (len <= sizeof(h->vdesc)) ? len : sizeof(h->vdesc); + memcpy(h->vdesc, p, n); + h->vdesc_len = n; + usb_disp_log("[ENUM] vendor desc (0x5F) in config, len=%u", len); + } + p += len; + } + if (h->bulk_ep == 0) { + usb_disp_log("[ENUM] no bulk OUT endpoint"); + // デバイスは掴んだまま + // (コアが VID を見て FAILED 判定できるように attached にはしない)。切断まで放置。 + return; + } + + // FS なのに mps>64 を宣言する個体対策: FS のバルクパケット上限は 64。 + // DWC はディスクリプタの mps でパケット化するため、 + // URB を 64B 単位に分割して 1 URB = 1 パケット (<=64B) に抑える + h->urb_max = USB_DISP_BULK_XFER_SIZE; + if (full_speed && h->bulk_mps > 64) { + usb_disp_log("[ENUM] quirk: FS but bulk mps=%u -> 64B URBs", + h->bulk_mps); + h->urb_max = 64; + } + if (usb_host_interface_claim(s_client, h->dev, h->iface, 0) != ESP_OK) { + usb_disp_log("[HAL] interface_claim failed (if=%u)", h->iface); + return; + } + h->iface_claimed = true; + + for (uint8_t i = 0; i < USB_DISP_BULK_XFER_COUNT; i++) { + if (usb_host_transfer_alloc(USB_DISP_BULK_XFER_SIZE, 0, + &h->bulk_xfer[i]) != ESP_OK) { + usb_disp_log("[HAL] bulk transfer alloc failed"); + return; + } + h->bulk_xfer[i]->device_handle = h->dev; + h->bulk_xfer[i]->bEndpointAddress = h->bulk_ep; + h->bulk_xfer[i]->callback = bulk_xfer_cb; + h->bulk_xfer[i]->context = h; + } + + h->cur = NULL; + h->cur_fill = 0; + h->next_slot = 0; + h->ep_recover = false; + h->ep_recover_cnt = 0; + h->bulk_err = 0; + h->bulk_short = 0; + // 空きスロットカウンタを満杯に + while (xSemaphoreTake(h->bulk_free, 0) == pdTRUE) {} + for (uint8_t i = 0; i < USB_DISP_BULK_XFER_COUNT; i++) + xSemaphoreGive(h->bulk_free); + + usb_disp_log("[ENUM] configured. bulk OUT=%02X mps=%u if=%u", h->bulk_ep, + h->bulk_mps, h->iface); + h->probe_reenum_cnt = 0; + h->attached = true; +} + +static void device_teardown(struct usb_disp_hal *h) { + h->attached = false; + h->pending_probe = false; + h->scan_retry_ms = 0; + if (h->dev == NULL) return; + // 未完了転送はスタックが DEV_GONE 時にエラー完了させる → + // コールバックがセマフォを返すので少し待ってから解放する + vTaskDelay(pdMS_TO_TICKS(50)); + for (uint8_t i = 0; i < USB_DISP_BULK_XFER_COUNT; i++) { + if (h->bulk_xfer[i]) { + usb_host_transfer_free(h->bulk_xfer[i]); + h->bulk_xfer[i] = NULL; + } + } + if (h->ctrl_xfer) { + usb_host_transfer_free(h->ctrl_xfer); + h->ctrl_xfer = NULL; + } + if (h->iface_claimed) { + usb_host_interface_release(s_client, h->dev, h->iface); + h->iface_claimed = false; + } + usb_host_device_close(s_client, h->dev); + h->dev = NULL; + h->vid = h->pid = 0; + h->bulk_ep = 0; + h->bulk_mps = 0; + h->vdesc_len = 0; + h->ep_recover = false; + usb_disp_log("[HAL] device closed"); +} + +// --------------------------------------------------------------- +// タスク +// --------------------------------------------------------------- + +static void client_event_cb(const usb_host_client_event_msg_t *msg, + void *arg) { + struct usb_disp_hal *h = &s_hal[0]; // OTG 1系統 = 単一インスタンス + (void)arg; + switch (msg->event) { + case USB_HOST_CLIENT_EVENT_NEW_DEV: + // ハブ経由ではハブ自身や同居デバイスの分も飛んでくるので、 + // アドレスは覚えず「走査が必要」フラグだけ立てる + h->scan_needed = true; + h->scan_retry_cnt = 8; // 列挙中デバイスの再走査試行を補充 + break; + case USB_HOST_CLIENT_EVENT_DEV_GONE: + // 自分が掴んでいるデバイスの切断のみ扱う + // (ハブ経由では同居デバイスの抜き差しでも DEV_GONE が来る) + if (h->dev != NULL && msg->dev_gone.dev_hdl == h->dev) { + h->gone = true; + } + break; + default: + break; + } +} + +// バルクエラーで HALT したパイプを復旧する (client_task 内で実行)。 +// halt→flush で未完了 URB が CANCELED 完了しコールバックがセマフォを返す。 +// clear でパイプ再開+データトグルもリセットされる (エラーでデバイス側の +// トグルと不一致になり得るため、デバイス側にも CLEAR_FEATURE を送る) +static void bulk_ep_recover(struct usb_disp_hal *h) { + h->ep_recover = false; + if (h->dev == NULL || h->bulk_ep == 0) return; + h->ep_recover_cnt = h->ep_recover_cnt + 1; + esp_err_t e1 = usb_host_endpoint_halt(h->dev, h->bulk_ep); + esp_err_t e2 = usb_host_endpoint_flush(h->dev, h->bulk_ep); + esp_err_t e3 = usb_host_endpoint_clear(h->dev, h->bulk_ep); + if (h->ep_recover_cnt <= 8 || (h->ep_recover_cnt & 0x3F) == 0) { + usb_disp_log("[HAL] bulk EP recover #%lu (halt=%d flush=%d clear=%d)", + (unsigned long)h->ep_recover_cnt, (int)e1, (int)e2, + (int)e3); + } +} + +static void client_task(void *arg) { + (void)arg; + struct usb_disp_hal *h = &s_hal[0]; + while (!s_stop_tasks) { + usb_host_client_handle_events(s_client, pdMS_TO_TICKS(100)); + if (h->gone) { + h->gone = false; + device_teardown(h); + // ハブ経由で他の DisplayLink が残っている可能性 → 再走査 + h->scan_needed = true; + } + // 列挙中で開けなかったデバイスの時限再走査 (device_setup 参照) + if (h->scan_retry_ms && h->dev == NULL && + (int32_t)(usb_disp_hal_ms() - h->scan_retry_ms) >= 0) { + h->scan_retry_ms = 0; + h->scan_needed = true; + } + if (h->scan_needed && h->dev == NULL) { + h->scan_needed = false; + scan_devices(h); + } + if (h->ep_recover && h->attached) { + bulk_ep_recover(h); + } + } + s_client_task_done = true; + vTaskDelete(NULL); +} + +static void daemon_task(void *arg) { + (void)arg; + while (!s_stop_tasks) { + uint32_t flags; + usb_host_lib_handle_events(portMAX_DELAY, &flags); + if (flags & USB_HOST_LIB_EVENT_FLAGS_NO_CLIENTS) { + usb_host_device_free_all(); + } + } + s_daemon_task_done = true; + vTaskDelete(NULL); +} + +// --------------------------------------------------------------- +// Hub-port watchdog (Terminus-hub + slow-booting FS device recovery) +// +// Background: IDF's ext_port driver attempts a hub-port reset exactly +// once (EXT_PORT_RESET_ATTEMPTS defaults to 1). A slow-booting +// Full-Speed device (a DisplayLink adapter needs 1-2s after VBUS) +// that stalls the first short-descriptor read leaves the port +// DISABLED forever ("CHECK_SHORT_DEV_DESC FAILED"), and the stack's +// own recycle path cannot recover pre-enumeration failures +// ("Ext hub port recycle error: ESP_ERR_INVALID_ARG"). Linux xHCI +// retries transparently, which is why the same hub+adapter works on +// a PC but wedges on ESP32 until the whole chain is replugged. +// +// This watchdog sweeps every external hub with standard, read-only +// GET_PORT_STATUS requests. Tracking runs on every poll, attached or +// not, so unplugs are observed even mid-display; recovery actions +// (resets, power cycles, escalation) fire only while detached. +// connected-but-unenumerated: up to 3 SET_FEATURE(PORT_RESET)s with +// growing backoff (~4s/~12s/~28s stuck time), then one PORT_POWER +// off/on cycle, then silence until the connection flaps. The resets +// are targeted (VBUS stays up, other ports untouched) and by the time +// they fire the device has finished booting, so re-enumeration +// succeeds; the power cycle is the last resort for a latched port. +// Enabled ports (healthy mouse/display, enumeration in progress) are +// never touched. High-speed ports are never touched either: they carry +// cascaded hubs, and resetting one drops the whole subtree, which the +// IDF enumerator cannot survive (abort in enum.c control_request_string +// on the resulting error cascade). Change bits are NEVER cleared here: +// clearing C_CONNECTION would steal the connect event from IDF's hub +// driver and cause the very silence this fixes. +// Every transition and action is logged ([HUB] lines) with the +// per-port counters, so recovery state is always visible on serial. +// --------------------------------------------------------------- + +#define USB_DISP_HUB_MAX_PORTS 8 // ports tracked per hub (Terminus = 4) +#define USB_DISP_HUB_WD_MAX 4 // hubs tracked simultaneously +#define USB_DISP_HUB_WD_GRACE_MS 4000 // episode start -> first reset due +#define USB_DISP_HUB_WD_RESETS 3 // port resets before power cycle +#define USB_DISP_HUB_WD_PC_GRACE_MS 8000 // power cycle -> give-up verdict +#define USB_DISP_HUB_WD_RETRY_MS 4000 // hub comm failure -> retry sweep +#define USB_DISP_HUB_WD_QUIET_MS 15000 // flapped idle port -> one auto-reset +#define USB_DISP_HUB_WD_BUS_SETTLE_MS 10000 // hub first seen -> dues allowed +#define USB_DISP_HUB_WD_DEFER_MS 3000 // bus growth -> push dues out +#define USB_DISP_HUB_WD_SWEEP_MS 1000 // min interval between sweeps +#define USB_DISP_HUB_CTRL_MS 1500 // per-transfer timeout +#define USB_DISP_LSUSB_MAX_DEV 16 // addresses listed by lsusb +#define USB_DISP_HUB_WD_ESCALATE_MAX 3 // auto root cycles per boot + +// Wait after reset N (1-based) before the next recovery step. A +// browned-out adapter can need 10-20s, so the tail is long on purpose; +// healthy enumerations finish before the first grace expires and never +// see any of this. +static const uint16_t kHubWdBackoffMs[USB_DISP_HUB_WD_RESETS] = { + 8000, 16000, 8000, +}; + +// Hub class request constants (USB 2.0 spec, Tables 11-16/17/19) +#define USB_DISP_HUB_DESC_TYPE 0x29 +#define USB_DISP_REQ_GET_STATUS 0 +#define USB_DISP_REQ_GET_DESCRIPTOR 6 +#define USB_DISP_REQ_SET_FEATURE 3 +#define USB_DISP_FEAT_PORT_RESET 4 +#define USB_DISP_FEAT_PORT_POWER 8 +#define USB_DISP_PORT_STAT_CONNECTION 0x0001 +#define USB_DISP_PORT_STAT_ENABLE 0x0002 +#define USB_DISP_PORT_CHG_CONNECTION 0x0001 +#define USB_DISP_PORT_STAT_HIGH_SPEED 0x0400 + +typedef struct { + bool occupied; + uint8_t hub_addr; + uint8_t nports; // cached hub-descriptor port count (0 = unknown) + // Per-port stuck-episode state (index = port - 1) + uint32_t stuck_since[USB_DISP_HUB_MAX_PORTS]; // episode start ms (0 = none) + uint32_t next_due[USB_DISP_HUB_MAX_PORTS]; // next action due ms + uint8_t attempts[USB_DISP_HUB_MAX_PORTS]; // port resets issued + uint8_t n_at_open[USB_DISP_HUB_MAX_PORTS]; // bus addr count at open + bool power_cycled[USB_DISP_HUB_MAX_PORTS]; // vbus cycle spent + bool given_up[USB_DISP_HUB_MAX_PORTS]; // silent until flap + bool noted[USB_DISP_HUB_MAX_PORTS]; // idle hint logged + bool hid_noted[USB_DISP_HUB_MAX_PORTS]; // hid-skip hint logged (MPOS) + bool preexisting[USB_DISP_HUB_MAX_PORTS]; // idle since before last arm + bool quiet[USB_DISP_HUB_MAX_PORTS]; // idle auto-reset episode + bool quiet_done[USB_DISP_HUB_MAX_PORTS]; // idle reset spent + uint8_t cerr; // consecutive hub failures + uint8_t backoffs; // breaker trips this lifetime + uint32_t skip_until; // breaker backoff deadline + uint32_t born_ms; // first sweep seeing hub + bool fresh; // needs first-pass marking +} hub_wd_t; + +static hub_wd_t s_hub_wd[USB_DISP_HUB_WD_MAX]; +static bool s_watchdog_on = true; +static bool s_auto_reset_idle = true; +static uint32_t s_hub_wd_last_ms = 0; +static int s_hub_wd_prev_n = -1; +static uint32_t s_hub_wd_last_growth_ms = 0; +static uint8_t s_escalations = 0; +static bool s_snapshot_idle = false; +static bool s_prev_attached = false; + +#define USB_DISP_HUB_PERF_MAX 4 +#define USB_DISP_HUB_CTRL_SLOW_MS 500 +typedef struct { + uint8_t addr; + uint8_t fails; +} hub_perf_t; +static hub_perf_t s_hub_perf[USB_DISP_HUB_PERF_MAX]; + +static uint32_t hub_timeout_ms(uint8_t hub_addr) { + for (uint8_t i = 0; i < USB_DISP_HUB_PERF_MAX; i++) { + if (s_hub_perf[i].addr == hub_addr) { + return s_hub_perf[i].fails ? USB_DISP_HUB_CTRL_SLOW_MS + : USB_DISP_HUB_CTRL_MS; + } + } + return USB_DISP_HUB_CTRL_MS; +} + +static void hub_perf_note(uint8_t hub_addr, bool ok) { + uint8_t free_idx = USB_DISP_HUB_PERF_MAX; + for (uint8_t i = 0; i < USB_DISP_HUB_PERF_MAX; i++) { + if (s_hub_perf[i].addr == hub_addr) { + s_hub_perf[i].fails = ok ? 0 : (s_hub_perf[i].fails < 255 + ? (uint8_t)(s_hub_perf[i].fails + 1) + : 255); + return; + } + if (s_hub_perf[i].addr == 0 && free_idx == USB_DISP_HUB_PERF_MAX) { + free_idx = i; + } + } + if (free_idx < USB_DISP_HUB_PERF_MAX) { + s_hub_perf[free_idx].addr = hub_addr; + s_hub_perf[free_idx].fails = ok ? 0 : 1; + } +} +static SemaphoreHandle_t s_hub_mutex = NULL; +static SemaphoreHandle_t s_hub_done = NULL; + +static void hub_xfer_cb(usb_transfer_t *xfer) { + SemaphoreHandle_t done = (SemaphoreHandle_t)xfer->context; + xSemaphoreGive(done); +} + +// Raw control transfer on an already-open device handle (no open/close, +// no class check). App-task context, serialized by s_hub_mutex. +static bool dev_ctrl_hdl(usb_device_handle_t dev, uint8_t bmRequestType, + uint8_t bRequest, uint16_t wValue, uint16_t wIndex, + void *data, uint16_t wLength, uint16_t *actual) { + if (!s_started || s_client == NULL || s_hub_mutex == NULL || + s_hub_done == NULL || dev == NULL) { + return false; + } + if (wLength > 128) return false; + if (xSemaphoreTake(s_hub_mutex, pdMS_TO_TICKS(3000)) != pdTRUE) { + return false; + } + bool ok = false; + usb_transfer_t *x = NULL; + if (usb_host_transfer_alloc(8 + 128, 0, &x) != ESP_OK) goto out; + { + bool dir_in = (bmRequestType & 0x80) != 0; + uint8_t *b = x->data_buffer; + b[0] = bmRequestType; + b[1] = bRequest; + b[2] = (uint8_t)wValue; + b[3] = (uint8_t)(wValue >> 8); + b[4] = (uint8_t)wIndex; + b[5] = (uint8_t)(wIndex >> 8); + b[6] = (uint8_t)wLength; + b[7] = (uint8_t)(wLength >> 8); + if (!dir_in && wLength && data) memcpy(b + 8, data, wLength); + x->num_bytes = 8 + wLength; + x->device_handle = dev; + x->bEndpointAddress = 0; + x->callback = hub_xfer_cb; + x->context = s_hub_done; + xSemaphoreTake(s_hub_done, 0); // clear stale signal + if (usb_host_transfer_submit_control(s_client, x) != ESP_OK) goto out; + if (xSemaphoreTake(s_hub_done, + pdMS_TO_TICKS(hub_timeout_ms(0))) != pdTRUE) { + goto out; + } + if (x->status != USB_TRANSFER_STATUS_COMPLETED) goto out; + uint16_t got = + (x->actual_num_bytes >= 8) ? (uint16_t)(x->actual_num_bytes - 8) + : 0; + if (dir_in && data && got) { + if (got > wLength) got = wLength; + memcpy(data, b + 8, got); + } + if (actual) *actual = got; + } + ok = true; +out: + if (x) usb_host_transfer_free(x); + xSemaphoreGive(s_hub_mutex); + return ok; +} + +// Address of the display device held open by this client, if any. +// Needed because the stack's address list only contains idle devices: +// an opened device leaves the idle tailq and vanishes from +// bus_devices()/lsusb unless re-added here. +bool usb_disp_hal_claimed_addr(uint8_t *addr) { + struct usb_disp_hal *h = &s_hal[0]; + if (h->dev == NULL || addr == NULL) return false; + usb_device_info_t info; + if (usb_host_device_info(h->dev, &info) != ESP_OK || info.dev_addr == 0) { + return false; + } + *addr = info.dev_addr; + return true; +} + +// One hub-class control transfer, app-task context, serialized by +// s_hub_mutex. Opens the hub (verifying class 09), submits on EP0, +// waits, closes. Returns true on COMPLETED. +static bool hub_ctrl(uint8_t hub_addr, uint8_t bmRequestType, uint8_t bRequest, + uint16_t wValue, uint16_t wIndex, + void *data, uint16_t wLength, uint16_t *actual) { + if (!s_started || s_client == NULL || s_hub_mutex == NULL || + s_hub_done == NULL) { + return false; + } + if (wLength > 32) return false; + if (xSemaphoreTake(s_hub_mutex, pdMS_TO_TICKS(3000)) != pdTRUE) { + return false; + } + bool ok = false; + usb_device_handle_t dev = NULL; + usb_transfer_t *x = NULL; + if (usb_host_device_open(s_client, hub_addr, &dev) != ESP_OK) { + goto out; + } + { + const usb_device_desc_t *ddesc = NULL; + if (usb_host_get_device_descriptor(dev, &ddesc) != ESP_OK || + ddesc == NULL || ddesc->bDeviceClass != 0x09) { + goto out; + } + } + if (usb_host_transfer_alloc(8 + 32, 0, &x) != ESP_OK) goto out; + { + bool dir_in = (bmRequestType & 0x80) != 0; + uint8_t *b = x->data_buffer; + b[0] = bmRequestType; + b[1] = bRequest; + b[2] = (uint8_t)wValue; + b[3] = (uint8_t)(wValue >> 8); + b[4] = (uint8_t)wIndex; + b[5] = (uint8_t)(wIndex >> 8); + b[6] = (uint8_t)wLength; + b[7] = (uint8_t)(wLength >> 8); + if (!dir_in && wLength && data) memcpy(b + 8, data, wLength); + x->num_bytes = 8 + wLength; + x->device_handle = dev; + x->bEndpointAddress = 0; + x->callback = hub_xfer_cb; + x->context = s_hub_done; + xSemaphoreTake(s_hub_done, 0); // clear stale signal + if (usb_host_transfer_submit_control(s_client, x) != ESP_OK) goto out; + if (xSemaphoreTake(s_hub_done, pdMS_TO_TICKS(hub_timeout_ms(hub_addr))) != + pdTRUE) { + goto out; + } + if (x->status != USB_TRANSFER_STATUS_COMPLETED) { + usb_disp_log("[HUB] ctrl error: status=%d (bReq=%02X addr=%u)", + (int)x->status, bRequest, hub_addr); + goto out; + } + uint16_t got = + (x->actual_num_bytes >= 8) ? (uint16_t)(x->actual_num_bytes - 8) + : 0; + if (dir_in && data && got) { + if (got > wLength) got = wLength; + memcpy(data, b + 8, got); + } + if (actual) *actual = got; + } + ok = true; +out: + if (x) usb_host_transfer_free(x); + if (dev) usb_host_device_close(s_client, dev); + hub_perf_note(hub_addr, ok); + xSemaphoreGive(s_hub_mutex); + return ok; +} + +static uint8_t hub_port_count(uint8_t hub_addr) { + uint8_t buf[16] = {0}; + uint16_t actual = 0; + if (!hub_ctrl(hub_addr, 0xA0, USB_DISP_REQ_GET_DESCRIPTOR, + (uint16_t)(USB_DISP_HUB_DESC_TYPE << 8), 0, buf, sizeof(buf), + &actual) || + actual < 3) { + return 0; + } + uint8_t n = buf[2]; + if (n == 0 || n > USB_DISP_HUB_MAX_PORTS) return 0; + return n; +} + +// Read-only port status. Never clears change bits (see banner above). +static bool hub_port_status(uint8_t hub_addr, uint8_t port, bool *connected, + bool *enabled, bool *conn_change, + bool *high_speed) { + uint8_t buf[4] = {0}; + uint16_t actual = 0; + if (!hub_ctrl(hub_addr, 0xA3, USB_DISP_REQ_GET_STATUS, 0, port, buf, 4, + &actual) || + actual < 4) { + return false; + } + uint16_t st = (uint16_t)(buf[0] | (buf[1] << 8)); + uint16_t ch = (uint16_t)(buf[2] | (buf[3] << 8)); + *connected = (st & USB_DISP_PORT_STAT_CONNECTION) != 0; + *enabled = (st & USB_DISP_PORT_STAT_ENABLE) != 0; + *conn_change = (ch & USB_DISP_PORT_CHG_CONNECTION) != 0; + *high_speed = (st & USB_DISP_PORT_STAT_HIGH_SPEED) != 0; + return true; +} + +static bool hub_reset_port(uint8_t hub_addr, uint8_t port, bool power_cycle) { + bool ok; + if (power_cycle) { + // VBUS drop: forces a from-scratch connection event through the + // stack's own path. Ganged-power hubs may drop sibling ports too. + ok = hub_ctrl(hub_addr, 0x23, 1 /*CLEAR_FEATURE*/, + USB_DISP_FEAT_PORT_POWER, port, NULL, 0, NULL); + if (ok) { + vTaskDelay(pdMS_TO_TICKS(300)); + ok = hub_ctrl(hub_addr, 0x23, USB_DISP_REQ_SET_FEATURE, + USB_DISP_FEAT_PORT_POWER, port, NULL, 0, NULL); + } + usb_disp_log("[HUB] addr=%u port=%u power cycle %s", hub_addr, port, + ok ? "ok" : "FAIL"); + return ok; + } + ok = hub_ctrl(hub_addr, 0x23, USB_DISP_REQ_SET_FEATURE, + USB_DISP_FEAT_PORT_RESET, port, NULL, 0, NULL); + usb_disp_log("[HUB] addr=%u port=%u reset %s", hub_addr, port, + ok ? "ok" : "FAIL"); + return ok; +} + +static void wd_port_clear(hub_wd_t *slot, uint8_t idx) { + slot->stuck_since[idx] = 0; + slot->next_due[idx] = 0; + slot->attempts[idx] = 0; + slot->power_cycled[idx] = false; + slot->given_up[idx] = false; + slot->n_at_open[idx] = 0; + slot->noted[idx] = false; + slot->hid_noted[idx] = false; + slot->quiet[idx] = false; + slot->quiet_done[idx] = false; + slot->preexisting[idx] = false; +} + +static void wd_port_closed(hub_wd_t *slot, uint8_t idx, uint8_t addr, + uint8_t port, uint32_t now, const char *why) { + usb_disp_log("[HUB] addr=%u port=%u %s, episode over (stuck %lus, %u " + "resets%s)", + addr, port, why, + (unsigned long)((now - slot->stuck_since[idx]) / 1000), + slot->attempts[idx], + slot->power_cycled[idx] ? "+power" : ""); + wd_port_clear(slot, idx); +} + +// Last-resort escalation: a dead-silent hub plus stuck ports means the +// chain needs a root power cycle (proven to revive EP0-dead hubs that +// nothing else touches). Only while detached, capped per boot; fresh +// hub addresses after the cycle reset all watchdog state naturally. +static void hub_escalate_maybe(void) { + bool stuck = false; + for (uint8_t s = 0; s < USB_DISP_HUB_WD_MAX && !stuck; s++) { + if (!s_hub_wd[s].occupied) continue; + for (uint8_t p = 0; p < USB_DISP_HUB_MAX_PORTS; p++) { + if (s_hub_wd[s].stuck_since[p] || s_hub_wd[s].given_up[p]) { + stuck = true; + break; + } + } + } + if (!stuck) return; + if (s_escalations < USB_DISP_HUB_WD_ESCALATE_MAX) { + s_escalations++; + usb_disp_log("[HUB] escalating: root power cycle (%u/%u this boot)", + s_escalations, USB_DISP_HUB_WD_ESCALATE_MAX); + usb_disp_hal_request_reenum(&s_hal[0]); + } else { + usb_disp_log("[HUB] escalation budget spent, manual force_reenum() only"); + } +} + +// One watchdog sweep over all hubs on the bus. Tracking (status reads, +// flap arms, episodes, breaker) always runs; recovery actions (resets, +// power cycles, escalation) only run when allow_actions is set, i.e. +// while no display is attached. Open (class-verified) failures skip the +// hub for this round but keep its episode state. +static void hub_watchdog_step(bool allow_actions) { + uint32_t now = usb_disp_hal_ms(); + if ((int32_t)(now - s_hub_wd_last_ms) < USB_DISP_HUB_WD_SWEEP_MS) return; + if (s_hub_wd_last_ms != 0 && + (uint32_t)(now - s_hub_wd_last_ms) > 3000) { + usb_disp_log("[HUB] sweep delayed %lus", + (unsigned long)((now - s_hub_wd_last_ms) / 1000)); + } + s_hub_wd_last_ms = now; + + uint8_t addrs[16]; + int n = 0; + if (usb_host_device_addr_list_fill((int)sizeof(addrs), addrs, &n) != + ESP_OK) { + return; + } + if (s_hub_wd_prev_n >= 0 && n > s_hub_wd_prev_n) { + s_hub_wd_last_growth_ms = now; + } + s_hub_wd_prev_n = n; + bool snap = s_snapshot_idle; + s_snapshot_idle = false; + + for (int i = 0; i < n; i++) { + uint8_t addr = addrs[i]; + hub_wd_t *slot = NULL; + hub_wd_t *free_slot = NULL; + for (uint8_t s = 0; s < USB_DISP_HUB_WD_MAX; s++) { + if (s_hub_wd[s].occupied && s_hub_wd[s].hub_addr == addr) { + slot = &s_hub_wd[s]; + break; + } + if (!s_hub_wd[s].occupied && free_slot == NULL) { + free_slot = &s_hub_wd[s]; + } + } + if (slot == NULL) { + if (free_slot == NULL) continue; // table full, skip hub + memset(free_slot, 0, sizeof(*free_slot)); + free_slot->occupied = true; + free_slot->hub_addr = addr; + free_slot->born_ms = now; + free_slot->fresh = true; + slot = free_slot; + } + if (slot->nports == 0) { + slot->nports = hub_port_count(addr); + if (slot->nports == 0) continue; // not a hub (or not yet + // readable); retry next sweep + usb_disp_log("[HUB] addr=%u ports=%u", addr, slot->nports); + } + if (slot->skip_until != 0) { + if ((int32_t)(now - slot->skip_until) < 0) continue; + slot->skip_until = 0; + } + bool hub_ok = true; + if (hub_ok) for (uint8_t port = 1; port <= slot->nports; port++) { + uint8_t idx = (uint8_t)(port - 1); + bool conn = false, en = false, cchg = false, hs = false; + if (!hub_port_status(addr, port, &conn, &en, &cchg, &hs)) { + hub_ok = false; + continue; + } + if (slot->fresh || snap) { + if (!conn) slot->preexisting[idx] = false; + else if (en && !cchg) slot->preexisting[idx] = true; + } else if (!conn) { + slot->preexisting[idx] = false; + } + bool episode = (slot->stuck_since[idx] != 0 || + slot->attempts[idx] != 0 || + slot->power_cycled[idx] || slot->given_up[idx]); + if (!conn) { + if (episode) wd_port_closed(slot, idx, addr, port, now, "unplugged"); + else wd_port_clear(slot, idx); + continue; + } + if (hs) { + if (episode && !slot->given_up[idx]) { + slot->given_up[idx] = true; + usb_disp_log("[HUB] addr=%u port=%u high-speed device, skipping", + addr, port); + } else if (!slot->noted[idx]) { + slot->noted[idx] = true; + usb_disp_log("[HUB] addr=%u port=%u high-speed device, skipping", + addr, port); + } + continue; + } + if (episode && n != slot->n_at_open[idx]) { + wd_port_closed(slot, idx, addr, port, now, "enumerated"); + slot->preexisting[idx] = true; + continue; + } + if (en && !cchg && !episode) { + if (!slot->noted[idx]) { + slot->noted[idx] = true; + usb_disp_log("[HUB] addr=%u port=%u enabled but idle " + "(reset_port(%u,%u) if stuck)", + addr, port, addr, port); + } + // Port-exact HID skip (MPOS): one of our HID slots holds + // this port's device, so the quiet auto-reset stands down + // here while other ports keep healing. Unresolvable parked + // devices stay covered by the Python-side global + // suppression instead (see USBManager). Only reported when + // the toggle is on: with it off nothing would arm anyway. + if (s_auto_reset_idle && usb_hid_owns_idle_port(addr, port)) { + if (!slot->hid_noted[idx]) { + slot->hid_noted[idx] = true; + usb_disp_log("[HUB] addr=%u port=%u HID device, " + "auto-reset skipped", + addr, port); + } + continue; + } + slot->hid_noted[idx] = false; + if (s_auto_reset_idle && !slot->preexisting[idx] && + !slot->quiet_done[idx]) { + slot->stuck_since[idx] = now; + slot->n_at_open[idx] = (uint8_t)n; + slot->quiet[idx] = true; + slot->next_due[idx] = now + USB_DISP_HUB_WD_QUIET_MS; + usb_disp_log("[HUB] addr=%u port=%u idle, auto-reset in " + "%us", + addr, port, + USB_DISP_HUB_WD_QUIET_MS / 1000); + } + continue; + } + if (slot->given_up[idx]) continue; + if (!episode && en) continue; + if (slot->stuck_since[idx] == 0) { + slot->stuck_since[idx] = now; + slot->attempts[idx] = 0; + slot->power_cycled[idx] = false; + slot->n_at_open[idx] = (uint8_t)n; + slot->next_due[idx] = now + USB_DISP_HUB_WD_GRACE_MS; + usb_disp_log("[HUB] addr=%u port=%u connected, waiting", + addr, port); + continue; + } + if ((int32_t)(now - slot->next_due[idx]) < 0) continue; + if (!allow_actions) continue; + if (slot->quiet[idx]) { + // A HID may have claimed this port during the grace: + // re-check ownership before firing (MPOS, see above). + if (usb_hid_owns_idle_port(addr, port)) { + usb_disp_log("[HUB] addr=%u port=%u HID arrived, " + "idle reset skipped", + addr, port); + wd_port_closed(slot, idx, addr, port, now, "hid owned"); + slot->quiet_done[idx] = true; + slot->preexisting[idx] = true; + continue; + } + usb_disp_log("[HUB] addr=%u port=%u idle reset (stuck %lus)", + addr, port, + (unsigned long)((now - slot->stuck_since[idx]) / + 1000)); + bool ok = hub_reset_port(addr, port, false); + wd_port_closed(slot, idx, addr, port, now, + ok ? "idle reset done" : "idle reset FAILED"); + slot->quiet_done[idx] = true; + slot->preexisting[idx] = true; + continue; + } + if ((int32_t)(now - s_hub_wd_last_growth_ms) < + USB_DISP_HUB_WD_DEFER_MS) { + slot->next_due[idx] = now + USB_DISP_HUB_WD_DEFER_MS; + continue; + } + if ((int32_t)(now - slot->born_ms) < USB_DISP_HUB_WD_BUS_SETTLE_MS) { + slot->next_due[idx] = + slot->born_ms + USB_DISP_HUB_WD_BUS_SETTLE_MS; + continue; + } + unsigned long stuck_s = + (unsigned long)((now - slot->stuck_since[idx]) / 1000); + if (slot->attempts[idx] < USB_DISP_HUB_WD_RESETS) { + uint8_t att = (uint8_t)(slot->attempts[idx] + 1); + usb_disp_log("[HUB] addr=%u port=%u reset %u/%u (stuck %lus)", + addr, port, att, USB_DISP_HUB_WD_RESETS, stuck_s); + if (hub_reset_port(addr, port, false)) { + slot->attempts[idx] = att; + slot->next_due[idx] = + now + kHubWdBackoffMs[att - 1]; + } else { + slot->next_due[idx] = now + USB_DISP_HUB_WD_RETRY_MS; + } + } else if (!slot->power_cycled[idx]) { + usb_disp_log("[HUB] addr=%u port=%u power cycle (stuck %lus, " + "%u resets done)", + addr, port, stuck_s, slot->attempts[idx]); + if (hub_reset_port(addr, port, true)) { + slot->power_cycled[idx] = true; + slot->next_due[idx] = now + USB_DISP_HUB_WD_PC_GRACE_MS; + } else { + slot->next_due[idx] = now + USB_DISP_HUB_WD_RETRY_MS; + } + } else { + slot->given_up[idx] = true; + usb_disp_log("[HUB] addr=%u port=%u stuck after %u resets + " + "power cycle, giving up until replug", + addr, port, slot->attempts[idx]); + } + } + if (hub_ok) { + if (slot->cerr > 5) { + usb_disp_log("[HUB] addr=%u responsive again", addr); + } + slot->cerr = 0; + slot->backoffs = 0; + slot->fresh = false; + } else { + if (slot->cerr < 255) slot->cerr++; + if (slot->cerr > 5 && slot->skip_until == 0 && slot->backoffs < 3) { + slot->backoffs++; + uint32_t wait_ms = (slot->backoffs == 1) ? 30000 + : (slot->backoffs == 2) ? 60000 : 120000; + slot->skip_until = now + wait_ms; + usb_disp_log("[HUB] addr=%u errors, backing off %lus", addr, + (unsigned long)(wait_ms / 1000)); + if (allow_actions) hub_escalate_maybe(); + } else if (slot->cerr > 5 && slot->skip_until == 0) { + slot->skip_until = now + 120000; + usb_disp_log("[HUB] addr=%u still dead, quiet 120s", addr); + if (allow_actions) hub_escalate_maybe(); + } + } + } + + // Age out hubs that left the bus (unplugged chain). + for (uint8_t s = 0; s < USB_DISP_HUB_WD_MAX; s++) { + if (!s_hub_wd[s].occupied) continue; + bool present = false; + for (int i = 0; i < n; i++) { + if (addrs[i] == s_hub_wd[s].hub_addr) { + present = true; + break; + } + } + if (!present) memset(&s_hub_wd[s], 0, sizeof(s_hub_wd[s])); + } +} + +uint8_t usb_disp_hal_hub_ports(usb_disp_hub_port_t *out, uint8_t max) { + uint8_t count = 0; + if (out == NULL || max == 0) return 0; + uint8_t addrs[16]; + int n = 0; + if (usb_host_device_addr_list_fill((int)sizeof(addrs), addrs, &n) != + ESP_OK) { + return 0; + } + for (int i = 0; i < n && count < max; i++) { + uint8_t nports = hub_port_count(addrs[i]); + if (nports == 0) continue; + for (uint8_t port = 1; port <= nports && count < max; port++) { + bool conn = false, en = false, cchg = false, hs = false; + if (!hub_port_status(addrs[i], port, &conn, &en, &cchg, &hs)) continue; + out[count].hub_addr = addrs[i]; + out[count].port = port; + out[count].connected = conn; + out[count].enabled = en; + out[count].high_speed = hs; + count++; + } + } + return count; +} + +bool usb_disp_hal_reset_hub_port(uint8_t hub_addr, uint8_t port, + bool power_cycle, bool force) { + if (port == 0 || port > USB_DISP_HUB_MAX_PORTS) return false; + bool conn = false, en = false, cchg = false, hs = false; + if (hub_port_status(hub_addr, port, &conn, &en, &cchg, &hs) && hs && + !force) { + usb_disp_log("[HUB] addr=%u port=%u high-speed device, refusing reset", + hub_addr, port); + return false; + } + if (force && hs) { + usb_disp_log("[HUB] addr=%u port=%u FORCED reset", hub_addr, port); + } + return hub_reset_port(hub_addr, port, power_cycle); +} + +// Raw control transfer to any device address (no class check). +// App-task context, serialized by s_hub_mutex like hub_ctrl. +static bool dev_ctrl(uint8_t dev_addr, uint8_t bmRequestType, uint8_t bRequest, + uint16_t wValue, uint16_t wIndex, + void *data, uint16_t wLength, uint16_t *actual) { + if (!s_started || s_client == NULL || s_hub_mutex == NULL || + s_hub_done == NULL) { + return false; + } + if (wLength > 128) return false; + if (xSemaphoreTake(s_hub_mutex, pdMS_TO_TICKS(3000)) != pdTRUE) { + return false; + } + bool ok = false; + usb_device_handle_t dev = NULL; + usb_transfer_t *x = NULL; + if (usb_host_device_open(s_client, dev_addr, &dev) != ESP_OK) { + goto out; + } + if (usb_host_transfer_alloc(8 + 128, 0, &x) != ESP_OK) goto out; + { + bool dir_in = (bmRequestType & 0x80) != 0; + uint8_t *b = x->data_buffer; + b[0] = bmRequestType; + b[1] = bRequest; + b[2] = (uint8_t)wValue; + b[3] = (uint8_t)(wValue >> 8); + b[4] = (uint8_t)wIndex; + b[5] = (uint8_t)(wIndex >> 8); + b[6] = (uint8_t)wLength; + b[7] = (uint8_t)(wLength >> 8); + if (!dir_in && wLength && data) memcpy(b + 8, data, wLength); + x->num_bytes = 8 + wLength; + x->device_handle = dev; + x->bEndpointAddress = 0; + x->callback = hub_xfer_cb; + x->context = s_hub_done; + xSemaphoreTake(s_hub_done, 0); // clear stale signal + if (usb_host_transfer_submit_control(s_client, x) != ESP_OK) goto out; + if (xSemaphoreTake(s_hub_done, + pdMS_TO_TICKS(hub_timeout_ms(dev_addr))) != pdTRUE) { + goto out; + } + if (x->status != USB_TRANSFER_STATUS_COMPLETED) goto out; + uint16_t got = + (x->actual_num_bytes >= 8) ? (uint16_t)(x->actual_num_bytes - 8) + : 0; + if (dir_in && data && got) { + if (got > wLength) got = wLength; + memcpy(data, b + 8, got); + } + if (actual) *actual = got; + } + ok = true; +out: + if (x) usb_host_transfer_free(x); + if (dev) usb_host_device_close(s_client, dev); + hub_perf_note(dev_addr, ok); + xSemaphoreGive(s_hub_mutex); + return ok; +} + +// Control transfer to a device we hold open (hdl) or by address +// (hdl NULL: opened and closed for the call). +static bool lsusb_xfer(usb_device_handle_t hdl, uint8_t addr, + uint8_t bmRequestType, uint8_t bRequest, + uint16_t wValue, uint16_t wIndex, + void *data, uint16_t wLength, uint16_t *actual) { + if (hdl != NULL) { + return dev_ctrl_hdl(hdl, bmRequestType, bRequest, wValue, wIndex, + data, wLength, actual); + } + return dev_ctrl(addr, bmRequestType, bRequest, wValue, wIndex, data, + wLength, actual); +} + +// One USB string descriptor as NUL-terminated ASCII ('?' for the rest). +static bool lsusb_get_str(usb_device_handle_t hdl, uint8_t dev_addr, + uint8_t index, uint16_t langid, + char *out, uint8_t cap) { + if (index == 0 || cap == 0) return false; + out[0] = 0; + uint8_t buf[66]; + uint16_t actual = 0; + if (!lsusb_xfer(hdl, dev_addr, 0x80, 0x06, (uint16_t)(0x0300 | index), + langid, buf, sizeof(buf), &actual) || + actual < 4 || buf[1] != 0x03) { + return false; + } + uint8_t blen = buf[0]; + if (blen > actual) blen = (uint8_t)actual; + uint8_t o = 0; + for (uint8_t i = 2; i + 1 < blen && o + 1 < cap; i += (uint8_t)2) { + uint16_t c = (uint16_t)(buf[i] | (buf[i + 1] << 8)); + out[o++] = (c >= 0x20 && c < 0x7F) ? (char)c : '?'; + } + out[o] = 0; + return o > 0; +} + +uint16_t usb_disp_hal_lsusb(char *out, uint16_t maxlen) { + if (out == NULL || maxlen == 0) return 0; + out[0] = 0; + if (!s_started || s_client == NULL) return 0; + uint8_t addrs[USB_DISP_LSUSB_MAX_DEV]; + int n = 0; + if (usb_host_device_addr_list_fill((int)sizeof(addrs), addrs, &n) != + ESP_OK) { + return 0; + } + for (int i = 1; i < n; i++) { + uint8_t a = addrs[i]; + int j = i - 1; + while (j >= 0 && addrs[j] > a) { + addrs[j + 1] = addrs[j]; + j--; + } + addrs[j + 1] = a; + } + uint16_t used = 0; + uint8_t held_addr = 0; + usb_disp_hal_claimed_addr(&held_addr); + if (held_addr != 0 && n < USB_DISP_LSUSB_MAX_DEV) { + bool seen = false; + for (int k = 0; k < n; k++) { + if (addrs[k] == held_addr) { + seen = true; + break; + } + } + if (!seen) addrs[n++] = held_addr; + } + for (int i = 0; i < n; i++) { + // The display device held open by this client left the stack's + // idle list, so use our own handle for it (opening it again by + // address is unreliable while streaming). + usb_device_handle_t held = NULL; + if (held_addr != 0 && addrs[i] == held_addr) { + held = s_hal[0].dev; + } + // Same for streaming HID devices (MPOS usb_hid.c): their + // interrupt transfers are live, so reuse the HID client's held + // handle for descriptor/string reads instead of reopening by + // address mid-stream. + if (held == NULL) { + held = usb_hid_held_handle(addrs[i]); + } + usb_device_handle_t dev = held; + if (dev == NULL) { + if (usb_host_device_open(s_client, addrs[i], &dev) != ESP_OK) { + continue; + } + } + const usb_device_desc_t *dd = NULL; + bool ok = + (usb_host_get_device_descriptor(dev, &dd) == ESP_OK && dd != NULL); + uint16_t vid = 0, pid = 0; + uint8_t cls = 0, iman = 0, iprod = 0; + if (ok) { + vid = dd->idVendor; + pid = dd->idProduct; + cls = dd->bDeviceClass; + iman = dd->iManufacturer; + iprod = dd->iProduct; + } + if (held == NULL) { + usb_host_device_close(s_client, dev); + } + if (!ok) continue; + char manuf[32] = {0}, prod[48] = {0}; + if (iman || iprod) { + uint16_t langid = 0x0409; + uint8_t lt[8]; + uint16_t la = 0; + if (lsusb_xfer(held, addrs[i], 0x80, 0x06, 0x0300, 0, lt, + sizeof(lt), &la) && + la >= 4 && lt[1] == 0x03) { + langid = (uint16_t)(lt[2] | (lt[3] << 8)); + } + if (iman) { + lsusb_get_str(held, addrs[i], iman, langid, manuf, + sizeof(manuf)); + } + if (iprod) { + lsusb_get_str(held, addrs[i], iprod, langid, prod, + sizeof(prod)); + } + } + char name[80]; + if (manuf[0] && prod[0]) { + snprintf(name, sizeof(name), "%s %s", manuf, prod); + } else if (prod[0]) { + snprintf(name, sizeof(name), "%s", prod); + } else if (manuf[0]) { + snprintf(name, sizeof(name), "%s", manuf); + } else if (cls == 0x09) { + snprintf(name, sizeof(name), "Hub"); + } else { + snprintf(name, sizeof(name), "Unknown device"); + } + int w = snprintf(out + used, maxlen - used, + "Bus 001 Device %03d: ID %04x:%04x %s\n", addrs[i], + vid, pid, name); + if (w < 0 || (uint16_t)w >= maxlen - used) break; + used = (uint16_t)(used + w); + } + return used; +} + +void usb_disp_hal_set_watchdog(bool on) { s_watchdog_on = on; } + +bool usb_disp_hal_watchdog(void) { return s_watchdog_on; } + +void usb_disp_hal_set_auto_reset_idle(bool on) { s_auto_reset_idle = on; } + +bool usb_disp_hal_auto_reset_idle(void) { return s_auto_reset_idle; } + +// --------------------------------------------------------------- +// HAL インターフェース実装 +// --------------------------------------------------------------- + +usb_disp_hal_t *usb_disp_hal_add(const usb_disp_config_t *cfg) { + // port は現状 0 のみ (S2/S3 は OTG 1系統。P4 の 2系統目 + // 1 = USB 1.1 OTG FS は現在未対応 - 対応時に cfg->port で選ぶ予定) + if (!cfg || cfg->port >= 1) { + usb_disp_log("[HAL] add failed (port %u: only port 0 is supported)", + cfg ? cfg->port : 0); + return NULL; + } + if (s_nhal >= USB_DISP_MAX) return NULL; + struct usb_disp_hal *h = &s_hal[s_nhal++]; + memset(h, 0, sizeof(*h)); + h->in_use = true; + h->urb_max = USB_DISP_BULK_XFER_SIZE; + h->ctrl_mutex = xSemaphoreCreateMutex(); + h->ctrl_done = xSemaphoreCreateBinary(); + h->bulk_free = xSemaphoreCreateCounting(USB_DISP_BULK_XFER_COUNT, 0); + if (s_hub_mutex == NULL) s_hub_mutex = xSemaphoreCreateMutex(); + if (s_hub_done == NULL) s_hub_done = xSemaphoreCreateBinary(); + return h; +} + +// 手動サービスモードは Pico 専用の概念 (ESP32 は start と同義 / task は no-op) +void usb_disp_hal_start_manual(void) { usb_disp_hal_start(); } +void usb_disp_hal_task(void) {} + +#if CONFIG_USB_HOST_ENABLE_ENUM_FILTER_CALLBACK +// エニュメレーションフィルタ (全デバイス許可) +// コンフィグ番号はスタックが渡してきた既定値のまま使う +static bool enum_filter_cb(const usb_device_desc_t *dev_desc, + uint8_t *bConfigurationValue) { + usb_disp_log("[HAL] enum filter: %04X:%04X (cfg=%u)", dev_desc->idVendor, + dev_desc->idProduct, *bConfigurationValue); + return true; +} +#endif + +void usb_disp_hal_start(void) { + if (s_started || s_nhal == 0) return; + s_started = true; + s_stop_tasks = false; + s_daemon_task_done = false; + s_client_task_done = false; + + // インストール前に切断期間を置く。 + // マイコンのリブートはバスリセットだけで VBUS が切れないため、 + // DL-1x5 が「ACK はするが映像出力を有効にしない」ウォーム状態 + // (status dword byte1=0x50、フレッシュ時は 0x40) で残る。 + // PHY 未初期化の間はバス無信号 (= デバイスから見て切断相当) なので、 + // インストール自体を遅らせることで毎回挿入直後と同じ状態から始める + // + // ※arduino-esp32 3.3.11 は CONFIG_USB_HOST_ENABLE_ENUM_FILTER_CALLBACK + // を新規有効化しており、enum_filter_cb が NULL のままだと + // エニュメレーションが無言でハングする。 + // 下の enum_filter_cb の明示指定が必須。 + vTaskDelay(pdMS_TO_TICKS(USB_DISP_ESP32_SETTLE_MS)); + + const usb_host_config_t host_cfg = { + .skip_phy_setup = false, + .root_port_unpowered = false, + .intr_flags = 0, +#if CONFIG_USB_HOST_ENABLE_ENUM_FILTER_CALLBACK + // 必須: + // この機能が有効なビルド (arduino-esp32 3.3.11 以降のプリビルド等) で + // NULL のままにするとエニュメレーションがハングする + .enum_filter_cb = enum_filter_cb, +#endif + }; + ESP_ERROR_CHECK(usb_host_install(&host_cfg)); + + const usb_host_client_config_t client_cfg = { + .is_synchronous = false, + .max_num_event_msg = 32, + .async = { + .client_event_callback = client_event_cb, + .callback_arg = NULL, + }, + }; + ESP_ERROR_CHECK(usb_host_client_register(&client_cfg, &s_client)); + + xTaskCreate(daemon_task, "usbd_daemon", 4096, NULL, 4, NULL); + xTaskCreate(client_task, "usbd_client", 4096, NULL, 5, NULL); +} + +// MPOS runtime host-mode exit (deactivate path, mirrors hal_start). +// Quiesces the display bulk pipe first (halt forces CANCELED completions +// through the still-running client task; freeing transfer memory under +// live URBs is the StoreProhibited crash), then tears down the device, +// stops both tasks, deregisters the client, and uninstalls the host +// stack (which deletes its PHY). Idempotent; hal_start() works again +// afterwards. App thread only (blocks ~300ms). +void usb_disp_hal_stop(void) { + if (!s_started) { + return; + } + for (uint8_t i = 0; i < s_nhal; i++) { + struct usb_disp_hal *h = &s_hal[i]; + if (h->dev != NULL && h->iface_claimed && h->bulk_ep != 0) { + usb_host_endpoint_halt(h->dev, h->bulk_ep); + usb_host_endpoint_flush(h->dev, h->bulk_ep); + } + } + // Let the still-running tasks pump the resulting CANCELED completions + // (and any NO_CLIENTS fallout) BEFORE the tasks die: afterwards nobody + // processes proc requests, their flags stick, and uninstall refuses. + vTaskDelay(pdMS_TO_TICKS(300)); + for (uint8_t i = 0; i < s_nhal; i++) { + device_teardown(&s_hal[i]); + } + s_stop_tasks = true; + usb_host_lib_unblock(); + if (s_client != NULL) { + usb_host_client_unblock(s_client); + } + // Join both task loops (bounded): the client task can be inside a long + // blocking transfer when signalled. Fixed-delay-and-hope left zombies. + uint32_t stop_waited = 0; + while ((!s_daemon_task_done || !s_client_task_done) && stop_waited < 1000) { + vTaskDelay(pdMS_TO_TICKS(10)); + stop_waited += 10; + } + if (!s_daemon_task_done || !s_client_task_done) { + usb_disp_log("[HAL] stop: task join timed out, proceeding anyway"); + } + if (s_client != NULL) { + if (usb_host_client_deregister(s_client) != ESP_OK) { + usb_disp_log("[HAL] stop: client deregister failed"); + } + s_client = NULL; + } + // Drain library events from the app thread: deregister raises + // NO_CLIENTS, which nobody pumps now that the tasks are gone, and + // uninstall refuses while event/proc flags are set. Uninstall itself + // is retried: teardown fallout can need several pump rounds to settle. + esp_err_t uninstall_err = ESP_FAIL; + for (uint8_t u = 0; u < 3 && uninstall_err != ESP_OK; u++) { + for (uint8_t i = 0; i < 10; i++) { + uint32_t flags = 0; + if (usb_host_lib_handle_events(0, &flags) != ESP_OK) { + break; + } + if (flags == 0) { + break; + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + // Free device objects the dead tasks can no longer release + // (NO_CLIENTS handling died with the daemon). + usb_host_device_free_all(); + uninstall_err = usb_host_uninstall(); + if (uninstall_err != ESP_OK) { + usb_disp_log("[HAL] stop: host uninstall failed (0x%X), retrying", + (unsigned)uninstall_err); + vTaskDelay(pdMS_TO_TICKS(100)); + } + } + if (uninstall_err != ESP_OK) { + usb_disp_log("[HAL] stop: host uninstall FAILED (0x%X)", + (unsigned)uninstall_err); + } + s_started = false; + usb_disp_log("[HAL] host stopped"); +} + +void usb_disp_hal_poll(usb_disp_hal_t *h) { + // ほぼイベント駆動 (クライアントタスクが処理)。ここでの仕事は + // 他コンフィグ探索の継続のみ (コントロール転送の完了イベントを + // 処理する client_task からはブロック転送できないため) + if (h->pending_probe && h->dev != NULL && !h->gone) { + h->pending_probe = false; + probe_and_finish(h); + } + // Hub-port watchdog: tracking always runs (so unplugs are observed + // even while attached); recovery actions only fire while detached. + // On the attached->detached edge, snapshot idle ports as preexisting + // (uplinks and steady residents); fresh replugs unmark themselves. + if (s_watchdog_on) { + if (s_prev_attached && !h->attached) s_snapshot_idle = true; + s_prev_attached = h->attached; + hub_watchdog_step(!h->attached); + } else { + s_prev_attached = h->attached; + } +} + +bool usb_disp_hal_attached(usb_disp_hal_t *h) { return h->attached; } +uint16_t usb_disp_hal_vid(usb_disp_hal_t *h) { return h->vid; } +uint16_t usb_disp_hal_pid(usb_disp_hal_t *h) { return h->pid; } + +bool usb_disp_hal_ctrl(usb_disp_hal_t *h, const uint8_t setup[8], void *data, + uint16_t *actual) { + if (!h->attached) return false; + return ctrl_common(h, setup, data, actual); +} + +// 書き込み中スロットをサブミットする (呼び出し側で cur != NULL を保証)。 +// パイプがエラーで HALT 中 (ESP_ERR_INVALID_STATE) は client_task の +// 復旧を待って再試行する (最大 ~500ms) +static bool submit_cur(struct usb_disp_hal *h) { + h->cur->num_bytes = (int)h->cur_fill; + esp_err_t err = ESP_FAIL; + for (uint8_t tries = 0; tries < 50; tries++) { + err = usb_host_transfer_submit(h->cur); + if (err == ESP_OK || !h->attached) break; + vTaskDelay(pdMS_TO_TICKS(10)); // EP 復旧待ち + } + bool ok = (err == ESP_OK); + if (ok) h->stat_bytes += h->cur_fill; + if (!ok) { + // スロットは消費されなかった: 空きカウンタとラウンドロビンを戻す + h->next_slot = (uint8_t)((h->next_slot + USB_DISP_BULK_XFER_COUNT - + 1) % USB_DISP_BULK_XFER_COUNT); + xSemaphoreGive(h->bulk_free); + } + h->cur = NULL; + h->cur_fill = 0; + return ok; +} + +uint32_t usb_disp_hal_bulk_write(usb_disp_hal_t *h, const void *data, + uint32_t len) { + if (!h->attached) return 0; + const uint8_t *src = (const uint8_t *)data; + uint32_t written = 0; + while (written < len) { + if (h->cur == NULL) { + if (xSemaphoreTake(h->bulk_free, pdMS_TO_TICKS(1000)) != pdTRUE) + break; // タイムアウト (デバイス消失等) + if (!h->attached) break; + // 投入順 = 完了順なのでラウンドロビンで空きスロットが決まる + h->cur = h->bulk_xfer[h->next_slot]; + h->next_slot = (uint8_t)((h->next_slot + 1) % + USB_DISP_BULK_XFER_COUNT); + h->cur->flags = 0; // 前回の ZERO_PACK が残らないように + h->cur_fill = 0; + } + uint32_t n = h->urb_max - h->cur_fill; + if (n > len - written) n = len - written; + memcpy(h->cur->data_buffer + h->cur_fill, src + written, n); + h->cur_fill += n; + written += n; + if (h->cur_fill == h->urb_max) { + if (!submit_cur(h)) break; + } + } + return written; +} + +bool usb_disp_hal_bulk_split(usb_disp_hal_t *h) { + if (!h->attached) return false; + if (h->cur && h->cur_fill) return submit_cur(h); + return true; // 保留なし = 既に境界 +} + +bool usb_disp_hal_bulk_zlp(usb_disp_hal_t *h) { + if (!h->attached) return false; + if (h->cur && h->cur_fill) { + // 書き込み中スロットに ZERO_PACK を立てて送出 → フレーム末尾が + // mps の倍数なら DWC が ZLP を後置する + h->cur->flags |= USB_TRANSFER_FLAG_ZERO_PACK; + return submit_cur(h); + } + // 保留データなし: 長さ0の転送で ZLP を送る + if (xSemaphoreTake(h->bulk_free, pdMS_TO_TICKS(1000)) != pdTRUE) + return false; + if (!h->attached) { + xSemaphoreGive(h->bulk_free); + return false; + } + h->cur = h->bulk_xfer[h->next_slot]; + h->next_slot = (uint8_t)((h->next_slot + 1) % USB_DISP_BULK_XFER_COUNT); + h->cur->flags = 0; + h->cur_fill = 0; + return submit_cur(h); // num_bytes=0 で送信 +} + +bool usb_disp_hal_bulk_flush(usb_disp_hal_t *h, uint32_t timeout_ms) { + if (!h->attached) return false; + if (h->cur && h->cur_fill) { + if (!submit_cur(h)) return false; + } + // 全スロットが空きに戻るまで待つ + TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); + uint8_t got = 0; + while (got < USB_DISP_BULK_XFER_COUNT) { + TickType_t now = xTaskGetTickCount(); + TickType_t remain = (deadline > now) ? (deadline - now) : 0; + if (xSemaphoreTake(h->bulk_free, remain) != pdTRUE) break; + got++; + } + for (uint8_t i = 0; i < got; i++) xSemaphoreGive(h->bulk_free); + return got == USB_DISP_BULK_XFER_COUNT; +} + +// ---- 大容量FBメモリ = PSRAM (合計使用量を上限管理) ---- +// 上限はコンパイル時に -DUSB_DISP_PSRAM_LIMIT_KB= で指定 (0=無制限) +// PSRAM 非搭載 (または PSRAM 無効ビルド) では heap_caps_malloc が NULL を返す +#ifndef USB_DISP_PSRAM_LIMIT_KB +#define USB_DISP_PSRAM_LIMIT_KB 0 +#endif + +static uint32_t s_fb_used = 0; + +void *usb_disp_hal_fb_alloc(uint32_t size) { +#if USB_DISP_PSRAM_LIMIT_KB > 0 + if (s_fb_used + size > (uint32_t)USB_DISP_PSRAM_LIMIT_KB * 1024u) { + usb_disp_log("[HAL] fb_alloc %lu KB rejected (limit %u KB, used %lu KB)", + (unsigned long)(size / 1024), USB_DISP_PSRAM_LIMIT_KB, + (unsigned long)(s_fb_used / 1024)); + return NULL; + } +#endif + void *p = heap_caps_malloc(size, MALLOC_CAP_SPIRAM); + if (p) s_fb_used += size; + return p; +} + +void usb_disp_hal_fb_free(void *p, uint32_t size) { + if (!p) return; + heap_caps_free(p); + s_fb_used = (s_fb_used >= size) ? s_fb_used - size : 0; +} + +uint32_t usb_disp_hal_fb_used(void) { return s_fb_used; } + +uint64_t usb_disp_hal_stat_bytes(usb_disp_hal_t *h) { return h->stat_bytes; } + +uint16_t usb_disp_hal_vendor_desc(usb_disp_hal_t *h, void *buf, + uint16_t maxlen) { + uint16_t n = h->vdesc_len; + if (n == 0) return 0; + if (n > maxlen) n = maxlen; + memcpy(buf, h->vdesc, n); + return n; +} + +void usb_disp_hal_request_reenum(usb_disp_hal_t *h) { + // ルートポートの電源 (バス信号) を落として入れ直す = 仮想的な抜き差し。 + // DEV_GONE → client_task の teardown → スタックが再エニュメレーション。 + // 注意: devkit の VBUS は通常 5V 直結なのでデバイスの実電源は切れない + // (信号レベルの切断のみ) + (void)h; + if (!s_started) return; + usb_disp_log("[HAL] root port power cycle"); + usb_host_lib_set_root_port_power(false); + vTaskDelay(pdMS_TO_TICKS(200)); + usb_host_lib_set_root_port_power(true); +} + +#endif // USB_DISP_PORT_ESP32 + diff --git a/c_mpos/usb/upstream/usb_disp_model.h b/c_mpos/usb/upstream/usb_disp_model.h new file mode 100644 index 000000000..73a94d725 --- /dev/null +++ b/c_mpos/usb/upstream/usb_disp_model.h @@ -0,0 +1,76 @@ +// +// ###################################################################### +// +// usb_disp_model - VID/PID 型番リスト +// +// Copyright (C) 2026 +// Hideto Kikuchi / PJラボ (@pcjpnet) - https://pc-jp.net/ +// +// ###################################################################### +// + +#ifndef USB_DISP_MODEL_H_ +#define USB_DISP_MODEL_H_ + +#include + +#include "usb_disp.h" // usb_disp_chip_t + +// ---- 型番リスト (VID/PID 製品型番) ---- +typedef struct { + uint16_t vid; + uint16_t pid; + const char *name; +} usb_disp_model_t; + +static const usb_disp_model_t usb_disp_models[] = { + // VID PID 型番 + { 0x17E9, 0x0128, "RATOC Systems REX-USBDVI" }, + { 0x17E9, 0x0129, "RATOC Systems REX-USBDVI2" }, + { 0x17E9, 0x0150, "I-O DATA USB-RGB" }, + { 0x17E9, 0x0151, "I-O DATA USB-RGB/D" }, + { 0x17E9, 0x0199, "AREA SD-U2VDH" }, + { 0x17E9, 0x01AC, "BUFFALO GX-DVI/U2" }, + { 0x17E9, 0x01BB, "CENTURY LCD-8000U" }, + { 0x17E9, 0x01D7, "HP NL571AA" }, + { 0x17E9, 0x0221, "BUFFALO GX-DVI/U2AI" }, + { 0x17E9, 0x0223, "BUFFALO GX-DVI/U2B" }, + { 0x17E9, 0x028F, "GREEN HOUSE GH-USB-DVIA" }, + { 0x17E9, 0x02D2, "NOVAC NV-CV100UH" }, + { 0x17E9, 0x02E3, "Logitec LDE-WX015U" }, + { 0x17E9, 0x032E, "I-O DATA USB-RGB/D2" }, + { 0x17E9, 0x032F, "I-O DATA USB-RGB2" }, + { 0x17E9, 0x0360, "WAVLINK WL-UG17D1" }, + { 0x17E9, 0x04AC, "BUFFALO GX-DVI/U2C" }, + { 0x17E9, 0x402B, "SANWA SUPPLY AD-USB23HD / 500-KC007, SANKA KDU231" }, + { 0x17E9, 0x413C, "BUFFALO GX-HDMI/U2" }, + { 0x17E9, 0x4304, "I-O DATA USB-RGB3/D" }, + { 0x0711, 0x5601, "j5create JUA330 / JUA350" }, + { 0x534D, 0x6021, "Unknown (China) - MS9122" }, + { 0x345F, 0x9133, "Unknown (China) - MS9132" }, + + +}; + +// ---- DL-1x0 チップ確定リスト ---- +// DL の世代は status dword で判るが、世代内の型番 (120/160 等) はチップが +// 自己申告する max_area (0x5F ベンダーディスクリプタ) の閾値で判定している +// 0x5F に応答しない個体はこの判定ができないため、 +// 分解して実チップを確認できた製品だけをここに登録する +typedef struct { + uint16_t vid; + uint16_t pid; + usb_disp_chip_t chip; +} usb_disp_model_chip_t; + +static const usb_disp_model_chip_t usb_disp_model_chips[] = { + // VID PID 実チップ + { 0x17E9, 0x0128, USB_DISP_CHIP_DL160 },// RATOC Systems REX-USBDVI + { 0x17E9, 0x0150, USB_DISP_CHIP_DL120 },// I-O DATA USB-RGB + { 0x17E9, 0x01AC, USB_DISP_CHIP_DL160 },// BUFFALO GX-DVI/U2 + + +}; + +#endif // USB_DISP_MODEL_H_ + diff --git a/c_mpos/usb/upstream/usb_disp_prot.h b/c_mpos/usb/upstream/usb_disp_prot.h new file mode 100644 index 000000000..d79425b6a --- /dev/null +++ b/c_mpos/usb/upstream/usb_disp_prot.h @@ -0,0 +1,182 @@ +// +// ###################################################################### +// +// usb_disp_prot - プロトコル層抽象 +// +// プロトコル実装 +// usb_disp_prot_dl-1xx.cpp : DisplayLink DL-1x0/1x5 +// usb_disp_prot_ms91xx.cpp : MacroSilicon MS912x/MS913x +// usb_disp_prot_t6.cpp : MCT Trigger 6 +// +// Copyright (C) 2026 +// Hideto Kikuchi / PJラボ (@pcjpnet) - https://pc-jp.net/ +// +// ###################################################################### +// + +#ifndef USB_DISP_PROT_H_ +#define USB_DISP_PROT_H_ + +#include "usb_disp.h" +#include "usb_disp_hal.h" + +// ---- HS プロトコル (T6/MS91xx) を組み込むかどうか ---- +// PC (libusb) と ESP32-P4 (HS ホスト) のみ +// それ以外は FS で帯域が不足するため、実装ごとコンパイルしない +// Teensy 4.x は HS ホストだが、 +// T6/MS91xx はフレームバッファがRAMに収まらないため組み込まない +#if USB_DISP_PORT_ESP32 + #include "sdkconfig.h" +#endif +#if USB_DISP_PORT_LIBUSB || \ + (USB_DISP_PORT_ESP32 && defined(CONFIG_IDF_TARGET_ESP32P4)) + #define USB_DISP_PROT_HS 1 +#else + #define USB_DISP_PROT_HS 0 +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// ---- 接続状態 ---- +typedef enum { + USB_DISP_STAGE_WAIT_DEVICE = 0, + USB_DISP_STAGE_MODE_SETUP, + USB_DISP_STAGE_READY, + USB_DISP_STAGE_FAILED, +} usb_disp_stage_t; + +typedef struct usb_disp_prot usb_disp_prot_t; + +// ---- ディスプレイインスタンス ---- +struct usb_disp { + bool in_use; + usb_disp_hal_t *hal; + bool ready; + usb_disp_config_t cfg; + uint16_t vid, pid; + usb_disp_chip_t chip; + uint32_t max_area; + uint16_t width, height; + usb_disp_mode_t cur_mode; // 現在のモード (タイミング込み。未設定は全0) + bool depth24_want; // アプリ要求 (cfg.depth24 / set_depth) + bool depth24; // 実効 24bit カラー (set_mode 時に確定。 + // prot が非対応なら false のまま = 565 動作) + + const usb_disp_prot_t *prot; // 判別されたプロトコル (未接続は NULL) + void *pp; // プロトコル私有状態 (実装が管理) + + usb_disp_stage_t stage; + uint32_t failed_since_ms; + uint32_t mode_deadline_ms; + uint32_t mode_next_ms; + bool edid_pending; + uint32_t edid_next_ms; + uint32_t edid_until_ms; + + // シャドウFB (差分更新)。スパン更新型プロトコル (CAP_SHADOW) のみ + bool shadow_want; + bool shadow_on; + uint16_t *shadow; + uint32_t shadow_bytes; +}; + +// ---- プロトコル ops ---- +// すべての描画系は READY 後・クリップ済み座標で呼ばれる。 +// NULL 許容: copy / blank / poll / detach (非対応は NULL) +struct usb_disp_prot { + const char *name; // ログ用 ("DL-1xx" 等) + uint8_t caps; + + // このプロトコルが担当するデバイスか (VID/PID) + bool (*match)(uint16_t vid, uint16_t pid); + + // 接続直後の初期化: チップ判別 (d->chip / d->max_area 設定)、 + // プロトコル初期化。false でこのデバイスを FAILED 扱い + bool (*attach)(usb_disp_t *d); + // 切断時のクリーンアップ + void (*detach)(usb_disp_t *d); + + // モード設定。非対応モードは false (コアがフォールバックを続ける) + bool (*set_mode)(usb_disp_t *d, const usb_disp_mode_t *m); + // EDID 読み出し + // offset バイト目から len バイト読み、読めたバイト数を返す + // 拡張ブロック (128B 単位) を読むため offset を取る + // DL-1xx はレジスタのオフセットが 8bit なので 256B 目以降は読めない + uint16_t (*read_edid)(usb_disp_t *d, uint16_t offset, uint8_t *buf, + uint16_t len); + + // 矩形更新 (RGB565, stride_px=0 は同一行の繰り返し) + bool (*update)(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint16_t *px, uint32_t stride_px); + // 矩形更新 (RGB888, 3B/px メモリ順 B,G,R)。NULL = 24bit 非対応。 + // d->depth24 (実効) が真のときだけ呼ばれる。 + // set_mode は d->depth24 を見てモードを組み、 + // 対応できない場合は d->depth24 を false に戻してよい + bool (*update888)(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint8_t *px, uint32_t stride_px); + // 画面内矩形コピー (NULL = 非対応) + bool (*copy)(usb_disp_t *d, uint16_t sx, uint16_t sy, uint16_t dx, + uint16_t dy, uint16_t w, uint16_t h); + // フレーム境界: 蓄積した更新の送出+送信完了待ち + bool (*flush)(usb_disp_t *d, uint32_t timeout_ms); + bool (*blank)(usb_disp_t *d, bool on); + // READY 中の定期処理 (キープアライブ等)。usb_disp_poll から呼ばれる + void (*poll)(usb_disp_t *d); +}; + +// スパン更新型 (行内の任意区間を独立に送れる) = コアのシャドウFB差分が使える。 +// フルフレーム型 (T6/MS91xx) はプロトコル側の dirty 管理に任せる +#define USB_DISP_PROT_CAP_SHADOW 0x01 +// 24bit カラー (RGB888) 対応 +#define USB_DISP_PROT_CAP_888 0x02 + +// ---- プロトコル実装 ---- +extern const usb_disp_prot_t usb_disp_prot_dl1xx; +#if USB_DISP_PROT_HS +extern const usb_disp_prot_t usb_disp_prot_t6; +extern const usb_disp_prot_t usb_disp_prot_ms91xx; +#endif + +// VID/PID からプロトコルを探す +// (コア実装。HAL は usb_disp.h の usb_disp_supported_device() を使う) +const usb_disp_prot_t *usb_disp_prot_find(uint16_t vid, uint16_t pid); + +// チップ確定リスト (usb_disp_model.h) から実チップを引く。 +// SKU 情報 (0x5F) を公開しない DL-1x0 個体の型番判定用 +// (リストに無ければ USB_DISP_CHIP_UNKNOWN) +usb_disp_chip_t usb_disp_model_chip(uint16_t vid, uint16_t pid); + +// ---- 共有ヘルパ (コア提供) ---- +// コントロール転送 (setup 8B を組み立てて HAL へ) +bool usb_disp_prot_ctrl(usb_disp_t *d, uint8_t bmRequestType, + uint8_t bRequest, uint16_t wValue, uint16_t wIndex, + void *data, uint16_t wLength, uint16_t *actual); + +// HS プロトコル用スリープ (初期化シーケンスの待ち時間。 +// PC / ESP32-P4 のみで使われる, Pico にはブロッキング待ちを持ち込まない) +#if USB_DISP_PROT_HS + #if USB_DISP_PORT_ESP32 + #include "freertos/FreeRTOS.h" + #include "freertos/task.h" + static inline void usb_disp_prot_sleep_ms(uint32_t ms) { + vTaskDelay(pdMS_TO_TICKS(ms)); + } + #elif defined(_WIN32) + void __stdcall Sleep(unsigned long ms); // を引き込まない + static inline void usb_disp_prot_sleep_ms(uint32_t ms) { Sleep(ms); } + #else + #include + static inline void usb_disp_prot_sleep_ms(uint32_t ms) { + usleep(ms * 1000); + } + #endif +#endif + +#ifdef __cplusplus +} +#endif + +#endif // USB_DISP_PROT_H_ + diff --git a/c_mpos/usb/upstream/usb_disp_prot_dl-1xx.cpp b/c_mpos/usb/upstream/usb_disp_prot_dl-1xx.cpp new file mode 100644 index 000000000..ed368f789 --- /dev/null +++ b/c_mpos/usb/upstream/usb_disp_prot_dl-1xx.cpp @@ -0,0 +1,499 @@ +// +// ###################################################################### +// +// usb_disp_prot_dl-1xx - DisplayLink DL-1x0 / 1x5 プロトコル実装 +// +// Copyright (C) 2026 +// Hideto Kikuchi / PJラボ (@pcjpnet) - https://pc-jp.net/ +// +// ###################################################################### +// + +#include + +#include "usb_disp_prot.h" + +#define USB_DISP_DL_LINE_MAX(w) \ + (7u * (((w) + 255u) / 256u) + 3u * (uint32_t)(w) + 16u) + +// 共有スクラッチ (描画 API は単一スレッドから逐次呼び出し = 共有可) +static uint8_t s_desc_buf[64]; +static uint8_t s_cmdbuf[USB_DISP_DL_LINE_MAX(USB_DISP_MAX_WIDTH)]; + +// --------------------------------------------------------------- +// レジスタ / エンコードヘルパ +// --------------------------------------------------------------- + +static bool dl_ctrl(usb_disp_t *d, uint8_t bmRequestType, uint8_t bRequest, + uint16_t wValue, uint16_t wIndex, void *data, + uint16_t wLength, uint16_t *actual) { + return usb_disp_prot_ctrl(d, bmRequestType, bRequest, wValue, wIndex, + data, wLength, actual); +} + +static uint8_t *set_register(uint8_t *buf, uint8_t reg, uint8_t val) { + *buf++ = 0xAF; + *buf++ = 0x20; + *buf++ = reg; + *buf++ = val; + return buf; +} +static uint8_t *set_register_16(uint8_t *buf, uint8_t reg, uint16_t val) { + buf = set_register(buf, reg, (uint8_t)(val >> 8)); + return set_register(buf, (uint8_t)(reg + 1), (uint8_t)val); +} +static uint8_t *set_register_16be(uint8_t *buf, uint8_t reg, uint16_t val) { + buf = set_register(buf, reg, (uint8_t)val); + return set_register(buf, (uint8_t)(reg + 1), (uint8_t)(val >> 8)); +} +static uint16_t lfsr16(uint16_t actual_count) { + uint32_t lv = 0xFFFF; + while (actual_count--) { + lv = ((lv << 1) | + (((lv >> 15) ^ (lv >> 4) ^ (lv >> 2) ^ (lv >> 1)) & 1)) & + 0xFFFF; + } + return (uint16_t)lv; +} +static uint8_t *set_register_lfsr16(uint8_t *buf, uint8_t reg, uint16_t val) { + return set_register_16(buf, reg, lfsr16(val)); +} + +// udlfb: dlfb_compress_hline() と同じ RLE エンコード (0xAF 0x6B) +static size_t dl_compress_span(uint32_t dev_addr, const uint16_t *pixels, + uint32_t count, uint8_t *out) { + const uint16_t *pixel = pixels; + const uint16_t *const pixel_end = pixels + count; + uint8_t *cmd = out; + + while (pixel < pixel_end) { + uint8_t *raw_pixels_count_byte; + uint8_t *cmd_pixels_count_byte; + const uint16_t *raw_pixel_start; + const uint16_t *cmd_pixel_start; + const uint16_t *cmd_pixel_end; + + *cmd++ = 0xAF; + *cmd++ = 0x6B; + *cmd++ = (uint8_t)(dev_addr >> 16); + *cmd++ = (uint8_t)(dev_addr >> 8); + *cmd++ = (uint8_t)dev_addr; + + cmd_pixels_count_byte = cmd++; + cmd_pixel_start = pixel; + raw_pixels_count_byte = cmd++; + raw_pixel_start = pixel; + + { + uint32_t remain = (uint32_t)(pixel_end - pixel); + cmd_pixel_end = pixel + (remain > 256 ? 256 : remain); + } + + while (pixel < cmd_pixel_end) { + const uint16_t *const repeating_pixel = pixel; + uint16_t v = *pixel; + *cmd++ = (uint8_t)(v >> 8); + *cmd++ = (uint8_t)v; + pixel++; + + if ((pixel < cmd_pixel_end) && (*pixel == *repeating_pixel)) { + *raw_pixels_count_byte = + (uint8_t)((repeating_pixel - raw_pixel_start) + 1); + while ((pixel < cmd_pixel_end) && (*pixel == *repeating_pixel)) + pixel++; + *cmd++ = (uint8_t)((pixel - repeating_pixel) - 1); + raw_pixel_start = pixel; + raw_pixels_count_byte = cmd++; + } + } + + if (pixel > raw_pixel_start) { + *raw_pixels_count_byte = (uint8_t)(pixel - raw_pixel_start); + } else { + cmd--; + } + *cmd_pixels_count_byte = (uint8_t)(pixel - cmd_pixel_start); + dev_addr += (uint32_t)(pixel - cmd_pixel_start) * 2; + } + return (size_t)(cmd - out); +} + +// --------------------------------------------------------------- +// チップ判別 +// --------------------------------------------------------------- + +static bool dl_select_channel(usb_disp_t *d) { + static const uint8_t key[16] = { + 0x57, 0xCD, 0xDC, 0xA7, 0x1C, 0x88, 0x5E, 0x15, + 0x60, 0xFE, 0xC6, 0x97, 0x16, 0x3D, 0x47, 0xF2, + }; + uint8_t buf[16]; + memcpy(buf, key, 16); + return dl_ctrl(d, 0x40, 0x12, 0, 0, buf, 16, NULL); +} + +// ベンダーディスクリプタ (0x5F) の KLV 列から key 0x0200 = max_area +static uint32_t parse_vendor_desc(const uint8_t *buf, uint16_t buflen) { + if (buflen < 6) return 0; + uint16_t total = buf[0]; + if (total > buflen) total = buflen; + if (buf[1] != 0x5F || buf[2] != 0x01 || buf[3] != 0x00) return 0; + const uint8_t *p = buf + 5; + const uint8_t *end = buf + total; + while (p + 3 <= end) { + uint16_t key = (uint16_t)(p[0] | (p[1] << 8)); + uint8_t len = p[2]; + p += 3; + if (p + len > end) break; + if (key == 0x0200 && len >= 4) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); + } + p += len; + } + return 0; +} + +// チップ種別 → 公称 max_area [px]。 +// 通常は max_area をチップが自己申告する (0x5F ベンダーディスクリプタ) が、 +// 申告しない個体をチップ確定リスト (usb_disp_model.h) で救済したときはここから導出する +static uint32_t dl_chip_nominal_area(usb_disp_chip_t chip) { + switch (chip) { + case USB_DISP_CHIP_DL120: return 1470000; // 1400x1050 + case USB_DISP_CHIP_DL160: return 1920000; // 1600x1200 + case USB_DISP_CHIP_DL115: return 614400; // 1024x600 + case USB_DISP_CHIP_DL125: return 1310720; // 1280x1024 + case USB_DISP_CHIP_DL165: return 2073600; // 1920x1080 + case USB_DISP_CHIP_DL195: return 2359296; // 2048x1152 + default: return 0; + } +} + +// チップ判別: status dword (世代) + ベンダーディスクリプタ (SKU)。attach から呼ばれる +static void dl_detect_chip(usb_disp_t *d) { + d->chip = USB_DISP_CHIP_UNKNOWN; + d->max_area = 0; + + bool alex = false, ollie = false; + uint8_t sbuf[4] = {0}; + uint16_t actual = 0; + if (dl_ctrl(d, 0xC0, 0x06, 0, 0, sbuf, 4, &actual) && actual == 4) { + usb_disp_log("[DL] status dword: %02X %02X %02X %02X", sbuf[0], + sbuf[1], sbuf[2], sbuf[3]); + if (sbuf[3] == 0xF1) ollie = true; // DL-1x5 世代 + else if ((sbuf[3] >> 4) == 0xF) alex = true; // DL-1x0 世代 + } + + actual = 0; + if (dl_ctrl(d, 0x80, 0x06, 0x5F00, 0, s_desc_buf, 64, &actual) && + actual > 5) { + usb_disp_log("[DL] vendor desc: len=%u %02X %02X %02X %02X %02X...", + actual, s_desc_buf[0], s_desc_buf[1], s_desc_buf[2], + s_desc_buf[3], s_desc_buf[4]); + d->max_area = parse_vendor_desc(s_desc_buf, actual); + } + if (d->max_area == 0) { + // フォールバック: コンフィグディスクリプタ内の 0x5F (HAL が捕捉) + uint16_t n = usb_disp_hal_vendor_desc(d->hal, s_desc_buf, + sizeof(s_desc_buf)); + if (n > 5) { + d->max_area = parse_vendor_desc(s_desc_buf, n); + usb_disp_log("[DL] vendor desc from config (len=%u, max_area=%lu)", + n, (unsigned long)d->max_area); + } + } + + // チップ確定リスト (usb_disp_model.h) による救済: + // 0x5F に応答しない個体でも、分解等で実チップが確認済みならチップを直接確定し、 + // max_area はチップ種別の公称値から導出する + if (d->max_area == 0) { + usb_disp_chip_t known = usb_disp_model_chip(d->vid, d->pid); + if (known != USB_DISP_CHIP_UNKNOWN) { + d->chip = known; + d->max_area = dl_chip_nominal_area(known); + usb_disp_log("[DL] known product: %s", + usb_disp_model_name(d->vid, d->pid)); + } + } + + if (d->chip != USB_DISP_CHIP_UNKNOWN) { + // チップ確定リストで判定済み (以下の閾値判定は不要) + } else if (d->max_area > 0 && alex) { + d->chip = (d->max_area >= 1764000) ? USB_DISP_CHIP_DL160 + : USB_DISP_CHIP_DL120; + } else if (d->max_area > 0) { + if (d->max_area >= 2359296) d->chip = USB_DISP_CHIP_DL195; + else if (d->max_area >= 2073600) d->chip = USB_DISP_CHIP_DL165; + else if (d->max_area >= 1310720) d->chip = USB_DISP_CHIP_DL125; + else d->chip = USB_DISP_CHIP_DL115; + } else if (ollie) { + d->chip = USB_DISP_CHIP_DL1X5; + } else if (alex) { + d->chip = USB_DISP_CHIP_DL1X0; + } + usb_disp_log("[DL] chip: %s (max_area=%lu px)", usb_disp_chip_name(d), + (unsigned long)d->max_area); +} + +// --------------------------------------------------------------- +// prot ops +// --------------------------------------------------------------- + +static bool dl_match(uint16_t vid, uint16_t pid) { + (void)pid; + return vid == 0x17E9; +} + +static bool dl_attach(usb_disp_t *d) { + if (!dl_select_channel(d)) + usb_disp_log("[DISP%d] channel select failed (continue)", + usb_disp_index(d)); + dl_detect_chip(d); + return true; +} + +// EDID 読み出し (1バイトずつ)。 +// バイトオフセットは wValue の上位バイトに載せるため 0..255 しか指定できない +// このチップから読めるのは EDID の先頭 256 バイト (ベースブロック + 拡張ブロック1個) まで +#define DL_EDID_MAX 256 + +static uint16_t dl_read_edid(usb_disp_t *d, uint16_t offset, uint8_t *buf, + uint16_t len) { + uint8_t rbuf[2]; + if (offset >= DL_EDID_MAX) return 0; + if ((uint32_t)offset + len > DL_EDID_MAX) len = (uint16_t)(DL_EDID_MAX - offset); + for (uint16_t i = 0; i < len; i++) { + uint16_t actual = 0; + if (!dl_ctrl(d, 0xC0, 0x02, (uint16_t)((offset + i) << 8), 0xA1, rbuf, + 2, &actual) || + actual != 2) { + return i; + } + buf[i] = rbuf[1]; + } + return len; +} + +static bool dl_set_mode(usb_disp_t *d, const usb_disp_mode_t *m) { + uint8_t buf[192]; + uint8_t *w = buf; + uint32_t fb_bytes = (uint32_t)m->width * m->height * 2; + + uint16_t xds = (uint16_t)(m->hbp + m->hsync); + uint16_t xde = (uint16_t)(xds + m->width); + uint16_t yds = (uint16_t)(m->vbp + m->vsync); + uint16_t yde = (uint16_t)(yds + m->height); + uint16_t yec = (uint16_t)(m->height + m->vbp + m->vfp + m->vsync); + + w = set_register(w, 0xFF, 0x00); + // 色深度: 0x00 = 16bpp / 0x01 = 24bpp + // (16+8 デュアルプレーン、base16 = RGB565、base8 = R[2:0]G[1:0]B[2:0] の下位ビット面。 + // 分割は libdlo dlo_grfx.c の DLO_RG16/GB16/RGB8 と同一) + w = set_register(w, 0x00, d->depth24 ? 0x01 : 0x00); + w = set_register(w, 0x20, 0); + w = set_register(w, 0x21, 0); + w = set_register(w, 0x22, 0); + w = set_register(w, 0x26, (uint8_t)(fb_bytes >> 16)); + w = set_register(w, 0x27, (uint8_t)(fb_bytes >> 8)); + w = set_register(w, 0x28, (uint8_t)fb_bytes); + w = set_register_lfsr16(w, 0x01, xds); + w = set_register_lfsr16(w, 0x03, xde); + w = set_register_lfsr16(w, 0x05, yds); + w = set_register_lfsr16(w, 0x07, yde); + w = set_register_lfsr16(w, 0x09, (uint16_t)(xde + m->hfp - 1)); + w = set_register_lfsr16(w, 0x0B, 1); + w = set_register_lfsr16(w, 0x0D, (uint16_t)(m->hsync + 1)); + w = set_register_16(w, 0x0F, m->width); + w = set_register_lfsr16(w, 0x11, yec); + w = set_register_lfsr16(w, 0x13, 0); + w = set_register_lfsr16(w, 0x15, m->vsync); + w = set_register_16(w, 0x17, m->height); + w = set_register_16be(w, 0x1B, (uint16_t)(m->pclk_khz / 5)); + w = set_register(w, 0x1F, 0x00); + w = set_register(w, 0xFF, 0xFF); + + uint32_t n = (uint32_t)(w - buf); + if (usb_disp_hal_bulk_write(d->hal, buf, n) != n) return false; + return usb_disp_hal_bulk_flush(d->hal, 1000); +} + +// 矩形更新 (クリップ済み)。行毎に RLE で送る。stride_px=0 は同一行の繰り返し +static bool dl_update(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint16_t *px, uint32_t stride_px) { + for (uint16_t row = 0; row < h; row++) { + const uint16_t *src = stride_px ? px + (uint32_t)row * stride_px : px; + uint32_t dev_addr = (((uint32_t)(y + row) * d->width) + x) * 2; + size_t n = dl_compress_span(dev_addr, src, w, s_cmdbuf); + if (usb_disp_hal_bulk_write(d->hal, s_cmdbuf, (uint32_t)n) != n) + return false; + } + return true; +} + +// 画面内矩形コピー (COPY16 0xAF 0x6A) +// ---- 24bpp (16+8 デュアルプレーン) ---- + +// B,G,R 3バイト → RGB565 (base16 プレーン用) +static inline uint16_t dl_px565(const uint8_t *p) { + return (uint16_t)(((p[2] & 0xF8) << 8) | ((p[1] & 0xFC) << 3) | + (p[0] >> 3)); +} +// B,G,R 3バイト → base8 プレーンの下位ビット面 (R[2:0] G[1:0] B[2:0]) +static inline uint8_t dl_px8(const uint8_t *p) { + return (uint8_t)(((p[2] & 0x07) << 5) | ((p[1] & 0x03) << 3) | + (p[0] & 0x07)); +} + +static uint16_t s_row565[USB_DISP_MAX_WIDTH]; // 888 → 565 変換した1行 + +// 矩形更新 (RGB888)。行毎に base16 (RLE) と base8 (RAW8) の両方を送る +static bool dl_update888(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint8_t *px, uint32_t stride_px) { + uint32_t base8_start = (uint32_t)d->width * d->height * 2; + for (uint16_t row = 0; row < h; row++) { + const uint8_t *src = + stride_px ? px + (uint32_t)row * stride_px * 3 : px; + + // base16 プレーン (RGB565, 既存の RLE 圧縮) + for (uint16_t i = 0; i < w; i++) s_row565[i] = dl_px565(src + i * 3); + uint32_t adr16 = (((uint32_t)(y + row) * d->width) + x) * 2; + size_t n = dl_compress_span(adr16, s_row565, w, s_cmdbuf); + if (usb_disp_hal_bulk_write(d->hal, s_cmdbuf, (uint32_t)n) != n) + return false; + + // base8 プレーン (下位ビット面, RAW8: AF 60 addr[3] count(0=256) data) + uint32_t adr8 = base8_start + ((uint32_t)(y + row) * d->width) + x; + uint8_t *q = s_cmdbuf; + uint32_t rem = w; + const uint8_t *sp = src; + while (rem) { + uint32_t seg = (rem > 256) ? 256 : rem; + *q++ = 0xAF; + *q++ = 0x60; + *q++ = (uint8_t)(adr8 >> 16); + *q++ = (uint8_t)(adr8 >> 8); + *q++ = (uint8_t)adr8; + *q++ = (uint8_t)(seg == 256 ? 0 : seg); + for (uint32_t i = 0; i < seg; i++, sp += 3) *q++ = dl_px8(sp); + adr8 += seg; + rem -= seg; + } + uint32_t len8 = (uint32_t)(q - s_cmdbuf); + if (usb_disp_hal_bulk_write(d->hal, s_cmdbuf, len8) != len8) + return false; + } + return true; +} + +static bool dl_copy(usb_disp_t *d, uint16_t sx, uint16_t sy, uint16_t dx, + uint16_t dy, uint16_t w, uint16_t h) { + uint32_t seg = 256; + bool rtl = false; + if (sy == dy && sx != dx) { // 同一行内の水平シフトのみ分割が必要 + uint32_t dist = (dx > sx) ? (uint32_t)(dx - sx) : (uint32_t)(sx - dx); + if (dist < seg) seg = dist; + rtl = (dx > sx); // 右シフトは右端セグメントから + } + bool bottom_up = (dy > sy); // コピー先が下 → 下の行から + uint32_t nseg = ((uint32_t)w + seg - 1) / seg; + + for (uint16_t i = 0; i < h; i++) { + uint16_t row = bottom_up ? (uint16_t)(h - 1 - i) : i; + uint32_t src = (((uint32_t)(sy + row) * d->width) + sx) * 2; + uint32_t dst = (((uint32_t)(dy + row) * d->width) + dx) * 2; + uint8_t *p = s_cmdbuf; + for (uint32_t k = 0; k < nseg; k++) { + uint32_t off = (rtl ? (nseg - 1 - k) : k) * seg; + uint32_t n = w - off; + if (n > seg) n = seg; + uint32_t s_adr = src + off * 2; + uint32_t d_adr = dst + off * 2; + *p++ = 0xAF; + *p++ = 0x6A; + *p++ = (uint8_t)(d_adr >> 16); + *p++ = (uint8_t)(d_adr >> 8); + *p++ = (uint8_t)d_adr; + *p++ = (uint8_t)(n == 256 ? 0 : n); + *p++ = (uint8_t)(s_adr >> 16); + *p++ = (uint8_t)(s_adr >> 8); + *p++ = (uint8_t)s_adr; + if (p + 9 > s_cmdbuf + sizeof(s_cmdbuf)) { + uint32_t len = (uint32_t)(p - s_cmdbuf); + if (usb_disp_hal_bulk_write(d->hal, s_cmdbuf, len) != len) + return false; + p = s_cmdbuf; + } + } + // 24bpp 時は base8 プレーンも同じ形でコピー + // (COPY8: AF 62 dest[3] len(0=256) src[3]、アドレスはバイト単位) + if (d->depth24) { + uint32_t base8 = (uint32_t)d->width * d->height * 2; + uint32_t src8 = base8 + ((uint32_t)(sy + row) * d->width) + sx; + uint32_t dst8 = base8 + ((uint32_t)(dy + row) * d->width) + dx; + for (uint32_t k = 0; k < nseg; k++) { + uint32_t off = (rtl ? (nseg - 1 - k) : k) * seg; + uint32_t n = w - off; + if (n > seg) n = seg; + uint32_t s_adr = src8 + off; + uint32_t d_adr = dst8 + off; + *p++ = 0xAF; + *p++ = 0x62; + *p++ = (uint8_t)(d_adr >> 16); + *p++ = (uint8_t)(d_adr >> 8); + *p++ = (uint8_t)d_adr; + *p++ = (uint8_t)(n == 256 ? 0 : n); + *p++ = (uint8_t)(s_adr >> 16); + *p++ = (uint8_t)(s_adr >> 8); + *p++ = (uint8_t)s_adr; + if (p + 9 > s_cmdbuf + sizeof(s_cmdbuf)) { + uint32_t len = (uint32_t)(p - s_cmdbuf); + if (usb_disp_hal_bulk_write(d->hal, s_cmdbuf, len) != len) + return false; + p = s_cmdbuf; + } + } + } + uint32_t len = (uint32_t)(p - s_cmdbuf); + if (len && usb_disp_hal_bulk_write(d->hal, s_cmdbuf, len) != len) + return false; + } + return true; +} + +static bool dl_flush(usb_disp_t *d, uint32_t timeout_ms) { + // DL チップはストリーム最後のコマンドを「次のデータの先頭」が来るまで + // 実行保留することがある → フラッシュコマンド 0xAF 0xA0 + if (d->ready) { + static const uint8_t k_sync[2] = {0xAF, 0xA0}; + usb_disp_hal_bulk_write(d->hal, k_sync, 2); + } + return usb_disp_hal_bulk_flush(d->hal, timeout_ms); +} + +static bool dl_blank(usb_disp_t *d, bool on) { + uint8_t buf[16]; + uint8_t *w = buf; + w = set_register(w, 0xFF, 0x00); + w = set_register(w, 0x1F, on ? 0x07 : 0x00); + w = set_register(w, 0xFF, 0xFF); + uint32_t n = (uint32_t)(w - buf); + if (usb_disp_hal_bulk_write(d->hal, buf, n) != n) return false; + return usb_disp_hal_bulk_flush(d->hal, 1000); +} + +const usb_disp_prot_t usb_disp_prot_dl1xx = { + .name = "DL-1xx", + .caps = USB_DISP_PROT_CAP_SHADOW | USB_DISP_PROT_CAP_888, + .match = dl_match, + .attach = dl_attach, + .detach = NULL, + .set_mode = dl_set_mode, + .read_edid = dl_read_edid, + .update = dl_update, + .update888 = dl_update888, + .copy = dl_copy, + .flush = dl_flush, + .blank = dl_blank, + .poll = NULL, +}; + diff --git a/c_mpos/usb/upstream/usb_disp_prot_ms91xx.cpp b/c_mpos/usb/upstream/usb_disp_prot_ms91xx.cpp new file mode 100644 index 000000000..89ad5859d --- /dev/null +++ b/c_mpos/usb/upstream/usb_disp_prot_ms91xx.cpp @@ -0,0 +1,639 @@ +// +// ###################################################################### +// +// usb_disp_prot_ms91xx - MacroSilicon MS912x / MS913x プロトコル実装 +// +// Copyright (C) 2026 +// Hideto Kikuchi / PJラボ (@pcjpnet) - https://pc-jp.net/ +// +// ###################################################################### +// + +#include "usb_disp_prot.h" + +#if USB_DISP_PROT_HS + +#include +#include + +#if USB_DISP_PORT_ESP32 + #include "esp_heap_caps.h" // PSRAM 有無の診断用 +#endif + +#define USB_DISP_MS_KEEPALIVE_MS 2000 // MS913x: 公式ドライバと同じ2秒毎再送 + +typedef enum { MS_VAR_912X, MS_VAR_913X } ms_variant_t; + +typedef struct { + ms_variant_t var; + // フレームバッファ + uint16_t *fb565; // マスタ (RGB565。m24 時は 3B/px B,G,R として使用) + bool m24; // 24bit マスタ + uint8_t *wire; // ワイヤ形式 (UYVY=2B/px or RGB24=3B/px) + size_t fb_bytes, wire_bytes; + uint16_t w, h; + // dirty 矩形 (565→wire 変換範囲の限定用) + bool dirty; + uint16_t dx0, dy0, dx1, dy1; + // MS913x + bool first; // 次フレームで video_enable + unmute + uint8_t frame_index; // 0/1 (ダブルバッファトグル) + uint32_t last_send_ms; + uint32_t mute_chk_ms; // MS913x: HDMIミュート監視の前回時刻 + uint32_t frames_dbg; // 診断ログ用フレームカウンタ + uint16_t alloc_fail_cnt; // FB 確保失敗の連発ログ抑制 +} ms_priv_t; + +static ms_priv_t s_ms[USB_DISP_MAX]; +static ms_priv_t *msp(usb_disp_t *d) { return &s_ms[usb_disp_index(d)]; } + +// --------------------------------------------------------------- +// 制御 (HID Feature Report 8B, EP0) +// --------------------------------------------------------------- + +static bool hid_set(usb_disp_t *d, const uint8_t data[8]) { + return usb_disp_prot_ctrl(d, 0x21, 0x09, 0x0300, 0, (void *)data, 8, + NULL); +} +static bool hid_get(usb_disp_t *d, uint8_t data[8]) { + return usb_disp_prot_ctrl(d, 0xA1, 0x01, 0x0300, 0, data, 8, NULL); +} + +// レジスタ読み。0..255 = 値, -1 = 転送失敗 +static int16_t ms_read_reg(usb_disp_t *d, uint16_t addr) { + uint8_t b[8] = {0xB5, (uint8_t)(addr >> 8), (uint8_t)addr, 0, 0, 0, 0, 0}; + if (!hid_set(d, b)) return -1; + if (!hid_get(d, b)) return -1; + return b[3]; +} +static bool ms_write_reg(usb_disp_t *d, uint16_t addr, uint8_t val) { + uint8_t b[8] = {0xB6, (uint8_t)(addr >> 8), (uint8_t)addr, val, + 0, 0, 0, 0}; + return hid_set(d, b); +} +// MS912x: 6バイト書き (0xA6 addr data[6]) +static bool ms_write6(usb_disp_t *d, uint8_t addr, const uint8_t data[6]) { + uint8_t b[8] = {0xA6, addr, 0, 0, 0, 0, 0, 0}; + memcpy(b + 2, data, 6); + return hid_set(d, b); +} +// MS913x: 映像コマンド (0xA6 sub_op + 6バイト) +static bool ms3_vid_cmd(usb_disp_t *d, uint8_t sub, uint8_t a, uint8_t b2, + uint8_t e, uint8_t f, uint8_t g, uint8_t h) { + uint8_t b[8] = {0xA6, sub, a, b2, e, f, g, h}; + return hid_set(d, b); +} + +// MS913x: HDMI TX mute (reg 0xFB07 bit1、enable でクリア) +static void ms3_screen_enable(usb_disp_t *d, bool en) { + int16_t v = ms_read_reg(d, 0xFB07); + if (v < 0) return; + uint8_t nv = en ? (uint8_t)(v & ~0x02) : (uint8_t)(v | 0x02); + ms_write_reg(d, 0xFB07, nv); +} + +// --------------------------------------------------------------- +// 初期化シーケンス +// --------------------------------------------------------------- + +// MS912x: rhgndf/ms912x の ms912x_set_resolution() と同一処理 +static bool ms2_set_resolution(usb_disp_t *d, uint16_t w, uint16_t h, + uint16_t mode_id) { + const uint16_t pixfmt = 0x2200; + uint8_t b[6]; + memset(b, 0, 6); + if (!ms_write6(d, 0x04, b)) return false; + ms_read_reg(d, 0x30); + ms_read_reg(d, 0x33); + ms_read_reg(d, 0xC620); + memset(b, 0, 6); + b[0] = 0x03; + if (!ms_write6(d, 0x03, b)) return false; + b[0] = (uint8_t)(w >> 8); b[1] = (uint8_t)w; + b[2] = (uint8_t)(h >> 8); b[3] = (uint8_t)h; + b[4] = (uint8_t)(pixfmt >> 8); b[5] = (uint8_t)pixfmt; + if (!ms_write6(d, 0x01, b)) return false; + b[0] = (uint8_t)(mode_id >> 8); b[1] = (uint8_t)mode_id; + b[2] = (uint8_t)(w >> 8); b[3] = (uint8_t)w; + b[4] = (uint8_t)(h >> 8); b[5] = (uint8_t)h; + if (!ms_write6(d, 0x02, b)) return false; + memset(b, 0, 6); + b[0] = 1; + if (!ms_write6(d, 0x04, b)) return false; + memset(b, 0, 6); + b[0] = 1; + if (!ms_write6(d, 0x05, b)) return false; + return true; +} + +static bool ms2_power(usb_disp_t *d, bool on) { + uint8_t b[6] = {0, 0, 0, 0, 0, 0}; + if (on) { b[0] = 0x01; b[1] = 0x02; } + return ms_write6(d, 0x07, b); +} + +// MS913x: 公式 ms9132_event_disable / enable と同一処理 +static void ms3_pipe_disable(usb_disp_t *d) { + ms3_vid_cmd(d, 0x04, 0, 0, 0, 0, 0, 0); // trans_enable(0) + ms3_vid_cmd(d, 0x05, 0, 0, 0, 0, 0, 0); // video_enable(0) + ms3_screen_enable(d, false); + ms3_vid_cmd(d, 0x07, 0, 2, 0, 0, 0, 0); // power(off) +} + +static void ms3_pipe_enable(usb_disp_t *d, uint16_t w, uint16_t h, + uint8_t vic) { + uint8_t wh = (uint8_t)(w >> 8), wl = (uint8_t)w; + uint8_t hh = (uint8_t)(h >> 8), hl = (uint8_t)h; + ms3_vid_cmd(d, 0x04, 0, 0, 0, 0, 0, 0); + ms3_vid_cmd(d, 0x05, 0, 0, 0, 0, 0, 0); + ms3_screen_enable(d, false); + usb_disp_prot_sleep_ms(50); + ms3_vid_cmd(d, 0x07, 1, 2, 0, 0, 0, 0); // power(on) + usb_disp_prot_sleep_ms(50); + ms3_vid_cmd(d, 0x03, 0, 0, 0, 0, 0, 0); // trans_mode(FRAME) + ms3_vid_cmd(d, 0x01, wh, wl, hh, hl, 0x21, 0); // in_info (RGB888=24bpp) + ms3_vid_cmd(d, 0x02, vic, 0x01, wh, wl, hh, hl); // out_info + ms3_vid_cmd(d, 0x04, 1, 0, 0, 0, 0, 0); // trans_enable(1) + usb_disp_prot_sleep_ms(50); + ms3_vid_cmd(d, 0x05, 0, 0, 0, 0, 0, 0); // video は初フレームまで + ms3_screen_enable(d, false); +} + +// --------------------------------------------------------------- +// フレームバッファ / 変換 +// --------------------------------------------------------------- + +static void ms_free_bufs(ms_priv_t *p) { + if (p->fb565) { free(p->fb565); p->fb565 = NULL; } + if (p->wire) { free(p->wire); p->wire = NULL; } + p->fb_bytes = p->wire_bytes = 0; +} + +static bool ms_alloc_bufs(ms_priv_t *p, uint16_t w, uint16_t h) { + uint8_t wbpp = (p->var == MS_VAR_913X) ? 3 : 2; + size_t need565 = (size_t)w * h * (p->m24 ? 3 : 2); + size_t needw = (size_t)w * h * wbpp; + if (p->fb565 && p->fb_bytes == need565 && p->wire && + p->wire_bytes == needw) + return true; + ms_free_bufs(p); + p->fb565 = (uint16_t *)malloc(need565); + p->wire = (uint8_t *)malloc(needw); + if (!p->fb565 || !p->wire) { + // モード自動選択が候補を総当たりするため失敗は連発する → 抑制 + p->alloc_fail_cnt++; + if (p->alloc_fail_cnt <= 8 || (p->alloc_fail_cnt & 0x3F) == 0) { + usb_disp_log("[MS91xx] FB alloc failed (%lu KB)", + (unsigned long)((need565 + needw) / 1024)); + } + ms_free_bufs(p); + return false; + } + p->alloc_fail_cnt = 0; + p->fb_bytes = need565; + p->wire_bytes = needw; + memset(p->fb565, 0, need565); + // 黒: UYVY = 0x10 0x80 / RGB24 = 0 + if (p->var == MS_VAR_913X) { + memset(p->wire, 0, needw); + } else { + for (size_t i = 0; i < needw; i += 2) { + p->wire[i] = 0x80; // U/V + p->wire[i + 1] = 0x10; // Y + } + } + return true; +} + +static void ms_dirty_add(ms_priv_t *p, uint16_t x, uint16_t y, uint16_t w, + uint16_t h) { + uint16_t x1 = (uint16_t)(x + w - 1), y1 = (uint16_t)(y + h - 1); + if (!p->dirty) { + p->dx0 = x; p->dy0 = y; p->dx1 = x1; p->dy1 = y1; + p->dirty = true; + } else { + if (x < p->dx0) p->dx0 = x; + if (y < p->dy0) p->dy0 = y; + if (x1 > p->dx1) p->dx1 = x1; + if (y1 > p->dy1) p->dy1 = y1; + } +} + +// RGB565 → Y/U/V (BT.601, TV レンジ近似) +static inline void rgb565_yuv(uint16_t v, uint8_t *Y, uint8_t *U, uint8_t *V) { + int r = ((v >> 11) & 0x1F) * 255 / 31; + int g = ((v >> 5) & 0x3F) * 255 / 63; + int b = (v & 0x1F) * 255 / 31; + int y = (77 * r + 150 * g + 29 * b) >> 8; // 0..255 + *Y = (uint8_t)(16 + y * 219 / 255); + *U = (uint8_t)(128 + (((b - y) * 126) >> 8)); + *V = (uint8_t)(128 + (((r - y) * 160) >> 8)); +} + +// B,G,R 3バイト → Y/U/V (BT.601, TV レンジ近似) +static inline void rgb888_yuv(const uint8_t *px, uint8_t *Y, uint8_t *U, + uint8_t *V) { + int b = px[0], g = px[1], r = px[2]; + int y = (77 * r + 150 * g + 29 * b) >> 8; + *Y = (uint8_t)(16 + y * 219 / 255); + *U = (uint8_t)(128 + (((b - y) * 126) >> 8)); + *V = (uint8_t)(128 + (((r - y) * 160) >> 8)); +} + +// dirty 領域を 565 マスタ → ワイヤ形式へ変換 +static void ms_convert_dirty(ms_priv_t *p) { + if (!p->dirty) return; + uint16_t x0 = p->dx0, x1 = p->dx1; + if (p->var != MS_VAR_913X) { // UYVY はピクセルペア境界に拡張 + x0 &= (uint16_t)~1u; + x1 |= 1; + if (x1 >= p->w) x1 = (uint16_t)(p->w - 1); + } + for (uint16_t y = p->dy0; y <= p->dy1; y++) { + if (p->m24) { + // 24bit マスタ (B,G,R) + const uint8_t *src8 = + (const uint8_t *)p->fb565 + (uint32_t)y * p->w * 3; + if (p->var == MS_VAR_913X) { + // ワイヤも B,G,R → そのままコピー + memcpy(p->wire + ((uint32_t)y * p->w + x0) * 3, + src8 + (uint32_t)x0 * 3, + (size_t)(x1 - x0 + 1) * 3); + } else { + uint8_t *dst = p->wire + ((uint32_t)y * p->w + x0) * 2; + for (uint16_t x = x0; x <= x1; x += 2) { + uint8_t Y0, U0, V0, Y1, U1, V1; + uint16_t xb = (uint16_t)(x + 1 <= x1 ? x + 1 : x); + rgb888_yuv(src8 + (uint32_t)x * 3, &Y0, &U0, &V0); + rgb888_yuv(src8 + (uint32_t)xb * 3, &Y1, &U1, &V1); + *dst++ = (uint8_t)((U0 + U1) / 2); + *dst++ = Y0; + *dst++ = (uint8_t)((V0 + V1) / 2); + *dst++ = Y1; + } + } + continue; + } + const uint16_t *src = p->fb565 + (uint32_t)y * p->w; + if (p->var == MS_VAR_913X) { + uint8_t *dst = p->wire + ((uint32_t)y * p->w + x0) * 3; + for (uint16_t x = x0; x <= x1; x++) { + uint16_t v = src[x]; + // ワイヤ順 B,G,R + *dst++ = (uint8_t)((v & 0x1F) * 255 / 31); + *dst++ = (uint8_t)(((v >> 5) & 0x3F) * 255 / 63); + *dst++ = (uint8_t)(((v >> 11) & 0x1F) * 255 / 31); + } + } else { + uint8_t *dst = p->wire + ((uint32_t)y * p->w + x0) * 2; + for (uint16_t x = x0; x <= x1; x += 2) { + uint8_t Y0, U0, V0, Y1, U1, V1; + rgb565_yuv(src[x], &Y0, &U0, &V0); + rgb565_yuv(src[x + 1 <= x1 ? x + 1 : x], &Y1, &U1, &V1); + *dst++ = (uint8_t)((U0 + U1) / 2); + *dst++ = Y0; + *dst++ = (uint8_t)((V0 + V1) / 2); + *dst++ = Y1; + } + } + } +} + +// --------------------------------------------------------------- +// フレーム送信 +// --------------------------------------------------------------- + +static bool ms_send_frame(usb_disp_t *d, ms_priv_t *p) { + if (p->var == MS_VAR_913X) { + // RGB24 全面 1 ストリーム + ZLP + frame_index トグル + trigger。 + // 送信が不完全でもトリガーまで必ず進める (公式ドライバと同じ流儀)。 + // 0x9133 実測: チップは TRIGGER_FRAME を受けるまでバルク受信 DMA を + // 起こさないことがあり (-88KB の FIFO が埋まって NAK 連発)、 + // ここで諦めるとトリガーが永遠に出ずデッドロックする。 + // トリガーは EP0 経由なので詰まっていても届き、受信 DMA が起きて + // 滞留データが流れ出す → 次フレームから正常化する + uint32_t sent = usb_disp_hal_bulk_write(d->hal, p->wire, + (uint32_t)p->wire_bytes); + bool zok = usb_disp_hal_bulk_zlp(d->hal); // フレーム長は 512 の倍数 + bool fok = usb_disp_hal_bulk_flush(d->hal, 5000); + p->frame_index ^= 1; + bool tok = ms3_vid_cmd(d, 0x00, (uint8_t)p->frame_index, 100, 0, 0, + 0, 0); + if (sent != p->wire_bytes || !zok || !fok) { + usb_disp_log("[MS913x] partial frame: sent=%lu/%lu zlp=%d " + "flush=%d trig=%d", + (unsigned long)sent, (unsigned long)p->wire_bytes, + (int)zok, (int)fok, (int)tok); + return false; + } + if (p->first) { + bool vok = ms3_vid_cmd(d, 0x05, 1, 0, 0, 0, 0, 0); + ms3_screen_enable(d, true); // HDMI unmute + p->first = false; + usb_disp_log("[MS913x] first frame: %lu B zlp=%d trig=%d " + "ven=%d mute=%02X d003=%02X", + (unsigned long)p->wire_bytes, zok, tok, vok, + ms_read_reg(d, 0xFB07), ms_read_reg(d, 0xD003)); + } else if ((p->frames_dbg++ & 0x3F) == 0) { + usb_disp_log("[MS913x] frame#%lu zlp=%d trig=%d idx=%d " + "mute=%02X d003=%02X", + (unsigned long)p->frames_dbg, zok, tok, + p->frame_index, ms_read_reg(d, 0xFB07), + ms_read_reg(d, 0xD003)); + } + } else { + // MS912x: [8B ヘッダ (全画面)] + UYVY + [8B 終端] + uint8_t hdr[8], eob[8]; + hdr[0] = 0xFF; hdr[1] = 0x00; + hdr[2] = 0; // x/16 + hdr[3] = 0; hdr[4] = 0; // y (BE16) + hdr[5] = (uint8_t)(p->w / 16); // width/16 + hdr[6] = (uint8_t)(p->h >> 8); hdr[7] = (uint8_t)p->h; + memset(eob, 0, 8); + eob[0] = 0xFF; eob[1] = 0xC0; + if (usb_disp_hal_bulk_write(d->hal, hdr, 8) != 8) return false; + if (usb_disp_hal_bulk_write(d->hal, p->wire, (uint32_t)p->wire_bytes) + != p->wire_bytes) + return false; + if (usb_disp_hal_bulk_write(d->hal, eob, 8) != 8) return false; + if (!usb_disp_hal_bulk_flush(d->hal, 5000)) return false; + } + p->last_send_ms = usb_disp_hal_ms(); + return true; +} + +// --------------------------------------------------------------- +// チップ判定 +// --------------------------------------------------------------- + +// ここに並ぶ VID/PID 推測で追加することはしない +// (誤って別物を掴むと、映像が出ないだけでなく MS913x では固着 → VBUS 物理切断が必要になる) +// +// 未知の個体を入手したときの追加手順: +// 1. その PID をここに追加して認識させる +// 2. MS913x 世代なら ms_attach の変種判定にも PID を足す +// (RGB24 経路。足さないと MS912x 扱い = UYVY で誤動作する) +// 3. attach 時に出る chip_id=XXXX ログで世代を確認する + +static bool ms_match(uint16_t vid, uint16_t pid) { + if (vid == 0x534D && pid == 0x6021) return true; // MS912x + if (vid == 0x345F && (pid == 0x9132 || pid == 0x9133)) return true; + return false; +} + +static bool ms_attach(usb_disp_t *d) { + ms_priv_t *p = msp(d); + // 世代の振り分け + // 既定は MS912x で、既知の MS913x 世代の PID だけを RGB24 経路へ回す + p->var = (d->vid == 0x345F && d->pid == 0x9133) ? MS_VAR_913X + : MS_VAR_912X; + // 疎通確認 + // ※最初のベンダーコマンドが ZeroCD イジェクトのトリガーになる + // (未イジェクト個体は約5秒後に再列挙 → コアが自動で再 attach) + int16_t r30 = ms_read_reg(d, 0x30); + if (r30 < 0) { + usb_disp_log("[MS91xx] control probe failed"); + return false; + } +#if USB_DISP_PORT_ESP32 + // MS91xx はフルフレーム FB + ワイヤバッファが必須 + // PSRAM 無効ビルド (P4 のボード設定 PSRAM: Disabled) では確保できず + // 表示が出ないので、原因が分かるようにここで明示しておく + if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM) == 0) { + usb_disp_log("[MS91xx] warning: PSRAM not available. Frame buffer " + "alloc will fail - enable board option PSRAM"); + } +#endif + p->alloc_fail_cnt = 0; + if (p->var == MS_VAR_913X) { + int16_t hi = ms_read_reg(d, 0xFF00), lo = ms_read_reg(d, 0xFF01); + usb_disp_log("[MS913x] chip_id=%02X%02X", hi, lo); + d->chip = USB_DISP_CHIP_MS913X; + d->max_area = 2073600; // 1920x1080 + } else { + d->chip = USB_DISP_CHIP_MS912X; + d->max_area = 2073600; // 1920x1080 + } + return true; +} + +static void ms_detach(usb_disp_t *d) { + ms_priv_t *p = msp(d); + p->dirty = false; + // MS913x: 可能なら映像パイプを止めておく + // (固着予防。切断後なので失敗しても無害 - hid_set が false を返すだけ) + if (p->var == MS_VAR_913X) ms3_pipe_disable(d); +} + +static uint16_t ms_read_edid(usb_disp_t *d, uint16_t offset, uint8_t *buf, + uint16_t len) { + // レジスタ 0xC000 + offset を 1 バイトずつ + for (uint16_t i = 0; i < len; i++) { + int16_t v = ms_read_reg(d, (uint16_t)(0xC000 + offset + i)); + if (v < 0) return i; + buf[i] = (uint8_t)v; + } + return len; +} + +// 既知モード (MS912x の mode_id / MS913x の vic) +// MS913x の vic は CEA-861 ではなく MS 独自番号 (公式ドライバ g_support_mode) +// MS912x の mode_id 上位バイトと同じ体系 (0x8100 -> 129 / 0x4F00 -> 79) +static const struct { uint16_t w, h, mode_id; uint8_t vic; } k_ms_modes[] = { + { 1920, 1080, 0x8100, 129 }, + { 1280, 720, 0x4F00, 79 }, +}; + +static bool ms_set_mode(usb_disp_t *d, const usb_disp_mode_t *m) { + ms_priv_t *p = msp(d); + int8_t mi = -1; + for (uint8_t i = 0; i < sizeof(k_ms_modes) / sizeof(k_ms_modes[0]); i++) { + if (k_ms_modes[i].w == m->width && k_ms_modes[i].h == m->height) { + mi = (int8_t)i; + break; + } + } + if (mi < 0) { + usb_disp_log("[MS91xx] %ux%u not in known mode list", m->width, + m->height); + return false; + } + p->m24 = d->depth24; + if (!ms_alloc_bufs(p, m->width, m->height)) { + if (p->m24) { // メモリ不足なら 565 マスタで再試行 + p->m24 = false; + d->depth24 = false; + if (!ms_alloc_bufs(p, m->width, m->height)) return false; + } else { + return false; + } + } + p->w = m->width; + p->h = m->height; + + if (p->var == MS_VAR_913X) { + ms3_pipe_disable(d); + usb_disp_prot_sleep_ms(200); + ms3_pipe_enable(d, p->w, p->h, k_ms_modes[mi].vic); + int16_t fsw = ms_read_reg(d, 0xD003); + p->frame_index = (fsw > 0) ? 1 : 0; + // 先行トリガー: バルク受信 DMA を起こす。まだミュート中なので画面には出ない。 + // (ms_send_frame の 0x9133 実測コメント参照) + // これが無いと初回フレームが FIFO 分 (-88KB) で詰まり、 + // トリガー到達までの数秒間デッドロック状態になる。 + // コールドスタートではこの先行トリガーも空振りすることがあるが、 + // その場合も ms_send_frame の partial 経路 (詰まってもトリガーを送る) + // が数秒で受信 DMA を起こし、次のフレームから正常化する + usb_disp_prot_sleep_ms(200); + ms3_vid_cmd(d, 0x00, (uint8_t)p->frame_index, 100, 0, 0, 0, 0); + p->first = true; + } else { + ms2_power(d, false); + usb_disp_prot_sleep_ms(100); + if (!ms2_power(d, true)) return false; + if (!ms2_set_resolution(d, p->w, p->h, k_ms_modes[mi].mode_id)) + return false; + } + // 黒 FB を初回 flush で送る (全面 dirty) + p->dirty = false; + ms_dirty_add(p, 0, 0, p->w, p->h); + return true; +} + +static bool ms_update(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint16_t *px, uint32_t stride_px) { + ms_priv_t *p = msp(d); + if (!p->fb565 || p->w == 0) return false; + for (uint16_t row = 0; row < h; row++) { + const uint16_t *src = stride_px ? px + (uint32_t)row * stride_px : px; + if (p->m24) { + uint8_t *dst = + (uint8_t *)p->fb565 + ((uint32_t)(y + row) * p->w + x) * 3; + for (uint16_t i = 0; i < w; i++) { + uint16_t v = src[i]; + dst[i * 3 + 0] = (uint8_t)((v & 0x1F) * 255 / 31); // B + dst[i * 3 + 1] = (uint8_t)(((v >> 5) & 0x3F) * 255 / 63); // G + dst[i * 3 + 2] = (uint8_t)(((v >> 11) & 0x1F) * 255 / 31); // R + } + } else { + memcpy(p->fb565 + (uint32_t)(y + row) * p->w + x, src, + (size_t)w * 2); + } + } + ms_dirty_add(p, x, y, w, h); + return true; +} + +// 矩形更新 (RGB888, B,G,R)。24bit マスタへそのまま書く +static bool ms_update888(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint8_t *px, uint32_t stride_px) { + ms_priv_t *p = msp(d); + if (!p->fb565 || p->w == 0 || !p->m24) return false; + for (uint16_t row = 0; row < h; row++) { + const uint8_t *src = + stride_px ? px + (uint32_t)row * stride_px * 3 : px; + memcpy((uint8_t *)p->fb565 + ((uint32_t)(y + row) * p->w + x) * 3, + src, (size_t)w * 3); + } + ms_dirty_add(p, x, y, w, h); + return true; +} + +static bool ms_copy(usb_disp_t *d, uint16_t sx, uint16_t sy, uint16_t dx, + uint16_t dy, uint16_t w, uint16_t h) { + ms_priv_t *p = msp(d); + if (!p->fb565 || p->w == 0) return false; + bool bottom_up = (dy > sy); + uint8_t bpp = (uint8_t)(p->m24 ? 3 : 2); + uint8_t *fb = (uint8_t *)p->fb565; + for (uint16_t i = 0; i < h; i++) { + uint16_t row = bottom_up ? (uint16_t)(h - 1 - i) : i; + memmove(fb + ((uint32_t)(dy + row) * p->w + dx) * bpp, + fb + ((uint32_t)(sy + row) * p->w + sx) * bpp, + (size_t)w * bpp); + } + ms_dirty_add(p, dx, dy, w, h); + return true; +} + +static bool ms_flush(usb_disp_t *d, uint32_t timeout_ms) { + ms_priv_t *p = msp(d); + if (p->dirty && p->fb565 && p->w) { + ms_convert_dirty(p); + p->dirty = false; + if (!ms_send_frame(d, p)) return false; + } + return usb_disp_hal_bulk_flush(d->hal, timeout_ms); +} + +static bool ms_blank(usb_disp_t *d, bool on) { + ms_priv_t *p = msp(d); + if (p->var == MS_VAR_913X) { + if (on) { + ms3_vid_cmd(d, 0x05, 0, 0, 0, 0, 0, 0); + ms3_screen_enable(d, false); + } else { + p->first = true; // 次フレームで video_enable + unmute + ms_dirty_add(p, 0, 0, p->w, p->h); + } + } else { + if (on) { + ms2_power(d, false); + } else { + // 復帰はモード再設定が必要 → 全面再送 + usb_disp_mode_t m; + memset(&m, 0, sizeof(m)); + m.width = p->w; + m.height = p->h; + m.pclk_khz = 60u * ((uint32_t)p->w + 160) * (p->h + 40) / 1000; + if (!ms_set_mode(d, &m)) return false; + } + } + return true; +} + +// MS913x キープアライブ: 最終送信から 12 秒でブランクする → +// 2秒毎にワイヤ FB をそのまま再送する (公式ドライバの周期再送と同じ) +static void ms_poll(usb_disp_t *d) { + ms_priv_t *p = msp(d); + if (p->var != MS_VAR_913X || !d->ready || !p->wire || p->w == 0) return; + uint32_t now = usb_disp_hal_ms(); + if (now - p->last_send_ms >= USB_DISP_MS_KEEPALIVE_MS) { + ms_send_frame(d, p); + } + // HDMI側の抜き差し等で出力がミュートされるとフレーム再送だけでは黒のまま復帰しない。 + // ミュートレジスタ (0xFB07 bit1) を監視し、立っていたら video_enable + unmute を再発行する。 + // 注意: 外乱の種類によっては FB07=00 (非ミュート)・フレーム消費正常のまま + // 出力だけ黒になる状態があり、これはレジスタから検出できない + // その場合はアプリから usb_disp_set_mode / set_auto_mode の + // 再実行 (パイプ再初期化) で復旧する - DL の HPD ブランクと同じ作法 + if (now - p->mute_chk_ms >= USB_DISP_MS_KEEPALIVE_MS) { + p->mute_chk_ms = now; + int16_t mv = ms_read_reg(d, 0xFB07); + if (mv >= 0 && (mv & 0x02)) { + usb_disp_log("[MS913x] HDMI muted (FB07=%02X) - re-enabling", mv); + ms3_vid_cmd(d, 0x05, 1, 0, 0, 0, 0, 0); // video_enable(1) + ms3_screen_enable(d, true); // unmute + ms_send_frame(d, p); // フレームも即再送 + } + } +} + +const usb_disp_prot_t usb_disp_prot_ms91xx = { + .name = "MS91xx", + .caps = USB_DISP_PROT_CAP_888, // フルフレーム型 + .match = ms_match, + .attach = ms_attach, + .detach = ms_detach, + .set_mode = ms_set_mode, + .read_edid = ms_read_edid, + .update = ms_update, + .update888 = ms_update888, + .copy = ms_copy, + .flush = ms_flush, + .blank = ms_blank, + .poll = ms_poll, +}; + +#endif // USB_DISP_PROT_HS + diff --git a/c_mpos/usb/upstream/usb_disp_prot_t6.cpp b/c_mpos/usb/upstream/usb_disp_prot_t6.cpp new file mode 100644 index 000000000..6a336a650 --- /dev/null +++ b/c_mpos/usb/upstream/usb_disp_prot_t6.cpp @@ -0,0 +1,479 @@ +// +// ###################################################################### +// +// usb_disp_prot_t6 - MCT Trigger 6 プロトコル実装 +// +// Copyright (C) 2026 +// Hideto Kikuchi / PJラボ (@pcjpnet) - https://pc-jp.net/ +// +// ###################################################################### +// + +#include "usb_disp_prot.h" + +#if USB_DISP_PROT_HS + +#include +#include + +#if USB_DISP_PORT_ESP32 + #include "driver/jpeg_encode.h" // ESP32-P4 HW JPEG エンコーダ + #include "esp_heap_caps.h" // PSRAM 有無の診断用 +#else + #include "usb_disp_prot_t6_jpeg.h" // PC: 依存なしソフトエンコーダ +#endif + +#define USB_DISP_T6_JPEG_Q 85 +#define USB_DISP_T6_MB (1024u * 1024u) +#define USB_DISP_T6_MAX_MODES 40 +#define USB_DISP_T6_JOUT_CAP (1024u * 1024u) // cmd リングの 1MB ステップ前提 + +typedef struct { + // チップ情報 (attach で取得) + uint8_t ram_mb; // VRAM サイズ [MB] (1 バイト応答) + uint8_t nmodes; + uint8_t modes[USB_DISP_T6_MAX_MODES][32]; // チップのモード表 (0x89) + // フレーム送信状態 + uint32_t fb_slot[3]; + uint32_t cmd_addr, cmd_limit; + uint32_t frames; + // フレームバッファ (ESP32=RGB565 / PC=RGB888) + JPEG 出力 + uint8_t *fb; + size_t fb_bytes; + uint8_t *jout; + size_t jout_cap; + uint16_t w, h; + bool dirty; + bool m24; // 24bit マスタ (ESP32: FB=RGB888 / PC は常に888) + uint8_t bpp; // FB のバイト/px (2 or 3) + uint16_t alloc_fail_cnt; // FB 確保失敗の連発ログ抑制 +#if USB_DISP_PORT_ESP32 + jpeg_encoder_handle_t enc; +#endif +} t6_priv_t; + +static t6_priv_t s_t6[USB_DISP_MAX]; + +static t6_priv_t *t6p(usb_disp_t *d) { return &s_t6[usb_disp_index(d)]; } + +static uint16_t rd16(const uint8_t *p) { return (uint16_t)(p[0] | (p[1] << 8)); } +static void wr32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); + p[2] = (uint8_t)(v >> 16); p[3] = (uint8_t)(v >> 24); +} +static void wr16(uint8_t *p, uint16_t v) { + p[0] = (uint8_t)v; p[1] = (uint8_t)(v >> 8); +} + +static bool t6_vin(usb_disp_t *d, uint8_t req, uint16_t wv, uint16_t wi, + void *buf, uint16_t len, uint16_t *actual) { + return usb_disp_prot_ctrl(d, 0xC0, req, wv, wi, buf, len, actual); +} +static bool t6_vout(usb_disp_t *d, uint8_t req, uint16_t wv, uint16_t wi, + void *buf, uint16_t len) { + return usb_disp_prot_ctrl(d, 0x40, req, wv, wi, buf, len, NULL); +} + +// --------------------------------------------------------------- +// バッファ管理 +// --------------------------------------------------------------- + +static void t6_free_bufs(t6_priv_t *p) { + // ESP32 の jpeg_alloc_encoder_mem も heap_caps 確保なので free でよい + if (p->fb) { free(p->fb); p->fb = NULL; } + if (p->jout) { free(p->jout); p->jout = NULL; } + p->fb_bytes = 0; +} + +static bool t6_alloc_bufs(usb_disp_t *d, t6_priv_t *p, uint16_t w, uint16_t h) { + size_t need = (size_t)w * h * p->bpp; + if (p->fb && p->fb_bytes == need) return true; + t6_free_bufs(p); +#if USB_DISP_PORT_ESP32 + jpeg_encode_memory_alloc_cfg_t ic = {JPEG_ENC_ALLOC_INPUT_BUFFER}; + jpeg_encode_memory_alloc_cfg_t oc = {JPEG_ENC_ALLOC_OUTPUT_BUFFER}; + size_t asz; + p->fb = (uint8_t *)jpeg_alloc_encoder_mem(need, &ic, &asz); + p->jout = (uint8_t *)jpeg_alloc_encoder_mem(USB_DISP_T6_JOUT_CAP, &oc, + &p->jout_cap); +#else + p->fb = (uint8_t *)malloc(need); + p->jout = (uint8_t *)malloc(USB_DISP_T6_JOUT_CAP); + p->jout_cap = USB_DISP_T6_JOUT_CAP; +#endif + if (!p->fb || !p->jout) { + // モード自動選択が候補を総当たりするため失敗は連発する → 抑制 + p->alloc_fail_cnt++; + if (p->alloc_fail_cnt <= 8 || (p->alloc_fail_cnt & 0x3F) == 0) { + usb_disp_log("[T6] FB alloc failed (%lu KB)", + (unsigned long)(need / 1024)); + } + t6_free_bufs(p); + return false; + } + p->alloc_fail_cnt = 0; + p->fb_bytes = need; + memset(p->fb, 0, need); // 黒 + return true; +} + +// --------------------------------------------------------------- +// JPEG エンコード (全面) +// --------------------------------------------------------------- + +// 戻り値: JPEG バイト数、負 = エンコード失敗/バッファ不足 +static int32_t t6_encode(usb_disp_t *d, t6_priv_t *p) { +#if USB_DISP_PORT_ESP32 + jpeg_encode_cfg_t cfg = {}; + cfg.height = p->h; + cfg.width = p->w; + cfg.src_type = p->m24 ? JPEG_ENCODE_IN_FORMAT_RGB888 + : JPEG_ENCODE_IN_FORMAT_RGB565; + cfg.sub_sample = JPEG_DOWN_SAMPLING_YUV420; + cfg.image_quality = USB_DISP_T6_JPEG_Q; + uint32_t jlen = 0; + esp_err_t err = jpeg_encoder_process(p->enc, &cfg, p->fb, + (uint32_t)p->fb_bytes, p->jout, + (uint32_t)p->jout_cap, &jlen); + if (err != 0) { + usb_disp_log("[T6] HW encode err=0x%X", (unsigned)err); + return -1; + } + return (int32_t)jlen; +#else + return usb_disp_prot_t6_jpeg_encode(p->jout, p->jout_cap, p->fb, p->w, + p->h, USB_DISP_T6_JPEG_Q, + USB_DISP_PROT_T6_JPEG_420); +#endif +} + +// --------------------------------------------------------------- +// フレーム送信 +// --------------------------------------------------------------- + +static bool t6_send_frame(usb_disp_t *d, t6_priv_t *p, uint32_t jlen) { + static uint8_t pad[1024]; // ゼロ (static 初期値) + uint8_t sel[32], vh[48]; + + uint32_t pitch = (uint32_t)((p->w + 31) / 32) * 32; + uint32_t hceil = (uint32_t)((p->h + 31) / 32) * 32; + uint32_t y_block = pitch * hceil + 1024; + uint32_t video_size = jlen + 1024; + uint32_t total = 48 + video_size; + uint32_t fb = p->fb_slot[p->frames % 3]; + uint8_t flag = (p->frames < 10) ? 0x80 : 0x00; + + uint32_t step = (total + USB_DISP_T6_MB - 1) / USB_DISP_T6_MB * + USB_DISP_T6_MB; + if (p->cmd_addr + step > p->cmd_limit) { + p->cmd_addr = 0; + flag = 0x80; + } + + memset(vh, 0, 48); + wr32(vh + 0, 3); // FLIP_PRIMARY + wr32(vh + 4, video_size); + wr32(vh + 12, 6); // TargetFormat = NV12 + wr16(vh + 16, (uint16_t)pitch); + wr16(vh + 18, (uint16_t)pitch); + wr32(vh + 20, fb); + wr32(vh + 24, fb + y_block); + wr32(vh + 32, 13); // SourceFormat = JPEG + vh[47] = flag; + + memset(sel, 0, 32); + wr32(sel + 4, total); // session 0 = video + wr32(sel + 8, p->cmd_addr); + wr32(sel + 12, total); + + // セレクタは独立した USB 転送にする (32B ショートパケット)。 + // PC / 実験スケッチで実証済みのワイヤ形式に合わせる + if (usb_disp_hal_bulk_write(d->hal, sel, 32) != 32) return false; + if (!usb_disp_hal_bulk_split(d->hal)) return false; + if (usb_disp_hal_bulk_write(d->hal, vh, 48) != 48) return false; + if (usb_disp_hal_bulk_write(d->hal, p->jout, jlen) != jlen) return false; + if (usb_disp_hal_bulk_write(d->hal, pad, 1024) != 1024) return false; + + p->cmd_addr += step; + p->frames++; + return true; +} + +// --------------------------------------------------------------- +// prot ops +// --------------------------------------------------------------- + +static bool t6_match(uint16_t vid, uint16_t pid) { + if (vid == 0x0711 && (pid & 0xFFE0) == 0x5600) return true; // MCT + if (vid == 0x19FF && (pid & 0xFFE0) == 0x5600) return true; // Insignia? + if (vid == 0x03F0 && (pid == 0x0182 || pid == 0x0788)) return true; // HP? + return false; +} + +static bool t6_attach(usb_disp_t *d) { + t6_priv_t *p = t6p(d); + + // VRAM サイズ → アドレス計画 (triggerdm 1出力ドングル流) + uint8_t b1 = 0; + uint16_t actual = 0; + if (!t6_vin(d, 0x88, 0, 0, &b1, 1, &actual) || actual != 1 || b1 < 16) { + usb_disp_log("[T6] VRAM query failed"); + return false; + } + p->ram_mb = b1; + p->fb_slot[0] = (uint32_t)(p->ram_mb - 12) * USB_DISP_T6_MB; + p->fb_slot[1] = (uint32_t)(p->ram_mb - 8) * USB_DISP_T6_MB; + p->fb_slot[2] = (uint32_t)(p->ram_mb - 4) * USB_DISP_T6_MB; + p->cmd_addr = 0; + p->cmd_limit = p->fb_slot[0]; + p->frames = 0; + + b1 = 0; + t6_vin(d, 0x87, 0, 0, &b1, 1, NULL); + usb_disp_log("[T6] VRAM=%uMB connector=%u", p->ram_mb, b1); + + // モード表 (32B × N)。オフセットは wIndex 指定なので 256B ずつ読む + uint8_t cnt4[4] = {0}; + t6_vin(d, 0x84, 0, 0, cnt4, 4, NULL); + uint16_t nmodes = (uint16_t)(cnt4[0] | (cnt4[1] << 8)); + if (nmodes == 0 || nmodes > USB_DISP_T6_MAX_MODES) + nmodes = USB_DISP_T6_MAX_MODES; + uint16_t mlen = 0; + for (uint16_t off = 0; off < nmodes * 32; off += 256) { + uint16_t want = (uint16_t)(nmodes * 32 - off); + if (want > 256) want = 256; + actual = 0; + if (!t6_vin(d, 0x89, 0, off, (uint8_t *)p->modes + off, want, + &actual) || actual == 0) + break; + mlen = (uint16_t)(off + actual); + if (actual < want) break; + } + p->nmodes = (uint8_t)(mlen / 32); + + // max_area = モード表の最大解像度 + uint32_t max_area = 0; + for (uint8_t i = 0; i < p->nmodes; i++) { + uint32_t a = (uint32_t)rd16(p->modes[i] + 8) * rd16(p->modes[i] + 16); + if (a > max_area) max_area = a; + } + d->chip = USB_DISP_CHIP_T6; + d->max_area = max_area; + usb_disp_log("[T6] %u modes, max_area=%lu px", p->nmodes, + (unsigned long)max_area); +#if USB_DISP_PORT_ESP32 + // T6 はフルフレーム FB + JPEG 出力バッファが必須 + // PSRAM 無効ビルド (P4 のボード設定 PSRAM: Disabled) では確保できず + // 表示が出ないので、原因が分かるようにここで明示しておく + if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM) == 0) { + usb_disp_log("[T6] warning: PSRAM not available. Frame buffer " + "alloc will fail - enable board option PSRAM"); + } +#endif + p->alloc_fail_cnt = 0; + return p->nmodes > 0; +} + +static void t6_detach(usb_disp_t *d) { + t6_priv_t *p = t6p(d); + p->dirty = false; + // FB/エンコーダは保持 (再接続で再利用。解像度が変われば realloc) +} + +static uint16_t t6_read_edid(usb_disp_t *d, uint16_t offset, uint8_t *buf, + uint16_t len) { + // 0x80: 128B ブロック読み (wValue = バイトオフセット) + uint16_t got = 0; + while (got < len) { + uint16_t want = (uint16_t)(len - got); + if (want > 128) want = 128; + uint16_t actual = 0; + if (!t6_vin(d, 0x80, (uint16_t)(offset + got), 0, buf + got, want, + &actual) || actual == 0) + break; + got = (uint16_t)(got + actual); + if (actual < want) break; + } + return got; +} + +// モード表から W/H/Hz の一致エントリを探す +static const uint8_t *t6_find_mode(t6_priv_t *p, uint16_t w, uint16_t h, + uint16_t hz) { + for (uint8_t i = 0; i < p->nmodes; i++) { + const uint8_t *m = p->modes[i]; + if (rd16(m + 8) == w && rd16(m + 16) == h && rd16(m + 4) == hz) + return m; + } + return NULL; +} + +static bool t6_set_mode(usb_disp_t *d, const usb_disp_mode_t *m) { + t6_priv_t *p = t6p(d); + // リフレッシュレートをタイミングから概算 (内蔵テーブルは 60Hz) + uint32_t htot = (uint32_t)m->width + m->hfp + m->hsync + m->hbp; + uint32_t vtot = (uint32_t)m->height + m->vfp + m->vsync + m->vbp; + uint16_t hz = (uint16_t)(((uint64_t)m->pclk_khz * 1000 + + htot * vtot / 2) / + ((uint64_t)htot * vtot)); + const uint8_t *entry = t6_find_mode(p, m->width, m->height, hz); + if (!entry && hz != 60) entry = t6_find_mode(p, m->width, m->height, 60); + if (!entry) { + usb_disp_log("[T6] %ux%u@%u not in chip mode table", m->width, + m->height, hz); + return false; + } +#if USB_DISP_PORT_ESP32 + p->m24 = d->depth24; + p->bpp = (uint8_t)(p->m24 ? 3 : 2); +#else + p->m24 = true; // PC ソフトエンコーダの FB は常に RGB888 + p->bpp = 3; +#endif + if (!t6_alloc_bufs(d, p, m->width, m->height)) return false; + + uint8_t mode[32]; + memcpy(mode, entry, 32); // チップのテーブルエントリをそのまま echo + if (!t6_vout(d, 0x12, 0, 0, mode, 32)) return false; + usb_disp_prot_sleep_ms(50); + t6_vout(d, 0x31, 0, 0, NULL, 0); // SOFTWARE_READY + t6_vout(d, 0x03, 0, 1, NULL, 0); // 出力 ON + p->w = m->width; + p->h = m->height; + p->frames = 0; + p->cmd_addr = 0; + p->dirty = true; // 黒 FB を初回 flush で送る + +#if USB_DISP_PORT_ESP32 + if (!p->enc) { + jpeg_encode_engine_cfg_t ecfg = {}; + ecfg.intr_priority = 0; + ecfg.timeout_ms = 1000; + if (jpeg_new_encoder_engine(&ecfg, &p->enc) != 0) { + usb_disp_log("[T6] HW JPEG encoder init failed"); + p->enc = NULL; + return false; + } + } +#endif + return true; +} + +// 矩形更新: FB へ書き込むだけ (送信は flush) +static bool t6_update(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint16_t *px, uint32_t stride_px) { + t6_priv_t *p = t6p(d); + if (!p->fb || p->w == 0) return false; + for (uint16_t row = 0; row < h; row++) { + const uint16_t *src = stride_px ? px + (uint32_t)row * stride_px : px; +#if USB_DISP_PORT_ESP32 + if (p->m24) { + // 24bit マスタ: 565 を B,G,R へ展開 + // (P4 HW エンコーダの RGB888 入力はメモリ順 B,G,R) + uint8_t *dst = p->fb + ((uint32_t)(y + row) * p->w + x) * 3; + for (uint16_t i = 0; i < w; i++) { + uint16_t v = src[i]; + dst[i * 3 + 0] = (uint8_t)((v & 0x1F) * 255 / 31); // B + dst[i * 3 + 1] = (uint8_t)(((v >> 5) & 0x3F) * 255 / 63); // G + dst[i * 3 + 2] = (uint8_t)(((v >> 11) & 0x1F) * 255 / 31); // R + } + } else { + // RGB565 のまま (HW エンコーダ直接入力) + memcpy(p->fb + ((uint32_t)(y + row) * p->w + x) * 2, src, + (size_t)w * 2); + } +#else + // PC: RGB888 へ変換して保持 (ソフトエンコーダ入力) + uint8_t *dst = p->fb + ((uint32_t)(y + row) * p->w + x) * 3; + for (uint16_t i = 0; i < w; i++) { + uint16_t v = src[i]; + dst[i * 3 + 0] = (uint8_t)(((v >> 11) & 0x1F) * 255 / 31); + dst[i * 3 + 1] = (uint8_t)(((v >> 5) & 0x3F) * 255 / 63); + dst[i * 3 + 2] = (uint8_t)((v & 0x1F) * 255 / 31); + } +#endif + } + p->dirty = true; + return true; +} + +// 矩形更新 (RGB888, 入力 B,G,R)。 +// ESP32-P4: HW エンコーダの入力も B,G,R → そのままコピー +// PC: ソフトエンコーダは R,G,B → 入れ替えて書く +static bool t6_update888(usb_disp_t *d, uint16_t x, uint16_t y, uint16_t w, + uint16_t h, const uint8_t *px, uint32_t stride_px) { + t6_priv_t *p = t6p(d); + if (!p->fb || p->w == 0 || !p->m24) return false; + for (uint16_t row = 0; row < h; row++) { + const uint8_t *src = + stride_px ? px + (uint32_t)row * stride_px * 3 : px; + uint8_t *dst = p->fb + ((uint32_t)(y + row) * p->w + x) * 3; +#if USB_DISP_PORT_ESP32 + memcpy(dst, src, (size_t)w * 3); +#else + for (uint16_t i = 0; i < w; i++) { + dst[i * 3 + 0] = src[i * 3 + 2]; // R + dst[i * 3 + 1] = src[i * 3 + 1]; // G + dst[i * 3 + 2] = src[i * 3 + 0]; // B + } +#endif + } + p->dirty = true; + return true; +} + +// 画面内コピー: FB 上の memmove (送信は flush) +static bool t6_copy(usb_disp_t *d, uint16_t sx, uint16_t sy, uint16_t dx, + uint16_t dy, uint16_t w, uint16_t h) { + t6_priv_t *p = t6p(d); + if (!p->fb || p->w == 0) return false; + uint8_t bpp = p->bpp; + bool bottom_up = (dy > sy); + for (uint16_t i = 0; i < h; i++) { + uint16_t row = bottom_up ? (uint16_t)(h - 1 - i) : i; + memmove(p->fb + ((uint32_t)(dy + row) * p->w + dx) * bpp, + p->fb + ((uint32_t)(sy + row) * p->w + sx) * bpp, + (size_t)w * bpp); + } + p->dirty = true; + return true; +} + +static bool t6_flush(usb_disp_t *d, uint32_t timeout_ms) { + t6_priv_t *p = t6p(d); + if (p->dirty && p->fb && p->w) { + int32_t jlen = t6_encode(d, p); + if (jlen <= 0) { + usb_disp_log("[T6] encode failed (%ld)", (long)jlen); + return false; + } + if (!t6_send_frame(d, p, (uint32_t)jlen)) return false; + p->dirty = false; + } + return usb_disp_hal_bulk_flush(d->hal, timeout_ms); +} + +static bool t6_blank(usb_disp_t *d, bool on) { + if (!t6_vout(d, 0x03, 0, on ? 0 : 1, NULL, 0)) return false; + if (!on) t6p(d)->dirty = true; // 復帰時は再送 + return true; +} + +const usb_disp_prot_t usb_disp_prot_t6 = { + .name = "T6", + .caps = USB_DISP_PROT_CAP_888, // フルフレーム型 (シャドウ差分は非適用) + .match = t6_match, + .attach = t6_attach, + .detach = t6_detach, + .set_mode = t6_set_mode, + .read_edid = t6_read_edid, + .update = t6_update, + .update888 = t6_update888, + .copy = t6_copy, + .flush = t6_flush, + .blank = t6_blank, + .poll = NULL, +}; + +#endif // USB_DISP_PROT_HS + diff --git a/c_mpos/usb/upstream/usb_disp_prot_t6_jpeg.h b/c_mpos/usb/upstream/usb_disp_prot_t6_jpeg.h new file mode 100644 index 000000000..700cd693d --- /dev/null +++ b/c_mpos/usb/upstream/usb_disp_prot_t6_jpeg.h @@ -0,0 +1,329 @@ +// +// ###################################################################### +// +// usb_disp_prot_t6_jpeg - T6 プロトコル用ソフトウェアJPEGエンコーダ +// +// Copyright (C) 2026 +// Hideto Kikuchi / PJラボ (@pcjpnet) - https://pc-jp.net/ +// +// ###################################################################### +// +#ifndef USB_DISP_PROT_T6_JPEG_H_ +#define USB_DISP_PROT_T6_JPEG_H_ + +#include +#include +#include + +enum { USB_DISP_PROT_T6_JPEG_444 = 0, USB_DISP_PROT_T6_JPEG_420 = 1 }; + +// ---- 標準テーブル (ITU-T T.81 Annex K) ---- +static const uint8_t jz_zigzag[64] = { + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63}; + +static const uint8_t jz_qlum[64] = { + 16, 11, 10, 16, 24, 40, 51, 61, 12, 12, 14, 19, 26, 58, 60, 55, + 14, 13, 16, 24, 40, 57, 69, 56, 14, 17, 22, 29, 51, 87, 80, 62, + 18, 22, 37, 56, 68, 109, 103, 77, 24, 35, 55, 64, 81, 104, 113, 92, + 49, 64, 78, 87, 103, 121, 120, 101, 72, 92, 95, 98, 112, 100, 103, 99}; +static const uint8_t jz_qchr[64] = { + 17, 18, 24, 47, 99, 99, 99, 99, 18, 21, 26, 66, 99, 99, 99, 99, + 24, 26, 56, 99, 99, 99, 99, 99, 47, 66, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, + 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99}; + +static const uint8_t jz_dc_lum_bits[17] = {0, 0, 1, 5, 1, 1, 1, 1, 1, + 1, 0, 0, 0, 0, 0, 0, 0}; +static const uint8_t jz_dc_lum_vals[12] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; +static const uint8_t jz_dc_chr_bits[17] = {0, 0, 3, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 0, 0, 0, 0, 0}; +static const uint8_t jz_dc_chr_vals[12] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; + +static const uint8_t jz_ac_lum_bits[17] = {0, 0, 2, 1, 3, 3, 2, 4, 3, + 5, 5, 4, 4, 0, 0, 1, 0x7d}; +static const uint8_t jz_ac_lum_vals[162] = { + 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, + 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, + 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, 0x72, + 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, + 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, + 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, + 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, + 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, + 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, + 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, + 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, + 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa}; +static const uint8_t jz_ac_chr_bits[17] = {0, 0, 2, 1, 2, 4, 4, 3, 4, + 7, 5, 4, 4, 0, 1, 2, 0x77}; +static const uint8_t jz_ac_chr_vals[162] = { + 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, + 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, + 0xa1, 0xb1, 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, + 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, 0x26, + 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, + 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, + 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, + 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, + 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, + 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, + 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, + 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, 0xf4, + 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa}; + +// ---- 内部状態 ---- +typedef struct { + uint8_t *out; + size_t cap, len; + uint32_t bitbuf; + uint8_t bitcnt; // ビットバッファ内の未出力ビット数 (0..31) + bool overflow; + uint16_t dc_lum_code[12], dc_chr_code[12]; + uint8_t dc_lum_size[12], dc_chr_size[12]; + uint16_t ac_lum_code[256], ac_chr_code[256]; + uint8_t ac_lum_size[256], ac_chr_size[256]; + uint8_t qtab[2][64]; // zigzag順 + float qinv[2][64]; // 自然順の 1/q (DCTスケール込み) + int dc_pred[3]; +} jz_t; + +static void jz_byte(jz_t *j, uint8_t b) { + if (j->len >= j->cap) { j->overflow = true; return; } + j->out[j->len++] = b; +} +static void jz_word(jz_t *j, uint16_t w) { jz_byte(j, w >> 8); jz_byte(j, w & 0xFF); } + +static void jz_bits(jz_t *j, uint16_t code, int size) { + j->bitbuf |= (uint32_t)(code & ((1u << size) - 1)) << (24 - j->bitcnt - size); + j->bitcnt += size; + while (j->bitcnt >= 8) { + uint8_t b = (j->bitbuf >> 16) & 0xFF; + jz_byte(j, b); + if (b == 0xFF) jz_byte(j, 0x00); + j->bitbuf <<= 8; + j->bitcnt -= 8; + } +} +static void jz_flushbits(jz_t *j) { + if (j->bitcnt > 0) jz_bits(j, 0xFF, 8 - j->bitcnt); // 1埋めでバイト境界へ + j->bitbuf = 0; j->bitcnt = 0; +} + +static void jz_build_huff(const uint8_t bits[17], const uint8_t *vals, + uint16_t *code, uint8_t *size, int maxsym) { + memset(size, 0, maxsym); + int k = 0; + uint16_t c = 0; + for (int l = 1; l <= 16; l++) { + for (int i = 0; i < bits[l]; i++) { + code[vals[k]] = c++; + size[vals[k]] = l; + k++; + } + c <<= 1; + } +} + +// 素直な 2D DCT-II (float) in: レベルシフト済み -128..127 +static void jz_dct8x8(const float in[64], float out[64]) { + static float cs[8][8]; + static int init = 0; + if (!init) { + for (int u = 0; u < 8; u++) + for (int x = 0; x < 8; x++) + cs[u][x] = (float)cos((2 * x + 1) * u * 3.14159265358979 / 16.0); + init = 1; + } + float tmp[64]; + for (int y = 0; y < 8; y++) // 行方向 + for (int u = 0; u < 8; u++) { + float s = 0; + for (int x = 0; x < 8; x++) s += in[y * 8 + x] * cs[u][x]; + tmp[y * 8 + u] = s; + } + for (int u = 0; u < 8; u++) // 列方向 + for (int v = 0; v < 8; v++) { + float s = 0; + for (int y = 0; y < 8; y++) s += tmp[y * 8 + u] * cs[v][y]; + float cu = (u == 0) ? 0.70710678f : 1.0f; + float cv = (v == 0) ? 0.70710678f : 1.0f; + out[v * 8 + u] = s * cu * cv * 0.25f; + } +} + +static int jz_bitsize(int v) { + int a = v < 0 ? -v : v, n = 0; + while (a) { a >>= 1; n++; } + return n; +} + +// 1ブロック符号化 comp: 0=Y, 1=C +static void jz_block(jz_t *j, const float px[64], int comp, int dcidx) { + float dct[64]; + jz_dct8x8(px, dct); + int q[64]; + for (int i = 0; i < 64; i++) { + float v = dct[jz_zigzag[i]] * j->qinv[comp][i]; + q[i] = (int)(v < 0 ? v - 0.5f : v + 0.5f); + } + const uint16_t *dcc = comp ? j->dc_chr_code : j->dc_lum_code; + const uint8_t *dcs = comp ? j->dc_chr_size : j->dc_lum_size; + const uint16_t *acc = comp ? j->ac_chr_code : j->ac_lum_code; + const uint8_t *acs = comp ? j->ac_chr_size : j->ac_lum_size; + + int diff = q[0] - j->dc_pred[dcidx]; + j->dc_pred[dcidx] = q[0]; + int n = jz_bitsize(diff); + jz_bits(j, dcc[n], dcs[n]); + if (n) jz_bits(j, diff < 0 ? diff - 1 : diff, n); + + int run = 0; + for (int i = 1; i < 64; i++) { + if (q[i] == 0) { run++; continue; } + while (run > 15) { jz_bits(j, acc[0xF0], acs[0xF0]); run -= 16; } + n = jz_bitsize(q[i]); + int sym = (run << 4) | n; + jz_bits(j, acc[sym], acs[sym]); + jz_bits(j, q[i] < 0 ? q[i] - 1 : q[i], n); + run = 0; + } + if (run) jz_bits(j, acc[0x00], acs[0x00]); +} + +// RGB -> YCbCr (BT.601 full range, JFIF) +static void jz_ycc(uint8_t r, uint8_t g, uint8_t b, float *y, float *cb, float *cr) { + *y = 0.299f * r + 0.587f * g + 0.114f * b - 128.0f; + *cb = -0.168736f * r - 0.331264f * g + 0.5f * b; + *cr = 0.5f * r - 0.418688f * g - 0.081312f * b; +} + +// 画素取得 (端はクランプ) +static void jz_fetch(const uint8_t *rgb, int w, int h, int x, int y, + uint8_t *r, uint8_t *g, uint8_t *b) { + if (x >= w) x = w - 1; + if (y >= h) y = h - 1; + const uint8_t *p = rgb + (size_t)(y * w + x) * 3; + *r = p[0]; *g = p[1]; *b = p[2]; +} + +// メイン 戻り値: JPEGバイト数、負=バッファ不足 +static int32_t usb_disp_prot_t6_jpeg_encode(uint8_t *out, size_t outcap, const uint8_t *rgb, + uint16_t w, uint16_t h, uint8_t quality, uint8_t subsamp) { + jz_t jj, *j = &jj; + memset(j, 0, sizeof(*j)); + j->out = out; j->cap = outcap; + + // 量子化テーブル (libjpeg 流スケーリング) + int scale = quality < 50 ? 5000 / (quality < 1 ? 1 : quality) + : 200 - 2 * (quality > 100 ? 100 : quality); + for (int c = 0; c < 2; c++) { + const uint8_t *base = c ? jz_qchr : jz_qlum; + for (int i = 0; i < 64; i++) { + int v = (base[jz_zigzag[i]] * scale + 50) / 100; // 自然順→zigzag格納 + if (v < 1) v = 1; + if (v > 255) v = 255; + j->qtab[c][i] = (uint8_t)v; + j->qinv[c][i] = 1.0f / v; + } + } + jz_build_huff(jz_dc_lum_bits, jz_dc_lum_vals, j->dc_lum_code, j->dc_lum_size, 12); + jz_build_huff(jz_dc_chr_bits, jz_dc_chr_vals, j->dc_chr_code, j->dc_chr_size, 12); + jz_build_huff(jz_ac_lum_bits, jz_ac_lum_vals, j->ac_lum_code, j->ac_lum_size, 256); + jz_build_huff(jz_ac_chr_bits, jz_ac_chr_vals, j->ac_chr_code, j->ac_chr_size, 256); + + // ---- ヘッダ ---- + jz_word(j, 0xFFD8); // SOI + // APP0 JFIF + jz_word(j, 0xFFE0); jz_word(j, 16); + jz_byte(j, 'J'); jz_byte(j, 'F'); jz_byte(j, 'I'); jz_byte(j, 'F'); jz_byte(j, 0); + jz_word(j, 0x0101); jz_byte(j, 0); jz_word(j, 1); jz_word(j, 1); + jz_byte(j, 0); jz_byte(j, 0); + // DQT x2 + for (int c = 0; c < 2; c++) { + jz_word(j, 0xFFDB); jz_word(j, 67); jz_byte(j, c); + for (int i = 0; i < 64; i++) jz_byte(j, j->qtab[c][i]); + } + // SOF0 + jz_word(j, 0xFFC0); jz_word(j, 17); jz_byte(j, 8); + jz_word(j, h); jz_word(j, w); jz_byte(j, 3); + uint8_t hv = (subsamp == USB_DISP_PROT_T6_JPEG_420) ? 0x22 : 0x11; + jz_byte(j, 1); jz_byte(j, hv); jz_byte(j, 0); // Y + jz_byte(j, 2); jz_byte(j, 0x11); jz_byte(j, 1); // Cb + jz_byte(j, 3); jz_byte(j, 0x11); jz_byte(j, 1); // Cr + // DHT x4 + static const struct { const uint8_t *bits, *vals; int nv; uint8_t id; } hts[4] = { + {jz_dc_lum_bits, jz_dc_lum_vals, 12, 0x00}, + {jz_ac_lum_bits, jz_ac_lum_vals, 162, 0x10}, + {jz_dc_chr_bits, jz_dc_chr_vals, 12, 0x01}, + {jz_ac_chr_bits, jz_ac_chr_vals, 162, 0x11}, + }; + for (int t = 0; t < 4; t++) { + jz_word(j, 0xFFC4); jz_word(j, (uint16_t)(19 + hts[t].nv)); + jz_byte(j, hts[t].id); + for (int i = 1; i <= 16; i++) jz_byte(j, hts[t].bits[i]); + for (int i = 0; i < hts[t].nv; i++) jz_byte(j, hts[t].vals[i]); + } + // SOS + jz_word(j, 0xFFDA); jz_word(j, 12); jz_byte(j, 3); + jz_byte(j, 1); jz_byte(j, 0x00); + jz_byte(j, 2); jz_byte(j, 0x11); + jz_byte(j, 3); jz_byte(j, 0x11); + jz_byte(j, 0); jz_byte(j, 63); jz_byte(j, 0); + + // ---- スキャン ---- + j->dc_pred[0] = j->dc_pred[1] = j->dc_pred[2] = 0; + if (subsamp == USB_DISP_PROT_T6_JPEG_420) { + for (int my = 0; my < h; my += 16) { + for (int mx = 0; mx < w; mx += 16) { + float Y[4][64], CB[64], CR[64]; + float cbs[64], crs[64]; // 16x16→8x8 平均用 (2x2和を先に集める) + memset(cbs, 0, sizeof(cbs)); + memset(crs, 0, sizeof(crs)); + for (int by = 0; by < 16; by++) { + for (int bx = 0; bx < 16; bx++) { + uint8_t r, g, b; + float y, cb, cr; + jz_fetch(rgb, w, h, mx + bx, my + by, &r, &g, &b); + jz_ycc(r, g, b, &y, &cb, &cr); + Y[(by / 8) * 2 + (bx / 8)][(by % 8) * 8 + (bx % 8)] = y; + cbs[(by / 2) * 8 + (bx / 2)] += cb * 0.25f; + crs[(by / 2) * 8 + (bx / 2)] += cr * 0.25f; + } + } + memcpy(CB, cbs, sizeof(CB)); + memcpy(CR, crs, sizeof(CR)); + for (int i = 0; i < 4; i++) jz_block(j, Y[i], 0, 0); + jz_block(j, CB, 1, 1); + jz_block(j, CR, 1, 2); + } + } + } else { + for (int my = 0; my < h; my += 8) { + for (int mx = 0; mx < w; mx += 8) { + float Y[64], CB[64], CR[64]; + for (int by = 0; by < 8; by++) + for (int bx = 0; bx < 8; bx++) { + uint8_t r, g, b; + jz_fetch(rgb, w, h, mx + bx, my + by, &r, &g, &b); + jz_ycc(r, g, b, &Y[by * 8 + bx], &CB[by * 8 + bx], + &CR[by * 8 + bx]); + } + jz_block(j, Y, 0, 0); + jz_block(j, CB, 1, 1); + jz_block(j, CR, 1, 2); + } + } + } + jz_flushbits(j); + jz_word(j, 0xFFD9); // EOI + return j->overflow ? -1 : (int32_t)j->len; +} + +#endif // USB_DISP_PROT_T6_JPEG_H_ + diff --git a/internal_filesystem/builtin/apps/com.micropythonos.appstore/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.appstore/MANIFEST.JSON index 06b8bdb21..50b9e2a6a 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/MANIFEST.JSON @@ -1 +1 @@ -{"name": "AppStore", "publisher": "MicroPythonOS", "short_description": "Store for App(lication)s", "long_description": "Find and install apps for your device.", "fullname": "com.micropythonos.appstore", "version": "1.4.0", "category": "appstore", "activities": [{"entrypoint": "appstore.py", "classname": "AppStore", "intent_filters": [{"action": "main", "category": "launcher"}]}], "services": [{"entrypoint": "appstore_boot_service.py", "classname": "AppStoreService", "intent_filters": [{"action": "boot_completed", "delay_s": 120}]}]} \ No newline at end of file +{"name": "AppStore", "publisher": "MicroPythonOS", "short_description": "Store for App(lication)s", "long_description": "Find and install apps for your device.", "fullname": "com.micropythonos.appstore", "version": "1.5.0", "category": "appstore", "activities": [{"entrypoint": "appstore.py", "classname": "AppStore", "intent_filters": [{"action": "main", "category": "launcher"}]}], "services": [{"entrypoint": "appstore_boot_service.py", "classname": "AppStoreService", "intent_filters": [{"action": "boot_completed", "delay_s": 120}]}]} \ No newline at end of file diff --git a/internal_filesystem/builtin/apps/com.micropythonos.appstore/appstore.py b/internal_filesystem/builtin/apps/com.micropythonos.appstore/appstore.py index e6b9e6847..2366dbcea 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.appstore/appstore.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.appstore/appstore.py @@ -1,9 +1,10 @@ -import json import logging +import time +import ujson import lvgl as lv -from mpos import Activity, App, AppManager, BuildInfo, Intent, DownloadManager, SettingsActivity, SharedPreferences, TaskManager +from mpos import Activity, App, AppManager, BuildInfo, DisplayMetrics, Intent, DownloadManager, SettingsActivity, SharedPreferences, TaskManager from mpos.ui import QR_SYMBOL, STAR_SYMBOL from mpos.content import deeplink @@ -26,8 +27,12 @@ class AppStore(Activity): _GENERATE_APP_ICON_BENCHMARK = 11 # ms _BLURHASH_APP_ICON_BENCHMARK = 76 # ms - _WAIT_FACTOR_APP_ICON = 7 # 85% idle time _DOWNLOAD_ICON_INTERVAL = 3000 # ms between icon downloads + _ICON_TICK_MS = 250 # viewport icon loader period + _BLURHASH_PER_TICK = 1 # max blurhash decodes per loader tick + _RAW_PER_TICK = 4 # max raw icons generated per loader tick + _PRELOAD_VIEWPORTS = 1 # extra viewports of icons to preload above/below + _SETTLE_TICKS = 2 # quiet ticks after scrolling before icons resume _STAGE_RANK = {'raw': 1, 'blurhash': 2, 'download': 3} _DEFAULT_ICON_PIPELINE = 'blurhash' @@ -58,8 +63,11 @@ def onCreate(self): self._wip_apps = [] self._refresh_in_progress = False self._data_loaded = False - self._icon_queue = [] - self._raw_timer = None + self._icon_timer = None + self._displayed_apps = [] + self._scroll_hold = False + self._last_scroll_y = None + self._stable_ticks = 0 self._download_in_progress = False self._icon_pipeline = self.prefs.get_string("icon_pipeline", self._DEFAULT_ICON_PIPELINE) self.main_screen = lv.obj() @@ -135,18 +143,18 @@ def onResume(self, screen): self.refresh_list() elif self._data_loaded and hasattr(self, "apps_list") and self.apps_list: self._stop_all_timers() - self._icon_queue.clear() - for app in self.apps: - if not app.image_icon_widget: - continue - if app.icon_data: - self._set_icon_widget(app) - elif self._restore_cached_icon(app, app.image_icon_widget): - pass - else: - self._icon_queue.append((app, 'raw')) - if self._icon_queue: - self._raw_timer = lv.timer_create(self._process_icon_queue, self._GENERATE_APP_ICON_BENCHMARK*self._WAIT_FACTOR_APP_ICON, None) + if self._icon_pipeline != "none" and any(getattr(app, "image_icon_widget", None) is None for app in self.apps): + # Rows were built while icons were disabled and have no icon + # slots: rebuild once with slots (the rebuild restarts the + # viewport icon loader as needed). + self.create_apps_list() + return + if self._icon_pipeline == "none" and any(getattr(app, "image_icon_widget", None) is not None for app in self.apps): + # Rows were built with icons: rebuild once without icon + # slots (full-width labels with side margins). + self.create_apps_list() + return + self._start_icon_timer() def onPause(self, screen): self._stop_all_timers() @@ -380,26 +388,13 @@ def _update_category_dropdown(self): def _icon_pipeline_changed(self, new_value): self._icon_pipeline = new_value self._stop_all_timers() - self._icon_queue.clear() self._download_in_progress = False if new_value != 'none' and hasattr(self, "apps_list") and self.apps_list: - for app in self.apps: - if not app.icon_data: - self._icon_queue.append((app, 'raw')) - if self._icon_queue: - self._raw_timer = lv.timer_create(self._process_icon_queue, self._GENERATE_APP_ICON_BENCHMARK*self._WAIT_FACTOR_APP_ICON, None) - - def _advance(self, app, from_stage): - if self._icon_pipeline == 'none' or app.icon_data: - return - if from_stage == 'raw': - if self._STAGE_RANK['blurhash'] <= self._STAGE_RANK[self._icon_pipeline] and app.blur_hash: - self._icon_queue.append((app, 'blurhash')) - elif self._STAGE_RANK['download'] <= self._STAGE_RANK[self._icon_pipeline] and app.icon_url: - self._icon_queue.append((app, 'download')) - elif from_stage == 'blurhash': - if self._STAGE_RANK['download'] <= self._STAGE_RANK[self._icon_pipeline] and app.icon_url: - self._icon_queue.append((app, 'download')) + if any(getattr(app, "image_icon_widget", None) is None for app in self.apps): + # Rows were built while icons were disabled: onResume rebuilds + # them with icon slots when this activity is visible again. + return + self._start_icon_timer() async def _download_app_index_wrapper(self, json_url): try: @@ -409,8 +404,12 @@ async def _download_app_index_wrapper(self, json_url): async def download_app_index(self, json_url): await TaskManager.sleep(0) + if __debug__: + _t_refresh = time.ticks_ms() # Phase 1: always show installed apps first (no network needed) + if __debug__: + _t_phase1 = time.ticks_ms() self.apps.clear() self._wip_apps.clear() self._builtin_fullnames = set() @@ -427,6 +426,9 @@ async def download_app_index(self, json_url): self._data_loaded = True self.create_apps_list() self._update_category_dropdown() + if __debug__: + _n_phase1 = len(self.apps) + logger.debug("appstore-perf: phase1 installed-apps list took=%dms n=%d", time.ticks_diff(time.ticks_ms(), _t_phase1), _n_phase1) # A deep link to an app that is already known locally (e.g. installed) # can open its detail screen right now, without waiting for the index @@ -434,18 +436,29 @@ async def download_app_index(self, json_url): self._try_early_deeplink() # Phase 2: download store index and merge in new apps + if __debug__: + _t_net = time.ticks_ms() try: response = await DownloadManager.download_url(json_url) except Exception as e: if __debug__: logger.debug("store index unavailable (%s), showing installed apps only", e) self._resolve_pending_deeplink(index_available=False) return + if __debug__: + _net_took = time.ticks_diff(time.ticks_ms(), _t_net) + _net_bytes = len(response) if response is not None else -1 + logger.debug("appstore-perf: phase2 network fetch took=%dms bytes=%d", _net_took, _net_bytes) + _t_parse = time.ticks_ms() try: - parsed = json.loads(response) + parsed = ujson.loads(response) except Exception as e: logger.warning("could not parse store index: %s", e) self._resolve_pending_deeplink(index_available=False) return + if __debug__: + _n_parsed = len(parsed) if parsed is not None else -1 + logger.debug("appstore-perf: phase2 json parse took=%dms entries=%d", time.ticks_diff(time.ticks_ms(), _t_parse), _n_parsed) + _t_merge = time.ticks_ms() installed_by_fullname = {app.fullname: app for app in self.apps} new_apps = [] @@ -475,25 +488,43 @@ async def download_app_index(self, json_url): new_apps.append(app) except Exception as e: logger.warning("could not process store app %s: %s", app_data.get("slug", "?"), e) + if __debug__: + logger.debug("appstore-perf: phase2 merge took=%dms new=%d wip=%d", time.ticks_diff(time.ticks_ms(), _t_merge), len(new_apps), len(self._wip_apps)) - # Insert new apps at their sorted positions (avoids rebuilding entire list) + # Merge new apps in memory (sorted once) and build the visible list + # exactly once below. Per-app widget insertion here would only be + # deleted again by the mandatory full rebuild for rating labels. # If the activity is no longer in the foreground (e.g. test called - # back_screen() while the download was in flight), the list widgets may - # have been deleted — inserting into a stale list can segfault LVGL. + # back_screen() while the download was in flight), skip the rebuild: + # acting on deleted LVGL objects can segfault the device. if not self.has_foreground(): self._resolve_pending_deeplink() return - for app in new_apps: - idx = self._find_sorted_insert_index(app) - self.apps.insert(idx, app) - self._insert_app_list_item(app, idx) + if __debug__: + _t_merge_apps = time.ticks_ms() + if new_apps: + self.apps.extend(new_apps) + keyed = [(self._sort_key(a.name), a) for a in self.apps] + keyed.sort(key=lambda t: t[0]) + self.apps = [a for _, a in keyed] + if __debug__: + logger.debug("appstore-perf: phase2 sort took=%dms n=%d", time.ticks_diff(time.ticks_ms(), _t_merge_apps), len(new_apps)) # ponytail: rebuild whole list so installed apps get their rating labels # (ratings were patched after Phase 1 already painted the list) if self.has_foreground(): + if __debug__: + _t_rebuild = time.ticks_ms() self.create_apps_list() + if __debug__: + logger.debug("appstore-perf: phase2 full rebuild took=%dms", time.ticks_diff(time.ticks_ms(), _t_rebuild)) + _t_dropdown = time.ticks_ms() self._update_category_dropdown() + if __debug__: + logger.debug("appstore-perf: phase2 dropdown took=%dms", time.ticks_diff(time.ticks_ms(), _t_dropdown)) self._resolve_pending_deeplink() + if __debug__: + logger.debug("appstore-perf: refresh total took=%dms apps=%d", time.ticks_diff(time.ticks_ms(), _t_refresh), len(self.apps)) def create_apps_list(self): if __debug__: logger.debug("create_apps_list") @@ -505,7 +536,6 @@ def create_apps_list(self): return self._stop_all_timers() - self._icon_queue.clear() self._download_in_progress = False if __debug__: logger.debug("hiding please wait label") @@ -524,12 +554,18 @@ def create_apps_list(self): app.image_icon_widget = None self.apps_list.delete() self.apps_list = lv.list(self.main_screen) + self.apps_list.add_event_cb(self._on_list_scroll, lv.EVENT.SCROLL_BEGIN, None) + self.apps_list.add_event_cb(self._on_list_scroll, lv.EVENT.SCROLL_END, None) self._apply_default_styles(self.apps_list) self.apps_list.set_size(lv.pct(100), list_h) self.apps_list.align(lv.ALIGN.TOP_LEFT, 0, list_top) self._icon_widgets = {} self._update_labels = {} + self._displayed_apps = [] if __debug__: logger.debug("create_apps_list iterating") + if __debug__: + _t_rows = time.ticks_ms() + _n_rows = 0 sel_cat = getattr(self, "_selected_category", None) apps_to_show = self._wip_apps if sel_cat == "Work In Progress" else self.apps installed_set = set() @@ -553,62 +589,95 @@ def create_apps_list(self): continue elif not app.categories or sel_cat not in app.categories: continue - if __debug__: logger.debug(app) + # Row-op micro-profile: time each build segment for the first and + # sixth built rows (cold vs warmed-up caches). Row count comes + # from _n_rows, incremented at the end of the loop body. + if __debug__: + _prof = (_n_rows == 0 or _n_rows == 5) + _pt = time.ticks_ms() item = self.apps_list.add_button(None, "") item.set_style_pad_all(0, lv.PART.MAIN) item.set_size(lv.pct(100), lv.SIZE_CONTENT) + item.set_flex_flow(lv.FLEX_FLOW.ROW) self._add_click_handler(item, self.show_app_detail, app) - cont = lv.obj(item) - cont.set_style_pad_all(0, lv.PART.MAIN) - cont.set_flex_flow(lv.FLEX_FLOW.ROW) - cont.set_size(lv.pct(100), lv.SIZE_CONTENT) - cont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - self._apply_default_styles(cont) - self._add_click_handler(cont, self.show_app_detail, app) - icon_spacer = lv.image(cont) - icon_spacer.set_size(self._ICON_SIZE, self._ICON_SIZE) - self._add_click_handler(icon_spacer, self.show_app_detail, app) - app.image_icon_widget = icon_spacer - if app.icon_data: - self._set_icon_widget(app) - elif self._restore_cached_icon(app, icon_spacer): - pass - elif self._icon_pipeline != 'none': - self._icon_queue.append((app, 'raw')) - label_cont = lv.obj(cont) + # add_button() always creates an empty auto label child (see + # lv_list_add_button in lvgl lv_list.c). As a ROW flex item with + # flex_grow 1 it would eat all free space, so remove it. With + # icon=None it is child 0: nothing else was added yet. + if item.get_child_count() > 0: + item.get_child(0).delete() + if __debug__ and _prof: + logger.debug("appstore-perf: row%d button took=%dms", _n_rows, time.ticks_diff(time.ticks_ms(), _pt)) + _pt = time.ticks_ms() + if self._icon_pipeline == "none": + app.image_icon_widget = None + item.set_style_pad_hor(DisplayMetrics.pct_of_width(4), lv.PART.MAIN) + else: + icon_spacer = lv.image(item) + icon_spacer.set_size(self._ICON_SIZE, self._ICON_SIZE) + app.image_icon_widget = icon_spacer + if app.icon_data: + self._set_icon_widget(app) + elif self._restore_cached_icon(app, icon_spacer): + pass + # Otherwise the viewport icon loader fills the slot in when + # the row scrolls into view. + self._displayed_apps.append(app) + if __debug__ and _prof: + logger.debug("appstore-perf: row%d icon took=%dms", _n_rows, time.ticks_diff(time.ticks_ms(), _pt)) + _pt = time.ticks_ms() + label_cont = lv.obj(item) self._apply_default_styles(label_cont) label_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) label_cont.set_style_pad_ver(10, lv.PART.MAIN) - label_cont.set_size(lv.pct(75), lv.SIZE_CONTENT) - self._add_click_handler(label_cont, self.show_app_detail, app) + label_cont.set_size(lv.pct(100 if self._icon_pipeline == "none" else 75), lv.SIZE_CONTENT) + # Every lv.obj is CLICKABLE by default (see lv_obj_init in lvgl + # lv_obj.c), so row taps land on these containers. The single + # CLICKED handler on the item covers the whole row only if the + # containers let the event bubble up to it. + label_cont.add_flag(lv.obj.FLAG.EVENT_BUBBLE) name_row = lv.obj(label_cont) self._apply_default_styles(name_row) name_row.set_flex_flow(lv.FLEX_FLOW.ROW) name_row.set_size(lv.pct(100), lv.SIZE_CONTENT) - self._add_click_handler(name_row, self.show_app_detail, app) + name_row.add_flag(lv.obj.FLAG.EVENT_BUBBLE) + if __debug__ and _prof: + logger.debug("appstore-perf: row%d containers took=%dms", _n_rows, time.ticks_diff(time.ticks_ms(), _pt)) + _pt = time.ticks_ms() name_label = lv.label(name_row) name_label.set_text(app.name) name_label.set_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) name_label.set_flex_grow(1) - self._add_click_handler(name_label, self.show_app_detail, app) rating_avg = getattr(app, "rating_average", None) if rating_avg is not None and rating_avg > 0: rating_label = lv.label(name_row) rating_label.set_text("%s %.1f" % (STAR_SYMBOL, rating_avg)) rating_label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) rating_label.set_size(lv.SIZE_CONTENT, lv.SIZE_CONTENT) + if __debug__ and _prof: + logger.debug("appstore-perf: row%d name took=%dms", _n_rows, time.ticks_diff(time.ticks_ms(), _pt)) + _pt = time.ticks_ms() desc_label = lv.label(label_cont) desc_label.set_text(app.short_description) desc_label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) - self._add_click_handler(desc_label, self.show_app_detail, app) update_label = lv.label(label_cont) update_label.set_text("Update available") update_label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) update_label.set_style_text_color(lv.palette_main(lv.PALETTE.GREEN), lv.PART.MAIN) update_label.add_flag(lv.obj.FLAG.HIDDEN) self._update_labels[app.fullname] = update_label - if self._icon_queue: - self._raw_timer = lv.timer_create(self._process_icon_queue, self._GENERATE_APP_ICON_BENCHMARK*self._WAIT_FACTOR_APP_ICON, None) + if __debug__ and _prof: + logger.debug("appstore-perf: row%d desc_update took=%dms", _n_rows, time.ticks_diff(time.ticks_ms(), _pt)) + if __debug__: + _n_rows += 1 + if _n_rows % 20 == 0: + logger.debug("appstore-perf: create_apps_list progress rows=%d took=%dms", _n_rows, time.ticks_diff(time.ticks_ms(), _t_rows)) + if __debug__: + logger.debug("appstore-perf: create_apps_list rows took=%dms rows=%d", time.ticks_diff(time.ticks_ms(), _t_rows), _n_rows) + if self._icon_pipeline != "none": + self._start_icon_timer() + if __debug__: + _t_updates = time.ticks_ms() try: from appstore_core import AppUpdateManager, AppUpdateState updatable = [] @@ -633,6 +702,8 @@ def create_apps_list(self): self._sync_update_banner(state, updatable) except Exception: pass + if __debug__: + logger.debug("appstore-perf: create_apps_list update-check took=%dms", time.ticks_diff(time.ticks_ms(), _t_updates)) if __debug__: logger.debug("create_apps_list done") _SORT_STRIP = "!\"'?:;.,@#$%^&*()-_=+[]{}\\|`~<>/" @@ -640,142 +711,151 @@ def create_apps_list(self): def _sort_key(self, name): return name.lstrip(self._SORT_STRIP).lower() - def _find_sorted_insert_index(self, app): - app_key = self._sort_key(app.name) - for i, existing in enumerate(self.apps): - if app_key < self._sort_key(existing.name): - return i - return len(self.apps) + def _stop_all_timers(self): + if getattr(self, "_icon_timer", None): + self._icon_timer.delete() + self._icon_timer = None - def _insert_app_list_item(self, app, index): - """Create LVGL widgets for an app and insert at the given index in the list.""" - if not hasattr(self, "apps_list") or not self.apps_list: + def _start_icon_timer(self): + self._stop_all_timers() + if self._icon_pipeline == "none": + return + if not getattr(self, "apps_list", None): + return + try: + self._load_viewport_icons(None) + except Exception as e: + logger.warning("initial icon load error: %s", e) + try: + self._icon_timer = self._create_timer(self._load_viewport_icons, self._ICON_TICK_MS) + except Exception as e: + logger.warning("could not start icon timer: %s", e) + + def _create_timer(self, callback, period_ms): + return lv.timer_create(callback, period_ms, None) + + def _visible_apps(self): + displayed = getattr(self, "_displayed_apps", None) or [] + try: + self.apps_list.update_layout() + scroll_y = self.apps_list.get_scroll_y() + list_h = self.apps_list.get_height() + except Exception: + return list(displayed) + try: + margin = list_h * self._PRELOAD_VIEWPORTS + lo = scroll_y - margin + hi = scroll_y + list_h + margin + visible = [] + n = self.apps_list.get_child_count() + for i in range(n): + if i >= len(displayed): + break + try: + row = self.apps_list.get_child(i) + y = row.get_y() + h = row.get_height() + except Exception: + continue + if y + h > lo and y < hi: + visible.append(displayed[i]) + return visible + except Exception: + return list(displayed) + + def _on_list_scroll(self, event): + code = event.get_code() + if code == lv.EVENT.SCROLL_BEGIN: + self._scroll_hold = True + elif code == lv.EVENT.SCROLL_END: + self._scroll_hold = False + self._stable_ticks = 0 + + def _scrolling_now(self): + try: + cur = self.apps_list.get_scroll_y() + except Exception: + return False + last = getattr(self, "_last_scroll_y", None) + self._last_scroll_y = cur + if last is None or cur is None: + self._stable_ticks = self._SETTLE_TICKS + return False + if getattr(self, "_scroll_hold", False) or cur != last: + self._stable_ticks = 0 + if cur == last: + self._scroll_hold = False + return True + stable = getattr(self, "_stable_ticks", 0) + if stable < self._SETTLE_TICKS: + self._stable_ticks = stable + 1 + return True + return False + + def _load_viewport_icons(self, timer): + if self._icon_pipeline == "none": + return + if not getattr(self, "apps_list", None): return if not self.has_foreground(): return - sel_cat = getattr(self, "_selected_category", None) - if sel_cat == "Installed": - if app.installed_path is None: - return - elif sel_cat == "Updates": + if self._scrolling_now(): + return + try: + visible = self._visible_apps() + except Exception: + return + if not visible: + return + target = self._STAGE_RANK.get(self._icon_pipeline, 1) + blurhash_left = self._BLURHASH_PER_TICK + raw_left = self._RAW_PER_TICK + for app in visible: try: - from appstore_core import AppUpdateManager - updatable_set = {a.get("fullname") for a in (AppUpdateManager.get_instance().updatable_apps or [])} - except Exception: - updatable_set = set() - if app.fullname not in updatable_set: - return - elif sel_cat and sel_cat not in AppStore._SPECIAL_CATEGORIES: - if not app.categories or sel_cat not in app.categories: - return - item = self.apps_list.add_button(None, "") - item.set_style_pad_all(0, lv.PART.MAIN) - item.set_size(lv.pct(100), lv.SIZE_CONTENT) - self._add_click_handler(item, self.show_app_detail, app) - cont = lv.obj(item) - cont.set_style_pad_all(0, lv.PART.MAIN) - cont.set_flex_flow(lv.FLEX_FLOW.ROW) - cont.set_size(lv.pct(100), lv.SIZE_CONTENT) - cont.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - self._apply_default_styles(cont) - self._add_click_handler(cont, self.show_app_detail, app) - icon_spacer = lv.image(cont) - icon_spacer.set_size(self._ICON_SIZE, self._ICON_SIZE) - self._add_click_handler(icon_spacer, self.show_app_detail, app) - app.image_icon_widget = icon_spacer + blurhash_left, raw_left = self._load_one_icon(app, target, blurhash_left, raw_left) + except Exception as e: + if __debug__: logger.debug("icon load skipped for %s: %s", getattr(app, "fullname", "?"), e) + + def _load_one_icon(self, app, target, blurhash_left, raw_left): + widget = getattr(app, "image_icon_widget", None) + if not widget: + return blurhash_left, raw_left + stage = getattr(app, "_icon_stage", None) + if stage == "download" or (target == 2 and stage == "blurhash") or (target == 1 and stage == "raw"): + return blurhash_left, raw_left if app.icon_data: self._set_icon_widget(app) - elif self._restore_cached_icon(app, icon_spacer): - pass - elif self._icon_pipeline != 'none': - self._icon_queue.append((app, 'raw')) - if not self._raw_timer: - self._raw_timer = lv.timer_create(self._process_icon_queue, self._GENERATE_APP_ICON_BENCHMARK*self._WAIT_FACTOR_APP_ICON, None) - label_cont = lv.obj(cont) - self._apply_default_styles(label_cont) - label_cont.set_flex_flow(lv.FLEX_FLOW.COLUMN) - label_cont.set_style_pad_ver(10, lv.PART.MAIN) - label_cont.set_size(lv.pct(75), lv.SIZE_CONTENT) - self._add_click_handler(label_cont, self.show_app_detail, app) - name_row = lv.obj(label_cont) - self._apply_default_styles(name_row) - name_row.set_flex_flow(lv.FLEX_FLOW.ROW) - name_row.set_size(lv.pct(100), lv.SIZE_CONTENT) - self._add_click_handler(name_row, self.show_app_detail, app) - name_label = lv.label(name_row) - name_label.set_text(app.name) - name_label.set_style_text_font(lv.font_montserrat_16, lv.PART.MAIN) - name_label.set_flex_grow(1) - self._add_click_handler(name_label, self.show_app_detail, app) - rating_avg = getattr(app, "rating_average", None) - if rating_avg is not None and rating_avg > 0: - rating_label = lv.label(name_row) - rating_label.set_text("%s %.1f" % (STAR_SYMBOL, rating_avg)) - rating_label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) - rating_label.set_size(lv.SIZE_CONTENT, lv.SIZE_CONTENT) - desc_label = lv.label(label_cont) - desc_label.set_text(app.short_description) - desc_label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) - self._add_click_handler(desc_label, self.show_app_detail, app) - update_label = lv.label(label_cont) - update_label.set_text("Update available") - update_label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) - update_label.set_style_text_color(lv.palette_main(lv.PALETTE.GREEN), lv.PART.MAIN) - update_label.add_flag(lv.obj.FLAG.HIDDEN) - self._update_labels[app.fullname] = update_label - item.move_to_index(index) - sel_cat = getattr(self, "_selected_category", None) - if sel_cat == "Installed" and not app.installed_path: - item.add_flag(lv.obj.FLAG.HIDDEN) - elif sel_cat == "Updates": - try: - from appstore_core import AppUpdateManager - updatable_set = {a.get("fullname") for a in (AppUpdateManager.get_instance().updatable_apps or [])} - except Exception: - updatable_set = set() - if app.fullname not in updatable_set: - item.add_flag(lv.obj.FLAG.HIDDEN) - elif sel_cat and sel_cat not in ("All", "Work In Progress"): - if not app.categories or sel_cat not in app.categories: - item.add_flag(lv.obj.FLAG.HIDDEN) - - def _stop_all_timers(self): - if self._raw_timer: - self._raw_timer.delete() - self._raw_timer = None - - def _process_icon_queue(self, timer): - if not self._icon_queue: - if self._download_in_progress: - return - if self._raw_timer: - self._raw_timer.delete() - self._raw_timer = None - return - idx = self._find_best_app_index(self._icon_queue) - app, stage = self._icon_queue.pop(idx) - if stage == 'raw': + return blurhash_left, raw_left + if stage is None: + if self._restore_cached_icon(app, widget): + return blurhash_left, raw_left + if raw_left <= 0: + return blurhash_left, raw_left self._set_raw_icon(app) - self._advance(app, 'raw') - elif stage == 'blurhash': - if app.blur_hash and not app.icon_data: - dsc, buf = blurhash_to_image_dsc(app.blur_hash, 16, 16) - if dsc is not None: - app._icon_dsc = dsc - app._icon_buf = buf - widget = getattr(app, 'image_icon_widget', None) - if widget: + return blurhash_left, raw_left - 1 + if target >= 2 and app.blur_hash and stage == "raw": + if blurhash_left <= 0: + return blurhash_left, raw_left + blurhash_left -= 1 + dsc, buf = blurhash_to_image_dsc(app.blur_hash, 16, 16) + if dsc is not None: + app._icon_dsc = dsc + app._icon_buf = buf + app._icon_stage = "blurhash" + widget = getattr(app, "image_icon_widget", None) + if widget: + try: widget.set_src(dsc) widget.set_scale(4 * 256) - self._advance(app, 'blurhash') - elif stage == 'download': + except Exception: + pass + return blurhash_left, raw_left + if target >= 3 and app.icon_url and not app.icon_data: if self._download_in_progress: - self._icon_queue.append((app, 'download')) - return - if app.icon_data or not app.icon_url: - return + return blurhash_left, raw_left self._download_in_progress = True TaskManager.create_task(self._do_download(app)) + return blurhash_left, raw_left def _set_raw_icon(self, app): try: @@ -788,6 +868,7 @@ def _set_raw_icon(self, app): dsc, buf = generate_raw_app_icon(app.fullname, AppStore._ICON_SIZE) app._icon_dsc = dsc app._icon_buf = buf + app._icon_stage = "raw" widget.set_src(dsc) widget.set_scale(256) @@ -803,38 +884,17 @@ async def _do_download(self, app): except Exception: pass - def _find_best_app_index(self, queue): - try: - scroll_y = self.apps_list.get_scroll_y() - list_h = self.apps_list.get_height() - except Exception: - return 0 - best_i = 0 - best_dist = 999999 - for i, entry in enumerate(queue): - app = entry[0] - try: - list_idx = self.apps.index(app) - except ValueError: - continue - item_y = list_idx * self._ICON_SIZE - if item_y + self._ICON_SIZE > scroll_y and item_y < scroll_y + list_h: - return i - if item_y + self._ICON_SIZE <= scroll_y: - dist = scroll_y - (item_y + self._ICON_SIZE) - else: - dist = item_y - (scroll_y + list_h) - if dist < best_dist: - best_dist = dist - best_i = i - return best_i - def _restore_cached_icon(self, app, widget): if hasattr(app, '_icon_dsc') and app._icon_dsc is not None: dsc = app._icon_dsc - if dsc.header.w == self._ICON_SIZE: + if app.icon_data: + app._icon_stage = "download" + scale = 256 + elif dsc.header.w == self._ICON_SIZE: + app._icon_stage = "raw" scale = 256 else: + app._icon_stage = "blurhash" scale = 4 * 256 widget.set_src(dsc) widget.set_scale(scale) @@ -856,13 +916,16 @@ def _set_icon_widget(self, app): }) scale = 256 buf = None + app._icon_stage = "download" else: dsc, buf = blurhash_to_image_dsc(app.blur_hash, 16, 16) if dsc is None: dsc, buf = generate_raw_app_icon(app.fullname, AppStore._ICON_SIZE) scale = 256 + app._icon_stage = "raw" else: scale = 4 * 256 + app._icon_stage = "blurhash" app._icon_dsc = dsc app._icon_buf = buf widget.set_src(dsc) @@ -960,7 +1023,6 @@ def _show_scan_message(self, title, text): @staticmethod def badgehub_app_to_mpos_app(bhapp): name = bhapp.get("name") - if __debug__: logger.debug("got app name: %s", name) short_description = bhapp.get("description") fullname = bhapp.get("slug") icon_url = None diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.settings/MANIFEST.JSON index c83f942ce..25b82aaa9 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/MANIFEST.JSON +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/MANIFEST.JSON @@ -1 +1 @@ -{"name": "Settings", "publisher": "MicroPythonOS", "short_description": "View and change MicroPythonOS settings.", "long_description": "This is the official settings app for MicroPythonOS. It allows you to configure all aspects of MicroPythonOS.", "fullname": "com.micropythonos.settings", "version": "0.3.2", "category": "settings", "activities": [{"entrypoint": "settings.py", "classname": "Settings", "intent_filters": [{"action": "main", "category": "launcher"}]}]} \ No newline at end of file +{"name": "Settings", "publisher": "MicroPythonOS", "short_description": "View and change MicroPythonOS settings.", "long_description": "This is the official settings app for MicroPythonOS. It allows you to configure all aspects of MicroPythonOS.", "fullname": "com.micropythonos.settings", "version": "0.4.0", "category": "settings", "activities": [{"entrypoint": "settings.py", "classname": "Settings", "intent_filters": [{"action": "main", "category": "launcher"}]}]} \ No newline at end of file diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/settings.py index 5a134cc93..c8eaf6ce5 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/settings.py @@ -1,8 +1,8 @@ import logging -from mpos import Activity, AppearanceManager, AppManager, AudioManager, InputManager, Intent, NumberFormat, SettingsActivity, TimeZone -from mpos.notification_manager import DEFAULT_NOTIFICATION_SOUND, NOTIFICATION_SOUND_OPTIONS +from mpos import Activity, AppearanceManager, AppManager, AudioManager, InputManager, Intent, NumberFormat, SettingsActivity, TimeZone, USBManager +from mpos.notification_manager import DEFAULT_NOTIFICATION_SOUND, NOTIFICATION_SOUND_OPTIONS, NotificationManager logger = logging.getLogger(__name__) @@ -98,7 +98,7 @@ def getIntent(self): # Basic settings, alphabetically: {"title": "Haptic feedback", "key": "haptic_feedback", "ui": "radiobuttons", "ui_options": [("On", "on"), ("Off", "off")], "default_value": "off", "should_show": InputManager.has_haptic_feedback()}, {"title": "Light/Dark Theme", "key": "theme_light_dark", "ui": "radiobuttons", "ui_options": [("Light", "light"), ("Dark", "dark")], "changed_callback": self.theme_changed}, - {"title": "Notification", "key": "notification_sound", "ui": "radiobuttons", "ui_options": NOTIFICATION_SOUND_OPTIONS, "default_value": DEFAULT_NOTIFICATION_SOUND}, + {"title": "Notification", "key": "notification_sound", "ui": "radiobuttons", "ui_options": NOTIFICATION_SOUND_OPTIONS, "default_value": DEFAULT_NOTIFICATION_SOUND, "selected_callback": NotificationManager.preview_sound}, {"title": "Startup sound", "key": "startup_sound", "ui": "radiobuttons", "ui_options": [("On", "on"), ("Off", "off")], "default_value": "on", "should_show": AudioManager.find_output_by_kind("buzzer")}, {"title": "Theme Color", "key": "theme_primary_color", "placeholder": "HTML hex color, like: EC048C", "ui": "dropdown", "ui_options": theme_colors, "changed_callback": self.theme_changed, "default_value": AppearanceManager.DEFAULT_PRIMARY_COLOR}, {"title": "Timezone", "key": "timezone", "ui": "dropdown", "ui_options": [(tz, tz) for tz in TimeZone.get_timezones()], "changed_callback": lambda *args: TimeZone.refresh_timezone_preference()}, @@ -111,6 +111,11 @@ def getIntent(self): {"title": "Auto Start App", "key": "auto_start_app", "ui": "radiobuttons", "ui_options": [(app.name, app.fullname) for app in AppManager.get_app_list()], "allow_deselect": True}, {"title": "Check IMU Calibration", "key": "check_imu_calibration", "ui": "activity", "activity_class": CheckIMUCalibrationActivity}, {"title": "Calibrate IMU", "key": "calibrate_imu", "ui": "activity", "activity_class": CalibrateIMUActivity}, + # USB host mode is expert-grade: activating kills USB-CDC (the + # console on no-UART boards) until deactivated. Persisted by the + # framework into the same key USBManager boots from, so the row + # always shows the truth; the callback only switches modes. + {"title": "USB Host Mode", "key": "usb_host_mode", "ui": "radiobuttons", "ui_options": [("On", "on"), ("On until reboot", "once"), ("Off", "off")], "note": "Allows connecting a USB keyboard, mouse or display adapter using a USB OTG cable. Stops debug REPL on USB-CDC, but WebREPL and TTL UART remain.", "default_value": "off", "changed_callback": self.usb_host_mode_changed, "should_show": USBManager.is_available()}, # Expert settings, alphabetically {"title": "Restart to Bootloader", "key": "boot_mode", "dont_persist": True, "ui": "radiobuttons", "ui_options": [("Normal", "normal"), ("Bootloader", "bootloader")], "changed_callback": self.reset_into_bootloader}, {"title": "Format internal data partition", "key": "format_internal_data_partition", "dont_persist": True, "ui": "radiobuttons", "ui_options": [("No, do not format", "no"), ("Yes, erase all settings, files and non-builtin apps", "yes")], "changed_callback": self.format_internal_data_partition}, @@ -122,6 +127,17 @@ def getIntent(self): return intent # Change handlers: + def usb_host_mode_changed(self, new_value): + # Persistence is handled by the framework (same key USBManager + # boots from); here we only switch modes, never persisting. The + # framework stores first, so a failed activation still persists + # the intent and retries next boot (BOOTSEL escapes if needed). + if new_value == "off": + if not USBManager.deactivate(persist=False): + logger.error("USB host mode deactivation failed") + elif not USBManager.activate(persist=False): + logger.error("USB host mode activation failed") + def reset_into_bootloader(self, new_value): if new_value != "bootloader": return diff --git a/internal_filesystem/lib/drivers/display/usb_display/__init__.py b/internal_filesystem/lib/drivers/display/usb_display/__init__.py new file mode 100644 index 000000000..baf74f974 --- /dev/null +++ b/internal_filesystem/lib/drivers/display/usb_display/__init__.py @@ -0,0 +1,17 @@ +from . import usb_display + +__all__ = [ + 'USBDisplayDriver', + 'STATE_HIGH', + 'STATE_LOW', + 'STATE_PWM', + 'BYTE_ORDER_RGB', + 'BYTE_ORDER_BGR', +] + +USBDisplayDriver = usb_display.USBDisplayDriver +STATE_HIGH = usb_display.STATE_HIGH +STATE_LOW = usb_display.STATE_LOW +STATE_PWM = usb_display.STATE_PWM +BYTE_ORDER_RGB = usb_display.BYTE_ORDER_RGB +BYTE_ORDER_BGR = usb_display.BYTE_ORDER_BGR diff --git a/internal_filesystem/lib/drivers/display/usb_display/usb_display.py b/internal_filesystem/lib/drivers/display/usb_display/usb_display.py new file mode 100644 index 000000000..639be78cb --- /dev/null +++ b/internal_filesystem/lib/drivers/display/usb_display/usb_display.py @@ -0,0 +1,82 @@ +import display_driver_framework +import lvgl as lv + + +STATE_HIGH = display_driver_framework.STATE_HIGH +STATE_LOW = display_driver_framework.STATE_LOW +STATE_PWM = display_driver_framework.STATE_PWM + +BYTE_ORDER_RGB = display_driver_framework.BYTE_ORDER_RGB +BYTE_ORDER_BGR = display_driver_framework.BYTE_ORDER_BGR + + +class _USBDisplayBus: + def __init__(self, usb_dev): + self._dev = usb_dev + self._callback = None + + def allocate_framebuffer(self, size, flags): + return bytearray(size) + + def free_framebuffer(self, fb): + return None + + def init(self, *args, **kwargs): + return None + + def tx_param(self, cmd, params=None): + return None + + def rx_param(self, cmd, params): + return 0 + + def register_callback(self, callback): + self._callback = callback + + def tx_color(self, cmd, data_view, x1, y1, x2, y2, rotation, last_update): + w = x2 - x1 + 1 + h = y2 - y1 + 1 + self._dev.update_565(x1, y1, w, h, data_view) + if last_update: + self._dev.flush(100) + if self._callback is not None: + self._callback() + + +class USBDisplayDriver(display_driver_framework.DisplayDriver): + def __init__( + self, + usb_dev, + display_width, + display_height, + frame_buffer1=None, + frame_buffer2=None, + offset_x=0, + offset_y=0, + color_space=lv.COLOR_FORMAT.RGB565, # NOQA + ): + if color_space != lv.COLOR_FORMAT.RGB565: # NOQA + raise ValueError("USBDisplayDriver only supports RGB565") + self._usb_dev = usb_dev + super().__init__( + data_bus=_USBDisplayBus(usb_dev), + display_width=display_width, + display_height=display_height, + frame_buffer1=frame_buffer1, + frame_buffer2=frame_buffer2, + offset_x=offset_x, + offset_y=offset_y, + color_space=color_space, # NOQA + _init_bus=True + ) + + def init(self, type=None): # NOQA + self._initilized = True + + def set_rotation(self, value): + if value != lv.DISPLAY_ROTATION._0: # NOQA + raise ValueError("USBDisplayDriver only supports rotation _0") + super().set_rotation(value) + + def poll(self): + return self._usb_dev.poll() diff --git a/internal_filesystem/lib/drivers/indev/usb_hid.py b/internal_filesystem/lib/drivers/indev/usb_hid.py new file mode 100644 index 000000000..7fa1806d5 --- /dev/null +++ b/internal_filesystem/lib/drivers/indev/usb_hid.py @@ -0,0 +1,411 @@ +import lvgl as lv # NOQA +import pointer_framework +from drivers.indev import fri3d_communicator_keyboard as communicator_keyboard + + +def _to_signed(v): + return v - 256 if v > 127 else v + + +def parse_boot_mouse_report(report): + if report is None or len(report) < 3: + return None + buttons = report[0] & 0x07 + dx = _to_signed(report[1]) + dy = _to_signed(report[2]) + wheel = _to_signed(report[3]) if len(report) > 3 else 0 + return (buttons, dx, dy, wheel) + + +class ReportParser: + kind = "generic" + + @staticmethod + def match(subclass, protocol): + return False + + def parse(self, report): + return None + + +class BootMouseParser(ReportParser): + kind = "mouse" + + @staticmethod + def match(subclass, protocol): + return subclass == 1 and protocol == 2 + + def parse(self, report): + return parse_boot_mouse_report(report) + + +class BootKeyboardParser(ReportParser): + kind = "keyboard" + + @staticmethod + def match(subclass, protocol): + return subclass == 1 and protocol == 1 + + +PARSERS = [BootMouseParser(), BootKeyboardParser()] + + +def find_parser(subclass, protocol): + for parser in PARSERS: + try: + if parser.match(subclass, protocol): + return parser + except Exception: + continue + return None + + +class HIDSource: + def drain(self): + try: + import usb # NOQA + except ImportError: + return [] + if not hasattr(usb, "hid_drain"): + return [] + try: + return list(usb.hid_drain()) + except Exception: + return [] + + +class FakeHIDSource: + def __init__(self): + self._reports = [] + + def inject(self, addr, subclass, protocol, report): + self._reports.append((addr, subclass, protocol, bytes(report))) + + def inject_mouse(self, buttons=0, dx=0, dy=0, wheel=0, addr=5): + dx_b = dx & 0xFF + dy_b = dy & 0xFF + wheel_b = wheel & 0xFF + self.inject(addr, 1, 2, bytes([buttons & 0x07, dx_b, dy_b, wheel_b])) + + def inject_keyboard(self, keys, modifiers=0, addr=6): + pad = [0] * 6 + for i, key in enumerate(keys[:6]): + pad[i] = key + self.inject(addr, 1, 1, bytes([modifiers, 0] + pad)) + + def drain(self): + reports, self._reports = self._reports, [] + return reports + + +class HIDHub: + def __init__(self, source=None): + self._source = source if source is not None else HIDSource() + self._mouse_events = [] + self._key_report = None + self._key_addr = None + + def pump(self): + try: + reports = self._source.drain() + except Exception: + return + for item in reports: + try: + addr, subclass, protocol, raw = item + except Exception: + continue + try: + parser = find_parser(subclass, protocol) + except Exception: + continue + if parser is None: + continue + if parser.kind == "mouse": + try: + event = parser.parse(raw) + except Exception: + continue + if event is not None: + self._mouse_events.append((addr,) + tuple(event)) + elif parser.kind == "keyboard": + if raw is not None and len(raw) >= 8: + self._key_report = tuple(raw[:8]) + self._key_addr = addr + + def drain_mouse(self): + events, self._mouse_events = self._mouse_events, [] + return events + + @property + def key_report(self): + return self._key_report + + @property + def key_addr(self): + return self._key_addr + + +class _IdentityCal: + alphaX = None + betaX = None + deltaX = None + alphaY = None + betaY = None + deltaY = None + mirrorX = None + mirrorY = None + + +_CURSOR_W = 16 +_CURSOR_H = 16 +_CURSOR_MAP = None + +# Classic 45-degree arrow pointer (tip at top-left = hotspot). +# X = filled pixel, . = transparent. +_CURSOR_ROWS = [ + "X...............", + "XX..............", + "X.X.............", + "X..X............", + "X...X...........", + "X....X..........", + "X.....X.........", + "X......X........", + "X.......X.......", + "X........X......", + "X....XXXXXX.....", + "X...XX..........", + "X..X.X..........", + "X.X..X..........", + "XX...X..........", + "X....X..........", +] + + +def _cursor_map(): + global _CURSOR_MAP + if _CURSOR_MAP is None: + px = bytearray(_CURSOR_W * _CURSOR_H * 4) + for y, row in enumerate(_CURSOR_ROWS): + for x, ch in enumerate(row): + if ch == "X": + o = (y * _CURSOR_W + x) * 4 + px[o] = 255 + px[o + 1] = 255 + px[o + 2] = 255 + px[o + 3] = 255 + _CURSOR_MAP = bytes(px) + return _CURSOR_MAP + + +class USBMouse(pointer_framework.PointerDriver): + __usb_absolute__ = True + + def __init__( + self, + source=None, + sensitivity=1.0, + debug=False, + ): + if source is None: + source = HIDSource() + self._hub = source if isinstance(source, HIDHub) else HIDHub(source) + self._sensitivity = sensitivity + super().__init__( + touch_cal=_IdentityCal(), + startup_rotation=pointer_framework.lv.DISPLAY_ROTATION._0, # NOQA + debug=debug, + ) + self._x = self._width // 2 + self._y = self._height // 2 + self._buttons = 0 + self._cursor = None + self._cursor_theme = None + + def _calc_coords(self, x, y): + return (x, y) + + def _clamp(self, v, hi): + if v < 0: + return 0 + if v > hi: + return hi + return v + + def _apply_wheel(self, wheel): + try: + pt = lv.point_t() # NOQA + pt.x = self._x + pt.y = self._y + obj = self.search_obj(pt) + if obj is None: + return + obj.scroll_by(0, -wheel * 20, lv.ANIM.OFF) # NOQA + except Exception: + pass + + def _get_coords(self): + try: + self._hub.pump() + except Exception: + pass + try: + events = self._hub.drain_mouse() + except Exception: + events = [] + for item in events: + try: + _, buttons, dx, dy, wheel = item + except Exception: + continue + self._buttons = buttons + step = self._sensitivity + self._x = self._clamp(int(self._x + dx * step), self._width - 1) + self._y = self._clamp(int(self._y + dy * step), self._height - 1) + if wheel: + self._apply_wheel(wheel) + state = self.PRESSED if self._buttons else self.RELEASED + self._sync_cursor_theme() + return (state, self._x, self._y) + + def _cursor_target_theme(self): + try: + from mpos.ui.appearance_manager import AppearanceManager + light = AppearanceManager.is_light_mode() + except Exception: + light = True + return "black" if light else "white" + + def _sync_cursor_theme(self): + if self._cursor is None: + return + target = self._cursor_target_theme() + if target == self._cursor_theme: + return + try: + if target == "black": + self._cursor.set_style_image_recolor(lv.color_hex(0x000000), 0) # NOQA + else: + self._cursor.set_style_image_recolor(lv.color_hex(0xFFFFFF), 0) # NOQA + self._cursor.set_style_image_recolor_opa(lv.OPA.COVER, 0) # NOQA + except Exception: + return + self._cursor_theme = target + + def _on_size_change(self, event): + super()._on_size_change(event) + self._x = self._clamp(self._x, self._width - 1) + self._y = self._clamp(self._y, self._height - 1) + + def attach_cursor(self): + if self._cursor is not None: + return self._cursor + dsc = lv.image_dsc_t() # NOQA + dsc.header.cf = lv.COLOR_FORMAT.ARGB8888 # NOQA + dsc.header.w = _CURSOR_W + dsc.header.h = _CURSOR_H + dsc.header.stride = _CURSOR_W * 4 + dsc.data_size = _CURSOR_W * _CURSOR_H * 4 + dsc.data = _cursor_map() + disp = self._disp_drv + try: + layer = disp.get_layer_top() # NOQA + except Exception: + layer = lv.screen_active() + cursor = lv.image(layer) # NOQA + cursor.set_src(dsc) + cursor.remove_flag(lv.obj.FLAG.CLICKABLE) # NOQA cursor must never take input + self._cursor_dsc = dsc + self._cursor = cursor + try: + self.set_cursor(cursor) + except Exception: + pass + self._sync_cursor_theme() + return cursor + + def show_cursor(self): + if self._cursor is None: + self.attach_cursor() + try: + self._cursor.remove_flag(lv.obj.FLAG.HIDDEN) # NOQA + except Exception: + pass + + def hide_cursor(self): + if self._cursor is None: + return + try: + self._cursor.add_flag(lv.obj.FLAG.HIDDEN) # NOQA + except Exception: + pass + + def _on_display_changed(self, new_lv_disp): + self._disp_drv = new_lv_disp + self._width = new_lv_disp.get_horizontal_resolution() + self._height = new_lv_disp.get_vertical_resolution() + self._x = self._clamp(self._x, self._width - 1) + self._y = self._clamp(self._y, self._height - 1) + if self._cursor is not None: + try: + self._cursor.set_parent(new_lv_disp.get_layer_top()) # NOQA + except Exception: + pass + try: + self.set_cursor(self._cursor) + except Exception: + pass + + def delete(self): + try: + if self._cursor is not None: + self._cursor.delete() # NOQA + except Exception: + pass + self._cursor = None + try: + if self in pointer_framework.PointerDriver._indevs: + pointer_framework.PointerDriver._indevs.remove(self) + except Exception: + pass + try: + self._indev_drv.delete() # NOQA + except Exception: + try: + self.enable(False) + except Exception: + pass + + +class USBHIDKeyboard(communicator_keyboard.Fri3dCommunicatorKeyboard): + def __init__( + self, + hub, + repeat_initial_delay_ms=300, + repeat_rate_ms=100, + ): + self._hub = hub + super().__init__(hub, repeat_initial_delay_ms, repeat_rate_ms) + + def _poll(self): + try: + self._hub.pump() + except Exception: + pass + super()._poll() + + def delete(self): + try: + import _indev_base + if self in _indev_base.IndevBase._indevs: + _indev_base.IndevBase._indevs.remove(self) + except Exception: + pass + try: + self._indev_drv.delete() # NOQA + except Exception: + try: + self.enable(False) + except Exception: + pass diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 549cb49e6..e6b5768db 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -15,6 +15,7 @@ from .task_manager import TaskManager from .camera_manager import CameraManager from .sensor_manager import SensorManager +from .usb.usbmanager import USBManager from .time_zone import TimeZone from .number_format import NumberFormat from .device_info import DeviceInfo @@ -105,6 +106,7 @@ "InputManager", "AppearanceManager", "SensorManager", + "USBManager", "get_event_name", "print_event", "setContentView", "back_screen", "set_back_screen_disabled", "is_back_screen_disabled", diff --git a/internal_filesystem/lib/mpos/build_info.py b/internal_filesystem/lib/mpos/build_info.py index c330aca67..939ff6ab7 100644 --- a/internal_filesystem/lib/mpos/build_info.py +++ b/internal_filesystem/lib/mpos/build_info.py @@ -9,5 +9,5 @@ class BuildInfo: class version: """Version information.""" - release = "0.18.0" + release = "0.19.1" api_level = 0 # subject to change until API Level 1 diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index 19446094c..3ab738005 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -407,6 +407,16 @@ def change_task_handler(period_ms=2): except Exception as e: logger.error("Couldn't start boot services: %s", e) +# USB host mode (--usb builds): CDC device mode is the default. The host +# stack starts only on explicit request: persisted flag (Settings "USB +# Host Mode"), unless BOOT is held (physical escape back to CDC). +try: + from mpos.usb import USBManager + if USBManager.host_boot_requested(): + USBManager.activate() +except Exception as e: + logger.error("USB host boot arm failed: %s", e) + async def ota_rollback_cancel(): try: from esp32 import Partition diff --git a/internal_filesystem/lib/mpos/notification_manager.py b/internal_filesystem/lib/mpos/notification_manager.py index 08602246d..aa46d2491 100644 --- a/internal_filesystem/lib/mpos/notification_manager.py +++ b/internal_filesystem/lib/mpos/notification_manager.py @@ -179,6 +179,30 @@ def _find_buzzer_output(): return output return None + @classmethod + def preview_sound(cls, rtttl): + """Play `rtttl` once on the buzzer, e.g. from a settings picker's + selected_callback so the user hears a choice before saving it. + + Unlike _play_notification_sound this ignores the stored preference + and the rate limiter; a falsy value (the "off" option) or a board + without a buzzer is a silent no-op. + """ + try: + if not rtttl: + return + output = cls._find_buzzer_output() + if output is None: + return + AudioManager.player( + rtttl=rtttl, + stream_type=AudioManager.STREAM_NOTIFICATION, + volume=60, + output=output, + ).start() + except Exception as e: + logger.warning("Failed to preview notification sound: %s", e) + @classmethod def _play_notification_sound(cls): try: diff --git a/internal_filesystem/lib/mpos/ui/infinite_list.py b/internal_filesystem/lib/mpos/ui/infinite_list.py index 6ad56e6fd..b6d51f67c 100644 --- a/internal_filesystem/lib/mpos/ui/infinite_list.py +++ b/internal_filesystem/lib/mpos/ui/infinite_list.py @@ -15,8 +15,18 @@ class InfiniteList: lst.set_size(lv.pct(100), lv.pct(70)) lst.center() lst.set_data(items, lambda container, idx, item: create_button(...)) + + The initial window is sized dynamically: rows are rendered until they + fill the container height, plus _LOAD_MARGIN_ROWS extra. The container + must be sized before set_data() is called; if its height cannot be + determined yet (e.g. percentage sizes before the first layout pass), + a fixed fallback window is used and scroll loading tops up the rest. """ + _LOAD_MARGIN_ROWS = 3 + _FALLBACK_INIT_ROWS = 18 + _TOP_UP_SLACK_ROWS = 8 + def __init__(self, parent, load_margin=200, unload_margin=600): self._container = lv.obj(parent) self._container.set_flex_flow(lv.FLEX_FLOW.COLUMN) @@ -111,24 +121,57 @@ def set_data(self, items, render_cb): if not self._items: return - visible = self._visible_count() - n_init = max(3, visible + 3) - n_init = min(n_init, len(self._items)) - self._first = 0 - self._last = n_init - 1 - for i in range(n_init): - self._render_cb(self._container, i, self._items[i]) + self._last = -1 + target = self._measure_initial_window() + while self._last + 1 < target: + self._last += 1 + self._render_cb(self._container, self._last, self._items[self._last]) self._container.update_layout() + self._top_up_to_viewport() + + def _measure_initial_window(self): + """How many rows to render up front: fill viewport + margin.""" + n = len(self._items) + self._render_cb(self._container, 0, self._items[0]) + self._last = 0 + self._container.update_layout() + try: + viewport_h = self._container.get_height() + row_h = self._container.get_child(0).get_height() + except Exception: + viewport_h = 0 + row_h = 0 + if viewport_h <= 0 or row_h <= 0: + return min(n, self._FALLBACK_INIT_ROWS) + visible = viewport_h // row_h + return min(n, visible + self._LOAD_MARGIN_ROWS) + + def _top_up_to_viewport(self): + """Render more rows if short rows left the viewport uncovered.""" + n = len(self._items) + extra = 0 + while ( + self._last < n - 1 + and extra < self._TOP_UP_SLACK_ROWS + and self._viewport_uncovered() + ): + self._last += 1 + self._render_cb(self._container, self._last, self._items[self._last]) + self._container.update_layout() + extra += 1 + + def _viewport_uncovered(self): + try: + return self._container.get_scroll_bottom() <= 0 + except Exception: + return False def clean(self): self._container.clean() self._first = -1 self._last = -1 - def _visible_count(self): - return 15 - def _on_scroll(self, event): if self._scroll_running: return diff --git a/internal_filesystem/lib/mpos/ui/input_activity.py b/internal_filesystem/lib/mpos/ui/input_activity.py index 5ceb51f34..42f5410a9 100644 --- a/internal_filesystem/lib/mpos/ui/input_activity.py +++ b/internal_filesystem/lib/mpos/ui/input_activity.py @@ -22,6 +22,22 @@ Callers (e.g. SettingActivity) are responsible for persisting the result and updating any UI that depends on it. """ +def _notify_selected(activity, index): + """Call the setting's optional `selected_callback(value)` for the option + at `index` of `activity._ui_options`. Module-level (not a method) so + handler code keeps working on lightweight test fixtures that only carry + the attributes the handlers read. Never raises: a broken preview hook + must not break the input screen.""" + cb = getattr(activity, "_selected_callback", None) + options = getattr(activity, "_ui_options", None) + if not cb or not options or index is None or index < 0 or index >= len(options): + return + try: + cb(options[index][1]) + except Exception as e: + logger.error("selected_callback raised: %s", e) + + class InputActivity(Activity): active_radio_index = -1 # Track active radio button index @@ -79,6 +95,11 @@ def onCreate(self): # `allow_deselect` is an opt-in for inputs where "nothing # selected" is a legitimate value. self._radio_allow_deselect = bool(self.setting.get("allow_deselect", False)) + # Optional live-selection hook: fires on every tap that leaves an + # option selected, BEFORE Save (Save/Cancel semantics unchanged). + # Lets pickers preview a choice, e.g. play a sound effect. + self._selected_callback = self.setting.get("selected_callback") + self._ui_options = ui_options # Create radio buttons and check the right one self.active_radio_index = -1 # none for i, (option_text, option_value) in enumerate(ui_options): @@ -96,6 +117,11 @@ def onCreate(self): else: # don't show identical options options_with_newlines += ("%s\n" % option[0]) self.dropdown.set_options(options_with_newlines) + self._selected_callback = self.setting.get("selected_callback") + self._ui_options = ui_options + self.dropdown.add_event_cb( + lambda e: _notify_selected(self, self.dropdown.get_selected()), + lv.EVENT.VALUE_CHANGED, None) # select the right one: for i, (option_text, option_value) in enumerate(ui_options): if initial_value == option_value: @@ -227,12 +253,17 @@ def radio_event_handler(self, event): else: logger.warning("radio: ignoring un-check of active option %s (radios require exactly one)", current_checkbox_index) target_obj.add_state(lv.STATE.CHECKED) + # A re-tap of the active option still counts as "selected": + # a preview hook wants to fire again (hear the sound again). + _notify_selected(self, current_checkbox_index) return else: if self.active_radio_index >= 0: # is there something to uncheck? old_checked = self.radio_container.get_child(self.active_radio_index) old_checked.remove_state(lv.STATE.CHECKED) self.active_radio_index = current_checkbox_index + _notify_selected(self, current_checkbox_index) + def create_radio_button(self, parent, text, index): cb = lv.checkbox(parent) diff --git a/internal_filesystem/lib/mpos/ui/settings_activity.py b/internal_filesystem/lib/mpos/ui/settings_activity.py index efce8a837..4d878fb67 100644 --- a/internal_filesystem/lib/mpos/ui/settings_activity.py +++ b/internal_filesystem/lib/mpos/ui/settings_activity.py @@ -27,6 +27,36 @@ def _value_label_for(setting, stored_value): return stored_value +def _row_value_text(setting, stored_value): + """Label text for a setting row in the SettingsActivity list. + + Pure function of the setting dict plus the stored pref value (None + when unset) so the row-rendering rule is unit-testable. A + `dont_persist` entry with a `default_value` (e.g. live state re-read + on every open, like USB Host Mode) shows "(defaults to X)" instead + of "(not persisted)"; `dont_persist` without one keeps the old text. + """ + if setting.get("activity_class"): + return setting.get("placeholder") or "" + if setting.get("dont_persist"): + default_value = setting.get("default_value") + if default_value is not None: + return f"(defaults to {_value_label_for(setting, default_value)})" + return "(not persisted)" + if stored_value is None: + default_value = setting.get("default_value") + if default_value is not None: + # Map default to its human-readable label too, when one exists. + return f"(defaults to {_value_label_for(setting, default_value)})" + return "(not set)" + # Map stored value to its ui_options label when present + # (e.g. "lightningpiggy" → "Lightning Piggy"). No-op when + # no ui_options or the value isn't in the list. Prefs are JSON, so a + # value stored as int/float (an older app version, or a numeric + # ui_options value) must still become label text. + return str(_value_label_for(setting, stored_value)) + + # Used to list and edit all settings: class SettingsActivity(Activity): @@ -82,24 +112,10 @@ def onResume(self, screen): # Value label (smaller, below title) value = lv.label(setting_cont) if setting.get("activity_class"): - placeholder = setting.get("placeholder") or "" - value_text = placeholder - elif setting.get("dont_persist"): - value_text = "(not persisted)" + value_text = setting.get("placeholder") or "" else: stored_value = self.prefs.get_string(setting["key"]) - if stored_value is None: - default_value = setting.get("default_value") - if default_value is not None: - # Map default to its human-readable label too, when one exists. - value_text = f"(defaults to {_value_label_for(setting, default_value)})" - else: - value_text = "(not set)" - else: - # Map stored value to its ui_options label when present - # (e.g. "lightningpiggy" → "Lightning Piggy"). No-op when - # no ui_options or the value isn't in the list. - value_text = _value_label_for(setting, stored_value) + value_text = _row_value_text(setting, stored_value) value.set_text(value_text) value.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) value.set_style_text_color(lv.color_hex(0x666666), lv.PART.MAIN) diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index e28abefaf..fb0a2709a 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -26,6 +26,8 @@ DRAWER_ANIM_DURATION = 1000 scroll_start_y = None +_press_start_y = None +_press_start_x = None # SlidePanel instances (created in create_notification_bar / create_drawer) _bar_panel = None @@ -117,6 +119,7 @@ def _build_drawer_notification_item(parent, notification): #card.set_flex_align(lv.FLEX_ALIGN.START, lv.FLEX_ALIGN.CENTER, lv.FLEX_ALIGN.START) card.add_flag(lv.obj.FLAG.CLICKABLE) + card.add_flag(lv.obj.FLAG.EVENT_BUBBLE) card.add_event_cb( lambda e, nid=notification.notification_id: _notification_pressed(e, nid), lv.EVENT.CLICKED, @@ -425,17 +428,27 @@ def create_drawer(): if drawer is not None: return drawer = lv.obj(lv.layer_top()) - drawer_height = DisplayMetrics.pct_of_height(90) + # Fill from below the notification bar down to the bottom edge of the + # screen so no strip of the underlying app stays visible (and tappable) + # beneath the open drawer. + if DisplayMetrics.height(): + drawer_height = DisplayMetrics.height() - AppearanceManager.NOTIFICATION_BAR_HEIGHT + else: + drawer_height = DisplayMetrics.pct_of_height(90) shown_y = AppearanceManager.NOTIFICATION_BAR_HEIGHT hidden_y = shown_y - drawer_height # slides up off-screen drawer.set_size(lv.pct(100), drawer_height) drawer.set_pos(0, hidden_y) # start hidden drawer.set_scroll_dir(lv.DIR.VER) drawer.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + drawer.add_flag(lv.obj.FLAG.CLICKABLE) drawer.set_style_pad_all(2, lv.PART.MAIN) drawer.set_style_border_width(0, lv.PART.MAIN) drawer.set_style_radius(0, lv.PART.MAIN) drawer.add_flag(lv.obj.FLAG.HIDDEN) + drawer.add_event_cb(drawer_scroll_callback, lv.EVENT.PRESSED, None) + drawer.add_event_cb(drawer_scroll_callback, lv.EVENT.PRESSING, None) + drawer.add_event_cb(drawer_scroll_callback, lv.EVENT.RELEASED, None) drawer.add_event_cb(drawer_scroll_callback, lv.EVENT.SCROLL_BEGIN, None) drawer.add_event_cb(drawer_scroll_callback, lv.EVENT.SCROLL, None) drawer.add_event_cb(drawer_scroll_callback, lv.EVENT.SCROLL_END, None) @@ -517,6 +530,7 @@ def brightness_slider_released(e): editor.commit() slider.add_event_cb(brightness_slider_changed, lv.EVENT.VALUE_CHANGED, None) slider.add_event_cb(brightness_slider_released, lv.EVENT.RELEASED, None) + slider.add_flag(lv.obj.FLAG.EVENT_BUBBLE) # ── Icon-only button row ───────────────────────────────────────────────── icon_row = lv.obj(top_group) @@ -546,6 +560,7 @@ def wifi_event(e): close_drawer() AppManager.start_app("com.micropythonos.settings.wifi") wifi_btn.add_event_cb(wifi_event, lv.EVENT.CLICKED, None) + wifi_btn.add_flag(lv.obj.FLAG.EVENT_BUBBLE) _register_focus_callbacks(wifi_btn) _drawer_focusables.append(wifi_btn) @@ -560,6 +575,7 @@ def settings_event(e): close_drawer() AppManager.start_app("com.micropythonos.settings") settings_btn.add_event_cb(settings_event, lv.EVENT.CLICKED, None) + settings_btn.add_flag(lv.obj.FLAG.EVENT_BUBBLE) _register_focus_callbacks(settings_btn) _drawer_focusables.append(settings_btn) @@ -579,6 +595,7 @@ def _on_drawer_hidden(): _drawer_panel.on_hidden = _on_drawer_hidden close_drawer(True) launcher_btn.add_event_cb(launcher_event, lv.EVENT.CLICKED, None) + launcher_btn.add_flag(lv.obj.FLAG.EVENT_BUBBLE) _register_focus_callbacks(launcher_btn) _drawer_focusables.append(launcher_btn) @@ -600,6 +617,7 @@ def reset_cb(e): else: logger.warning("machine has no reset or soft_reset method available") restart_btn.add_event_cb(reset_cb, lv.EVENT.CLICKED, None) + restart_btn.add_flag(lv.obj.FLAG.EVENT_BUBBLE) _register_focus_callbacks(restart_btn) _drawer_focusables.append(restart_btn) @@ -625,6 +643,7 @@ def poweroff_cb(e): import os os.system("kill $PPID") poweroff_btn.add_event_cb(poweroff_cb, lv.EVENT.CLICKED, None) + poweroff_btn.add_flag(lv.obj.FLAG.EVENT_BUBBLE) _register_focus_callbacks(poweroff_btn) _drawer_focusables.append(poweroff_btn) @@ -668,18 +687,100 @@ def poweroff_cb(e): spacer = lv.label(outer) spacer.set_text("") spacer.set_height(DisplayMetrics.pct_of_height(40)) + # Let press/drag gestures on any drawer content bubble up to the drawer + # itself so drawer_scroll_callback sees swipe-up-to-close everywhere. + for _bub in (outer, top_group, brightness_row, icon_row, notif_section, + drawer_notifications_container, spacer): + try: + _bub.add_flag(lv.obj.FLAG.EVENT_BUBBLE) + except Exception: + pass + + +def move_to_display(): + global _pre_drawer_focused + if notification_bar is None or drawer is None: + return + close_drawer(animate=False) + close_bar(animate=False) + _pre_drawer_focused = None + new_layer = lv.layer_top() + notification_bar.set_parent(new_layer) + drawer.set_parent(new_layer) + bar_h = AppearanceManager.NOTIFICATION_BAR_HEIGHT + notification_bar.set_size(lv.pct(100), bar_h) + notification_bar.set_pos(0, -bar_h) + _bar_panel.shown_y = 0 + _bar_panel.hidden_y = -bar_h + drawer_h = DisplayMetrics.height() - bar_h if DisplayMetrics.height() else DisplayMetrics.pct_of_height(90) + drawer.set_size(lv.pct(100), drawer_h) + _drawer_panel.shown_y = bar_h + _drawer_panel.hidden_y = bar_h - drawer_h + drawer.set_pos(0, bar_h - drawer_h) + logger.warning("topmenu moved, drawer_h=%d" % (drawer_h)) def drawer_scroll_callback(event): - global scroll_start_y - event_code=event.get_code() + global scroll_start_y, _press_start_y, _press_start_x + event_code = event.get_code() + try: + from .event import get_event_name as _ev_name + _ev_label = _ev_name(event_code) + except Exception: + _ev_label = str(event_code) x, y = InputManager.pointer_xy() - #name = mpos.ui.get_event_name(event_code) - if event_code == lv.EVENT.SCROLL_BEGIN and scroll_start_y is None: + try: + _t = event.get_target_obj() + _t_label = _t.__class__.__name__ if _t is not None else "None" + except Exception as _e: + _t_label = ":%s" % (_e,) + if __debug__: logger.debug("drawer cb %s x=%s y=%s open=%s press=(%s,%s) scroll=%s target=%s", + _ev_label, x, y, drawer_open, + _press_start_x, _press_start_y, scroll_start_y, _t_label) + _threshold = AppearanceManager.NOTIFICATION_BAR_HEIGHT + if event_code == lv.EVENT.PRESSED: + if y >= 0: + _press_start_x = x + _press_start_y = y + else: + _press_start_x = None + _press_start_y = None + elif event_code == lv.EVENT.PRESSING: + if _press_start_y is not None and y >= 0: + _diff = y - _press_start_y + if __debug__: logger.debug("drawer pressing diff=%s threshold=-%s", _diff, _threshold) + if _diff < -_threshold: + if __debug__: logger.debug("drawer pressing swipe-up detected, closing") + _press_start_x = None + _press_start_y = None + scroll_start_y = None + close_drawer() + elif event_code == lv.EVENT.RELEASED: + if _press_start_y is not None and y >= 0: + _diff = y - _press_start_y + if __debug__: logger.debug("drawer released diff=%s threshold=-%s", _diff, _threshold) + if _diff < -_threshold: + if __debug__: logger.debug("drawer released swipe-up detected, closing") + _press_start_x = None + _press_start_y = None + scroll_start_y = None + close_drawer() + return + _press_start_x = None + _press_start_y = None + elif event_code == lv.EVENT.SCROLL_BEGIN and scroll_start_y is None: scroll_start_y = y elif event_code == lv.EVENT.SCROLL and scroll_start_y is not None: diff = y - scroll_start_y - if diff < -AppearanceManager.NOTIFICATION_BAR_HEIGHT: + if __debug__: logger.debug("drawer scroll diff=%s threshold=-%s", diff, _threshold) + if diff < -_threshold: + if __debug__: logger.debug("drawer scroll swipe-up detected, closing") + scroll_start_y = None + _press_start_x = None + _press_start_y = None close_drawer() elif event_code == lv.EVENT.SCROLL_END: scroll_start_y = None + elif event_code == lv.EVENT.PRESS_LOST or event_code == lv.EVENT.CANCEL: + _press_start_x = None + _press_start_y = None diff --git a/internal_filesystem/lib/mpos/usb/__init__.py b/internal_filesystem/lib/mpos/usb/__init__.py new file mode 100644 index 000000000..188ea4b0d --- /dev/null +++ b/internal_filesystem/lib/mpos/usb/__init__.py @@ -0,0 +1,3 @@ +from .usbmanager import USBManager # noqa: F401 + +__all__ = ["USBManager"] diff --git a/internal_filesystem/lib/mpos/usb/usbmanager.py b/internal_filesystem/lib/mpos/usb/usbmanager.py new file mode 100644 index 000000000..e2b66cbe1 --- /dev/null +++ b/internal_filesystem/lib/mpos/usb/usbmanager.py @@ -0,0 +1,711 @@ +import gc +import logging +import time + +import lvgl as lv + +logger = logging.getLogger(__name__) + + +class USBManager: + _usb_dev = None + _usb_display = None + _panel_display = None + _panel_backlight = None + _active = "panel" + _switching = False + _poll_timer = None + _pump_suspended = False + # USB touch exceptions: ONLY boards whose drag test disagrees with the + # default (same orientation: scale; portrait panel to landscape USB: rotate + # clockwise, no mirrors) get an entry. Empty by design; the panel's own + # proven mapping is reused, so mounting knowledge is never needed here. + _USB_TOUCH_EXC = { + # "board_id": {"ccw": True, "mx": True, "my": True}, + } + _wrapped_indevs = [] + _usb_mouse = None + _usb_keyboard = None + _hid_hub = None + _hid_idle_prev = None + # Future Settings-toggle seam: when False, hotplugged displays enumerate + # but the UI never auto-switches (manual switch_to_usb still works). + _auto_switch = True + _sw_idle_polls = 0 + _sw_retries = 0 + _SW_RETRY_EVERY = 5 + _SW_MAX_RETRIES = 6 + # Host-mode preference lives in the Settings app's preferences (same + # key the Settings UI persists), so UI, REPL and boot all share one + # source of truth. Stored as strings ("on"/"once"/"off"); only "on" + # boots into host mode. + _HOST_PREFS = "com.micropythonos.settings" + _HOST_MODE_KEY = "usb_host_mode" + + @classmethod + def is_available(cls): + try: + import usb # NOQA + return True + except ImportError: + return False + + @classmethod + def _get_host_pref(cls): + try: + from mpos import SharedPreferences + return SharedPreferences(cls._HOST_PREFS).get_string(cls._HOST_MODE_KEY) == "on" + except Exception: + return False + + @classmethod + def _set_host_pref(cls, on): + try: + from mpos import SharedPreferences + SharedPreferences(cls._HOST_PREFS).edit().put_string( + cls._HOST_MODE_KEY, "on" if on else "off").commit() + except Exception as e: + logger.error("usb host pref save fail: %s" % (e)) + + @classmethod + def _bootsel_held(cls): + # Physical escape hatch: BOOT held at boot forces CDC device mode + # regardless of the persisted flag (no-UART boards would otherwise + # strand headless in host mode). Best effort: GPIO0 with pull-up + # on most S3 boards; silent no-op anywhere else. + try: + from machine import Pin + import time as _time + boot = Pin(0, Pin.IN, Pin.PULL_UP) + _time.sleep_ms(5) + return boot.value() == 0 + except Exception: + return False + + @classmethod + def host_boot_requested(cls): + if cls._bootsel_held(): + logger.warning("usb BOOTSEL held: staying in CDC device mode") + return False + try: + from mpos import SharedPreferences + stored = SharedPreferences(cls._HOST_PREFS).get_string(cls._HOST_MODE_KEY) + except Exception: + return False + if stored == "once": + # One-shot expired on reboot: normalize to Off so the Settings + # row stops showing a stale selection. Runs once per session. + cls._set_host_pref(False) + return False + return stored == "on" + + @classmethod + def host_mode_active(cls): + try: + import usb + except ImportError: + return False + if not hasattr(usb, "host_active"): + return False + try: + return bool(usb.host_active()) + except Exception: + return False + + @classmethod + def activate(cls, persist=True): + # Runtime switch from CDC device mode to USB host mode. Tears down + # TinyUSB, starts the host stack, arms display + HID. CDC dies here + # by design (announce it in the UI before calling). + try: + import usb + except ImportError: + return False + if not hasattr(usb, "activate_host"): + return False + try: + usb.activate_host() + except Exception as e: + logger.error("usb host activate fail: %s" % (e)) + return False + cls.arm_display() + cls.arm_hid() + if persist: + cls._set_host_pref(True) + return True + + @classmethod + def deactivate(cls, persist=True): + # Runtime switch back to CDC device mode. The UI must be on panel + # first (can't tear down the active display); indevs are + # unregistered + deleted so no stale pointers survive. + try: + import usb + except ImportError: + return False + if not hasattr(usb, "deactivate_host"): + return False + if cls._active == "usb": + try: + cls.switch_to_panel() + except Exception as e: + logger.error("usb host deactivate switch-back fail: %s" % (e)) + return False + try: + from mpos import InputManager + for dev in (cls._usb_mouse, cls._usb_keyboard): + if dev is None: + continue + try: + InputManager.unregister_indev(dev) + except Exception: + pass + try: + dev.delete() + except Exception as e: + logger.error("usb hid delete fail: %s" % (e)) + except Exception as e: + logger.error("usb hid teardown fail: %s" % (e)) + cls._usb_mouse = None + cls._usb_keyboard = None + cls._hid_hub = None + cls._usb_dev = None + cls._usb_display = None + cls._update_hid_watchdog_exclusion() + try: + usb.deactivate_host() + except Exception as e: + logger.error("usb host deactivate fail (reboot recommended): %s" % (e)) + if persist: + cls._set_host_pref(False) + return False + if persist: + cls._set_host_pref(False) + return True + + @classmethod + def arm_display(cls, width=640, height=480): + if not cls.is_available(): + return None + if cls._usb_dev is None: + import usb + cls._usb_dev = usb.Display(width=width, height=height) + cls._usb_dev.start() + cls._ensure_poll_timer() + return cls._usb_dev + + @classmethod + def arm_hid(cls): + if cls._usb_mouse is not None and cls._usb_keyboard is not None: + return cls._usb_mouse + try: + import usb + except ImportError: + return None + if not hasattr(usb, "hid_start"): + return None + try: + import drivers.indev.usb_hid as usb_hid_mod + except ImportError as e: + logger.error("usb hid driver import fail: %s" % (e)) + return None + try: + if not usb.hid_start(): + return None + except Exception as e: + logger.error("usb hid start fail: %s" % (e)) + return None + from mpos import InputManager + if cls._hid_hub is None: + cls._hid_hub = usb_hid_mod.HIDHub() + if cls._usb_mouse is None: + try: + cls._usb_mouse = usb_hid_mod.USBMouse(source=cls._hid_hub) + InputManager.register_indev(cls._usb_mouse) + cls._usb_mouse.attach_cursor() + cls._usb_mouse.hide_cursor() + cls._usb_mouse.enable(False) + except Exception as e: + logger.error("usb hid mouse init fail: %s" % (e)) + cls._usb_mouse = None + if cls._usb_keyboard is None: + try: + cls._usb_keyboard = usb_hid_mod.USBHIDKeyboard(cls._hid_hub) + try: + group = lv.group_get_default() + except Exception: + group = None + if group is not None: + cls._usb_keyboard.set_group(group) + InputManager.register_indev(cls._usb_keyboard) + cls._usb_keyboard.enable(False) + except Exception as e: + logger.error("usb hid keyboard init fail: %s" % (e)) + cls._usb_keyboard = None + cls._ensure_poll_timer() + return cls._usb_mouse + + @classmethod + def _hid_claimed(cls): + try: + import usb + except ImportError: + return [] + if not hasattr(usb, "hid_claimed_addrs"): + return [] + try: + return list(usb.hid_claimed_addrs()) + except Exception as e: + logger.error("usb hid claimed fail: %s" % (e)) + return [] + + @classmethod + def _hid_parked_entries(cls): + try: + import usb + except ImportError: + return [] + if not hasattr(usb, "hid_parked"): + return [] + try: + return list(usb.hid_parked()) + except Exception as e: + logger.error("usb hid parked fail: %s" % (e)) + return [] + + @classmethod + def _update_hid_watchdog_exclusion(cls): + # Global idle-reset suppression, parked-only: a parked device is + # enumerated but silent, which reads exactly like a wedged adapter, + # and with no open handle its port is unresolvable. Claimed devices + # need no global suppression: the C watchdog skips exactly the + # HID-owned ports (usb_hid_owns_idle_port) while other ports keep + # healing. Restores the prior value afterwards, so a manual user + # setting is never forced back on. + try: + import usb + except ImportError: + return + if not hasattr(usb, "auto_reset_idle"): + return + parked = cls._hid_parked_entries() + if parked and cls._hid_idle_prev is None: + try: + cls._hid_idle_prev = bool(usb.auto_reset_idle()) + usb.auto_reset_idle(False) + except Exception as e: + logger.error("hid idle suppress fail: %s" % (e)) + cls._hid_idle_prev = None + elif not parked and cls._hid_idle_prev is not None: + try: + usb.auto_reset_idle(cls._hid_idle_prev) + except Exception as e: + logger.error("hid idle restore fail: %s" % (e)) + cls._hid_idle_prev = None + + @classmethod + def _hid_kinds(cls, claimed): + try: + import usb + except ImportError: + return (False, False) + if not hasattr(usb, "hid_state"): + return (bool(claimed), bool(claimed)) + try: + kinds = [entry[1] for entry in usb.hid_state()] + except Exception as e: + logger.error("usb hid kinds fail: %s" % (e)) + return (bool(claimed), bool(claimed)) + return ("mouse" in kinds, "keyboard" in kinds) + + @classmethod + def _sync_usb_hid(cls, claimed): + if claimed and (cls._usb_mouse is None or cls._usb_keyboard is None): + cls.arm_hid() + has_mouse, has_keyboard = cls._hid_kinds(claimed) + try: + if cls._usb_mouse is not None: + cls._usb_mouse.enable(bool(has_mouse)) + if has_mouse: + cls._usb_mouse.show_cursor() + else: + cls._usb_mouse.hide_cursor() + if cls._usb_keyboard is not None: + cls._usb_keyboard.enable(bool(has_keyboard)) + except Exception as e: + logger.error("usb hid sync fail: %s" % (e)) + + @classmethod + def _poll_hid(cls): + try: + import usb + except ImportError: + return + if not hasattr(usb, "hid_poll"): + return + try: + usb.hid_poll() + except Exception as e: + logger.error("usb hid poll fail: %s" % (e)) + return + claimed = cls._hid_claimed() + cls._update_hid_watchdog_exclusion() + cls._sync_usb_hid(claimed) + + # width/height default to 640x480: smallest standard DMT mode, proven to sync. + # Never request below that: smaller modes need a sub-25MHz pixel clock that + # real monitors cannot sync to (verified). 0,0 = EDID auto. + @classmethod + def try_init_usb_display(cls, width=640, height=480, timeout_s=10, buf_lines=16): + import drivers.display.usb_display as usb_display_driver + dev = cls.arm_display(width=width, height=height) + if dev is None: + raise RuntimeError("USB display unavailable (stock build?)") + if timeout_s: + logger.warning("usb wait %ss" % (timeout_s)) + deadline = time.ticks_add(time.ticks_ms(), timeout_s * 1000) + else: + logger.warning("usb wait (Ctrl-C aborts)...") + deadline = None + _polls = 0 + while not dev.ready(): + dev.poll() + _polls += 1 + if deadline is not None and time.ticks_diff(deadline, time.ticks_ms()) <= 0: + raise RuntimeError("usb timeout %ss polls=%d" % (timeout_s, _polls)) + time.sleep_ms(100) + disp_width = dev.width() + disp_height = dev.height() + logger.warning("usb ready %sx%s %s polls=%d" % (disp_width, disp_height, dev.chip_name(), _polls)) + buf_size = disp_width * buf_lines * 2 + logger.warning("usb bufs=%d" % (buf_size)) + display = usb_display_driver.USBDisplayDriver( + usb_dev=dev, + display_width=disp_width, + display_height=disp_height, + frame_buffer1=bytearray(buf_size), + frame_buffer2=bytearray(buf_size), + color_space=lv.COLOR_FORMAT.RGB565, + ) + display.init() + cls._load_blank(display) + cls._usb_display = display + return display + + @classmethod + def _load_blank(cls, display): + prev = lv.display_get_default() + display.set_default() + try: + lv.screen_load(lv.obj()) + finally: + if prev is not None: + prev.set_default() + logger.warning("sw blank ok") + + @classmethod + def switch_to_usb(cls, width=640, height=480, timeout_s=0): + import mpos.ui + if cls._active == "usb": + return mpos.ui.main_display + cls._panel_display = mpos.ui.main_display + cls._switching = True + logger.warning("sw usb begin") + try: + display = cls.try_init_usb_display(width=width, height=height, timeout_s=timeout_s) + cls._swap_to(display, "usb") + return display + finally: + cls._pump_resume() + cls._switching = False + + @classmethod + def switch_to_panel(cls): + import mpos.ui + if cls._active == "panel": + return mpos.ui.main_display + if cls._panel_display is None: + raise RuntimeError("no panel (USB boot)") + cls._switching = True + logger.warning("sw panel begin") + try: + cls._swap_to(cls._panel_display, "panel") + if cls._usb_display is not None: + cls._delete_display(cls._usb_display) + cls._usb_display = None + return mpos.ui.main_display + finally: + cls._pump_resume() + cls._switching = False + + @classmethod + def _swap_to(cls, display, name): + import mpos.ui + from mpos import AppManager, DisplayMetrics, InputManager + from mpos.ui.view import remove_and_stop_all_activities + old = mpos.ui.main_display + logger.warning("sw teardown old=%s new=%s" % (type(old).__name__, type(display).__name__)) + indevs = InputManager.list_indevs() + logger.warning("sw indevs=%d stack=%d" % (len(indevs), len(mpos.ui.view.screen_stack))) + cls._pump_suspend() + for indev in indevs: + indev.enable(False) + remove_and_stop_all_activities() + logger.warning("sw torn down stack=%d" % (len(mpos.ui.view.screen_stack))) + cls._load_blank(old) + logger.warning("sw inval off") + old.enable_invalidation(False) + logger.warning("sw inval off old ok") + display.enable_invalidation(False) + logger.warning("sw inval off new ok") + if name == "usb": + try: + cls._panel_backlight = old.get_backlight() + logger.warning("sw panel bl=%s" % (cls._panel_backlight)) + except Exception as e: + logger.error("sw panel bl read fail: %s" % (e)) + cls._panel_backlight = None + try: + old.set_backlight(0) + logger.warning("sw panel blanked") + except Exception as e: + logger.error("panel blank fail: %s" % (e)) + try: + logger.warning("sw default") + display.set_default() + mpos.ui.main_display = display + if name == "panel": + level = cls._panel_backlight + if level is None or level < 0: + level = cls._brightness_pref() + try: + display.set_backlight(level) + except Exception as e: + logger.error("panel bl restore fail: %s" % (e)) + logger.warning("sw indevs") + cls._repoint_indevs(display, old) + if name == "usb": + cls._wrap_all_touch(display, old) + else: + cls._unwrap_touch() + logger.warning("sw metrics") + DisplayMetrics.set_resolution(display.get_horizontal_resolution(), display.get_vertical_resolution()) + DisplayMetrics.set_dpi(display.get_dpi()) + logger.warning("sw topmenu") + mpos.ui.topmenu.move_to_display() + logger.warning("sw gestures") + # Gesture zones are recreated (one leaked set per switch, harmless). + mpos.ui.handle_back_swipe() + mpos.ui.handle_top_swipe() + logger.warning("sw launcher") + launcher = AppManager.get_launcher() + if launcher is None: + raise RuntimeError("no launcher") + logger.warning("sw starting %s" % (launcher.fullname)) + ok = AppManager.start_app(launcher.fullname) + logger.warning("sw started=%s" % (ok)) + finally: + logger.warning("sw inval on") + try: + display.enable_invalidation(True) + except Exception as e: + logger.error("inval on fail: %s" % (e)) + try: + old.enable_invalidation(True) + except Exception as e: + logger.error("old inval on fail: %s" % (e)) + cls._pump_resume() + cls._active = name + logger.warning("switched to %s" % (name)) + + @classmethod + def _pump_suspend(cls): + if cls._pump_suspended: + return + try: + import mpos.ui + th = getattr(mpos.ui, "task_handler", None) + if th is not None: + th.disable() + cls._pump_suspended = True + logger.warning("sw pump off") + except Exception as e: + logger.error("sw pump off fail: %s" % (e)) + + @classmethod + def _pump_resume(cls): + if not cls._pump_suspended: + return + cls._pump_suspended = False + try: + import mpos.ui + th = getattr(mpos.ui, "task_handler", None) + if th is not None: + th.enable() + logger.warning("sw pump on") + except Exception as e: + logger.error("sw pump on fail: %s" % (e)) + + @classmethod + def _brightness_pref(cls): + try: + from mpos import SharedPreferences + return SharedPreferences("com.micropythonos.settings").get_int("display_brightness", 100) + except Exception: + return 100 + + @classmethod + def _repoint_indevs(cls, display, old): + import display_driver_framework + from mpos import InputManager + new_lv_disp = display._disp_drv + py_disp = None + for d in display_driver_framework.DisplayDriver.get_displays(): + if d._disp_drv == new_lv_disp: + py_disp = d + break + for indev in InputManager.list_indevs(): + logger.warning("sw indev %s" % (type(indev).__name__)) + drv = getattr(indev, "_indev_drv", indev) + drv.set_display(new_lv_disp) + if hasattr(indev, "_disp_drv"): + indev._disp_drv = new_lv_disp + indev._width = new_lv_disp.get_horizontal_resolution() + indev._height = new_lv_disp.get_vertical_resolution() + indev._py_disp_drv = py_disp + on_display_changed = getattr(indev, "_on_display_changed", None) + if on_display_changed is not None: + try: + on_display_changed(new_lv_disp) + except Exception as e: + logger.error("sw indev display hook fail: %s" % (e)) + if hasattr(indev, "_on_size_change"): + new_lv_disp.add_event_cb(indev._on_size_change, lv.EVENT.RESOLUTION_CHANGED, None) + indev.enable(True) + logger.warning("sw indevs done") + + @classmethod + def _wrap_all_touch(cls, display, old): + pw = getattr(old, "display_width", None) + ph = getattr(old, "display_height", None) + uw = getattr(display, "display_width", None) + uh = getattr(display, "display_height", None) + if None in (pw, ph, uw, uh) or 0 in (pw, ph): + logger.error("sw touch wrap skipped (dims unknown)") + return + from mpos import DeviceInfo, InputManager + try: + exc = cls._USB_TOUCH_EXC.get(DeviceInfo.get_hardware_id(), {}) + except Exception: + exc = {} + for indev in InputManager.list_indevs(): + if getattr(indev, "__usb_absolute__", False): + continue + if not hasattr(indev, "_calc_coords") or indev in cls._wrapped_indevs: + continue + cls._wrapped_indevs.append(indev) + orig = indev._calc_coords + if (pw >= ph) == (uw >= uh): + sx = uw / pw + sy = uh / ph + mx = exc.get("mx", False) + my = exc.get("my", False) + + def usb_calc(x, y, _o=orig): + px, py = _o(x, y) + if mx: + px = pw - 1 - px + if my: + py = ph - 1 - py + return (int(px * sx), int(py * sy)) + elif exc.get("ccw", False): + def usb_calc(x, y, _o=orig): + px, py = _o(x, y) + return (int((ph - 1 - py) * uw / ph), int(px * uh / pw)) + else: + def usb_calc(x, y, _o=orig): + px, py = _o(x, y) + return (int(py * uw / ph), int((pw - 1 - px) * uh / pw)) + indev._calc_coords = usb_calc + logger.warning("sw touch wrapped=%d" % (len(cls._wrapped_indevs))) + + @classmethod + def _unwrap_touch(cls): + for indev in cls._wrapped_indevs: + try: + del indev._calc_coords + except Exception as e: + logger.error("sw touch unwrap fail: %s" % (e)) + del cls._wrapped_indevs[:] + logger.warning("sw touch unwrapped") + + @classmethod + def _delete_display(cls, display): + import display_driver_framework + try: + displays = display_driver_framework.DisplayDriver.get_displays() + if display in displays: + displays.remove(display) + except Exception as e: + logger.error("untrack fail: %s" % (e)) + try: + display._disp_drv.delete() + except Exception as e: + logger.error("delete fail: %s" % (e)) + gc.collect() + + @classmethod + def _ensure_poll_timer(cls): + if cls._poll_timer is None: + cls._poll_timer = lv.timer_create(cls._poll_cb, 1000, None) + + @classmethod + def _poll_cb(cls, t): + if not cls._switching: + cls._poll_hid() + dev = cls._usb_dev + if dev is None or cls._switching: + return + try: + event = dev.poll() + except Exception as e: + logger.error("usb poll fail: %s" % (e)) + return + try: + ready = dev.ready() + except Exception: + return + if event and ready and cls._active == "panel" and cls._auto_switch: + logger.warning("usb ready, auto-switch") + try: + cls.switch_to_usb(timeout_s=5) + except Exception as e: + logger.error("auto-switch fail: %s" % (e)) + cls._sw_idle_polls = 0 + cls._sw_retries = 0 + elif cls._active == "usb" and event and not ready: + logger.warning("usb gone, back to panel") + try: + cls.switch_to_panel() + except Exception as e: + logger.error("auto-revert fail: %s" % (e)) + cls._sw_idle_polls = 0 + cls._sw_retries = 0 + elif event: + logger.warning("usb ev ready=%s %sx%s" % (ready, dev.width(), dev.height())) + cls._sw_idle_polls = 0 + cls._sw_retries = 0 + elif ready and cls._active == "panel" and cls._auto_switch: + cls._sw_idle_polls += 1 + if cls._sw_idle_polls >= cls._SW_RETRY_EVERY and cls._sw_retries < cls._SW_MAX_RETRIES: + cls._sw_idle_polls = 0 + cls._sw_retries += 1 + logger.warning("usb ready but still on panel, retry %d/%d" % (cls._sw_retries, cls._SW_MAX_RETRIES)) + try: + cls.switch_to_usb(timeout_s=5) + except Exception as e: + logger.error("auto-switch retry fail: %s" % (e)) + else: + cls._sw_idle_polls = 0 + if not ready: + cls._sw_retries = 0 diff --git a/internal_filesystem/lib/uaiowebsocket.py b/internal_filesystem/lib/uaiowebsocket.py index 6f185dad3..1a38b7c0f 100644 --- a/internal_filesystem/lib/uaiowebsocket.py +++ b/internal_filesystem/lib/uaiowebsocket.py @@ -404,12 +404,15 @@ async def _connect_and_run(self): elif msg.type == ABNF.OPCODE_PONG: self.last_pong_tm = time.time() elif msg.type == ABNF.OPCODE_PING: - data = msg.data - _run_callback(self.on_ping, self, data) - try: - await self.ws.pong(data) - except Exception as e: - _log_error(f"Failed to send pong: {e}") + self._handle_ping(msg.data) + + def _handle_ping(self, data): + """Incoming PING frame. The bundled aiohttp port already answered it: + WebSocketClient.receive() maps PING to a PONG send before handing the + frame up (and ClientWebSocketResponse has no pong() at all), so only + the callback runs here. Replying again used to raise AttributeError + on every relay ping and log a misleading ERROR (MicroPythonOS#299).""" + _run_callback(self.on_ping, self, data) async def _send_async(self, data, opcode): """Async send implementation.""" diff --git a/lvgl_micropython b/lvgl_micropython index 200ad1f98..c3bbcfd32 160000 --- a/lvgl_micropython +++ b/lvgl_micropython @@ -1 +1 @@ -Subproject commit 200ad1f989b8973f15057e992d6fef52d16daf49 +Subproject commit c3bbcfd3292d3704b448afca7236f728ff2ad105 diff --git a/manifests/manifest.py b/manifests/manifest.py index a276d0b56..ab9a5af4d 100644 --- a/manifests/manifest.py +++ b/manifests/manifest.py @@ -1,5 +1,10 @@ freeze('../internal_filesystem/', 'main.py') # Hardware initialization freeze('../internal_filesystem/lib', '') # Additional libraries freeze('../freezeFS/', 'freezefs_mount_builtin.py') # Built-in apps -package("usb", base_path="../lvgl_micropython/lib/micropython/lib/micropython-lib/micropython/usb/usb-device") -package("usb", base_path="../lvgl_micropython/lib/micropython/lib/micropython-lib/micropython/usb/usb-device-midi") +import os +# USB host builds (MPOS_NO_USBDEV=1, see scripts/build_mpos.sh +# --usb) compile out the TinyUSB *device* stack, so its pure-Python +# framework would be dead flash. Nothing in the tree imports it. +if not os.getenv("MPOS_NO_USBDEV"): + package("usb", base_path="../lvgl_micropython/lib/micropython/lib/micropython-lib/micropython/usb/usb-device") + package("usb", base_path="../lvgl_micropython/lib/micropython/lib/micropython-lib/micropython/usb/usb-device-midi") diff --git a/micropython-camera-API b/micropython-camera-API index db42861e1..f88b29d7c 160000 --- a/micropython-camera-API +++ b/micropython-camera-API @@ -1 +1 @@ -Subproject commit db42861e1c0e5e5e522fa2b02ed38566bb28dd1b +Subproject commit f88b29d7ce9bb0c3733532bbb31fde794a51e6df diff --git a/patches/tinyusb_isr_double_free.patch b/patches/tinyusb_isr_double_free.patch new file mode 100644 index 000000000..40dfd0763 --- /dev/null +++ b/patches/tinyusb_isr_double_free.patch @@ -0,0 +1,16 @@ +--- a/src/portable/synopsys/dwc2/dwc2_esp32.h ++++ b/src/portable/synopsys/dwc2/dwc2_esp32.h +@@ -101,8 +101,12 @@ + if (enabled) { + esp_intr_alloc(_dwc2_controller[rhport].irqnum, ESP_INTR_FLAG_LOWMED, + dwc2_int_handler_wrap, (void*)(uintptr_t)tu_u16(role, rhport), &usb_ih[rhport]); +- } else { ++ } else if (usb_ih[rhport]) { ++ // MPOS runtime host/device switching deinits and reinits the stack ++ // without rebooting: the handle dangles after the first free, so guard ++ // the re-free (double-free crashes in esp_intr_disable) and clear it. + esp_intr_free(usb_ih[rhport]); ++ usb_ih[rhport] = NULL; + } + } + diff --git a/patches/usb_enum_no_abort.patch b/patches/usb_enum_no_abort.patch new file mode 100644 index 000000000..b6f23629b --- /dev/null +++ b/patches/usb_enum_no_abort.patch @@ -0,0 +1,219 @@ +diff --git a/components/usb/enum.c b/components/usb/enum.c +index fc93a38f..f9dd0f30 100644 +--- a/components/usb/enum.c ++++ b/components/usb/enum.c +@@ -298,7 +298,7 @@ static esp_err_t second_reset_request(void) + * @param[out] index String index + * @param[out] langid String langid + */ +-static inline void get_index_langid_for_stage(enum_stage_t stage, uint8_t *index, uint16_t *langid) ++static inline bool get_index_langid_for_stage(enum_stage_t stage, uint8_t *index, uint16_t *langid) + { + switch (stage) { + case ENUM_STAGE_GET_SHORT_LANGID_TABLE: +@@ -322,10 +322,13 @@ static inline void get_index_langid_for_stage(enum_stage_t stage, uint8_t *index + *langid = ENUM_LANGID; // Use the default LANGID + break; + default: +- // Should not occur +- abort(); +- break; ++ // Corrupt stage (e.g. surprise removal racing enumeration): ++ // abandon this device instead of aborting the board. ++ ESP_LOGE(ENUM_TAG, "unexpected stage %d, cancelling enumeration", (int)stage); ++ p_enum_driver->single_thread.stage = ENUM_STAGE_CANCEL; ++ return false; + } ++ return true; + } + + /** +@@ -335,7 +338,7 @@ static inline void get_index_langid_for_stage(enum_stage_t stage, uint8_t *index + * + * @param[in] stage Enumeration stage + */ +-static void control_request_general(enum_stage_t stage) ++static bool control_request_general(enum_stage_t stage) + { + usb_transfer_t *transfer = &p_enum_driver->constant.urb->transfer; + uint8_t ctrl_ep_mps = p_enum_driver->single_thread.dev_params.bMaxPacketSize0; +@@ -390,11 +393,13 @@ static void control_request_general(enum_stage_t stage) + break; + } + default: +- // Should never occur ++ // Corrupt stage: abandon the enumeration instead of aborting. ++ ESP_LOGE(ENUM_TAG, "unexpected stage %d, cancelling enumeration", (int)stage); + p_enum_driver->single_thread.expect_num_bytes = 0; +- abort(); +- break; ++ p_enum_driver->single_thread.stage = ENUM_STAGE_CANCEL; ++ return false; + } ++ return true; + } + + /** +@@ -404,7 +409,7 @@ static void control_request_general(enum_stage_t stage) + * + * @param[in] stage Enumeration stage + */ +-static void control_request_string(enum_stage_t stage) ++static bool control_request_string(enum_stage_t stage) + { + usb_transfer_t *transfer = &p_enum_driver->constant.urb->transfer; + uint8_t ctrl_ep_mps = p_enum_driver->single_thread.dev_params.bMaxPacketSize0; +@@ -412,7 +417,9 @@ static void control_request_string(enum_stage_t stage) + uint8_t index = 0; + uint16_t langid = 0; + +- get_index_langid_for_stage(stage, &index, &langid); ++ if (!get_index_langid_for_stage(stage, &index, &langid)) { ++ return false; ++ } + + switch (stage) { + case ENUM_STAGE_GET_SHORT_LANGID_TABLE: +@@ -438,11 +445,13 @@ static void control_request_string(enum_stage_t stage) + break; + } + default: +- // Should never occur ++ // Corrupt stage: abandon the enumeration instead of aborting. ++ ESP_LOGE(ENUM_TAG, "unexpected stage %d, cancelling enumeration", (int)stage); + p_enum_driver->single_thread.expect_num_bytes = 0; +- abort(); +- break; ++ p_enum_driver->single_thread.stage = ENUM_STAGE_CANCEL; ++ return false; + } ++ return true; + } + + /** +@@ -673,8 +682,8 @@ static inline int get_str_index(enum_stage_t stage) + default: + break; + } +- // Should never occurred +- abort(); ++ // Corrupt stage: fail the parse so the state machine cancels. ++ ESP_LOGE(ENUM_TAG, "unexpected stage %d, cancelling enumeration", (int)stage); + return -1; + } + +@@ -689,7 +698,11 @@ static esp_err_t parse_full_str_desc(void) + usb_device_handle_t dev_hdl = p_enum_driver->single_thread.dev_hdl; + const usb_str_desc_t *str_desc = (usb_str_desc_t *)(transfer->data_buffer + sizeof(usb_setup_packet_t)); + +- return usbh_dev_set_str_desc(dev_hdl, str_desc, get_str_index(p_enum_driver->single_thread.stage)); ++ int index = get_str_index(p_enum_driver->single_thread.stage); ++ if (index < 0) { ++ return ESP_FAIL; ++ } ++ return usbh_dev_set_str_desc(dev_hdl, str_desc, index); + } + + static esp_err_t check_config(void) +@@ -720,7 +733,9 @@ static esp_err_t control_request(enum_stage_t stage) + case ENUM_STAGE_GET_SHORT_CONFIG_DESC: + case ENUM_STAGE_GET_FULL_CONFIG_DESC: + case ENUM_STAGE_SET_CONFIG: +- control_request_general(stage); ++ if (!control_request_general(stage)) { ++ return ESP_ERR_INVALID_STATE; ++ } + break; + case ENUM_STAGE_GET_SHORT_LANGID_TABLE: + case ENUM_STAGE_GET_FULL_LANGID_TABLE: +@@ -730,12 +745,16 @@ static esp_err_t control_request(enum_stage_t stage) + case ENUM_STAGE_GET_FULL_PROD_STR_DESC: + case ENUM_STAGE_GET_SHORT_SER_STR_DESC: + case ENUM_STAGE_GET_FULL_SER_STR_DESC: +- control_request_string(stage); +- break; +- default: // Should never occur +- ret = ESP_ERR_INVALID_STATE; +- abort(); ++ if (!control_request_string(stage)) { ++ return ESP_ERR_INVALID_STATE; ++ } + break; ++ default: // Corrupt stage: skip the transfer, fail the stage. ++ // The state machine routes failures to CANCEL, and the ++ // stage was already normalized to CANCEL above. ++ ESP_LOGE(ENUM_TAG, "unexpected stage %d, cancelling enumeration", (int)stage); ++ p_enum_driver->single_thread.stage = ENUM_STAGE_CANCEL; ++ return ESP_ERR_INVALID_STATE; + } + + ret = usbh_dev_submit_ctrl_urb(p_enum_driver->single_thread.dev_hdl, p_enum_driver->constant.urb); +@@ -831,9 +850,10 @@ static esp_err_t control_response_handling(enum_stage_t stage) + ret = parse_full_str_desc(); + break; + default: +- // Should never occurred ++ // Corrupt stage: fail so the state machine cancels. ++ ESP_LOGE(ENUM_TAG, "unexpected stage %d, cancelling enumeration", (int)stage); ++ p_enum_driver->single_thread.stage = ENUM_STAGE_CANCEL; + ret = ESP_ERR_INVALID_STATE; +- abort(); + break; + } + +@@ -1021,14 +1041,9 @@ static bool set_next_stage(bool last_stage_pass) + next_stage = last_stage + 1; + } + } else { +- // These stages cannot fail +- assert(last_stage != ENUM_STAGE_SET_ADDR_RECOVERY && +- last_stage != ENUM_STAGE_SELECT_CONFIG && +- last_stage != ENUM_STAGE_SECOND_RESET && +- last_stage != ENUM_STAGE_SECOND_RESET_COMPLETE && +- last_stage != ENUM_STAGE_COMPLETE && +- last_stage != ENUM_STAGE_CANCEL); +- ++ // (No assert here: after surprise removal the stage value ++ // itself may be corrupt and name any of these. All paths ++ // below handle arbitrary values.) + // Last stage failed + switch (last_stage) { + // Stages that are allowed to fail skip to the next appropriate stage +@@ -1052,15 +1067,18 @@ static bool set_next_stage(bool last_stage_pass) + break; + case ENUM_STAGE_COMPLETE: + case ENUM_STAGE_CANCEL: +- // These stages should never fail +- abort(); ++ // Terminal stages report failure only on corrupt input. ++ ESP_LOGE(ENUM_TAG, "terminal stage failed, cancelling enumeration"); ++ next_stage = ENUM_STAGE_CANCEL; + break; + default: + // Stage is not allowed to failed. Cancel enumeration. +- ESP_LOGE(ENUM_TAG, "[%d:%d] %s FAILED", ++ // (Numeric stage: the value may be corrupt after surprise ++ // removal, so the name table must not be indexed with it.) ++ ESP_LOGE(ENUM_TAG, "[%d:%d] stage %d FAILED", + p_enum_driver->single_thread.parent_dev_addr, + p_enum_driver->single_thread.parent_port_num, +- enum_stage_strings[last_stage]); ++ (int)last_stage); + next_stage = ENUM_STAGE_CANCEL; + break; + } +@@ -1371,8 +1389,12 @@ esp_err_t enum_process(void) + res = stage_complete(); + break; + default: +- // Should never occur +- abort(); ++ // Corrupt stage (e.g. surprise removal racing enumeration): ++ // run the cancel path instead of aborting the board. ++ ESP_LOGE(ENUM_TAG, "unexpected stage %d, cancelling enumeration", ++ (int)stage); ++ p_enum_driver->single_thread.stage = ENUM_STAGE_CANCEL; ++ res = stage_cancel(); + break; + } + diff --git a/patches/usb_ext_port_retries.patch b/patches/usb_ext_port_retries.patch new file mode 100644 index 000000000..3de9731f3 --- /dev/null +++ b/patches/usb_ext_port_retries.patch @@ -0,0 +1,22 @@ +diff --git a/components/usb/ext_port.c b/components/usb/ext_port.c +index eb5f40d3..62cded21 100644 +--- a/components/usb/ext_port.c ++++ b/components/usb/ext_port.c +@@ -24,6 +24,17 @@ + #else + #define EXT_PORT_RESET_ATTEMPTS 1 + #endif ++#ifdef MPOS_USB_PORT_SETTLE_MS ++// MPOS USB display support: retry hub-port resets (Linux xHCI parity ++// for fast transients like TT/DWC glitches). The Kconfig knob behind ++// this is invisible and needs IDF_EXPERIMENTAL_FEATURES, so override ++// it here instead, scoped to --usbdisplay builds (the only ones that ++// define MPOS_USB_PORT_SETTLE_MS). Slow boots are unaffected: the ++// first attempt still waits out the settle delay, and anything slower ++// falls through to the userspace watchdog as before. ++#undef EXT_PORT_RESET_ATTEMPTS ++#define EXT_PORT_RESET_ATTEMPTS 3 ++#endif + // Delay in ms after sending the SetFeature() class specific request + #define EXT_PORT_RESET_RECOVERY_DELAY_MS CONFIG_USB_HOST_EXT_PORT_RESET_RECOVERY_DELAY_MS + #define EXT_PORT_POWER_ON_CUSTOM_DELAY CONFIG_USB_HOST_EXT_PORT_CUSTOM_POWER_ON_DELAY_ENABLE diff --git a/patches/usb_ext_port_settle.patch b/patches/usb_ext_port_settle.patch new file mode 100644 index 000000000..11255f99c --- /dev/null +++ b/patches/usb_ext_port_settle.patch @@ -0,0 +1,18 @@ +diff --git a/components/usb/ext_port.c b/components/usb/ext_port.c +index eb5f40d3ea..da794721a9 100644 +--- a/components/usb/ext_port.c ++++ b/components/usb/ext_port.c +@@ -630,6 +630,13 @@ static void handle_port_connection(ext_port_t *ext_port) + } else { + // New device connected, flush reset attempts + ext_port->dev_reset_attempts = 0; ++#ifdef MPOS_USB_PORT_SETTLE_MS ++ // MPOS USB display support: slow-booting devices ++ // (a DisplayLink adapter needs 1-2s after power-on) are ++ // wedged by an immediate port reset, so let the device ++ // boot before the first reset. Inert when undefined. ++ vTaskDelay(pdMS_TO_TICKS(MPOS_USB_PORT_SETTLE_MS)); ++#endif + ext_port->state = USB_PORT_STATE_RESETTING; + // New device has not been enumerated yet, reset the flag + ext_port->flags.has_enum_device = 0; diff --git a/patches/usb_phy_deinit.patch b/patches/usb_phy_deinit.patch new file mode 100644 index 000000000..099375f35 --- /dev/null +++ b/patches/usb_phy_deinit.patch @@ -0,0 +1,30 @@ +diff --git a/ports/esp32/usb.c b/ports/esp32/usb.c +index bca2305..bc1c479 100644 +--- a/ports/esp32/usb.c ++++ b/ports/esp32/usb.c +@@ -52,6 +52,13 @@ void usb_phy_init(void) { + usb_new_phy(&phy_conf, &phy_hdl); + } + ++void usb_phy_deinit(void) { ++ if (phy_hdl) { ++ usb_del_phy(phy_hdl); ++ phy_hdl = NULL; ++ } ++} ++ + #if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32P4 + void usb_usj_mode(void) { + // Switch the USB PHY back to Serial/Jtag mode, disabling OTG support +diff --git a/ports/esp32/usb.h b/ports/esp32/usb.h +index 9943726..76b9aa5 100644 +--- a/ports/esp32/usb.h ++++ b/ports/esp32/usb.h +@@ -29,6 +29,7 @@ + #define MICROPY_HW_USB_CDC_TX_TIMEOUT_MS (500) + + void usb_phy_init(void); ++void usb_phy_deinit(void); + void usb_usj_mode(void); + + #endif // MICROPY_INCLUDED_ESP32_USB_H diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 6a203184b..b64e98145 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -88,6 +88,21 @@ reset_web_port_changes() { target="$1" buildtype="$2" +# USB host support is opt-in and ESP32-S3-only (needs USB OTG): +# ./scripts/build_mpos.sh esp32s3 --usb +usbhost=0 +if [[ "$*" == *--usbdisplay* ]]; then + echo "ERROR: --usbdisplay was renamed to --usb" + exit 1 +fi +if [[ " $* " == *" --usb "* ]]; then + if [ "$target" == "esp32s3" ]; then + usbhost=1 + else + echo "WARNING: --usb is only supported for the esp32s3 target, ignoring it" + fi +fi + if [ -z "$target" ]; then echo "Usage: $0 target" echo "Usage: $0 " @@ -97,6 +112,7 @@ if [ -z "$target" ]; then echo "Example: $0 esp32" echo "Example: $0 esp32-small" echo "Example: $0 esp32s3" + echo "Example: $0 esp32s3 --usb (USB host support: display adapters + HID, ESP32-S3 USB host)" echo "Example: $0 unphone" echo "Example: $0 lilygo_t4" echo "Example: $0 clean" @@ -196,6 +212,49 @@ apply_patch "$codebasedir"/lvgl_micropython/lib/lvgl "$codebasedir"/lvgl_micropy echo "Applying lvgl_micropython/lib/lvgl/src/libs/tjpgd scaling fix patch..." apply_patch "$codebasedir"/lvgl_micropython/lib/lvgl "$codebasedir"/lvgl_micropython/lib_lvgl_src_libs_tjpgd_fix_scaling.patch +# USB host support: settle delay before the first hub-port reset, +# so slow-booting devices (DisplayLink needs 1-2s) are not wedged by an +# immediate reset. Inert without -DMPOS_USB_PORT_SETTLE_MS (only --usb +# builds define it), so all other builds are unaffected. +echo "Applying lib/esp-idf USB ext-port settle patch..." +apply_patch "$codebasedir"/lvgl_micropython/lib/esp-idf "$codebasedir"/patches/usb_ext_port_settle.patch + +# USB host support: retry hub-port resets (see the +# CONFIG_USB_HOST_EXT_PORT_RESET_RECOVERY_DELAY_MS extra_config above +# for rationale). Scoped to --usb builds via +# MPOS_USB_PORT_SETTLE_MS, inert everywhere else. +echo "Applying lib/esp-idf USB ext-port retries patch..." +apply_patch "$codebasedir"/lvgl_micropython/lib/esp-idf "$codebasedir"/patches/usb_ext_port_retries.patch + +# USB enumerator robustness: IDF's enum.c aborts the whole board on any +# "impossible" stage value (8 sites), but surprise removal racing an +# enumeration corrupts the single-thread stage (proven by identical +# abort() crash dumps at enum.c control_request_string on hub replug). +# Downgrade every site to log + cancel-device instead. Same repo +# patch-file convention as above; aborts in other usb/ files are left +# alone (none observed in the field). +echo "Applying lib/esp-idf USB enum no-abort patch..." +apply_patch "$codebasedir"/lvgl_micropython/lib/esp-idf "$codebasedir"/patches/usb_enum_no_abort.patch + +# Dynamic USB host support: expose usb_phy_deinit() so the runtime +# host-activation path can delete the device-mode PHY before the host +# stack creates its own. Purely additive, inert everywhere else. +echo "Applying micropython USB PHY deinit patch..." +apply_patch "$codebasedir"/lvgl_micropython/lib/micropython "$codebasedir"/patches/usb_phy_deinit.patch + +# Dynamic USB host support: TinyUSB frees its DWC2 ISR handle on deinit +# without NULLing it, so a deinit→reinit cycle (host deactivate back to +# CDC) double-frees and crashes in esp_intr_disable. Guard + clear. +# The component is fetched at build time, so on a fresh checkout it may +# not exist yet: build once to fetch, then rebuild to patch. +_tinyusb_dwc2="$codebasedir"/lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__tinyusb/src/portable/synopsys/dwc2/dwc2_esp32.h +if [ -f "$_tinyusb_dwc2" ]; then + echo "Applying tinyusb ISR double-free patch..." + apply_patch "$codebasedir"/lvgl_micropython/lib/micropython/ports/esp32/managed_components/espressif__tinyusb "$codebasedir"/patches/tinyusb_isr_double_free.patch +else + echo "WARNING: tinyusb component not fetched yet — skipping ISR patch; rebuild once to apply it." +fi + # Fast emoji rendering: bake a codepoint range filter into lv_imgfont so # non-emoji glyphs bail out in C without invoking the MicroPython path_cb. # Pre-existence check so MPOS still builds against older pinned @@ -309,11 +368,33 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "unphone" -o BOARD_VARIANT=SPIRAM_OCT # These options disable hardware AES, SHA and MPI because they give warnings in QEMU: [AES] Error reading from GDMA buffer # There's a 25% https download speed penalty for this, but that's usually not the bottleneck. - extra_configs="CONFIG_MBEDTLS_HARDWARE_AES=n CONFIG_MBEDTLS_HARDWARE_SHA=n CONFIG_MBEDTLS_HARDWARE_MPI=n" + extra_configs="CONFIG_MBEDTLS_HARDWARE_AES=n CONFIG_MBEDTLS_HARDWARE_SHA=n CONFIG_MBEDTLS_HARDWARE_MPI=n CONFIG_LOG_MAXIMUM_EQUALS_DEFAULT=y CONFIG_ETH_ENABLED=n CONFIG_ETH_USE_SPI_ETHERNET=n CONFIG_ETH_SPI_ETHERNET_DM9051=n CONFIG_ETH_SPI_ETHERNET_W5500=n CONFIG_ETH_SPI_ETHERNET_KSZ8851SNL=n" # --py-freertos: add MicroPython FreeRTOS module to expose internals #extra_configs="$extra_configs --py-freertos" # Enable UART based REPL, in addition to the USB-CDC or JTAG REPL. Can be disabled with esp.uart_repl(False) extra_configs="$extra_configs --enable-uart-repl=y" + if [ "$usbhost" == "1" ]; then + # USB host (needs the adapter behind a USB hub to + # enumerate: explicit IDF usb_host external-hub support, off by + # default -> downstream devices never enumerate). + # DEBOUNCE_DELAY 2000: root-port settle so a directly attached + # slow-booting adapter is awake before its first reset (hub + # downstream ports are covered by the ext-port settle patch). + extra_configs="$extra_configs CONFIG_USB_HOST_HUBS_SUPPORTED=y CONFIG_USB_HOST_HUB_MULTI_LEVEL=y CONFIG_USB_HOST_DEBOUNCE_DELAY_MS=2000" + # Retry hub-port resets (Linux parity for fast transients): + # EXT_PORT_RESET_ATTEMPTS=3 retries a failed port reset instead + # of permanently disabling the port after one CHECK_SHORT_DEV_DESC + # failure. Covers the fast-transient window (TT/DWC glitches); + # slow boots stay owned by the settle patch above plus the + # watchdog's seconds-later retries, and exhaustion still lands + # on the watchdog path. Implemented as a patch (not Kconfig): + # the knob is invisible and needs IDF_EXPERIMENTAL_FEATURES, + # which we don't want to enable tree-wide; the patch below is + # scoped to --usb builds via MPOS_USB_PORT_SETTLE_MS. + # RESET_RECOVERY_DELAY 100 (default 30, visible Kconfig): + # per-attempt settle. + extra_configs="$extra_configs CONFIG_USB_HOST_EXT_PORT_RESET_RECOVERY_DELAY_MS=100" + fi fi if [ "$BOARD_VARIANT" == "SPIRAM" -o "$BOARD_VARIANT" == "SPIRAM_OCT" ]; then @@ -325,7 +406,12 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "unphone" -o frozenmanifest="FROZEN_MANIFEST=$manifest" # Comment this out if you want to make a build without any frozen files, just an empty MicroPython + whatever files you have on the internal storage echo "Note that you can also prevent the builtin filesystem from being mounted by umounting it and creating a builtin/ folder." pushd "$codebasedir"/lvgl_micropython/ - rm -rf lib/micropython/ports/esp32/build-$BOARD-$BOARD_VARIANT + # MPOS_NO_CLEAN=1 skips the build-dir wipe for fast iteration when only + # frozen .py files changed (ninja rebuilds incrementally). C/CMake/config + # changes still need a clean build. + if [ "${MPOS_NO_CLEAN:-0}" != "1" ]; then + rm -rf lib/micropython/ports/esp32/build-$BOARD-$BOARD_VARIANT + fi # For more info on the options, see https://github.com/lvgl-micropython/lvgl_micropython # --optimize-size: optimize for size @@ -344,14 +430,32 @@ if [ "$target" == "esp32" -o "$target" == "esp32s3" -o "$target" == "unphone" -o # CONFIG_SPIRAM_XIP_FROM_PSRAM: load entire firmware into RAM to reduce SD vs PSRAM contention (recommended at https://github.com/MicroPythonOS/MicroPythonOS/issues/17) ccache_arg="" [ "${MPOS_CCACHE:-0}" = "1" ] && ccache_arg="--ccache" + # USB host support (./build_mpos.sh esp32s3 --usb): + # frees the S3 OTG peripheral for the IDF usb_host stack by disabling + # MicroPython's TinyUSB device mode (USB-serial REPL goes away, console + # remains over UART REPL / USB-Serial-JTAG). -DESP_PLATFORM is needed by + # Pico_USB_Disp's platform detection in the QSTR pre-pass too (the real + # compiles get it via the usermod INTERFACE definition). + if [ "$usbhost" == "1" ]; then + # P0 spike: keep TinyUSB compiled in so CDC is available by default. + # Host mode activates at runtime via tud_deinit() + usb_host_install(). + export CFLAGS_EXTRA="-DESP_PLATFORM -DMPOS_USB_PORT_SETTLE_MS=2000" + export MPOS_NO_USBDEV=1 + usb_usermod="USER_C_MODULE=$codebasedir/c_mpos/usb/micropython.cmake" + else + usb_usermod="" + fi set -x python3 make.py $ccache_arg $otasupport --optimize-size --partition-size=$partition_size --flash-size=$flash_size esp32 BOARD=$BOARD BOARD_VARIANT=$BOARD_VARIANT \ USER_C_MODULE="$codebasedir"/secp256k1-embedded-ecdh/micropython.cmake \ USER_C_MODULE="$codebasedir"/c_mpos/micropython.cmake \ + $usb_usermod \ CONFIG_ADC_MIC_TASK_CORE=1 \ $extra_configs \ "$frozenmanifest" set +x + unset CFLAGS_EXTRA + unset MPOS_NO_USBDEV popd # Report firmware size vs the OTA partition budget so headroom erosion is diff --git a/scripts/run_and_screenshot_emulator.sh b/scripts/run_and_screenshot_emulator.sh new file mode 100755 index 000000000..0d527e864 --- /dev/null +++ b/scripts/run_and_screenshot_emulator.sh @@ -0,0 +1,30 @@ +# Somehow, the last line isn't parsed... +rm screenshot.png +rm /tmp/core.* +pkill -f qemu-system-xtensa +date +~/projects/MicroPythonOS/claude/qemu_a159x36/run.sh 2>&1 | while read line; do + echo "$line" + #if echo "$line" | grep -q "Starting asyncio REPL"; then + if echo "$line" | grep -q "asyncio REPL task"; then + echo "finished boot!" # this doesnt show + sleep 1 # allow time for top menu bar to animate into view + echo "making screenshot" + import -window "$(xdotool getwindowfocus)" screenshot.png + sleep 1 + echo "screenshot is in:" + readlink -f screenshot.png + echo "stopping emulator..." + pkill -f qemu-system-xtensa + echo "finished!" + date + echo "breaking..." + echo "really breaking..." + break + fi +done +result=$? +echo "before exit" +date +echo "exit code: $result" + diff --git a/scripts/run_emulator.sh b/scripts/run_emulator.sh new file mode 100755 index 000000000..3bd45fdc1 --- /dev/null +++ b/scripts/run_emulator.sh @@ -0,0 +1,44 @@ +rm screenshot.png +rm /tmp/core.* +pkill -f qemu-system-xtensa +date +logfile=/tmp/run_emulator.log +lines=0 +maxlines=100 +timeout=60 +rm "$logfile" +timeout $timeout ~/projects/MicroPythonOS/claude/qemu_a159x36/run.sh 2>&1 | while read line; do + echo "$line" >> "$logfile" + lines=$(expr $lines \+ 1) + if [ $lines -gt $maxlines ]; then + echo "stopping after $maxlines lines" + pkill -f qemu-system-xtensa + break + fi + if false && echo "$line" | grep -q "asyncio REPL task"; then + echo "finished boot!" # this doesnt show + sleep 1 # allow time for top menu bar to animate into view + echo "making screenshot" + import -window "$(xdotool getwindowfocus)" screenshot.png + sleep 1 + echo "screenshot is in:" + readlink -f screenshot.png + echo "stopping emulator..." + pkill -f qemu-system-xtensa + echo "finished!" + date + echo "breaking..." + echo "really breaking..." + break + fi +done +result=$? # 124 means it had a timeout +echo -n "Emulator stopped at " ; date +if [ $result -eq 0 ]; then + echo "Emulator stopped after reaching $maxlines" +elif [ $result -eq 124 ]; then + echo "Emulator did not stop and had to be killed after timeout of $timeout seconds" +else + echo "Emulator had different exit code: $result" +fi + diff --git a/scripts/serial.sh b/scripts/serial.sh index 6f9208027..3bdfbc1c9 100755 --- a/scripts/serial.sh +++ b/scripts/serial.sh @@ -13,6 +13,8 @@ device=$(find /dev/serial/by-id -iname "usb-Espressif_Systems_Espressif_Device*" | tail -n 1) +# also: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 + if [ -z "$device" ]; then echo "could not find device, defaulting to final badge 2026..." device=/dev/serial/by-id/usb-Espressif_Systems_Espressif_Device_9070690094340000-if00 diff --git a/scripts/size.sh b/scripts/size.sh new file mode 100755 index 000000000..5556976e5 --- /dev/null +++ b/scripts/size.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Firmware size drill-down for MicroPythonOS ESP32 builds. +# Parses micropython.elf/.map + frozen .mpy tree + sdkconfig, writes a +# terminal report plus drill-down datafiles for treemap/CSV inspection. +# +# Usage: +# scripts/size.sh [--build-dir ] [--out-dir ] [--label ] +# +# Defaults target the ESP32-S3 usbdisplay build and write to tmp/size-reports/. + +set -u + +mydir=$(readlink -f "$0") +mydir=$(dirname "$mydir") +codebasedir=$(readlink -f "$mydir"/..) + +BUILD_DIR="$codebasedir/lvgl_micropython/lib/micropython/ports/esp32/build-ESP32_GENERIC_S3-SPIRAM_OCT" +OUT_DIR="$codebasedir/tmp/size-reports" +LABEL="size" + +while [ $# -gt 0 ]; do + case "$1" in + --build-dir) BUILD_DIR="$2"; shift 2 ;; + --out-dir) OUT_DIR="$2"; shift 2 ;; + --label) LABEL="$2"; shift 2 ;; + -h|--help) + echo "Usage: $0 [--build-dir ] [--out-dir ] [--label ]" + exit 0 + ;; + *) echo "Unknown arg: $1" >&2; exit 1 ;; + esac +done + +mkdir -p "$OUT_DIR" +python3 "$mydir/size_analyze.py" --build-dir "$BUILD_DIR" --out-dir "$OUT_DIR" --label "$LABEL" diff --git a/scripts/size_analyze.py b/scripts/size_analyze.py new file mode 100755 index 000000000..0307516c8 --- /dev/null +++ b/scripts/size_analyze.py @@ -0,0 +1,766 @@ +#!/usr/bin/env python3 +"""Analyze ESP32 micropython.elf/.map size and emit drill-down reports. + +Reads the linker map, ELF symbols, frozen .mpy tree, sdkconfig and bin +sizes, then writes a terminal report, CSVs, a drill-down JSON tree and a +self-contained HTML treemap into tmp/size-reports/. + +Usage: + python3 scripts/size_analyze.py --build-dir --out-dir --label +""" + +import argparse +import csv +import json +import os +import re +import subprocess +import sys + + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + +FLASH_SECTIONS = { + ".flash.text", + ".flash.rodata", + ".flash.appdesc", + ".iram0.text", + ".iram0.vectors", + ".dram0.data", + ".rtc.text", +} + +RAM_ONLY_SECTIONS = { + ".dram0.bss", + ".dram0.heap_start", + ".noinit", + ".rtc_noinit", + ".rtc.bss", +} + +ALLOC_FLASH_SECTIONS = { + ".flash.text", + ".flash.rodata", + ".flash.appdesc", + ".iram0.text", + ".iram0.vectors", + ".dram0.data", + ".rtc.text", + ".rtc.force_fast", +} + +ALLOC_RAM_SECTIONS = { + ".dram0.data", + ".dram0.bss", + ".iram0.bss", + ".iram0.data", + ".noinit", + ".rtc_noinit", + ".rtc.force_slow", + ".rtc_reserved", + ".dram0.heap_start", +} + +CONTRIB_RE = re.compile( + r"^\s+\.([A-Za-z][A-Za-z0-9_]*)" + r"(?:\.\S+)?" + r"\s+(0x[0-9a-fA-F]+)\s+(0x[0-9a-fA-F]+)\s+(\S.*\S)\s*$" +) + +KNOWN_INPUT_BASES = { + "text", + "literal", + "rodata", + "srodata", + "sdata2", + "data", + "sdata", + "bss", + "common", + "rodata_desc", +} + +RELAX_RE = re.compile(r"^\s+(0x[0-9a-fA-F]+)\s+\(size before relaxing\)\s*$") + +SECTION_HDR_RE = re.compile(r"^(\.\S+)\s+(0x[0-9a-fA-F]+)\s+(0x[0-9a-fA-F]+)\s*$") + +OUTPUT_HDR_ONLY_RE = re.compile(r"^(\.\S+)\s*$") + +ADDR_SIZE_ONLY_RE = re.compile(r"^\s+(0x[0-9a-fA-F]+)\s+(0x[0-9a-fA-F]+)\s*$") + +SECTION_ONLY_RE = re.compile(r"^\s+\.(\S+)\s*$") + +ADDR_SIZE_SRC_RE = re.compile( + r"^\s+(0x[0-9a-fA-F]+)\s+(0x[0-9a-fA-F]+)\s+(\S.*\S)\s*$" +) + +FILL_RE = re.compile(r"^\s+\*fill\*\s+(0x[0-9a-fA-F]+)\s+(0x[0-9a-fA-F]+)\s*$") + +INPUT_SECTIONS = { + "text", + "literal", + "rodata", + "srodata", + "sdata2", + "data", + "sdata", + "bss", + "common", + "rodata_desc", +} + + +def input_base(name): + base = name.split(".")[0] + if base in KNOWN_INPUT_BASES: + return base + if base.startswith("iram") or base.startswith("sram"): + return "text" + if base.startswith("dram"): + return "data" + if base.startswith("rtc"): + return "data" + if base.startswith("wif") and base.endswith("iram"): + return "text" + if base in ("coexiram",): + return "text" + return "" + +ARCHIVE_RE = re.compile(r"^(.*?\.a)\(([^)]+)\)\s*$") + +CMAKE_PREFIX = "CMakeFiles/micropython.elf.dir/" + +SIZE_OPTS = [ + "CONFIG_COMPILER_OPTIMIZATION_SIZE", + "CONFIG_COMPILER_OPTIMIZATION_PERF", + "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_DISABLE", + "CONFIG_BOOTLOADER_LOG_LEVEL", + "CONFIG_LOG_DEFAULT_LEVEL", + "CONFIG_LOG_COLORS", + "CONFIG_BT_ENABLED", + "CONFIG_BT_NIMBLE_ENABLED", + "CONFIG_BT_BLUEDROID_ENABLED", + "CONFIG_BT_CONTROLLER_ENABLED", + "CONFIG_WIFI_ENABLED", + "CONFIG_ESP_WIFI_", + "CONFIG_MBEDTLS_", + "CONFIG_LWIP_", + "CONFIG_FATFS_", + "CONFIG_VFS_", + "CONFIG_FREERTOS_", + "CONFIG_ESPTOOLPY_FLASHSIZE", + "CONFIG_PARTITION_TABLE_CUSTOM_FILENAME", + "CONFIG_SPIRAM_", + "CONFIG_TINYUSB", + "CONFIG_USB_HOST_", +] + + +def find_tool(name): + candidates = [ + os.path.join( + os.path.expanduser("~"), + ".espressif/tools/xtensa-esp-elf/esp-14.2.0_20241119/" + "xtensa-esp-elf/bin", + name, + ), + ] + path_dirs = os.environ.get("PATH", "").split(os.pathsep) + for d in path_dirs: + candidates.append(os.path.join(d, name)) + for c in candidates: + if c and os.path.isfile(c) and os.access(c, os.X_OK): + return c + return name + + +def parse_partitions_size(build_dir): + for name in ("partitions.csv",): + for root, _dirs, files in os.walk(build_dir): + if name in files: + p = os.path.join(root, name) + try: + with open(p) as f: + for line in f: + line = line.strip() + if line.startswith("ota_0,"): + parts = [x.strip() for x in line.split(",")] + if len(parts) >= 5: + return int(parts[4], 16), p + except (OSError, ValueError): + pass + csv_path = os.path.join(REPO, "lvgl_micropython/build/partitions.csv") + try: + with open(csv_path) as f: + for line in f: + line = line.strip() + if line.startswith("ota_0,"): + parts = [x.strip() for x in line.split(",")] + if len(parts) >= 5: + return int(parts[4], 16), csv_path + except OSError: + pass + return 3670016, "default(0x380000)" + + +def index_main_objects(build_dir): + index = {} + main_dir = os.path.join(build_dir, "esp-idf", "main") + for root, _dirs, files in os.walk(main_dir): + for fn in files: + if fn.endswith(".obj"): + full = os.path.join(root, fn) + index.setdefault(fn, full) + return index + + +def classify(source, main_index): + s = source.strip() + if s.startswith("*fill*"): + return ("padding", "alignment-fill") + if s.startswith("*merged-strings*"): + return ("string-merge-pool", "merged-rodata-strings") + m = ARCHIVE_RE.match(s) + if m: + archive, member = m.group(1), m.group(2) + if "/main/libmain.a" in archive or archive.endswith("main/libmain.a"): + if member == "frozen_content.c.obj": + return ("frozen-python", "frozen_content.c") + full = main_index.get(member, "") + if "/lib/micropython/py/" in full: + return ("micropython-core", member) + if "/lib/micropython/extmod/" in full: + return ("micropython-extmod", member) + if "/ports/esp32/" in full or "/ports/" in full: + return ("micropython-port", member) + if "/drivers/" in full: + return ("micropython-drivers", member) + if "/lib/" in full and ("mbedtls" in full or "littlefs" in full or "fatfs" in full): + return ("micropython-lib", member) + return ("micropython-other", member) + if archive.startswith("esp-idf/"): + comp = archive.split("/")[1] if "/" in archive else archive + return ("esp-idf:" + comp, member) + if "libnet80211.a" in archive or "libpp.a" in archive or "libmesh.a" in archive: + return ("esp-idf:esp_wifi-blob", os.path.basename(archive) + ":" + member) + if "libbtdm_app.a" in archive or "libcoexist.a" in archive: + return ("esp-idf:bt-blob", os.path.basename(archive) + ":" + member) + if "libphy.a" in archive: + return ("esp-idf:phy-blob", member) + return ("other-archive", os.path.basename(archive) + ":" + member) + if s.startswith(CMAKE_PREFIX): + rel = s[len(CMAKE_PREFIX):] + if "/lib/lvgl/" in rel: + parts = rel.split("/lib/lvgl/src/") + sub = parts[1].split("/")[0] if len(parts) > 1 else "other" + return ("lvgl:" + sub, rel.split("/")[-1]) + if rel.endswith("lv_mp.c.obj"): + return ("lvgl-bindings", "lv_mp.c") + if "/c_mpos/usb/" in rel: + return ("usermod:usb", rel.split("/")[-1]) + if "/c_mpos/quirc/" in rel: + return ("usermod:quirc", rel.split("/")[-1]) + if "/c_mpos/" in rel: + return ("usermod:c_mpos", rel.split("/")[-1]) + if "secp256k1" in rel: + return ("usermod:secp256k1", rel.split("/")[-1]) + if "micropython-camera-API" in rel or "esp32-camera" in rel: + return ("usermod:camera", rel.split("/")[-1]) + if "esp32-component-rvswd" in rel: + return ("usermod:rvswd", rel.split("/")[-1]) + if "/ext_mod/" in rel: + parts = rel.split("/ext_mod/") + sub = parts[1].split("/")[0] if len(parts) > 1 else "other" + return ("drivers:" + sub, rel.split("/")[-1]) + if "adc_mic" in rel or "espressif__" in rel: + return ("usermod:adc_mic", rel.split("/")[-1]) + return ("other-direct", rel.split("/")[-1]) + if s.startswith("esp-idf/"): + comp = s.split("/")[1] if "/" in s else s + return ("esp-idf:" + comp, s.split("/")[-1]) + return ("other", s.split("/")[-1][:60]) + + +def parse_map(map_path, main_index): + per_object = {} + per_bucket_flash = {} + per_bucket_ram = {} + per_bucket_detail = {} + total_flash_mapped = 0 + total_bss = 0 + in_map = False + current_section = "" + pending_input = "" + pending_output_hdr = "" + in_output_section = False + + def record(source, size, ibase): + nonlocal total_flash_mapped, total_bss + if size == 0: + return + if not ibase: + return + if "=" in source and "0x" in source: + return + bucket, detail = classify(source, main_index) + key = (bucket, detail, current_section, ibase) + per_object[key] = per_object.get(key, 0) + size + if current_section in ALLOC_FLASH_SECTIONS: + per_bucket_flash[bucket] = per_bucket_flash.get(bucket, 0) + size + total_flash_mapped += size + dkey = (bucket, detail) + per_bucket_detail[dkey] = per_bucket_detail.get(dkey, 0) + size + elif current_section in ALLOC_RAM_SECTIONS or ibase == "bss": + per_bucket_ram[bucket] = per_bucket_ram.get(bucket, 0) + size + total_bss += size + + def record_with_relax(source, size, ibase, relax_size): + if relax_size is not None and relax_size < size: + record(source, relax_size, ibase) + else: + record(source, size, ibase) + + with open(map_path, errors="replace") as f: + lines = f.read().splitlines() + n = len(lines) + i = 0 + while i < n: + line = lines[i] + if not in_map: + if "Linker script and memory map" in line: + in_map = True + i += 1 + continue + if line.startswith("OUTPUT(") or line.startswith("ENTRY("): + i += 1 + continue + stripped = line.rstrip("\n") + if not stripped.strip(): + pending_input = "" + pending_output_hdr = "" + i += 1 + continue + if "(size before relaxing)" in stripped: + i += 1 + continue + if pending_output_hdr: + ao = ADDR_SIZE_ONLY_RE.match(stripped) + if ao: + current_section = pending_output_hdr + in_output_section = current_section in ALLOC_FLASH_SECTIONS or ( + current_section in ALLOC_RAM_SECTIONS + ) + pending_output_hdr = "" + pending_input = "" + i += 1 + continue + pending_output_hdr = "" + om = OUTPUT_HDR_ONLY_RE.match(stripped) + if om and not line.startswith(" ") and not line.startswith("\t"): + pending_output_hdr = om.group(1) + pending_input = "" + i += 1 + continue + hm = SECTION_HDR_RE.match(stripped) + if hm and not line.startswith(" "): + current_section = hm.group(1) + in_output_section = current_section in ALLOC_FLASH_SECTIONS or ( + current_section in ALLOC_RAM_SECTIONS + ) + pending_input = "" + pending_output_hdr = "" + i += 1 + continue + if line.startswith(".") and "0x" in line: + tok = line.split() + if len(tok) >= 3 and tok[0].startswith(".") and tok[1].startswith("0x"): + current_section = tok[0] + in_output_section = current_section in ALLOC_FLASH_SECTIONS or ( + current_section in ALLOC_RAM_SECTIONS + ) + pending_input = "" + pending_output_hdr = "" + i += 1 + continue + if not in_output_section: + if stripped.startswith("LOAD "): + pending_input = "" + pending_output_hdr = "" + i += 1 + continue + fm = FILL_RE.match(stripped) + if fm: + size = int(fm.group(2), 16) + if size: + record("*fill*", size, "fill") + pending_input = "" + i += 1 + continue + cm = CONTRIB_RE.match(stripped) + if cm: + size = int(cm.group(3), 16) + source = cm.group(4).strip() + relax_size = None + if i + 1 < n: + rm = RELAX_RE.match(lines[i + 1]) + if rm: + relax_size = int(rm.group(1), 16) + i += 1 + record_with_relax(source, size, input_base(cm.group(1)), relax_size) + pending_input = "" + i += 1 + continue + am = ADDR_SIZE_SRC_RE.match(stripped) + if am and pending_input: + size = int(am.group(2), 16) + source = am.group(3).strip() + if ".obj" in source or ".a(" in source: + relax_size = None + if i + 1 < n: + rm = RELAX_RE.match(lines[i + 1]) + if rm: + relax_size = int(rm.group(1), 16) + i += 1 + record_with_relax(source, size, input_base(pending_input), relax_size) + pending_input = "" + i += 1 + continue + sm = SECTION_ONLY_RE.match(stripped) + if sm: + pending_input = sm.group(1) + i += 1 + continue + if stripped.startswith("LOAD "): + pending_input = "" + i += 1 + return { + "per_object": per_object, + "per_bucket_flash": per_bucket_flash, + "per_bucket_ram": per_bucket_ram, + "per_bucket_detail": per_bucket_detail, + "total_flash_mapped": total_flash_mapped, + "total_bss": total_bss, + } + + +def parse_nm(elf_path): + tool = find_tool("xtensa-esp32s3-elf-nm") + try: + out = subprocess.run( + [tool, "--print-size", "--size-sort", "-t", "d", elf_path], + capture_output=True, + text=True, + timeout=300, + ) + except (OSError, subprocess.SubprocessError) as e: + return [], "nm failed: %s" % e + if out.returncode != 0: + return [], "nm exit %s: %s" % (out.returncode, out.stderr[:500]) + syms = [] + for line in out.stdout.splitlines(): + parts = line.split() + if len(parts) < 4: + continue + try: + addr = int(parts[0], 10) + size = int(parts[1], 10) + except ValueError: + continue + if size <= 0: + continue + typ, name = parts[2], parts[3] + if typ in ("T", "t", "R", "r", "D", "d", "W", "w", "O", "o"): + if 0x3C000000 <= addr < 0x3E000000: + mem = "flash-rodata" + elif 0x42000000 <= addr < 0x44000000: + mem = "flash-text" + elif 0x40370000 <= addr < 0x40400000: + mem = "iram" + elif 0x3FC00000 <= addr < 0x40000000: + mem = "dram" + else: + mem = "other" + syms.append((size, addr, typ, name, mem)) + syms.sort(reverse=True) + return syms, "" + + +def frozen_tree(frozen_dir): + entries = [] + total = 0 + for root, _dirs, files in os.walk(frozen_dir): + for fn in files: + if not fn.endswith(".mpy"): + continue + full = os.path.join(root, fn) + try: + sz = os.path.getsize(full) + except OSError: + continue + rel = os.path.relpath(full, frozen_dir) + entries.append((sz, rel)) + total += sz + entries.sort(reverse=True) + rollup = {} + for sz, rel in entries: + top = rel.split(os.sep)[0] if os.sep in rel else "(top)" + rollup[top] = rollup.get(top, 0) + sz + return entries, rollup, total + + +def section_sizes(elf_path): + tool = find_tool("xtensa-esp32s3-elf-size") + try: + out = subprocess.run([tool, "-A", "-d", elf_path], capture_output=True, text=True, timeout=120) + except (OSError, subprocess.SubprocessError) as e: + return "size failed: %s" % e + if out.returncode != 0: + return "size exit %s" % out.returncode + return out.stdout + + +def sdkconfig_relevant(build_dir): + path = os.path.join(build_dir, "sdkconfig") + found = [] + try: + with open(path, errors="replace") as f: + for line in f: + line = line.strip() + for opt in SIZE_OPTS: + if opt in line: + found.append(line) + break + except OSError as e: + return ["sdkconfig unreadable: %s" % e] + return found + + +def fmt(n): + if n >= 1048576: + return "%d (%0.1f MiB)" % (n, n / 1048576.0) + return "%d (%0.1f KiB)" % (n, n / 1024.0) + + +def build_json_tree(label, bin_size, partition_size, bucket_flash, detail, frozen_entries): + children = [] + buckets = {} + for (bucket, member), size in detail.items(): + buckets.setdefault(bucket, []).append((size, member)) + for bucket in sorted(buckets, key=lambda b: -sum(s for s, _m in buckets[b])): + total = sum(s for s, _m in buckets[bucket]) + members = sorted(buckets[bucket], reverse=True)[:40] + kids = [{"name": m, "size": s} for s, m in members] + children.append({"name": bucket, "size": total, "children": kids}) + fchildren = [{"name": rel, "size": sz} for sz, rel in frozen_entries[:200]] + children.append( + { + "name": "frozen-mpy-files", + "size": sum(s for s, _r in frozen_entries), + "children": fchildren, + } + ) + return { + "name": label, + "bin_size": bin_size, + "partition_size": partition_size, + "headroom": partition_size - bin_size, + "children": children, + "bucket_flash": dict(sorted(bucket_flash.items(), key=lambda kv: -kv[1])), + } + + +TREEMAP_HTML = """ +MPOS size drill-down + +

MPOS firmware size drill-down

+
Click a block to zoom. size-data.json
+
+
+ +""" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--build-dir", required=True) + ap.add_argument("--out-dir", required=True) + ap.add_argument("--label", default="size") + args = ap.parse_args() + + build_dir = os.path.abspath(args.build_dir) + out_dir = os.path.abspath(args.out_dir) + os.makedirs(out_dir, exist_ok=True) + + elf = os.path.join(build_dir, "micropython.elf") + map_path = os.path.join(build_dir, "micropython.map") + bin_path = os.path.join(build_dir, "micropython.bin") + fw_path = os.path.join(build_dir, "firmware.bin") + frozen_dir = os.path.join(build_dir, "frozen_mpy") + for p in (elf, map_path, bin_path): + if not os.path.isfile(p): + print("missing required file: %s" % p, file=sys.stderr) + return 2 + + bin_size = os.path.getsize(bin_path) + fw_size = os.path.getsize(fw_path) if os.path.isfile(fw_path) else 0 + part_size, part_src = parse_partitions_size(build_dir) + main_index = index_main_objects(build_dir) + parsed = parse_map(map_path, main_index) + syms, nm_err = parse_nm(elf) + frozen_entries, frozen_rollup, frozen_total = frozen_tree(frozen_dir) + sections = section_sizes(elf) + sdk_lines = sdkconfig_relevant(build_dir) + + bucket_flash = parsed["per_bucket_flash"] + detail = parsed["per_bucket_detail"] + + with open(os.path.join(out_dir, "objects.csv"), "w", newline="") as f: + w = csv.writer(f) + w.writerow(["bucket", "member", "output_section", "input_section", "bytes"]) + for (bucket, member, osec, isec), size in sorted( + parsed["per_object"].items(), key=lambda kv: -kv[1] + ): + w.writerow([bucket, member, osec, isec, size]) + + with open(os.path.join(out_dir, "symbols.csv"), "w", newline="") as f: + w = csv.writer(f) + w.writerow(["size", "addr", "type", "mem", "name"]) + for size, addr, typ, name, mem in syms[:500]: + w.writerow([size, addr, typ, mem, name]) + + with open(os.path.join(out_dir, "frozen.csv"), "w", newline="") as f: + w = csv.writer(f) + w.writerow(["bytes", "path"]) + for size, rel in frozen_entries: + w.writerow([size, rel]) + + with open(os.path.join(out_dir, "sections.txt"), "w") as f: + f.write(sections) + + with open(os.path.join(out_dir, "sdkconfig-relevant.txt"), "w") as f: + f.write("\n".join(sdk_lines) + "\n") + + tree = build_json_tree(args.label, bin_size, part_size, bucket_flash, detail, frozen_entries) + with open(os.path.join(out_dir, "size-data.json"), "w") as f: + json.dump(tree, f, indent=1) + + with open(os.path.join(out_dir, "size-treemap.html"), "w") as f: + f.write(TREEMAP_HTML) + + flash_mapped = parsed["total_flash_mapped"] + lines = [] + lines.append("label: %s" % args.label) + lines.append("micropython.bin: %s" % fmt(bin_size)) + if fw_size: + lines.append("firmware.bin: %s" % fmt(fw_size)) + lines.append("ota_0 partition: %s (source: %s)" % (fmt(part_size), part_src)) + lines.append("headroom: %s%s" % (fmt(part_size - bin_size), "" if part_size - bin_size >= 0 else " OVERFLOW")) + lines.append("map-attributed flash (text+rodata+data): %s" % fmt(flash_mapped)) + lines.append("") + lines.append("== flash footprint by bucket (from micropython.map) ==") + for bucket, size in sorted(bucket_flash.items(), key=lambda kv: -kv[1])[:30]: + lines.append(" %-28s %s (%5.1f%% of mapped)" % (bucket, fmt(size), 100.0 * size / flash_mapped)) + lines.append("") + lines.append("== frozen .mpy rollup (frozen_mpy/, compiled into frozen_content) ==") + lines.append(" frozen_mpy total: %s in %d files" % (fmt(frozen_total), len(frozen_entries))) + for top, size in sorted(frozen_rollup.items(), key=lambda kv: -kv[1])[:20]: + lines.append(" %-28s %s" % (top, fmt(size))) + lines.append("") + lines.append("== top frozen .mpy files ==") + for size, rel in frozen_entries[:30]: + lines.append(" %-60s %s" % (rel, fmt(size))) + lines.append("") + lines.append("== top flash symbols (nm --size-sort) ==") + if nm_err: + lines.append(" " + nm_err) + shown = 0 + for size, addr, typ, name, mem in syms: + if mem not in ("flash-text", "flash-rodata"): + continue + lines.append(" %-8s %-12s %s" % (fmt(size), mem, name)) + shown += 1 + if shown >= 40: + break + lines.append("") + lines.append("== biggest linked objects (flash) ==") + for (bucket, member), size in sorted(detail.items(), key=lambda kv: -kv[1])[:40]: + lines.append(" %-28s %-45s %s" % (bucket, member, fmt(size))) + report = "\n".join(lines) + "\n" + + with open(os.path.join(out_dir, "size-report.txt"), "w") as f: + f.write(report) + + md = [] + md.append("# Size report: %s" % args.label) + md.append("") + md.append("- micropython.bin: %s" % fmt(bin_size)) + md.append("- ota_0 partition: %s" % fmt(part_size)) + md.append("- headroom: %s" % fmt(part_size - bin_size)) + md.append("") + md.append("## Flash by bucket") + md.append("") + md.append("| bucket | bytes | share |") + md.append("|---|---|---|") + for bucket, size in sorted(bucket_flash.items(), key=lambda kv: -kv[1])[:30]: + md.append("| %s | %d | %0.1f%% |" % (bucket, size, 100.0 * size / flash_mapped)) + md.append("") + md.append("## Frozen rollup") + md.append("") + md.append("| top dir | bytes |") + md.append("|---|---|") + for top, size in sorted(frozen_rollup.items(), key=lambda kv: -kv[1])[:20]: + md.append("| %s | %d |" % (top, size)) + with open(os.path.join(out_dir, "size-report.md"), "w") as f: + f.write("\n".join(md) + "\n") + + print(report) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_on_device.sh b/scripts/test_on_device.sh new file mode 100755 index 000000000..98d6b225e --- /dev/null +++ b/scripts/test_on_device.sh @@ -0,0 +1,8 @@ +before=$(date) +echo "Don't forget to run ./scripts/install.sh if you have local work that you want to deploy and test." +echo "And add --no-install-test-apps to skip installing apps that are needed for the tests, if already installed." +sleep 1 +name=$(date +%Y%m%d%H%M%S) +time ./scripts/test_runner.py --relayport /dev/ttyUSB0 --reset --ondevice --usb-unbind --logserial /tmp/$name-test_on_device-serial.log $@ 2>&1 | tee /tmp/$name-test_on_device-console.log +echo -n "Started at $before until " ; date + diff --git a/scripts/wificonfig.sh b/scripts/wificonfig.sh new file mode 100755 index 000000000..2aab329b0 --- /dev/null +++ b/scripts/wificonfig.sh @@ -0,0 +1,6 @@ +mpremote.py mkdir :/prefs +mpremote.py mkdir :/prefs/com.micropythonos.system.wifiservice +#mpremote.py cp internal_filesystem/prefs/com.micropythonos.system.wifiservice/config.json_works :/prefs/com.micropythonos.system.wifiservice/config.json +#mpremote.py cp internal_filesystem/data/prefs/com.micropythonos.system.wifiservice/config.json.orig :/prefs/com.micropythonos.system.wifiservice/config.json +mpremote.py cp internal_filesystem/data/prefs/com.micropythonos.system.wifiservice/config.json :/prefs/com.micropythonos.system.wifiservice/config.json + diff --git a/tests/manual_test_benchmark_appstore_list_size.py b/tests/manual_test_benchmark_appstore_list_size.py index 5030af2f5..f9992654f 100644 --- a/tests/manual_test_benchmark_appstore_list_size.py +++ b/tests/manual_test_benchmark_appstore_list_size.py @@ -83,7 +83,11 @@ def _wait_pipeline_done(timeout_ms): deadline = time.ticks_add(time.ticks_ms(), timeout_ms) activity = _get_appstore_activity() while time.ticks_diff(deadline, time.ticks_ms()) > 0: - if not activity._icon_queue and not activity._raw_timer: + try: + visible = activity._visible_apps() + except Exception: + visible = [] + if not visible or all(getattr(a, "_icon_stage", None) in ("blurhash", "download") for a in visible): return True lv.task_handler() time.sleep(0.02) @@ -92,11 +96,11 @@ def _wait_pipeline_done(timeout_ms): def _cleanup_apps_list(activity): activity._stop_all_timers() - activity._icon_queue.clear() for app in activity.apps: app.image_icon_widget = None app._icon_dsc = None app._icon_buf = None + app._icon_stage = None if hasattr(activity, "apps_list") and activity.apps_list: activity.apps_list.delete() activity.apps_list = None @@ -175,12 +179,12 @@ def _print_legend(self): print("alloc_start — gc.mem_alloc() BEFORE creating the LVGL list (bytes).") print("free_list — gc.mem_free() AFTER create_apps_list() builds widgets.") print("alloc_list — gc.mem_alloc() AFTER create_apps_list() builds widgets.") - print("free_icons — gc.mem_free() AFTER all raw+blurhash icons are rendered,") + print("free_icons — gc.mem_free() AFTER visible raw+blurhash icons are rendered,") print(" or after 300s timeout (whatever came first).") print("alloc_icons — gc.mem_alloc() AFTER icon pipeline finished/timed out.") print("t_list_ms — wall-clock time for create_apps_list() alone (ms).") print("t_icons_ms — wall-clock time from start of create_apps_list() until") - print(" the icon queue emptied (or 300s timeout). Includes") + print(" the visible icons finished (or 300s timeout). Includes") print(" t_list_ms. Subtract them to get pure icon-render time.") print("icons? — 'yes' = pipeline finished, 'TIMEOUT' = hit 300s limit,") print(" 'no' = pipeline still running after 300s (unlikely).") @@ -195,8 +199,8 @@ def _print_legend(self): print(" total wall-clock per batch = t_icons_ms + ~500ms cleanup + 200ms settle") print() print("Flow per batch:") - print(" 1. generate N App() objects 2. create_apps_list() 3. wait for icon") - print(" pipeline (raw→blurhash per app, one at a time) or 300s timeout") + print(" 1. generate N App() objects 2. create_apps_list() 3. wait for visible") + print(" icons (raw immediately, blurhash capped per 250ms tick) or 300s timeout") print(" 4. scroll test 5. delete all widgets, clear queues, gc") print() diff --git a/tests/test_appstore_async_refresh.py b/tests/test_appstore_async_refresh.py index e8afa8da9..2eb4cd861 100644 --- a/tests/test_appstore_async_refresh.py +++ b/tests/test_appstore_async_refresh.py @@ -147,10 +147,9 @@ def _make_store(self): store._refresh_in_progress = False store.update_all_button = MockLabel() store.main_screen = MockLabel() - store._raw_timer = None - store._blurhash_timer = None - store._icon_queue = [] - store._blurhash_queue = [] + store._icon_timer = None + store._displayed_apps = [] + store._download_in_progress = False store._wip_apps = [] store._has_foreground = True return store diff --git a/tests/test_appstore_list_build.py b/tests/test_appstore_list_build.py new file mode 100644 index 000000000..45dc29044 --- /dev/null +++ b/tests/test_appstore_list_build.py @@ -0,0 +1,172 @@ +""" +test_appstore_list_build.py - Verify the store index builds the visible list +exactly once per refresh (no throwaway incremental widget pass). + +Regression tests for the ~8.8s Phase-2 incremental insert pass that built +one full widget row per new app and then deleted all of them in the +mandatory full rebuild right afterwards: +- download_app_index() must never call _insert_app_list_item. +- self.apps must still end up sorted by AppStore._sort_key. +- create_apps_list() must still run once for Phase 1 and once for Phase 2. + +Usage: + python3 scripts/test_runner.py tests/test_appstore_list_build.py +""" + +import json +import unittest +import sys + +sys.path.insert(0, "builtin/apps/com.micropythonos.appstore") + + +class MockLabel: + """Minimal stand-in for an lv.label.""" + + def __init__(self): + self._text = "" + self._flags = set() + + def set_text(self, text): + self._text = text + + def add_flag(self, flag): + self._flags.add(flag) + + def remove_flag(self, flag): + self._flags.discard(flag) + + def has_flag(self, flag): + return flag in self._flags + + +class MockPrefs: + """Minimal SharedPreferences stand-in.""" + + def __init__(self): + self._data = {} + + def get_string(self, key, default=None): + return self._data.get(key, default) + + def edit(self): + return self + + def put_string(self, key, value): + self._data[key] = value + return self + + def commit(self): + pass + + +def _index_entry(slug, name): + return { + "slug": slug, + "name": name, + "description": "desc %s" % name, + "version": "1.0", + "categories": ["Tools"], + } + + +class TestAppStoreSingleListBuild(unittest.TestCase): + """Phase 2 must sort apps in memory and build widgets exactly once.""" + + def _make_store(self): + from appstore import AppStore + + store = AppStore() + store.prefs = MockPrefs() + store.please_wait_label = MockLabel() + store.update_all_button = MockLabel() + store.main_screen = MockLabel() + store._refresh_in_progress = False + store._data_loaded = False + store._wip_apps = [] + store._has_foreground = True + store._hide_wip = True + store._selected_category = None + store._builtin_fullnames = set() + store.apps = [] + self.build_calls = [] + orig_build = store.create_apps_list + + def _counting_build(): + self.build_calls.append(1) + + store.create_apps_list = _counting_build + self._orig_build = orig_build + return store + + def _run_download(self, store, entries): + import asyncio + from mpos import App, AppManager + import mpos.net.download_manager as dm + + # NOTE: _app_list must stay non-empty, otherwise get_app_list() + # refreshes it from disk. The installed app is also present in the + # index entries, so it is patched (not counted as new). + installed = App("ExistingApp", "Pub", "desc", "", "", "", + "com.test.existing", "1.0") + orig_apps = AppManager._app_list + AppManager._app_list = [installed] + entries = [_index_entry("com.test.existing", "ExistingApp")] + entries + + json_data = json.dumps(entries) + + async def _fake_download(url): + return json_data + + orig_dl = dm.DownloadManager.download_url + dm.DownloadManager.download_url = staticmethod(_fake_download) + try: + loop = asyncio.get_event_loop() + loop.run_until_complete( + store.download_app_index("http://example.com/index.json") + ) + finally: + dm.DownloadManager.download_url = orig_dl + AppManager._app_list = orig_apps + + def test_phase2_does_not_insert_widgets_incrementally(self): + """No per-app widget insertion may happen during the index merge.""" + store = self._make_store() + insert_calls = [] + meth = getattr(store, "_insert_app_list_item", None) + if meth is not None: + def _counting_insert(app, index): + insert_calls.append(app.fullname) + store._insert_app_list_item = _counting_insert + self._run_download(store, [ + _index_entry("com.test.zulu", "Zulu"), + _index_entry("com.test.alpha", "alpha"), + _index_entry("com.test.mike", "mike"), + ]) + self.assertEqual(insert_calls, []) + self.assertEqual(len(store.apps), 4) + + def test_phase2_apps_end_up_sorted(self): + """Memory-only merge must preserve the sorted order guarantee.""" + store = self._make_store() + self._run_download(store, [ + _index_entry("com.test.zulu", "Zulu"), + _index_entry("com.test.bang", "!Bang"), + _index_entry("com.test.alpha", "alpha"), + ]) + names = [a.name for a in store.apps] + expected = sorted(names, key=store._sort_key) + self.assertEqual(names, expected) + + def test_phase2_rebuilds_list_exactly_once(self): + """Phase 1 + Phase 2 final rebuild: exactly two list builds total.""" + store = self._make_store() + self._run_download(store, [ + _index_entry("com.test.zulu", "Zulu"), + _index_entry("com.test.alpha", "alpha"), + ]) + self.assertEqual(len(self.build_calls), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_appstore_update_flow.py b/tests/test_appstore_update_flow.py index 84bcb6192..73a0eb971 100644 --- a/tests/test_appstore_update_flow.py +++ b/tests/test_appstore_update_flow.py @@ -1281,8 +1281,8 @@ def _make_store(self): store._wip_apps = [] store._builtin_fullnames = set() store.category_dropdown = None - store._raw_timer = None - store._icon_queue = [] + store._icon_timer = None + store._displayed_apps = [] store._download_in_progress = False store._icon_pipeline = "none" store._has_foreground = True @@ -1363,6 +1363,337 @@ def test_on_resume_syncs_with_auto_check(self): self.assertFalse(store.update_all_button.has_flag(HIDDEN_FLAG)) +# --------------------------------------------------------------------------- +# Icon pipeline toggle: returning from settings must refresh the list both ways +# --------------------------------------------------------------------------- + +class TestAppStoreIconPipelineResume(unittest.TestCase): + """Switching App List Icons Blocky->None in settings then backing out to + the list left stale icon rows until restart: onResume only rebuilt for + the None->icons direction. It must rebuild for icons->None too.""" + + def setUp(self): + import asyncio + import appstore_core + + asyncio.new_event_loop() + + self._orig_aum = appstore_core.AppUpdateManager + appstore_core.AppUpdateManager = _MockAUM + _MockAUM.reset_instance() + + def tearDown(self): + import appstore_core + appstore_core.AppUpdateManager = self._orig_aum + _MockAUM.reset_instance() + + def _make_store(self, pipeline, widgets): + from appstore import AppStore + from mpos import App + store = AppStore() + store.prefs = MockPrefs(None) + store.please_wait_label = MockLabel() + store._refresh_in_progress = False + store._data_loaded = True + store.update_all_button = MockLabel() + store.update_all_label = MockLabel() + store.main_screen = MockLabel() + store._update_labels = {} + store._wip_apps = [] + store._builtin_fullnames = set() + store.category_dropdown = None + store._icon_timer = None + store._displayed_apps = [] + store._download_in_progress = False + store._has_foreground = True + store._icon_pipeline = pipeline + store.apps_list = MockLabel() + store._sync_update_banner = lambda *a: None + store.apps = [] + for i, widget in enumerate(widgets): + app = App("App%d" % i, "Pub", "desc", "", "", "", "com.test.app%d" % i, "1.0") + app.image_icon_widget = widget + store.apps.append(app) + return store + + def _resume(self, store): + rebuilds = [] + store.create_apps_list = lambda: rebuilds.append(True) + store.onResume(MockLabel()) + return rebuilds + + def test_resume_rebuilds_when_icons_disabled_with_stale_widgets(self): + store = self._make_store("none", [object(), object()]) + rebuilds = self._resume(store) + self.assertEqual(len(rebuilds), 1, + "Blocky->None must rebuild the list to drop icon slots") + + def test_resume_skips_rebuild_when_already_iconless(self): + store = self._make_store("none", [None, None]) + rebuilds = self._resume(store) + self.assertEqual(len(rebuilds), 0, + "no rebuild when rows already have no icon slots") + + def test_resume_rebuilds_when_icons_enabled_without_slots(self): + store = self._make_store("blurhash", [None, None]) + rebuilds = self._resume(store) + self.assertEqual(len(rebuilds), 1, + "None->Blocky must still rebuild the list with icon slots") + + +# --------------------------------------------------------------------------- +# Viewport icon loader: only visible rows get icon work, for every pipeline +# --------------------------------------------------------------------------- + +class _MockIconWidget: + def __init__(self): + self.src_count = 0 + + def set_src(self, src): + self.src_count += 1 + + def set_scale(self, scale): + pass + + +class _MockRow: + def __init__(self, y, h): + self._y = y + self._h = h + + def get_y(self): + return self._y + + def get_height(self): + return self._h + + +class _MockAppsList: + def __init__(self, count, row_h=64, list_h=320, scroll_y=0): + self._rows = [_MockRow(i * row_h, row_h) for i in range(count)] + self._list_h = list_h + self._scroll_y = scroll_y + + def update_layout(self): + pass + + def get_scroll_y(self): + return self._scroll_y + + def get_height(self): + return self._list_h + + def get_child_count(self): + return len(self._rows) + + def get_child(self, i): + return self._rows[i] + + +class _FakeScrollEvent: + def __init__(self, code): + self._code = code + + def get_code(self): + return self._code + + +class TestAppStoreViewportIconLoader(unittest.TestCase): + _HASH = "UBMOZfK1GG%LBBNG,;Rj2skq=eE1s9n4S5Na" + + def setUp(self): + import asyncio + asyncio.new_event_loop() + + def _make_store(self, pipeline, n, scroll_y=0, **app_kwargs): + from appstore import AppStore + from mpos import App + store = AppStore() + store._icon_pipeline = pipeline + store._icon_timer = None + store._displayed_apps = [] + store._download_in_progress = False + store._has_foreground = True + store.apps_list = _MockAppsList(n, scroll_y=scroll_y) + store.apps = [] + for i in range(n): + app = App("App%d" % i, "Pub", "desc", "", app_kwargs.get("icon_url", ""), "", + "com.test.app%d" % i, "1.0", + blur_hash=app_kwargs.get("blur_hash"), + icon_data=app_kwargs.get("icon_data")) + app.image_icon_widget = _MockIconWidget() + if "stage" in app_kwargs: + app._icon_stage = app_kwargs["stage"] + store.apps.append(app) + store._displayed_apps = list(store.apps) + return store + + def _stages(self, store): + return [getattr(a, "_icon_stage", None) for a in store.apps] + + def test_raw_pipeline_loads_visible_plus_margin_only(self): + store = self._make_store("raw", 20) + store._load_viewport_icons(None) + stages = self._stages(store) + self.assertEqual(stages[:4], ["raw"] * 4, + "first tick fills up to the per-tick raw cap") + self.assertEqual(stages[4:], [None] * 16, + "far off-screen rows must not burn icon work") + store._load_viewport_icons(None) + store._load_viewport_icons(None) + stages = self._stages(store) + self.assertEqual(stages[:10], ["raw"] * 10, + "remaining visible rows fill in on the next quiet ticks") + self.assertEqual(stages[10:], [None] * 10) + + def test_scrolling_skips_ticks_then_loads_new_rows(self): + store = self._make_store("raw", 20) + for _ in range(3): + store._load_viewport_icons(None) + self.assertEqual(self._stages(store)[:10], ["raw"] * 10) + store.apps_list._scroll_y = 640 + store._load_viewport_icons(None) + self.assertEqual(self._stages(store)[10:], [None] * 10, + "the tick that observes motion must do no icon work") + for _ in range(8): + store._load_viewport_icons(None) + self.assertTrue(all(s == "raw" for s in self._stages(store)), + "after scrolling stops, new rows fill in") + + def test_scroll_hold_freezes_loader(self): + import lvgl as lv + store = self._make_store("raw", 20) + store._load_viewport_icons(None) + self.assertEqual(self._stages(store)[:4], ["raw"] * 4) + store._on_list_scroll(_FakeScrollEvent(lv.EVENT.SCROLL_BEGIN)) + for i in range(1, 5): + store.apps_list._scroll_y = i * 64 + store._load_viewport_icons(None) + self.assertEqual(self._stages(store)[:4], ["raw"] * 4) + self.assertEqual(self._stages(store)[4:], [None] * 16, + "no icon work while scrolling is held or moving") + store._on_list_scroll(_FakeScrollEvent(lv.EVENT.SCROLL_END)) + store._load_viewport_icons(None) + store._load_viewport_icons(None) + self.assertEqual(self._stages(store)[4:], [None] * 16, + "settle ticks after scroll end still do no work") + store._load_viewport_icons(None) + self.assertEqual(self._stages(store)[:8], ["raw"] * 8, + "work resumes once scrolling settles") + + def test_missed_scroll_end_auto_recovers(self): + import lvgl as lv + store = self._make_store("raw", 20) + store._last_scroll_y = 0 + store._stable_ticks = store._SETTLE_TICKS + store._on_list_scroll(_FakeScrollEvent(lv.EVENT.SCROLL_BEGIN)) + for _ in range(4): + store._load_viewport_icons(None) + self.assertEqual(self._stages(store)[:4], ["raw"] * 4, + "a stale hold must clear once scrolling is stable") + + def test_scroll_hold_blocks_download_kick(self): + import lvgl as lv + import mpos + store = self._make_store("download", 20, icon_url="http://x/a.mpk", stage="raw") + store._last_scroll_y = 0 + store._stable_ticks = store._SETTLE_TICKS + store._on_list_scroll(_FakeScrollEvent(lv.EVENT.SCROLL_BEGIN)) + tasks = [] + orig_create_task = mpos.TaskManager.create_task + mpos.TaskManager.create_task = lambda coro: tasks.append(coro) + try: + store._load_viewport_icons(None) + self.assertEqual(tasks, []) + self.assertFalse(store._download_in_progress) + finally: + mpos.TaskManager.create_task = orig_create_task + + def test_blurhash_capped_per_tick(self): + store = self._make_store("blurhash", 20, blur_hash=self._HASH, stage="raw") + store._load_viewport_icons(None) + self.assertEqual(self._stages(store).count("blurhash"), 1, + "only one blurhash decode per tick") + store._load_viewport_icons(None) + self.assertEqual(self._stages(store).count("blurhash"), 2, + "next quiet tick advances the next visible row") + + def test_download_kicked_once_for_visible(self): + import mpos + store = self._make_store("download", 20, icon_url="http://x/a.mpk", stage="raw") + tasks = [] + orig_create_task = mpos.TaskManager.create_task + mpos.TaskManager.create_task = lambda coro: tasks.append(coro) + try: + store._load_viewport_icons(None) + self.assertEqual(len(tasks), 1, + "one download kicked for the first visible row needing it") + self.assertTrue(store._download_in_progress) + store._stable_ticks = store._SETTLE_TICKS + store._load_viewport_icons(None) + self.assertEqual(len(tasks), 1, + "no second download while one is in flight") + finally: + mpos.TaskManager.create_task = orig_create_task + + def test_cached_icons_restored_without_regeneration(self): + store = self._make_store("raw", 3) + restored = [] + raws = [] + store._restore_cached_icon = lambda app, widget: restored.append(app.fullname) or True + store._set_raw_icon = lambda app: raws.append(app.fullname) + store._load_viewport_icons(None) + self.assertEqual(len(restored), 3) + self.assertEqual(raws, [], + "restored rows must not regenerate raw icons") + + def test_finished_and_widgetless_rows_skipped(self): + store = self._make_store("blurhash", 3, blur_hash=self._HASH, stage="blurhash") + store.apps[1].image_icon_widget = None + calls = [] + store._set_raw_icon = lambda app: calls.append(app) + store._set_icon_widget = lambda app: calls.append(app) + store._load_viewport_icons(None) + self.assertEqual(calls, [], + "finished rows and rows without widgets need no work") + + def test_broken_geometry_falls_back_to_all(self): + store = self._make_store("raw", 4) + store.apps_list = MockLabel() + store._load_viewport_icons(None) + self.assertEqual(self._stages(store), ["raw"] * 4, + "geometry failure must degrade to loading everything") + + def test_timer_lifecycle(self): + store = self._make_store("raw", 2) + created = [] + deleted = [] + + class _FakeTimer: + def delete(self): + deleted.append(True) + + store._create_timer = lambda cb, ms: created.append(ms) or _FakeTimer() + store._start_icon_timer() + self.assertEqual(created, [store._ICON_TICK_MS]) + self.assertIsNotNone(store._icon_timer) + self.assertEqual(self._stages(store), ["raw"] * 2, + "starting the timer also does one immediate pass") + store._stop_all_timers() + self.assertEqual(len(deleted), 1) + self.assertIsNone(store._icon_timer) + + def test_no_timer_when_icons_disabled(self): + store = self._make_store("none", 2) + created = [] + store._create_timer = lambda cb, ms: created.append(ms) + self.assertIsNone(store._icon_timer) + store._start_icon_timer() + self.assertEqual(created, [], + "no loader timer when icons are disabled") + self.assertIsNone(store._icon_timer) + + # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- diff --git a/tests/test_graphical_appstore_category_filter.py b/tests/test_graphical_appstore_category_filter.py index 6dc53a811..56efdd727 100644 --- a/tests/test_graphical_appstore_category_filter.py +++ b/tests/test_graphical_appstore_category_filter.py @@ -327,9 +327,10 @@ def test_updates_category_filters_correctly(self): "Should reset _selected_category to None") def test_installed_filter_does_not_leak_remote_apps_during_phase2(self): - """When _selected_category is 'Installed', _insert_app_list_item for an - uninstalled app must not make it visible. Phase 2 inserts remote-only - apps — they should stay hidden under the 'Installed' filter.""" + """When _selected_category is 'Installed', a remote-only app merged + during Phase 2 must not become visible. The merge only touches + self.apps in memory; visibility is decided by the Phase 2 full + rebuild, which skips uninstalled apps under 'Installed'.""" AppManager.start_app("com.micropythonos.appstore") wait_for_render(iterations=40) activity = _get_appstore_activity() @@ -362,10 +363,26 @@ def test_installed_filter_does_not_leak_remote_apps_during_phase2(self): "Remote app should not be visible under 'Installed' filter", ) - activity.apps.insert(1, remote_app) - activity._insert_app_list_item(remote_app, 1) + # Simulate the Phase 2 memory merge (sorted, no widgets built). + activity.apps.extend([remote_app]) + keyed = [(activity._sort_key(a.name), a) for a in activity.apps] + keyed.sort(key=lambda t: t[0]) + activity.apps = [a for _, a in keyed] wait_for_render(iterations=10) + self.assertIsNone( + find_label_with_text(lv.screen_active(), "RemoteApp"), + "Remote app leaked into 'Installed' view before rebuild", + ) + + # Simulate the Phase 2 final rebuild. + activity.create_apps_list() + wait_for_render(iterations=10) + + self.assertIsNotNone( + find_label_with_text(lv.screen_active(), "InstalledApp"), + "Installed app should stay visible after rebuild", + ) self.assertIsNone( find_label_with_text(lv.screen_active(), "RemoteApp"), "Remote app leaked into 'Installed' view", diff --git a/tests/test_graphical_appstore_row_tap.py b/tests/test_graphical_appstore_row_tap.py new file mode 100644 index 000000000..83376ba89 --- /dev/null +++ b/tests/test_graphical_appstore_row_tap.py @@ -0,0 +1,93 @@ +""" +test_graphical_appstore_row_tap.py - Verify taps anywhere on a store list row +open the app detail screen. + +Each row registers a single CLICKED handler on the row item; taps on +non-clickable children (name/description labels) must fall through to it +through LVGL's normal input handling. + +Usage: + python3 scripts/test_runner.py tests/test_graphical_appstore_row_tap.py +""" + +import unittest + +import mpos +import mpos.ui + +from mpos import App, AppManager +from mpos.ui.testing import wait_for_render, simulate_click, get_widget_coords + + +def _get_activity(): + activity, _, _, _ = mpos.ui.screen_stack[-1] + return activity + + +def _make_app(i, name): + return App( + name, + "TapTest", + "Tap description %d" % i, + "Long description for tap test app number %d." % i, + None, + None, + "com.taptap.app%d" % i, + "1.0", + "Tools", + [], + ) + + +class TestAppStoreRowTap(unittest.TestCase): + + def setUp(self): + result = AppManager.start_app("com.micropythonos.appstore") + self.assertTrue(result, "AppStore failed to launch") + wait_for_render(40) + self.activity = _get_activity() + self.assertIsNotNone(self.activity, "Could not get AppStore activity") + self.activity._icon_pipeline = "none" + self.activity._selected_category = None + self.activity.apps = [_make_app(0, "Zulu App"), _make_app(1, "Alpha App")] + self.activity.create_apps_list() + wait_for_render(20) + + def tearDown(self): + try: + while type(_get_activity()).__name__ == "AppDetail": + mpos.ui.back_screen() + wait_for_render(20) + finally: + mpos.ui.back_screen() + wait_for_render(20) + + def _row_parts(self, row_index): + item = self.activity.apps_list.get_child(row_index) + label_cont = item.get_child(item.get_child_count() - 1) + name_row = label_cont.get_child(0) + desc_label = label_cont.get_child(1) + name_label = name_row.get_child(0) + return item, name_label, desc_label + + def _click_center(self, obj): + coords = get_widget_coords(obj) + self.assertIsNotNone(coords, "widget has no coordinates") + simulate_click(coords["center_x"], coords["center_y"]) + wait_for_render(30) + + def test_tap_description_label_opens_detail(self): + _, _, desc = self._row_parts(0) + self.assertEqual(desc.get_text(), "Tap description 0") + self._click_center(desc) + self.assertEqual(type(_get_activity()).__name__, "AppDetail") + + def test_tap_name_label_opens_detail(self): + _, name_label, _ = self._row_parts(1) + self.assertEqual(name_label.get_text(), "Alpha App") + self._click_center(name_label) + self.assertEqual(type(_get_activity()).__name__, "AppDetail") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_graphical_infinite_list_dynamic.py b/tests/test_graphical_infinite_list_dynamic.py new file mode 100644 index 000000000..291817272 --- /dev/null +++ b/tests/test_graphical_infinite_list_dynamic.py @@ -0,0 +1,80 @@ +""" +test_graphical_infinite_list_dynamic.py - Verify InfiniteList sizes its +initial render window dynamically from the container height. + +- A taller container renders more rows than a shorter one (same items). +- The rendered window always covers the viewport (content overflows it). +- Few items still render fully; empty still renders nothing. + +Usage: + python3 scripts/test_runner.py tests/test_graphical_infinite_list_dynamic.py +""" + +import sys +import unittest + +sys.path.insert(0, ".") + +import lvgl as lv +from mpos.ui.testing import GraphicalTestCase +from mpos.ui.infinite_list import InfiniteList + + +def _make_items(count): + return [("rom_%04d.wad" % i,) for i in range(count)] + + +def _render_row(container, idx, item): + row = lv.obj(container) + row.set_flex_flow(lv.FLEX_FLOW.ROW) + row.set_size(lv.pct(100), lv.SIZE_CONTENT) + label = lv.label(row) + label.set_text(item[0]) + return row + + +class TestInfiniteListDynamicWindow(GraphicalTestCase): + + def _make_list(self, height_pct, item_count=5000): + lst = InfiniteList(self.screen) + lst.set_size(lv.pct(100), lv.pct(height_pct)) + lst.center() + lst.set_data(_make_items(item_count), _render_row) + self.wait_for_render(10) + return lst + + def test_taller_container_renders_more_rows(self): + short = self._make_list(30) + tall = self._make_list(90) + self.assertTrue( + tall.rendered_count > short.rendered_count, + "tall=%d should render more than short=%d" + % (tall.rendered_count, short.rendered_count), + ) + for lst in (short, tall): + self.assertTrue(lst.rendered_count < 60) + first, _ = lst.rendered_range + self.assertEqual(first, 0) + + def test_initial_window_covers_viewport(self): + lst = self._make_list(70) + self.assertTrue( + lst.obj.get_scroll_bottom() > 0, + "rendered content should overflow the viewport", + ) + self.assertTextPresent("rom_0000.wad") + + def test_few_items_still_all_rendered(self): + lst = self._make_list(70, item_count=4) + self.assertEqual(lst.rendered_count, 4) + + def test_empty_still_renders_nothing(self): + lst = InfiniteList(self.screen) + lst.set_size(lv.pct(100), lv.pct(70)) + lst.set_data([], lambda c, i, item: None) + self.wait_for_render(10) + self.assertEqual(lst.rendered_count, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_graphical_input_activity_selected_callback.py b/tests/test_graphical_input_activity_selected_callback.py new file mode 100644 index 000000000..b54f70093 --- /dev/null +++ b/tests/test_graphical_input_activity_selected_callback.py @@ -0,0 +1,118 @@ +""" +Graphical test for InputActivity's optional `selected_callback`. + +A setting dict may carry `selected_callback`; InputActivity calls it with the +option VALUE on every radio pick before Save: on a new selection, and again +on a re-tap of the already-selected option (a preview hook wants to fire +again). It must not fire when allow_deselect un-checks the active option +(nothing is selected then), exceptions inside it must be swallowed, and +fixtures/instances without the attribute must keep working unchanged. + +Same fixture technique as test_graphical_setting_activity_radio.py: the +unbound handler is run against a small object exposing the attributes it +reads on `self`, avoiding a full Activity/AppManager. + +Usage: + python3 scripts/test_runner.py tests/test_graphical_input_activity_selected_callback.py +""" + +import unittest +import lvgl as lv + +from mpos.ui.input_activity import InputActivity +from mpos import wait_for_render + +OPTIONS = [("Off", "off"), ("Pig Oink", "oink"), ("Pig Squeal", "squeal")] + + +class _FakeEvent: + def __init__(self, target): + self._target = target + def get_target_obj(self): + return self._target + + +class _Fixture: + def __init__(self, container, active_index, callback=None, options=OPTIONS, allow_deselect=False): + self.radio_container = container + self.active_radio_index = active_index + self._radio_allow_deselect = allow_deselect + if callback is not None: + self._selected_callback = callback + self._ui_options = options + + +class TestSelectedCallback(unittest.TestCase): + def setUp(self): + self.screen = lv.obj() + self.screen.set_size(320, 240) + lv.screen_load(self.screen) + self.container = lv.obj(self.screen) + self.container.set_flex_flow(lv.FLEX_FLOW.COLUMN) + self.cbs = [] + for label, _ in OPTIONS: + cb = lv.checkbox(self.container) + cb.set_text(label) + self.cbs.append(cb) + wait_for_render(2) + + def tearDown(self): + lv.screen_load(lv.obj()) + wait_for_render(2) + + def _tap(self, fixture, index, check): + # Emulate LVGL: a tap toggles the checkbox state, then the handler runs. + if check: + self.cbs[index].add_state(lv.STATE.CHECKED) + else: + self.cbs[index].remove_state(lv.STATE.CHECKED) + InputActivity.radio_event_handler(fixture, _FakeEvent(self.cbs[index])) + + def test_new_selection_fires_with_value(self): + seen = [] + f = _Fixture(self.container, -1, seen.append) + self._tap(f, 1, check=True) + self.assertEqual(seen, ["oink"]) + self.assertEqual(f.active_radio_index, 1) + + def test_switching_option_fires_new_value_only(self): + seen = [] + self.cbs[1].add_state(lv.STATE.CHECKED) + f = _Fixture(self.container, 1, seen.append) + self._tap(f, 2, check=True) + self.assertEqual(seen, ["squeal"]) + self.assertFalse(self.cbs[1].has_state(lv.STATE.CHECKED)) + self.assertTrue(self.cbs[2].has_state(lv.STATE.CHECKED)) + + def test_retap_of_active_option_fires_again_and_stays_checked(self): + seen = [] + self.cbs[1].add_state(lv.STATE.CHECKED) + f = _Fixture(self.container, 1, seen.append) + self._tap(f, 1, check=False) # LVGL would un-check on the tap + self.assertEqual(seen, ["oink"]) + self.assertTrue(self.cbs[1].has_state(lv.STATE.CHECKED)) + self.assertEqual(f.active_radio_index, 1) + + def test_allow_deselect_uncheck_does_not_fire(self): + seen = [] + self.cbs[1].add_state(lv.STATE.CHECKED) + f = _Fixture(self.container, 1, seen.append, allow_deselect=True) + self._tap(f, 1, check=False) + self.assertEqual(seen, []) + self.assertEqual(f.active_radio_index, -1) + + def test_callback_exception_is_swallowed(self): + def boom(v): + raise RuntimeError("preview failed") + f = _Fixture(self.container, -1, boom) + self._tap(f, 2, check=True) # must not raise + self.assertEqual(f.active_radio_index, 2) + + def test_no_callback_attribute_is_fine(self): + f = _Fixture(self.container, -1) # no _selected_callback / _ui_options + self._tap(f, 0, check=True) + self.assertEqual(f.active_radio_index, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_graphical_simulate_long_press.py b/tests/test_graphical_simulate_long_press.py index 0b9bb1dc4..98aff99da 100644 --- a/tests/test_graphical_simulate_long_press.py +++ b/tests/test_graphical_simulate_long_press.py @@ -15,6 +15,11 @@ from mpos import simulate_click, simulate_long_press, wait_for_render +_LONG_PRESS_TIME_DEFAULT = 400 +_LONG_PRESS_TIME_SHORT_CLICK = 3000 +_LONG_PRESS_TIME_LONG_CLICK = 150 + + class TestGraphicalSimulateLongPress(unittest.TestCase): def setUp(self): @@ -42,6 +47,19 @@ def _button_center(self): self.button.get_coords(area) return (area.x1 + area.x2) // 2, (area.y1 + area.y2) // 2 + def _set_long_press_time(self, ms): + """Set the simulated touch indev's long-press time and return the indev. + + Also resets long-press state so a previous test cannot pollute this one. + Caller must restore the default with set_long_press_time(400). + """ + import mpos.ui.testing as _testing + _testing._ensure_touch_indev() + indev = _testing._touch_indev + indev.reset_long_press() + indev.set_long_press_time(ms) + return indev + def _wait_until(self, predicate, timeout_ms=2000): """Poll wait_for_render() until predicate() is true or timeout expires.""" deadline = time.ticks_add(time.ticks_ms(), timeout_ms) @@ -52,35 +70,47 @@ def _wait_until(self, predicate, timeout_ms=2000): return False def test_long_press_fires_long_pressed(self): - x, y = self._button_center() - simulate_long_press(x, y) - found = self._wait_until( - lambda: "long_pressed" in self.events, - timeout_ms=2000, - ) - self.assertTrue( - found, - "simulate_long_press did not deliver LONG_PRESSED, got: %s" % self.events, - ) + # Lower the threshold so a real 1000ms long press fires quickly and + # deterministically, then restore the default afterwards. + indev = self._set_long_press_time(_LONG_PRESS_TIME_LONG_CLICK) + try: + x, y = self._button_center() + simulate_long_press(x, y) + found = self._wait_until( + lambda: "long_pressed" in self.events, + timeout_ms=2000, + ) + self.assertTrue( + found, + "simulate_long_press did not deliver LONG_PRESSED, got: %s" % self.events, + ) + finally: + indev.set_long_press_time(_LONG_PRESS_TIME_DEFAULT) def test_short_click_does_not_fire_long_pressed(self): - x, y = self._button_center() - # Use a very short press duration so that even severe OS sleep jitter on - # slow CI runners cannot accidentally cross LVGL's long-press threshold. - simulate_click(x, y, press_duration_ms=20) - # On slow systems SHORT_CLICKED may take a few frames to arrive. - found_short = self._wait_until( - lambda: "short_clicked" in self.events, - timeout_ms=1000, - ) - self.assertTrue( - "long_pressed" not in self.events, - "short click unexpectedly delivered LONG_PRESSED, got: %s" % self.events, - ) - self.assertTrue( - found_short, - "short click did not deliver SHORT_CLICKED, got: %s" % self.events, - ) + # Raise the long-press threshold so that scheduling jitter on slow CI + # runners cannot accidentally turn a 20ms short click into a long press. + # LVGL decides long press by wall-clock time, not CPU time, so limiting + # the intended press duration alone is not enough on a loaded machine. + indev = self._set_long_press_time(_LONG_PRESS_TIME_SHORT_CLICK) + try: + x, y = self._button_center() + simulate_click(x, y, press_duration_ms=20) + # On slow systems SHORT_CLICKED may take a few frames to arrive. + found_short = self._wait_until( + lambda: "short_clicked" in self.events, + timeout_ms=1000, + ) + self.assertTrue( + "long_pressed" not in self.events, + "short click unexpectedly delivered LONG_PRESSED, got: %s" % self.events, + ) + self.assertTrue( + found_short, + "short click did not deliver SHORT_CLICKED, got: %s" % self.events, + ) + finally: + indev.set_long_press_time(_LONG_PRESS_TIME_DEFAULT) if __name__ == "__main__": diff --git a/tests/test_graphical_topmenu_bar.py b/tests/test_graphical_topmenu_bar.py index e6e841086..781f929e7 100644 --- a/tests/test_graphical_topmenu_bar.py +++ b/tests/test_graphical_topmenu_bar.py @@ -8,6 +8,7 @@ - Bar widgets (clock, wifi, bell) are present after open """ +import sys import time import unittest @@ -21,6 +22,11 @@ ) from mpos.ui import topmenu +sys.path.append("tests") +from _mpos_device_unittest import patch_unittest_main + +patch_unittest_main() + # --------------------------------------------------------------------------- # Helpers shared across test cases diff --git a/tests/test_graphical_usb_mouse.py b/tests/test_graphical_usb_mouse.py new file mode 100644 index 000000000..57e8c38e1 --- /dev/null +++ b/tests/test_graphical_usb_mouse.py @@ -0,0 +1,148 @@ +import lvgl as lv + +from drivers.indev.usb_hid import _CURSOR_H, _CURSOR_W, _cursor_map, FakeHIDSource, USBMouse +from mpos.ui.appearance_manager import AppearanceManager +from mpos.ui.testing import GraphicalTestCase + + +def _logical_to_physical(x, y): + """Map display-logical coords to the physical coords USBMouse reports. + + LVGL rotates every indev point by the display rotation before + hit-testing (indev_pointer_proc), so pointer drivers report + physical-panel coords; mirrors _touch_read_cb in mpos.ui.testing. + """ + disp = lv.display_get_default() + rot = disp.get_rotation() if disp else 0 + if rot == 3: + return disp.get_vertical_resolution() - 1 - y, x + if rot == 1: + return y, disp.get_horizontal_resolution() - 1 - x + if rot == 2: + return (disp.get_horizontal_resolution() - 1 - x, + disp.get_vertical_resolution() - 1 - y) + return x, y + + +class TestUSBMouse(GraphicalTestCase): + def setUp(self): + super().setUp() + self.source = FakeHIDSource() + self.mouse = USBMouse(source=self.source) + self.addCleanup(self.mouse.delete) + + def test_starts_at_display_center(self): + self.assertEqual( + (self.mouse._x, self.mouse._y), + (self.mouse._width // 2, self.mouse._height // 2), + ) + + def test_movement_accumulates(self): + self.source.inject_mouse(dx=10, dy=-4) + state, x, y = self.mouse._get_coords() + self.assertEqual((x, y), (self.mouse._width // 2 + 10, self.mouse._height // 2 - 4)) + self.assertEqual(state, lv.INDEV_STATE.RELEASED) + + def test_position_clamps_at_edges(self): + for _ in range(10): + self.source.inject_mouse(dx=127, dy=127) + _, x, y = self.mouse._get_coords() + self.assertEqual((x, y), (self.mouse._width - 1, self.mouse._height - 1)) + for _ in range(10): + self.source.inject_mouse(dx=-128, dy=-128) + _, x, y = self.mouse._get_coords() + self.assertEqual((x, y), (0, 0)) + + def test_button_press_latches_until_release(self): + self.source.inject_mouse(buttons=1) + state, _, _ = self.mouse._get_coords() + self.assertEqual(state, lv.INDEV_STATE.PRESSED) + state, _, _ = self.mouse._get_coords() + self.assertEqual(state, lv.INDEV_STATE.PRESSED) + self.source.inject_mouse(buttons=0) + state, _, _ = self.mouse._get_coords() + self.assertEqual(state, lv.INDEV_STATE.RELEASED) + + def test_coords_are_absolute(self): + self.assertTrue(USBMouse.__usb_absolute__) + self.assertEqual(self.mouse._calc_coords(123, 45), (123, 45)) + + def test_click_reaches_button(self): + clicked = [] + btn = lv.button(self.screen) + btn.set_size(80, 40) + btn.center() + btn.add_event_cb(lambda e: clicked.append(True), lv.EVENT.CLICKED, None) + self.wait_for_render() + area = lv.area_t() + for _ in range(50): + btn.get_coords(area) + if area.x2 >= area.x1 and area.y2 >= area.y1: + break + self.wait_for_render() + cx = (area.x1 + area.x2) // 2 + cy = (area.y1 + area.y2) // 2 + tx, ty = _logical_to_physical(cx, cy) + self.source.inject_mouse(dx=tx - self.mouse._x, dy=ty - self.mouse._y) + self.mouse.read() + self.wait_for_render() + self.source.inject_mouse(buttons=1) + self.mouse.read() + self.wait_for_render() + self.source.inject_mouse(buttons=0) + self.mouse.read() + self.wait_for_render() + self.assertTrue(clicked) + + def test_cursor_attaches_above_ui(self): + disp = lv.display_get_default() + before = disp.get_layer_sys().get_child_count() + self.mouse.attach_cursor() + self.assertIsNotNone(self.mouse._cursor) + after = disp.get_layer_sys().get_child_count() + self.assertEqual(after, before + 1) + self.assertEqual(self.screen.get_child_count(), 0) + + def test_cursor_is_arrow_shaped(self): + def alpha(x, y): + return _cursor_map()[(y * _CURSOR_W + x) * 4 + 3] + + self.assertEqual((_CURSOR_W, _CURSOR_H), (16, 16)) + self.assertEqual(alpha(0, 0), 255) + self.assertEqual(alpha(15, 0), 0) + self.assertEqual(alpha(15, 15), 0) + self.assertEqual(alpha(0, 12), 255) + self.assertEqual(alpha(12, 12), 0) + + def test_wheel_does_not_crash(self): + target = lv.obj(self.screen) + target.set_size(300, 200) + self.source.inject_mouse(wheel=1) + self.mouse.read() + self.wait_for_render() + self.source.inject_mouse(wheel=-1) + self.mouse.read() + self.wait_for_render() + + def _cursor_rgb(self): + c = self.mouse._cursor.get_style_image_recolor(0) + return (c.red, c.green, c.blue) + + def test_cursor_is_black_in_light_theme(self): + self.assertTrue(AppearanceManager.is_light_mode()) + self.mouse.attach_cursor() + self.assertEqual(self.mouse._cursor_theme, "black") + self.assertEqual(self._cursor_rgb(), (0, 0, 0)) + + def test_cursor_follows_theme_switch(self): + prev = AppearanceManager._is_light_mode + self.addCleanup(setattr, AppearanceManager, "_is_light_mode", prev) + self.mouse.attach_cursor() + AppearanceManager._is_light_mode = False + self.mouse._get_coords() + self.assertEqual(self.mouse._cursor_theme, "white") + self.assertEqual(self._cursor_rgb(), (255, 255, 255)) + AppearanceManager._is_light_mode = True + self.mouse._get_coords() + self.assertEqual(self.mouse._cursor_theme, "black") + self.assertEqual(self._cursor_rgb(), (0, 0, 0)) diff --git a/tests/test_nostr_first_open.py b/tests/test_nostr_first_open.py index dea2d8f72..df5fc4e4c 100644 --- a/tests/test_nostr_first_open.py +++ b/tests/test_nostr_first_open.py @@ -8,6 +8,7 @@ import lvgl as lv sys.path.append("apps") +sys.path.append("tests") from com_micropythonos_nostr.chat_model import ( DEFAULT_CHANNEL_ID, @@ -26,6 +27,10 @@ from nostr.event import Event +from _mpos_device_unittest import patch_unittest_main + +patch_unittest_main() + class TestNostrFirstOpenShowsDefaultChannel(unittest.TestCase): """A brand new Nostr install should list the default public channel.""" diff --git a/tests/test_notification_manager.py b/tests/test_notification_manager.py index 790434c0e..7c9cbfded 100644 --- a/tests/test_notification_manager.py +++ b/tests/test_notification_manager.py @@ -283,5 +283,49 @@ def ticks_diff(end, start): nm_module.time = original_time +class TestPreviewSound(unittest.TestCase): + """preview_sound plays a given RTTTL on the buzzer regardless of the + stored preference, and is a silent no-op for a falsy value or when the + board has no buzzer output.""" + + def setUp(self): + from mpos import AudioManager + self._orig_find = NotificationManager._find_buzzer_output + self._orig_player = AudioManager.player + self.started = [] + mgr_self = self + + class _P: + def __init__(self, **kw): + self.kw = kw + + def start(self): + mgr_self.started.append(self.kw) + + AudioManager.player = staticmethod(lambda **kw: _P(**kw)) + NotificationManager._find_buzzer_output = staticmethod(lambda: "buzzer") + + def tearDown(self): + from mpos import AudioManager + NotificationManager._find_buzzer_output = staticmethod(self._orig_find) if not isinstance(self._orig_find, staticmethod) else self._orig_find + AudioManager.player = self._orig_player + + def test_plays_given_rtttl(self): + NotificationManager.preview_sound("beep:d=4,o=5,b=120:c") + self.assertEqual(len(self.started), 1) + self.assertEqual(self.started[0]["rtttl"], "beep:d=4,o=5,b=120:c") + self.assertEqual(self.started[0]["output"], "buzzer") + + def test_falsy_value_is_noop(self): + NotificationManager.preview_sound("") + NotificationManager.preview_sound(None) + self.assertEqual(self.started, []) + + def test_no_buzzer_is_noop(self): + NotificationManager._find_buzzer_output = staticmethod(lambda: None) + NotificationManager.preview_sound("beep:d=4,o=5,b=120:c") + self.assertEqual(self.started, []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_settings_activity_label_mapping.py b/tests/test_settings_activity_label_mapping.py index 1c96f56bf..56df88e17 100644 --- a/tests/test_settings_activity_label_mapping.py +++ b/tests/test_settings_activity_label_mapping.py @@ -15,7 +15,7 @@ import unittest -from mpos.ui.settings_activity import _value_label_for +from mpos.ui.settings_activity import _row_value_text, _value_label_for class TestValueLabelFor(unittest.TestCase): @@ -71,6 +71,24 @@ def test_first_match_wins_with_duplicate_values(self): self.assertEqual(_value_label_for(setting, "x"), "First") +class TestRowValueTextNonStringValues(unittest.TestCase): + """Prefs are JSON: a value stored as int (older app versions, numeric + ui_options values) must still render as text, not raise in set_text.""" + + def test_int_stored_value_becomes_text(self): + self.assertEqual(_row_value_text({"key": "n"}, 6), "6") + + def test_int_stored_value_maps_to_option_label(self): + setting = {"key": "n", "ui_options": [("Six", 6), ("Eight", 8)]} + self.assertEqual(_row_value_text(setting, 8), "Eight") + + def test_int_default_value_is_shown_as_text(self): + self.assertEqual(_row_value_text({"key": "n", "default_value": 6}, None), "(defaults to 6)") + + def test_string_values_unchanged(self): + self.assertEqual(_row_value_text({"key": "n"}, "abc"), "abc") + + class TestShouldShow(unittest.TestCase): def _should_show(self, setting): @@ -116,5 +134,45 @@ def test_callable_receives_setting_dict(self): self.assertEqual(captured[0]["key"], "mykey") +class TestRowValueText(unittest.TestCase): + + def test_dont_persist_with_default_shows_defaults_to(self): + # USB Host Mode case: dont_persist (single source of truth lives + # elsewhere) + live default_value re-read on every open. + setting = { + "dont_persist": True, + "default_value": "on", + "ui_options": [("On", "on"), ("Off", "off")], + } + self.assertEqual(_row_value_text(setting, None), "(defaults to On)") + + def test_dont_persist_with_default_no_options(self): + setting = {"dont_persist": True, "default_value": "off"} + self.assertEqual(_row_value_text(setting, None), "(defaults to off)") + + def test_dont_persist_without_default_keeps_old_text(self): + # One-shot actions (bootloader, format) have no meaningful value. + setting = {"dont_persist": True} + self.assertEqual(_row_value_text(setting, None), "(not persisted)") + self.assertEqual(_row_value_text(setting, "anything"), "(not persisted)") + + def test_activity_shows_placeholder(self): + setting = {"activity_class": object(), "placeholder": "Scan Wi-Fi"} + self.assertEqual(_row_value_text(setting, None), "Scan Wi-Fi") + setting = {"activity_class": object()} + self.assertEqual(_row_value_text(setting, None), "") + + def test_stored_value_maps_to_label(self): + setting = {"key": "theme", "ui_options": [("Light", "light")]} + self.assertEqual(_row_value_text(setting, "light"), "Light") + + def test_unset_with_default_shows_defaults_to(self): + setting = {"key": "theme", "default_value": "dark"} + self.assertEqual(_row_value_text(setting, None), "(defaults to dark)") + + def test_unset_without_default_shows_not_set(self): + self.assertEqual(_row_value_text({"key": "x"}, None), "(not set)") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_stream_wav.py b/tests/test_stream_wav.py index fc321588a..b1898bc8d 100644 --- a/tests/test_stream_wav.py +++ b/tests/test_stream_wav.py @@ -154,6 +154,9 @@ def test_wall_clock_ahead_of_audio_means_no_wait(self): # e.g. playback stalled and ran long: never sleep a negative amount self.assertEqual(WAVStream.compute_drain_ms(2000, 4171), 0) + +@unittest.skipIf(sys.platform == "esp32", + "Desktop timing-simulation branch never runs on ESP32 (I2S path instead)") class TestDesktopRepeat(unittest.TestCase): """Desktop playback must honor repeat_count like the I2S path does. diff --git a/tests/test_uaiowebsocket_ping.py b/tests/test_uaiowebsocket_ping.py new file mode 100644 index 000000000..ebc3955df --- /dev/null +++ b/tests/test_uaiowebsocket_ping.py @@ -0,0 +1,50 @@ +""" +Regression test for MicroPythonOS#299: uaiowebsocket must not try to send a +pong itself on an incoming PING. The bundled aiohttp port answers pings +inside WebSocketClient.receive() and its ClientWebSocketResponse has no +pong() method, so the old call raised AttributeError and logged an ERROR on +every relay ping. The PING path now only runs the on_ping callback. + +The module is loaded from lib/ (purging any frozen copy) so the test sees +the source under test without a firmware rebuild. + +Usage: + python3 scripts/test_runner.py tests/test_uaiowebsocket_ping.py +""" +import sys, unittest + +sys.modules.pop("uaiowebsocket", None) +if "lib" not in sys.path[:1]: + sys.path.insert(0, "lib") +import uaiowebsocket +from aiohttp.aiohttp_ws import ClientWebSocketResponse + + +class _App: + """Only what _handle_ping touches on self.""" + def __init__(self): + self.pings = [] + self.on_ping = lambda ws, data: self.pings.append(data) + + +class TestPingHandling(unittest.TestCase): + def test_bundled_aiohttp_has_no_pong(self): + # Documents the constraint the fix is built on. + self.assertFalse(hasattr(ClientWebSocketResponse, "pong")) + + def test_ping_runs_callback_only(self): + app = _App() + uaiowebsocket.WebSocketApp._handle_ping(app, b"relay-ping") + # callbacks are queued by _run_callback; drain the queue synchronously + while uaiowebsocket._callback_queue: + cb, args = uaiowebsocket._callback_queue.popleft() + cb(*args) + self.assertEqual(app.pings, [b"relay-ping"]) + + def test_no_pong_attribute_needed_anywhere(self): + src = open("lib/uaiowebsocket.py").read() + self.assertFalse(".pong(" in src) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_usb_hid_keyboard.py b/tests/test_usb_hid_keyboard.py new file mode 100644 index 000000000..147fbfe2e --- /dev/null +++ b/tests/test_usb_hid_keyboard.py @@ -0,0 +1,106 @@ +import unittest + +import lvgl as lv + +from drivers.indev.usb_hid import FakeHIDSource, HIDHub, USBHIDKeyboard +from mpos import InputManager +from mpos.ui.testing import GraphicalTestCase + + + + + +class TestHIDHub(unittest.TestCase): + def test_mouse_and_keyboard_demux(self): + source = FakeHIDSource() + hub = HIDHub(source) + source.inject_mouse(buttons=1, dx=3, dy=-2, addr=5) + source.inject_keyboard([0x0B], addr=6) + source.inject(7, 9, 9, bytes([1, 2, 3])) + hub.pump() + self.assertEqual(hub.drain_mouse(), [(5, 1, 3, -2, 0)]) + self.assertEqual(hub.key_report, (0, 0, 0x0B, 0, 0, 0, 0, 0)) + self.assertEqual(hub.key_addr, 6) + self.assertEqual(hub.drain_mouse(), []) + + def test_key_report_keeps_latest(self): + source = FakeHIDSource() + hub = HIDHub(source) + source.inject_keyboard([0x0B], addr=6) + source.inject_keyboard([0x0C, 0x0D], addr=6) + hub.pump() + self.assertEqual(hub.key_report, (0, 0, 0x0C, 0x0D, 0, 0, 0, 0)) + + def test_short_keyboard_report_ignored(self): + source = FakeHIDSource() + hub = HIDHub(source) + source.inject(6, 1, 1, bytes([0, 0, 0x0B])) + hub.pump() + self.assertIsNone(hub.key_report) + + def test_empty_hub(self): + hub = HIDHub(FakeHIDSource()) + hub.pump() + self.assertEqual(hub.drain_mouse(), []) + self.assertIsNone(hub.key_report) + + +class TestUSBHIDKeyboard(GraphicalTestCase): + def setUp(self): + super().setUp() + self.source = FakeHIDSource() + self.hub = HIDHub(self.source) + # Disable key repeat for these tests: _type_key() intentionally leaves + # each key held across wait_for_render() gaps. On slow/loaded CI runners + # that wall-clock gap can exceed the default 300ms repeat delay and + # emit duplicate characters, making the assertions flaky. + self.kbd = USBHIDKeyboard( + self.hub, + repeat_initial_delay_ms=1_000_000, + repeat_rate_ms=1_000_000, + ) + self.addCleanup(self._cleanup_kbd) + group = lv.group_get_default() + if group is not None: + self.kbd.set_group(group) + InputManager.register_indev(self.kbd) + self.ta = lv.textarea(self.screen) + self.ta.set_size(200, 40) + if group is not None: + group.add_obj(self.ta) + lv.group_focus_obj(self.ta) + self.wait_for_render() + + def _cleanup_kbd(self): + try: + InputManager.unregister_indev(self.kbd) + except Exception: + pass + try: + self.kbd.delete() + except Exception: + pass + + def _type_key(self, keys, modifiers=0): + self.source.inject_keyboard(keys, modifiers=modifiers) + self.kbd.read() + self.wait_for_render() + self.source.inject_keyboard([]) + self.kbd.read() + self.wait_for_render() + + def test_typing_letters(self): + self._type_key([0x0B]) + self.assertEqual(self.ta.get_text(), "h") + self._type_key([0x0C]) + self.assertEqual(self.ta.get_text(), "hi") + + def test_shift_gives_uppercase(self): + self._type_key([0x0B], modifiers=0x02) + self.assertEqual(self.ta.get_text(), "H") + + def test_key_release_emits_nothing_extra(self): + self._type_key([0x0B]) + self.kbd.read() + self.wait_for_render() + self.assertEqual(self.ta.get_text(), "h") diff --git a/tests/test_usb_hid_parser.py b/tests/test_usb_hid_parser.py new file mode 100644 index 000000000..18c1c0559 --- /dev/null +++ b/tests/test_usb_hid_parser.py @@ -0,0 +1,57 @@ +import unittest + +from drivers.indev.usb_hid import BootKeyboardParser, BootMouseParser, find_parser, parse_boot_mouse_report + + +class TestParseBootMouseReport(unittest.TestCase): + def test_idle_report(self): + self.assertEqual(parse_boot_mouse_report(bytes([0x00, 0x00, 0x00])), (0, 0, 0, 0)) + + def test_left_button_and_positive_deltas(self): + self.assertEqual(parse_boot_mouse_report(bytes([0x01, 0x05, 0x03])), (1, 5, 3, 0)) + + def test_negative_deltas_are_sign_extended(self): + self.assertEqual(parse_boot_mouse_report(bytes([0x02, 0xFF, 0xFB])), (2, -1, -5, 0)) + + def test_button_bits_masked_to_three(self): + self.assertEqual(parse_boot_mouse_report(bytes([0xFF, 0x00, 0x00]))[0], 0x07) + + def test_wheel_up(self): + self.assertEqual(parse_boot_mouse_report(bytes([0x00, 0x00, 0x00, 0x01])), (0, 0, 0, 1)) + + def test_wheel_down_is_signed(self): + self.assertEqual(parse_boot_mouse_report(bytes([0x00, 0x00, 0x00, 0xFF])), (0, 0, 0, -1)) + + def test_long_report_parses_first_bytes(self): + self.assertEqual( + parse_boot_mouse_report(bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08])), + (1, 2, 3, 4), + ) + + def test_short_report_is_none(self): + self.assertIsNone(parse_boot_mouse_report(bytes([0x01, 0x02]))) + self.assertIsNone(parse_boot_mouse_report(bytes([]))) + + def test_none_report_is_none(self): + self.assertIsNone(parse_boot_mouse_report(None)) + + +class TestParserRegistry(unittest.TestCase): + def test_boot_mouse_match(self): + parser = find_parser(1, 2) + self.assertIsInstance(parser, BootMouseParser) + self.assertEqual(parser.kind, "mouse") + + def test_boot_keyboard_match(self): + parser = find_parser(1, 1) + self.assertIsInstance(parser, BootKeyboardParser) + self.assertEqual(parser.kind, "keyboard") + + def test_unknown_proto_is_none(self): + self.assertIsNone(find_parser(0, 0)) + self.assertIsNone(find_parser(1, 0)) + + def test_mouse_parser_uses_report_fn(self): + parser = BootMouseParser() + self.assertEqual(parser.parse(bytes([0x01, 0x0A, 0xF6])), (1, 10, -10, 0)) + self.assertIsNone(parser.parse(bytes([0x01]))) diff --git a/tests/test_usb_hid_watchdog.py b/tests/test_usb_hid_watchdog.py new file mode 100644 index 000000000..0b3f2ac74 --- /dev/null +++ b/tests/test_usb_hid_watchdog.py @@ -0,0 +1,389 @@ +import sys +import unittest + +import lvgl as lv + +from mpos import InputManager +from mpos.usb import USBManager +from mpos.ui.testing import GraphicalTestCase + + +class FakeDisplayHandle: + def __init__(self, **kwargs): + self.kwargs = kwargs + + def start(self): + return None + + def poll(self): + return False + + def ready(self): + return False + + +class FakeUSBMod: + Display = FakeDisplayHandle + + def __init__(self): + self.idle_reset = True + self.addrs = [] + self.hid_states = [] + self.parked_entries = [] + self.activated = False + self.deactivated = False + self._host_active = False + + def hid_poll(self): + return False + + def hid_claimed_addrs(self): + return list(self.addrs) + + def hid_state(self): + return list(self.hid_states) + + def hid_parked(self): + return list(self.parked_entries) + + def hid_start(self): + return True + + def auto_reset_idle(self, *args): + if not args: + return self.idle_reset + self.idle_reset = bool(args[0]) + + def activate_host(self): + self.activated = True + self._host_active = True + return True + + def deactivate_host(self): + self.deactivated = True + self._host_active = False + return True + + def host_active(self): + return self._host_active + + +class LegacyFakeUSBMod: + def __init__(self): + self.idle_reset = True + self.addrs = [] + + def hid_poll(self): + return False + + def hid_claimed_addrs(self): + return list(self.addrs) + + def hid_start(self): + return True + + def auto_reset_idle(self, *args): + if not args: + return self.idle_reset + self.idle_reset = bool(args[0]) + + +class FakePanel: + display_width = 320 + display_height = 240 + + +class FakeUSB: + display_width = 640 + display_height = 480 + + +class TestHIDWatchdogExclusion(unittest.TestCase): + def setUp(self): + self.fake = FakeUSBMod() + sys.modules["usb"] = self.fake + self.prev_idle = USBManager._hid_idle_prev + USBManager._hid_idle_prev = None + + def tearDown(self): + sys.modules.pop("usb", None) + USBManager._hid_idle_prev = self.prev_idle + try: + self.fake.auto_reset_idle(True) + except Exception: + pass + + def test_claimed_only_leaves_idle_reset_alone(self): + # Port-exact skip (C side) covers claimed devices; global + # suppression is parked-only now. + self.fake.addrs = [10] + self.fake.parked_entries = [] + USBManager._update_hid_watchdog_exclusion() + self.assertTrue(self.fake.idle_reset) + self.assertIsNone(USBManager._hid_idle_prev) + + def test_unplug_restores_idle_reset(self): + self.fake.parked_entries = [(0x046D, 0xC31C, "keyboard", 255)] + USBManager._update_hid_watchdog_exclusion() + self.fake.parked_entries = [] + USBManager._update_hid_watchdog_exclusion() + self.assertTrue(self.fake.idle_reset) + + def test_manual_disable_is_not_forced_back_on(self): + self.fake.idle_reset = False + self.fake.parked_entries = [(0x046D, 0xC31C, "keyboard", 255)] + USBManager._update_hid_watchdog_exclusion() + self.fake.parked_entries = [] + USBManager._update_hid_watchdog_exclusion() + self.assertFalse(self.fake.idle_reset) + + def test_no_parked_no_touch(self): + self.fake.parked_entries = [] + USBManager._update_hid_watchdog_exclusion() + self.assertTrue(self.fake.idle_reset) + self.assertIsNone(USBManager._hid_idle_prev) + + def test_parked_entries_read(self): + self.fake.parked_entries = [(0x046D, 0xC31C, "keyboard", 255)] + self.assertEqual( + USBManager._hid_parked_entries(), [(0x046D, 0xC31C, "keyboard", 255)] + ) + + def test_parked_suppresses_idle_reset(self): + self.fake.parked_entries = [(0x046D, 0xC31C, "keyboard", 255)] + USBManager._update_hid_watchdog_exclusion() + self.assertFalse(self.fake.idle_reset) + self.fake.parked_entries = [] + USBManager._update_hid_watchdog_exclusion() + self.assertTrue(self.fake.idle_reset) + + def test_poll_hid_wires_parked_to_suppression(self): + self.fake.addrs = [] + self.fake.hid_states = [] + self.fake.parked_entries = [(0x046D, 0xC31C, "keyboard", 255)] + USBManager._poll_hid() + self.assertFalse(self.fake.idle_reset) + self.fake.parked_entries = [] + USBManager._poll_hid() + self.assertTrue(self.fake.idle_reset) + + +class TestSyncUSBHID(GraphicalTestCase): + def setUp(self): + super().setUp() + self.fake = FakeUSBMod() + sys.modules["usb"] = self.fake + self.prev = (USBManager._usb_mouse, USBManager._usb_keyboard, USBManager._hid_hub) + USBManager._usb_mouse = None + USBManager._usb_keyboard = None + USBManager._hid_hub = None + + def tearDown(self): + sys.modules.pop("usb", None) + for dev in (USBManager._usb_mouse, USBManager._usb_keyboard): + if dev is None: + continue + try: + InputManager.unregister_indev(dev) + except Exception: + pass + try: + dev.delete() + except Exception: + pass + USBManager._usb_mouse, USBManager._usb_keyboard, USBManager._hid_hub = self.prev + + def _armed_with_recorders(self): + self.fake.addrs = [5, 6] + USBManager._sync_usb_hid([5, 6]) + mouse = USBManager._usb_mouse + kbd = USBManager._usb_keyboard + self.assertIsNotNone(mouse) + self.assertIsNotNone(kbd) + mouse_calls = [] + kbd_calls = [] + orig_mouse_enable = mouse.enable + orig_kbd_enable = kbd.enable + + def mouse_rec(en): + mouse_calls.append(bool(en)) + return orig_mouse_enable(en) + + def kbd_rec(en): + kbd_calls.append(bool(en)) + return orig_kbd_enable(en) + + mouse.enable = mouse_rec + kbd.enable = kbd_rec + return mouse, kbd, mouse_calls, kbd_calls + + def test_mouse_only_state(self): + mouse, kbd, mouse_calls, kbd_calls = self._armed_with_recorders() + self.fake.hid_states = [(6, "mouse", 0x17EF, 0x608D)] + USBManager._sync_usb_hid([6]) + self.assertEqual(mouse_calls, [True]) + self.assertEqual(kbd_calls, [False]) + self.assertFalse(mouse._cursor.has_flag(lv.obj.FLAG.HIDDEN)) + + def test_keyboard_only_state(self): + mouse, kbd, mouse_calls, kbd_calls = self._armed_with_recorders() + self.fake.hid_states = [(5, "keyboard", 0x046D, 0xC31C)] + USBManager._sync_usb_hid([5]) + self.assertEqual(mouse_calls, [False]) + self.assertEqual(kbd_calls, [True]) + self.assertTrue(mouse._cursor.has_flag(lv.obj.FLAG.HIDDEN)) + + def test_empty_state_disables_both(self): + mouse, kbd, mouse_calls, kbd_calls = self._armed_with_recorders() + self.fake.hid_states = [] + self.fake.addrs = [] + USBManager._sync_usb_hid([]) + self.assertEqual(mouse_calls, [False]) + self.assertEqual(kbd_calls, [False]) + self.assertTrue(mouse._cursor.has_flag(lv.obj.FLAG.HIDDEN)) + + def test_legacy_module_follows_claimed(self): + sys.modules["usb"] = LegacyFakeUSBMod() + USBManager._usb_mouse = None + USBManager._usb_keyboard = None + USBManager._hid_hub = None + USBManager._sync_usb_hid([6]) + self.assertIsNotNone(USBManager._usb_mouse) + self.assertIsNotNone(USBManager._usb_keyboard) + + +class TestMouseSkipsTouchWrap(GraphicalTestCase): + def test_wrap_leaves_absolute_mouse_alone(self): + from drivers.indev.usb_hid import FakeHIDSource, USBMouse + + mouse = USBMouse(source=FakeHIDSource()) + self.addCleanup(mouse.delete) + InputManager.register_indev(mouse) + self.addCleanup(InputManager.unregister_indev, mouse) + USBManager._wrap_all_touch(FakeUSB(), FakePanel()) + self.addCleanup(USBManager._unwrap_touch) + self.assertTrue("_calc_coords" not in mouse.__dict__) + self.assertEqual(mouse._calc_coords(7, 9), (7, 9)) + _ = lv # silence unused import if helpers change + + +class TestActivateDeactivate(GraphicalTestCase): + def setUp(self): + super().setUp() + self.fake = FakeUSBMod() + sys.modules["usb"] = self.fake + self.prev = ( + USBManager._usb_dev, USBManager._usb_mouse, + USBManager._usb_keyboard, USBManager._hid_hub, + USBManager._hid_idle_prev, USBManager._active, + ) + USBManager._usb_dev = None + USBManager._usb_mouse = None + USBManager._usb_keyboard = None + USBManager._hid_hub = None + USBManager._hid_idle_prev = None + USBManager._active = "panel" + + def tearDown(self): + sys.modules.pop("usb", None) + for dev in (USBManager._usb_mouse, USBManager._usb_keyboard): + if dev is None: + continue + try: + InputManager.unregister_indev(dev) + except Exception: + pass + try: + dev.delete() + except Exception: + pass + (USBManager._usb_dev, USBManager._usb_mouse, + USBManager._usb_keyboard, USBManager._hid_hub, + USBManager._hid_idle_prev, USBManager._active) = self.prev + + def test_activate_arms_display_and_hid(self): + self.assertTrue(USBManager.activate(persist=False)) + self.assertTrue(self.fake.activated) + self.assertIsNotNone(USBManager._usb_dev) + self.assertIsNotNone(USBManager._usb_mouse) + self.assertIsNotNone(USBManager._usb_keyboard) + self.assertTrue(USBManager.host_mode_active()) + + def test_activate_legacy_module_fails(self): + sys.modules["usb"] = LegacyFakeUSBMod() + self.assertFalse(USBManager.activate(persist=False)) + + def test_deactivate_tears_everything_down(self): + self.assertTrue(USBManager.activate(persist=False)) + mouse, kbd = USBManager._usb_mouse, USBManager._usb_keyboard + self.assertTrue(mouse in InputManager.list_indevs()) + self.assertTrue(USBManager.deactivate(persist=False)) + self.assertTrue(self.fake.deactivated) + self.assertIsNone(USBManager._usb_dev) + self.assertIsNone(USBManager._usb_mouse) + self.assertIsNone(USBManager._usb_keyboard) + self.assertIsNone(USBManager._hid_hub) + self.assertTrue(mouse not in InputManager.list_indevs()) + self.assertTrue(kbd not in InputManager.list_indevs()) + self.assertFalse(USBManager.host_mode_active()) + self.assertIsNone(USBManager._hid_idle_prev) + + def test_host_boot_defaults_off(self): + # No pref file, BOOT pin unreadable-or-high on desktop: off either way. + self.assertFalse(USBManager.host_boot_requested()) + self.assertFalse(USBManager._bootsel_held() + and USBManager._get_host_pref()) + + def test_host_pref_round_trip(self): + # Single source of truth shared with the Settings UI: same + # namespace, same key the framework persists ("on"/"off" strings). + self.assertEqual((USBManager._HOST_PREFS, USBManager._HOST_MODE_KEY), + ("com.micropythonos.settings", "usb_host_mode")) + had = self._backup_prefs() + try: + USBManager._set_host_pref(True) + self.assertTrue(USBManager._get_host_pref()) + USBManager._set_host_pref(False) + self.assertFalse(USBManager._get_host_pref()) + finally: + self._restore_prefs(had) + + def test_once_normalizes_to_off_on_boot(self): + # "On until reboot" expires at the next boot: stored value becomes + # Off so the Settings row stops showing a stale selection. + had = self._backup_prefs() + try: + from mpos import SharedPreferences + SharedPreferences("com.micropythonos.settings").edit().put_string( + "usb_host_mode", "once").commit() + self.assertFalse(USBManager.host_boot_requested()) + self.assertFalse(USBManager._get_host_pref()) + self.assertEqual( + SharedPreferences("com.micropythonos.settings").get_string("usb_host_mode"), + "off") + # Second boot: already normalized, stays off. + self.assertFalse(USBManager.host_boot_requested()) + finally: + self._restore_prefs(had) + + @staticmethod + def _backup_prefs(): + try: + with open("prefs/com.micropythonos.settings/config.json", "rb") as f: + return f.read() + except Exception: + return None + + @staticmethod + def _restore_prefs(had): + import os + path = "prefs/com.micropythonos.settings/config.json" + try: + if had is None: + if os.path.exists(path): + os.remove(path) + else: + with open(path, "wb") as f: + f.write(had) + except Exception: + pass