diff --git a/.claude/commands/diagnose.md b/.claude/commands/diagnose.md index 45aa937a5..749668956 100644 --- a/.claude/commands/diagnose.md +++ b/.claude/commands/diagnose.md @@ -24,7 +24,12 @@ Call the meshtastic MCP tool bundle and format a structured health report for on - `mcp__meshtastic__get_config(section="lora", port=

)` — region, preset, channel_num, tx_power, hop_limit. - Optionally, if the device seems unhappy (fails to connect, `num_nodes==1` when ≥2 are plugged in, missing firmware*version), open a short firmware log window: `mcp__meshtastic__serial_open(port=

, env=)`, wait 3s, `serial_read(session_id=, max_lines=100)`, `serial_close(session_id=)`. The env should be inferred from the VID map in `mcp-server/run-tests.sh` (nrf52 → rak4631, esp32s3 → heltec-v3) unless `MESHTASTIC_MCP_ENV*` is set. -4. **Render per-device report** as: +4. **Hub health** (call once, not per-device): `mcp__meshtastic__uhubctl_list()` — enumerates every USB hub the host can see. Note which hubs advertise `ppps=true` and which hub hosts each Meshtastic device (cross-reference by VID). Flag it in the report if: + - No hub advertises PPPS → `tests/recovery/` can't run on this setup; hard-recovery via `uhubctl_cycle` isn't available. + - A Meshtastic device is on a non-PPPS hub → note it; operator may want to move the device to a PPPS hub to unlock auto-recovery. + - `uhubctl_list` raises `ConfigError: uhubctl not found` → just say `uhubctl not installed` in the report; don't treat as a fault. + +5. **Render per-device report** as: ```text [nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631 @@ -33,20 +38,22 @@ Call the meshtastic MCP tool bundle and format a structured health report for on tx_power : 30 dBm, hop_limit=3 peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm) primary ch : McpTest + hub : 1-1.3 port 2 (PPPS, uhubctl-controllable) firmware : no panics in last 3s; NodeInfoModule emitted 2 broadcasts ``` - Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub), flag it inline with a short `⚠︎ `. + Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub, device on non-PPPS hub), flag it inline with a short `⚠︎ `. -5. **Cross-device correlation** (only when >1 device is inspected): +6. **Cross-device correlation** (only when >1 device is inspected): - Do both sides see each other in `nodesByNum`? If one does and the other doesn't, that's asymmetric NodeInfo — flag it. - Do the LoRa configs match? (region, channel_num, modem_preset should all agree; mismatch = no mesh) - Do the primary channel NAMES match? Mismatch = different PSK = no decode. -6. **Suggest next actions only for specific, recognisable failure modes**: +7. **Suggest next actions only for specific, recognisable failure modes**: - Stale PKI pubkey one-way → "run `/test tests/mesh/test_direct_with_ack.py` — the retry + nodeinfo-ping heals this in the test path." - Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`." - - Device unreachable → point at touch_1200bps + the CP2102-wedged-driver note in run-tests.sh. + - Device unreachable, reachable via DFU → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds AND the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`. + - CP2102-wedged-driver on macOS → see the note in `run-tests.sh`. ## What NOT to do diff --git a/.claude/commands/repro.md b/.claude/commands/repro.md index 52dcf222b..c5f466ce6 100644 --- a/.claude/commands/repro.md +++ b/.claude/commands/repro.md @@ -44,7 +44,8 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac - **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body. - **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this. - **NodeInfo cooldown** → `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs `broadcast_nodeinfo_ping()` warmup. - - **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer. + - **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer. For a device that's wedged past `touch_1200bps`, the next escalation is `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed). + - **Device went dark mid-run** → fails from some attempt onward, never recovers, firmware log stops arriving. Almost always hardware: a Guru crash + frozen CDC. Hard-power-cycle via `uhubctl_cycle(role=..., confirm=True)` before the next iteration; if that also fails, escalate to replug. - **Genuinely unknown** → say so; don't invent a root cause. 7. **Report back** with: diff --git a/.claude/commands/test.md b/.claude/commands/test.md index 986ee1f31..46a753749 100644 --- a/.claude/commands/test.md +++ b/.claude/commands/test.md @@ -19,16 +19,21 @@ Run `mcp-server/run-tests.sh` and make sense of the output so the operator doesn 2. **Read the pre-flight header.** First ~6 lines print the detected hub (role → port → env). If that line reads `detected hub : (none)`, the wrapper will narrow to `tests/unit` only — say so explicitly in your summary so the operator knows hardware tiers were skipped. -3. **On pass**: one-line summary of the form `N passed, M skipped in `. Don't enumerate the 52 test names — the user can read those. Do mention if any test was SKIPPED for a NON-placeholder reason (e.g. "role not present on hub" is worth flagging). +3. **On pass**: one-line summary of the form `N passed, M skipped in `. Don't enumerate the test names — the user can read those. Do mention any SKIPPED tests and name the cause: + - `"role not present on hub"` → device unplugged; operator knows to reconnect. + - `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped because the macro isn't in firmware yet; suggest `--force-bake`. + - `"uhubctl not installed"` → tests/recovery + peer-offline skipped; suggest `brew install uhubctl` / `apt install uhubctl`. + - `"no PPPS-capable hubs detected"` → tests/recovery skipped because the hub doesn't support per-port power; the tier will never run on that setup. + - `"opencv-python-headless is not installed"` → tests/ui auto-deselected by run-tests.sh; suggest `pip install -e 'mcp-server/.[ui]'`. -4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`). +4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`). For UI-tier failures also glance at `mcp-server/tests/ui_captures///transcript.md` — it records each step's frame + OCR. 5. **Classify the failure** as one of: - **Transient/flake**: LoRa collision, timing-sensitive assertion, first-attempt NAK + successful retry pattern. Propose `/repro ` to confirm. - - **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery (replug USB, `touch_1200bps`, check `git status userPrefs.jsonc`). + - **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery in escalation order: (a) replug USB, (b) `touch_1200bps(port=...)` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role="nrf52", confirm=True)` when a device is fully wedged past DFU (needs `uhubctl` installed — `baked_single`'s auto-recovery hook does this once automatically). Also check `git status userPrefs.jsonc`. - **Regression**: same assertion fails repeatedly, firmware log shows a new/unusual error. Surface the diff between expected and observed, identify the module likely responsible. -6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, or USB replug, \_describe what to do* — don't execute. The operator decides. +6. **Never run destructive recovery automatically.** If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or USB replug, \_describe what to do* — don't execute. The operator decides. ## Arguments handling diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d12244229..7c71a5014 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -474,7 +474,7 @@ The repo registers the server via `.mcp.json` at the repo root — Claude Code p **One MCP call per port at a time.** `SerialInterface` holds an exclusive OS-level lock on the serial port for its lifetime. If a `serial_*` session is open on `/dev/cu.usbmodem101`, calling `device_info` on the same port will fail fast pointing at the active session. Sequence calls: open → read/mutate → close, then next device. Never parallelize tool calls on the same port. -### MCP tool surface (~32 tools) +### MCP tool surface (43 tools) Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-value signatures are called out here. @@ -482,11 +482,13 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v - **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 only), `update_flash` (ESP32 OTA), `touch_1200bps` - **Serial sessions** (long-running, 10k-line ring buffer): `serial_open`, `serial_read`, `serial_list`, `serial_close` - **Device reads**: `device_info`, `list_nodes` -- **Device writes** (all require `confirm=True`): `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `reboot`, `shutdown`, `factory_reset`, `set_debug_log_api` +- **Device writes**: `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `send_input_event` (inject a button/key press via the firmware's InputBroker), `set_debug_log_api`; destructive/power-state writes require `confirm=True`: `reboot`, `shutdown`, `factory_reset` - **userPrefs admin** (build-time constants, not runtime config): `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` - **Vendor escape hatches**: `esptool_chip_info`, `esptool_erase_flash`, `esptool_raw`, `nrfutil_dfu`, `nrfutil_raw`, `picotool_info`, `picotool_load`, `picotool_raw` +- **USB power control** (via `uhubctl`, per-port PPPS toggle): `uhubctl_list` (read-only), `uhubctl_power(action='on'|'off', confirm=True)`, `uhubctl_cycle(delay_s, confirm=True)`. Target by raw `(location, port)` or by `role` (`"nrf52"`, `"esp32s3"`); role lookup checks `MESHTASTIC_UHUBCTL_LOCATION_` + `_PORT_` env vars first, falls back to VID auto-detection. +- **Observability** (UI tier + operator ad-hoc): `capture_screen(role, ocr=True)` — grabs a USB-webcam frame of the device OLED and optionally OCRs it. Requires `mcp-server[ui]` extras (`opencv-python-headless`, `easyocr`) and `MESHTASTIC_UI_CAMERA_DEVICE_` env var; falls through to a 1×1 black PNG `NullBackend` when unconfigured. -`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset` and `erase_and_flash`. +`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`. ### Hardware test suite (`mcp-server/run-tests.sh`) @@ -494,14 +496,16 @@ The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf5 Suite tiers (collected + run in this order via `pytest_collection_modifyitems`): -1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile). No hardware. +1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile, uhubctl parser). No hardware. 2. `tests/test_00_bake.py` — flashes each detected device with current `userPrefs.jsonc` merged with the session's test profile. Has its own skip-if-already-baked check comparing region + primary channel to the session profile; skips cheaply on warm devices. -3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`. +3. `tests/mesh/` — multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized `[nrf52->esp32s3]` and `[esp32s3->nrf52]`. Includes `test_peer_offline_recovery` which uses uhubctl to physically power off one peer mid-conversation (requires uhubctl; skips without). 4. `tests/telemetry/` — `DEVICE_METRICS_APP` broadcast timing. 5. `tests/monitor/` — boot-log panic check. -6. `tests/fleet/` — PSK seed session isolation. -7. `tests/admin/` — channel URL roundtrip, owner persistence across reboot. -8. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset. +6. `tests/recovery/` — `uhubctl` power-cycle round-trip + NVS persistence across hard reset. Requires `uhubctl` installed and a PPPS-capable hub; entire tier auto-skips otherwise. +7. `tests/ui/` — input-broker-driven screen navigation with camera + OCR evidence. +8. `tests/fleet/` — PSK seed session isolation. +9. `tests/admin/` — channel URL roundtrip, owner persistence across reboot. +10. `tests/provisioning/` — region + modem + slot bake, admin key presence, `UNSET` region blocks TX, userPrefs survive factory reset. Invocation patterns: @@ -586,15 +590,19 @@ If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` fl ### Recovery playbooks -| Symptom | First check | Fix | -| ---------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. | -| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. | -| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. | -| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. | -| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. | -| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. | -| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. | +| Symptom | First check | Fix | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `userPrefs.jsonc` dirty after test run | `git status --porcelain userPrefs.jsonc` | If non-empty, re-run `./mcp-server/run-tests.sh` once — the pre-flight self-heal restores from sidecar. If still dirty, `git checkout userPrefs.jsonc`. | +| Port busy / wedged CP2102 on macOS | `lsof /dev/cu.usbserial-0001` | Kill the holder. USB replug if the kernel still reports busy. Often a stale `pio device monitor` or zombie `meshtastic_mcp` process. | +| nRF52 appears unresponsive | `list_devices` shows VID `0x239A` but `device_info` times out | `touch_1200bps(port=...)` drops it into the DFU bootloader → `pio_flash` re-installs. | +| Device fully wedged (Guru Meditation, frozen CDC, no DFU) | `list_devices` shows the VID but every admin call times out | `uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles the port via USB hub PPPS. `baked_single`'s auto-recovery hook does this once automatically if uhubctl is installed. Falls back to physical replug if no PPPS hub. | +| Multiple MCP server processes | `ps aux \| grep meshtastic_mcp` shows >1 | Kill all but the one your MCP host spawned. Zombies hold ports and break tests. | +| Mesh formation fails, one side sees peer but other doesn't | `/diagnose` (or `list_nodes` on both sides) | Asymmetric NodeInfo. `test_direct_with_ack` has a heal path; `/repro` it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. | +| "role not present on hub" in skip reasons | `list_devices` | Expected if a device is unplugged. Reconnect before re-running the tier. | +| Entire `tests/recovery/` tier skipped | `command -v uhubctl` | Expected if `uhubctl` isn't on PATH. Install via `brew install uhubctl` (macOS) or `apt install uhubctl` (Debian/Ubuntu). Also skips if no hub advertises PPPS. | +| Entire `tests/ui/` tier skipped ("firmware not baked with USERPREFS_UI_TEST_LOG") | reportlog.jsonl for the skip reason | Re-run with `--force-bake` so the UI-log macro gets compiled into the fresh firmware. First run after the Round-3 landing always re-bakes. | +| `tests/ui/` runs but captures are all 1×1 black PNGs | `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3` | Env var not set → `NullBackend`. Point a USB webcam at the heltec-v3 OLED and set the device index; `.venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]"` discovers it. | +| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with `--force-bake` to reset to a known state. | ### Never do these without asking diff --git a/.github/prompts/mcp-diagnose.prompt.md b/.github/prompts/mcp-diagnose.prompt.md index c86826030..1049858f8 100644 --- a/.github/prompts/mcp-diagnose.prompt.md +++ b/.github/prompts/mcp-diagnose.prompt.md @@ -26,7 +26,12 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co - `get_config(section="lora", port=

)` → region, preset, channel_num, tx_power, hop_limit - If anything looks off (can't connect, `num_nodes` wrong, missing `firmware_version`), open a short firmware-log window: `serial_open(port=

, env=)`, wait 3 seconds, `serial_read(session_id, max_lines=100)`, `serial_close(session_id)`. Infer env from VID (0x239a → `rak4631`, 0x303a/0x10c4 → `heltec-v3`) unless an `MESHTASTIC_MCP_ENV_` env var overrides it. -4. **Render per-device report** as a compact block: +4. **Hub health** (call once, not per-device): `uhubctl_list()` — enumerates every USB hub the host sees. Cross-reference each Meshtastic device's VID to find which hub + port it's on. Flag in the report if: + - No hub advertises `ppps=true` → `tests/recovery/` can't run; hard-recovery via `uhubctl_cycle` isn't available. + - A Meshtastic device is on a non-PPPS hub → note it; moving to a PPPS hub unlocks auto-recovery. + - `uhubctl_list` raises `ConfigError: uhubctl not found` → report as "uhubctl not installed"; don't treat as a device fault. + +5. **Render per-device report** as a compact block: ```text [nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631 @@ -35,20 +40,22 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co tx_power : 30 dBm, hop_limit=3 peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm) primary ch : McpTest + hub : 1-1.3 port 2 (PPPS, uhubctl-controllable) firmware : no panics in last 3s ``` - Flag abnormalities inline with `⚠︎ ` — missing pubkey on a known peer, region UNSET, mismatched channel name, etc. + Flag abnormalities inline with `⚠︎ ` — missing pubkey on a known peer, region UNSET, mismatched channel name, device on non-PPPS hub, etc. -5. **Cross-device correlation** (when >1 device selected): +6. **Cross-device correlation** (when >1 device selected): - Do both see each other in `nodesByNum`? - Do `region`, `channel_num`, `modem_preset` match across devices? - Do the primary channel names match? (Different name → different PSK → no decode.) -6. **Suggest next steps only for recognizable failure modes**, never speculatively: +7. **Suggest next steps only for recognizable failure modes**, never speculatively: - Stale PKI one-way → "`/mcp-test tests/mesh/test_direct_with_ack.py` — the test's retry+nodeinfo-ping heals this." - Region mismatch → "re-bake one side via `./mcp-server/run-tests.sh --force-bake`." - - Device unreachable → refer operator to the touch_1200bps + CP2102-wedged-driver notes in `run-tests.sh`. + - Device unreachable, DFU reachable → `touch_1200bps(port=...)` + `pio_flash`. If not even DFU responds and the device is on a PPPS hub, escalate to `uhubctl_cycle(role=..., confirm=True)`. + - CP2102-wedged-driver on macOS → see `run-tests.sh` notes. ## Hard constraints diff --git a/.github/prompts/mcp-repro.prompt.md b/.github/prompts/mcp-repro.prompt.md index be2963c33..3a7c5c3de 100644 --- a/.github/prompts/mcp-repro.prompt.md +++ b/.github/prompts/mcp-repro.prompt.md @@ -46,7 +46,8 @@ Equivalent of `.claude/commands/repro.md`. Use when the operator says "that one - **LoRa airtime collision** — pass rate improves with fewer concurrent transmitters. Suggest a `time.sleep` gap or retry bump in the test body. - **PKI key staleness** — first attempt fails, subsequent ones pass; existing retry-loop pattern in `test_direct_with_ack.py` is the fix. - **NodeInfo cooldown** — `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs a `broadcast_nodeinfo_ping()` warmup. - - **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc. + - **Hardware-specific** — one direction consistently fails, firmware versions differ, CP2102 driver wedged, etc. For a device wedged past `touch_1200bps`, recommend `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed). + - **Device went dark mid-run** — fails from some iteration onward and never recovers; firmware log stops arriving. Almost always a Guru crash with frozen CDC. Recommend `uhubctl_cycle` before the next iteration; escalate to replug if that also fails. - **Unknown** — say so. Don't invent a root cause. 7. **Report back** with: diff --git a/.github/prompts/mcp-test.prompt.md b/.github/prompts/mcp-test.prompt.md index 092ad3d85..148569e83 100644 --- a/.github/prompts/mcp-test.prompt.md +++ b/.github/prompts/mcp-test.prompt.md @@ -21,19 +21,25 @@ Equivalent of the Claude Code `/test` slash command in `.claude/commands/test.md 2. **Read the pre-flight header** (first few lines of wrapper output). The `detected hub :` line lists role → port → env mappings. If it reads `(none)`, the wrapper narrowed to `tests/unit` only — call that out explicitly so the operator knows hardware tiers were skipped. -3. **On pass**: one-line summary like `N passed, M skipped in `. Don't enumerate test names. DO mention any non-placeholder SKIPs (things like "role not present on hub") because they indicate missing hardware or setup issues. +3. **On pass**: one-line summary like `N passed, M skipped in `. Don't enumerate test names. DO mention any non-placeholder SKIPs and name the cause: + - `"role not present on hub"` → device unplugged; operator should reconnect. + - `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped; the UI-log compile macro isn't in the baked firmware. Suggest `--force-bake`. + - `"uhubctl not installed"` → tests/recovery + `test_peer_offline_recovery` skipped. Suggest `brew install uhubctl` / `apt install uhubctl`. + - `"no PPPS-capable hubs detected"` → tests/recovery skipped because the attached hub doesn't support per-port power switching; won't run on that setup. + - `"opencv-python-headless is not installed"` → tests/ui auto-deselected by `run-tests.sh`. Suggest `pip install -e 'mcp-server/.[ui]'`. 4. **On failure**: open `mcp-server/tests/report.html` (pytest-html output, self-contained) and extract the `Meshtastic debug` section for each failed test. That section includes a firmware log stream (last 200 lines) and device state dump. For each failure, summarise: - test name - one-line assertion message - the specific firmware log lines that explain why (look for `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`, `No suitable channel`) + - for UI-tier failures also check `mcp-server/tests/ui_captures///transcript.md` (per-step frame + OCR) 5. **Classify each failure** as one of: - **Transient flake** — LoRa collision, first-attempt NAK with self-heal pattern, timing-sensitive assertion. Suggest `/mcp-repro ` to confirm. - - **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest specific recovery (USB replug, `touch_1200bps`, `git status userPrefs.jsonc`). + - **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest recovery in escalation order: (a) replug USB, (b) `touch_1200bps` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role=..., confirm=True)` for a device wedged past DFU (needs `uhubctl` installed; `baked_single` does this once automatically when available). Also check `git status userPrefs.jsonc`. - **Regression** — same assertion fails repeatedly on re-runs, firmware log shows novel errors. Identify the firmware module likely responsible. -6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval. +6. **Do NOT run destructive recovery automatically**. If a failure looks like it needs a reflash, factory*reset, `uhubctl_cycle`, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval. ## Arguments convention diff --git a/AGENTS.md b/AGENTS.md index cd043c087..b3fa1970c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,25 +89,42 @@ Sequence these; don't parallelize on the same port. ## Where to look -| Path | What's there | -| --------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) | -| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI | -| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers | -| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) | -| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` | -| `test/` | Firmware unit tests (12 suites; `pio test -e native`) | -| `mcp-server/` | Python MCP server + pytest hardware integration tests | -| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `fleet/`, `admin/`, `provisioning/` | -| `.claude/commands/` | Claude Code slash command bodies | -| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) | -| `.github/copilot-instructions.md` | **Primary agent instructions — read this** | -| `.github/workflows/` | CI pipelines | -| `.mcp.json` | MCP server registration for Claude Code | +| Path | What's there | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `src/` | Firmware C++ source (`mesh/`, `modules/`, `platform/`, `graphics/`, `gps/`, `motion/`, `mqtt/`, …) | +| `src/mesh/` | Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI | +| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers | +| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) | +| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` | +| `test/` | Firmware unit tests (12 suites; `pio test -e native`) | +| `mcp-server/` | Python MCP server + pytest hardware integration tests | +| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/` | +| `.claude/commands/` | Claude Code slash command bodies | +| `.github/prompts/` | Copilot prompt bodies (mirrors of the Claude Code ones) | +| `.github/copilot-instructions.md` | **Primary agent instructions — read this** | +| `.github/workflows/` | CI pipelines | +| `.mcp.json` | MCP server registration for Claude Code | ## Recovery one-liners - **`userPrefs.jsonc` dirty after a test run?** Re-run `./mcp-server/run-tests.sh` once (pre-flight self-heals from the sidecar). If still dirty: `git checkout userPrefs.jsonc`. - **nRF52 not responding?** `mcp__meshtastic__touch_1200bps(port=...)` drops it into the DFU bootloader, then `pio_flash` re-installs. +- **Device fully wedged (no DFU)?** `mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles it via USB hub PPPS. Needs `uhubctl` installed (`brew install uhubctl` / `apt install uhubctl`); on Linux without udev rules, permission errors fail fast, so use `sudo uhubctl` yourself or configure udev access. - **Port busy?** `lsof ` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it. - **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned. + +## Environment variables (test harness) + +| Var | Purpose | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `MESHTASTIC_MCP_ENV_` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. | +| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp--`. | +| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. | +| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. | +| `MESHTASTIC_UHUBCTL_LOCATION_` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. | +| `MESHTASTIC_UHUBCTL_PORT_` | Pin a role to a specific hub port number. Required alongside `LOCATION_`. | +| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). | +| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. | +| `MESHTASTIC_UI_CAMERA_DEVICE_` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). | +| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). | +| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. | diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore index f5180bc71..4cc892b2a 100644 --- a/mcp-server/.gitignore +++ b/mcp-server/.gitignore @@ -24,3 +24,6 @@ tests/.tui-runs tests/.history/ # Reproducer bundles (TUI `x` export on failed tests). tests/reproducers/ +# UI-tier camera captures + per-test transcripts. Regenerated every run; +# left on disk for human review between runs. +tests/ui_captures/ diff --git a/mcp-server/README.md b/mcp-server/README.md index 7d5fc551a..7a36a6fac 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -61,7 +61,7 @@ Replace `` with the absolute path, e.g. `/Users/you/GitHub/firmwa Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows). -## Tools (38) +## Tools (43) ### Discovery & metadata @@ -130,6 +130,34 @@ _The tool tables below document 38 currently registered MCP server tools._ | `picotool_load` | Load a UF2 | | `picotool_raw` | Pass-through | +### USB power control (uhubctl) + +| Tool | What it does | +| --------------- | ----------------------------------------------------------- | +| `uhubctl_list` | Enumerate USB hubs + attached-device VID/PID (read-only) | +| `uhubctl_power` | Drive a hub port `on` or `off`; `off` requires confirm=True | +| `uhubctl_cycle` | Off → wait `delay_s` → on; confirm=True required | + +Target a port by explicit `(location, port)` (raw uhubctl syntax like +`location="1-1.3", port=2`) or by `role` (`"nrf52"`, `"esp32s3"`). Role +lookup checks `MESHTASTIC_UHUBCTL_LOCATION_` + +`MESHTASTIC_UHUBCTL_PORT_` env vars first, then auto-detects via VID +against `uhubctl`'s output. + +Requires [`uhubctl`](https://github.com/mvp/uhubctl) on PATH: + +```bash +brew install uhubctl # macOS +apt install uhubctl # Debian/Ubuntu +``` + +Modern macOS + PPPS-capable hubs generally work without root. On Linux +without udev rules, or on old macOS with driver quirks, you may need +`sudo`. If uhubctl returns a permission error the MCP tool raises a +clear `UhubctlError` pointing at the +[udev-rules / sudo fallback](https://github.com/mvp/uhubctl#linux-usb-permissions) +rather than auto-`sudo`'ing mid-run. + ## Safety - **All destructive flash/admin tools require `confirm=True`** as a tool-level gate, on top of any permission prompt from Claude. @@ -182,10 +210,22 @@ in the pre-flight header. - **`unit`** — pure Python, no hardware. boards / PIO wrapper / userPrefs-parse / testing-profile fixtures. - **`mesh`** — 2-device mesh: formation, broadcast delivery, direct+ACK, - traceroute, bidirectional. Parametrized over both directions. + traceroute, bidirectional. Parametrized over both directions. Includes + `test_peer_offline_recovery` which uses uhubctl to power-cycle one peer + mid-conversation and verifies the mesh recovers (skips without uhubctl). - **`telemetry`** — periodic telemetry broadcast + on-demand request/reply (`TELEMETRY_APP` with `wantResponse=True`). - **`monitor`** — boot log has no panic markers within 60 s of reboot. +- **`recovery`** — `uhubctl` power-cycle round-trip: verifies the hub port + can be toggled off/on, the device re-enumerates with the same + `my_node_num`, and NVS-resident config (region, channel, modem preset) + survives a hard reset. Requires `uhubctl` on PATH; skips cleanly otherwise. +- **`ui`** — input-broker-driven screen navigation (`AdminMessage.send_input_event` + injection → `Screen::handleInputEvent` → frame transition). Parametrized + on the screen-bearing role (heltec-v3 OLED). Captures images via USB + webcam + OCRs them for HTML-report evidence. Requires `pip install -e '.[ui]'` + and `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=`; tier is auto-deselected + if `cv2` isn't importable. - **`fleet`** — PSK-seed isolation: two labs with different seeds never overlap. - **`admin`** — owner persistence across reboot, channel URL round-trip, @@ -193,6 +233,42 @@ in the pre-flight header. - **`provisioning`** — region/channel baking, userPrefs survive `factory_reset(full=False)`. +#### UI tier setup + +The `tests/ui/` tier drives the on-device OLED via the firmware's existing +`AdminMessage.send_input_event` RPC (no firmware changes required) and +verifies transitions via a macro-gated log line + camera + OCR. Summary: + +1. Install extras: `pip install -e 'mcp-server/.[ui]'` — pulls in + `opencv-python-headless`, `numpy`, `easyocr`, `Pillow`. First easyocr + run downloads ~100 MB of models to `~/.EasyOCR/`; an autouse session + fixture pre-warms the reader so per-test OCR is <100 ms after that. +2. Point a USB webcam at the heltec-v3 OLED. Discover its index: + ```bash + .venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]" + ``` +3. Export the per-role device env var: + ```bash + export MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0 + ``` +4. Run: + ```bash + ./run-tests.sh tests/ui -v + ``` + Captures land under `tests/ui_captures///`, one + PNG + `.ocr.txt` per `frame_capture()` call, with a per-test + `transcript.md` stepping through event → frame → OCR. The HTML report + embeds the full image strip inline (pass or fail). + +On macOS, `cv2.VideoCapture(0)` triggers the TCC Camera permission prompt +on first use. Pre-grant Terminal (or your IDE's terminal) before running. +The `OpenCVBackend` fails fast on 10 consecutive black frames so a silent +permission denial surfaces as a clear error, not an empty PNG strip. + +No camera? Set `MESHTASTIC_UI_CAMERA_BACKEND=null` (or leave the device var +unset). Tests still exercise the event-injection path and log assertions; +captures just become 1×1 black PNGs. + ### Artifacts (regenerated every run, under `tests/`) - `report.html` — self-contained pytest-html report. Each test gets a @@ -217,6 +293,14 @@ Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open `report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q` quit (SIGINT → SIGTERM → SIGKILL escalation). +Set `MESHTASTIC_UI_TUI_CAMERA=1` to mount a bottom-of-screen **UI camera** +panel. Left side: the latest capture PNG rendered as Unicode half-blocks +(via `rich-pixels`, works in any terminal — no kitty/sixel required). +Right side: live transcript tail ("step 3 — frame 4/8 name=nodelist_nodes +— OCR: Nodes 2/2") so you can see every event-injection and its result +as each UI test runs. Requires the `[ui]` extras for image rendering; the +transcript alone works without them. + ### Slash commands Three AI-assisted workflows are wired up for Claude Code operators diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml index d73bf795f..3241c843f 100644 --- a/mcp-server/pyproject.toml +++ b/mcp-server/pyproject.toml @@ -23,6 +23,21 @@ test = [ # consumers; revisit if install cost pushes back. "textual>=0.50", ] +# UI test tier + `capture_screen` MCP tool. Optional because the ML OCR +# model alone is ~100 MB and camera hardware is user-supplied. +# pip install -e '.[ui]' — full (OpenCV + easyocr) +# pip install -e '.[ui-min]' — image capture only, no OCR +ui = [ + "opencv-python-headless>=4.9", + "numpy>=1.26", + "easyocr>=1.7", + "Pillow>=10.0", + # Renders the latest camera capture as Unicode half-blocks in the TUI + # (MESHTASTIC_UI_TUI_CAMERA=1). Terminal-agnostic — no kitty / sixel + # dependency. Pure Python, tiny. + "rich-pixels>=3.0", +] +ui-min = ["opencv-python-headless>=4.9", "numpy>=1.26"] [project.scripts] meshtastic-mcp = "meshtastic_mcp.__main__:main" diff --git a/mcp-server/run-tests.sh b/mcp-server/run-tests.sh index 292e6e3a2..c84a8f751 100755 --- a/mcp-server/run-tests.sh +++ b/mcp-server/run-tests.sh @@ -217,6 +217,40 @@ if [[ $# -eq 0 ]]; then -v --tb=short fi +# UI tier requires opencv-python-headless (and ideally easyocr). If it's +# not installed, auto-deselect tests/ui so operators without the [ui] +# extra still get a green run. Printed in yellow; silent when cv2 is +# present. +_cv2_ok=0 +if "$VENV_PY" -c "import cv2" >/dev/null 2>&1; then + _cv2_ok=1 +fi +_running_ui=0 +for _arg in "$@"; do + case "$_arg" in + *tests/ui* | tests/) _running_ui=1 ;; + *) ;; + esac +done +if [[ $_running_ui -eq 1 && $_cv2_ok -eq 0 ]]; then + printf '\033[33m[pre-flight] tests/ui tier detected, but opencv-python-headless is not installed — deselecting.\033[0m\n' + printf ' install with: .venv/bin/pip install -e "mcp-server/.[ui]"\n' + echo + set -- "$@" --ignore=tests/ui +fi + +# Recovery tier needs `uhubctl` on PATH — it power-cycles devices via USB +# hub PPPS. The tier's conftest already skips cleanly, so this is just a +# friendly heads-up before the skip happens. `baked_single`'s auto- +# recovery hook also benefits from having uhubctl available across the +# whole suite. +if ! command -v uhubctl >/dev/null 2>&1; then + printf "\033[33m[pre-flight] uhubctl not found on PATH — recovery tier will skip, and\n" + printf " wedged-device auto-recovery is disabled.\033[0m\n" + printf " install with: brew install uhubctl (macOS) or apt install uhubctl (Debian/Ubuntu).\n" + echo +fi + # Always emit `tests/reportlog.jsonl` (unless the operator explicitly passed # their own `--report-log=...`). Consumers — notably the # `meshtastic-mcp-test-tui` TUI — tail the reportlog for live per-test state. diff --git a/mcp-server/src/meshtastic_mcp/admin.py b/mcp-server/src/meshtastic_mcp/admin.py index 6da92d860..33f3865dd 100644 --- a/mcp-server/src/meshtastic_mcp/admin.py +++ b/mcp-server/src/meshtastic_mcp/admin.py @@ -356,6 +356,46 @@ def shutdown( return {"ok": True, "shutting_down_in_s": seconds} +def send_input_event( + event_code: int | str, + kb_char: int = 0, + touch_x: int = 0, + touch_y: int = 0, + port: str | None = None, +) -> dict[str, Any]: + """Inject an InputBroker event (button press / key / gesture) into the UI. + + Wraps `AdminMessage.send_input_event` (handled in firmware at + src/modules/AdminModule.cpp::handleSendInputEvent). Local-only — no PKI + warmup needed since the admin message is addressed to `my_node_num`. + + `event_code` accepts an int, a case-insensitive name + (`"RIGHT"` / `"input_broker_right"`), or an `InputEventCode`. The + firmware-side enum lives in src/input/InputBroker.h and is mirrored in + `meshtastic_mcp.input_events`. + """ + from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped] + + from .input_events import coerce_event_code + + code = coerce_event_code(event_code) + if not 0 <= kb_char <= 255: + raise ValueError(f"kb_char out of u8 range: {kb_char}") + if not 0 <= touch_x <= 65535: + raise ValueError(f"touch_x out of u16 range: {touch_x}") + if not 0 <= touch_y <= 65535: + raise ValueError(f"touch_y out of u16 range: {touch_y}") + + with connect(port=port) as iface: + msg = admin_pb2.AdminMessage() + msg.send_input_event.event_code = code + msg.send_input_event.kb_char = kb_char + msg.send_input_event.touch_x = touch_x + msg.send_input_event.touch_y = touch_y + iface.localNode._sendAdmin(msg) + return {"ok": True, "event_code": code, "kb_char": kb_char} + + def factory_reset( port: str | None = None, confirm: bool = False, full: bool = False ) -> dict[str, Any]: diff --git a/mcp-server/src/meshtastic_mcp/camera.py b/mcp-server/src/meshtastic_mcp/camera.py new file mode 100644 index 000000000..5f1e5ede3 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/camera.py @@ -0,0 +1,286 @@ +"""Cross-platform USB-webcam capture for UI tests + the `capture_screen` tool. + +Backends: +- `opencv` — cv2.VideoCapture (AVFoundation on macOS, V4L2 on Linux). +- `ffmpeg` — subprocess shelling out to the system `ffmpeg` binary. Slower + per frame, but zero Python deps beyond stdlib. +- `null` — no-op stub returning a 1×1 black PNG. Used when no camera is + configured; keeps code paths alive without forcing every operator to + hook up hardware. + +Environment variables (read at `get_camera()` call time): +- `MESHTASTIC_UI_CAMERA_BACKEND` — one of `opencv` / `ffmpeg` / `null` / + `auto` (default). `auto` picks opencv if `cv2` imports, else ffmpeg if + `ffmpeg --version` resolves, else null. +- `MESHTASTIC_UI_CAMERA_DEVICE` — generic default (index or path). +- `MESHTASTIC_UI_CAMERA_DEVICE_` — per-role override, e.g. + `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3. + Role suffix is uppercased before lookup. + +Dependencies land in the optional `[ui]` extra; imports are lazy so clients +without `opencv-python-headless` installed can still import this module. +""" + +from __future__ import annotations + +import io +import os +import shutil +import subprocess +import sys +import time +import warnings +from pathlib import Path +from typing import Protocol + + +class CameraError(RuntimeError): + """Raised when a camera backend fails to initialize or capture.""" + + +class CameraBackend(Protocol): + name: str + + def capture(self) -> bytes: + """Return one PNG-encoded frame.""" + ... + + def close(self) -> None: ... + + +# ---------- OpenCV backend ------------------------------------------------- + + +class OpenCVBackend: + name = "opencv" + + def __init__(self, device: int | str, warmup_frames: int = 5) -> None: + try: + import cv2 # type: ignore[import-untyped] # noqa: PLC0415 + except ImportError as exc: + raise CameraError( + "opencv backend requested but `cv2` is not installed. " + "Install the mcp-server [ui] extra: pip install -e '.[ui]'" + ) from exc + + self._cv2 = cv2 + device_arg: int | str + if isinstance(device, str) and device.isdigit(): + device_arg = int(device) + else: + device_arg = device + self._cap = cv2.VideoCapture(device_arg) + if not self._cap.isOpened(): + raise CameraError( + f"cv2.VideoCapture({device_arg!r}) failed to open. " + "On macOS check TCC Camera permission; on Linux check /dev/video* and v4l2 access." + ) + + # Drop the first few frames — auto-exposure + white-balance settle. + for _ in range(warmup_frames): + self._cap.read() + # Detect a stuck black-frame camera early rather than silently + # producing all-black captures. + ok, frame = self._cap.read() + if not ok or frame is None: + self._cap.release() + raise CameraError(f"camera {device_arg!r} opened but returned no frames") + + def capture(self) -> bytes: + cv2 = self._cv2 + ok, frame = self._cap.read() + if not ok or frame is None: + raise CameraError("cv2.VideoCapture.read() returned no frame") + success, buf = cv2.imencode(".png", frame) + if not success: + raise CameraError("cv2.imencode('.png', ...) failed") + return bytes(buf) + + def close(self) -> None: + try: + self._cap.release() + except Exception: # noqa: BLE001 + pass + + +# ---------- ffmpeg subprocess backend -------------------------------------- + + +class FfmpegBackend: + name = "ffmpeg" + + def __init__(self, device: int | str) -> None: + if shutil.which("ffmpeg") is None: + raise CameraError("ffmpeg backend requested but `ffmpeg` is not on PATH") + + self._device = str(device) + # Platform-specific -f flag: + # macOS → avfoundation (index like "0") + # Linux → v4l2 (device like "/dev/video0" or "0") + if sys.platform == "darwin": + self._input_format = "avfoundation" + self._input_spec = self._device # bare index for avfoundation + else: + self._input_format = "v4l2" + self._input_spec = ( + self._device + if self._device.startswith("/dev/") + else f"/dev/video{self._device}" + ) + + def capture(self) -> bytes: + cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-f", + self._input_format, + "-i", + self._input_spec, + "-frames:v", + "1", + "-f", + "image2pipe", + "-vcodec", + "png", + "-", + ] + try: + out = subprocess.run( + cmd, capture_output=True, check=True, timeout=15 # noqa: S603 + ) + except subprocess.CalledProcessError as exc: + raise CameraError( + f"ffmpeg capture failed (rc={exc.returncode}): {exc.stderr.decode(errors='replace')[:200]}" + ) from exc + except subprocess.TimeoutExpired as exc: + raise CameraError("ffmpeg capture timed out after 15s") from exc + return out.stdout + + def close(self) -> None: + pass # stateless — each capture spawns a new process + + +# ---------- Null backend --------------------------------------------------- + + +# A tiny valid 1×1 transparent PNG so callers always get a decodable image. +_BLACK_1X1_PNG = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" + "0000000d49444154789c6300010000000500010d0a2db40000000049454e44ae426082" +) + + +class NullBackend: + name = "null" + + def capture(self) -> bytes: + return _BLACK_1X1_PNG + + def close(self) -> None: + pass + + +# ---------- Factory -------------------------------------------------------- + + +def _resolve_device(role: str | None) -> str | None: + if role: + specific = os.environ.get(f"MESHTASTIC_UI_CAMERA_DEVICE_{role.upper()}") + if specific: + return specific + return os.environ.get("MESHTASTIC_UI_CAMERA_DEVICE") + + +def get_camera(role: str | None = None) -> CameraBackend: + """Return a CameraBackend for the given device role (e.g. `"esp32s3"`). + + Falls back to `NullBackend` if no camera is configured or the selected + backend fails to init — tests should treat captures as best-effort + evidence, not a blocker. + """ + backend = os.environ.get("MESHTASTIC_UI_CAMERA_BACKEND", "auto").lower() + device = _resolve_device(role) + + if backend in ("null", "none") or device is None: + return NullBackend() + + if backend == "auto": + # Prefer opencv if importable; fall back to ffmpeg; else null. + try: + import cv2 # type: ignore[import-untyped] # noqa: F401,PLC0415 + + backend = "opencv" + except ImportError: + backend = "ffmpeg" if shutil.which("ffmpeg") else "null" + + if backend == "opencv": + try: + return OpenCVBackend(device) + except CameraError as exc: + warnings.warn( + f"camera backend {backend!r} failed to initialize for device " + f"{device!r}: {exc}; falling back to null backend", + RuntimeWarning, + stacklevel=2, + ) + return NullBackend() + if backend == "ffmpeg": + try: + return FfmpegBackend(device) + except CameraError as exc: + warnings.warn( + f"camera backend {backend!r} failed to initialize for device " + f"{device!r}: {exc}; falling back to null backend", + RuntimeWarning, + stacklevel=2, + ) + return NullBackend() + if backend == "null": + return NullBackend() + + raise CameraError(f"unknown MESHTASTIC_UI_CAMERA_BACKEND: {backend!r}") + + +def save_capture(png_bytes: bytes, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(png_bytes) + + +def capture_to_file(role: str | None, path: Path) -> dict[str, object]: + """One-shot: open camera, capture, write PNG, close. Returns metadata.""" + started = time.monotonic() + cam = get_camera(role) + try: + data = cam.capture() + finally: + cam.close() + save_capture(data, path) + return { + "backend": cam.name, + "path": str(path), + "bytes": len(data), + "elapsed_s": round(time.monotonic() - started, 3), + } + + +def _is_png(data: bytes) -> bool: + return data.startswith(b"\x89PNG\r\n\x1a\n") + + +# Exposed so callers can sanity-check a capture without a full PIL import. +__all__ = [ + "CameraBackend", + "CameraError", + "FfmpegBackend", + "NullBackend", + "OpenCVBackend", + "capture_to_file", + "get_camera", + "save_capture", +] + +# Keep `io` import used (pyflakes is picky) via a small guard used at import +# time to normalize stdin/stdout if a subclass ever needs it. +_ = io.BytesIO # noqa: SLF001 diff --git a/mcp-server/src/meshtastic_mcp/cli/_uicap.py b/mcp-server/src/meshtastic_mcp/cli/_uicap.py new file mode 100644 index 000000000..448459954 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/cli/_uicap.py @@ -0,0 +1,83 @@ +"""UI-capture transcript tailer for ``meshtastic-mcp-test-tui``. + +Watches ``tests/ui_captures//`` for new transcript lines +(one per ``frame_capture()`` call from the UI tier) and posts them to +the TUI. Enabled by ``MESHTASTIC_UI_TUI_CAMERA=1``. + +Design mirrors ``_flashlog.py``: +- Daemon thread, cooperative stop via ``threading.Event``. +- Tolerates the captures directory not existing yet (UI tier hasn't run). +- Per-file seek state so we only forward genuinely-new lines. +""" + +from __future__ import annotations + +import pathlib +import threading +import time +from typing import Callable + + +class UiCaptureTailer(threading.Thread): + """Recursively watch a captures root for new `transcript.md` lines. + + Invokes ``post(test_id, line)`` for each new line, where ``test_id`` + is derived from the path — the sanitized nodeid directory name. + """ + + def __init__( + self, + root: pathlib.Path, + post: Callable[[str, str], None], + stop: threading.Event, + *, + poll_interval: float = 0.5, + ) -> None: + super().__init__(daemon=True, name="uicap-tail") + self._root = root + self._post = post + self._stop = stop + self._poll_interval = poll_interval + # path → byte offset we've already read through + self._offsets: dict[pathlib.Path, int] = {} + + def run(self) -> None: + while not self._stop.is_set(): + try: + self._scan_once() + except Exception: + # Best-effort tailer — never bring down the TUI because a + # directory vanished mid-scan. + pass + time.sleep(self._poll_interval) + + def _scan_once(self) -> None: + if not self._root.is_dir(): + return + for transcript in self._root.rglob("transcript.md"): + test_id = transcript.parent.name + offset = self._offsets.get(transcript, 0) + try: + size = transcript.stat().st_size + except OSError: + continue + if size < offset: + # File truncated / rewritten — reset and re-emit. + offset = 0 + if size == offset: + continue + try: + with transcript.open("rb") as fh: + fh.seek(offset) + chunk = fh.read(size - offset).decode("utf-8", errors="replace") + except OSError: + continue + for line in chunk.splitlines(): + line = line.rstrip() + if not line or line.startswith("#"): + continue + try: + self._post(test_id, line) + except Exception: + return + self._offsets[transcript] = size diff --git a/mcp-server/src/meshtastic_mcp/cli/test_tui.py b/mcp-server/src/meshtastic_mcp/cli/test_tui.py index 33201101b..7f3a2da36 100644 --- a/mcp-server/src/meshtastic_mcp/cli/test_tui.py +++ b/mcp-server/src/meshtastic_mcp/cli/test_tui.py @@ -518,6 +518,7 @@ def _build_app( from . import _fwlog as _fwlog_mod from . import _history as _history_mod from . import _reproducer as _reproducer_mod + from . import _uicap as _uicap_mod # ---------------- Messages ---------------- @@ -548,6 +549,16 @@ def _build_app( self.line = line super().__init__() + class UiCaptureLine(tx.Message): + """Live line from the UI-tier camera transcript — one per + `frame_capture()` call. Posted only when the camera panel is + enabled via `MESHTASTIC_UI_TUI_CAMERA=1`.""" + + def __init__(self, test_id: str, line: str) -> None: + self.test_id = test_id + self.line = line + super().__init__() + class DeviceSnapshot(tx.Message): def __init__(self, rows: list[DeviceRow]) -> None: self.rows = rows @@ -871,6 +882,10 @@ def _build_app( #pytest-pane { height: 50%; border-bottom: solid $primary-background; } #fwlog-header { height: 1; padding: 0 1; background: $panel; } #fwlog-pane { height: 1fr; } + #uicap-header { height: 1; padding: 0 1; background: $boost; } + #uicap-pane { height: 14; border-top: solid $primary-background; } + #uicap-image { width: 36; border-right: solid $primary-background; padding: 0 1; } + #uicap-log { width: 1fr; height: 14; } Tree { height: 100%; } RichLog { height: 100%; } #device-table { height: auto; max-height: 6; } @@ -912,6 +927,11 @@ def _build_app( self._device_worker: DevicePollerWorker | None = None self._fwlog_worker: _fwlog_mod.FirmwareLogTailer | None = None self._flashlog_worker: _flashlog_mod.FlashLogTailer | None = None + self._uicap_worker: _uicap_mod.UiCaptureTailer | None = None + # Env-gated; only mounts the UI-capture panel when operator asks for it. + self._ui_camera_enabled = bool( + int(os.environ.get("MESHTASTIC_UI_TUI_CAMERA", "0") or "0") + ) self._tree_filter: str = "" self._sigint_count = 0 # Firmware-log port filter: None = all, else exact port match. @@ -959,6 +979,22 @@ def _build_app( wrap=True, max_lines=5000, ) + if self._ui_camera_enabled: + yield tx.Static( + "UI camera — latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)", + id="uicap-header", + ) + with tx.Horizontal(id="uicap-pane"): + yield tx.Static( + "(waiting…)", id="uicap-image", markup=False + ) + yield tx.RichLog( + id="uicap-log", + highlight=False, + markup=False, + wrap=True, + max_lines=500, + ) yield tx.DataTable(id="device-table", show_cursor=False) yield tx.Footer() @@ -1023,6 +1059,21 @@ def _build_app( stop=self._stop, ) self._flashlog_worker.start() + # UI-capture transcript tailer — only runs when the camera panel + # is enabled. Watches tests/ui_captures/**/transcript.md for new + # lines as UI tests execute. + if self._ui_camera_enabled: + captures_root = self._root / "mcp-server" / "tests" / "ui_captures" + # When the TUI is launched from inside mcp-server (the usual + # case), `self._root` is already mcp-server/, so adjust: + if not captures_root.parent.name == "mcp-server": + captures_root = self._root / "tests" / "ui_captures" + self._uicap_worker = _uicap_mod.UiCaptureTailer( + root=captures_root, + post=lambda tid, line: self.post_message(UiCaptureLine(tid, line)), + stop=self._stop, + ) + self._uicap_worker.start() self._spawn_pytest(self._pytest_args) # Header tick (seed / runtime / sparkline re-renders at 1 Hz). # Also refreshes the device-status column so the per-test elapsed @@ -1217,6 +1268,84 @@ def _build_app( log = self.query_one("#pytest-log", tx.RichLog) log.write(f"[flash] {message.line}") + def on_ui_capture_line(self, message: Any) -> None: + """Route a UI-capture transcript line into the camera panel. + + Each line is already formatted by frame_capture — e.g. + `1. **initial** — frame 2/8 name=home — OCR: ...`. We write + the text into the RichLog AND try to render the corresponding + PNG on the left side (requires rich-pixels, Pillow). + """ + if not self._ui_camera_enabled: + return + try: + log_panel = self.query_one("#uicap-log", tx.RichLog) + except Exception: + return + log_panel.write(f"[{message.test_id}] {message.line}") + self._render_latest_ui_capture(message.test_id, message.line) + + def _render_latest_ui_capture(self, test_id: str, line: str) -> None: + """Find the PNG that corresponds to `line` and render it on the + left of the uicap pane. Soft-fails if rich-pixels isn't + installed or the PNG isn't found — operator still has the text + transcript on the right. + """ + try: + from PIL import Image # type: ignore[import-untyped] + from rich_pixels import Pixels # type: ignore[import-untyped] + except ImportError: + return + + # Transcript lines look like `1. **label** — ...`. Pull the leading + # integer to locate the capture file. + import re as _re + + m = _re.match(r"\s*(\d+)\.\s", line) + if not m: + return + step = int(m.group(1)) + + # Captures directory is sibling of tests/ — mirror the path the + # tailer watches. Search both likely layouts (in-mcp-server vs. + # firmware-root invocation). + candidates = [ + self._root / "tests" / "ui_captures", + self._root / "mcp-server" / "tests" / "ui_captures", + ] + captures_root = next((p for p in candidates if p.is_dir()), None) + if captures_root is None: + return + + # Drill into // — test_id is the + # sanitized nodeid the tailer already passed through. + matches = list(captures_root.rglob(f"{test_id}/{step:03d}-*.png")) + if not matches: + return + png_path = matches[-1] + + try: + img = Image.open(png_path).convert("RGB") + # Resize to fit ~32 cells wide × ~12 rows tall (half-block + # renderer gives 2× vertical resolution, so 32×24 px input + # lands at ~32×12 cells). Keep aspect ratio. + target_w = 60 + w, h = img.size + target_h = max(1, int(h * (target_w / max(1, w)))) + # Clamp: the image panel is 14 rows; half-blocks give 2 rows + # per vertical cell, so cap pixel height at ~26. + target_h = min(target_h, 26) + img = img.resize((target_w, target_h)) + pixels = Pixels.from_image(img) + except Exception: + return + + try: + image_widget = self.query_one("#uicap-image", tx.Static) + image_widget.update(pixels) + except Exception: + pass + def on_firmware_log_line(self, message: Any) -> None: rec = message.record port = rec.get("port") diff --git a/mcp-server/src/meshtastic_mcp/config.py b/mcp-server/src/meshtastic_mcp/config.py index 7ece10032..d3006a73e 100644 --- a/mcp-server/src/meshtastic_mcp/config.py +++ b/mcp-server/src/meshtastic_mcp/config.py @@ -135,3 +135,14 @@ def picotool_bin() -> Path: ("picotool",), "Install via `brew install picotool` or build from https://github.com/raspberrypi/picotool.", ) + + +def uhubctl_bin() -> Path: + return _hw_tool( + "MESHTASTIC_UHUBCTL_BIN", + ("uhubctl",), + "Install via `brew install uhubctl` (macOS) or `apt install uhubctl` " + "(Debian/Ubuntu). On Linux without the udev rules, or on older macOS " + "with certain hubs, you may need to run via `sudo`: " + "https://github.com/mvp/uhubctl#linux-usb-permissions", + ) diff --git a/mcp-server/src/meshtastic_mcp/input_events.py b/mcp-server/src/meshtastic_mcp/input_events.py new file mode 100644 index 000000000..f6b287ba3 --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/input_events.py @@ -0,0 +1,67 @@ +"""Python mirror of firmware `enum input_broker_event` (src/input/InputBroker.h). + +Used by `admin.send_input_event` + `tests/ui/` so callers can say +`InputEventCode.RIGHT` instead of hard-coding 20. Values MUST stay in sync +with the firmware enum — unit test `tests/unit/test_input_event_codes.py` +pins the mapping. +""" + +from __future__ import annotations + +from enum import IntEnum + + +class InputEventCode(IntEnum): + """Button / key / gesture events dispatched by the firmware InputBroker.""" + + NONE = 0 + SELECT = 10 + SELECT_LONG = 11 + UP_LONG = 12 + DOWN_LONG = 13 + UP = 17 + DOWN = 18 + LEFT = 19 + RIGHT = 20 + CANCEL = 24 + BACK = 27 + # Auto-incremented values in the C enum (27 + 1, +2, +3): + USER_PRESS = 28 + ALT_PRESS = 29 + ALT_LONG = 30 + SHUTDOWN = 0x9B + GPS_TOGGLE = 0x9E + SEND_PING = 0xAF + FN_F1 = 0xF1 + FN_F2 = 0xF2 + FN_F3 = 0xF3 + FN_F4 = 0xF4 + FN_F5 = 0xF5 + MATRIXKEY = 0xFE + ANYKEY = 0xFF + + +def coerce_event_code(value: int | str | InputEventCode) -> int: + """Accept an int, a case-insensitive name, or an `InputEventCode` and return + the u8 wire value. Raises ValueError on unknown names / out-of-range ints. + """ + if isinstance(value, InputEventCode): + return int(value) + if isinstance(value, int): + if not 0 <= value <= 255: + raise ValueError(f"event_code out of u8 range: {value}") + return value + if isinstance(value, str): + key = value.upper().replace("-", "_") + if key.startswith("INPUT_BROKER_"): + key = key[len("INPUT_BROKER_") :] + try: + return int(InputEventCode[key]) + except KeyError as exc: + known = ", ".join(m.name for m in InputEventCode) + raise ValueError( + f"unknown event code name {value!r}; known: {known}" + ) from exc + raise TypeError( + f"event_code must be int|str|InputEventCode, got {type(value).__name__}" + ) diff --git a/mcp-server/src/meshtastic_mcp/ocr.py b/mcp-server/src/meshtastic_mcp/ocr.py new file mode 100644 index 000000000..f40bc3cbd --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/ocr.py @@ -0,0 +1,147 @@ +"""OCR wrapper for UI tests + the `capture_screen` tool. + +Auto-selects a reader in priority order: + 1. `easyocr` (deep-learning, high quality on OLED screens — but ~100 MB + model download on first use). + 2. `pytesseract` (requires system `tesseract` binary on PATH). + 3. `null` — returns `""` with a warning. Tests fall back to log + image + evidence when OCR is unavailable. + +Override via `MESHTASTIC_UI_OCR_BACKEND=easyocr|pytesseract|null|auto` +(default `auto`). + +`ocr_text(png_bytes) -> str` is the only public entry point. The reader is +constructed lazily on first call and cached, so the easyocr cold-start cost +only hits once per process. +""" + +from __future__ import annotations + +import functools +import logging +import os +import shutil +import sys +from typing import Callable + +log = logging.getLogger(__name__) + + +def _backend_choice() -> str: + return os.environ.get("MESHTASTIC_UI_OCR_BACKEND", "auto").lower() + + +@functools.lru_cache(maxsize=1) +def _reader() -> tuple[str, Callable[[bytes], str]]: + """Return `(backend_name, callable)` for whichever OCR is available.""" + choice = _backend_choice() + + def _easyocr() -> tuple[str, Callable[[bytes], str]]: + import easyocr # type: ignore[import-untyped] # noqa: PLC0415 + import numpy as np # type: ignore[import-untyped] # noqa: PLC0415 + + reader = easyocr.Reader(["en"], gpu=False, verbose=False) + + def _run(png: bytes) -> str: + try: + import cv2 # type: ignore[import-untyped] # noqa: PLC0415 + + arr = np.frombuffer(png, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + except ImportError: + # Fall back to PIL if cv2 isn't around. + from io import BytesIO # noqa: PLC0415 + + from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415 + + img = np.array(Image.open(BytesIO(png)).convert("RGB")) + try: + results = reader.readtext(img, detail=0, paragraph=True) + except Exception as exc: # noqa: BLE001 + log.warning("easyocr failed: %s", exc) + return "" + return "\n".join(str(r) for r in results) + + return "easyocr", _run + + def _pytesseract() -> tuple[str, Callable[[bytes], str]]: + from io import BytesIO # noqa: PLC0415 + + import pytesseract # type: ignore[import-untyped] # noqa: PLC0415 + from PIL import Image # type: ignore[import-untyped] # noqa: PLC0415 + + if shutil.which("tesseract") is None: + raise ImportError("`tesseract` binary not on PATH") + + def _run(png: bytes) -> str: + try: + return str(pytesseract.image_to_string(Image.open(BytesIO(png)))) + except Exception as exc: # noqa: BLE001 + log.warning("pytesseract failed: %s", exc) + return "" + + return "pytesseract", _run + + def _null() -> tuple[str, Callable[[bytes], str]]: + log.warning( + "OCR backend is null; install easyocr or tesseract for text extraction" + ) + return "null", lambda _png: "" + + if choice == "easyocr": + return _easyocr() + if choice == "pytesseract": + return _pytesseract() + if choice == "null": + return _null() + if choice != "auto": + print( + f"[ocr] unknown MESHTASTIC_UI_OCR_BACKEND={choice!r}; falling back to auto", + file=sys.stderr, + ) + + # auto mode + try: + return _easyocr() + except ImportError: + pass + try: + return _pytesseract() + except ImportError: + pass + return _null() + + +def ocr_text(png_bytes: bytes) -> str: + """Run OCR on a PNG-encoded image and return the decoded text (possibly empty).""" + if not png_bytes: + return "" + _, run = _reader() + return run(png_bytes) + + +def backend_name() -> str: + """Return the currently-selected backend name, initializing if necessary.""" + name, _ = _reader() + return name + + +def warm() -> None: + """Run one dummy inference so the easyocr cold-start cost is paid upfront. + + Pytest session fixture calls this once so the first real capture doesn't + eat the model-load latency. + """ + # A 64×32 white PNG — decodes clean, no text to extract. + white_png = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000400000002008060000007ccac28e" + "0000001c49444154785eedc1010d000000c2a0f74f6d0d370000000000000080" + "0b010000ffff030000000000000049454e44ae426082" + ) + try: + ocr_text(white_png) + except Exception as exc: # noqa: BLE001 + log.warning("ocr.warm() failed: %s", exc) + + +__all__ = ["backend_name", "ocr_text", "warm"] diff --git a/mcp-server/src/meshtastic_mcp/server.py b/mcp-server/src/meshtastic_mcp/server.py index 7cb8db65e..83aa80c45 100644 --- a/mcp-server/src/meshtastic_mcp/server.py +++ b/mcp-server/src/meshtastic_mcp/server.py @@ -1,4 +1,4 @@ -"""FastMCP server wiring — 38 tools across 7 categories. +"""FastMCP server wiring — 43 tools across 9 categories (adds uhubctl power control). Each tool handler is a thin delegation to a named module (pio.py, admin.py, etc.). Business logic does not live here. @@ -513,6 +513,152 @@ def factory_reset( return admin.factory_reset(port=port, confirm=confirm, full=full) +@app.tool() +def send_input_event( + event_code: int | str, + kb_char: int = 0, + touch_x: int = 0, + touch_y: int = 0, + port: str | None = None, +) -> dict[str, Any]: + """Inject an InputBroker event (button / key / gesture) into the device UI. + + Drives the same code path as a physical button press. Accepts a numeric + event code (0..255) or a name like `"RIGHT"`, `"SELECT"`, `"FN_F1"`. + + Common codes: SELECT=10, UP=17, DOWN=18, LEFT=19, RIGHT=20, CANCEL=24, + BACK=27, FN_F1..F5=241..245. + """ + return admin.send_input_event( + event_code=event_code, + kb_char=kb_char, + touch_x=touch_x, + touch_y=touch_y, + port=port, + ) + + +@app.tool() +def capture_screen(role: str | None = None, ocr: bool = True) -> dict[str, Any]: + """Grab a frame from the USB webcam pointed at the device screen. + + Returns PNG bytes (base64), optional OCR text, and backend metadata. + Requires the `[ui]` extras (opencv-python-headless) and a camera + configured via `MESHTASTIC_UI_CAMERA_DEVICE[_]`. Falls back to a + 1×1 black PNG from the null backend when no camera is configured. + """ + import base64 + + from . import camera as camera_mod + + cam = camera_mod.get_camera(role) + try: + png = cam.capture() + finally: + cam.close() + + result: dict[str, Any] = { + "backend": cam.name, + "bytes": len(png), + "image_base64": base64.b64encode(png).decode("ascii"), + } + if ocr: + from . import ocr as ocr_mod + + result["ocr_backend"] = ocr_mod.backend_name() + result["ocr_text"] = ocr_mod.ocr_text(png) + return result + + +# ---------- USB power control (uhubctl) ----------------------------------- + + +@app.tool() +def uhubctl_list() -> list[dict[str, Any]]: + """List every USB hub + per-port device attachment as seen by `uhubctl`. + + Read-only — no confirm required. Each hub entry includes its location + (`1-1.3`), descriptor, whether it supports Per-Port Power Switching, + and a list of populated ports with VID:PID of attached devices. + Useful for pre-flight checks before a destructive power-cycle call. + """ + from . import uhubctl as uhubctl_mod + + return uhubctl_mod.list_hubs() + + +@app.tool() +def uhubctl_power( + action: str, + location: str | None = None, + port: int | None = None, + role: str | None = None, + confirm: bool = False, +) -> dict[str, Any]: + """Power a USB hub port on or off via `uhubctl -a on|off`. + + Target the port by either (`location`, `port`) — raw uhubctl syntax, + e.g. `location="1-1.3", port=2` — OR by `role` ("nrf52", "esp32s3"). + Role lookup honors `MESHTASTIC_UHUBCTL_LOCATION_` + + `_PORT_` env vars first, falls back to VID auto-detection. + + `action="off"` requires `confirm=True` (destructive — the attached + device will immediately disappear from the OS). + """ + from . import uhubctl as uhubctl_mod + + action_lower = action.lower() + if action_lower not in {"on", "off"}: + raise ValueError(f"action must be 'on' or 'off', got {action!r}") + if action_lower == "off" and not confirm: + raise uhubctl_mod.UhubctlError( + "uhubctl_power action='off' requires confirm=True" + ) + loc, p = _resolve_uhubctl_target(location, port, role) + if action_lower == "on": + return uhubctl_mod.power_on(loc, p) + return uhubctl_mod.power_off(loc, p) + + +@app.tool() +def uhubctl_cycle( + location: str | None = None, + port: int | None = None, + role: str | None = None, + delay_s: int = 2, + confirm: bool = False, +) -> dict[str, Any]: + """Power a USB hub port off, wait `delay_s` seconds, then on. + + The typical hard-reset sequence — shorter than off+on as two RPCs + because uhubctl handles the timing in-process. Target by (location, + port) or by role (see `uhubctl_power`). Requires `confirm=True`. + """ + from . import uhubctl as uhubctl_mod + + if not confirm: + raise uhubctl_mod.UhubctlError("uhubctl_cycle requires confirm=True") + if delay_s < 0 or delay_s > 60: + raise ValueError(f"delay_s must be 0..60, got {delay_s}") + loc, p = _resolve_uhubctl_target(location, port, role) + return uhubctl_mod.cycle(loc, p, delay_s=delay_s) + + +def _resolve_uhubctl_target( + location: str | None, port: int | None, role: str | None +) -> tuple[str, int]: + """Shared arg-resolution for uhubctl_power + uhubctl_cycle.""" + from . import uhubctl as uhubctl_mod + + if role is not None: + if location is not None or port is not None: + raise ValueError("pass either `role` OR (`location` + `port`), not both") + return uhubctl_mod.resolve_target(role) + if location is None or port is None: + raise ValueError("must pass `role` or both `location` and `port`") + return (location, int(port)) + + # ---------- Direct hardware tools ----------------------------------------- diff --git a/mcp-server/src/meshtastic_mcp/uhubctl.py b/mcp-server/src/meshtastic_mcp/uhubctl.py new file mode 100644 index 000000000..e0306386b --- /dev/null +++ b/mcp-server/src/meshtastic_mcp/uhubctl.py @@ -0,0 +1,321 @@ +"""USB hub power control via `uhubctl` — hard-recovery for wedged devices + +deliberate offline-peer simulation for mesh tests. + +Why: when a Meshtastic device's serial port wedges (stuck in a boot loop, +frozen USB CDC, crashed firmware that didn't reboot), the only recovery is +a physical unplug. uhubctl toggles VBUS per-port on any hub with Per-Port +Power Switching (PPPS) support — which is most externally-powered hubs +from the last ~5 years — so the harness can power-cycle a device +programmatically. + +Architecture: +- `list_hubs()` parses `uhubctl` default output into structured records. +- `find_port_for_vid(vid)` walks the hubs to find which location+port + hosts a given USB VID. +- `resolve_target(role)` is the public entry for callers that know a role + (`nrf52`, `esp32s3`) but not a hub location: env-var pins win, VID + auto-detect falls back. +- `power_on`, `power_off`, `cycle` wrap the corresponding `uhubctl -a` + invocations, routed through `hw_tools._run` so they share tee-to-flash- + log + timeout handling with esptool / nrfutil / picotool. + +Sudo policy: **fail fast**. Modern macOS + most PPPS-capable hubs work +without root, but Linux without udev rules (or old macOS with specific +driver quirks) still needs it. We run uhubctl non-root; if stderr +matches the classic permission pattern we raise `UhubctlError` with an +install hint pointing at the uhubctl docs. Auto-wrapping with `sudo` +would prompt in the middle of test runs — bad for CI. +""" + +from __future__ import annotations + +import os +import re +from typing import Any, Sequence + +from . import config, hw_tools + +# ---------- Parser --------------------------------------------------------- + +# Hub descriptor line: +# Current status for hub 1-1.3 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps] +_HUB_RE = re.compile( + r"^Current status for hub (?P\S+)\s+\[(?P.+)\]\s*$" +) + +# Port line: +# " Port 2: 0103 power enable connect [239a:8029 RAKwireless ...]" +# The bracketed section is absent for empty ports. +_PORT_RE = re.compile( + r"^\s+Port\s+(?P\d+):\s+(?P\S+)\s+(?P.*?)" + r"(?:\s+\[(?P[0-9a-fA-F]{4}):(?P[0-9a-fA-F]{4})(?:\s+(?P.+))?\])?\s*$" +) + + +class UhubctlError(RuntimeError): + """Raised on uhubctl-specific failures: parse errors, permission denied, + hub-not-found, or PPPS not supported.""" + + +# ---------- Role → VID map ------------------------------------------------- + +# Mirrors the default hub_profile in `mcp-server/tests/conftest.py:335`. +# Note: esp32s3 and esp32s3_alt share a logical role — we search both. +ROLE_VIDS: dict[str, tuple[int, ...]] = { + "nrf52": (0x239A,), + "esp32s3": (0x303A, 0x10C4), +} + + +def _normalize_role(role: str) -> str: + """Collapse `esp32s3_alt` → `esp32s3` to match the tier conventions.""" + return role.split("_alt", 1)[0].lower() + + +# ---------- Core subprocess runner ----------------------------------------- + + +# If uhubctl hits a permission problem — most commonly Linux without the +# udev rules, or a macOS variant where the kernel holds the hub driver — +# it prints something like "Permission denied. Try running as root". +# Linux error text varies; we match a broad substring rather than exact. +_PERM_ERROR_PATTERNS = ( + "permission denied", + "operation not permitted", + "try running as root", + "need root", + "requires root", +) + + +def _run_uhubctl(args: Sequence[str], *, timeout: float = 30.0) -> dict[str, Any]: + """Invoke uhubctl with the given args. Returns `hw_tools._run`'s dict. + + Translates permission-denied failures into a `UhubctlError` with the + install hint, so callers don't have to match stderr themselves. Other + non-zero exits are returned as-is for the caller to interpret. + """ + binary = config.uhubctl_bin() + result = hw_tools._run(binary, args, timeout=timeout) # noqa: SLF001 + if result["exit_code"] != 0: + combined = (result.get("stderr") or "") + "\n" + (result.get("stdout") or "") + lower = combined.lower() + if any(pat in lower for pat in _PERM_ERROR_PATTERNS): + raise UhubctlError( + "uhubctl exited with a permission error. Install the udev " + "rules on Linux, or try `sudo` as a fallback: " + "https://github.com/mvp/uhubctl#linux-usb-permissions\n" + f"stderr: {result.get('stderr_tail')!r}" + ) + return result + + +# ---------- List / parse --------------------------------------------------- + + +def parse_list_output(output: str) -> list[dict[str, Any]]: + """Parse the default `uhubctl` stdout into structured hubs. + + Each hub: { + "location": "1-1.3", + "descriptor": "2109:2817 VIA Labs ...", + "vid": 0x2109, + "pid": 0x2817, + "ppps": bool, + "ports": [{"port": int, "status": str, "flags": str, + "device_vid": int | None, "device_pid": int | None, + "device_desc": str | None}, ...], + } + """ + hubs: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + + for line in output.splitlines(): + hm = _HUB_RE.match(line) + if hm: + descriptor = hm.group("descriptor") + hub_vid, hub_pid = None, None + vid_match = re.match(r"([0-9a-fA-F]{4}):([0-9a-fA-F]{4})", descriptor) + if vid_match: + hub_vid = int(vid_match.group(1), 16) + hub_pid = int(vid_match.group(2), 16) + current = { + "location": hm.group("location"), + "descriptor": descriptor, + "vid": hub_vid, + "pid": hub_pid, + "ppps": ", ppps" in descriptor or descriptor.endswith("ppps"), + "ports": [], + } + hubs.append(current) + continue + + pm = _PORT_RE.match(line) + if pm and current is not None: + device_vid = pm.group("device_vid") + device_pid = pm.group("device_pid") + current["ports"].append( + { + "port": int(pm.group("port")), + "status": pm.group("status"), + "flags": (pm.group("flags") or "").strip(), + "device_vid": int(device_vid, 16) if device_vid else None, + "device_pid": int(device_pid, 16) if device_pid else None, + "device_desc": (pm.group("device_desc") or "").strip() or None, + } + ) + return hubs + + +def list_hubs() -> list[dict[str, Any]]: + """Enumerate every hub uhubctl can see, with per-port device attachments. + + Pure read — no power state changes. Useful as a pre-flight check before + a destructive `power_off` call. + """ + result = _run_uhubctl([], timeout=15.0) + if result["exit_code"] != 0: + raise UhubctlError( + f"uhubctl list failed (exit {result['exit_code']}): {result.get('stderr_tail')!r}" + ) + return parse_list_output(result["stdout"]) + + +# ---------- Lookup / resolution ------------------------------------------- + + +def find_port_for_vid( + vid: int, pid: int | None = None, *, only_ppps: bool = True +) -> list[tuple[str, int]]: + """Return ALL (location, port) matches for a device VID (optionally +PID). + + `only_ppps=True` filters out hubs that don't advertise PPPS — we can't + control them anyway. Callers that want to diagnose a missing device can + pass `only_ppps=False` to see if the device is on a non-controllable + hub (and raise a clearer error). + """ + hubs = list_hubs() + matches: list[tuple[str, int]] = [] + for hub in hubs: + if only_ppps and not hub["ppps"]: + continue + for port in hub["ports"]: + if port["device_vid"] != vid: + continue + if pid is not None and port["device_pid"] != pid: + continue + matches.append((hub["location"], port["port"])) + return matches + + +def resolve_target(role: str) -> tuple[str, int]: + """Resolve a Meshtastic role to (hub_location, port_number). + + Priority: + 1. Env vars `MESHTASTIC_UHUBCTL_LOCATION_` + `_PORT_` + (e.g. `MESHTASTIC_UHUBCTL_LOCATION_NRF52=1-1.3`, `_PORT_NRF52=2`). + 2. VID auto-detect against `ROLE_VIDS[role]`, taking the first PPPS + match. + + Raises `UhubctlError` on ambiguity (multiple matches) or no-match. The + env-var path exists specifically to disambiguate when two devices share + a VID. + """ + role = _normalize_role(role) + env_key_loc = f"MESHTASTIC_UHUBCTL_LOCATION_{role.upper()}" + env_key_port = f"MESHTASTIC_UHUBCTL_PORT_{role.upper()}" + loc = os.environ.get(env_key_loc) + port_str = os.environ.get(env_key_port) + if loc and port_str: + try: + return (loc, int(port_str)) + except ValueError as exc: + raise UhubctlError( + f"{env_key_port}={port_str!r} is not a valid integer" + ) from exc + + if role not in ROLE_VIDS: + raise UhubctlError( + f"unknown role {role!r}; known roles: {sorted(ROLE_VIDS)}. " + f"Set {env_key_loc} + {env_key_port} to pin manually." + ) + + matches: list[tuple[str, int]] = [] + for vid in ROLE_VIDS[role]: + matches.extend(find_port_for_vid(vid)) + + if not matches: + vids = ", ".join(f"0x{v:04x}" for v in ROLE_VIDS[role]) + raise UhubctlError( + f"no controllable hub hosts a device with VID in {{{vids}}} " + f"for role={role!r}. Check the device is plugged into a " + f"PPPS-capable hub, or pin manually via {env_key_loc} + {env_key_port}." + ) + if len(matches) > 1: + shown = ", ".join(f"{loc}:port{p}" for loc, p in matches) + raise UhubctlError( + f"ambiguous: multiple devices match role={role!r} ({shown}). " + f"Pin the target via {env_key_loc} + {env_key_port}." + ) + return matches[0] + + +# ---------- Power actions -------------------------------------------------- + + +def _action( + action: str, + location: str, + port: int, + *, + delay_s: int | None = None, + timeout: float = 30.0, +) -> dict[str, Any]: + args: list[str] = ["-a", action, "-l", location, "-p", str(port)] + if delay_s is not None: + args.extend(["-d", str(delay_s)]) + # Suppress verbose "before" printout so our parser doesn't have to skip it. + args.append("-N") + result = _run_uhubctl(args, timeout=timeout) + if result["exit_code"] != 0: + raise UhubctlError( + f"uhubctl -a {action} -l {location} -p {port} failed " + f"(exit {result['exit_code']}): {result.get('stderr_tail')!r}" + ) + return { + "action": action, + "location": location, + "port": port, + "delay_s": delay_s, + "duration_s": result["duration_s"], + } + + +def power_on(location: str, port: int) -> dict[str, Any]: + """Drive the port VBUS high. Device re-enumerates in 1-3 s on healthy hubs.""" + return _action("on", location, port) + + +def power_off(location: str, port: int) -> dict[str, Any]: + """Drive the port VBUS low. Device disappears from `list_devices` immediately.""" + return _action("off", location, port) + + +def cycle(location: str, port: int, delay_s: int = 2) -> dict[str, Any]: + """Off → wait `delay_s` → on. The common hard-reset pattern.""" + # uhubctl's own `-a cycle` handles the delay internally; we use a + # slightly longer timeout to accommodate delay_s + enumeration. + return _action("cycle", location, port, delay_s=delay_s, timeout=30.0 + delay_s * 2) + + +__all__ = [ + "ROLE_VIDS", + "UhubctlError", + "cycle", + "find_port_for_vid", + "list_hubs", + "parse_list_output", + "power_off", + "power_on", + "resolve_target", +] diff --git a/mcp-server/src/meshtastic_mcp/userprefs.py b/mcp-server/src/meshtastic_mcp/userprefs.py index 59d7165f9..d5f8bab69 100644 --- a/mcp-server/src/meshtastic_mcp/userprefs.py +++ b/mcp-server/src/meshtastic_mcp/userprefs.py @@ -393,6 +393,7 @@ def build_testing_profile( long_name: str | None = None, disable_mqtt: bool = True, disable_position: bool = False, + enable_ui_log: bool = False, ) -> dict[str, Any]: """Build a USERPREFS dict for an isolated test-mesh device. @@ -423,6 +424,10 @@ def build_testing_profile( traffic never leaks to a public broker. disable_position: if True, disables GPS + position broadcasts — useful when test devices sit on a bench without antennas. + enable_ui_log: if True, stamps `USERPREFS_UI_TEST_LOG=true` so the + firmware emits one `Screen: frame N/M name=... reason=...` log + line per frame transition. Test-only; off by default because the + log is chatty (multiple times per second during UI interaction). """ if region not in KNOWN_REGIONS: @@ -475,6 +480,9 @@ def build_testing_profile( prefs["USERPREFS_CONFIG_OWNER_LONG_NAME"] = long_name if short_name is not None: prefs["USERPREFS_CONFIG_OWNER_SHORT_NAME"] = short_name + if enable_ui_log: + # Consumed by `#ifdef USERPREFS_UI_TEST_LOG` in src/graphics/Screen.cpp. + prefs["USERPREFS_UI_TEST_LOG"] = True return prefs diff --git a/mcp-server/tests/_power.py b/mcp-server/tests/_power.py new file mode 100644 index 000000000..0542bbed3 --- /dev/null +++ b/mcp-server/tests/_power.py @@ -0,0 +1,112 @@ +"""USB hub power control for tests — thin composition of the `uhubctl` +module + `_port_discovery.resolve_port_by_role`. + +Why separate from the production module: +- `meshtastic_mcp.uhubctl.cycle` returns as soon as uhubctl exits (VBUS is + back on, but the device hasn't finished enumerating as a CDC port yet). +- Tests that want to immediately issue a `connect(port=...)` need the NEW + `/dev/cu.*` path, which can differ from the pre-cycle path on nRF52 + boards (CDC re-enumeration assigns a fresh `cu.usbmodemNNNN`). +- `resolve_port_by_role` already handles that wait + path-resolution for + the `factory_reset` flow. Composing the two gives a one-call helper. + +Also exposes `is_uhubctl_available()` so fixtures can skip cleanly when +uhubctl isn't installed — we never want "no uhubctl" to look like a test +failure. +""" + +from __future__ import annotations + +import time +from typing import Any + +from meshtastic_mcp import config as config_mod +from meshtastic_mcp import uhubctl as uhubctl_mod + +from ._port_discovery import resolve_port_by_role + + +def is_uhubctl_available() -> bool: + """Return True iff `config.uhubctl_bin()` resolves AND the binary is callable. + + Soft-fails silently — fixtures use this to `pytest.skip` with an + actionable message when the operator hasn't installed uhubctl. + """ + try: + config_mod.uhubctl_bin() + except Exception: # noqa: BLE001 + return False + # Do NOT actually invoke uhubctl here — on macOS a non-sudo run would + # fail, which is a config issue, not a tool-missing issue. That gets + # surfaced to the user when they actually run a recovery action. + return True + + +def power_on(role: str) -> dict[str, Any]: + """Power on the hub port hosting `role`. Does NOT wait for re-enumeration. + Use `power_cycle` or follow with `resolve_port_by_role` to block on readiness. + """ + loc, port = uhubctl_mod.resolve_target(role) + return uhubctl_mod.power_on(loc, port) + + +def power_off(role: str) -> dict[str, Any]: + """Power off the hub port hosting `role`. The device disappears from + `list_devices` immediately. + """ + loc, port = uhubctl_mod.resolve_target(role) + return uhubctl_mod.power_off(loc, port) + + +def power_cycle( + role: str, + *, + delay_s: int = 2, + rediscover_timeout_s: float = 30.0, +) -> str: + """Cycle the port hosting `role`, wait for re-enumeration, return the + new port path. + + On nRF52 the post-cycle path typically matches the pre-cycle path, but + macOS may assign a different `/dev/cu.usbmodemNNNN` if the previous + CDC endpoint hasn't been fully released. `resolve_port_by_role` + handles that transparently. + """ + loc, port = uhubctl_mod.resolve_target(role) + uhubctl_mod.cycle(loc, port, delay_s=delay_s) + # After uhubctl exits, VBUS is on but the device may still be in + # bootloader init. Give it ~500 ms head-start before polling so we + # don't spam list_devices pointlessly. + time.sleep(0.5) + return resolve_port_by_role(role, timeout_s=rediscover_timeout_s) + + +def wait_for_absence(role: str, *, timeout_s: float = 10.0) -> None: + """Block until a device matching `role` is NOT in `list_devices`. + + Used by the recovery tier to assert power_off actually took effect. + Raises TimeoutError on failure. + """ + from meshtastic_mcp import devices as devices_mod + + from ._port_discovery import _ROLE_VIDS, _coerce_vid # type: ignore[attr-defined] + + if role not in _ROLE_VIDS: + raise ValueError(f"unknown role {role!r}") + wanted = _ROLE_VIDS[role] + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + found = devices_mod.list_devices(include_unknown=True) + if not any(_coerce_vid(d.get("vid")) in wanted for d in found): + return + time.sleep(0.3) + raise TimeoutError(f"role {role!r} still visible after {timeout_s}s of power_off") + + +__all__ = [ + "is_uhubctl_available", + "power_cycle", + "power_off", + "power_on", + "wait_for_absence", +] diff --git a/mcp-server/tests/conftest.py b/mcp-server/tests/conftest.py index 3d033b9b8..d4607b239 100644 --- a/mcp-server/tests/conftest.py +++ b/mcp-server/tests/conftest.py @@ -123,15 +123,24 @@ def pytest_collection_modifyitems( return (2, item.nodeid) if "/monitor/" in path or "tests/monitor" in path: return (3, item.nodeid) - if "/fleet/" in path or "tests/fleet" in path: + # Recovery tier: explicitly cycles device power via uhubctl. Slots + # between monitor (read-only) and ui (state-preserving) so any tier + # after it starts from a known re-enumerated + re-verified state. + if "/recovery/" in path or "tests/recovery" in path: return (4, item.nodeid) + # UI tier slots here — read-only w.r.t. mesh state, only mutates + # the on-screen UI (BACK×5 guard restores home before each test). + if "/ui/" in path or "tests/ui" in path: + return (5, item.nodeid) + if "/fleet/" in path or "tests/fleet" in path: + return (6, item.nodeid) # State-mutating tiers run last. if "/admin/" in path or "tests/admin" in path: - return (5, item.nodeid) + return (7, item.nodeid) if "/provisioning/" in path or "tests/provisioning" in path: - return (6, item.nodeid) + return (8, item.nodeid) # Top-level + anything else falls between. - return (7, item.nodeid) + return (9, item.nodeid) items.sort(key=sort_key) @@ -156,13 +165,20 @@ def session_seed(request: pytest.FixtureRequest) -> str: @pytest.fixture(scope="session") def test_profile(session_seed: str) -> dict[str, Any]: - """The canonical isolated-mesh test profile for this session.""" + """The canonical isolated-mesh test profile for this session. + + `enable_ui_log=True` stamps `USERPREFS_UI_TEST_LOG` so the firmware + emits `Screen: frame N/M name=... reason=...` log lines per UI + transition — consumed by the `tests/ui/` tier. Harmless on boards + without a screen (the `#ifdef` sits behind `HAS_SCREEN`). + """ return userprefs.build_testing_profile( psk_seed=session_seed, channel_name="McpTest", channel_num=88, region="US", modem_preset="LONG_FAST", + enable_ui_log=True, ) @@ -654,6 +670,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: def baked_single( baked_mesh: dict[str, Any], baked_single_role: str, + hub_devices: dict[str, str], ) -> dict[str, Any]: """Function-scoped: a single verified baked device. @@ -662,10 +679,75 @@ def baked_single( (e.g. `test_owner_survives_reboot[nrf52]` + `test_owner_survives_reboot[esp32s3]`). Tests never hardcode a role and never skip a device that happens to be connected. + + Auto-recovery: if the baked device fails a pre-test `device_info` probe + AND uhubctl is available, power-cycle the port once and retry. Without + uhubctl, surface the wedge as a clear skip. This catches "device got + stuck between tests" without masking persistent regressions (a second + wedge after cycling still skips). """ if baked_single_role not in baked_mesh: pytest.skip(f"role {baked_single_role!r} not present on the hub") - return {"role": baked_single_role, **baked_mesh[baked_single_role]} + + entry = baked_mesh[baked_single_role] + port = entry.get("port") + if port: + try: + _run_with_timeout(lambda: info.device_info(port=port, timeout_s=3.0), 5.0) + except Exception: + # Device didn't respond. Try a power-cycle recovery if uhubctl + # is installed; otherwise surface a skip that names the root + # cause clearly. + from tests import _power + + if not _power.is_uhubctl_available(): + pytest.skip( + f"device {baked_single_role!r} unresponsive on {port}; " + "install uhubctl (`brew install uhubctl` / `apt install " + "uhubctl`) for auto power-cycle recovery" + ) + try: + new_port = _power.power_cycle(baked_single_role, delay_s=2) + except Exception as exc: # noqa: BLE001 + pytest.skip( + f"device {baked_single_role!r} wedged and power-cycle " + f"failed: {exc}" + ) + # Mutate both the session-scoped `hub_devices` map AND the + # baked_mesh entry so downstream fixtures see the recovered port. + hub_devices[baked_single_role] = new_port + baked_mesh[baked_single_role]["port"] = new_port + entry = baked_mesh[baked_single_role] + return {"role": baked_single_role, **entry} + + +@pytest.fixture +def power_cycle( + hub_devices: dict[str, str], +) -> Callable[..., str]: + """Return a callable `(role, delay_s=2) -> new_port` that hard-resets the + hub port hosting `role`. Skips the test cleanly when uhubctl isn't + installed — never want "no uhubctl" to look like a test failure. + + The callable mutates `hub_devices[role]` in place so subsequent fixture + lookups pick up the post-cycle port (mirrors the pattern in + provisioning/test_userprefs_survive_factory_reset.py). + """ + from tests import _power + + if not _power.is_uhubctl_available(): + pytest.skip( + "uhubctl not installed; this test needs it for power control. " + "Install via `brew install uhubctl` (macOS) or `apt install " + "uhubctl` (Debian/Ubuntu)." + ) + + def _cycle(role: str, delay_s: int = 2) -> str: + new_port = _power.power_cycle(role, delay_s=delay_s) + hub_devices[role] = new_port + return new_port + + return _cycle _DEFAULT_ROLE_ENVS = { @@ -960,6 +1042,45 @@ def _run_with_timeout(fn: Callable[[], Any], timeout: float) -> Any: raise TimeoutError(f"operation did not complete within {timeout}s") from exc +def _attach_ui_captures(item: pytest.Item, report: Any) -> None: + """Embed per-step UI captures (PNG + OCR) into the pytest-html extras. + + Runs for every UI-tier test on BOTH pass and fail so the HTML report + always shows the image strip + OCR transcript. Silently no-ops if + pytest-html isn't installed or the test didn't use `frame_capture`. + """ + captures = getattr(item, "_ui_captures", None) + if not captures: + return + try: + from pytest_html import extras as html_extras # type: ignore[import-untyped] + except ImportError: + return + + existing = getattr(report, "extras", None) or [] + extras_list = list(existing) + for cap in captures: + png_path = cap.get("png_path") + label = f"{cap.get('step', '?')}: {cap.get('label', '')}" + frame = cap.get("frame") or {} + frame_str = ( + f" — frame {frame.get('idx')} {frame.get('name')!r}" if frame else "" + ) + if png_path: + try: + with open(png_path, "rb") as fh: + import base64 + + b64 = base64.b64encode(fh.read()).decode("ascii") + extras_list.append(html_extras.png(b64, name=f"{label}{frame_str}")) + except OSError: + pass + ocr = (cap.get("ocr_text") or "").strip() + if ocr: + extras_list.append(html_extras.text(ocr, name=f"OCR: {label}{frame_str}")) + report.extras = extras_list # type: ignore[attr-defined] + + @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Any: """On test failure, attach serial capture + device state as report artifacts. @@ -967,10 +1088,20 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Hard-bounded by `_run_with_timeout` — if the device is unreachable (stuck port, unbaked firmware, dead board), the dump is skipped rather than hanging the session. + + For UI-tier tests, also embeds per-step camera captures + OCR on every + test (pass or fail) so the HTML report shows visual evidence of what + the device did. """ outcome = yield report = outcome.get_result() + # Attach UI captures on any outcome (pass + fail) — these are the whole + # point of the UI tier. Do this before the failure-only branch below so + # passing tests still get their image strip. + if report.when == "call": + _attach_ui_captures(item, report) + if report.when != "call" or report.outcome != "failed": return diff --git a/mcp-server/tests/mesh/test_peer_offline_recovery.py b/mcp-server/tests/mesh/test_peer_offline_recovery.py new file mode 100644 index 000000000..b8d0f7a63 --- /dev/null +++ b/mcp-server/tests/mesh/test_peer_offline_recovery.py @@ -0,0 +1,155 @@ +"""Isolation test for peer-offline-then-back mid-conversation. + +Verifies the mesh stack's behavior when a peer is physically powered +off mid-send via uhubctl, then powered back on. + +Flow (parametrized over every directed mesh_pair): + 1. Bilateral PKI warmup (same pattern as test_direct_with_ack). + 2. TX sends a broadcast text "msg-1" — RX confirms receipt via pubsub. + 3. Power OFF RX via uhubctl. The RX device disappears from the OS. + 4. TX sends a directed text "msg-2" with wantAck=True. Firmware retries + internally for ~30s before giving up. Assertion: the packet object + was accepted by the TX stack (non-None) — we don't assert an ACK + since there's no peer to send one. + 5. Power ON RX. Wait for re-enumeration + boot. + 6. Bilateral PKI re-nudge — RX's in-RAM PKI cache was wiped on reboot, + so the first directed send may err=35 without a fresh NodeInfo ping. + 7. TX sends a directed "msg-3" — RX receives it via pubsub, confirming + the mesh recovered. + +Skips cleanly if uhubctl isn't installed (via the `power_cycle` fixture's +auto-skip). Skips for pair directions where RX isn't power-controllable +(e.g. a USB-IF hub that doesn't support PPPS for its port). +""" + +from __future__ import annotations + +import time +from typing import Any + +import pytest +from meshtastic_mcp.connection import connect +from tests import _power +from tests._port_discovery import resolve_port_by_role + +from ._receive import ReceiveCollector, nudge_nodeinfo + + +@pytest.mark.timeout(360) +def test_peer_offline_then_recovers( + mesh_pair: dict[str, Any], + power_cycle, # noqa: ARG001 — forces uhubctl-availability skip + hub_devices: dict[str, str], +) -> None: + tx_port = mesh_pair["tx"]["port"] + rx_node_num = mesh_pair["rx"]["my_node_num"] + tx_role = mesh_pair["tx_role"] + rx_role = mesh_pair["rx_role"] + + unique_pre = f"peer-offline-pre-{tx_role}-to-{rx_role}-{int(time.time())}" + unique_post = f"peer-offline-post-{tx_role}-to-{rx_role}-{int(time.time())}" + + # Step 1 + 2: warm up + confirm baseline delivery works before the test. + with ReceiveCollector( + mesh_pair["rx"]["port"], topic="meshtastic.receive.text" + ) as rx: + rx.broadcast_nodeinfo_ping() + with connect(port=tx_port) as tx_iface: + nudge_nodeinfo(tx_iface) + # Wait for bilateral PKI (RX pubkey in TX's nodesByNum). + deadline = time.monotonic() + 45.0 + last_nudge = time.monotonic() + while time.monotonic() < deadline: + rec = (tx_iface.nodesByNum or {}).get(rx_node_num, {}) + if rec.get("user", {}).get("publicKey"): + break + if time.monotonic() - last_nudge > 15.0: + rx.broadcast_nodeinfo_ping() + nudge_nodeinfo(tx_iface) + last_nudge = time.monotonic() + time.sleep(1.0) + else: + pytest.skip( + f"bilateral PKI never completed ({tx_role}→{rx_role}); " + "can't run the offline test without a warm baseline" + ) + + tx_iface.sendText(unique_pre, destinationId=rx_node_num, wantAck=True) + got = rx.wait_for( + lambda pkt: pkt.get("decoded", {}).get("text") == unique_pre, + timeout=30, + ) + assert got is not None, ( + f"baseline directed send ({tx_role}→{rx_role}) didn't land — " + "skipping offline test to avoid false positive" + ) + + # Step 3: power off RX. uhubctl skips the test with a clear message if + # the RX role isn't on a controllable hub. + try: + _power.power_off(rx_role) + except Exception as exc: # noqa: BLE001 + pytest.skip(f"can't power-control {rx_role!r}: {exc}") + + try: + _power.wait_for_absence(rx_role, timeout_s=10.0) + except TimeoutError: + _power.power_on(rx_role) # restore hub state before failing + resolve_port_by_role(rx_role, timeout_s=30.0) + pytest.fail(f"{rx_role!r} didn't disappear after power_off") + + # Step 4: send to a peer that isn't there. Firmware will retry + # internally. We don't wait for an ACK (there won't be one); we just + # confirm TX's stack accepts the packet without crashing. + try: + with connect(port=tx_port) as tx_iface: + packet = tx_iface.sendText( + f"while-offline-{rx_role}", + destinationId=rx_node_num, + wantAck=True, + ) + assert packet is not None + # Give firmware a moment to do a retry or two while RX is down. + time.sleep(5.0) + except Exception as exc: # noqa: BLE001 — TX should survive the peer being gone + # Restore RX before reraising so the bench state is sane. + _power.power_on(rx_role) + resolve_port_by_role(rx_role, timeout_s=30.0) + raise AssertionError(f"TX crashed when sending to offline peer: {exc}") from exc + + # Step 5: power RX back on + rediscover. + _power.power_on(rx_role) + time.sleep(0.5) + new_rx_port = resolve_port_by_role(rx_role, timeout_s=30.0) + hub_devices[rx_role] = new_rx_port + + # Step 6 + 7: bilateral re-warmup + directed send that should now work. + with ReceiveCollector(new_rx_port, topic="meshtastic.receive.text") as rx: + # RX rebooted → its PKI cache is gone. Re-warm. + rx.broadcast_nodeinfo_ping() + with connect(port=tx_port) as tx_iface: + nudge_nodeinfo(tx_iface) + time.sleep(3.0) + + got = None + for _attempt in range(3): + packet = tx_iface.sendText( + unique_post, + destinationId=rx_node_num, + wantAck=True, + ) + assert packet is not None + got = rx.wait_for( + lambda pkt: pkt.get("decoded", {}).get("text") == unique_post, + timeout=30, + ) + if got is not None: + break + rx.broadcast_nodeinfo_ping() + nudge_nodeinfo(tx_iface) + time.sleep(5.0) + + assert got is not None, ( + f"post-recovery directed send {unique_post!r} ({tx_role}→{rx_role}) " + "never landed — recovery path may be broken" + ) diff --git a/mcp-server/tests/recovery/__init__.py b/mcp-server/tests/recovery/__init__.py new file mode 100644 index 000000000..cf8f3919e --- /dev/null +++ b/mcp-server/tests/recovery/__init__.py @@ -0,0 +1,6 @@ +"""Recovery tier — exercises `uhubctl` power control end-to-end. + +Requires `uhubctl` installed AND at least one connected device on a +PPPS-capable hub. The whole tier skips cleanly via +`tests/recovery/conftest.py::_recovery_tier_guard` when either is missing. +""" diff --git a/mcp-server/tests/recovery/conftest.py b/mcp-server/tests/recovery/conftest.py new file mode 100644 index 000000000..21116d07a --- /dev/null +++ b/mcp-server/tests/recovery/conftest.py @@ -0,0 +1,44 @@ +"""Recovery-tier gating + shared helpers. + +Session-scoped guard skips the whole tier when uhubctl isn't installed. +Tests under this directory assume uhubctl is callable AND that at least +one hub role is detected on a PPPS-capable port. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def _recovery_tier_guard() -> None: + """Skip the tier when uhubctl is unavailable OR no device is on a + PPPS-capable hub. Prints the specific reason so operators know what + to fix.""" + from tests import _power + + if not _power.is_uhubctl_available(): + pytest.skip( + "uhubctl not installed; recovery tier needs it. " + "Install via `brew install uhubctl` or `apt install uhubctl`.", + allow_module_level=True, + ) + + # Probe: can we even list hubs? (A macOS user without sudo gets a + # permission error here — we'd rather find out once at tier-start than + # 6 tests later.) + from meshtastic_mcp import uhubctl + + try: + hubs = uhubctl.list_hubs() + except uhubctl.UhubctlError as exc: + pytest.skip( + f"uhubctl list failed: {exc}. Try the udev rules or `sudo` as a fallback.", + allow_module_level=True, + ) + + if not any(h["ppps"] for h in hubs): + pytest.skip( + "no PPPS-capable hubs detected — recovery tier has nothing to exercise.", + allow_module_level=True, + ) diff --git a/mcp-server/tests/recovery/test_list_hubs.py b/mcp-server/tests/recovery/test_list_hubs.py new file mode 100644 index 000000000..0a09a672a --- /dev/null +++ b/mcp-server/tests/recovery/test_list_hubs.py @@ -0,0 +1,43 @@ +"""Smoke test: `uhubctl_list` returns a well-formed structure. + +No destructive action. Runs first in the tier as a sanity check that the +tier's dependencies (uhubctl binary + permissions) are actually satisfied. +""" + +from __future__ import annotations + +import pytest +from meshtastic_mcp import uhubctl + + +@pytest.mark.timeout(30) +def test_list_hubs_returns_at_least_one_ppps_hub() -> None: + hubs = uhubctl.list_hubs() + assert hubs, "uhubctl found no hubs at all — is a USB hub connected?" + assert any(h["ppps"] for h in hubs), ( + "no PPPS-capable hubs detected; power control won't work. " + "Check that the hub supports Per-Port Power Switching." + ) + + +@pytest.mark.timeout(30) +def test_list_hubs_structure(hub_devices: dict[str, str]) -> None: + hubs = uhubctl.list_hubs() + for hub in hubs: + assert "location" in hub and hub["location"] + assert "ports" in hub and isinstance(hub["ports"], list) + for port in hub["ports"]: + assert "port" in port and isinstance(port["port"], int) + assert "status" in port + + # At least one of the detected Meshtastic roles should show up in some + # port's device_vid — otherwise the recovery tier can't drive them. + seen_vids = { + p["device_vid"] for h in hubs for p in h["ports"] if p["device_vid"] is not None + } + expected_any = {0x239A, 0x303A, 0x10C4} & seen_vids + assert expected_any or not hub_devices, ( + f"hub_devices detected roles {list(hub_devices)} but uhubctl sees " + f"VIDs {sorted(hex(v) for v in seen_vids)} — the devices may be on " + "a hub that uhubctl can't see (e.g. built-in laptop ports)." + ) diff --git a/mcp-server/tests/recovery/test_power_cycle_preserves_userprefs.py b/mcp-server/tests/recovery/test_power_cycle_preserves_userprefs.py new file mode 100644 index 000000000..9558a7fb4 --- /dev/null +++ b/mcp-server/tests/recovery/test_power_cycle_preserves_userprefs.py @@ -0,0 +1,60 @@ +"""Hard reset via uhubctl must NOT wipe NVS. Verify the test profile's +region + channel survive a power-cycle. + +Guards against a regression where a firmware change treats unexpected +power loss as a factory-reset trigger (e.g. bad EEPROM wear-leveling, +erase-on-boot-for-safety). Such a regression would be catastrophic for +field deployments. +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp import admin, info +from tests import _power +from tests._port_discovery import resolve_port_by_role + + +@pytest.mark.timeout(180) +def test_lora_config_survives_power_cycle( + baked_single: dict[str, object], + test_profile: dict[str, object], +) -> None: + role = baked_single["role"] + pre_port = baked_single["port"] + + pre_config = admin.get_config(section="lora", port=pre_port)["config"]["lora"] + pre_region = pre_config.get("region") + pre_preset = pre_config.get("modem_preset") + assert pre_region, f"lora.region not set pre-cycle on {role}" + + # Hard power-cycle. + _power.power_cycle(role, delay_s=2) + time.sleep(0.5) + new_port = resolve_port_by_role(role, timeout_s=30.0) + # Let the firmware complete boot before admin reads. + time.sleep(2.0) + # Quick readiness probe. + probe = info.device_info(port=new_port, timeout_s=10.0) + assert ( + probe.get("my_node_num") is not None + ), f"device {role!r} didn't respond after power-cycle" + + post_config = admin.get_config(section="lora", port=new_port)["config"]["lora"] + post_region = post_config.get("region") + post_preset = post_config.get("modem_preset") + + assert post_region == pre_region, ( + f"lora.region wiped by power-cycle on {role}: " + f"pre={pre_region!r} post={post_region!r}" + ) + assert post_preset == pre_preset, ( + f"lora.modem_preset wiped by power-cycle on {role}: " + f"pre={pre_preset!r} post={post_preset!r}" + ) + + # Channel-0 name should also match the test profile. + pri_ch = admin.get_channel_url(port=new_port) + assert pri_ch.get("url"), f"channel URL empty after power-cycle on {role}" diff --git a/mcp-server/tests/recovery/test_power_cycle_roundtrip.py b/mcp-server/tests/recovery/test_power_cycle_roundtrip.py new file mode 100644 index 000000000..37bc7c8d2 --- /dev/null +++ b/mcp-server/tests/recovery/test_power_cycle_roundtrip.py @@ -0,0 +1,61 @@ +"""Full power-cycle round-trip: off → verify gone → on → verify identity +preserved. + +Parametrized over every connected role. Validates both the uhubctl +plumbing AND that the device survives a hard reset with the same +`my_node_num` (no firmware-level identity regeneration). +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp import info +from tests import _power +from tests._port_discovery import resolve_port_by_role + + +@pytest.mark.timeout(180) +def test_power_cycle_preserves_node_identity( + baked_single: dict[str, object], +) -> None: + role = baked_single["role"] + pre_port = baked_single["port"] + pre_node_num = baked_single["my_node_num"] + pre_fw = baked_single.get("firmware_version") + + # Record pre-cycle state. + pre_info = info.device_info(port=pre_port, timeout_s=5.0) + assert pre_info.get("my_node_num") == pre_node_num + + # Power off; confirm the device actually disappears from list_devices. + _power.power_off(role) + try: + _power.wait_for_absence(role, timeout_s=10.0) + except TimeoutError: + # If it didn't disappear, power it back on so we don't leave the + # hub in a weird state for the next test. + _power.power_on(role) + resolve_port_by_role(role, timeout_s=30.0) + pytest.fail(f"device {role!r} stayed visible after power_off") + + # Power back on + re-discover port. + _power.power_on(role) + time.sleep(0.5) # head-start before polling + new_port = resolve_port_by_role(role, timeout_s=30.0) + + # Give the firmware a moment to finish boot before we hit it with admin. + time.sleep(2.0) + + post_info = info.device_info(port=new_port, timeout_s=10.0) + assert post_info.get("my_node_num") == pre_node_num, ( + f"my_node_num changed across power-cycle: pre={pre_node_num:#x} " + f"post={post_info.get('my_node_num'):#x}" + ) + # Firmware version must match (same bake, not a re-flash). + if pre_fw: + assert post_info.get("firmware_version") == pre_fw, ( + f"firmware changed across cycle: pre={pre_fw} " + f"post={post_info.get('firmware_version')}" + ) diff --git a/mcp-server/tests/tool_coverage.py b/mcp-server/tests/tool_coverage.py index b91bd4039..edf974e03 100644 --- a/mcp-server/tests/tool_coverage.py +++ b/mcp-server/tests/tool_coverage.py @@ -73,6 +73,13 @@ _TOOL_MAP: dict[str, tuple[str, str]] = { "reboot": ("meshtastic_mcp.admin", "reboot"), "shutdown": ("meshtastic_mcp.admin", "shutdown"), "factory_reset": ("meshtastic_mcp.admin", "factory_reset"), + "send_input_event": ("meshtastic_mcp.admin", "send_input_event"), + # `capture_screen` in server.py calls camera.get_camera — instrument that. + "capture_screen": ("meshtastic_mcp.camera", "get_camera"), + # USB power control via uhubctl. + "uhubctl_list": ("meshtastic_mcp.uhubctl", "list_hubs"), + "uhubctl_power": ("meshtastic_mcp.uhubctl", "power_on"), + "uhubctl_cycle": ("meshtastic_mcp.uhubctl", "cycle"), # USERPREFS "userprefs_manifest": ("meshtastic_mcp.userprefs", "build_manifest"), "userprefs_get": ("meshtastic_mcp.userprefs", "read_state"), diff --git a/mcp-server/tests/ui/__init__.py b/mcp-server/tests/ui/__init__.py new file mode 100644 index 000000000..006fc3c8e --- /dev/null +++ b/mcp-server/tests/ui/__init__.py @@ -0,0 +1,7 @@ +"""UI tier — input-broker-driven screen navigation tests. + +Only runs when a screen-bearing role (esp32s3/heltec-v3) is present on the +hub AND the firmware was baked with `enable_ui_log=True` (so the +`Screen: frame N/M name=... reason=...` log lines are emitted). The +`tests/ui/conftest.py` fixture forces that bake stamp. +""" diff --git a/mcp-server/tests/ui/_screen_log.py b/mcp-server/tests/ui/_screen_log.py new file mode 100644 index 000000000..97954db48 --- /dev/null +++ b/mcp-server/tests/ui/_screen_log.py @@ -0,0 +1,176 @@ +"""Parse `Screen: frame N/M name=X reason=Y` log lines from `_debug_log_buffer`. + +The firmware emits one line per frame transition when +`USERPREFS_UI_TEST_LOG` is defined (see src/graphics/Screen.cpp). Tests use +these helpers to assert which frame is shown / to wait for a transition to +settle before taking a camera capture. +""" + +from __future__ import annotations + +import re +import time +from dataclasses import dataclass +from typing import Iterable, Iterator + +FRAME_RE = re.compile( + r"Screen: frame (?P\d+)/(?P\d+) name=(?P\S+) reason=(?P\S+)" +) + + +@dataclass(frozen=True) +class FrameEvent: + idx: int + count: int + name: str + reason: str + raw: str + + @classmethod + def parse(cls, line: str) -> "FrameEvent | None": + m = FRAME_RE.search(line) + if not m: + return None + return cls( + idx=int(m["idx"]), + count=int(m["count"]), + name=m["name"], + reason=m["reason"], + raw=line, + ) + + +def iter_frame_events(lines: Iterable[str]) -> Iterator[FrameEvent]: + for line in lines: + evt = FrameEvent.parse(line) + if evt is not None: + yield evt + + +def get_current_frame(lines: list[str]) -> FrameEvent | None: + """Return the most recent FrameEvent in `lines`, or None if none found.""" + for line in reversed(lines): + evt = FrameEvent.parse(line) + if evt is not None: + return evt + return None + + +def wait_for_frame( + lines: list[str], + expected_name: str, + *, + timeout_s: float = 5.0, + poll_interval_s: float = 0.1, + reason: str | None = None, +) -> FrameEvent: + """Poll `lines` (the `_debug_log_buffer`) until a FrameEvent with + `name=expected_name` appears after the call started. Raises TimeoutError + with context if it doesn't arrive in `timeout_s`. + + `reason` optionally filters to events matching a specific cause + (e.g. `"fn_f1"`, `"next"`, `"rebuild"`). + """ + start_idx = len(lines) + deadline = time.monotonic() + timeout_s + last: FrameEvent | None = None + while time.monotonic() < deadline: + # Scan only lines appended since we started waiting. + for line in lines[start_idx:]: + evt = FrameEvent.parse(line) + if evt is None: + continue + last = evt + if evt.name == expected_name and (reason is None or evt.reason == reason): + return evt + time.sleep(poll_interval_s) + + seen = [e.name for e in iter_frame_events(lines[start_idx:])] + raise TimeoutError( + f"frame name={expected_name!r} reason={reason!r} not seen in {timeout_s}s; " + f"saw {len(seen)} transition(s): {seen!r}; last={last!r}" + ) + + +def wait_for_any_frame( + lines: list[str], + *, + timeout_s: float = 5.0, + poll_interval_s: float = 0.1, +) -> FrameEvent: + """Wait for ANY frame transition to appear after call-start. Useful for + `no-op` tests that want to confirm a transition did NOT happen (via + TimeoutError) vs. one that did. + """ + start_idx = len(lines) + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + for line in lines[start_idx:]: + evt = FrameEvent.parse(line) + if evt is not None: + return evt + time.sleep(poll_interval_s) + raise TimeoutError(f"no frame transition in {timeout_s}s") + + +def wait_for_reason( + lines: list[str], + reason: str, + *, + timeout_s: float = 5.0, + poll_interval_s: float = 0.1, +) -> FrameEvent: + """Wait for a frame event with `reason=` after call-start. + + Matches only on `reason` — useful when the caller knows *why* a + transition should happen (e.g. `fn_f1`, `rebuild`) but not which named + frame the firmware will land on for this particular board. + """ + start_idx = len(lines) + deadline = time.monotonic() + timeout_s + last: FrameEvent | None = None + while time.monotonic() < deadline: + for line in lines[start_idx:]: + evt = FrameEvent.parse(line) + if evt is None: + continue + last = evt + if evt.reason == reason: + return evt + time.sleep(poll_interval_s) + raise TimeoutError( + f"no frame with reason={reason!r} in {timeout_s}s; last={last!r}" + ) + + +def assert_no_frame_change( + lines: list[str], + *, + wait_s: float = 2.0, +) -> None: + """Assert that NO new FrameEvent lines arrive within `wait_s`. + + Used by idempotency / no-op tests (e.g. BACK on home frame). + """ + start_idx = len(lines) + time.sleep(wait_s) + new = [ + e for e in (FrameEvent.parse(ln) for ln in lines[start_idx:]) if e is not None + ] + if new: + raise AssertionError( + f"expected no frame change in {wait_s}s, but saw {len(new)} event(s): " + f"{[(e.reason, e.name) for e in new]!r}" + ) + + +__all__ = [ + "FRAME_RE", + "FrameEvent", + "assert_no_frame_change", + "get_current_frame", + "iter_frame_events", + "wait_for_any_frame", + "wait_for_frame", + "wait_for_reason", +] diff --git a/mcp-server/tests/ui/conftest.py b/mcp-server/tests/ui/conftest.py new file mode 100644 index 000000000..aedfdbc8f --- /dev/null +++ b/mcp-server/tests/ui/conftest.py @@ -0,0 +1,381 @@ +"""UI-tier fixtures: camera lifecycle, OCR warmup, per-test frame capture, +and a `ui_home_state` autouse guard that resets to the home frame before +every test (prevents state bleed if a prior test exited inside a menu). + +The camera + OCR modules live in `meshtastic_mcp/{camera,ocr}.py` (production +code, so the `capture_screen` MCP tool can share them). These fixtures wire +them into pytest + write per-test captures to `tests/ui_captures/…`. +""" + +from __future__ import annotations + +import re +import shutil +import time +from pathlib import Path +from typing import Any, Iterator + +import pytest +from meshtastic_mcp import admin as admin_mod +from meshtastic_mcp import camera as camera_mod +from meshtastic_mcp import ocr as ocr_mod +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import FrameEvent, get_current_frame, wait_for_frame + +# Roles that carry a screen the UI tier can drive. Only esp32s3 (heltec-v3 +# SSD1306) qualifies today — nrf52 (rak4631) has no display. +UI_CAPABLE_ROLES = ("esp32s3",) + +# Where per-test captures land. One subdirectory per session seed, then per +# sanitized test nodeid — identical pattern to other pytest artifacts. +CAPTURES_ROOT = Path(__file__).resolve().parent.parent / "ui_captures" + + +def _sanitize_nodeid(nodeid: str) -> str: + return re.sub(r"[^a-zA-Z0-9_.-]+", "_", nodeid) + + +# ---------- Role gating ---------------------------------------------------- + + +@pytest.fixture +def ui_capable_role(request: pytest.FixtureRequest, hub_devices: dict[str, Any]) -> str: + """Resolve the single role the UI tier drives. + + Today that's `esp32s3`. Skips if the hub doesn't have one. A future + multi-screen hub could pick a role per parametrization. + """ + for role in UI_CAPABLE_ROLES: + if role in hub_devices: + return role + pytest.skip( + f"no UI-capable role on hub; need one of {UI_CAPABLE_ROLES} in {sorted(hub_devices)}" + ) + + +@pytest.fixture +def ui_port(ui_capable_role: str, hub_devices: dict[str, Any]) -> str: + port = ( + hub_devices[ui_capable_role].get("port") + if isinstance(hub_devices[ui_capable_role], dict) + else hub_devices[ui_capable_role] + ) + if not port: + pytest.skip(f"{ui_capable_role!r} has no usable port") + return port + + +# ---------- Camera + OCR session fixtures --------------------------------- + + +@pytest.fixture(scope="session") +def camera(ui_capable_role_session: str | None) -> Iterator[camera_mod.CameraBackend]: + """Session-scoped camera backend. Closed at teardown. + + Backend + device selected by env vars (see `meshtastic_mcp.camera`). + Falls through to `NullBackend` when no camera is configured, so the + tests run end-to-end on machines without hardware; they just won't + have useful images. + """ + role = ui_capable_role_session or "esp32s3" + cam = camera_mod.get_camera(role) + try: + yield cam + finally: + cam.close() + + +@pytest.fixture(scope="session") +def ui_capable_role_session(hub_devices: dict[str, Any]) -> str | None: + """Session-scoped lookup mirroring `ui_capable_role` but non-skipping. + + Used by the `camera` session fixture so it doesn't depend on a + test-scoped skip. + """ + for role in UI_CAPABLE_ROLES: + if role in hub_devices: + return role + return None + + +@pytest.fixture(scope="session", autouse=True) +def _ocr_warm() -> None: + """Pay easyocr's ~100 MB / cold-start cost ONCE per session. + + Subsequent `ocr_text()` calls hit the cached reader and return quickly. + Swallows errors — if OCR isn't installed, warm is a no-op. + """ + try: + ocr_mod.warm() + except Exception: # noqa: BLE001 — belt: never block the suite on OCR init + pass + + +@pytest.fixture(scope="session") +def _ui_screen_kept_on( + ui_capable_role_session: str | None, hub_devices: dict[str, Any] +) -> Iterator[None]: + """Keep the OLED on throughout the UI tier so input events aren't dropped. + + Why: `InputBroker::handleInputEvent` (src/input/InputBroker.cpp:118-122) + silently DROPS any event that arrives while the screen is off — it just + wakes the screen and returns. Every first event in each test would + disappear. We set `display.screen_on_secs = 86400` at session start + (effectively "always on" for the test window) and restore the prior + value at teardown. + """ + if ui_capable_role_session is None: + yield + return + + hub_entry = hub_devices[ui_capable_role_session] + port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry + if not port: + yield + return + + original: int | None = None + try: + current = admin_mod.get_config(section="display", port=port) + original = int( + current.get("config", {}).get("display", {}).get("screen_on_secs") or 0 + ) + except Exception: # noqa: BLE001 + pass + + try: + admin_mod.set_config("display.screen_on_secs", 86400, port=port) + # Send one wake event so the screen is actually ON going into the + # first test. The event itself gets dropped (screenWasOff), but the + # wake side-effect sticks. + try: + admin_mod.send_input_event(event_code=int(InputEventCode.FN_F1), port=port) + except Exception: # noqa: BLE001 + pass + time.sleep(1.5) # Let the screen finish its wake transition. + except ( + Exception + ): # noqa: BLE001 — best-effort; ui_home_state surfaces the real error + pass + + try: + yield + finally: + if original is not None: + try: + admin_mod.set_config("display.screen_on_secs", original, port=port) + except Exception: # noqa: BLE001 + pass + + +# ---------- Per-test capture + transcript ---------------------------------- + + +class FrameCapture: + """Per-test capture recorder. Created once per test via the + `frame_capture` fixture; call with a label to snapshot the screen. + """ + + def __init__( + self, + cam: camera_mod.CameraBackend, + dir_path: Path, + lines: list[str], + nodeid: str, + ) -> None: + self._cam = cam + self._dir = dir_path + self._lines = lines + self._nodeid = nodeid + self._step = 0 + self.captures: list[dict[str, Any]] = [] + self._transcript_path = dir_path / "transcript.md" + self._dir.mkdir(parents=True, exist_ok=True) + self._transcript_path.write_text( + f"# {nodeid} — {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n\n", + encoding="utf-8", + ) + + def __call__(self, label: str) -> dict[str, Any]: + self._step += 1 + stem = f"{self._step:03d}-{re.sub(r'[^a-zA-Z0-9_-]+', '-', label)}" + png_path = self._dir / f"{stem}.png" + ocr_path = self._dir / f"{stem}.ocr.txt" + + try: + png = self._cam.capture() + except Exception as exc: # noqa: BLE001 + png = b"" + ocr_str = f"[capture error: {exc}]" + else: + camera_mod.save_capture(png, png_path) + try: + ocr_str = ocr_mod.ocr_text(png) + except Exception as exc: # noqa: BLE001 + ocr_str = f"[ocr error: {exc}]" + ocr_path.write_text(ocr_str or "", encoding="utf-8") + + frame = get_current_frame(self._lines) + entry: dict[str, Any] = { + "step": self._step, + "label": label, + "png_path": str(png_path) if png else None, + "ocr_text": ocr_str, + "frame": ( + { + "idx": frame.idx, + "name": frame.name, + "reason": frame.reason, + } + if frame is not None + else None + ), + } + self.captures.append(entry) + + with self._transcript_path.open("a", encoding="utf-8") as fh: + frame_str = ( + f"frame {frame.idx}/{frame.count} name={frame.name} reason={frame.reason}" + if frame is not None + else "frame " + ) + ocr_summary = (ocr_str or "").replace("\n", " / ")[:80] + fh.write( + f"{self._step}. **{label}** — {frame_str} — OCR: `{ocr_summary}`\n" + ) + return entry + + +@pytest.fixture +def frame_capture( + request: pytest.FixtureRequest, + camera: camera_mod.CameraBackend, + session_seed: str, +) -> Iterator[FrameCapture]: + nodeid = _sanitize_nodeid(request.node.nodeid) + dir_path = CAPTURES_ROOT / session_seed / nodeid + # Fresh directory per test run so reruns don't mix old and new images. + if dir_path.exists(): + shutil.rmtree(dir_path) + + lines = getattr(request.node, "_debug_log_buffer", []) + fc = FrameCapture(camera, dir_path, lines, nodeid) + # Stash so pytest_runtest_makereport can embed captures in HTML extras. + request.node._ui_captures = fc.captures # type: ignore[attr-defined] + yield fc + + +# ---------- Pre-test home-state reset -------------------------------------- + + +def _send_event(port: str, event: InputEventCode) -> None: + try: + admin_mod.send_input_event(event_code=int(event), port=port) + except Exception: # noqa: BLE001 + # Treat a failed event as soft — the subsequent frame-log assertion + # surfaces the real problem with better context. + pass + + +@pytest.fixture(autouse=True) +def ui_home_state( + request: pytest.FixtureRequest, + hub_devices: dict[str, Any], + _ui_screen_kept_on: None, +) -> Iterator[None]: + """Before every UI test, jump to frame 0 (usually `home`) via FN_F1 and + confirm the device emitted the expected frame log. + + Why FN_F1 (not BACK): FN_F1 maps to `switchToFrame(0)` and ALWAYS + produces a `reason=fn_f1` log line, regardless of whatever frame the + prior test left us on. BACK is context-sensitive (dismisses overlays + on some frames, no-op on others) and can silently fail to transition. + + This fixture doubles as the macro-presence detector: if no `fn_f1` + log arrives within 5 s, the firmware almost certainly wasn't baked + with `USERPREFS_UI_TEST_LOG`. Skip the tier with an actionable hint + instead of letting every test body fail with a confusing assertion. + + Autouse scope is restricted to `tests/ui/` by virtue of this fixture + living in that directory's conftest.py — no explicit nodeid guard + needed (and earlier attempts at one were wrong, matching `/tests/ui/` + against a nodeid that has no leading slash). + """ + role = next((r for r in UI_CAPABLE_ROLES if r in hub_devices), None) + if role is None: + yield + return + + hub_entry = hub_devices[role] + port = hub_entry.get("port") if isinstance(hub_entry, dict) else hub_entry + lines: list[str] = getattr(request.node, "_debug_log_buffer", []) + start_len = len(lines) + + # First: a wake event. The screen should already be kept on by + # `_ui_screen_kept_on`, but belt + suspenders — if it somehow + # powered off (sleep after factory_reset, etc.), this first FN_F1 + # gets dropped by InputBroker's screenWasOff guard. That's fine; + # the second FN_F1 below lands cleanly. + _send_event(port, InputEventCode.FN_F1) + time.sleep(0.4) + _send_event(port, InputEventCode.FN_F1) + + # Wait for the fn_f1 transition log. Any new `reason=fn_f1` line + # after call-start counts — we don't care about the name (it should + # be `home` or `deviceFocused` depending on board-specific frame order). + from ._screen_log import wait_for_reason + + try: + wait_for_reason(lines, "fn_f1", timeout_s=5.0) + except TimeoutError: + # One more try — FreeRTOS queue may be draining slowly. + _send_event(port, InputEventCode.FN_F1) + try: + wait_for_reason(lines, "fn_f1", timeout_s=5.0) + except TimeoutError: + # Look at what the _debug_log_buffer actually contains to + # disambiguate "macro off" from "macro on but event lost". + frame_lines = [ln for ln in lines[start_len:] if "Screen: frame" in ln] + processing_lines = [ + ln for ln in lines[start_len:] if "Processing input event" in ln + ] + if frame_lines: + pytest.skip( + f"ui_home_state: events fire but none reach Screen " + f"(saw {len(frame_lines)} frame line(s), " + f"{len(processing_lines)} admin inject(s)). " + f"Device may be in an unusual state — try `--force-bake`." + ) + else: + pytest.skip( + "ui_home_state: no `Screen: frame` log after FN_F1. " + "Firmware not baked with USERPREFS_UI_TEST_LOG — " + "run with `--force-bake` to reflash, or verify the " + "macro is active in the bake." + ) + yield + + +# ---------- Small helpers reused by tests --------------------------------- + + +def send_event( + port: str, event: InputEventCode | int | str, **kwargs: Any +) -> dict[str, Any]: + """Thin wrapper so tests read `send_event(port, InputEventCode.RIGHT)`.""" + return admin_mod.send_input_event(event_code=event, port=port, **kwargs) + + +__all__ = [ + "FrameCapture", + "UI_CAPABLE_ROLES", + "send_event", + "wait_for_frame", + "FrameEvent", +] + + +# Make the helpers discoverable to test modules via `from .conftest import …`. +# pytest auto-loads conftest.py, but the symbols above are also re-exported +# for readability in the test files. diff --git a/mcp-server/tests/ui/test_input_fn_jump.py b/mcp-server/tests/ui/test_input_fn_jump.py new file mode 100644 index 000000000..047aff7d2 --- /dev/null +++ b/mcp-server/tests/ui/test_input_fn_jump.py @@ -0,0 +1,61 @@ +"""FN_F1..F5 directly jumps to frame 0..4 via Screen::handleInputEvent. + +Parametrized over the 5 function keys. Each expects a +`Screen: frame / name=... reason=fn_f` log line, with +`idx == k-1`. We don't hardcode the frame *name* because the layout +depends on which modules are compiled in for this board. +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import get_current_frame, wait_for_reason +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(120) +@pytest.mark.parametrize( + "event,expected_idx,reason", + [ + (InputEventCode.FN_F1, 0, "fn_f1"), + (InputEventCode.FN_F2, 1, "fn_f2"), + (InputEventCode.FN_F3, 2, "fn_f3"), + (InputEventCode.FN_F4, 3, "fn_f4"), + (InputEventCode.FN_F5, 4, "fn_f5"), + ], + ids=["FN_F1", "FN_F2", "FN_F3", "FN_F4", "FN_F5"], +) +def test_fn_jump_direct_frame( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, + event: InputEventCode, + expected_idx: int, + reason: str, +) -> None: + lines: list[str] = request.node._debug_log_buffer + start = get_current_frame(lines) + assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?" + assert start.name in ( + "home", + "deviceFocused", + ), f"setup expected frame 0 landing, got {start.name!r}" + frame_capture("initial") + + if start.count <= expected_idx: + pytest.skip( + f"device has {start.count} frames; FN_F{expected_idx + 1} needs > {expected_idx}" + ) + + send_event(ui_port, event) + time.sleep(0.1) + evt = wait_for_reason(lines, reason, timeout_s=5.0) + assert evt.idx == expected_idx, ( + f"FN_F{expected_idx + 1} expected idx={expected_idx}, got {evt.idx} " + f"(name={evt.name}, count={evt.count})" + ) + frame_capture(f"after-{reason}") diff --git a/mcp-server/tests/ui/test_input_fn_oob.py b/mcp-server/tests/ui/test_input_fn_oob.py new file mode 100644 index 000000000..a33ff1cc5 --- /dev/null +++ b/mcp-server/tests/ui/test_input_fn_oob.py @@ -0,0 +1,61 @@ +"""Out-of-bounds FN_F5 when the device has <5 frames: no crash, idx unchanged. + +`Screen::handleInputEvent` dispatches FN_F5 unconditionally to +`ui->switchToFrame(4)`. The OLEDDisplayUi library typically clamps or +silently ignores out-of-range indices, but firmware bugs have existed +here — this test protects against a regression that would wedge the UI. + +If this test fails, first check: did the device actually crash (Guru +Meditation in the log)? Or did switchToFrame accept an OOB index and +leave the UI blank? +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import get_current_frame, wait_for_reason +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(90) +def test_fn_f5_out_of_bounds( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + lines: list[str] = request.node._debug_log_buffer + start = get_current_frame(lines) + assert start is not None + + if start.count > 5: + pytest.skip( + f"device has {start.count} frames; FN_F5 is in-bounds — not testing OOB here" + ) + + frame_capture("initial-home") + send_event(ui_port, InputEventCode.FN_F5) + time.sleep(0.5) + + try: + wait_for_reason(lines, "fn_f5", timeout_s=3.0) + except TimeoutError: + # Firmware may have ignored the event entirely — acceptable. + pass + + # Capture whatever is on screen (OCR will tell us if something weird + # happened). Device must remain responsive — subsequent events should + # still land. + frame_capture("after-fn_f5-oob") + + # Send a RIGHT to confirm the UI is still alive. If this times out, + # the OOB switchToFrame wedged the UI. + send_event(ui_port, InputEventCode.RIGHT) + post = wait_for_reason(lines, "next", timeout_s=5.0) + assert ( + post is not None + ), "UI wedged after OOB FN_F5 — RIGHT no longer produces frame log" + frame_capture("after-recovery-right") diff --git a/mcp-server/tests/ui/test_input_menu.py b/mcp-server/tests/ui/test_input_menu.py new file mode 100644 index 000000000..8799d7dfb --- /dev/null +++ b/mcp-server/tests/ui/test_input_menu.py @@ -0,0 +1,68 @@ +"""SELECT on the home frame opens the home menu; BACK closes it. + +The home menu is an overlay (menuHandler::homeBaseMenu), not a frame +transition — so we verify via OCR difference between before/after +captures rather than a `Screen: frame` log line. The underlying +mechanism is still InputBroker → Screen::handleInputEvent → menu +callback. +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import get_current_frame +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(120) +def test_select_opens_home_menu( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + lines: list[str] = request.node._debug_log_buffer + start = get_current_frame(lines) + assert start is not None + if start.name not in ("home", "deviceFocused"): + pytest.skip( + f"SELECT on {start.name!r} doesn't open homeBaseMenu; " + "test is only valid when the landing frame is home/deviceFocused" + ) + + initial = frame_capture("initial") + send_event(ui_port, InputEventCode.SELECT) + time.sleep(0.8) + opened = frame_capture("after-select") + + # The menu is an overlay (not a frame change). We cannot use log + # assertion — instead, OCR should differ because a menu list is now + # drawn on top. + initial_text = (initial.get("ocr_text") or "").strip() + opened_text = (opened.get("ocr_text") or "").strip() + if initial_text and opened_text: + # When OCR is available, require *some* difference between the two + # frames — even a single menu title changes the transcribed text. + assert initial_text != opened_text, ( + f"expected OCR diff after SELECT; both read {initial_text!r}. " + "If both are empty, check camera alignment + OCR backend." + ) + + # Back out — the menu dismisses on BACK. + send_event(ui_port, InputEventCode.BACK) + time.sleep(0.8) + closed = frame_capture("after-back") + + # Soft check: OCR after BACK should look different from the menu + # (either back to home or onto a previous frame — BACK's exact + # behavior when the menu is up vs. not-up varies). We don't assert + # equality because OLED rendering is pixel-stable but camera sampling + # introduces noise. + if opened_text and closed.get("ocr_text"): + close_text = (closed.get("ocr_text") or "").strip() + assert ( + close_text != opened_text + ), f"after BACK, OCR still looks like the menu: {close_text!r}" diff --git a/mcp-server/tests/ui/test_input_message_scroll.py b/mcp-server/tests/ui/test_input_message_scroll.py new file mode 100644 index 000000000..85dc2d8e2 --- /dev/null +++ b/mcp-server/tests/ui/test_input_message_scroll.py @@ -0,0 +1,60 @@ +"""Once we navigate to the textMessage frame, UP/DOWN exercises the +message-scroll path (or opens CannedMessages on empty devices). + +Weaker than a "no frame change" assertion because on a fresh bench +device the message store is usually empty, and the firmware's UP +handler in that case launches CannedMessage — which DOES rebuild +frames. We just verify the path doesn't crash + produce captures for +visual inspection. +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import get_current_frame, wait_for_frame +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(180) +def test_up_down_on_textmessage_survives( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + lines: list[str] = request.node._debug_log_buffer + frame_capture("initial") + + # Walk RIGHT until we land on textMessage — up to 15 hops. + for _i in range(15): + send_event(ui_port, InputEventCode.RIGHT) + time.sleep(0.3) + current = get_current_frame(lines) + if current is not None and current.name == "textMessage": + break + else: + pytest.skip( + "couldn't reach textMessage frame within 15 RIGHTs — not present on this board" + ) + + wait_for_frame(lines, "textMessage", timeout_s=5.0) + frame_capture("on-textMessage") + + # UP and DOWN exercise the message-scroll / canned-message-launch path. + # Capture after each so the HTML report shows any visual effect. + send_event(ui_port, InputEventCode.UP) + time.sleep(0.3) + frame_capture("after-up") + + send_event(ui_port, InputEventCode.DOWN) + time.sleep(0.3) + frame_capture("after-down") + + # Soft check: we should still be in a reachable frame (not wedged). + # The next test's `ui_home_state` will error out if the device is + # unresponsive, so we don't need a stricter guarantee here. + final = get_current_frame(lines) + assert final is not None, "no frame log after UP/DOWN — event path broke" diff --git a/mcp-server/tests/ui/test_input_navigation.py b/mcp-server/tests/ui/test_input_navigation.py new file mode 100644 index 000000000..1fe00b134 --- /dev/null +++ b/mcp-server/tests/ui/test_input_navigation.py @@ -0,0 +1,93 @@ +"""INPUT_BROKER_RIGHT cycles forward through frames; INPUT_BROKER_LEFT backs. + +The simplest UI test: fire N RIGHT events and assert the frame index +moves forward by N (modulo frameCount). Each step captures an image + +OCR for the HTML report. +""" + +from __future__ import annotations + +import time +from typing import Any + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import get_current_frame, wait_for_frame +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(120) +def test_input_right_cycles_frames( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + lines: list[str] = request.node._debug_log_buffer + start = get_current_frame(lines) + assert start is not None, "no frame log yet — USERPREFS_UI_TEST_LOG not wired?" + # FN_F1 in ui_home_state lands on frame 0. The name at frame 0 varies + # by board (home on heltec-v3, deviceFocused on others) — accept either. + assert start.name in ( + "home", + "deviceFocused", + ), f"setup expected home/deviceFocused at frame 0, got {start.name!r}" + + frame_capture("initial") + visited = [start.idx] + + for step in range(4): + send_event(ui_port, InputEventCode.RIGHT) + # Each RIGHT should bump the frame index by 1. The log fires with + # `reason=next` from showFrame(NEXT). + before_count = len(list(_frame_events(lines))) + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if len(list(_frame_events(lines))) > before_count: + break + time.sleep(0.1) + evt = get_current_frame(lines) + assert evt is not None + assert ( + evt.reason == "next" + ), f"step {step}: expected reason=next, got {evt.reason!r}" + visited.append(evt.idx) + frame_capture(f"after-right-{step + 1}") + + # Sanity: each index should differ from its predecessor. + diffs = [visited[i + 1] - visited[i] for i in range(len(visited) - 1)] + assert all( + d in (1, -(start.count - 1)) for d in diffs + ), f"expected monotonic +1 steps (or a wrap), got visited={visited} diffs={diffs}" + + +@pytest.mark.timeout(120) +def test_input_left_returns_to_home( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + """After RIGHT×3 + LEFT×3, we should end up back on the starting frame.""" + lines: list[str] = request.node._debug_log_buffer + start = get_current_frame(lines) + assert start is not None + start_name = start.name + frame_capture("initial") + for _ in range(3): + send_event(ui_port, InputEventCode.RIGHT) + time.sleep(0.3) + frame_capture("after-right-3") + + for _ in range(3): + send_event(ui_port, InputEventCode.LEFT) + time.sleep(0.3) + + # Back to whichever frame we started on (home or deviceFocused). + wait_for_frame(lines, start_name, timeout_s=5.0) + frame_capture(f"after-left-3-back-{start_name}") + + +def _frame_events(lines: list[str]) -> Any: + from ._screen_log import iter_frame_events + + return iter_frame_events(lines) diff --git a/mcp-server/tests/ui/test_input_node_scroll.py b/mcp-server/tests/ui/test_input_node_scroll.py new file mode 100644 index 000000000..594b358a0 --- /dev/null +++ b/mcp-server/tests/ui/test_input_node_scroll.py @@ -0,0 +1,51 @@ +"""On the nodelist_nodes frame, UP/DOWN scrolls the list via +`NodeListRenderer::scrollUp/scrollDown` (src/graphics/Screen.cpp:1779-1788). +The firmware returns 0 before notifying observers, so no frame-change +log fires. Verify the path doesn't crash and we stay on nodelist_nodes. +""" + +from __future__ import annotations + +import time + +import pytest +from meshtastic_mcp.input_events import InputEventCode + +from ._screen_log import assert_no_frame_change, get_current_frame, wait_for_frame +from .conftest import FrameCapture, send_event + + +@pytest.mark.timeout(180) +def test_up_down_on_nodelist_no_frame_change( + ui_port: str, + frame_capture: FrameCapture, + request: pytest.FixtureRequest, +) -> None: + lines: list[str] = request.node._debug_log_buffer + frame_capture("initial") + + # Walk RIGHT until we land on nodelist_nodes. + for _i in range(15): + send_event(ui_port, InputEventCode.RIGHT) + time.sleep(0.3) + current = get_current_frame(lines) + if current is not None and current.name == "nodelist_nodes": + break + else: + pytest.skip("couldn't reach nodelist_nodes within 15 RIGHTs") + + wait_for_frame(lines, "nodelist_nodes", timeout_s=5.0) + frame_capture("on-nodelist") + + # UP/DOWN on nodelist scroll internally + `return 0` before + # notifyObservers — no frame-change log. Verify. + send_event(ui_port, InputEventCode.UP) + assert_no_frame_change(lines, wait_s=1.5) + send_event(ui_port, InputEventCode.DOWN) + assert_no_frame_change(lines, wait_s=1.5) + + final = get_current_frame(lines) + assert ( + final is not None and final.name == "nodelist_nodes" + ), f"UP/DOWN moved us off nodelist_nodes; now on {final!r}" + frame_capture("after-up-down") diff --git a/mcp-server/tests/unit/test_input_event_codes.py b/mcp-server/tests/unit/test_input_event_codes.py new file mode 100644 index 000000000..f92698edf --- /dev/null +++ b/mcp-server/tests/unit/test_input_event_codes.py @@ -0,0 +1,90 @@ +"""Pin `InputEventCode` values to the firmware `input_broker_event` enum. + +If this test fails, someone changed the firmware enum (or this Python +mirror) and they must stay in sync — the admin RPC sends these as u8 +wire values directly. + +Also exercises `coerce_event_code` for the happy + error paths. +""" + +from __future__ import annotations + +import pytest +from meshtastic_mcp.input_events import InputEventCode, coerce_event_code + + +class TestInputEventCodeValues: + """These values MUST match src/input/InputBroker.h exactly.""" + + def test_navigation_keys(self) -> None: + assert int(InputEventCode.UP) == 17 + assert int(InputEventCode.DOWN) == 18 + assert int(InputEventCode.LEFT) == 19 + assert int(InputEventCode.RIGHT) == 20 + + def test_action_keys(self) -> None: + assert int(InputEventCode.SELECT) == 10 + assert int(InputEventCode.CANCEL) == 24 + assert int(InputEventCode.BACK) == 27 + + def test_long_press_variants(self) -> None: + assert int(InputEventCode.SELECT_LONG) == 11 + assert int(InputEventCode.UP_LONG) == 12 + assert int(InputEventCode.DOWN_LONG) == 13 + + def test_fn_keys(self) -> None: + assert int(InputEventCode.FN_F1) == 0xF1 + assert int(InputEventCode.FN_F2) == 0xF2 + assert int(InputEventCode.FN_F3) == 0xF3 + assert int(InputEventCode.FN_F4) == 0xF4 + assert int(InputEventCode.FN_F5) == 0xF5 + + def test_system_events(self) -> None: + assert int(InputEventCode.SHUTDOWN) == 0x9B + assert int(InputEventCode.GPS_TOGGLE) == 0x9E + assert int(InputEventCode.SEND_PING) == 0xAF + + def test_auto_increment_block(self) -> None: + # C enum: `BACK = 27, USER_PRESS, ALT_PRESS, ALT_LONG` → 28, 29, 30. + assert int(InputEventCode.USER_PRESS) == 28 + assert int(InputEventCode.ALT_PRESS) == 29 + assert int(InputEventCode.ALT_LONG) == 30 + + +class TestCoerceEventCode: + def test_int_passthrough(self) -> None: + assert coerce_event_code(20) == 20 + assert coerce_event_code(0) == 0 + assert coerce_event_code(255) == 255 + + def test_enum_passthrough(self) -> None: + assert coerce_event_code(InputEventCode.RIGHT) == 20 + assert coerce_event_code(InputEventCode.FN_F1) == 0xF1 + + def test_name_case_insensitive(self) -> None: + assert coerce_event_code("right") == 20 + assert coerce_event_code("RIGHT") == 20 + assert coerce_event_code("Right") == 20 + + def test_input_broker_prefix_stripped(self) -> None: + assert coerce_event_code("INPUT_BROKER_FN_F1") == 0xF1 + assert coerce_event_code("input_broker_select") == 10 + + def test_hyphen_and_underscore_equivalence(self) -> None: + assert coerce_event_code("fn-f1") == 0xF1 + + def test_int_out_of_range_raises(self) -> None: + with pytest.raises(ValueError, match="u8"): + coerce_event_code(256) + with pytest.raises(ValueError, match="u8"): + coerce_event_code(-1) + + def test_unknown_name_raises(self) -> None: + with pytest.raises(ValueError, match="unknown event code name"): + coerce_event_code("NOT_A_KEY") + + def test_wrong_type_raises(self) -> None: + with pytest.raises(TypeError): + coerce_event_code(1.5) # type: ignore[arg-type] + with pytest.raises(TypeError): + coerce_event_code(None) # type: ignore[arg-type] diff --git a/mcp-server/tests/unit/test_uhubctl_parser.py b/mcp-server/tests/unit/test_uhubctl_parser.py new file mode 100644 index 000000000..373147950 --- /dev/null +++ b/mcp-server/tests/unit/test_uhubctl_parser.py @@ -0,0 +1,148 @@ +"""Pin the `uhubctl` default-output parser against canned real-world samples. + +uhubctl's output format has been stable since v2.x but occasionally adds +new hub-descriptor fields (e.g. the `, ppps` marker). The parser uses loose +regexes to tolerate additions; this test keeps us honest. + +Samples captured from: +- v2.6.0 on macOS (Homebrew) — two USB2 hubs, one populated with an + nRF52 and a CP2102, plus chained USB3 hubs. +- v2.5.0 on Linux (hypothetical — reconstructed from the project README). +""" + +from __future__ import annotations + +import pytest +from meshtastic_mcp.uhubctl import ( + ROLE_VIDS, + UhubctlError, + parse_list_output, +) + +# Actual `uhubctl` stdout on the developer's macOS bench, Apr 2026. +_SAMPLE_MACOS_V26 = """\ +Current status for hub 1-1.3 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps] + Port 1: 0100 power + Port 2: 0103 power enable connect [239a:8029 RAKwireless WisCore RAK4631 Board 920456B1E6972262] + Port 3: 0103 power enable connect [10c4:ea60 Silicon Labs CP2102 USB to UART Bridge Controller 0001] + Port 4: 0100 power +Current status for hub 1-2.3 [2109:0817 VIA Labs, Inc. USB3.0 Hub, USB 3.10, 4 ports, ppps] + Port 1: 02a0 power 5gbps Rx.Detect + Port 2: 02a0 power 5gbps Rx.Detect + Port 3: 02a0 power 5gbps Rx.Detect + Port 4: 02a0 power 5gbps Rx.Detect +Current status for hub 1-1 [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps] + Port 1: 0100 power + Port 2: 0100 power + Port 3: 0503 power highspeed enable connect [2109:2817 VIA Labs, Inc. USB2.0 Hub, USB 2.10, 4 ports, ppps] + Port 4: 0100 power +""" + + +# Minimal Linux-style sample (fewer hubs, shows a non-PPPS hub). +_SAMPLE_LINUX_NONPPPS = """\ +Current status for hub 2-1.4 [05e3:0608 GenesysLogic USB2.1 Hub, USB 2.10, 4 ports] + Port 1: 0507 power highspeed suspend enable connect [239a:0029 Adafruit Feather Bootloader] + Port 2: 0100 power + Port 3: 0100 power + Port 4: 0100 power +""" + + +class TestParseListOutput: + def test_parses_macos_sample_hub_count(self) -> None: + hubs = parse_list_output(_SAMPLE_MACOS_V26) + assert len(hubs) == 3 + + def test_parses_hub_location_and_vid(self) -> None: + hubs = parse_list_output(_SAMPLE_MACOS_V26) + via_hub = hubs[0] + assert via_hub["location"] == "1-1.3" + assert via_hub["vid"] == 0x2109 + assert via_hub["pid"] == 0x2817 + assert via_hub["ppps"] is True + + def test_parses_port_with_device(self) -> None: + hubs = parse_list_output(_SAMPLE_MACOS_V26) + nrf52_hub = hubs[0] + port2 = next(p for p in nrf52_hub["ports"] if p["port"] == 2) + assert port2["device_vid"] == 0x239A + assert port2["device_pid"] == 0x8029 + assert "RAKwireless" in port2["device_desc"] + + def test_empty_port_has_no_device(self) -> None: + hubs = parse_list_output(_SAMPLE_MACOS_V26) + nrf52_hub = hubs[0] + port1 = next(p for p in nrf52_hub["ports"] if p["port"] == 1) + assert port1["device_vid"] is None + assert port1["device_pid"] is None + assert port1["device_desc"] is None + + def test_ports_count(self) -> None: + hubs = parse_list_output(_SAMPLE_MACOS_V26) + for hub in hubs: + assert len(hub["ports"]) == 4 # each sample hub has 4 ports + + def test_non_ppps_hub_flagged(self) -> None: + hubs = parse_list_output(_SAMPLE_LINUX_NONPPPS) + assert len(hubs) == 1 + assert hubs[0]["ppps"] is False + + def test_handles_empty_input(self) -> None: + assert parse_list_output("") == [] + + def test_handles_malformed_lines_gracefully(self) -> None: + # Lines that don't match HUB_RE or PORT_RE are ignored silently. + garbage = "uhubctl: warning: something weird\n" + _SAMPLE_LINUX_NONPPPS + hubs = parse_list_output(garbage) + assert len(hubs) == 1 + + +class TestRoleVids: + def test_nrf52_mapped(self) -> None: + assert 0x239A in ROLE_VIDS["nrf52"] + + def test_esp32s3_covers_both_vids(self) -> None: + # Espressif native USB + CP2102 USB-UART on heltec-v3 boards. + assert 0x303A in ROLE_VIDS["esp32s3"] + assert 0x10C4 in ROLE_VIDS["esp32s3"] + + +class TestResolveTargetErrorPaths: + def test_unknown_role_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + from meshtastic_mcp.uhubctl import resolve_target + + # Clear any env-var pinning that might make this pass accidentally. + for key in ( + "MESHTASTIC_UHUBCTL_LOCATION_FLUX", + "MESHTASTIC_UHUBCTL_PORT_FLUX", + ): + monkeypatch.delenv(key, raising=False) + with pytest.raises(UhubctlError, match="unknown role"): + resolve_target("flux") + + def test_invalid_env_port_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + from meshtastic_mcp.uhubctl import resolve_target + + monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "1-1.3") + monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "not-an-int") + with pytest.raises(UhubctlError, match="not a valid integer"): + resolve_target("nrf52") + + def test_env_var_pinning_wins(self, monkeypatch: pytest.MonkeyPatch) -> None: + from meshtastic_mcp.uhubctl import resolve_target + + # Env-var pinning should NOT require uhubctl to be running / installed. + monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_NRF52", "9-9.9") + monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_NRF52", "7") + assert resolve_target("nrf52") == ("9-9.9", 7) + + def test_normalize_role_strips_alt_suffix( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from meshtastic_mcp.uhubctl import resolve_target + + # esp32s3_alt collapses to esp32s3 for env-var lookup. + monkeypatch.setenv("MESHTASTIC_UHUBCTL_LOCATION_ESP32S3", "2-2") + monkeypatch.setenv("MESHTASTIC_UHUBCTL_PORT_ESP32S3", "3") + assert resolve_target("esp32s3_alt") == ("2-2", 3) diff --git a/mcp-server/tests/unit/test_ui_screen_log.py b/mcp-server/tests/unit/test_ui_screen_log.py new file mode 100644 index 000000000..55029b38c --- /dev/null +++ b/mcp-server/tests/unit/test_ui_screen_log.py @@ -0,0 +1,80 @@ +"""Pin the `Screen: frame N/M name=X reason=Y` regex + FrameEvent dataclass. + +The firmware-side format lives in `src/graphics/Screen.cpp::logFrameChange`; +if the format string changes, this test — and the parser in +`tests/ui/_screen_log.py` — have to be updated together. +""" + +from __future__ import annotations + +from tests.ui._screen_log import FRAME_RE, FrameEvent, iter_frame_events + + +class TestFrameEventParse: + def test_exact_firmware_output(self) -> None: + raw = "Screen: frame 2/8 name=home reason=next" + evt = FrameEvent.parse(raw) + assert evt is not None + assert evt.idx == 2 + assert evt.count == 8 + assert evt.name == "home" + assert evt.reason == "next" + assert evt.raw == raw + + def test_with_log_prefix(self) -> None: + """Log lines may be preamble-wrapped by the firmware LOG_INFO macro + (timestamp, severity, etc.) — the regex uses .search() not .match() + so prefixes are fine.""" + raw = "[INFO] 00:12:34 567 Screen: frame 4/12 name=nodelist_nodes reason=fn_f3 " + evt = FrameEvent.parse(raw) + assert evt is not None + assert evt.idx == 4 + assert evt.count == 12 + assert evt.name == "nodelist_nodes" + assert evt.reason == "fn_f3" + + def test_rebuild_reason(self) -> None: + evt = FrameEvent.parse("Screen: frame 0/5 name=deviceFocused reason=rebuild") + assert evt is not None + assert evt.reason == "rebuild" + + def test_all_fn_reasons(self) -> None: + for k in range(1, 6): + evt = FrameEvent.parse( + f"Screen: frame {k - 1}/8 name=settings reason=fn_f{k}" + ) + assert evt is not None and evt.reason == f"fn_f{k}" + + def test_unknown_name_is_preserved(self) -> None: + """If the reverse-map returns 'unknown', that still parses cleanly.""" + evt = FrameEvent.parse("Screen: frame 99/100 name=unknown reason=prev") + assert evt is not None and evt.name == "unknown" + + def test_non_matching_line_returns_none(self) -> None: + assert FrameEvent.parse("BOOT Booting firmware 2.7.23") is None + assert FrameEvent.parse("") is None + assert FrameEvent.parse("Screen: without the right format") is None + + +class TestIterFrameEvents: + def test_filters_non_matching_lines(self) -> None: + lines = [ + "Booting...", + "Screen: frame 1/5 name=home reason=rebuild", + "Some other log line", + "Screen: frame 2/5 name=textMessage reason=next", + ] + evts = list(iter_frame_events(lines)) + assert len(evts) == 2 + assert evts[0].reason == "rebuild" + assert evts[1].reason == "next" + + +class TestRegexAnchoring: + def test_regex_is_compiled(self) -> None: + assert FRAME_RE.search("Screen: frame 0/0 name=home reason=next") is not None + + def test_regex_allows_unusual_names(self) -> None: + r"""Name is `\S+`, so compound names with underscores/digits match.""" + m = FRAME_RE.search("Screen: frame 5/10 name=nodelist_hopsignal reason=fn_f2") + assert m is not None and m["name"] == "nodelist_hopsignal" diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index b9745b29a..3d13a68af 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -96,7 +96,6 @@ class ScanI2C CW2015, SCD30, ADS1115, - CST3530, } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 55ec93db5..60e1c43a6 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1265,6 +1265,10 @@ void Screen::setFrames(FrameFocus focus) // Store the info about this frameset, for future setFrames calls this->framesetInfo = fsi; +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("rebuild", ui->getUiState()->currentFrame); +#endif + setFastFramerate(); // Draw ASAP } @@ -1419,11 +1423,77 @@ void Screen::handleOnPress() } } +#ifdef USERPREFS_UI_TEST_LOG +void Screen::logFrameChange(const char *reason, uint8_t targetIdx) +{ + // Reverse-map an index to a stable name string keyed off FramePositions + // field names — so the pytest harness can assert `name=nodelist_nodes` + // without caring about how the positions were ordered this boot. + const auto &p = framesetInfo.positions; + const char *name = "unknown"; + if (targetIdx == p.home) + name = "home"; + else if (targetIdx == p.deviceFocused) + name = "deviceFocused"; + else if (targetIdx == p.textMessage) + name = "textMessage"; + else if (targetIdx == p.nodelist_nodes) + name = "nodelist_nodes"; + else if (targetIdx == p.nodelist_location) + name = "nodelist_location"; + else if (targetIdx == p.nodelist_lastheard) + name = "nodelist_lastheard"; + else if (targetIdx == p.nodelist_hopsignal) + name = "nodelist_hopsignal"; + else if (targetIdx == p.nodelist_distance) + name = "nodelist_distance"; + else if (targetIdx == p.nodelist_bearings) + name = "nodelist_bearings"; + else if (targetIdx == p.system) + name = "system"; + else if (targetIdx == p.gps) + name = "gps"; + else if (targetIdx == p.lora) + name = "lora"; + else if (targetIdx == p.clock) + name = "clock"; + else if (targetIdx == p.chirpy) + name = "chirpy"; + else if (targetIdx == p.fault) + name = "fault"; + else if (targetIdx == p.waypoint) + name = "waypoint"; + else if (targetIdx == p.focusedModule) + name = "focusedModule"; + else if (targetIdx == p.log) + name = "log"; + else if (targetIdx == p.settings) + name = "settings"; + else if (targetIdx == p.wifi) + name = "wifi"; + else if (p.firstFavorite != 255 && p.lastFavorite != 255 && targetIdx >= p.firstFavorite && targetIdx <= p.lastFavorite) + name = "favorite"; + LOG_INFO("Screen: frame %u/%u name=%s reason=%s", (unsigned)targetIdx, (unsigned)framesetInfo.frameCount, name, reason); +} +#endif + void Screen::showFrame(FrameDirection direction) { // Only advance frames when UI is stable if (ui->getUiState()->frameState == FIXED) { +#ifdef USERPREFS_UI_TEST_LOG + // Log the *intended* target before the (async) transition fires, so + // tests see a deterministic record of what was requested. + if (framesetInfo.frameCount > 0) { + uint8_t curr = ui->getUiState()->currentFrame; + uint8_t target = (direction == FrameDirection::NEXT) + ? (uint8_t)((curr + 1) % framesetInfo.frameCount) + : (uint8_t)((curr + framesetInfo.frameCount - 1) % framesetInfo.frameCount); + logFrameChange(direction == FrameDirection::NEXT ? "next" : "prev", target); + } +#endif + if (direction == FrameDirection::NEXT) { ui->nextFrame(); } else { @@ -1755,22 +1825,37 @@ int Screen::handleInputEvent(const InputEvent *event) showFrame(FrameDirection::NEXT); } else if (event->inputEvent == INPUT_BROKER_FN_F1) { this->ui->switchToFrame(0); +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("fn_f1", 0); +#endif lastScreenTransition = millis(); setFastFramerate(); } else if (event->inputEvent == INPUT_BROKER_FN_F2) { this->ui->switchToFrame(1); +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("fn_f2", 1); +#endif lastScreenTransition = millis(); setFastFramerate(); } else if (event->inputEvent == INPUT_BROKER_FN_F3) { this->ui->switchToFrame(2); +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("fn_f3", 2); +#endif lastScreenTransition = millis(); setFastFramerate(); } else if (event->inputEvent == INPUT_BROKER_FN_F4) { this->ui->switchToFrame(3); +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("fn_f4", 3); +#endif lastScreenTransition = millis(); setFastFramerate(); } else if (event->inputEvent == INPUT_BROKER_FN_F5) { this->ui->switchToFrame(4); +#ifdef USERPREFS_UI_TEST_LOG + logFrameChange("fn_f5", 4); +#endif lastScreenTransition = millis(); setFastFramerate(); } else if (event->inputEvent == INPUT_BROKER_UP_LONG) { diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index e259f7691..023f36f38 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -673,6 +673,16 @@ class Screen : public concurrency::OSThread void handleOnPress(); void handleStartFirmwareUpdateScreen(); +#ifdef USERPREFS_UI_TEST_LOG + // Test-only: emits one LOG_INFO line on every frame transition so the + // pytest harness can assert which frame is shown. Gated behind a macro + // so the chatty log doesn't ship in release builds. Enabled via + // build_testing_profile(enable_ui_log=True) in mcp-server/userprefs.py. + // Member function (not free) because FramesetInfo is a private nested + // type — only methods of Screen can reach it. + void logFrameChange(const char *reason, uint8_t targetIdx); +#endif + // Info collected by setFrames method. // Index location of specific frames. // - Used to apply the FrameFocus parameter of setFrames diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 7492d7361..8a1843bcb 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1489,8 +1489,15 @@ void AdminModule::handleSendInputEvent(const meshtastic_AdminMessage_InputEvent LOG_DEBUG("Processing input event: event_code=%u, kb_char=%u, touch_x=%u, touch_y=%u", inputEvent.event_code, inputEvent.kb_char, inputEvent.touch_x, inputEvent.touch_y); - // Create InputEvent for injection - InputEvent event = {.inputEvent = (input_broker_event)inputEvent.event_code, + // Create InputEvent for injection. + // + // `.source` MUST be a non-null C string: the LOG_INFO below formats it + // with %s, and passing NULL to the esp-log formatter crashes with + // Guru Meditation LoadProhibited at strlen(NULL). Other InputBroker + // sources (buttons, rotary) always set this; the admin path was the + // only one leaving it default-null. + InputEvent event = {.source = "admin", + .inputEvent = (input_broker_event)inputEvent.event_code, .kbchar = (unsigned char)inputEvent.kb_char, .touchX = inputEvent.touch_x, .touchY = inputEvent.touch_y}; diff --git a/userPrefs.jsonc b/userPrefs.jsonc index b81f09362..a8201bab3 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -57,6 +57,7 @@ // "USERPREFS_MQTT_ROOT_TOPIC": "event/REPLACEME", // "USERPREFS_RINGTONE_NAG_SECS": "60", // "USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS": "43200", + // "USERPREFS_UI_TEST_LOG": "true", // Test-only: emits `Screen: frame N/M name=... reason=...` log per UI transition (for the mcp-server ui test tier); off in release builds. "USERPREFS_RINGTONE_RTTTL": "24:d=32,o=5,b=565:f6,p,f6,4p,p,f6,p,f6,2p,p,b6,p,b6,p,b6,p,b6,p,b,p,b,p,b,p,b,p,b,p,b,p,b,p,b,1p.,2p.,p", // "USERPREFS_NETWORK_IPV6_ENABLED": "1", "USERPREFS_TZ_STRING": "tzplaceholder "