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/platformio.ini b/platformio.ini
index 0205d1ad8..48f534d5d 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -124,7 +124,7 @@ lib_deps =
[device-ui_base]
lib_deps =
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
- https://github.com/meshtastic/device-ui/archive/5305670b68eb5b92d14e62b5b536969ca4bb441f.zip
+ https://github.com/meshtastic/device-ui/archive/56e1da4e7d30abcd746a2092a30e422f8cf5fc2b.zip
; Common libs for environmental measurements in telemetry module
[environmental_base]
diff --git a/protobufs b/protobufs
index e30092e61..4d5b500df 160000
--- a/protobufs
+++ b/protobufs
@@ -1 +1 @@
-Subproject commit e30092e6168b13341c2b7ec4be19c789ad5cd77f
+Subproject commit 4d5b500df5af68a4f57d3e19705cc3bb1136358c
diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h
index d451d3948..3d13a68af 100644
--- a/src/detect/ScanI2C.h
+++ b/src/detect/ScanI2C.h
@@ -31,6 +31,9 @@ class ScanI2C
INA3221,
MAX17048,
MCP9808,
+ SHT31,
+ SHT4X,
+ SHTC3,
LPS22HB,
QMC6310U,
QMC6310N,
@@ -86,13 +89,13 @@ class ScanI2C
DA217,
CHSC6X,
CST226SE,
+ CST3530,
BMI270,
SEN5X,
SFA30,
CW2015,
SCD30,
ADS1115,
- SHTXX
} DeviceType;
// typedef uint8_t DeviceAddress;
diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp
index 052b2245a..e298663a0 100644
--- a/src/detect/ScanI2CTwoWire.cpp
+++ b/src/detect/ScanI2CTwoWire.cpp
@@ -629,7 +629,31 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
SCAN_SIMPLE_CASE(PCT2075_ADDR, PCT2075, "PCT2075", (uint8_t)addr.address);
SCAN_SIMPLE_CASE(SCD30_ADDR, SCD30, "SCD30", (uint8_t)addr.address);
case CST328_ADDR:
- // Do we have the CST328 or the CST226SE
+ // Do we have the CST328 or the CST226SE,CST3530
+ {
+ // T-Deck pro V1.1 new touch panel use CST3530
+ int retry = 5;
+ while (retry--) {
+ uint8_t buffer[7];
+ uint8_t r_cmd[] = {0x0d0, 0x03, 0x00, 0x00};
+ i2cBus->beginTransmission(addr.address);
+ i2cBus->write(r_cmd, sizeof(r_cmd));
+ if (i2cBus->endTransmission() == 0) {
+ i2cBus->requestFrom((int)addr.address, 7);
+ i2cBus->readBytes(buffer, 7);
+ if (buffer[2] == 0xCA && buffer[3] == 0xCA) {
+ logFoundDevice("CST3530", (uint8_t)addr.address);
+ type = CST3530;
+ break;
+ }
+ }
+ uint8_t cmd1[] = {0xD0, 0x00, 0x04, 0x00};
+ i2cBus->beginTransmission(addr.address);
+ i2cBus->write(cmd1, sizeof(cmd1));
+ i2cBus->endTransmission();
+ delay(50);
+ }
+ }
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xAB), 1);
if (registerValue == 0xA9) {
type = CST226SE;
diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp
index 704487bc8..28b956bb1 100644
--- a/src/graphics/EInkDisplay2.cpp
+++ b/src/graphics/EInkDisplay2.cpp
@@ -104,8 +104,13 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit)
// End the update process - virtual method, overridden in derived class
void EInkDisplay::endUpdate()
{
- // Power off display hardware, then deep-sleep (Except Wireless Paper V1.1, no deep-sleep)
+#ifndef EINK_NOT_HIBERNATE
+ // By default, power off the E-Ink display hardware and enter hibernate().
+ // Boards/panels that define EINK_NOT_HIBERNATE intentionally skip this step.
+ // Skipping hibernate() can help avoid panel-specific wake/refresh or ghosting issues,
+ // but it typically trades lower power savings for that compatibility.
adafruitDisplay->hibernate();
+#endif
}
// Write the buffer to the display memory
diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp
index 0fc34ddb3..f315011d8 100644
--- a/src/graphics/Screen.cpp
+++ b/src/graphics/Screen.cpp
@@ -1357,6 +1357,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
}
@@ -1511,11 +1515,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 {
@@ -1847,22 +1917,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 5a1a2d6da..401cba59e 100644
--- a/src/graphics/Screen.h
+++ b/src/graphics/Screen.h
@@ -669,6 +669,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/main.cpp b/src/main.cpp
index 6f78c0b96..e517f94f0 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -340,9 +340,138 @@ void setup()
#ifdef BLE_LED
pinMode(BLE_LED, OUTPUT);
+#ifdef BLE_LED_INVERTED
+ digitalWrite(BLE_LED, HIGH);
+#else
+ digitalWrite(BLE_LED, LOW);
+#endif
+#endif
+
+#if defined(T_DECK)
+ // GPIO10 manages all peripheral power supplies
+ // Turn on peripheral power immediately after MUC starts.
+ // If some boards are turned on late, ESP32 will reset due to low voltage.
+ // ESP32-C3(Keyboard) , MAX98357A(Audio Power Amplifier) ,
+ // TF Card , Display backlight(AW9364DNR) , AN48841B(Trackball) , ES7210(Decoder)
+ pinMode(KB_POWERON, OUTPUT);
+ digitalWrite(KB_POWERON, HIGH);
+ // T-Deck has all three SPI peripherals (TFT, SD, LoRa) attached to the same SPI bus
+ // We need to initialize all CS pins in advance otherwise there will be SPI communication issues
+ // e.g. when detecting the SD card
+ pinMode(LORA_CS, OUTPUT);
+ digitalWrite(LORA_CS, HIGH);
+ pinMode(SDCARD_CS, OUTPUT);
+ digitalWrite(SDCARD_CS, HIGH);
+ pinMode(TFT_CS, OUTPUT);
+ digitalWrite(TFT_CS, HIGH);
+ delay(100);
+#elif defined(T_DECK_PRO)
+ pinMode(LORA_EN, OUTPUT);
+ digitalWrite(LORA_EN, HIGH);
+ pinMode(LORA_CS, OUTPUT);
+ digitalWrite(LORA_CS, HIGH);
+ pinMode(SDCARD_CS, OUTPUT);
+ digitalWrite(SDCARD_CS, HIGH);
+ pinMode(PIN_EINK_CS, OUTPUT);
+ digitalWrite(PIN_EINK_CS, HIGH);
+#if PIN_EINK_RES >= 0
+ pinMode(PIN_EINK_RES, OUTPUT);
+ digitalWrite(PIN_EINK_RES, HIGH);
+#endif
+ pinMode(CST328_PIN_RST, OUTPUT);
+ digitalWrite(CST328_PIN_RST, HIGH);
+#elif defined(T_LORA_PAGER)
+ pinMode(LORA_CS, OUTPUT);
+ digitalWrite(LORA_CS, HIGH);
+ pinMode(SDCARD_CS, OUTPUT);
+ digitalWrite(SDCARD_CS, HIGH);
+ pinMode(TFT_CS, OUTPUT);
+ digitalWrite(TFT_CS, HIGH);
+ pinMode(KB_INT, INPUT_PULLUP);
+ // io expander
+ io.begin(Wire, XL9555_SLAVE_ADDRESS0, SDA, SCL);
+ io.pinMode(EXPANDS_DRV_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_DRV_EN, HIGH);
+ io.pinMode(EXPANDS_AMP_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_AMP_EN, LOW);
+ io.pinMode(EXPANDS_LORA_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_LORA_EN, HIGH);
+ io.pinMode(EXPANDS_GPS_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_GPS_EN, HIGH);
+ io.pinMode(EXPANDS_KB_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_KB_EN, HIGH);
+ io.pinMode(EXPANDS_SD_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_SD_EN, HIGH);
+ io.pinMode(EXPANDS_GPIO_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_GPIO_EN, HIGH);
+ io.pinMode(EXPANDS_SD_PULLEN, INPUT);
+#elif defined(HACKADAY_COMMUNICATOR)
+ pinMode(KB_INT, INPUT);
digitalWrite(BLE_LED, LED_STATE_OFF);
#endif
+#if defined(T_DECK)
+ // GPIO10 manages all peripheral power supplies
+ // Turn on peripheral power immediately after MUC starts.
+ // If some boards are turned on late, ESP32 will reset due to low voltage.
+ // ESP32-C3(Keyboard) , MAX98357A(Audio Power Amplifier) ,
+ // TF Card , Display backlight(AW9364DNR) , AN48841B(Trackball) , ES7210(Decoder)
+ pinMode(KB_POWERON, OUTPUT);
+ digitalWrite(KB_POWERON, HIGH);
+ // T-Deck has all three SPI peripherals (TFT, SD, LoRa) attached to the same SPI bus
+ // We need to initialize all CS pins in advance otherwise there will be SPI communication issues
+ // e.g. when detecting the SD card
+ pinMode(LORA_CS, OUTPUT);
+ digitalWrite(LORA_CS, HIGH);
+ pinMode(SDCARD_CS, OUTPUT);
+ digitalWrite(SDCARD_CS, HIGH);
+ pinMode(TFT_CS, OUTPUT);
+ digitalWrite(TFT_CS, HIGH);
+ delay(100);
+#elif defined(T_DECK_PRO)
+ pinMode(LORA_EN, OUTPUT);
+ digitalWrite(LORA_EN, HIGH);
+ pinMode(LORA_CS, OUTPUT);
+ digitalWrite(LORA_CS, HIGH);
+ pinMode(SDCARD_CS, OUTPUT);
+ digitalWrite(SDCARD_CS, HIGH);
+ pinMode(PIN_EINK_CS, OUTPUT);
+ digitalWrite(PIN_EINK_CS, HIGH);
+#if PIN_EINK_RES >= 0
+ pinMode(PIN_EINK_RES, OUTPUT);
+ digitalWrite(PIN_EINK_RES, HIGH);
+#endif
+ pinMode(CST328_PIN_RST, OUTPUT);
+ digitalWrite(CST328_PIN_RST, HIGH);
+#elif defined(T_LORA_PAGER)
+ pinMode(LORA_CS, OUTPUT);
+ digitalWrite(LORA_CS, HIGH);
+ pinMode(SDCARD_CS, OUTPUT);
+ digitalWrite(SDCARD_CS, HIGH);
+ pinMode(TFT_CS, OUTPUT);
+ digitalWrite(TFT_CS, HIGH);
+ pinMode(KB_INT, INPUT_PULLUP);
+ // io expander
+ io.begin(Wire, XL9555_SLAVE_ADDRESS0, SDA, SCL);
+ io.pinMode(EXPANDS_DRV_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_DRV_EN, HIGH);
+ io.pinMode(EXPANDS_AMP_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_AMP_EN, LOW);
+ io.pinMode(EXPANDS_LORA_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_LORA_EN, HIGH);
+ io.pinMode(EXPANDS_GPS_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_GPS_EN, HIGH);
+ io.pinMode(EXPANDS_KB_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_KB_EN, HIGH);
+ io.pinMode(EXPANDS_SD_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_SD_EN, HIGH);
+ io.pinMode(EXPANDS_GPIO_EN, OUTPUT);
+ io.digitalWrite(EXPANDS_GPIO_EN, HIGH);
+ io.pinMode(EXPANDS_SD_PULLEN, INPUT);
+#elif defined(HACKADAY_COMMUNICATOR)
+ pinMode(KB_INT, INPUT);
+#endif
+
concurrency::hasBeenSetup = true;
#if HAS_SCREEN
meshtastic_Config_DisplayConfig_OledType screen_model =
diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp
index 05bc0aa5d..852a257e5 100644
--- a/src/modules/AdminModule.cpp
+++ b/src/modules/AdminModule.cpp
@@ -1519,8 +1519,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/src/platform/extra_variants/t_deck_pro/variant.cpp b/src/platform/extra_variants/t_deck_pro/variant.cpp
index eae9335ce..2915ff363 100644
--- a/src/platform/extra_variants/t_deck_pro/variant.cpp
+++ b/src/platform/extra_variants/t_deck_pro/variant.cpp
@@ -8,20 +8,127 @@
CSE_CST328 tsPanel = CSE_CST328(EINK_WIDTH, EINK_HEIGHT, &Wire, CST328_PIN_RST, CST328_PIN_INT);
+static bool is_cst3530 = false;
+volatile bool touch_isr = false;
+#define CST3530_ADDR 0x1A
+
+bool read_cst3530_touch(int16_t *x, int16_t *y)
+{
+ uint8_t buffer[9] = {0};
+ uint8_t r_cmd[] = {0xD0, 0x07, 0x00, 0x00};
+ uint8_t clear_cmd[] = {0xD0, 0x00, 0x02, 0xAB};
+
+ Wire.beginTransmission(CST3530_ADDR);
+ Wire.write(r_cmd, sizeof(r_cmd));
+ if (Wire.endTransmission() != 0) {
+ LOG_DEBUG("CST3530 I2C send addr failed");
+ return false;
+ }
+
+ int read_len = Wire.requestFrom((int)CST3530_ADDR, sizeof(buffer));
+ if (read_len != sizeof(buffer)) {
+ LOG_DEBUG("CST3530 read len error: %d (expect 9)", read_len);
+ return false;
+ }
+ int actual_read = Wire.readBytes(buffer, sizeof(buffer));
+ if (actual_read != sizeof(buffer)) {
+ LOG_DEBUG("CST3530 read bytes error: %d (expect 9)", actual_read);
+ return false;
+ }
+
+ uint8_t report_typ = buffer[2];
+ if (report_typ != 0xFF) {
+ return false;
+ }
+
+ uint8_t touch_points = buffer[3] & 0x0F;
+ if (touch_points == 0 || touch_points > 1) {
+ LOG_DEBUG("CST3530 touch points invalid: %d", touch_points);
+ return false;
+ }
+
+ *x = buffer[4] + ((uint16_t)(buffer[7] & 0x0F) << 8);
+ *y = buffer[5] + ((uint16_t)(buffer[7] & 0xF0) << 4);
+
+ // LOG_DEBUG("CST3530 touch: num:%d x=%d,y=%d", touch_points, *x, *y);
+
+ Wire.beginTransmission(CST3530_ADDR);
+ Wire.write(clear_cmd, sizeof(clear_cmd));
+ if (Wire.endTransmission() != 0) {
+ LOG_DEBUG("CST3530 clear cmd failed");
+ }
+
+ return true;
+}
+
bool readTouch(int16_t *x, int16_t *y)
{
- if (tsPanel.getTouches()) {
- *x = tsPanel.getPoint(0).x;
- *y = tsPanel.getPoint(0).y;
- return true;
+
+ if (is_cst3530) {
+ if (touch_isr) {
+ touch_isr = false;
+ return read_cst3530_touch(x, y);
+ }
+ return false;
+ } else {
+ if (tsPanel.getTouches()) {
+ *x = tsPanel.getPoint(0).x;
+ *y = tsPanel.getPoint(0).y;
+ return true;
+ }
}
return false;
}
+static void IRAM_ATTR touchInterruptHandler()
+{
+ touch_isr = true;
+}
+
// T-Deck Pro specific init
void lateInitVariant()
{
- tsPanel.begin();
+ // Reset touch
+ pinMode(CST328_PIN_RST, OUTPUT);
+ digitalWrite(CST328_PIN_RST, HIGH);
+ delay(20);
+ digitalWrite(CST328_PIN_RST, LOW);
+ delay(80);
+ digitalWrite(CST328_PIN_RST, HIGH);
+ delay(20);
+
+ int retry = 5;
+ uint8_t buffer[7];
+ uint8_t r_cmd[] = {0x0d0, 0x03, 0x00, 0x00};
+
+ // Probe touch chip
+ while (retry--) {
+ Wire.beginTransmission(CST3530_ADDR);
+ Wire.write(r_cmd, sizeof(r_cmd));
+ if (Wire.endTransmission() == 0) {
+ Wire.requestFrom((int)CST3530_ADDR, 7);
+ Wire.readBytes(buffer, 7);
+ if (buffer[2] == 0xCA && buffer[3] == 0xCA) {
+ LOG_DEBUG("CST3530 detected");
+ is_cst3530 = true;
+
+ // The CST3530 will automatically enter sleep mode;
+ // polling should not be used, but rather an interrupt method should be employed.
+ pinMode(CST328_PIN_INT, INPUT);
+ attachInterrupt(digitalPinToInterrupt(CST328_PIN_INT), touchInterruptHandler, FALLING);
+
+ break;
+ } else {
+ LOG_DEBUG("CST3530 not response ~!");
+ }
+ }
+ uint8_t cmd1[] = {0xD0, 0x00, 0x04, 0x00};
+ Wire.beginTransmission(CST3530_ADDR);
+ Wire.write(cmd1, sizeof(cmd1));
+ Wire.endTransmission();
+ delay(50);
+ }
+
touchScreenImpl1 = new TouchScreenImpl1(EINK_WIDTH, EINK_HEIGHT, readTouch);
touchScreenImpl1->init();
}
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 "
diff --git a/variants/esp32s3/t-deck-pro-v1_1/pins_arduino.h b/variants/esp32s3/t-deck-pro-v1_1/pins_arduino.h
new file mode 100644
index 000000000..af0ba80b3
--- /dev/null
+++ b/variants/esp32s3/t-deck-pro-v1_1/pins_arduino.h
@@ -0,0 +1,19 @@
+#ifndef Pins_Arduino_h
+#define Pins_Arduino_h
+
+#include
+
+#define USB_VID 0x303a
+#define USB_PID 0x1001
+
+// used for keyboard, touch controller, beam sensor, and gyroscope
+static const uint8_t SDA = 13;
+static const uint8_t SCL = 14;
+
+// Default SPI will be mapped to Radio
+static const uint8_t SS = 3;
+static const uint8_t MOSI = 33;
+static const uint8_t MISO = 47;
+static const uint8_t SCK = 36;
+
+#endif /* Pins_Arduino_h */
diff --git a/variants/esp32s3/t-deck-pro-v1_1/platformio.ini b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini
new file mode 100644
index 000000000..1a9b20f76
--- /dev/null
+++ b/variants/esp32s3/t-deck-pro-v1_1/platformio.ini
@@ -0,0 +1,41 @@
+[env:t-deck-pro-v1_1]
+custom_meshtastic_hw_model = 102
+custom_meshtastic_hw_model_slug = T_DECK_PRO
+custom_meshtastic_architecture = esp32-s3
+custom_meshtastic_actively_supported = true
+custom_meshtastic_support_level = 1
+custom_meshtastic_display_name = LILYGO T-Deck Pro
+custom_meshtastic_images = tdeck_pro.svg
+custom_meshtastic_tags = LilyGo
+custom_meshtastic_requires_dfu = true
+custom_meshtastic_partition_scheme = 16MB
+
+extends = esp32s3_base
+board = t-deck-pro
+board_check = true
+upload_protocol = esptool
+
+build_flags =
+ ${esp32s3_base.build_flags} -I variants/esp32s3/t-deck-pro-v1_1
+ -D T_DECK_PRO
+ -D USE_EINK
+ -D EINK_DISPLAY_MODEL=GxEPD2_310_GDEQ031T10
+ -D EINK_WIDTH=240
+ -D EINK_HEIGHT=320
+ ;-D USE_EINK_DYNAMICDISPLAY ; Enable Dynamic EInk
+ -D EINK_LIMIT_FASTREFRESH=10 ; How many consecutive fast-refreshes are permitted
+ -D EINK_LIMIT_GHOSTING_PX=2000 ; (Optional) How much image ghosting is tolerated
+ -D EINK_NOT_HIBERNATE ; Disable hibernate to avoid issues with elink
+
+lib_deps =
+ ${esp32s3_base.lib_deps}
+ # renovate: datasource=custom.pio depName=GxEPD2 packageName=zinggjm/library/GxEPD2
+ zinggjm/GxEPD2@1.6.8
+ # renovate: datasource=git-refs depName=CSE_Touch packageName=https://github.com/CIRCUITSTATE/CSE_Touch gitBranch=main
+ https://github.com/CIRCUITSTATE/CSE_Touch/archive/b44f23b6f870b848f1fbe453c190879bc6cfaafa.zip
+ # renovate: datasource=github-tags depName=CSE_CST328 packageName=CIRCUITSTATE/CSE_CST328
+ https://github.com/CIRCUITSTATE/CSE_CST328/archive/refs/tags/v0.0.4.zip
+ # renovate: datasource=git-refs depName=BQ27220 packageName=https://github.com/mverch67/BQ27220 gitBranch=main
+ https://github.com/mverch67/BQ27220/archive/07d92be846abd8a0258a50c23198dac0858b22ed.zip
+ # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library
+ adafruit/Adafruit DRV2605 Library@1.2.4
diff --git a/variants/esp32s3/t-deck-pro-v1_1/variant.h b/variants/esp32s3/t-deck-pro-v1_1/variant.h
new file mode 100644
index 000000000..d7accfe82
--- /dev/null
+++ b/variants/esp32s3/t-deck-pro-v1_1/variant.h
@@ -0,0 +1,106 @@
+// Display (E-Ink)
+#define PIN_EINK_CS 34
+#define PIN_EINK_BUSY 37
+#define PIN_EINK_DC 35
+#define PIN_EINK_RES 16
+#define PIN_EINK_SCLK 36
+#define PIN_EINK_MOSI 47
+#define TFT_BL 45 // option , default not backlight
+
+#define I2C_SDA SDA
+#define I2C_SCL SCL
+
+// CST328 touch screen (implementation in src/platform/extra_variants/t_deck_pro/variant.cpp)
+#define HAS_TOUCHSCREEN 1
+#define CST328_PIN_INT 12
+#define CST328_PIN_RST 38
+
+#define USE_POWERSAVE
+#define SLEEP_TIME 120
+
+// GNSS
+#define HAS_GPS 1
+#define GPS_BAUDRATE 38400
+#define PIN_GPS_EN 39
+#define GPS_EN_ACTIVE 1
+#define GPS_RX_PIN 44
+#define GPS_TX_PIN 43
+#define PIN_GPS_PPS 1
+
+#define BUTTON_PIN 0
+
+// vibration motor
+#define HAS_DRV2605
+#define PIN_DRV_EN 2
+
+// Have SPI interface SD card slot
+#define HAS_SDCARD
+#define SDCARD_USE_SPI1
+#define SPI_MOSI (33)
+#define SPI_SCK (36)
+#define SPI_MISO (47)
+#define SPI_CS (48)
+#define SDCARD_CS SPI_CS
+#define SD_SPI_FREQUENCY 75000000U
+
+// TCA8418 keyboard
+#define KB_BL_PIN 42
+#define CANNED_MESSAGE_MODULE_ENABLE 1
+
+// microphone PCM5102A
+#define PCM5102A_SCK 47
+#define PCM5102A_DIN 17
+#define PCM5102A_LRCK 18
+
+// LTR_553ALS light sensor
+#define HAS_LTR553ALS
+
+// gyroscope BHI260AP
+// #define BOARD_1V8_EN 38 //Deck-Pro remove 1.8v en pin
+#define HAS_BHI260AP
+
+// battery charger BQ25896
+#define HAS_PPM 1
+#define XPOWERS_CHIP_BQ25896
+
+// battery quality management BQ27220
+#define HAS_BQ27220 1
+#define BQ27220_I2C_SDA SDA
+#define BQ27220_I2C_SCL SCL
+#define BQ27220_DESIGN_CAPACITY 1400
+
+// LoRa
+#define USE_SX1262
+#define USE_SX1268
+
+#define LORA_EN 46 // LoRa enable pin
+#define LORA_SCK 36
+#define LORA_MISO 47
+#define LORA_MOSI 33
+#define LORA_CS 3
+
+#define LORA_DIO0 -1 // a No connect on the SX1262 module
+#define LORA_RESET 4
+#define LORA_DIO1 5 // SX1262 IRQ
+#define LORA_DIO2 6 // SX1262 BUSY
+#define LORA_DIO3 // Not connected on PCB, but internally on the TTGO SX1262, if DIO3 is high the TXCO is enabled
+
+#define SX126X_CS LORA_CS // FIXME - we really should define LORA_CS instead
+#define SX126X_DIO1 LORA_DIO1
+#define SX126X_BUSY LORA_DIO2
+#define SX126X_RESET LORA_RESET
+// Not really an E22 but TTGO seems to be trying to clone that
+#define SX126X_DIO2_AS_RF_SWITCH
+#define SX126X_DIO3_TCXO_VOLTAGE 2.4
+// Internally the TTGO module hooks the SX1262-DIO2 in to control the TX/RX switch (which is the default for the sx1262interface
+// code)
+
+#define MODEM_POWER_EN 41
+#define MODEM_PWRKEY 40
+#define MODEM_RST 9
+#define MODEM_RI 7
+#define MODEM_DTR 8
+#define MODEM_RX 10
+#define MODEM_TX 11
+
+#define HAS_PHYSICAL_KEYBOARD 1
\ No newline at end of file