Merge branch 'pioarduino' into crowpanel-p4

This commit is contained in:
Manuel
2026-04-22 22:28:41 +02:00
committed by GitHub
240 changed files with 22980 additions and 1048 deletions

View File

@@ -0,0 +1,49 @@
# Claude Code slash commands for the mcp-server test suite
Three AI-assisted workflows wrapping `mcp-server/run-tests.sh` and the meshtastic MCP tools. Each one has a twin in `.github/prompts/` for Copilot users.
| Slash command | What it does | Copilot equivalent |
| --------------------- | ------------------------------------------------------------------------- | ---------------------------------------- |
| `/test [args]` | Runs the test suite (auto-detects hardware) and interprets failures | `.github/prompts/mcp-test.prompt.md` |
| `/diagnose [role]` | Read-only device health report via the meshtastic MCP tools | `.github/prompts/mcp-diagnose.prompt.md` |
| `/repro <test> [n=5]` | Re-runs one test N times, diffs firmware logs between passes and failures | `.github/prompts/mcp-repro.prompt.md` |
## Why two surfaces
The Claude Code commands and Copilot prompts cover the same three workflows but each speaks its host's idiom:
- **Claude Code** (`/test`) uses `$ARGUMENTS` for pass-through, has direct access to Bash + all MCP tools registered in the user's settings, and runs in the terminal context.
- **Copilot** (`/mcp-test`) runs in VS Code's agent mode; it has terminal + MCP access too but typically asks the operator to confirm inputs interactively.
A contributor using either IDE gets equivalent assistance. Keep the two in sync when behavior changes — the diff of intent should be minimal.
## House rules
- **No destructive writes without explicit operator approval.** Skills that could reflash, factory-reset, or reboot a device must describe the action and stop — the operator authorizes.
- **Interpret failures, don't just echo them.** The skill body should pull firmware log lines from `mcp-server/tests/report.html` (the `Meshtastic debug` section, attached by `tests/conftest.py::pytest_runtest_makereport`) and classify the failure.
- **Keep MCP tool calls sequential per port.** SerialInterface holds an exclusive port lock; two parallel tool calls on the same port deadlock.
- **Never speculate about root cause.** If the evidence doesn't support a classification, say "unknown" and list what you'd need to disambiguate.
## Adding a new command
1. Write the Claude Code version at `.claude/commands/<name>.md` with YAML frontmatter:
```yaml
---
description: one-line purpose (used for auto-invocation by the model)
argument-hint: [optional-hint]
---
```
2. Write the Copilot equivalent at `.github/prompts/mcp-<name>.prompt.md` with:
```yaml
---
mode: agent
description: ...
---
```
3. Add the row to the table above. Cross-link in both bodies.
4. Smoke-test on Claude Code first (`/<name>` should appear in autocomplete), then in VS Code Copilot (`/mcp-<name>` in Chat).

View File

@@ -0,0 +1,62 @@
---
description: Produce a device health report using the meshtastic MCP tools (device_info, list_nodes, get_config, short serial log capture)
argument-hint: [role=all|nrf52|esp32s3|<port>]
---
# `/diagnose` — device health report
Call the meshtastic MCP tool bundle and format a structured health report for one or all detected devices. Zero guesswork for the operator.
## What to do
1. **Enumerate hardware.** Call `mcp__meshtastic__list_devices(include_unknown=True)`. For each entry where `likely_meshtastic=True`, capture `port`, `vid`, `pid`, `description`.
2. **Filter by `$ARGUMENTS`**:
- No args, `all` → every likely-meshtastic device.
- `nrf52` → only devices with `vid == 0x239a`.
- `esp32s3` → only devices with `vid == 0x303a` or `vid == 0x10c4`.
- A `/dev/cu.*` path → only that one port.
- Anything else → treat as a substring match against the `port` string.
3. **For each selected device, in sequence (NOT parallel — SerialInterface holds an exclusive port lock):**
- `mcp__meshtastic__device_info(port=<p>)` — captures `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel`.
- `mcp__meshtastic__list_nodes(port=<p>)` — count of peers, which ones have `publicKey` set, SNR/RSSI distribution.
- `mcp__meshtastic__get_config(section="lora", port=<p>)` — 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=<p>, env=<inferred-env>)`, wait 3s, `serial_read(session_id=<s>, max_lines=100)`, `serial_close(session_id=<s>)`. The env should be inferred from the VID map in `mcp-server/run-tests.sh` (nrf52 → rak4631, esp32s3 → heltec-v3) unless `MESHTASTIC_MCP_ENV*<ROLE>` is set.
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
owner : Meshtastic 40eb / 40eb
region/band : US, channel 88, LONG_FAST
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, device on non-PPPS hub), flag it inline with a short `⚠︎ <one-line reason>`.
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.
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, 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
- No writes. No `set_config`, no `reboot`, no `factory_reset`. This is a read-only diagnostic skill — if the operator wants to change state, they'll ask explicitly.
- No `flash` / `erase_and_flash`. Those are separate escalations.
- No holding SerialInterface across tool calls — open, query, close; next device. The port lock is exclusive.

66
.claude/commands/repro.md Normal file
View File

@@ -0,0 +1,66 @@
---
description: Re-run a specific test N times in isolation to triage flakes, diff firmware logs between passes and failures
argument-hint: <test-node-id> [count=5]
---
# `/repro` — flakiness triage for one test
Re-run a single pytest node ID N times in isolation, track pass rate, and surface what's _different_ in the firmware logs between the passing attempts and the failing ones. Turns "it's flaky, I guess" into "it fails when X, passes when Y."
## What to do
1. **Parse `$ARGUMENTS`**: first token is the pytest node id (e.g. `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[nrf52->esp32s3]`); second token is an integer count (default `5`, cap at `20`). If the first token doesn't look like a test path (no `::` and no `tests/` prefix), treat the whole `$ARGUMENTS` as a `-k` filter instead.
2. **Sanity-check the hub first** (so we're not measuring "nothing plugged in" N times): call `mcp__meshtastic__list_devices`. If the test name contains `nrf52` or `esp32s3` and the matching VID isn't present, stop and report — re-running won't help.
3. **Loop N times**. For each iteration:
```bash
./mcp-server/run-tests.sh <test-id> --tb=short -p no:cacheprovider
```
Capture: exit code, duration, and (on failure) the `Meshtastic debug` firmware log section from `mcp-server/tests/report.html`. `-p no:cacheprovider` suppresses pytest's `.pytest_cache` writes so iterations don't influence each other.
4. **Track a small structured tally**:
```text
attempt 1: PASS (42s)
attempt 2: FAIL (128s) ← firmware log 200-line tail captured
attempt 3: PASS (39s)
attempt 4: FAIL (121s)
attempt 5: PASS (41s)
--------------------------------------
pass rate: 3/5 (60%) | mean duration: 74s
```
5. **On mixed outcomes**: diff the firmware log tails between a representative passing attempt and a representative failing attempt. Focus on:
- Error-level lines only present in failures (`PKI_UNKNOWN_PUBKEY`, `Alloc an err=`, `Skip send`, `No suitable channel`)
- Timing around the assertion event — did a broadcast go out, was there an ACK, did NAK fire?
- Device state fields that changed (nodesByNum entries, region/preset, channel_num)
Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs — pull specific lines with uptime timestamps.
6. **Classify the flake** into one of:
- **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. 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:
- Pass rate and mean duration.
- Classification + evidence (the specific log lines that support it).
- A suggested next step (re-run with specific args, open `/diagnose`, edit a specific test file, nothing).
## Examples
- `/repro tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[esp32s3->nrf52] 10` — runs 10 times, diffs firmware logs.
- `/repro broadcast_delivers` — no `::`, no `tests/`, so interpreted as `-k broadcast_delivers`; runs every matching test the default 5 times.
- `/repro tests/telemetry/test_device_telemetry_broadcast.py 3` — shorter run for a slow test.
## Constraints
- Don't exceed `count=20` per invocation — airtime and USB wear add up. If the user asks for 50, negotiate down.
- Don't rebuild firmware as part of triage; flakes that only reproduce under different firmware belong in a separate session.
- If the FIRST attempt fails AND the rest all pass, that's a classic "state leak from a prior test" → say so and suggest running with `--force-bake` or starting from a clean state rather than chasing the first failure.

47
.claude/commands/test.md Normal file
View File

@@ -0,0 +1,47 @@
---
description: Run the mcp-server test suite (auto-detects devices) and interpret the results
argument-hint: [pytest-args]
---
# `/test` — mcp-server test runner with interpretation
Run `mcp-server/run-tests.sh` and make sense of the output so the operator doesn't have to.
## What to do
1. **Invoke the wrapper.** From the firmware repo root, run:
```bash
./mcp-server/run-tests.sh $ARGUMENTS
```
The wrapper auto-detects connected Meshtastic devices, maps each to its PlatformIO env, exports the required `MESHTASTIC_MCP_ENV_*` env vars, and invokes pytest. If the user passed no arguments, the wrapper supplies a sensible default set (`tests/ --html=tests/report.html --self-contained-html --junitxml=tests/junit.xml -v --tb=short`). A `--report-log=tests/reportlog.jsonl` arg is always appended (unless the operator passed their own). `--assume-baked` is deliberately NOT in the defaults — `test_00_bake.py` has its own skip-if-already-baked check and runs the ~8 s verification by default. Operators can opt into the fast path with `--assume-baked`, or force a reflash with `--force-bake`.
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 <duration>`. 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`). For UI-tier failures also glance at `mcp-server/tests/ui_captures/<session>/<test>/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 <test_node_id>` to confirm.
- **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, `uhubctl_cycle`, or USB replug, \_describe what to do* — don't execute. The operator decides.
## Arguments handling
- No args → wrapper's defaults (full suite).
- `$ARGUMENTS` passed verbatim to the wrapper, which passes them to pytest.
- Common operator invocations: `/test tests/mesh`, `/test tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip`, `/test --force-bake`, `/test -k telemetry`.
## Side-effects to mention in summary
- The session fixture snapshots `userPrefs.jsonc` at session start and restores at teardown (plus on `atexit`). After a clean run, `git status userPrefs.jsonc` should be empty. If the wrapper's pre-flight printed a warning about a stale sidecar, call that out — means a prior session crashed.
- `mcp-server/tests/report.html` and `junit.xml` are regenerated on every run; the HTML is self-contained (shareable).

View File

@@ -4,11 +4,11 @@ This document provides context and guidelines for AI assistants working with the
## Project Overview
Meshtastic is an open-source LoRa mesh networking project for long-range, low-power communication without relying on internet or cellular infrastructure. The firmware enables text messaging, location sharing, and telemetry over a decentralized mesh network.
Meshtastic is an open-source LoRa mesh networking project for long-range, low-power communication without relying on internet or cellular infrastructure. The firmware enables text messaging, location sharing, and telemetry over a decentralized mesh network. The project uses **C++17** as its language standard across all platforms.
### Supported Hardware Platforms
- **ESP32** (ESP32, ESP32-S3, ESP32-C3) - Most common platform
- **ESP32** (ESP32, ESP32-S3, ESP32-C3, ESP32-C6) - Most common platform
- **nRF52** (nRF52840, nRF52833) - Low power Nordic chips
- **RP2040/RP2350** - Raspberry Pi Pico variants
- **STM32WL** - STM32 with integrated LoRa
@@ -70,6 +70,70 @@ PKI (Public Key Infrastructure) messages have special handling:
- Accepted on a special "PKI" channel
- Allow encrypted DMs between nodes that discovered each other on downlink-enabled channels
## Encryption & Key Management
Meshtastic packets on the air are typically encrypted one of two ways: the **per-channel symmetric** layer (AES-CTR with a shared PSK) for broadcasts and channel traffic, and the **per-peer PKI** layer (X25519 ECDH → AES-256-CCM) for direct messages and remote admin. A channel with a 0-byte PSK (or Ham mode, which wipes PSKs) transmits cleartext — see the size table below. Both are implemented in `src/mesh/CryptoEngine.cpp`; the send/receive dispatch lives in `src/mesh/Router.cpp`; admin authorization lives in `src/modules/AdminModule.cpp`.
### High-level model
- **Channels** are symmetric rooms: anyone with the PSK can read any message on the channel. Channel 0 is the "primary" channel and ships with the short-form default PSK on factory devices, forming the public mesh most users join. (The LoRa modem preset `LONG_FAST` lives on `config.lora.modem_preset` and is an independent field — don't conflate "channel 0 default PSK" with the modem preset name.)
- **DMs** addressed to a single node require PKI so that other holders of the channel PSK can't read them. Outside Ham mode, Meshtastic does not fall back to channel-symmetric encryption when the destination public key is unknown.
- **Remote admin** is a DM carrying an `AdminMessage`. The receiver only acts on it if the sender's public key is on its allowlist (`config.security.admin_key[0..2]`).
- **Ham mode** (`owner.is_licensed=true`, where `owner` is the local `meshtastic_User` record) disables PKI entirely and sends cleartext — FCC Part 97 prohibits encryption on amateur bands.
- **No ratchet, no session.** Every packet is encrypted from scratch — a stateless design that matches the high-loss, store-and-forward nature of LoRa.
### Symmetric channel encryption (AES-CTR)
`CryptoEngine::encryptPacket` / `decrypt` / `encryptAESCtr` in `src/mesh/CryptoEngine.cpp`.
- **Cipher**: AES-CTR, AES-128 or AES-256 depending on key length. Same routine in both directions (CTR is a stream cipher, so encrypt == decrypt).
- **Key**: `ChannelSettings.psk` bytes. Size semantics:
- **0 bytes** → no encryption, cleartext on the air
- **1 byte** → short-form index into the well-known `defaultpsk[]` in `src/mesh/Channels.h`. Index 0 = cleartext; 1 = defaultpsk unchanged; 2..255 = defaultpsk with its last byte incremented by (index 1). This is what the CLI's `--ch-set psk default` produces.
- **16 bytes** → raw AES-128 key
- **32 bytes** → raw AES-256 key
- **2..15 bytes** → zero-padded to 16 and used as AES-128 (with a warn log); **17..31 bytes** → zero-padded to 32 and used as AES-256 (with a warn log). Defensive fallback for malformed PSK input, not something to rely on.
- **Nonce (128 bit)**: `packet_id` (u64 LE) ‖ `from_node` (u32 LE) ‖ `block_counter` (u32, starts at 0). Built in `CryptoEngine::initNonce`.
- **No AEAD**: channel packets carry no MAC, so the channel-hash byte is not an integrity or authenticity check. `Channels::getHash` is a 1-byte XOR-derived hint over the channel name bytes and PSK bytes that helps receivers pick a candidate channel/PSK for decryption. Because it is only a small hint and collisions are easy to find, it should be described purely as a PSK-selection aid, not as a security filter an attacker cannot bypass.
- **Channel 0 is special in one way only**: it's the channel the Router attempts PKI decryption on before falling through to AES-CTR. Non-zero channels always go straight to AES-CTR.
### PKI encryption for DMs (X25519 ECDH + AES-256-CCM)
`CryptoEngine::encryptCurve25519` / `decryptCurve25519` in `src/mesh/CryptoEngine.cpp`.
- **Keypair**: Curve25519 (aka X25519), 32-byte public + 32-byte private. Stored in `config.security.public_key` / `private_key`; the public half is mirrored into `owner.public_key` so it rides along in NodeInfo broadcasts and propagates through the mesh like any other identity field.
- **Key generation** (`generateKeyPair`): stirs `HardwareRNG::fill()` (64 B from platform TRNG when available), the 16-byte `myNodeInfo.device_id`, and a call to `random()` into the rweather/Crypto library's software RNG, then `Curve25519::dh1`. `regeneratePublicKey` recomputes the public half from a known private (used when restoring from backup).
- **Keygen entry points**: at boot, `NodeDB` calls `generateKeyPair` (or `regeneratePublicKey` when a stored private key is present and passes a low-entropy check) **directly** when `!owner.is_licensed` and `config.lora.region != UNSET`. `ensurePkiKeys` wraps the same logic for runtime/admin flows — it's the path `AdminModule::handleSetConfig` runs when first assigning a valid region or when security config is written; **do not assume it's the universal boot-time gate**, because the NodeDB path bypasses it.
- **Handshake**: `Curve25519::dh2(local_private, remote_public) → 32-byte shared secret → SHA-256 → 32-byte AES-256 key`. Recomputed per packet. The SHA-256 step is effectively a KDF over the raw ECDH output.
- **Cipher**: AES-256-CCM via `aes_ccm_ae` / `aes_ccm_ad` (`src/mesh/aes-ccm.cpp`). MAC length (the `M` parameter) is **8 bytes**. No AAD — the MAC covers ciphertext only.
- **Nonce (13 bytes / 104 bit)**: `aes_ccm_ae`/`aes_ccm_ad` use a 13-byte CCM nonce (`L = 2` is hardcoded in `src/mesh/aes-ccm.cpp`), not a 16-byte nonce. For PKI packets, `CryptoEngine::initNonce(fromNode, packetNum, extraNonce)` starts from the usual packet-derived nonce material, then overwrites nonce bytes `4..7` with a fresh 32-bit `extraNonce = random()`. The effective nonce bytes are therefore: bytes `0..3` = `packet_id`, bytes `4..7` = transmitted `extraNonce`, bytes `8..11` = `from_node`, byte `12` = `0x00`. The receiver reconstructs the same 13-byte nonce from the packet metadata plus the appended `extraNonce`.
- **Wire overhead**: 12 bytes appended to the ciphertext = 8-byte MAC ‖ 4-byte extraNonce. Defined as `MESHTASTIC_PKC_OVERHEAD = 12` in `src/mesh/RadioInterface.h`. Only the 4-byte `extraNonce` is sent; the rest of the 13-byte CCM nonce is reconstructed from packet fields as described above. The Router's send path checks this overhead against `MAX_LORA_PAYLOAD_LEN` before committing to PKI.
- **Send selection** (`Router::send`): the sender enters the PKI path when **all** hold — we're the originator AND not Ham mode AND not Portduino simradio AND not on the `serial`/`gpio` channels (unless the packet is already marked `pki_encrypted`) AND `config.security.private_key.size == 32` AND destination is a single node (not broadcast) AND the portnum isn't infrastructure. `TRACEROUTE_APP`, `NODEINFO_APP`, `ROUTING_APP`, and `POSITION_APP` are routed through channel encryption even when DMed (these need to be readable by relaying peers). Once on the PKI path, if the destination's public key isn't in our NodeDB the send **fails** with `PKI_SEND_FAIL_PUBLIC_KEY` — it does not silently fall back to channel encryption. If the client explicitly set `pki_encrypted=true` and any condition blocks PKI, the send fails with `PKI_FAILED`.
- **Receive selection** (`Router::perhapsDecode`): try PKI decrypt first when `channel == 0` AND `isToUs(p)` AND not broadcast AND both peers have public keys in NodeDB AND `rawSize > MESHTASTIC_PKC_OVERHEAD`. On success the packet gets `pki_encrypted=true` stamped and the sender's public key copied into `p->public_key` for downstream authorization.
### Remote admin authorization
Implemented in `src/modules/AdminModule.cpp``handleReceivedProtobuf`. The authorization check runs in this order:
1. **Response messages** — if `messageIsResponse(r)` is true (the payload is a response to one of our earlier admin requests), it's accepted without any further check. The in-file comment flags this as a known-untightened gap: a stricter implementation would remember which `public_key` we last queried and reject responses that don't match.
2. **Local admin**`mp.from == 0` (phone app over BLE, serial CLI, internal module); never travels over the air. **Rejected** if `config.security.is_managed` is true, because managed devices expect admin to arrive over the air through an authorized remote path.
3. **Legacy admin channel (deprecated)** — the packet arrived on a channel named literally `"admin"`. Gated by `config.security.admin_channel_enabled`; returns `NOT_AUTHORIZED` if the flag is false. Kept for backward compatibility; new deployments should use PKI admin.
4. **PKI admin (preferred for remote)**`mp.pki_encrypted == true` AND `mp.public_key` matches one of `config.security.admin_key[0..2]` (up to three authorized 32-byte Curve25519 public keys, typically copied from the admin node's own `user.public_key`).
5. **Fallthrough**`NOT_AUTHORIZED`.
On top of authorization, any remote admin message that **mutates** state (not a request, not a response) also has to pass a session-key check (`checkPassKey`): the client must first pull a fresh 8-byte `session_passkey` via `get_admin_session_key_request`, then echo that passkey back in the mutating message. The device rotates the passkey after 150 s and rejects values older than 300 s — a narrow anti-replay window on top of the PKI layer.
`config.security.is_managed = true` disables **local** admin writes (`mp.from == 0` is rejected). It does not by itself force every admin action through PKI — the legacy `"admin"` channel still authorizes remote admin when `config.security.admin_channel_enabled == true`. The AdminModule refuses to persist `is_managed=true` unless at least one `admin_key` is populated — a deliberate guard against operators locking themselves out.
### Key-rotation hazards (actions that invalidate peers)
- **`factory_reset_device`** (the "full" variant, calls `NodeDB::factoryReset(eraseBleBonds=true)`) → **wipes** the X25519 private key; a fresh keypair is generated on the next region-set. Every existing peer holds the old public key, so DMs to this node silently fail PKI decrypt until every peer re-exchanges NodeInfo.
- **`factory_reset_config`** (the "partial" variant, calls `NodeDB::factoryReset()` with `eraseBleBonds=false`) → **preserves** the X25519 private key in `installDefaultConfig(preserveKey=true)`; the public key is zeroed and gets rebuilt from the preserved private key on the next boot via the NodeDB path's `regeneratePublicKey` call. Identity is preserved and the mesh does not need to re-exchange keys.
- **`region=UNSET → valid region`** → `ensurePkiKeys` runs inside the same `handleSetConfig` path; missing keys get generated at that moment.
- **Ham mode transitions** — entering Ham mode (`user.is_licensed=true`) runs `Channels::ensureLicensedOperation`, which **wipes every channel PSK** (all traffic becomes cleartext) and disables the legacy admin channel. The X25519 private key is preserved on the device but not used because `Router::send` skips PKI when `owner.is_licensed` is true. Leaving Ham mode re-enables PKI with the preserved keypair but does not restore the wiped channel PSKs — the operator has to re-set them.
- **Channel 0 PSK change** → every peer must re-learn the channel hash; cached NodeInfo becomes temporarily unreachable until the next broadcast.
- **`security.private_key` blanked via admin** → regenerates both halves (unless in Ham mode) and propagates the new public key via NodeInfo.
## Project Structure
```
@@ -80,21 +144,46 @@ firmware/
│ │ ├── NodeDB.* # Node database management
│ │ ├── Router.* # Packet routing
│ │ ├── Channels.* # Channel management
│ │ ├── CryptoEngine.* # AES-CTR (channels) + X25519 ECDH→AES-256-CCM (PKI for DMs/admin)
│ │ ├── *Interface.* # Radio interface implementations
│ │ ├── api/ # WiFi/Ethernet server APIs (ServerAPI, PacketAPI)
│ │ ├── http/ # HTTP server (WebServer, ContentHandler)
│ │ ├── wifi/ # WiFi support (WiFiAPClient)
│ │ ├── eth/ # Ethernet support (ethClient)
│ │ ├── udp/ # UDP multicast
│ │ ├── compression/ # Message compression (unishox2)
│ │ └── generated/ # Protobuf generated code
│ ├── modules/ # Feature modules (Position, Telemetry, etc.)
│ │ └── Telemetry/ # Telemetry subsystem
│ │ └── Sensor/ # 50+ I2C sensor drivers
│ ├── gps/ # GPS handling
│ ├── graphics/ # Display drivers and UI
├── platform/ # Platform-specific code
│ ├── input/ # Input device handling
── concurrency/ # Threading utilities
│ └── niche/ # Specialized UIs (InkHUD e-ink framework)
│ ├── platform/ # Platform-specific code (esp32, nrf52, rp2xx0, stm32wl, portduino)
── input/ # Input device handling (InputBroker, keyboards, buttons)
│ ├── detect/ # I2C hardware auto-detection (80+ device types)
│ ├── motion/ # Accelerometer drivers (BMA423, BMI270, MPU6050, etc.)
│ ├── mqtt/ # MQTT bridge client
│ ├── power/ # Power HAL
│ ├── nimble/ # BLE via NimBLE
│ ├── buzz/ # Audio/notification (buzzer, RTTTL)
│ ├── serialization/ # JSON serialization, COBS encoding
│ ├── watchdog/ # Hardware watchdog thread
│ ├── concurrency/ # Threading utilities (OSThread, Lock)
│ ├── PowerFSM.* # Power finite state machine
│ └── Observer.h # Observer/Observable event pattern
├── variants/ # Hardware variant definitions
│ ├── esp32/ # ESP32 variants
│ ├── esp32s3/ # ESP32-S3 variants
│ ├── nrf52/ # nRF52 variants
── rp2xxx/ # RP2040/RP2350 variants
│ ├── esp32c3/ # ESP32-C3 variants
── esp32c6/ # ESP32-C6 variants
│ ├── nrf52840/ # nRF52 variants
│ ├── rp2040/ # RP2040/RP2350 variants
│ ├── stm32/ # STM32WL variants
│ └── native/ # Linux/Portduino variants
├── protobufs/ # Protocol buffer definitions
├── boards/ # Custom PlatformIO board definitions
├── test/ # Unit tests (12 test suites)
└── bin/ # Build and utility scripts
```
@@ -105,6 +194,7 @@ firmware/
- Follow existing code style - run `trunk fmt` before commits
- Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging
- Use `assert()` for invariants that should never fail
- C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.)
### Naming Conventions
@@ -118,70 +208,151 @@ firmware/
#### Module System
Modules inherit from `MeshModule` or `ProtobufModule<T>` and implement:
Modules use a three-tier class hierarchy:
- `handleReceivedProtobuf()` - Process incoming packets
- `allocReply()` - Generate response packets
- `runOnce()` - Periodic task execution (returns next run interval in ms)
1. **`MeshModule`** - Base class. Implement `wantPacket()` and `handleReceived()`. Returns `ProcessMessage::STOP` or `ProcessMessage::CONTINUE`.
2. **`SinglePortModule`** - Handles a single portnum. Simplified `wantPacket()` that checks `decoded.portnum`.
3. **`ProtobufModule<T>`** - Template for protobuf-based modules. Handles encoding/decoding automatically.
Most modules also inherit from **`OSThread`** for periodic tasks (the "mixin" pattern):
```cpp
class MyModule : public ProtobufModule<meshtastic_MyMessage>
class MyModule : public ProtobufModule<meshtastic_MyMessage>, private concurrency::OSThread
{
public:
MyModule();
protected:
virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MyMessage *msg) override;
virtual int32_t runOnce() override;
virtual meshtastic_MeshPacket *allocReply() override; // Generate response packets
virtual int32_t runOnce() override; // Periodic task (returns next interval in ms)
virtual bool alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic_MyMessage *msg); // Modify in-flight
virtual bool wantUIFrame(); // Request a UI display frame
};
```
Modules are registered in `src/modules/Modules.cpp` guarded by `MESHTASTIC_EXCLUDE_*` flags.
#### Observer/Observable Pattern
Event-driven communication between subsystems uses `src/Observer.h`:
```cpp
// Observable emits events
Observable<const meshtastic::Status *> newStatus;
newStatus.notifyObservers(&status);
// Observer receives events via callback
CallbackObserver<MyClass, const meshtastic::Status *> statusObserver =
CallbackObserver<MyClass, const meshtastic::Status *>(this, &MyClass::handleStatusUpdate);
```
#### Configuration Access
- `config.*` - Device configuration (LoRa, position, power, etc.)
- `moduleConfig.*` - Module-specific configuration
- `channels.*` - Channel configuration and management
- `owner` - Device owner info
- `myNodeInfo` - Local node info
#### Default Values
Use the `Default` class helpers in `src/mesh/Default.h`:
- `Default::getConfiguredOrDefaultMs(configured, default)` - Returns ms, using default if configured is 0
- `Default::getConfiguredOrDefault(configured, default)` - Generic configured/default getter
- `Default::getConfiguredOrMinimumValue(configured, min)` - Enforces minimum values
- `Default::getConfiguredOrDefaultMsScaled(configured, default, numNodes)` - Scales based on network size
#### Thread Safety
- Use `concurrency::Lock` for mutex protection
- Use `concurrency::Lock` and `concurrency::LockGuard` for mutex protection
- Radio SPI access uses `SPILock`
- Prefer `OSThread` for background tasks
### Hardware Detection
`src/detect/ScanI2C` automatically enumerates 80+ I2C device types at boot including displays, sensors, RTCs, keyboards, PMUs, and touch controllers. This drives automatic initialization of the correct drivers.
### Graphics/UI System
Multiple display driver families in `src/graphics/`:
- **OLED**: SSD1306, SH1106, ST7567
- **TFT**: TFTDisplay (LovyanGFX-based)
- **E-Ink**: EInkDisplay2, EInkDynamicDisplay, EInkParallelDisplay
**InkHUD** (`src/graphics/niche/InkHUD/`) is an event-driven e-ink UI framework:
- Applet-based architecture — modular display tiles
- Read-only, static display optimized for minimal refreshes and low power
- Configured per-variant via `nicheGraphics.h`
- Separate PlatformIO config: `src/graphics/niche/InkHUD/PlatformioConfig.ini`
### Input System
`src/input/InputBroker` is the centralized input event dispatcher. Supports multiple input sources: buttons, keyboards (BBQ10, Cardputer, TCA8418), touch screens, rotary encoders, and matrix keyboards.
### Power Management
`src/PowerFSM.*` implements a finite state machine with states: `stateON`, `statePOWER`, `stateSERIAL`, `stateDARK`. Key events: `EVENT_PRESS`, `EVENT_WAKE_TIMER`, `EVENT_LOW_BATTERY`, `EVENT_RECEIVED_MSG`, `EVENT_SHUTDOWN`. Conditionally excluded with `MESHTASTIC_EXCLUDE_POWER_FSM` (falls back to `FakeFsm`).
### Motion Sensors
`src/motion/AccelerometerThread` provides background motion monitoring with automatic screen wake and double-tap button press detection. Supports 10+ accelerometer/gyroscope chips (BMA423, BMI270, MPU6050, LIS3DH, LSM6DS3, STK8XXX, QMA6100P, ICM20948, BMX160).
### Telemetry Sensor Library
`src/modules/Telemetry/Sensor/` contains 50+ I2C sensor drivers organized by category:
- **Power monitoring**: INA219/226/260/3221, MAX17048
- **Environmental**: BME280/680, SCD4X (CO₂), SEN5X (particulate)
- **Humidity/Temperature**: SHT3X/4X, AHT10, MCP9808, MLX90614
- **Light**: BH1750, TSL2561/2591, VEML7700, LTR390UV, OPT3001
- **Air quality**: PMSA003I, SFA30
- **Specialized**: CGRadSens (radiation), NAU7802 (weight scale)
### API/Networking
`src/mesh/api/` provides a template-based `ServerAPI` for client communication over WiFi (`WiFiServerAPI`) and Ethernet (`ethServerAPI`). Default port: **4403**. HTTP server in `src/mesh/http/`. JSON serialization in `src/serialization/MeshPacketSerializer`.
### Hardware Variants
Each hardware variant has:
- `variant.h` - Pin definitions and hardware capabilities
- `platformio.ini` - Build configuration
- Optional: `pins_arduino.h`, `rfswitch.h`
- Optional: `pins_arduino.h`, `rfswitch.h`, `nicheGraphics.h` (for InkHUD variants)
Key defines in variant.h:
```cpp
#define USE_SX1262 // Radio chip selection
#define HAS_GPS 1 // Hardware capabilities
#define HAS_SCREEN 1 // Display present
#define LORA_CS 36 // Pin assignments
#define SX126X_DIO1 14 // Radio-specific pins
```
### Protobuf Messages
- Defined in `protobufs/meshtastic/*.proto`
- Generated code in `src/mesh/generated/`
- Defined in `protobufs/meshtastic/*.proto` (~32 proto files)
- Generated code in `src/mesh/generated/meshtastic/`
- Regenerate with `bin/regen-protos.sh`
- Message types prefixed with `meshtastic_`
- Nanopb `.options` files control field sizes and encoding
### Conditional Compilation
```cpp
#if !MESHTASTIC_EXCLUDE_GPS // Feature exclusion
#if !MESHTASTIC_EXCLUDE_WIFI // Network feature exclusion
#if !MESHTASTIC_EXCLUDE_BLUETOOTH // BLE exclusion
#if !MESHTASTIC_EXCLUDE_POWER_FSM // Power FSM exclusion
#ifdef ARCH_ESP32 // Architecture-specific
#ifdef ARCH_NRF52 // Nordic platform
#ifdef ARCH_RP2040 // Raspberry Pi Pico
#ifdef ARCH_PORTDUINO // Linux native
#if defined(USE_SX1262) // Radio-specific
#ifdef HAS_SCREEN // Hardware capability
#if USERPREFS_EVENT_MODE // User preferences
@@ -189,10 +360,27 @@ Key defines in variant.h:
## Build System
## Agent Tooling Baseline
Mirror counterpart: `AGENTS.md` under **Agent Tooling Baseline**.
To reduce avoidable agent mistakes, assume these tools are available (or install them before significant repo work):
- **Required CLI basics**: `bash`, `git`, `find`, `grep`, `sed`, `awk`, `xargs`
- **Strongly recommended**: `rg` (ripgrep) for fast file/text search, `jq` for JSON processing
- **Build/test tools**: `python3`, `pip`, virtualenv (`python3 -m venv`), `platformio` (`pio`)
- **Containerized native testing**: `docker` (especially important on macOS / non-Linux hosts)
Fallback expectations for agents:
- If `rg` is unavailable, use `find` + `grep` instead of failing.
- For native tests on hosts without Linux deps, prefer `./bin/test-native-docker.sh`.
- The simulator helper script is `./bin/test-simulator.sh`.
Uses **PlatformIO** with custom scripts:
- `bin/platformio-pre.py` - Pre-build script
- `bin/platformio-custom.py` - Custom build logic
- `bin/platformio-custom.py` - Custom build logic, manifest generation
Build commands:
@@ -202,21 +390,38 @@ pio run -e tbeam -t upload # Build and upload
pio run -e native # Build native/Linux version
```
### Build Manifest
`bin/platformio-custom.py` emits a build manifest with metadata:
- `hasMui`, `hasInkHud` - UI capability flags (overridable via `custom_meshtastic_has_mui`, `custom_meshtastic_has_ink_hud`)
- Architecture normalization (e.g., `esp32s3``esp32-s3` for API compatibility)
## Common Tasks
### Adding a New Module
1. Create `src/modules/MyModule.cpp` and `.h`
2. Inherit from appropriate base class
3. Register in `src/modules/Modules.cpp`
4. Add protobuf messages if needed in `protobufs/`
2. Inherit from appropriate base class (`MeshModule`, `SinglePortModule`, or `ProtobufModule<T>`)
3. Mix in `concurrency::OSThread` if periodic work is needed
4. Register in `src/modules/Modules.cpp` guarded by `#if !MESHTASTIC_EXCLUDE_MYMODULE`
5. Add protobuf messages if needed in `protobufs/meshtastic/`
6. Add test suite in `test/test_mymodule/` if applicable
### Adding a New Hardware Variant
1. Create directory under `variants/<arch>/<name>/`
2. Add `variant.h` with pin definitions
3. Add `platformio.ini` with build config
4. Reference common configs with `extends`
2. Add `variant.h` with pin definitions and hardware capability defines
3. Add `platformio.ini` with build config — use `extends` to reference common base (e.g., `esp32s3_base`)
4. Set `custom_meshtastic_support_level = 1` (PR builds) or `2` (merge builds)
5. For e-ink displays, add `nicheGraphics.h` for InkHUD configuration
### Adding a New Telemetry Sensor
1. Create driver in `src/modules/Telemetry/Sensor/` following existing sensor pattern
2. Register I2C address in `src/detect/ScanI2C` for auto-detection
3. Integrate with the appropriate telemetry module (Environment, Health, Power, AirQuality)
4. Add proto fields in `protobufs/meshtastic/telemetry.proto` if new data types are needed
### Modifying Configuration Defaults
@@ -305,9 +510,192 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing.
## Testing
- Unit tests in `test/` directory
- Run with `pio test -e native`
- Use `bin/test-simulator.sh` for simulation testing
### Native unit tests (C++)
Unit tests in `test/` directory with 12 test suites:
- `test_crypto/` - Cryptography
- `test_mqtt/` - MQTT integration
- `test_radio/` - Radio interface
- `test_mesh_module/` - Module framework
- `test_meshpacket_serializer/` - Packet serialization
- `test_transmit_history/` - Retransmission tracking
- `test_atak/` - ATAK integration
- `test_default/` - Default configuration
- `test_http_content_handler/` - HTTP handling
- `test_serial/` - Serial communication
Run with: `pio test -e native`
Simulation testing: `bin/test-simulator.sh`
Quick entry point for new test modules: `test/README.md` (native unit-test authoring guide, skeleton, pitfalls, and setup checklist).
### Hardware-in-the-loop tests (`mcp-server/tests/`)
Separate pytest suite that exercises real USB-connected Meshtastic devices. See the **MCP Server & Hardware Test Harness** section below for invocation, tier layout, and agent usage rules.
## MCP Server & Hardware Test Harness
The `mcp-server/` directory houses a firmware-aware [MCP](https://modelcontextprotocol.io/) server plus a pytest-based integration suite. AI agents that speak MCP get a well-defined tool surface for flashing, configuring, and inspecting physical Meshtastic devices — use it instead of hand-rolling `pio` or `meshtastic --port` calls where possible. `mcp-server/README.md` is the operator-facing setup doc; this section is the agent-facing usage contract.
The repo registers the server via `.mcp.json` at the repo root — Claude Code picks it up automatically once `mcp-server/.venv/` is built (`cd mcp-server && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'`).
### When to use which surface
| Goal | Tool |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Find a connected device | `mcp__meshtastic__list_devices` |
| Read a live node's config/state | `mcp__meshtastic__device_info`, `list_nodes`, `get_config` |
| Mutate a device (owner, region, channels, reboot) | `set_owner`, `set_config`, `set_channel_url`, `reboot`, `shutdown`, `factory_reset` — all require `confirm=True` |
| Flash firmware to a variant | `pio_flash` (any arch) or `erase_and_flash` (ESP32 factory install) |
| Stream serial logs while debugging | `serial_open``serial_read` loop → `serial_close` |
| Administer `userPrefs.jsonc` build-time constants | `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest` |
| Run the regression suite | `./mcp-server/run-tests.sh` (or `/test` slash command) |
| Diagnose a specific device | `/diagnose [role]` slash command (read-only) |
| Triage a flaky test | `/repro <node-id> [count]` slash command |
**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 (43 tools)
Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-value signatures are called out here.
- **Discovery & metadata**: `list_devices`, `list_boards`, `get_board`
- **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**: `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_<ROLE>` + `_PORT_<ROLE>` 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_<ROLE>` 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`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`.
### Hardware test suite (`mcp-server/run-tests.sh`)
The wrapper auto-detects connected devices (VID → role map: `0x239A``nrf52`, `0x303A`/`0x10C4``esp32s3`), maps each role to a PlatformIO env (`nrf52``rak4631`, `esp32s3``heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_<ROLE>`), then invokes pytest. Zero pre-flight config needed from the operator.
Suite tiers (collected + run in this order via `pytest_collection_modifyitems`):
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]`. 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/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:
```bash
./mcp-server/run-tests.sh # full suite (auto-bake-if-needed)
./mcp-server/run-tests.sh --force-bake # reflash before testing
./mcp-server/run-tests.sh --assume-baked # skip bake (caller vouches for device state)
./mcp-server/run-tests.sh tests/mesh # one tier
./mcp-server/run-tests.sh tests/mesh/test_direct_with_ack.py # one file
./mcp-server/run-tests.sh -k telemetry # name filter
```
**No hardware detected?** The wrapper auto-narrows to `tests/unit/` only and prints `detected hub : (none)` in the pre-flight header. Agents interpreting the output should call this out explicitly — a 52-test green run without hardware is qualitatively different from a 12-unit-test green run.
**Artifacts every run produces:**
- `mcp-server/tests/report.html` — self-contained pytest-html. Each test gets a `Meshtastic debug` section with the tail of firmware log + device state dump. **Open this first** on failures; it's the canonical evidence source.
- `mcp-server/tests/junit.xml` — CI-parseable.
- `mcp-server/tests/reportlog.jsonl` — pytest-reportlog stream (`$report_type` keyed JSONL). Consumed by the live TUI.
- `mcp-server/tests/fwlog.jsonl` — firmware log mirror from the `meshtastic.log.line` pubsub topic. Populated by the `_firmware_log_stream` autouse session fixture.
### Live TUI (`meshtastic-mcp-test-tui`)
A Textual-based live view that wraps `run-tests.sh`. Tails reportlog for per-test state, streams firmware logs, polls device state at startup + post-run (gated out of the active run because `hub_devices` holds exclusive port locks). Key bindings:
| Key | Action |
| --- | ------------------------------------------------------------------------------------------------------------ |
| `r` | re-run focused test (leaf → that node id; internal node → directory or `-k`) |
| `f` | filter tree by substring |
| `d` | failure detail modal (pulls `longrepr` + captured stdout from the reportlog) |
| `g` | export reproducer bundle (tar.gz with README, test_report.json, time-filtered fwlog, devices.json, env.json) |
| `l` | toggle firmware log pane |
| `x` | tool coverage modal |
| `c` | cross-run history sparkline |
| `q` | quit (SIGINT → SIGTERM → SIGKILL escalation, 5-s windows each) |
Launch:
```bash
cd mcp-server
.venv/bin/meshtastic-mcp-test-tui # full suite
.venv/bin/meshtastic-mcp-test-tui tests/mesh # args pass through to pytest
```
The plain CLI stays primary; the TUI is for operators who want a live dashboard. Both consume the same `run-tests.sh`.
### Slash commands (Claude Code + Copilot)
Three AI-assisted workflows wrap the test harness. Claude Code operators get `/test`, `/diagnose`, `/repro`; Copilot operators get `/mcp-test`, `/mcp-diagnose`, `/mcp-repro`. Bodies:
- `.claude/commands/{test,diagnose,repro}.md`
- `.github/prompts/mcp-{test,diagnose,repro}.prompt.md`
`.claude/commands/README.md` is the index.
House rules for agents running these prompts:
- **Interpret failures, don't just echo them.** Pull firmware log tails from `report.html` and classify each failure as transient / environmental / regression. Use the exact format in `.claude/commands/test.md`.
- **No destructive writes without operator approval.** Any skill that could reflash, factory-reset, or reboot a device must describe the action and stop. The operator authorizes.
- **Sequential MCP calls per port.** See above.
- **"Unknown" is a valid classification.** If evidence doesn't support a root cause, say so and list what would disambiguate. Do not invent.
### Key fixtures (test authors + agents debugging)
`mcp-server/tests/conftest.py` provides:
- **`_session_userprefs`** (autouse session) — snapshots `userPrefs.jsonc` at session start, merges the session test profile via `userprefs.merge_active(test_profile)`, restores at teardown. Four layers of safety: pytest teardown + `atexit` + sidecar file (`userPrefs.jsonc.mcp-session-bak`) + startup self-heal in `run-tests.sh`. **Do not edit `userPrefs.jsonc` from inside a test.**
- **`_firmware_log_stream`** (autouse session) — subscribes to `meshtastic.log.line` pubsub on every connected `SerialInterface` and mirrors lines to `tests/fwlog.jsonl`. Drives the TUI firmware-log pane.
- **`_debug_log_buffer`** (autouse per-test) — captures last 200 firmware log lines + device state for attachment to the pytest-html `Meshtastic debug` section on failure.
- **`hub_devices`** (session) — `dict[role, SerialInterface]` with session-long exclusive port locks. Reason the TUI's device poller is gated to startup + post-run only.
- **`baked_mesh`** — parametrized mesh-pair fixture; depends on `test_00_bake`. `pytest_generate_tests` in `conftest.py` auto-generates `[nrf52->esp32s3]` and `[esp32s3->nrf52]` variants.
- **`test_profile`** — session-scoped dict: region, primary channel, admin key, PSK seed. Derived from `MESHTASTIC_MCP_SEED` (defaults to `mcp-<user>-<host>`).
### Firmware integration points tied to the test harness
Two firmware changes exist specifically so the test harness works reliably. **Keep these in mind when touching related code.**
- **`src/mesh/StreamAPI.cpp` + `StreamAPI.h`** — `emitLogRecord` uses a dedicated `fromRadioScratchLog` + `txBufLog` pair and a `concurrency::Lock streamLock`. Before this fix, `debug_log_api_enabled=true` would tear `FromRadio` protobufs on the serial transport because `emitTxBuffer` and `emitLogRecord` shared a single scratch buffer. The conftest enables the log stream session-wide; without this fix the device would corrupt its own FromRadio replies mid-session.
- **`src/mesh/PhoneAPI.cpp`** — `ToRadio` `Heartbeat(nonce=1)` triggers `nodeInfoModule->sendOurNodeInfo(NODENUM_BROADCAST, true, 0, true)` for serial clients, mirroring the pre-existing behavior for TCP/UDP clients in `PacketAPI.cpp`. The mesh tests rely on this to force a NodeInfo broadcast right after connect so the peer discovers them before the test's first assertion.
If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` flow, run `./mcp-server/run-tests.sh` at minimum before asking for review.
### 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. |
| 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
- `factory_reset` — wipes node identity; regenerates PKI keypair. Mesh peers will reject old DMs until re-exchange. Legitimate only when the operator explicitly wants it.
- `erase_and_flash` — full chip erase; destroys all on-device state.
- `esptool_erase_flash` / `esptool_raw` write/erase — bypasses pio's safety chain.
- `set_config` on `lora.region` — changes regulatory domain; requires physical-location context the operator has and the agent doesn't.
- `reboot` / `shutdown` mid-test — breaks fixture invariants.
- `push -f`, `rebase -i`, `reset --hard`, or any history-rewriting git operation.
- Clicking computer-use tools on web links in Mail/Messages/PDFs — open URLs via the claude-in-chrome MCP so the extension's link-safety checks apply.
## Resources

64
.github/prompts/mcp-diagnose.prompt.md vendored Normal file
View File

@@ -0,0 +1,64 @@
---
mode: agent
description: Device health report via the meshtastic MCP tools (Copilot equivalent of the Claude Code /diagnose slash command)
---
# `/mcp-diagnose` — device health report
Equivalent of `.claude/commands/diagnose.md`. Use when the operator asks to "check the devices", "what's the mesh looking like", "is nrf52 alive", etc.
This prompt assumes the meshtastic MCP server is registered with your VS Code Copilot agent. If it isn't, fall back to running `./mcp-server/run-tests.sh tests/unit` plus a short `device_info` script via the terminal.
## What to do
1. **Enumerate hardware** via the `list_devices` MCP tool (with `include_unknown=True`). For each entry where `likely_meshtastic=True`, capture `port`, `vid`, `pid`, `description`.
2. **Apply the operator's filter** (if any):
- No filter → every likely-meshtastic device.
- `nrf52``vid == 0x239a`
- `esp32s3``vid == 0x303a` or `vid == 0x10c4`
- A `/dev/cu.*` path → only that port.
- Anything else → substring match on port.
3. **For each selected device, in sequence (don't parallelize — SerialInterface holds an exclusive port lock):**
- `device_info(port=<p>)``my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel`
- `list_nodes(port=<p>)` → peer count, which peers have `publicKey`, SNR/RSSI distribution
- `get_config(section="lora", port=<p>)` → 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=<p>, env=<inferred>)`, 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_<ROLE>` env var overrides it.
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
owner : Meshtastic 40eb / 40eb
region/band : US, channel 88, LONG_FAST
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 `⚠︎ <short reason>` — missing pubkey on a known peer, region UNSET, mismatched channel name, device on non-PPPS hub, etc.
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.)
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, 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
- **Read-only.** No `set_config`, no `reboot`, no `factory_reset`, no `flash`. If the operator wants mutation, they'll escalate explicitly.
- **Open/query/close per device.** Never hold multiple SerialInterfaces to the same port. The port lock is exclusive.
- **Don't infer env beyond the VID map** — if the operator has an unusual board, ask them which env to use rather than guessing.

68
.github/prompts/mcp-repro.prompt.md vendored Normal file
View File

@@ -0,0 +1,68 @@
---
mode: agent
description: Re-run a specific test N times to triage flakes; diff firmware logs between passes and failures (Copilot equivalent of the Claude Code /repro slash command)
---
# `/mcp-repro` — flakiness triage for one test
Equivalent of `.claude/commands/repro.md`. Use when the operator says "that one test is flaky — dig in", "repro the direct_with_ack failure", "why does X sometimes fail?".
## What to do
1. **Parse the operator's input** into two pieces:
- **Test identifier** — either a pytest node id (has `::` or starts with `tests/`) or a `-k`-style filter (plain substring like `direct_with_ack`).
- **Count** — integer, default `5`, cap at `20`. If the operator asks for 50, negotiate down and explain (airtime + USB wear).
2. **Sanity-check the hub** via the `list_devices` MCP tool. If the test name references `nrf52` or `esp32s3` and the matching VID isn't present, stop and report — re-running won't help.
3. **Loop** N times. Each iteration:
```bash
./mcp-server/run-tests.sh <test-id> --tb=short -p no:cacheprovider
```
`-p no:cacheprovider` keeps pytest from caching anything between iterations. Capture: exit code, duration, and (on failure) the `Meshtastic debug` firmware-log section from `mcp-server/tests/report.html`.
4. **Tally** results as you go:
```text
attempt 1: PASS (42s)
attempt 2: FAIL (128s) ← fw log captured
attempt 3: PASS (39s)
attempt 4: FAIL (121s)
attempt 5: PASS (41s)
--------------------------------------------------
pass rate: 3/5 (60%) | mean duration: 74s
```
5. **On mixed outcomes, diff the firmware logs** between one representative pass and one representative fail. Focus on:
- Error-level lines present only in failures (`PKI_UNKNOWN_PUBKEY`, `Alloc an err=`, `Skip send`, `No suitable channel`, `NAK`)
- Timing around the assertion point (broadcast sent? ACK received? retry fired?)
- Device-state fields that changed between attempts
Surface the top 3 differences as a compact "passes when / fails when" table with uptime timestamps. Don't dump full logs.
6. **Classify** the flake into one of:
- **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. 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:
- Pass rate + mean duration.
- Classification + the specific log evidence for it.
- A concrete next step (tighter assertion, more retries, open `/mcp-diagnose`, file a bug, nothing).
## Examples
- `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip[esp32s3->nrf52] 10` — 10 runs of that parametrized case.
- `broadcast_delivers` — no `::`, no `tests/`; treat as `-k broadcast_delivers`; runs every match 5 times.
- `tests/telemetry/test_device_telemetry_broadcast.py 3` — shorter count for a slow test.
## Notes
- If the FIRST attempt fails and the rest pass, that's a state-leak signature — suggest starting from `--force-bake` or a clean device state rather than chasing the first-failure firmware logs.
- If ALL N fail, this isn't a flake — it's a regression. Say so, stop iterating, escalate to `/mcp-test` for full-suite context.
- Don't rebuild firmware during triage. Flakes that only reproduce under different firmware belong in a separate session with a plan.

57
.github/prompts/mcp-test.prompt.md vendored Normal file
View File

@@ -0,0 +1,57 @@
---
mode: agent
description: Run the mcp-server test suite and interpret results (Copilot equivalent of the Claude Code /test slash command)
---
# `/mcp-test` — mcp-server test runner with interpretation
Equivalent of the Claude Code `/test` slash command in `.claude/commands/test.md`. Use this when the operator asks you to "run the tests", "check the mcp test suite", "run the mesh tests", etc.
## What to do
1. **Invoke the wrapper** from the firmware repo root:
```bash
./mcp-server/run-tests.sh [pytest-args]
```
If the operator specified a subset (e.g. "just the mesh tests"), pass it through as `tests/mesh` or a pytest `-k filter`. If they said nothing, use the wrapper's defaults (full suite with pytest-html report).
The wrapper auto-detects connected Meshtastic devices, maps each to its PlatformIO env, exports the required env vars, and invokes pytest. Zero pre-flight config needed from the operator.
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 <duration>`. 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/<session>/<test>/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 <test-id>` to confirm.
- **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, `uhubctl_cycle`, or replug — \_describe the steps* and let the operator decide. Never burn airtime or flash cycles without approval.
## Arguments convention
Operators generally invoke this prompt either with no arguments (full suite) or with a specific subset. Examples:
- `tests/mesh` — one tier
- `tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip` — one test
- `--force-bake` — reflash devices first
- `-k telemetry` — name-filter
## Side-effects to confirm in your summary
- `userPrefs.jsonc` should be clean after a successful run. The session fixture in `mcp-server/tests/conftest.py` (`_session_userprefs`) snapshots and restores. Check `git status --porcelain userPrefs.jsonc` and report if it's non-empty.
- `mcp-server/tests/report.html` and `junit.xml` regenerate on every run.
- The wrapper prints a warning if a `.mcp-session-bak` sidecar was left over from a crashed prior session and auto-restores from it — mention that if it happened.

138
.github/prompts/new-module.prompt.md vendored Normal file
View File

@@ -0,0 +1,138 @@
# New Meshtastic Module
Guide for developing a new Meshtastic firmware module.
## Module Hierarchy
Choose the appropriate base class:
1. **`MeshModule`** — Raw base class. Override `wantPacket()` and `handleReceived()`. Returns `ProcessMessage::STOP` or `ProcessMessage::CONTINUE`.
2. **`SinglePortModule`** — Handles a single `meshtastic_PortNum`. Constructor takes `(name, portNum)`. Simplified `wantPacket()` checking `decoded.portnum`. Use `allocDataPacket()` to create outgoing packets.
3. **`ProtobufModule<T>`** — Template for protobuf-encoded modules. Constructor takes `(name, portNum, fields)`. Override `handleReceivedProtobuf()`. Use `allocDataProtobuf(payload)` to create outgoing packets.
Most modules also mix in `concurrency::OSThread` for periodic background tasks.
## Implementation Pattern
```cpp
// src/modules/MyModule.h
#pragma once
#include "ProtobufModule.h"
#include "concurrency/OSThread.h"
class MyModule : public ProtobufModule<meshtastic_MyMessage>, private concurrency::OSThread
{
public:
MyModule();
protected:
// Process incoming protobuf packet. Return true to stop further processing.
virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MyMessage *msg) override;
// Generate response packet (optional)
virtual meshtastic_MeshPacket *allocReply() override;
// Periodic task — return next run interval in ms, or disable()
virtual int32_t runOnce() override;
// Modify packet in-flight before delivery (optional)
virtual bool alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic_MyMessage *msg);
// Request a UI display frame (optional)
virtual bool wantUIFrame();
};
```
## Registration
Register in `src/modules/Modules.cpp` inside `setupModules()`:
```cpp
#if !MESHTASTIC_EXCLUDE_MYMODULE
new MyModule();
#endif
```
If other code needs to reference the module instance:
```cpp
#if !MESHTASTIC_EXCLUDE_MYMODULE
myModule = new MyModule();
#endif
```
And declare the global in the header:
```cpp
extern MyModule *myModule;
```
Some modules also conditionally instantiate based on `moduleConfig`:
```cpp
#if !MESHTASTIC_EXCLUDE_MYMODULE
if (moduleConfig.has_my_module && moduleConfig.my_module.enabled) {
new MyModule();
}
#endif
```
## Conditional Compilation
Add a `MESHTASTIC_EXCLUDE_MYMODULE` guard. This allows the module to be excluded from constrained builds. The flag name must follow the pattern: `MESHTASTIC_EXCLUDE_` + uppercase module name.
## Protobuf Messages (if needed)
1. Define messages in `protobufs/meshtastic/` (e.g., `mymodule.proto`)
2. Add a `.options` file for nanopb field size constraints
3. Regenerate with `bin/regen-protos.sh`
4. Generated code appears in `src/mesh/generated/meshtastic/`
5. Assign a `meshtastic_PortNum` if the module uses a new port number
## Timing and Defaults
Use `Default` class helpers for configurable intervals:
```cpp
int32_t MyModule::runOnce()
{
uint32_t interval = Default::getConfiguredOrDefaultMs(moduleConfig.my_module.update_interval,
default_my_module_interval);
// ... do work ...
return interval;
}
```
On public/default channels, enforce minimums with `Default::getConfiguredOrMinimumValue()`.
## Observer Pattern
Subscribe to system events:
```cpp
CallbackObserver<MyModule, const meshtastic::Status *> statusObserver =
CallbackObserver<MyModule, const meshtastic::Status *>(this, &MyModule::handleStatusUpdate);
```
## Testing
Add test suite in `test/test_mymodule/`:
```text
test/
└── test_mymodule/
└── test_main.cpp
```
Run with: `pio test -e native`
## Checklist
- [ ] Header and implementation files in `src/modules/`
- [ ] Inherit from appropriate base class (MeshModule / SinglePortModule / ProtobufModule)
- [ ] Mix in OSThread if periodic work is needed
- [ ] Register in `src/modules/Modules.cpp` with `MESHTASTIC_EXCLUDE_` guard
- [ ] Add protobuf definitions if needed (`protobufs/meshtastic/`)
- [ ] Use `Default::getConfiguredOrDefaultMs()` for timing
- [ ] Respect bandwidth limits on public channels
- [ ] Add test suite in `test/`

149
.github/prompts/new-sensor.prompt.md vendored Normal file
View File

@@ -0,0 +1,149 @@
# New Telemetry Sensor
Guide for adding a new I2C telemetry sensor driver to Meshtastic firmware.
## Overview
Telemetry sensors live in `src/modules/Telemetry/Sensor/`. There are 50+ existing drivers organized by measurement type. Each sensor integrates with one of the telemetry modules:
- **EnvironmentTelemetryModule** — Temperature, humidity, pressure, gas, light
- **AirQualityTelemetryModule** — Particulate matter, VOCs
- **PowerTelemetryModule** — Voltage, current, power monitoring
- **HealthTelemetryModule** — Heart rate, SpO2, body temperature
## Sensor Driver Pattern
Each sensor has a `.h` and `.cpp` file pair following this pattern:
```cpp
// src/modules/Telemetry/Sensor/MySensor.h
#pragma once
#include "TelemetrySensor.h"
#include <MySensorLibrary.h> // Arduino/PlatformIO library
class MySensor : virtual public TelemetrySensor
{
private:
MySensorLibrary sensor;
public:
MySensor() : TelemetrySensor(meshtastic_TelemetrySensorType_MY_SENSOR, "MySensor") {}
// Initialize sensor hardware. Return true on success.
virtual void setup() override;
// Read sensor data into the telemetry protobuf. Return true on success.
virtual bool getMetrics(meshtastic_Telemetry *measurement) override;
};
```
```cpp
// src/modules/Telemetry/Sensor/MySensor.cpp
#include "MySensor.h"
#include "TelemetrySensor.h"
void MySensor::setup()
{
sensor.begin();
// Configure sensor parameters...
}
bool MySensor::getMetrics(meshtastic_Telemetry *measurement)
{
// Read from hardware
float value = sensor.readValue();
// Populate the appropriate protobuf variant
measurement->variant.environment_metrics.temperature = value;
// ... other fields ...
return true;
}
```
## I2C Address Registration
Register the sensor's I2C address(es) in `src/detect/ScanI2C` so it's auto-detected at boot:
1. Add a `DeviceType` enum entry in `src/detect/ScanI2C.h`
2. Add the I2C address mapping in `src/detect/ScanI2CTwoWire.cpp`
The scan runs at boot and populates a device map that telemetry modules use to decide which sensors to initialize.
## Protobuf Fields
If the sensor provides data not covered by existing telemetry fields:
1. Add fields to the appropriate message in `protobufs/meshtastic/telemetry.proto`:
- `EnvironmentMetrics` — Environmental measurements
- `AirQualityMetrics` — Air quality data
- `PowerMetrics` — Power/energy data
- `HealthMetrics` — Health/biometric data
2. Add a `.options` constraint if needed (field sizes for nanopb)
3. Regenerate: `bin/regen-protos.sh`
## Sensor Type Enum
Add the sensor to `meshtastic_TelemetrySensorType` enum in `protobufs/meshtastic/telemetry.proto`:
```protobuf
enum TelemetrySensorType {
// ... existing entries ...
MY_SENSOR = XX;
}
```
## Integration with Telemetry Module
Wire the sensor into the appropriate telemetry module. For environment sensors, this is typically in `src/modules/Telemetry/EnvironmentTelemetry.cpp`:
1. Include the sensor header
2. Add initialization in `setupSensor()` guarded by detection results
3. Call `getMetrics()` in the measurement collection path
Example pattern from existing sensors:
```cpp
#include "Sensor/MySensor.h"
MySensor mySensor;
// In setup:
if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_MY_SENSOR].first > 0) {
mySensor.setup();
}
// In measurement collection:
if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_MY_SENSOR].first > 0) {
mySensor.getMetrics(&measurement);
}
```
## Library Dependencies
If the sensor needs an external library, add it to the `lib_deps` in the relevant base platformio.ini configs:
```ini
lib_deps =
${env.lib_deps}
mysensorlibrary@^1.0.0
```
Or use a conditional dependency if it's platform-specific.
## Unit Conversions
If the sensor reports values in non-standard units, use `src/modules/Telemetry/UnitConversions.h` for conversion helpers (e.g., Celsius ↔ Fahrenheit, hPa ↔ inHg).
## Checklist
- [ ] Create `src/modules/Telemetry/Sensor/MySensor.h` and `.cpp`
- [ ] Inherit from `TelemetrySensor` base class
- [ ] Implement `setup()` and `getMetrics()` methods
- [ ] Add `meshtastic_TelemetrySensorType` enum entry in `telemetry.proto`
- [ ] Add I2C address to `src/detect/ScanI2C` for auto-detection
- [ ] Add protobuf fields in `telemetry.proto` if new data types needed
- [ ] Regenerate protos: `bin/regen-protos.sh`
- [ ] Wire into the appropriate telemetry module (Environment/AirQuality/Power/Health)
- [ ] Add library dependency if external library required
- [ ] Test on hardware or native build

178
.github/prompts/new-variant.prompt.md vendored Normal file
View File

@@ -0,0 +1,178 @@
# New Hardware Variant
Guide for adding a new Meshtastic hardware variant to the firmware.
## Directory Structure
Create under `variants/<arch>/<name>/`:
```text
variants/
├── esp32/ # ESP32
├── esp32s3/ # ESP32-S3
├── esp32c3/ # ESP32-C3
├── esp32c6/ # ESP32-C6
├── nrf52840/ # nRF52840
├── rp2040/ # RP2040/RP2350
├── stm32/ # STM32WL
└── native/ # Linux/Portduino
```
Each variant needs at minimum:
- `variant.h` — Pin definitions and hardware capabilities
- `platformio.ini` — Build configuration
Optional files:
- `pins_arduino.h` — Arduino pin mapping overrides
- `rfswitch.h` — RF switch control for multi-band radios
- `nicheGraphics.h` — InkHUD e-ink configuration
## variant.h Template
```cpp
// Pin definitions
#define I2C_SDA 21
#define I2C_SCL 22
// LoRa radio
#define USE_SX1262 // Radio chip: USE_SX1262, USE_SX1268, USE_SX1280, USE_RF95, USE_LLCC68, USE_LR1110, USE_LR1120, USE_LR1121
#define LORA_CS 18
#define LORA_SCK 5
#define LORA_MOSI 27
#define LORA_MISO 19
#define LORA_DIO1 33 // SX126x: DIO1, SX128x: DIO1, RF95: IRQ
#define LORA_RESET 23
#define LORA_BUSY 32 // SX126x/SX128x only
#define SX126X_DIO2_AS_RF_SWITCH // Common for SX1262 boards
// GPS
#define HAS_GPS 1
#define GPS_RX_PIN 34
#define GPS_TX_PIN 12
// #define PIN_GPS_EN 47 // Optional GPS enable pin
// #define GPS_BAUDRATE 9600 // Override default 9600
// Display
#define HAS_SCREEN 1
// #define USE_SSD1306 // OLED type
// #define USE_SH1106 // Alternative OLED
// #define USE_ST7789 // TFT type
// #define SCREEN_WIDTH 128
// #define SCREEN_HEIGHT 64
// LEDs
#define LED_PIN 2 // Status LED (optional)
// #define HAS_NEOPIXEL 1 // WS2812 support
// Buttons
#define BUTTON_PIN 38
// #define BUTTON_PIN_ALT 0 // Secondary button
// Power management
// #define HAS_AXP192 1 // AXP192 PMU (T-Beam v1.0)
// #define HAS_AXP2101 1 // AXP2101 PMU (T-Beam v1.2+)
// #define BATTERY_PIN 35 // ADC battery voltage pin
// #define ADC_MULTIPLIER 2.0 // Voltage divider ratio
// Optional I2C devices
// #define HAS_RTC 1 // Real-time clock
// #define HAS_TELEMETRY 1 // Enable telemetry sensor support
// #define HAS_SENSOR 1 // I2C sensors present
```
## platformio.ini Template
```ini
[env:my_variant]
extends = esp32s3_base ; Use architecture-specific base
board = esp32-s3-devkitc-1 ; PlatformIO board definition (or custom in boards/)
board_level = extra ; Build level: extra, or omit for default
custom_meshtastic_support_level = 1 ; 1 = PR builds, 2 = merge builds only
build_flags =
${esp32s3_base.build_flags}
-D MY_VARIANT_SPECIFIC_FLAG=1
-I variants/esp32s3/my_variant ; Include path for variant.h
upload_speed = 921600
```
### Common Base Configs
- `esp32_base` / `esp32-common.ini` — ESP32
- `esp32s3_base` — ESP32-S3
- `esp32c3_base` — ESP32-C3
- `esp32c6_base` — ESP32-C6
- `nrf52840_base` / `nrf52.ini` — nRF52840
- `rp2040_base` — RP2040/RP2350
### Support Levels
- `custom_meshtastic_support_level = 1` — Built on every PR (actively supported)
- `custom_meshtastic_support_level = 2` — Built only on merge to main branches
- `board_level = extra` — Only built on full releases
## Build Manifest Metadata
`bin/platformio-custom.py` emits UI capability flags in the build manifest:
- `custom_meshtastic_has_mui = true/false` — Override MUI detection
- `custom_meshtastic_has_ink_hud = true/false` — Override InkHUD detection
- Architecture names are normalized (e.g., `esp32s3``esp32-s3`)
## InkHUD E-Ink Variants
For e-ink display variants using the InkHUD framework, add `nicheGraphics.h`:
```cpp
// nicheGraphics.h — InkHUD configuration for this variant
#define INKHUD // Enable InkHUD
// Configure display, applets, and refresh behavior per device
```
InkHUD has its own PlatformIO config: `src/graphics/niche/InkHUD/PlatformioConfig.ini`
## I2C Device Detection
If the variant has I2C devices, ensure `src/detect/ScanI2C` will detect them. The auto-detection system handles 80+ device types including displays, sensors, RTCs, keyboards, PMUs, and touch controllers at boot.
## Custom Board Definitions
If the PlatformIO board doesn't exist, create a custom board JSON in `boards/`:
```json
{
"build": {
"arduino": { "ldscript": "esp32s3_out.ld" },
"core": "esp32",
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"mcu": "esp32s3",
"variant": "esp32s3"
},
"connectivity": ["wifi", "bluetooth"],
"frameworks": ["arduino", "espidf"],
"name": "My Custom Board",
"upload": {
"flash_size": "8MB",
"maximum_ram_size": 327680,
"maximum_size": 8388608
},
"url": "https://example.com",
"vendor": "MyVendor"
}
```
## Checklist
- [ ] Create `variants/<arch>/<name>/variant.h` with pin definitions
- [ ] Create `variants/<arch>/<name>/platformio.ini` extending correct base
- [ ] Set `custom_meshtastic_support_level` (1 or 2)
- [ ] Verify radio chip define matches hardware (`USE_SX1262`, etc.)
- [ ] Set hardware capability flags (`HAS_GPS`, `HAS_SCREEN`, etc.)
- [ ] Add custom board JSON in `boards/` if needed
- [ ] Test build: `pio run -e my_variant`
- [ ] For e-ink: add `nicheGraphics.h` with InkHUD config

View File

@@ -301,10 +301,12 @@ jobs:
id: release_notes
run: |
chmod +x ./bin/generate_release_notes.py
NOTES=$(./bin/generate_release_notes.py ${{ needs.version.outputs.long }})
NOTES=$(./bin/generate_release_notes.py ${{ needs.version.outputs.long }} --compare-ref HEAD 2>release_notes.log)
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "### Release note range" >> $GITHUB_STEP_SUMMARY
cat release_notes.log >> $GITHUB_STEP_SUMMARY
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -466,7 +468,7 @@ jobs:
- name: Generate release notes
run: |
chmod +x ./bin/generate_release_notes.py
./bin/generate_release_notes.py ${{ needs.version.outputs.long }} > ./publish/release_notes.md
./bin/generate_release_notes.py ${{ needs.version.outputs.long }} --compare-ref HEAD > ./publish/release_notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -86,7 +86,13 @@ jobs:
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
- name: PlatformIO Tests
run: platformio test -e coverage -v --junit-output-path testreport.xml
run: |
set -o pipefail
# Filter out SKIPPED summary rows for hardware variants that can't run on the
# native host. They flood the log and make it harder to spot real failures.
# The JUnit XML is written directly to testreport.xml before the pipe, so
# the test artifact is unaffected.
platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$"
- name: Save test results
if: always() # run this step even if previous step failed

2
.gitignore vendored
View File

@@ -54,3 +54,5 @@ CMakeLists.txt
# PYTHONPATH used by the Nix shell
.python3
.claude/scheduled_tasks.lock
userPrefs.jsonc.mcp-session-bak

11
.mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"meshtastic": {
"command": "./mcp-server/.venv/bin/python",
"args": ["-m", "meshtastic_mcp"],
"env": {
"MESHTASTIC_FIRMWARE_ROOT": "."
}
}
}
}

View File

@@ -1,2 +1,28 @@
[bandit]
skips = B101
# Rule IDs: https://bandit.readthedocs.io/en/latest/plugins/index.html
#
# B101 assert_used
# pytest assertions + internal invariants; required for pytest.
# B110 try_except_pass
# best-effort cleanup paths (atexit handlers, pubsub unsubscribe,
# session-end file close, socket shutdown). Logging inside the
# except block would be worse than the silent pass — teardown is
# already at end-of-session and the surrounding caller has context.
# B112 try_except_continue
# defensive loops over flaky sources (pubsub handlers, device
# re-enumeration polls). One failed iteration shouldn't abort the loop.
# B404 import_subprocess
# mcp-server wraps PlatformIO, esptool, nrfutil, picotool, and the
# pytest test-runner — subprocess is a load-bearing import here, not
# a smell. The "consider possible security implications" advisory is
# redundant given the file-level review already applied.
# B603 subprocess_without_shell_equals_true
# all subprocess calls use a static argv list; `shell=False` is the
# default and we never string-interpolate user input into the command.
# B606 start_process_with_no_shell
# same invariant as B603 — running a binary via argv list (not
# `shell=True`) is the safe pattern bandit is asking for.
#
# Higher-severity checks (B102 exec_used, B301 pickle, B307 eval,
# B602 shell=True, etc.) remain enabled.
skips = B101,B110,B112,B404,B603,B606

View File

@@ -1,5 +1,6 @@
{
"recommendations": [
"Jason2866.esp-decoder",
"pioarduino.pioarduino-ide"
],
"unwantedRecommendations": [

139
AGENTS.md Normal file
View File

@@ -0,0 +1,139 @@
# Agent instructions
This repository is the [Meshtastic](https://meshtastic.org) firmware — a C++17 embedded codebase targeting ESP32 / nRF52 / RP2040 / STM32WL / Linux-Portduino LoRa mesh radios — plus a Python MCP server in `mcp-server/` that AI agents use to flash, configure, and test connected devices.
## Primary instruction file
**Read `.github/copilot-instructions.md` first.** That file is the canonical agent-facing document for this repo. It covers project layout, coding conventions (naming, module framework, Observer pattern, thread safety), the build system, CI/CD, the native C++ test suite, and — most importantly for automation work — the **MCP Server & Hardware Test Harness** section. Read it top-to-bottom before starting any non-trivial change.
This file (`AGENTS.md`) is a short pointer + quick reference for agents that don't read `.github/copilot-instructions.md` by default.
## Quick command reference
| Action | Command |
| -------------------------------- | ----------------------------------------------------------------------------------- |
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
| Run firmware unit tests (native) | `pio test -e native` |
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
| Format before commit | `trunk fmt` |
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
## MCP server (device + test automation)
The `mcp-server/` package exposes ~32 MCP tools for device discovery, building, flashing, serial monitoring, and live-node administration. Tools are grouped as:
- **Discovery**: `list_devices`, `list_boards`, `get_board`
- **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 factory), `update_flash` (ESP32 OTA), `touch_1200bps`
- **Serial sessions**: `serial_open`, `serial_read`, `serial_list`, `serial_close`
- **Device reads**: `device_info`, `list_nodes`
- **Device writes** (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`
- **userPrefs admin**: `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile`
- **Vendor escape hatches**: `esptool_*`, `nrfutil_*`, `picotool_*`
Setup: `cd mcp-server && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'`. The repo registers the server via `.mcp.json` — Claude Code picks it up automatically.
See `mcp-server/README.md` for argument shapes and the **MCP Server & Hardware Test Harness** section of `.github/copilot-instructions.md` for agent usage rules (tool surface, fixture contract, firmware integration points, recovery playbooks).
## Slash commands (AI-assisted workflows)
Three test-and-diagnose workflows exist as slash commands:
- **`/test` (Claude Code) / `/mcp-test` (Copilot)** — run the hardware test suite and interpret failures
- **`/diagnose` / `/mcp-diagnose`** — read-only device health report
- **`/repro` / `/mcp-repro`** — flakiness triage: re-run one test N times, diff firmware logs between passes and failures
Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude/commands/README.md` is the index.
## Encryption at a glance
Two layers, both in `src/mesh/CryptoEngine.cpp`:
- **Channel (symmetric)** — **AES-CTR** with a channel-wide PSK (AES-128 or AES-256). Nonce = packet_id ‖ from_node ‖ block_counter. No AEAD; integrity is soft (channel-hash filter). The well-known default PSK lives in `src/mesh/Channels.h`; a 1-byte PSK is a short-form index into it.
- **Per-peer PKI** — **X25519 ECDH** (Curve25519, 32-byte keys) → SHA-256 → **AES-256-CCM** with an 8-byte MAC. Fresh 32-bit `extraNonce` per packet, sent in the clear alongside the MAC. 12-byte wire overhead (`MESHTASTIC_PKC_OVERHEAD`). Used for DMs. Also used for remote admin (`src/modules/AdminModule.cpp`), where AdminMessage authorization is gated by `config.security.admin_key[0..2]`. Disabled entirely in Ham mode (`user.is_licensed=true`).
Key rotation to never trigger casually: only the **full** factory reset (`factory_reset_device`, `eraseBleBonds=true`) wipes `security.private_key` and regenerates the keypair — every peer holds the old public key, so DMs silently fail PKI decrypt until NodeInfo re-exchanges. The **partial** config reset (`factory_reset_config`) preserves the private key and doesn't invalidate peer relationships. Explicitly blanking `security.private_key` via admin also triggers regen. See the **Encryption & Key Management** section of `.github/copilot-instructions.md` for the full spec (nonce layout, send/receive selection logic including infrastructure-portnum exceptions, admin-key + session-passkey authorization, `is_managed` scope, key-rotation hazards).
## House rules
- **No destructive device operations without operator approval.** `factory_reset`, `erase_and_flash`, `reboot`, `shutdown`, history-rewriting git ops — describe the action and stop. Operator authorizes.
- **One MCP call per serial port at a time.** The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device.
- **`userPrefs.jsonc` is session state during tests.** The `_session_userprefs` fixture snapshots + restores it; never edit it from inside a test.
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
## Typical agent workflows
### Flashing a device
1. `list_devices` → find the port + likely VID
2. `list_boards` → confirm the env, or use the known default for the hardware
3. `pio_flash(env=..., port=..., confirm=True)` for any arch, or `erase_and_flash(env=..., port=..., confirm=True)` for an ESP32 factory install
### Inspecting live node state
1. `device_info(port=...)` — short summary (node num, firmware version, region, peer count)
2. `list_nodes(port=...)` — full peer table (SNR, RSSI, pubkey presence, last_heard)
3. `get_config(section="lora", port=...)` — LoRa settings for cross-device comparison
Sequence these; don't parallelize on the same port.
### Testing a firmware change
1. Build locally: `pio run -e <env>`
2. Flash the test device: `pio_flash(env=..., port=..., confirm=True)`
3. Run the suite: `./mcp-server/run-tests.sh tests/<tier>` or `/test tests/<tier>`
4. On failure, open `mcp-server/tests/report.html``Meshtastic debug` section for the firmware log tail + device state dump
5. Iterate
### Debugging a flaky test
1. `/repro <test-node-id> [count]` — re-runs the test N times, diffs firmware logs between passes and failures
2. If the first attempt always fails and the rest pass, that's a state-leak pattern → suggest `--force-bake` or a clean device state, don't chase the first failure
3. If all N fail, this isn't a flake — it's a regression. Stop iterating and escalate to `/test` for full-suite context.
## 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/`, `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 <port>` 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_<ROLE>` | 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-<user>-<host>`. |
| `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_<ROLE>` | 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_<ROLE>` | Pin a role to a specific hub port number. Required alongside `LOCATION_<ROLE>`. |
| `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_<ROLE>` | 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`. |

View File

@@ -0,0 +1,31 @@
# For use with Armbian luckfox-pico-max
# Waveshare LoRa HAT for Raspberry Pi Pico
# https://www.waveshare.com/wiki/Pico-LoRa-SX1262
Meta:
name: luckfox-pico-max-ws-raspberry-pi-pico-hat
support: community
compatible:
- luckfox-pico-max # Armbian
Lora:
Module: sx1262
DIO2_AS_RF_SWITCH: true
DIO3_TCXO_VOLTAGE: true
spidev: spidev0.0
Busy: # GPIO1_C7 / GP2
pin: 55
gpiochip: 1
line: 23
CS: # GPIO1_C6 / GP3
pin: 54
gpiochip: 1
line: 22
Reset: # GPIO1_D1 / GP15
pin: 57
gpiochip: 1
line: 25
IRQ: # GPIO2_A2 / GP20
pin: 66
gpiochip: 2
line: 2

View File

@@ -1,25 +1,31 @@
#!/usr/bin/env python3
"""
Generate release notes from merged PRs on develop and master branches.
Categorizes PRs into Enhancements and Bug Fixes/Maintenance sections.
"""
"""Generate release notes from the actual release commit range."""
import subprocess
import re
import argparse
import json
import re
import subprocess
import sys
from datetime import datetime
def get_last_release_tag():
"""Get the most recent release tag."""
def get_last_release_tag(compare_ref, exclude_tag=None):
"""Get the most recent version tag merged into compare_ref."""
result = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
["git", "tag", "--merged", compare_ref, "--sort=-version:refname", "v*"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
for line in result.stdout.splitlines():
candidate = line.strip()
if not candidate:
continue
if exclude_tag and candidate == exclude_tag:
continue
return candidate
raise subprocess.CalledProcessError(result.returncode, result.args, output=result.stdout, stderr=result.stderr)
def get_tag_date(tag):
@@ -33,18 +39,18 @@ def get_tag_date(tag):
return result.stdout.strip()
def get_merged_prs_since_tag(tag, branch):
"""Get all merged PRs since the given tag on the specified branch."""
# Get commits since tag on the branch - look for PR numbers in parentheses
def get_merged_prs_in_range(tag, compare_ref):
"""Get all merged PRs in the git range between tag and compare_ref."""
result = subprocess.run(
[
"git",
"log",
f"{tag}..origin/{branch}",
f"{tag}..{compare_ref}",
"--oneline",
],
capture_output=True,
text=True,
check=True,
)
prs = []
@@ -65,6 +71,25 @@ def get_merged_prs_since_tag(tag, branch):
return prs
def parse_args():
"""Parse CLI arguments."""
parser = argparse.ArgumentParser(
description="Generate release notes from the actual release commit range."
)
parser.add_argument("new_version", help="Version that will be tagged for this release")
parser.add_argument(
"--base-tag",
dest="base_tag",
help="Existing version tag to diff from. Defaults to the latest version tag merged into the compare ref.",
)
parser.add_argument(
"--compare-ref",
default="HEAD",
help="Git ref to diff to. Defaults to HEAD.",
)
return parser.parse_args()
def get_pr_details(pr_number):
"""Get PR details from GitHub API via gh CLI."""
try:
@@ -268,28 +293,28 @@ def get_new_contributors(pr_details_list, tag, repo="meshtastic/firmware"):
def main():
if len(sys.argv) < 2:
print("Usage: generate_release_notes.py <new_version>", file=sys.stderr)
sys.exit(1)
new_version = sys.argv[1]
args = parse_args()
new_version = args.new_version
compare_ref = args.compare_ref
current_tag = f"v{new_version}"
# Get last release tag
try:
last_tag = get_last_release_tag()
last_tag = args.base_tag or get_last_release_tag(compare_ref, exclude_tag=current_tag)
except subprocess.CalledProcessError:
print("Error: Could not find last release tag", file=sys.stderr)
sys.exit(1)
# Collect PRs from both branches
all_pr_numbers = set()
print(
f"Resolved release note range: {last_tag}..{compare_ref}",
file=sys.stderr,
)
for branch in ["develop", "master"]:
try:
prs = get_merged_prs_since_tag(last_tag, branch)
all_pr_numbers.update(prs)
except Exception as e:
print(f"Warning: Could not get PRs from {branch}: {e}", file=sys.stderr)
try:
all_pr_numbers = set(get_merged_prs_in_range(last_tag, compare_ref))
except subprocess.CalledProcessError as e:
print(f"Error: Could not get PRs for range {last_tag}..{compare_ref}: {e}", file=sys.stderr)
sys.exit(1)
# Get details for all PRs
enhancements = []

View File

@@ -87,6 +87,9 @@
</screenshots>
<releases>
<release version="2.7.23" date="2026-04-14">
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.23</url>
</release>
<release version="2.7.22" date="2026-04-06">
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.22</url>
</release>

6
debian/changelog vendored
View File

@@ -1,3 +1,9 @@
meshtasticd (2.7.23.0) unstable; urgency=medium
* Version 2.7.23
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Tue, 14 Apr 2026 12:29:48 +0000
meshtasticd (2.7.22.0) unstable; urgency=medium
* Version 2.7.22

29
mcp-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
dist/
build/
# Test harness artifacts
tests/report.html
tests/junit.xml
tests/reportlog.jsonl
tests/fwlog.jsonl
# Subprocess-output tee from pio/esptool/nrfutil/picotool (live flash
# progress for the TUI; also a post-run diagnostic for plain CLI runs).
tests/flash.log
tests/tool_coverage.json
tests/.coverage
htmlcov/
# Persistent run counter for meshtastic-mcp-test-tui header.
tests/.tui-runs
# Cross-run history (TUI duration sparkline).
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/

354
mcp-server/README.md Normal file
View File

@@ -0,0 +1,354 @@
# Meshtastic MCP Server
An [MCP](https://modelcontextprotocol.io) server for working with the Meshtastic firmware repo and connected devices. Lets Claude Code / Claude Desktop:
- Discover USB-connected Meshtastic devices
- Enumerate PlatformIO board variants (166+) with Meshtastic metadata
- Build, clean, flash, erase-and-flash (factory), and OTA-update firmware
- Read serial logs via `pio device monitor` (with board-specific exception decoders)
- Trigger 1200bps touch-reset for bootloader entry (nRF52, ESP32-S3, RP2040)
- Query and administer a running node via the [`meshtastic` Python API](https://github.com/meshtastic/python): owner name, config (LocalConfig + ModuleConfig), channels, messaging, reboot/shutdown/factory-reset
- Call `esptool`, `nrfutil`, `picotool` directly when PlatformIO doesn't cover the operation
## Design principle
**PlatformIO first.** Its `pio run -t upload` knows the correct protocol, offsets, and post-build chain for every variant in `variants/`. Direct vendor-tool wrappers (`esptool_*`, `nrfutil_*`, `picotool_*`) exist as escape hatches for operations pio doesn't cover (blank-chip erase, DFU `.zip` packages, BOOTSEL-mode inspection).
## Prerequisites
- Python ≥ 3.11
- [PlatformIO Core](https://platformio.org/install/cli) — `pio` on `$PATH` or at `~/.platformio/penv/bin/pio`
- The Meshtastic firmware repo checked out somewhere (set via `MESHTASTIC_FIRMWARE_ROOT`)
- Optional: `esptool`, `nrfutil`, `picotool` on `$PATH` (or under the firmware venv at `.venv/bin/`) if you want to use the direct-tool wrappers
## Install
```bash
cd <firmware-repo>/mcp-server
python3 -m venv .venv
.venv/bin/pip install -e .
```
Verify:
```bash
MESHTASTIC_FIRMWARE_ROOT=<firmware-repo> .venv/bin/python -m meshtastic_mcp
```
The server blocks on stdin (that's correct — it speaks MCP over stdio). Ctrl-C to exit.
## Register with Claude Code
Edit `~/.claude/settings.json` (global) or `<firmware-repo>/.claude/settings.local.json` (project-only):
```json
{
"mcpServers": {
"meshtastic": {
"command": "<firmware-repo>/mcp-server/.venv/bin/python",
"args": ["-m", "meshtastic_mcp"],
"env": {
"MESHTASTIC_FIRMWARE_ROOT": "<firmware-repo>"
}
}
}
}
```
Replace `<firmware-repo>` with the absolute path, e.g. `/Users/you/GitHub/firmware`. Restart Claude Code after editing.
## Register with Claude Desktop
Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).
## Tools (43)
### Discovery & metadata
| Tool | What it does |
| -------------- | ------------------------------------------------------------------------------------------ |
| `list_devices` | USB/serial port listing, flags likely-Meshtastic candidates |
| `list_boards` | PlatformIO envs with `custom_meshtastic_*` metadata; filters by arch/supported/query/level |
| `get_board` | Full env dict incl. raw pio config |
### Build & flash
| Tool | What it does |
| ----------------- | -------------------------------------------------------------------- |
| `build` | `pio run -e <env>` (+ mtjson target) |
| `clean` | `pio run -e <env> -t clean` |
| `pio_flash` | `pio run -e <env> -t upload --upload-port <port>` — any architecture |
| `erase_and_flash` | ESP32 full factory flash via `bin/device-install.sh` |
| `update_flash` | ESP32 OTA app-partition update via `bin/device-update.sh` |
| `touch_1200bps` | 1200-baud open/close to trigger USB CDC bootloader entry |
### Serial log sessions
Backed by long-running `pio device monitor` subprocesses with a 10k-line ring buffer per session and board-specific filters (`esp32_exception_decoder` auto-selected when you pass `env=`).
| Tool | What it does |
| -------------- | ------------------------------------------------------------------ |
| `serial_open` | Start a monitor session; returns `session_id` |
| `serial_read` | Cursor-based pull; reports `dropped` if lines aged out of the ring |
| `serial_list` | All active sessions |
| `serial_close` | Terminate a session |
### Device reads
| Tool | What it does |
| ------------- | --------------------------------------------------------------------------- |
| `device_info` | my_node_num, long/short name, firmware version, region, channel, node count |
| `list_nodes` | Full node database with position, SNR, RSSI, last_heard, battery |
_The tool tables below document 38 currently registered MCP server tools._
### Device writes
| Tool | What it does |
| ------------------- | -------------------------------------------------------------------------- |
| `set_owner` | Long name + optional short name (≤4 chars) |
| `get_config` | One section or all (LocalConfig + ModuleConfig) |
| `set_config` | Dot-path field write: `lora.region`=`"US"`, `device.role`=`"ROUTER"`, etc. |
| `get_channel_url` | Primary-only or include_all=admin URL |
| `set_channel_url` | Import channels from a Meshtastic URL |
| `set_debug_log_api` | Enable or disable debug logging for the Meshtastic Python API client |
| `send_text` | Broadcast or direct text message |
| `reboot` | `localNode.reboot(secs)` — requires `confirm=True` |
| `shutdown` | `localNode.shutdown(secs)` — requires `confirm=True` |
| `factory_reset` | `localNode.factoryReset(full?)` — requires `confirm=True` |
### Direct hardware tools (escape hatches)
| Tool | What it does |
| --------------------- | --------------------------------------------------------- |
| `esptool_chip_info` | Read chip, MAC, crystal, flash size |
| `esptool_erase_flash` | Full-chip erase (destructive) |
| `esptool_raw` | Pass-through; confirm=True required for write/erase/merge |
| `nrfutil_dfu` | DFU-flash a `.zip` package |
| `nrfutil_raw` | Pass-through |
| `picotool_info` | Read Pico BOOTSEL-mode info |
| `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_<ROLE>` +
`MESHTASTIC_UHUBCTL_PORT_<ROLE>` 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.
- **Serial port is exclusive.** If a `serial_*` session is active on a port, `device_info`/admin tools on the same port will fail fast with a pointer at the active `session_id`. Close the session first.
- **Flash confirmation by architecture**: `erase_and_flash` / `update_flash` error if the env's architecture isn't ESP32 — use `pio_flash` for nRF52/RP2040/STM32.
## Environment variables
| Var | Default | Purpose |
| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- |
| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo |
| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio``$PATH` `pio``platformio` | Override `pio` location |
| `MESHTASTIC_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool``$PATH` | Override esptool |
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
## Hardware Test Suite
`mcp-server/tests/` holds a pytest-based integration suite that exercises
real USB-connected Meshtastic devices against the MCP server surface. Separate
from the native C++ unit tests in the firmware repo's top-level `test/`
directory — this one validates the device-facing behavior end-to-end.
### Invocation
```bash
./mcp-server/run-tests.sh # full suite (auto-detect + auto-bake-if-needed)
./mcp-server/run-tests.sh --force-bake # reflash devices before testing
./mcp-server/run-tests.sh --assume-baked # skip the bake step (caller vouches for state)
./mcp-server/run-tests.sh tests/mesh # one tier
./mcp-server/run-tests.sh tests/mesh/test_traceroute.py # one file
./mcp-server/run-tests.sh -k telemetry # pytest name filter
```
The wrapper auto-detects connected devices (VID `0x239A``nrf52` → env
`rak4631`; `0x303A` or `0x10C4``esp32s3` → env `heltec-v3`), exports
`MESHTASTIC_MCP_ENV_<ROLE>` env vars, and invokes pytest. Overrides via
per-role env vars: `MESHTASTIC_MCP_ENV_NRF52=heltec-mesh-node-t114 ./run-tests.sh`.
No hardware connected? The wrapper narrows to `tests/unit/` only and says so
in the pre-flight header.
### Tiers (run in this order)
- **`bake`** (`tests/test_00_bake.py`) — flashes both hub roles with the
session's test profile. Has a skip-if-already-baked check (region + channel
match); `--force-bake` overrides.
- **`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. 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=<index>`; 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,
`lora.hop_limit` persistence.
- **`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/<session_seed>/<test_id>/`, 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
**Meshtastic debug** section attached on failure with a 200-line firmware
log tail + device-state dump. Open this first on failures.
- `junit.xml` — CI-parseable.
- `reportlog.jsonl` — `pytest-reportlog` event stream; consumed by the TUI.
- `fwlog.jsonl` — firmware log mirror (`meshtastic.log.line` pubsub → JSONL).
- `flash.log` — tee of all pio / esptool / nrfutil / picotool subprocess
output during the run (driven by `MESHTASTIC_MCP_FLASH_LOG`).
### Live TUI
```bash
.venv/bin/meshtastic-mcp-test-tui
.venv/bin/meshtastic-mcp-test-tui tests/mesh # pytest args pass through
```
Textual-based wrapper over `run-tests.sh` with a live test tree, tier
counters, pytest output pane, firmware-log pane, and a device-status strip.
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
(`.claude/commands/`) and Copilot operators (`.github/prompts/`):
`/test` (run + interpret), `/diagnose` (read-only health report), `/repro`
(flake triage, N-times re-run with log diff).
### House rules (for human + agent contributors)
- Session-scoped fixtures in `tests/conftest.py` snapshot + restore
`userPrefs.jsonc`; **never edit `userPrefs.jsonc` from inside a test**.
Use the `test_profile` / `no_region_profile` fixtures for ephemeral
overrides.
- `SerialInterface` holds an **exclusive port lock**; sequence calls
open → mutate → close, then next device. No parallel calls to the
same port.
- Directed PKI-encrypted sends need **bilateral NodeInfo warmup** —
both sides must hold the other's current pubkey. See
`tests/mesh/_receive.py::nudge_nodeinfo_port` and the three directed-
send tests (`test_direct_with_ack`, `test_traceroute`,
`test_telemetry_request_reply`) for the canonical pattern.
## Layout
```text
mcp-server/
├── pyproject.toml
├── README.md
└── src/meshtastic_mcp/
├── __main__.py # entry: python -m meshtastic_mcp
├── server.py # FastMCP app + @app.tool() registrations (thin)
├── config.py # firmware_root, pio_bin, esptool_bin, etc.
├── pio.py # subprocess wrapper (timeouts, JSON, tail_lines)
├── devices.py # list_devices (findPorts + comports)
├── boards.py # list_boards / get_board (pio project config parse + cache)
├── flash.py # build, clean, flash, erase_and_flash, update_flash, touch_1200bps
├── serial_session.py # SerialSession + reader thread + ring buffer
├── registry.py # session registry + per-port locks
├── connection.py # connect(port) ctx mgr — SerialInterface + port lock
├── info.py # device_info, list_nodes
├── admin.py # set_owner, get/set_config, channels, send_text, reboot/shutdown/factory_reset
└── hw_tools.py # esptool / nrfutil / picotool wrappers
```
## Troubleshooting
- **"Could not locate Meshtastic firmware root"** — set `MESHTASTIC_FIRMWARE_ROOT`.
- **"Could not find `pio`"** — install PlatformIO or set `MESHTASTIC_PIO_BIN`.
- **"Port is held by serial session ..."** — call `serial_close(session_id)` or `serial_list` to find it.
- **`factory.bin` not found after build** — the env may not be ESP32; only ESP32 envs produce a `.factory.bin`.
- **`touch_1200bps` reported `new_port: null`** — the device may not have 1200bps-reset stdio, or the bootloader re-uses the same port name. Check `list_devices` manually.

54
mcp-server/pyproject.toml Normal file
View File

@@ -0,0 +1,54 @@
[project]
name = "meshtastic-mcp"
version = "0.1.0"
description = "MCP server for Meshtastic firmware development: device discovery, PlatformIO tooling, flashing, serial monitoring, and device administration via the meshtastic Python API."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "GPL-3.0-only" }
authors = [{ name = "thebentern" }]
dependencies = ["mcp>=1.2", "pyserial>=3.5", "meshtastic>=2.7.8"]
[project.optional-dependencies]
dev = ["pytest>=7"]
test = [
"pytest>=8",
"pytest-html>=4",
"pytest-reportlog>=0.4",
"pytest-timeout>=2.3",
"coverage[toml]>=7",
"pyyaml>=6",
# textual is required by the `meshtastic-mcp-test-tui` script (see
# `src/meshtastic_mcp/cli/test_tui.py`). Bundled into `test` rather than a
# separate `[tui]` extra because v1 expects test operators are the only
# 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"
# Live TUI wrapping run-tests.sh — shells out to the same script the plain
# CLI uses, tails pytest-reportlog for per-test state, and polls the device
# list at startup + post-run (port lock forces it to stay idle during the run).
meshtastic-mcp-test-tui = "meshtastic_mcp.cli.test_tui:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/meshtastic_mcp"]

270
mcp-server/run-tests.sh Executable file
View File

@@ -0,0 +1,270 @@
#!/usr/bin/env bash
# mcp-server hardware test runner.
#
# Auto-detects connected Meshtastic devices, maps each to its PlatformIO env
# via the same role table the pytest fixtures use, exports the right
# MESHTASTIC_MCP_ENV_* env vars, and invokes pytest.
#
# Usage:
# ./run-tests.sh # full suite, default pytest args
# ./run-tests.sh tests/mesh # subset (any pytest args pass through)
# ./run-tests.sh --force-bake # override one default with another
# MESHTASTIC_MCP_ENV_NRF52=foo ./run-tests.sh # override env per role
# MESHTASTIC_MCP_SEED=ci-run-42 ./run-tests.sh # override PSK seed
#
# If zero supported devices are detected, only the unit tier runs.
#
# Also restores `userPrefs.jsonc` from the session-backup sidecar if a prior
# run exited abnormally (belt to conftest.py's atexit suspenders).
set -euo pipefail
# cd to the script's directory so relative paths resolve consistently no
# matter where the user invoked from.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
VENV_PY="$SCRIPT_DIR/.venv/bin/python"
if [[ ! -x $VENV_PY ]]; then
echo "error: $VENV_PY not found or not executable." >&2
echo " Bootstrap the venv first:" >&2
echo " cd $SCRIPT_DIR && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'" >&2
exit 2
fi
# Resolve firmware root the same way conftest.py does (this script sits in
# mcp-server/, firmware repo root is one level up).
FIRMWARE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
USERPREFS_PATH="$FIRMWARE_ROOT/userPrefs.jsonc"
USERPREFS_SIDECAR="$USERPREFS_PATH.mcp-session-bak"
# ---------- Pre-flight: recover stale userPrefs.jsonc from prior crash ----
# If conftest.py's atexit hook didn't fire (SIGKILL, kernel panic, OS
# restart), the sidecar is the ground truth. Self-heal before running so we
# don't bake the previous run's dirty state into this run's firmware.
if [[ -f $USERPREFS_SIDECAR ]]; then
echo "[pre-flight] found $USERPREFS_SIDECAR from a prior abnormal exit;" >&2
echo " restoring userPrefs.jsonc before starting." >&2
cp "$USERPREFS_SIDECAR" "$USERPREFS_PATH"
rm -f "$USERPREFS_SIDECAR"
fi
# If userPrefs.jsonc has uncommitted changes BEFORE the run starts, that's
# worth warning about — tests will snapshot this dirty state and restore to
# it at the end, which may not be what the operator wants.
if command -v git >/dev/null 2>&1; then
cd "$FIRMWARE_ROOT"
# Capture the git status into a local first — SC2312 flags command
# substitution inside `[[ -n ... ]]` because the exit code of `git
# status` is masked. A two-step assignment makes the failure path
# explicit (non-git, missing file) and keeps the bracket test clean.
_git_status_porcelain="$(git status --porcelain userPrefs.jsonc 2>/dev/null || true)"
if [[ -n $_git_status_porcelain ]]; then
echo "[pre-flight] warning: userPrefs.jsonc has uncommitted changes." >&2
echo " Tests will snapshot THIS state and restore to it" >&2
echo " at teardown. If that's not intended, run:" >&2
echo " git checkout userPrefs.jsonc" >&2
echo " and re-invoke." >&2
fi
cd "$SCRIPT_DIR"
fi
# ---------- Seed default --------------------------------------------------
# Per-machine default so repeated runs from the same operator land on the
# same PSK (makes --assume-baked valid across invocations). Operator can
# override with an explicit env var if they want isolation (e.g. CI).
if [[ -z ${MESHTASTIC_MCP_SEED-} ]]; then
WHO="$(whoami 2>/dev/null || echo anon)"
HOST="$(hostname -s 2>/dev/null || echo host)"
export MESHTASTIC_MCP_SEED="mcp-${WHO}-${HOST}"
fi
# ---------- Flash progress log --------------------------------------------
# pio.py / hw_tools.py tee subprocess output (pio run -t upload, esptool,
# nrfutil, picotool) to this file line-by-line as it arrives when this env
# var is set. The TUI tails it so the operator sees live flash progress
# instead of 3 minutes of silence during `test_00_bake.py`. Plain CLI users
# also benefit — the log is a post-run diagnostic even without the TUI.
# Truncate at session start so each run gets a clean log.
export MESHTASTIC_MCP_FLASH_LOG="$SCRIPT_DIR/tests/flash.log"
: >"$MESHTASTIC_MCP_FLASH_LOG"
# ---------- Detect connected hardware -------------------------------------
# In-process call to the same Python API the test fixtures use, so the
# script never drifts from what pytest sees. Returns a JSON object
# {role: port, ...}.
ROLES_JSON="$(
"$VENV_PY" - <<'PY'
import json
import sys
sys.path.insert(0, "src")
from meshtastic_mcp import devices
# Role → canonical VID map. Kept in sync with
# `tests/conftest.py::hub_profile` defaults; if that changes, this must too.
ROLE_BY_VID = {
0x239A: "nrf52", # Adafruit / RAK nRF52 native USB (app + DFU)
0x303A: "esp32s3", # Espressif native USB (ESP32-S3)
0x10C4: "esp32s3", # CP2102 USB-UART (common on Heltec/LilyGO ESP32 boards)
}
out: dict[str, str] = {}
for dev in devices.list_devices(include_unknown=True):
vid_raw = dev.get("vid") or ""
try:
if isinstance(vid_raw, str) and vid_raw.startswith("0x"):
vid = int(vid_raw, 16)
else:
vid = int(vid_raw)
except (TypeError, ValueError):
continue
role = ROLE_BY_VID.get(vid)
# First port wins per role — matches hub_devices fixture semantics.
if role and role not in out:
out[role] = dev["port"]
json.dump(out, sys.stdout)
PY
)"
# ---------- Map role → pio env --------------------------------------------
# Honor MESHTASTIC_MCP_ENV_<ROLE> operator overrides; fall back to the
# same defaults hardcoded in tests/conftest.py::_DEFAULT_ROLE_ENVS.
resolve_env() {
local role="$1"
local default="$2"
local upper
upper="$(echo "$role" | tr '[:lower:]' '[:upper:]')"
local var="MESHTASTIC_MCP_ENV_${upper}"
eval "local override=\${$var:-}"
if [[ -n $override ]]; then
echo "$override"
else
echo "$default"
fi
}
NRF52_PORT="$(echo "$ROLES_JSON" | "$VENV_PY" -c 'import json,sys; print(json.loads(sys.stdin.read()).get("nrf52", ""))')"
ESP32S3_PORT="$(echo "$ROLES_JSON" | "$VENV_PY" -c 'import json,sys; print(json.loads(sys.stdin.read()).get("esp32s3", ""))')"
DETECTED=""
if [[ -n $NRF52_PORT ]]; then
NRF52_ENV="$(resolve_env nrf52 rak4631)"
export MESHTASTIC_MCP_ENV_NRF52="$NRF52_ENV"
DETECTED="${DETECTED} nrf52 @ ${NRF52_PORT} -> env=${NRF52_ENV}\n"
fi
if [[ -n $ESP32S3_PORT ]]; then
ESP32S3_ENV="$(resolve_env esp32s3 heltec-v3)"
export MESHTASTIC_MCP_ENV_ESP32S3="$ESP32S3_ENV"
DETECTED="${DETECTED} esp32s3 @ ${ESP32S3_PORT} -> env=${ESP32S3_ENV}\n"
fi
# ---------- Pre-flight summary --------------------------------------------
# Surface what pytest is about to do with respect to the bake phase: the
# operator should see "will verify + bake if needed" by default, so a
# 3-minute flash appearing mid-run isn't a surprise. Detection of the
# explicit overrides is best-effort — we just scan $@ for the known flags.
_bake_mode="auto (verify + bake if needed)"
for _arg in "$@"; do
case "$_arg" in
--assume-baked) _bake_mode="skip (--assume-baked)" ;;
--force-bake) _bake_mode="force (--force-bake)" ;;
*) ;; # any other arg: pass-through; bake mode unchanged
esac
done
echo "mcp-server test runner"
echo " firmware root : $FIRMWARE_ROOT"
echo " seed : $MESHTASTIC_MCP_SEED"
echo " bake : $_bake_mode"
if [[ -n $DETECTED ]]; then
echo " detected hub :"
printf "%b" "$DETECTED"
else
echo " detected hub : (none)"
fi
echo
# ---------- Invoke pytest -------------------------------------------------
# If no devices detected, only the unit tier would produce meaningful
# PASS/FAIL — every hardware test would SKIP with "role not present". We
# narrow to tests/unit explicitly so the summary reads as "no hardware,
# unit suite only" instead of "big skip count looks suspicious".
if [[ -z $DETECTED && $# -eq 0 ]]; then
echo "[pre-flight] no supported devices detected; running unit tier only."
echo
exec "$VENV_PY" -m pytest tests/unit -v --report-log=tests/reportlog.jsonl
fi
# Default pytest args when the user passed none. Power users can invoke
# `./run-tests.sh tests/mesh -v --tb=long` and skip all of these defaults.
#
# NOTE: `--assume-baked` is DELIBERATELY omitted here. `tests/test_00_bake.py`
# has an internal skip-if-already-baked check (`_bake_role`: query device_info,
# compare region + primary_channel to the session profile, skip on match).
# So the fast path is ~8-10 s of verification overhead when the devices are
# already baked — negligible next to the 2-6 min suite runtime. Letting
# test_00_bake.py run means a fresh device, a re-seeded session, or a post-
# factory-reset device gets flashed automatically instead of silently
# skipping half the hardware tests with "not baked with session profile"
# errors. Power users who know their hardware is current and want to shave
# those seconds can pass `--assume-baked` explicitly.
if [[ $# -eq 0 ]]; then
set -- tests/ \
--html=tests/report.html --self-contained-html \
--junitxml=tests/junit.xml \
-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.
# Appending here means power-user invocations like `./run-tests.sh tests/mesh`
# also produce it, not just the all-defaults invocation.
_has_report_log=0
for _arg in "$@"; do
case "$_arg" in
--report-log | --report-log=*) _has_report_log=1 ;;
*) ;; # any other arg: no-op; loop continues
esac
done
if [[ $_has_report_log -eq 0 ]]; then
set -- "$@" --report-log=tests/reportlog.jsonl
fi
exec "$VENV_PY" -m pytest "$@"

View File

@@ -0,0 +1,3 @@
"""Meshtastic MCP server — device discovery, PlatformIO tooling, and device admin."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,11 @@
"""Entry point for `python -m meshtastic_mcp`."""
from meshtastic_mcp.server import app
def main() -> None:
app.run()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,417 @@
"""Device administration: owner, config, channels, messaging, admin actions.
All operations use the same `connect()` context manager so port selection,
port-busy detection, and cleanup are handled uniformly.
Config writes use a dot-path: the first segment names a section (e.g.
`"lora"` in LocalConfig or `"mqtt"` in LocalModuleConfig), remaining segments
walk protobuf fields. Enum fields accept their string names (`"US"` for
`lora.region`) so callers don't need to know the numeric values.
"""
from __future__ import annotations
from typing import Any
from google.protobuf import descriptor as pb_descriptor
from google.protobuf import json_format
from meshtastic.protobuf import localonly_pb2
from .connection import connect
class AdminError(RuntimeError):
pass
LOCAL_CONFIG_SECTIONS = {f.name for f in localonly_pb2.LocalConfig.DESCRIPTOR.fields}
MODULE_CONFIG_SECTIONS = {
f.name for f in localonly_pb2.LocalModuleConfig.DESCRIPTOR.fields
}
def _require_confirm(confirm: bool, operation: str) -> None:
if not confirm:
raise AdminError(f"{operation} is destructive and requires confirm=True.")
def _message_to_dict(msg: Any) -> dict[str, Any]:
# `including_default_value_fields` was renamed to
# `always_print_fields_with_no_presence` in protobuf 5.26+. Pick whichever
# kwarg the installed version accepts so we work against both.
kwargs: dict[str, Any] = {"preserving_proto_field_name": True}
import inspect
sig = inspect.signature(json_format.MessageToDict)
if "always_print_fields_with_no_presence" in sig.parameters:
kwargs["always_print_fields_with_no_presence"] = False
elif "including_default_value_fields" in sig.parameters:
kwargs["including_default_value_fields"] = False
return json_format.MessageToDict(msg, **kwargs)
# ---------- owner ----------------------------------------------------------
def set_owner(
long_name: str,
short_name: str | None = None,
port: str | None = None,
) -> dict[str, Any]:
if short_name is not None and len(short_name) > 4:
raise AdminError("short_name must be 4 characters or fewer")
with connect(port=port) as iface:
iface.localNode.setOwner(long_name=long_name, short_name=short_name)
return {
"ok": True,
"long_name": long_name,
"short_name": short_name,
}
# ---------- config reads ---------------------------------------------------
def _section_container(node, section: str) -> tuple[Any, str]:
"""Return (container_message, parent_name) for a section name.
Parent is 'localConfig' or 'moduleConfig' so callers know where to call
writeConfig() after mutating.
"""
if section in LOCAL_CONFIG_SECTIONS:
return getattr(node.localConfig, section), "localConfig"
if section in MODULE_CONFIG_SECTIONS:
return getattr(node.moduleConfig, section), "moduleConfig"
raise AdminError(
f"Unknown config section: {section!r}. "
f"Valid sections: {sorted(LOCAL_CONFIG_SECTIONS | MODULE_CONFIG_SECTIONS)}"
)
def get_config(section: str | None = None, port: str | None = None) -> dict[str, Any]:
"""Read one or all config sections.
`section` may be any name in LocalConfig (device, lora, position, power,
network, display, bluetooth, security) or LocalModuleConfig (mqtt, serial,
telemetry, ...). Omit `section` or pass `"all"` for everything.
"""
with connect(port=port) as iface:
node = iface.localNode
if section in (None, "all"):
lc = _message_to_dict(node.localConfig)
mc = _message_to_dict(node.moduleConfig)
return {
"config": {
"localConfig": lc,
"moduleConfig": mc,
}
}
container, _parent = _section_container(node, section)
return {"config": {section: _message_to_dict(container)}}
# ---------- config writes --------------------------------------------------
def _coerce_enum(field: pb_descriptor.FieldDescriptor, value: Any) -> int:
"""Accept an enum value as either its int or its string name."""
enum_type = field.enum_type
if isinstance(value, bool):
raise AdminError(f"{field.name}: expected enum {enum_type.name}, got bool")
if isinstance(value, int):
if enum_type.values_by_number.get(value) is None:
raise AdminError(
f"{field.name}: {value} is not a valid {enum_type.name} value"
)
return value
if isinstance(value, str):
upper = value.upper()
ev = enum_type.values_by_name.get(upper)
if ev is None:
valid = sorted(enum_type.values_by_name.keys())
raise AdminError(
f"{field.name}: {value!r} is not a valid {enum_type.name}. "
f"Valid: {valid}"
)
return ev.number
raise AdminError(
f"{field.name}: expected enum {enum_type.name}, got {type(value).__name__}"
)
def _coerce_scalar(field: pb_descriptor.FieldDescriptor, value: Any) -> Any:
t = field.type
FT = pb_descriptor.FieldDescriptor
if t == FT.TYPE_ENUM:
return _coerce_enum(field, value)
if t == FT.TYPE_BOOL:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in ("true", "yes", "1", "on")
if isinstance(value, int):
return bool(value)
if t in (
FT.TYPE_INT32,
FT.TYPE_INT64,
FT.TYPE_UINT32,
FT.TYPE_UINT64,
FT.TYPE_SINT32,
FT.TYPE_SINT64,
FT.TYPE_FIXED32,
FT.TYPE_FIXED64,
):
return int(value)
if t in (FT.TYPE_FLOAT, FT.TYPE_DOUBLE):
return float(value)
if t == FT.TYPE_STRING:
return str(value)
if t == FT.TYPE_BYTES:
if isinstance(value, (bytes, bytearray)):
return bytes(value)
return str(value).encode("utf-8")
raise AdminError(
f"{field.name}: unsupported field type {t}. Use raw protobuf for this field."
)
def _walk_to_field(
root_msg: Any, path_segments: list[str]
) -> tuple[Any, pb_descriptor.FieldDescriptor]:
"""Walk `root_msg` by field names until the leaf; return (parent_msg, leaf_field_descriptor)."""
msg = root_msg
for i, name in enumerate(path_segments):
desc = msg.DESCRIPTOR
field = desc.fields_by_name.get(name)
if field is None:
trail = ".".join(path_segments[:i] or ["<root>"])
valid = [f.name for f in desc.fields]
raise AdminError(f"No field {name!r} in {trail}. Valid: {valid}")
is_last = i == len(path_segments) - 1
if is_last:
return msg, field
if field.type != pb_descriptor.FieldDescriptor.TYPE_MESSAGE:
raise AdminError(
f"{'.'.join(path_segments[:i+1])} is a scalar; cannot descend into it"
)
msg = getattr(msg, name)
# path_segments was empty
raise AdminError("Empty config path")
def set_config(path: str, value: Any, port: str | None = None) -> dict[str, Any]:
"""Set a single config field by dot-path and write it to the device.
Examples:
set_config("lora.region", "US")
set_config("lora.modem_preset", "LONG_FAST")
set_config("device.role", "ROUTER")
set_config("mqtt.enabled", True)
set_config("mqtt.address", "mqtt.example.com")
"""
segments = [s for s in path.split(".") if s]
if not segments:
raise AdminError("path cannot be empty")
section = segments[0]
with connect(port=port) as iface:
node = iface.localNode
container, parent_name = _section_container(node, section)
# Treat the section as the root; the rest of the path walks into it.
leaf_parent, field = _walk_to_field(container, segments[1:] or [])
# Use `is_repeated` (modern upb protobuf API) rather than the
# deprecated `label == LABEL_REPEATED` check — the C-extension
# FieldDescriptor in protobuf >= 5.x doesn't expose `.label` at
# all, and `is_repeated` is the supported replacement that works
# across both the pure-python and upb backends.
if field.is_repeated:
raise AdminError(
f"{path!r} is a repeated field; v1 only supports scalar sets. "
"Use the raw meshtastic CLI for now."
)
old_raw = getattr(leaf_parent, field.name)
coerced = _coerce_scalar(field, value)
try:
setattr(leaf_parent, field.name, coerced)
except (TypeError, ValueError) as exc:
raise AdminError(f"{path}: {exc}") from exc
node.writeConfig(section)
# Stringify enums for the response (so the caller can see the change in
# the same vocabulary they used to set it).
if field.type == pb_descriptor.FieldDescriptor.TYPE_ENUM:
try:
old_display = field.enum_type.values_by_number[old_raw].name
new_display = field.enum_type.values_by_number[coerced].name
except Exception:
old_display, new_display = old_raw, coerced
else:
old_display, new_display = old_raw, coerced
return {
"ok": True,
"path": path,
"section": section,
"parent": parent_name,
"old_value": old_display,
"new_value": new_display,
}
# ---------- channels -------------------------------------------------------
def get_channel_url(
include_all: bool = False, port: str | None = None
) -> dict[str, Any]:
with connect(port=port) as iface:
url = iface.localNode.getURL(includeAll=include_all)
return {"url": url}
def set_channel_url(url: str, port: str | None = None) -> dict[str, Any]:
with connect(port=port) as iface:
# setURL replaces the channel set from the URL's contents. It does not
# return a count; we infer by counting non-DISABLED channels after.
iface.localNode.setURL(url)
channels = iface.localNode.channels or []
active = sum(1 for c in channels if getattr(c, "role", 0) != 0)
return {"ok": True, "channels_imported": active}
# ---------- messaging ------------------------------------------------------
def send_text(
text: str,
to: str | int | None = None,
channel_index: int = 0,
want_ack: bool = False,
port: str | None = None,
) -> dict[str, Any]:
destination = to if to is not None else "^all"
with connect(port=port) as iface:
packet = iface.sendText(
text,
destinationId=destination,
wantAck=want_ack,
channelIndex=channel_index,
)
packet_id = getattr(packet, "id", None)
return {"ok": True, "packet_id": packet_id, "destination": destination}
# ---------- diagnostics ----------------------------------------------------
def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
"""Toggle `config.security.debug_log_api_enabled` on the local node.
When enabled, firmware emits log lines as protobuf `LogRecord` messages
over the StreamAPI instead of raw text. meshtastic-python surfaces them
on pubsub topic `meshtastic.log.line`, which flows through the SAME
SerialInterface our tests already hold open — no `pio device monitor`
needed, no port-contention with admin/info calls.
Firmware gate: `src/SerialConsole.cpp` (`usingProtobufs &&
config.security.debug_log_api_enabled`). Setting persists in NVS; it
survives reboot. `factory_reset(full=False)` clears it unless it's
re-applied after reset.
Previously-documented concurrency hazard (emitLogRecord sharing the
main packet-emission buffers) has been fixed — see `StreamAPI.h`
where the log path now owns dedicated `fromRadioScratchLog` /
`txBufLog` buffers, and `StreamAPI::emitTxBuffer` +
`StreamAPI::emitLogRecord` both serialize their `stream->write`
calls via `streamLock`. Leaving the flag on under traffic is safe.
"""
with connect(port=port) as iface:
sec = iface.localNode.localConfig.security
sec.debug_log_api_enabled = bool(enabled)
iface.localNode.writeConfig("security")
return {"ok": True, "debug_log_api_enabled": bool(enabled)}
# ---------- admin actions --------------------------------------------------
def reboot(
port: str | None = None, confirm: bool = False, seconds: int = 10
) -> dict[str, Any]:
_require_confirm(confirm, "reboot")
with connect(port=port) as iface:
iface.localNode.reboot(secs=seconds)
return {"ok": True, "rebooting_in_s": seconds}
def shutdown(
port: str | None = None, confirm: bool = False, seconds: int = 10
) -> dict[str, Any]:
_require_confirm(confirm, "shutdown")
with connect(port=port) as iface:
iface.localNode.shutdown(secs=seconds)
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]:
"""Tell the node to factory-reset its config.
Works around a meshtastic-python 2.7.8 bug: `Node.factoryReset(full=True)`
internally does `p.factory_reset_config = True` where the field is
int32. protobuf 5.x rejects bool→int assignment as a TypeError. We build
the AdminMessage directly with int values (1=non-full, 2=full) and call
`_sendAdmin` to sidestep the SDK bug entirely.
"""
_require_confirm(confirm, "factory_reset")
from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped]
with connect(port=port) as iface:
msg = admin_pb2.AdminMessage()
msg.factory_reset_config = 2 if full else 1
iface.localNode._sendAdmin(msg)
return {"ok": True, "full": full}

View File

@@ -0,0 +1,159 @@
"""Board / PlatformIO env enumeration.
Parses `pio project config --json-output` — a nested list of
`[section_name, [[key, value], ...]]` pairs — into a dict keyed by env name,
extracting the `custom_meshtastic_*` metadata the firmware variants expose.
The parsed config is cached and invalidated when `platformio.ini`'s mtime
changes, so subsequent calls don't pay the 12s pio startup cost.
"""
from __future__ import annotations
import threading
from typing import Any
from . import config, pio
_CACHE_LOCK = threading.Lock()
_CACHE: dict[str, Any] = {"mtime": None, "envs": None}
def _parse_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in ("true", "yes", "1", "on")
return bool(value)
def _parse_int(value: Any) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _parse_tags(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(v).strip() for v in value if str(v).strip()]
return [t.strip() for t in str(value).replace(",", " ").split() if t.strip()]
def _env_record(env_name: str, items: list[list[Any]]) -> dict[str, Any]:
"""Build a normalized dict for one env section."""
d = dict(items)
return {
"env": env_name,
"architecture": d.get("custom_meshtastic_architecture"),
"hw_model": _parse_int(d.get("custom_meshtastic_hw_model")),
"hw_model_slug": d.get("custom_meshtastic_hw_model_slug"),
"display_name": d.get("custom_meshtastic_display_name"),
"actively_supported": _parse_bool(
d.get("custom_meshtastic_actively_supported")
),
"support_level": _parse_int(d.get("custom_meshtastic_support_level")),
"board_level": d.get("board_level"), # "pr", "extra", or None
"tags": _parse_tags(d.get("custom_meshtastic_tags")),
"images": _parse_tags(d.get("custom_meshtastic_images")),
"board": d.get("board"),
"upload_speed": _parse_int(d.get("upload_speed")),
"upload_protocol": d.get("upload_protocol"),
"monitor_speed": _parse_int(d.get("monitor_speed")),
"monitor_filters": d.get("monitor_filters") or [],
"_raw": d, # Full dict for get_board
}
def _load_all() -> dict[str, dict[str, Any]]:
"""Parse `pio project config` into `{env_name: record}`."""
raw = pio.run_json(["project", "config"], timeout=pio.TIMEOUT_PROJECT_CONFIG)
result: dict[str, dict[str, Any]] = {}
for section_name, items in raw:
if not isinstance(section_name, str) or not section_name.startswith("env:"):
continue
env_name = section_name.split(":", 1)[1]
result[env_name] = _env_record(env_name, items)
return result
def _get_cached() -> dict[str, dict[str, Any]]:
root = config.firmware_root()
platformio_ini = root / "platformio.ini"
try:
mtime = platformio_ini.stat().st_mtime
except FileNotFoundError:
mtime = None
with _CACHE_LOCK:
if _CACHE["envs"] is not None and _CACHE["mtime"] == mtime:
return _CACHE["envs"]
envs = _load_all()
_CACHE["envs"] = envs
_CACHE["mtime"] = mtime
return envs
def invalidate_cache() -> None:
with _CACHE_LOCK:
_CACHE["envs"] = None
_CACHE["mtime"] = None
def _public_record(rec: dict[str, Any]) -> dict[str, Any]:
"""Strip the `_raw` field for list outputs."""
return {k: v for k, v in rec.items() if not k.startswith("_")}
def list_boards(
architecture: str | None = None,
actively_supported_only: bool = False,
query: str | None = None,
board_level: str | None = None, # "release" | "pr" | "extra"
) -> list[dict[str, Any]]:
"""Enumerate PlatformIO envs with Meshtastic metadata.
Filters are cumulative (AND). `board_level="release"` means envs with no
explicit `board_level` set (the default release targets).
"""
envs = _get_cached()
q = query.lower().strip() if query else None
out = []
for rec in envs.values():
if architecture and rec.get("architecture") != architecture:
continue
if actively_supported_only and not rec.get("actively_supported"):
continue
if board_level is not None:
rec_level = rec.get("board_level")
if board_level == "release":
if rec_level not in (None, ""):
continue
elif rec_level != board_level:
continue
if q:
display = (rec.get("display_name") or "").lower()
env_name = rec.get("env", "").lower()
slug = (rec.get("hw_model_slug") or "").lower()
if q not in display and q not in env_name and q not in slug:
continue
out.append(_public_record(rec))
out.sort(key=lambda r: (r.get("architecture") or "", r.get("env")))
return out
def get_board(env: str) -> dict[str, Any]:
"""Full metadata for one env, including the raw pio config dict."""
envs = _get_cached()
rec = envs.get(env)
if rec is None:
raise KeyError(
f"Unknown env: {env!r}. Use list_boards() to see available envs."
)
public = _public_record(rec)
public["raw_config"] = rec["_raw"]
return public

View File

@@ -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_<ROLE>` — 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

View File

@@ -0,0 +1,6 @@
"""Command-line entry points that sit alongside the MCP server.
Modules here are loaded on-demand by `[project.scripts]` entries in
`pyproject.toml`. They are NOT imported by `meshtastic_mcp.server` or the
admin/info tool surface — the MCP server stays pure stdio JSON-RPC.
"""

View File

@@ -0,0 +1,73 @@
"""Flash progress log tailer for ``meshtastic-mcp-test-tui``.
``pio.py`` / ``hw_tools.py`` tee subprocess output (``pio run -t upload``,
``esptool erase_flash``, ``nrfutil dfu``, etc.) to ``tests/flash.log``
line-by-line as it arrives — controlled by the ``MESHTASTIC_MCP_FLASH_LOG``
env var that ``run-tests.sh`` sets. The TUI tails that file so the operator
sees live flash progress in the pytest pane instead of 3 minutes of silence
during ``test_00_bake``.
Separate from ``_fwlog.py`` because that one parses JSONL, this one
streams plain text lines. Same daemon-thread + EOF-backoff structure.
"""
from __future__ import annotations
import pathlib
import threading
import time
from typing import Callable
class FlashLogTailer(threading.Thread):
"""Tail a plain-text log file, publish each stripped line via ``post``.
``post`` is invoked with a single ``str`` for every new line. Lines are
stripped of trailing newlines; empty lines after stripping are dropped.
The file may not exist yet when this thread starts — it's truncated by
``run-tests.sh`` at session start, but if the tailer races the shell,
we tolerate FileNotFoundError for up to ``wait_s`` seconds.
"""
def __init__(
self,
path: pathlib.Path,
post: Callable[[str], None],
stop: threading.Event,
*,
wait_s: float = 30.0,
) -> None:
super().__init__(daemon=True, name="flashlog-tail")
self._path = path
self._post = post
self._stop = stop
self._wait_s = wait_s
def run(self) -> None:
deadline = time.monotonic() + self._wait_s
while not self._path.is_file():
if self._stop.is_set() or time.monotonic() > deadline:
return
time.sleep(0.1)
try:
fh = self._path.open("r", encoding="utf-8", errors="replace")
except OSError:
return
try:
while not self._stop.is_set():
line = fh.readline()
if not line:
time.sleep(0.05)
continue
line = line.rstrip("\r\n")
if not line:
continue
try:
self._post(line)
except Exception:
# A post failure (e.g. closed app) is terminal for this
# thread but we still want to close the file handle.
return
finally:
fh.close()

View File

@@ -0,0 +1,96 @@
"""Firmware log tail worker for ``meshtastic-mcp-test-tui``.
Complements v1's reportlog-tail worker. ``tests/conftest.py`` owns a
session-scoped autouse fixture (``_firmware_log_stream``) that mirrors
every ``meshtastic.log.line`` pubsub event to ``tests/fwlog.jsonl`` —
one JSON object per line:
{"ts": 1729100000.123, "port": "/dev/cu.usbmodem1101", "line": "..."}
The TUI tails that file from a worker thread; each new line becomes a
:class:`FirmwareLogLine` message posted to the App. Same pattern as the
reportlog tail worker — truncate on launch, tolerate missing file for
30 s, back off at EOF.
Kept in its own module so the (large) ``test_tui.py`` stays focused on
the Textual App shell.
"""
from __future__ import annotations
import json
import pathlib
import threading
import time
from typing import Any, Callable
class FirmwareLogTailer(threading.Thread):
"""Tail ``tests/fwlog.jsonl``, publish parsed records via ``post``.
``post`` is the App's ``post_message`` (or any callable that accepts a
single payload arg). We pass parsed dicts rather than constructing
Textual Message objects here — keeps this module free of the
textual dependency so it's unit-testable in a bare venv.
Parameters
----------
path:
Path to ``tests/fwlog.jsonl``. The file may not exist yet at
startup — pytest only creates it once the session fixture runs.
post:
Callable invoked with a dict ``{"ts", "port", "line"}`` for every
new line parsed from the file.
stop:
An event the App sets to signal shutdown.
wait_s:
How long to poll for the file's creation before giving up. Default
30 s; pytest collection on a cold cache can be slow.
"""
def __init__(
self,
path: pathlib.Path,
post: Callable[[dict[str, Any]], None],
stop: threading.Event,
*,
wait_s: float = 30.0,
) -> None:
super().__init__(daemon=True, name="fwlog-tail")
self._path = path
self._post = post
self._stop = stop
self._wait_s = wait_s
def run(self) -> None:
deadline = time.monotonic() + self._wait_s
while not self._path.is_file():
if self._stop.is_set() or time.monotonic() > deadline:
return
time.sleep(0.1)
try:
fh = self._path.open("r", encoding="utf-8")
except OSError:
return
try:
while not self._stop.is_set():
line = fh.readline()
if not line:
time.sleep(0.05)
continue
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
# Defensive: require the three fields we rely on.
if not isinstance(record, dict):
continue
if "line" not in record:
continue
self._post(record)
finally:
fh.close()

View File

@@ -0,0 +1,127 @@
"""Cross-run history for ``meshtastic-mcp-test-tui``.
Persists one JSON object per pytest run to
``mcp-server/tests/.history/runs.jsonl``. The TUI reads the last N
entries on launch to render a duration sparkline in the header — a
quick read on whether the suite is slowing down over time.
Schema (keep small; the file can grow for months):
{"run": 42, "ts": 1729100000.0, "duration_s": 387.2,
"passed": 52, "failed": 0, "skipped": 23, "exit_code": 0,
"seed": "mcp-user-host"}
"""
from __future__ import annotations
import json
import pathlib
import time
from dataclasses import asdict, dataclass
from typing import Iterable
# Sparkline glyphs, low → high. 8 levels is the Unicode convention.
_SPARK_BLOCKS = "▁▂▃▄▅▆▇█"
@dataclass
class RunRecord:
run: int
ts: float
duration_s: float
passed: int
failed: int
skipped: int
exit_code: int
seed: str
class HistoryStore:
"""Append-only JSONL store with bounded read.
Writes are fsynced after each append (the file is tiny; fsync cost
is negligible and protects against truncation on a crash).
"""
def __init__(self, path: pathlib.Path, *, keep_last: int = 50) -> None:
self._path = path
self._keep_last = keep_last
def append(self, record: RunRecord) -> None:
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
with self._path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(asdict(record)) + "\n")
fh.flush()
except Exception:
# Non-fatal: history is cosmetic.
pass
def read_recent(self) -> list[RunRecord]:
"""Return the last ``keep_last`` records in chronological order."""
if not self._path.is_file():
return []
try:
lines = self._path.read_text(encoding="utf-8").splitlines()
except OSError:
return []
out: list[RunRecord] = []
# Parse tail-first so we don't waste work on a huge history.
for line in lines[-self._keep_last :]:
line = line.strip()
if not line:
continue
try:
raw = json.loads(line)
except json.JSONDecodeError:
continue
try:
out.append(RunRecord(**raw))
except TypeError:
# Schema drift; skip the record rather than crash.
continue
return out
def record_run(
self,
*,
run: int,
duration_s: float,
passed: int,
failed: int,
skipped: int,
exit_code: int,
seed: str,
) -> RunRecord:
rec = RunRecord(
run=run,
ts=time.time(),
duration_s=float(duration_s),
passed=int(passed),
failed=int(failed),
skipped=int(skipped),
exit_code=int(exit_code),
seed=seed,
)
self.append(rec)
return rec
def sparkline(values: Iterable[float], *, width: int = 20) -> str:
"""Render a Unicode block-character sparkline from the last ``width`` values.
Returns an empty string for empty input so the header handles
"no history yet" gracefully.
"""
buf = [v for v in values if v >= 0][-width:]
if not buf:
return ""
lo, hi = min(buf), max(buf)
if hi - lo < 1e-9:
return _SPARK_BLOCKS[len(_SPARK_BLOCKS) // 2] * len(buf)
n = len(_SPARK_BLOCKS) - 1
out = []
for v in buf:
idx = int(round((v - lo) / (hi - lo) * n))
out.append(_SPARK_BLOCKS[max(0, min(n, idx))])
return "".join(out)

View File

@@ -0,0 +1,214 @@
"""Reproducer bundle builder for ``meshtastic-mcp-test-tui``.
When the operator presses ``x`` on a failed test leaf, we package the
minimum viable failure context into a tarball under
``mcp-server/tests/reproducers/``:
::
repro-<ts>-<short_nodeid>.tar.gz
├── README.md human-readable overview
├── test_report.json the failing TestReport event from reportlog
├── fwlog.jsonl firmware log filtered to the failure window
├── devices.json per-device device_info + lora config snapshot
└── env.json seed, run #, pytest version, platform, hostname
Separate module so the logic can be unit-tested without Textual. The
TUI glue is thin — one key binding calls :func:`build_reproducer_bundle`
with the focused test's state and shows the path in a modal.
"""
from __future__ import annotations
import io
import json
import pathlib
import platform
import re
import socket
import tarfile
import time
from dataclasses import dataclass
from typing import Any, Iterable
@dataclass
class ReproContext:
"""Everything :func:`build_reproducer_bundle` needs. Shaped to map
cleanly onto the state the TUI already tracks — no extra data
collection required at export time."""
nodeid: str
longrepr: str
sections: list[tuple[str, str]]
start_ts: float | None
stop_ts: float | None
seed: str
run_number: int
exit_code: int | None
fwlog_path: pathlib.Path
output_dir: pathlib.Path
extra_device_rows: list[dict[str, Any]] # [{role, port, info, ...}, ...]
def _short_nodeid(nodeid: str) -> str:
"""Collapse a pytest nodeid into a filename-safe slug (<= 60 chars)."""
# Drop the file path prefix; keep test name + parametrization.
tail = nodeid.split("::", 1)[-1] if "::" in nodeid else nodeid
slug = re.sub(r"[^A-Za-z0-9_.\-]", "_", tail)
return slug[:60].strip("_.-") or "test"
def _filtered_fwlog(
fwlog_path: pathlib.Path,
start_ts: float | None,
stop_ts: float | None,
*,
pad_s: float = 5.0,
) -> bytes:
"""Return fwlog.jsonl lines whose ``ts`` lies in [start-pad, stop+pad]."""
if not fwlog_path.is_file():
return b""
if start_ts is None or stop_ts is None:
# Without a time window, include the whole file — rare; happens
# when a test fails in setup before pytest emitted a start ts.
try:
return fwlog_path.read_bytes()
except OSError:
return b""
lo, hi = start_ts - pad_s, stop_ts + pad_s
out = io.BytesIO()
try:
with fwlog_path.open("r", encoding="utf-8") as fh:
for line in fh:
stripped = line.strip()
if not stripped:
continue
try:
record = json.loads(stripped)
except json.JSONDecodeError:
continue
ts = record.get("ts")
if not isinstance(ts, (int, float)):
continue
if lo <= ts <= hi:
out.write(line.encode("utf-8"))
except OSError:
return b""
return out.getvalue()
def _readme(ctx: ReproContext) -> str:
t = time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime())
return f"""# Reproducer bundle
Exported by `meshtastic-mcp-test-tui` on {t}.
## Failing test
- **nodeid:** `{ctx.nodeid}`
- **seed:** `{ctx.seed}`
- **run #:** {ctx.run_number}
- **suite exit code (at export time):** {ctx.exit_code if ctx.exit_code is not None else "in progress"}
## Files in this archive
| File | Contents |
|---|---|
| `test_report.json` | The pytest-reportlog `TestReport` event for the failing test — includes `longrepr`, captured `sections` (stdout/stderr/log), `duration`, `location`, `keywords`. |
| `fwlog.jsonl` | Firmware log lines (from `meshtastic.log.line` pubsub) filtered to [start5s, stop+5s] around the test's run window. Each line is `{{ts, port, line}}`. |
| `devices.json` | Per-device snapshot at export time: `device_info` + `lora` config per detected role. |
| `env.json` | Python version, platform, hostname, seed, run number. |
## How to triage
1. Open `test_report.json` and read `longrepr` + `sections` — most failures explain themselves there.
2. If the failure is a mesh/telemetry assertion, `fwlog.jsonl` is where the answer usually lives. Grep for `Error=`, `NAK`, `PKI_UNKNOWN_PUBKEY`, `Skip send`, `Guru Meditation`, or the uptime timestamps around the assertion event.
3. Compare `devices.json` against the expected state (e.g. `num_nodes >= 2`, `primary_channel == "McpTest"`, `region == "US"`). If fields disagree with the seed-derived USERPREFS profile, the device probably wasn't baked with this session's profile.
## Reproducing locally
```bash
cd mcp-server
MESHTASTIC_MCP_SEED='{ctx.seed}' .venv/bin/pytest '{ctx.nodeid}' --tb=long -v
```
"""
def build_reproducer_bundle(ctx: ReproContext) -> pathlib.Path:
"""Build a tarball under ``ctx.output_dir`` and return its path.
Parent dirs are created as needed. Errors during optional sections
(devices, env) are swallowed — the bundle is still useful without
them; refusing to export because the device poller had a hiccup
would be worse than the export missing a file.
"""
ctx.output_dir.mkdir(parents=True, exist_ok=True)
ts = int(time.time())
slug = _short_nodeid(ctx.nodeid)
archive_path = ctx.output_dir / f"repro-{ts}-{slug}.tar.gz"
with tarfile.open(archive_path, "w:gz") as tar:
def _add(name: str, data: bytes) -> None:
info = tarfile.TarInfo(name=name)
info.size = len(data)
info.mtime = ts
tar.addfile(info, io.BytesIO(data))
# README
_add("README.md", _readme(ctx).encode("utf-8"))
# test_report.json — reconstruct from the fields the TUI stashes.
test_report = {
"nodeid": ctx.nodeid,
"outcome": "failed",
"longrepr": ctx.longrepr,
"sections": [list(s) for s in ctx.sections],
"start": ctx.start_ts,
"stop": ctx.stop_ts,
}
_add(
"test_report.json",
json.dumps(test_report, indent=2, default=str).encode("utf-8"),
)
# fwlog.jsonl (filtered)
_add("fwlog.jsonl", _filtered_fwlog(ctx.fwlog_path, ctx.start_ts, ctx.stop_ts))
# devices.json
try:
devices_payload = json.dumps(
ctx.extra_device_rows or [], indent=2, default=str
)
except Exception:
devices_payload = "[]"
_add("devices.json", devices_payload.encode("utf-8"))
# env.json
try:
from importlib.metadata import version as _pkg_version
pytest_version = _pkg_version("pytest")
except Exception:
pytest_version = "unknown"
env_payload = {
"seed": ctx.seed,
"run": ctx.run_number,
"exit_code": ctx.exit_code,
"export_ts": ts,
"python": platform.python_version(),
"pytest": pytest_version,
"platform": f"{platform.system()} {platform.release()} {platform.machine()}",
"hostname": socket.gethostname(),
}
_add("env.json", json.dumps(env_payload, indent=2).encode("utf-8"))
return archive_path
def iter_entries(archive_path: pathlib.Path) -> Iterable[str]:
"""Yield member names — used by callers that want to confirm the bundle shape."""
with tarfile.open(archive_path, "r:gz") as tar:
for m in tar.getmembers():
yield m.name

View File

@@ -0,0 +1,83 @@
"""UI-capture transcript tailer for ``meshtastic-mcp-test-tui``.
Watches ``tests/ui_captures/<session_seed>/`` 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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
"""Resolves the firmware repo root and the binaries we invoke.
Everything that needs a path (the firmware root, `pio`, `esptool`, etc.) goes
through this module so the rest of the package never calls `shutil.which` or
parses environment variables directly.
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
from typing import Iterable
class ConfigError(RuntimeError):
"""Raised when a required path or binary cannot be resolved."""
def firmware_root() -> Path:
"""Resolve the root of the Meshtastic firmware repo.
Resolution order:
1. `MESHTASTIC_FIRMWARE_ROOT` env var.
2. Walk up from `cwd` looking for a directory with `platformio.ini`.
"""
env = os.environ.get("MESHTASTIC_FIRMWARE_ROOT")
if env:
root = Path(env).expanduser().resolve()
if not (root / "platformio.ini").is_file():
raise ConfigError(
f"MESHTASTIC_FIRMWARE_ROOT={env!r} does not contain platformio.ini"
)
return root
cur = Path.cwd().resolve()
for candidate in (cur, *cur.parents):
if (candidate / "platformio.ini").is_file():
return candidate
raise ConfigError(
"Could not locate Meshtastic firmware root. Set MESHTASTIC_FIRMWARE_ROOT "
"to the directory containing platformio.ini."
)
def _first_existing(paths: Iterable[Path]) -> Path | None:
for p in paths:
if p and p.is_file() and os.access(p, os.X_OK):
return p
return None
def pio_bin() -> Path:
"""Resolve the `pio` binary.
Order: MESHTASTIC_PIO_BIN → ~/.platformio/penv/bin/pio (PlatformIO keeps
this one current) → `pio` on PATH → `platformio` on PATH.
"""
env = os.environ.get("MESHTASTIC_PIO_BIN")
if env:
p = Path(env).expanduser()
if p.is_file() and os.access(p, os.X_OK):
return p
raise ConfigError(f"MESHTASTIC_PIO_BIN={env!r} is not an executable file")
penv = Path.home() / ".platformio" / "penv" / "bin" / "pio"
if penv.is_file() and os.access(penv, os.X_OK):
return penv
for name in ("pio", "platformio"):
w = shutil.which(name)
if w:
return Path(w)
raise ConfigError(
"Could not find `pio`. Install PlatformIO (https://platformio.org/install/cli) "
"or set MESHTASTIC_PIO_BIN."
)
def _hw_tool(env_var: str, names: tuple[str, ...], install_hint: str) -> Path:
"""Shared resolver for esptool / nrfutil / picotool.
Prefers the firmware repo's own `.venv/bin/<name>` (esptool lives there),
then PATH.
"""
env = os.environ.get(env_var)
if env:
p = Path(env).expanduser()
if p.is_file() and os.access(p, os.X_OK):
return p
raise ConfigError(f"{env_var}={env!r} is not an executable file")
try:
venv_bin = firmware_root() / ".venv" / "bin"
except ConfigError:
venv_bin = None
for name in names:
if venv_bin is not None:
p = venv_bin / name
if p.is_file() and os.access(p, os.X_OK):
return p
for name in names:
w = shutil.which(name)
if w:
return Path(w)
raise ConfigError(
f"Could not find `{names[0]}`. {install_hint} "
f"Or set {env_var} to an absolute path."
)
def esptool_bin() -> Path:
return _hw_tool(
"MESHTASTIC_ESPTOOL_BIN",
("esptool", "esptool.py"),
"Install via `pip install esptool`.",
)
def nrfutil_bin() -> Path:
return _hw_tool(
"MESHTASTIC_NRFUTIL_BIN",
("nrfutil", "adafruit-nrfutil"),
"Install via `pip install adafruit-nrfutil` or download Nordic nRF Util.",
)
def picotool_bin() -> Path:
return _hw_tool(
"MESHTASTIC_PICOTOOL_BIN",
("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",
)

View File

@@ -0,0 +1,84 @@
"""Context manager for meshtastic.SerialInterface connections.
Every info/admin tool goes through `connect(port)` so we have a single place
that:
- auto-selects the port when one likely_meshtastic device is present,
- fails fast if a serial_session is already holding the port,
- guarantees `.close()` is called, even on exception.
The `SerialInterface` blocks on construction waiting for the node database;
that's fine for v1 since every tool is a short-lived request.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Iterator
from . import devices, registry
class ConnectionError(RuntimeError):
pass
def resolve_port(port: str | None) -> str:
"""Pick a port: explicit > sole likely_meshtastic candidate > error."""
if port:
return port
candidates = [d for d in devices.list_devices() if d["likely_meshtastic"]]
if not candidates:
raise ConnectionError(
"No Meshtastic devices detected. Plug one in or pass `port` explicitly. "
"Run `list_devices` with include_unknown=True to see all serial ports."
)
if len(candidates) > 1:
ports = ", ".join(c["port"] for c in candidates)
raise ConnectionError(
f"Multiple Meshtastic devices detected ({ports}). "
"Specify `port` explicitly."
)
return candidates[0]["port"]
@contextmanager
def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
"""Open a `meshtastic.SerialInterface` and always close it.
Raises `ConnectionError` immediately if another serial session holds the
port (a `pio device monitor` in `serial_sessions/`, for instance).
"""
from meshtastic.serial_interface import (
SerialInterface, # type: ignore[import-untyped]
)
resolved = resolve_port(port)
active = registry.active_session_for_port(resolved)
if active is not None:
raise ConnectionError(
f"Port {resolved} is held by serial session {active.id}. "
"Call `serial_close` first."
)
lock = registry.port_lock(resolved)
if not lock.acquire(blocking=False):
raise ConnectionError(
f"Port {resolved} is busy — another device operation is in flight. "
"Retry shortly."
)
iface = None
try:
iface = SerialInterface(devPath=resolved, connectNow=True, noProto=False)
yield iface
finally:
if iface is not None:
try:
iface.close()
except Exception:
pass
try:
lock.release()
except RuntimeError:
pass

View File

@@ -0,0 +1,75 @@
"""USB/serial device discovery.
Combines the canonical `meshtastic.util.findPorts()` allowlist/blocklist with
the richer metadata (`serial.tools.list_ports.comports()`) so callers see
VID/PID, descriptions, and manufacturer strings alongside the "is this likely
a Meshtastic device" signal.
"""
from __future__ import annotations
from typing import Any
from serial.tools import list_ports
def _to_hex(value: int | None) -> str | None:
if value is None:
return None
return f"0x{value:04x}"
def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
"""Return enriched info for serial ports, flagging Meshtastic candidates.
`likely_meshtastic` is True when the port's USB VID matches the Meshtastic
allowlist (`0x239a` Adafruit/RAK, `0x303a` Espressif). When no allowlisted
ports are present, ports whose VID is NOT in the blocklist (J-Link, ST-LINK,
PPK2, etc.) are surfaced as `likely_meshtastic=False` candidates.
With `include_unknown=False` (default), we return only ports that are
plausibly Meshtastic. With `include_unknown=True`, every serial port the
OS knows about is returned (useful for debugging "why isn't my board
detected").
"""
# Import lazily so the module loads even without the `meshtastic` package
# (useful for introspection / schema generation).
from meshtastic import util as mt_util # type: ignore[import-untyped]
meshtastic_ports: set[str] = set(mt_util.findPorts(eliminate_duplicates=True))
whitelist = getattr(mt_util, "whitelistVids", {})
blacklist = getattr(mt_util, "blacklistVids", {})
results: list[dict[str, Any]] = []
for info in list_ports.comports():
port_path = info.device
vid = info.vid
in_whitelist = vid is not None and vid in whitelist
in_blacklist = vid is not None and vid in blacklist
likely = port_path in meshtastic_ports and in_whitelist
# If no allowlisted ports were found, findPorts falls back to
# everything-not-in-blacklist; treat those as plausible candidates
# but not "likely".
fallback_candidate = port_path in meshtastic_ports and not in_whitelist
if not likely and not fallback_candidate and not include_unknown:
continue
results.append(
{
"port": port_path,
"vid": _to_hex(vid),
"pid": _to_hex(info.pid),
"description": info.description or None,
"manufacturer": info.manufacturer or None,
"product": info.product or None,
"serial_number": info.serial_number or None,
"likely_meshtastic": likely,
"blacklisted": in_blacklist,
}
)
# Stable ordering: likely_meshtastic first, then by port path
results.sort(key=lambda r: (not r["likely_meshtastic"], r["port"]))
return results

View File

@@ -0,0 +1,447 @@
"""Build, clean, flash, and bootloader-entry operations.
Design: pio is the preferred path for every architecture via `flash()`. For
ESP32 factory flashes we shell out to `bin/device-install.sh` (which knows
about partition offsets and the OTA/littlefs partitions); for ESP32 OTA
updates we use `bin/device-update.sh`. Both scripts require the build
artifacts to exist, so these tools build first if needed.
"""
from __future__ import annotations
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
import serial
from . import boards, config, devices, pio, userprefs
# Meshtastic variants use both `esp32s3` and `esp32-s3` style names across
# variants/*/platformio.ini (no consistency enforced). Accept both spellings.
ESP32_ARCHES = {
"esp32",
"esp32s2",
"esp32-s2",
"esp32s3",
"esp32-s3",
"esp32c3",
"esp32-c3",
"esp32c6",
"esp32-c6",
}
class FlashError(RuntimeError):
pass
def _require_confirm(confirm: bool, operation: str) -> None:
if not confirm:
raise FlashError(
f"{operation} is destructive and requires confirm=True. "
"This will overwrite firmware on the device."
)
def _artifacts_for(env: str) -> list[Path]:
build_dir = config.firmware_root() / ".pio" / "build" / env
if not build_dir.is_dir():
return []
patterns = (
"firmware*.bin",
"firmware*.uf2",
"firmware*.hex",
"firmware*.zip",
"firmware*.elf",
"*.mt.json",
"littlefs-*.bin",
)
out: list[Path] = []
for pat in patterns:
out.extend(sorted(build_dir.glob(pat)))
return out
def _factory_bin_for(env: str) -> Path | None:
build_dir = config.firmware_root() / ".pio" / "build" / env
if not build_dir.is_dir():
return None
matches = sorted(build_dir.glob("firmware-*.factory.bin"))
return matches[0] if matches else None
def _firmware_bin_for(env: str) -> Path | None:
"""Return the OTA-update firmware binary (app partition only)."""
build_dir = config.firmware_root() / ".pio" / "build" / env
if not build_dir.is_dir():
return None
# device-update.sh expects firmware-<env>-<version>.bin (not .factory.bin)
matches = sorted(
p
for p in build_dir.glob("firmware-*.bin")
if not p.name.endswith(".factory.bin")
)
return matches[0] if matches else None
def _userprefs_summary(active: dict[str, str]) -> dict[str, Any]:
"""Compact summary of which USERPREFS_* are baked into the build."""
return {"count": len(active), "keys": sorted(active.keys())}
def build(
env: str,
with_manifest: bool = True,
userprefs_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Run `pio run -e <env>` and return artifact paths.
`userprefs_overrides` (optional): dict of `USERPREFS_<KEY>: value` to inject
into userPrefs.jsonc for this build only. File is restored byte-for-byte
on exit. Use `userprefs_set()` for persistent changes.
"""
args = ["run", "-e", env]
if with_manifest:
args.extend(["-t", "mtjson"])
with userprefs.temporary_overrides(userprefs_overrides) as effective:
result = pio.run(args, timeout=pio.TIMEOUT_BUILD, check=False)
return {
"exit_code": result.returncode,
"artifacts": [str(p) for p in _artifacts_for(env)],
"stdout_tail": pio.tail_lines(result.stdout, 200),
"stderr_tail": pio.tail_lines(result.stderr, 200),
"duration_s": round(result.duration_s, 2),
"userprefs": _userprefs_summary(effective),
}
def clean(env: str) -> dict[str, Any]:
"""Run `pio run -e <env> -t clean`."""
result = pio.run(["run", "-e", env, "-t", "clean"], timeout=120, check=False)
return {
"exit_code": result.returncode,
"stdout_tail": pio.tail_lines(result.stdout, 200),
"stderr_tail": pio.tail_lines(result.stderr, 200),
"duration_s": round(result.duration_s, 2),
}
def flash(
env: str,
port: str,
confirm: bool = False,
userprefs_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""`pio run -e <env> -t upload --upload-port <port>`. All architectures.
`userprefs_overrides` (optional): see `build()` — the rebuild-before-upload
that pio performs will pick up the injected values.
"""
_require_confirm(confirm, "flash")
with userprefs.temporary_overrides(userprefs_overrides) as effective:
result = pio.run(
["run", "-e", env, "-t", "upload", "--upload-port", port],
timeout=pio.TIMEOUT_UPLOAD,
check=False,
)
return {
"exit_code": result.returncode,
"stdout_tail": pio.tail_lines(result.stdout, 200),
"stderr_tail": pio.tail_lines(result.stderr, 200),
"duration_s": round(result.duration_s, 2),
"userprefs": _userprefs_summary(effective),
}
def _check_esp32_env(env: str) -> str:
rec = boards.get_board(env)
arch = rec.get("architecture")
if arch not in ESP32_ARCHES:
raise FlashError(
f"Env {env!r} has architecture {arch!r}, not ESP32. "
"Use `flash` for non-ESP32 boards."
)
return arch
def _run_install_script(script: Path, port: str, binary: Path) -> dict[str, Any]:
"""Invoke bin/device-install.sh or bin/device-update.sh."""
t0 = time.monotonic()
proc = subprocess.run(
[str(script), "-p", port, "-f", str(binary)],
cwd=str(config.firmware_root()),
capture_output=True,
text=True,
timeout=pio.TIMEOUT_UPLOAD,
)
duration = time.monotonic() - t0
return {
"exit_code": proc.returncode,
"stdout_tail": pio.tail_lines(proc.stdout, 200),
"stderr_tail": pio.tail_lines(proc.stderr, 200),
"duration_s": round(duration, 2),
}
def erase_and_flash(
env: str,
port: str,
confirm: bool = False,
skip_build: bool = False,
userprefs_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""ESP32-only: full erase + factory flash via bin/device-install.sh.
`userprefs_overrides`: baked into the factory.bin via a fresh build. If
overrides are provided we always force a rebuild (skip_build=True errors
in that case) since a cached factory.bin would not reflect the new prefs.
"""
_require_confirm(confirm, "erase_and_flash")
_check_esp32_env(env)
if userprefs_overrides and skip_build:
raise FlashError(
"userprefs_overrides forces a rebuild so the factory.bin reflects "
"the new values; skip_build=True is incompatible."
)
with userprefs.temporary_overrides(userprefs_overrides) as effective:
# If overrides were provided, always build; otherwise only build if
# no factory.bin is present.
factory = _factory_bin_for(env)
if factory is None or userprefs_overrides:
if skip_build:
raise FlashError(
f"No factory.bin found for env {env!r} and skip_build=True. "
"Run `build` first or set skip_build=False."
)
build_args = ["run", "-e", env, "-t", "mtjson"]
build_result = pio.run(build_args, timeout=pio.TIMEOUT_BUILD, check=False)
if build_result.returncode != 0:
return {
"exit_code": build_result.returncode,
"stdout_tail": pio.tail_lines(build_result.stdout, 200),
"stderr_tail": pio.tail_lines(build_result.stderr, 200),
"duration_s": round(build_result.duration_s, 2),
"error": "build failed before erase_and_flash could run",
"userprefs": _userprefs_summary(effective),
}
factory = _factory_bin_for(env)
if factory is None:
raise FlashError(
f"Build succeeded but no factory.bin appeared in .pio/build/{env}/"
)
script = config.firmware_root() / "bin" / "device-install.sh"
if not script.is_file():
raise FlashError(f"device-install.sh not found at {script}")
result = _run_install_script(script, port, factory)
result["userprefs"] = _userprefs_summary(effective)
return result
def update_flash(
env: str,
port: str,
confirm: bool = False,
skip_build: bool = False,
userprefs_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""ESP32-only: OTA app-partition update via bin/device-update.sh.
`userprefs_overrides`: baked into the firmware.bin via a fresh build. If
overrides are provided we always force a rebuild.
"""
_require_confirm(confirm, "update_flash")
_check_esp32_env(env)
if userprefs_overrides and skip_build:
raise FlashError(
"userprefs_overrides forces a rebuild so the firmware.bin reflects "
"the new values; skip_build=True is incompatible."
)
with userprefs.temporary_overrides(userprefs_overrides) as effective:
firmware = _firmware_bin_for(env)
if firmware is None or userprefs_overrides:
if skip_build:
raise FlashError(
f"No firmware.bin found for env {env!r} and skip_build=True. "
"Run `build` first or set skip_build=False."
)
build_args = ["run", "-e", env, "-t", "mtjson"]
build_result = pio.run(build_args, timeout=pio.TIMEOUT_BUILD, check=False)
if build_result.returncode != 0:
return {
"exit_code": build_result.returncode,
"stdout_tail": pio.tail_lines(build_result.stdout, 200),
"stderr_tail": pio.tail_lines(build_result.stderr, 200),
"duration_s": round(build_result.duration_s, 2),
"error": "build failed before update_flash could run",
"userprefs": _userprefs_summary(effective),
}
firmware = _firmware_bin_for(env)
if firmware is None:
raise FlashError(
f"Build succeeded but no firmware.bin appeared in .pio/build/{env}/"
)
script = config.firmware_root() / "bin" / "device-update.sh"
if not script.is_file():
raise FlashError(f"device-update.sh not found at {script}")
result = _run_install_script(script, port, firmware)
result["userprefs"] = _userprefs_summary(effective)
return result
def _do_1200bps_touch(port: str, settle_ms: int, touch_timeout_s: float = 3.0) -> None:
"""Open port at 1200 baud and close, bounded by a worker thread.
Both the open and the close can block on a busy CDC device — we wrap the
whole thing in a worker so the caller returns in at most `touch_timeout_s`
regardless. The touch is signal-only: the USB configuration change to
1200 baud alone is enough to trip the Adafruit bootloader's reset, so a
worker that's still blocked in the background after timeout has already
delivered the signal.
"""
errors: list[BaseException] = []
def _inner() -> None:
try:
s = serial.Serial(port, 1200)
except serial.SerialException as exc:
if "No such file" in str(exc) or "could not open" in str(exc).lower():
raise
return # other serial errors mid-open are expected during DFU entry
try:
time.sleep(settle_ms / 1000.0)
finally:
try:
s.close()
except Exception:
pass
def _runner() -> None:
try:
_inner()
except BaseException as exc: # re-raised on caller thread after join
errors.append(exc)
worker = threading.Thread(target=_runner, daemon=True)
worker.start()
worker.join(timeout=touch_timeout_s)
if worker.is_alive():
return # signal already delivered; allow daemon worker to finish/exit
if errors:
raise errors[0]
# Adafruit nRF52 bootloader VID/PID (BOTH RAK4631 and most Feather nRF52 boards).
# See https://github.com/adafruit/Adafruit_nRF52_Bootloader
_NRF52_BOOTLOADER_VID = 0x239A
_NRF52_BOOTLOADER_PIDS = {
0x0029, # Adafruit nRF52 bootloader (generic, used by RAK4631)
0x002A, # Adafruit Feather Express bootloader variant
0x4029, # alt seen on some boards
}
def _find_nrf52_bootloader_port() -> dict[str, Any] | None:
"""Return a dict for any currently-enumerated nRF52 bootloader port, or None."""
for d in devices.list_devices(include_unknown=True):
vid_str = d.get("vid")
pid_str = d.get("pid")
if vid_str is None or pid_str is None:
continue
try:
vid = int(vid_str, 16) if isinstance(vid_str, str) else int(vid_str)
pid = int(pid_str, 16) if isinstance(pid_str, str) else int(pid_str)
except ValueError:
continue
if vid == _NRF52_BOOTLOADER_VID and pid in _NRF52_BOOTLOADER_PIDS:
return d
return None
def touch_1200bps(
port: str,
settle_ms: int = 250,
poll_timeout_s: float = 8.0,
retries: int = 2,
) -> dict[str, Any]:
"""Open port at 1200 baud, close immediately — triggers USB CDC bootloader.
Works for: nRF52840 (Adafruit bootloader), ESP32-S3 (native USB download
mode), RP2040 (when built with 1200bps-reset stdio), Arduino Leonardo/Micro.
For nRF52 specifically: after the touch, polls for the Adafruit bootloader
VID/PID (0x239A / 0x0029) for up to `poll_timeout_s` seconds. Adafruit's
bootloader docs note a touch sometimes needs to be repeated, so this
retries up to `retries` times. The returned `new_port` is the bootloader
port (distinct from the app port) — exactly what's needed for `pio run
-t upload` to drive nrfutil.
For non-nRF52 devices (ESP32-S3, RP2040, Arduino), falls back to
"any-new-port appeared" detection.
Returns `{ok, former_port, new_port, new_port_vid_pid, attempts}`.
"""
before_list = devices.list_devices(include_unknown=True)
before_ports = {d["port"] for d in before_list}
attempts = 0
new_port_info: dict[str, Any] | None = None
for attempt in range(1, retries + 1):
attempts = attempt
_do_1200bps_touch(port, settle_ms=settle_ms, touch_timeout_s=3.0)
# Poll for either (a) the nRF52 bootloader VID/PID appearing, or
# (b) a brand-new port appearing that wasn't there before.
deadline = time.monotonic() + poll_timeout_s
while time.monotonic() < deadline:
time.sleep(0.2)
bootloader = _find_nrf52_bootloader_port()
if bootloader is not None:
new_port_info = bootloader
break
current = devices.list_devices(include_unknown=True)
current_paths = {d["port"] for d in current}
added = current_paths - before_ports
if added:
added_record = next((d for d in current if d["port"] in added), None)
if added_record:
new_port_info = added_record
break
if new_port_info is not None:
break
# No bootloader appeared; try touching again (Adafruit recommends
# sometimes requiring two touches for reliability).
if new_port_info is not None:
return {
"ok": True,
"former_port": port,
"new_port": new_port_info["port"],
"new_port_vid_pid": (
new_port_info.get("vid"),
new_port_info.get("pid"),
),
"attempts": attempts,
}
return {
"ok": False,
"former_port": port,
"new_port": None,
"new_port_vid_pid": (None, None),
"attempts": attempts,
}

View File

@@ -0,0 +1,243 @@
"""Direct wrappers around vendor flashing tools: esptool, nrfutil, picotool.
These are escape hatches. Prefer the pio-based tools in flash.py when they
cover the operation — pio knows the correct offsets, protocols, and filters
for every supported board. Use these when pio doesn't: to erase a bricked
ESP32, DFU-flash an nRF52 zip package, or inspect an RP2040's bootloader.
Every destructive `*_raw` subcommand is gated by `confirm=True` so callers
can't accidentally `--write-flash` from freeform args.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Any, Sequence
from . import config, pio
_TIMEOUT_SHORT = 30
_TIMEOUT_LONG = 600
class ToolError(RuntimeError):
pass
def _run(
binary: Path,
args: Sequence[str],
*,
timeout: float = _TIMEOUT_LONG,
cwd: Path | None = None,
) -> dict[str, Any]:
# Shared with pio.run(): if `MESHTASTIC_MCP_FLASH_LOG` is set, each line
# of output is tee'd to that file as it arrives so the TUI can show live
# esptool/nrfutil/picotool progress instead of 3 minutes of silence.
full = [str(binary), *args]
try:
rc, stdout, stderr, duration = pio._run_capturing(
full,
cwd=cwd,
timeout=timeout,
tee_header=f"{binary.name} {' '.join(args)}",
)
except subprocess.TimeoutExpired as exc:
raise ToolError(
f"{binary.name} {' '.join(args)} timed out after {timeout}s"
) from exc
return {
"exit_code": rc,
"stdout": stdout,
"stderr": stderr,
"stdout_tail": pio.tail_lines(stdout, 200),
"stderr_tail": pio.tail_lines(stderr, 200),
"duration_s": round(duration, 2),
}
def _require_confirm(confirm: bool, what: str) -> None:
if not confirm:
raise ToolError(f"{what} is destructive and requires confirm=True.")
# ---------- esptool --------------------------------------------------------
ESPTOOL_DESTRUCTIVE = {
"write_flash",
"write-flash",
"erase_flash",
"erase-flash",
"erase_region",
"erase-region",
"merge_bin",
"merge-bin",
}
def _parse_esptool_chip_info(stdout: str) -> dict[str, Any]:
"""Parse `esptool chip_id` / `flash_id` output into structured fields."""
result: dict[str, Any] = {
"chip": None,
"mac": None,
"crystal_mhz": None,
"flash_size": None,
"features": [],
}
for line in stdout.splitlines():
line = line.strip()
if m := re.match(r"Chip is (.+)", line):
result["chip"] = m.group(1).strip()
elif m := re.match(r"MAC: ([0-9a-fA-F:]+)", line):
result["mac"] = m.group(1)
elif m := re.match(r"Crystal is (\d+)MHz", line):
result["crystal_mhz"] = int(m.group(1))
elif m := re.match(r"Detected flash size: (\S+)", line):
result["flash_size"] = m.group(1)
elif m := re.match(r"Features: (.+)", line):
result["features"] = [f.strip() for f in m.group(1).split(",") if f.strip()]
return result
def esptool_chip_info(port: str) -> dict[str, Any]:
binary = config.esptool_bin()
# `chip_id` prints chip + mac + crystal + features. `flash_id` adds flash.
combined = _run(binary, ["--port", port, "flash_id"], timeout=_TIMEOUT_SHORT)
if combined["exit_code"] != 0:
raise ToolError(
f"esptool failed (exit {combined['exit_code']}):\n{combined['stderr_tail']}"
)
parsed = _parse_esptool_chip_info(combined["stdout"])
return {**parsed, "raw_stdout_tail": combined["stdout_tail"]}
def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]:
"""Full-chip erase. Leaves the device unbootable until reflashed."""
_require_confirm(confirm, "esptool_erase_flash")
binary = config.esptool_bin()
# esptool v5 uses `erase-flash`, older uses `erase_flash`. Try the new name
# first; if it fails with unknown command, retry old.
res = _run(binary, ["--port", port, "erase-flash"], timeout=_TIMEOUT_LONG)
if (
res["exit_code"] != 0
and "unrecognized" in (res["stderr"] or res["stdout"]).lower()
):
res = _run(binary, ["--port", port, "erase_flash"], timeout=_TIMEOUT_LONG)
return res
def esptool_raw(
args: list[str], port: str | None = None, confirm: bool = False
) -> dict[str, Any]:
"""Raw esptool passthrough. Destructive subcommands require confirm=True."""
if not args:
raise ToolError("args must not be empty")
# Find the first non-flag arg (the subcommand).
subcommand = next((a for a in args if not a.startswith("-")), None)
if subcommand and subcommand.replace("-", "_") in {
s.replace("-", "_") for s in ESPTOOL_DESTRUCTIVE
}:
_require_confirm(confirm, f"esptool {subcommand}")
binary = config.esptool_bin()
full_args: list[str] = []
if port:
full_args.extend(["--port", port])
full_args.extend(args)
return _run(binary, full_args, timeout=_TIMEOUT_LONG)
# ---------- nrfutil --------------------------------------------------------
NRFUTIL_DESTRUCTIVE = {"dfu", "settings"}
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
_require_confirm(confirm, "nrfutil_dfu")
pkg = Path(package_path).expanduser()
if not pkg.is_file():
raise ToolError(f"Package not found: {pkg}")
binary = config.nrfutil_bin()
return _run(
binary,
["dfu", "serial", "--package", str(pkg), "--port", port, "-b", "115200"],
timeout=_TIMEOUT_LONG,
)
def nrfutil_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
if not args:
raise ToolError("args must not be empty")
subcommand = next((a for a in args if not a.startswith("-")), None)
if subcommand in NRFUTIL_DESTRUCTIVE:
_require_confirm(confirm, f"nrfutil {subcommand}")
binary = config.nrfutil_bin()
return _run(binary, args, timeout=_TIMEOUT_LONG)
# ---------- picotool -------------------------------------------------------
PICOTOOL_DESTRUCTIVE = {"load", "reboot", "save", "erase"}
def _parse_picotool_info(stdout: str) -> dict[str, Any]:
result: dict[str, Any] = {
"vendor": None,
"product": None,
"serial": None,
"flash_size": None,
"program_name": None,
"program_version": None,
}
for line in stdout.splitlines():
line = line.strip()
if m := re.match(r"Program information:", line):
continue
if m := re.match(r"name:\s*(.+)", line):
result["program_name"] = m.group(1).strip()
elif m := re.match(r"version:\s*(.+)", line):
result["program_version"] = m.group(1).strip()
elif m := re.match(r"vendor:\s*(.+)", line):
result["vendor"] = m.group(1).strip()
elif m := re.match(r"product:\s*(.+)", line):
result["product"] = m.group(1).strip()
elif m := re.match(r"serial number:\s*(.+)", line):
result["serial"] = m.group(1).strip()
elif m := re.match(r"flash size:\s*(.+)", line):
result["flash_size"] = m.group(1).strip()
return result
def picotool_info(port: str | None = None) -> dict[str, Any]:
"""Read device info from a Pico in BOOTSEL mode. `port` is informational
only — picotool auto-detects."""
binary = config.picotool_bin()
res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT)
if res["exit_code"] != 0:
raise ToolError(
f"picotool info failed (exit {res['exit_code']}): "
"is the Pico in BOOTSEL mode?\n" + res["stderr_tail"]
)
parsed = _parse_picotool_info(res["stdout"])
return {**parsed, "raw_stdout_tail": res["stdout_tail"]}
def picotool_load(uf2_path: str, confirm: bool = False) -> dict[str, Any]:
_require_confirm(confirm, "picotool_load")
uf2 = Path(uf2_path).expanduser()
if not uf2.is_file():
raise ToolError(f"UF2 not found: {uf2}")
binary = config.picotool_bin()
return _run(binary, ["load", "-x", "-t", "uf2", str(uf2)], timeout=_TIMEOUT_LONG)
def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
if not args:
raise ToolError("args must not be empty")
subcommand = next((a for a in args if not a.startswith("-")), None)
if subcommand in PICOTOOL_DESTRUCTIVE:
_require_confirm(confirm, f"picotool {subcommand}")
binary = config.picotool_bin()
return _run(binary, args, timeout=_TIMEOUT_LONG)

View File

@@ -0,0 +1,103 @@
"""Read-only device queries via meshtastic.SerialInterface."""
from __future__ import annotations
from typing import Any
from .connection import connect
def _primary_channel_name(iface) -> str | None:
try:
channels = iface.localNode.channels or []
except AttributeError:
return None
for ch in channels:
role = getattr(ch, "role", None)
# Role enum: 0 DISABLED, 1 PRIMARY, 2 SECONDARY
if role == 1:
name = getattr(getattr(ch, "settings", None), "name", None)
return name or "(default)"
return None
def device_info(port: str | None = None, timeout_s: float = 8.0) -> dict[str, Any]:
"""Return summary info for the connected device."""
with connect(port=port, timeout_s=timeout_s) as iface:
my = iface.myInfo
meta = iface.metadata
local = iface.localNode
# Owner (long/short name) is on the local node's user record
long_name: str | None = None
short_name: str | None = None
hw_model: str | int | None = None
if iface.nodesByNum and my is not None:
local_rec = iface.nodesByNum.get(my.my_node_num, {})
user = local_rec.get("user") or {}
long_name = user.get("longName")
short_name = user.get("shortName")
hw_model = user.get("hwModel")
region = None
if local is not None and local.localConfig is not None:
try:
lora = local.localConfig.lora
# region is an enum; get its string name
region = (
lora.DESCRIPTOR.fields_by_name["region"]
.enum_type.values_by_number[lora.region]
.name
)
except Exception:
region = None
return {
"port": iface.devPath if hasattr(iface, "devPath") else port,
"my_node_num": getattr(my, "my_node_num", None),
"long_name": long_name,
"short_name": short_name,
"firmware_version": getattr(meta, "firmware_version", None),
"hw_model": hw_model,
"region": region,
"num_nodes": len(iface.nodesByNum) if iface.nodesByNum else 0,
"primary_channel": _primary_channel_name(iface),
}
def _node_record(node_dict: dict[str, Any]) -> dict[str, Any]:
user = node_dict.get("user") or {}
position = node_dict.get("position") or None
device_metrics = node_dict.get("deviceMetrics") or {}
return {
"node_num": node_dict.get("num"),
"user": {
"long_name": user.get("longName"),
"short_name": user.get("shortName"),
"hw_model": user.get("hwModel"),
"role": user.get("role"),
},
"position": (
{
"latitude": position.get("latitude"),
"longitude": position.get("longitude"),
"altitude": position.get("altitude"),
"time": position.get("time"),
}
if position
else None
),
"snr": node_dict.get("snr"),
"rssi": node_dict.get("rssi"),
"last_heard": node_dict.get("lastHeard"),
"battery_level": device_metrics.get("batteryLevel"),
"is_favorite": bool(node_dict.get("isFavorite", False)),
}
def list_nodes(port: str | None = None, timeout_s: float = 8.0) -> list[dict[str, Any]]:
"""Return the device's node database."""
with connect(port=port, timeout_s=timeout_s) as iface:
if not iface.nodesByNum:
return []
return [_node_record(n) for n in iface.nodesByNum.values()]

View File

@@ -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__}"
)

View File

@@ -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"]

View File

@@ -0,0 +1,295 @@
"""Subprocess wrappers around the `pio` CLI.
Every PlatformIO interaction in this package funnels through `run()` so we
have a single place that owns timeouts, buffer sizes, JSON parsing, and the
"stderr on exit-0 is informational" convention.
`run()` has two execution paths:
* Fast path (default): `subprocess.run(capture_output=True)` — buffered, one
return; fine for sub-second pio calls like `pio --version` or
`pio project config --json-output`.
* Streaming path: when the `MESHTASTIC_MCP_FLASH_LOG` env var is set, each
output line is tee'd to that file as it arrives via a threaded reader.
The TUI tails the file to give live flash progress — otherwise a 3-minute
`pio run -t upload` is completely silent to the operator.
`hw_tools.py` shares the streaming helper via `pio._run_capturing()` so
esptool/nrfutil/picotool output also streams when the env var is set.
"""
from __future__ import annotations
import json
import os
import subprocess
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence, TextIO
from . import config
# 10 MB matches the reference impl (jl-codes/platformio-mcp). Build output can
# be hundreds of KB; we'd rather keep it in memory than truncate.
_MAX_BUFFER = 10 * 1024 * 1024
# Per-operation defaults (seconds). None = no timeout.
TIMEOUT_DEFAULT = 120
TIMEOUT_PROJECT_CONFIG = 60
TIMEOUT_DEVICE_LIST = 15
TIMEOUT_BUILD = 900
TIMEOUT_UPLOAD = 600
class PioError(RuntimeError):
"""pio exited non-zero."""
def __init__(self, args: Sequence[str], returncode: int, stdout: str, stderr: str):
self.args = list(args)
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
tail = (stderr or stdout).strip().splitlines()[-20:]
super().__init__(
f"pio {' '.join(args)} failed with exit {returncode}:\n" + "\n".join(tail)
)
class PioTimeout(RuntimeError):
"""pio did not return within the timeout."""
@dataclass
class PioResult:
args: list[str]
returncode: int
stdout: str
stderr: str
duration_s: float
_FLASH_LOG_ENV = "MESHTASTIC_MCP_FLASH_LOG"
def _flash_log_path() -> Path | None:
"""Return the path to tee subprocess output to, or None if streaming off.
Controlled by `MESHTASTIC_MCP_FLASH_LOG`. `run-tests.sh` sets this to
`tests/flash.log`; the TUI tails that file so `pio run -t upload` shows
live progress in the pytest pane.
"""
raw = os.environ.get(_FLASH_LOG_ENV)
if not raw:
return None
return Path(raw)
def _run_capturing(
argv: Sequence[str],
*,
cwd: Path | None = None,
timeout: float | None = None,
tee_header: str | None = None,
) -> tuple[int, str, str, float]:
"""Run a subprocess, capture stdout+stderr, optionally tee to the flash log.
Returns `(returncode, stdout_str, stderr_str, duration_s)`. Raises
`subprocess.TimeoutExpired` on timeout (callers map this to their own
domain-specific error).
Fast path: `subprocess.run(capture_output=True)` when no flash log is
configured (unchanged behavior).
Streaming path: `Popen` with line-buffered stdout+stderr pipes; two
reader threads accumulate into result strings AND append each line to
the flash log file. Stdout and stderr stay separate in the return value
(so `stderr_tail` still means stderr), but are interleaved in the log
file in the order they arrived — that's what a human wants to read.
"""
log_path = _flash_log_path()
t0 = time.monotonic()
if log_path is None:
# Fast path — unchanged.
proc = subprocess.run(
list(argv),
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
timeout=timeout,
)
return (
proc.returncode,
proc.stdout or "",
proc.stderr or "",
time.monotonic() - t0,
)
# Streaming path: line-buffered Popen, threaded readers, tee to file.
# Ensure parent directory exists so the first tee write doesn't fail.
log_path.parent.mkdir(parents=True, exist_ok=True)
log_fh: TextIO | None = None
try:
log_fh = log_path.open("a", encoding="utf-8")
except OSError:
pass
# Append mode: the TUI truncates on startup, the session may produce
# many tee'd commands (erase + flash + factory-reset response), and
# we want all of them chronologically in one log.
proc = subprocess.Popen( # noqa: S603
list(argv),
cwd=str(cwd) if cwd else None,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1, # line-buffered
)
stdout_chunks: list[str] = []
stderr_chunks: list[str] = []
log_lock = threading.Lock()
def _append_log(line: str) -> None:
# Hold the lock briefly to serialize interleaved stdout/stderr writes
# so a half-written line from one stream doesn't get garbled by the
# other.
nonlocal log_fh
with log_lock:
if log_fh is None:
return
try:
log_fh.write(line)
log_fh.flush()
except OSError:
# Log file disappeared (umount, operator deleted the dir).
# Don't let that bubble up — the subprocess output is still
# collected in-memory for the return value.
try:
log_fh.close()
except OSError:
pass
log_fh = None
def _tee(stream, sink: list[str]) -> None:
try:
for line in stream:
sink.append(line)
_append_log(line)
except Exception:
pass
# Header line so the operator can tell commands apart in the log.
if tee_header:
_append_log(f"\n--- {tee_header} (start)\n")
assert proc.stdout is not None and proc.stderr is not None
t_out = threading.Thread(
target=_tee, args=(proc.stdout, stdout_chunks), daemon=True
)
t_err = threading.Thread(
target=_tee, args=(proc.stderr, stderr_chunks), daemon=True
)
t_out.start()
t_err.start()
# `Popen.wait` with a timeout is the cleanest way to get TimeoutExpired.
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
# Drain readers before re-raising so we don't leave threads behind.
t_out.join(timeout=2)
t_err.join(timeout=2)
raise
t_out.join()
t_err.join()
duration = time.monotonic() - t0
if tee_header:
_append_log(f"--- {tee_header} (exit {proc.returncode} in {duration:.1f}s)\n")
try:
return (
proc.returncode,
"".join(stdout_chunks),
"".join(stderr_chunks),
duration,
)
finally:
if log_fh is not None:
try:
log_fh.close()
except OSError:
pass
def run(
args: Sequence[str],
*,
cwd: Path | None = None,
timeout: float | None = TIMEOUT_DEFAULT,
check: bool = True,
) -> PioResult:
"""Invoke `pio <args>` and return captured output.
`cwd` defaults to the firmware root. `check=True` raises `PioError` on
non-zero exit; set `check=False` to inspect `returncode` manually.
If `MESHTASTIC_MCP_FLASH_LOG` is set, output is also tee'd to that file
line-by-line as it arrives (for live flash progress in the TUI).
"""
binary = str(config.pio_bin())
work_dir = cwd or config.firmware_root()
full = [binary, *args]
try:
rc, stdout, stderr, duration = _run_capturing(
full,
cwd=work_dir,
timeout=timeout,
tee_header=f"pio {' '.join(args)}",
)
except subprocess.TimeoutExpired as exc:
raise PioTimeout(f"pio {' '.join(args)} timed out after {timeout}s") from exc
result = PioResult(
args=list(args),
returncode=rc,
stdout=stdout,
stderr=stderr,
duration_s=duration,
)
if check and result.returncode != 0:
raise PioError(args, result.returncode, result.stdout, result.stderr)
return result
def run_json(
args: Sequence[str],
*,
cwd: Path | None = None,
timeout: float | None = TIMEOUT_DEFAULT,
):
"""Run pio with `--json-output` appended and parse the result."""
full = list(args)
if "--json-output" not in full:
full.append("--json-output")
res = run(full, cwd=cwd, timeout=timeout, check=True)
if not res.stdout.strip():
raise PioError(args, 0, res.stdout, res.stderr or "pio returned empty output")
try:
return json.loads(res.stdout)
except json.JSONDecodeError as exc:
raise PioError(
args, 0, res.stdout[:2000], f"invalid JSON from pio: {exc}"
) from exc
def tail_lines(text: str, n: int = 200) -> str:
"""Last `n` lines of `text`, joined with newlines. Empty string stays empty."""
if not text:
return ""
lines = text.splitlines()
return "\n".join(lines[-n:])

View File

@@ -0,0 +1,98 @@
"""In-memory registry of active serial monitor sessions and port locks.
Two things live here so the rest of the package has a single place to reach
them:
1. `sessions`: `{session_id: SerialSession}` for pio device monitor subprocs.
2. `port_locks`: `{port: threading.Lock}` so admin/info tools can fail fast
when a serial monitor or another meshtastic client already owns a port.
"""
from __future__ import annotations
import threading
from typing import Any
from .serial_session import SerialSession, close_session
_LOCK = threading.Lock()
_sessions: dict[str, SerialSession] = {}
_port_locks: dict[str, threading.Lock] = {}
def register_session(session: SerialSession) -> None:
with _LOCK:
_sessions[session.id] = session
def get_session(session_id: str) -> SerialSession:
with _LOCK:
session = _sessions.get(session_id)
if session is None:
raise KeyError(f"Unknown session_id: {session_id!r}")
return session
def remove_session(session_id: str) -> SerialSession | None:
with _LOCK:
return _sessions.pop(session_id, None)
def active_session_for_port(port: str) -> SerialSession | None:
"""Find any active (non-eof) session owning `port`."""
sweep_dead()
with _LOCK:
for s in _sessions.values():
if s.port == port and s.proc.poll() is None:
return s
return None
def all_sessions() -> list[SerialSession]:
with _LOCK:
return list(_sessions.values())
def sweep_dead() -> int:
"""Remove sessions whose subprocess has exited. Returns count removed."""
removed_sessions: list[SerialSession] = []
with _LOCK:
for sid, s in list(_sessions.items()):
if s.proc.poll() is not None:
removed_sessions.append(_sessions.pop(sid))
for session in removed_sessions:
try:
close_session(session)
except Exception:
pass
return len(removed_sessions)
def shutdown_all() -> None:
"""Close every live session (called on server exit)."""
with _LOCK:
items = list(_sessions.items())
_sessions.clear()
for _sid, session in items:
try:
close_session(session)
except Exception:
pass
def port_lock(port: str) -> threading.Lock:
"""Per-port lock for SerialInterface / admin tool serialization."""
with _LOCK:
lock = _port_locks.get(port)
if lock is None:
lock = threading.Lock()
_port_locks[port] = lock
return lock
def snapshot() -> dict[str, Any]:
"""Debug dump: session count, port lock count."""
with _LOCK:
return {
"sessions": len(_sessions),
"port_locks": len(_port_locks),
}

View File

@@ -0,0 +1,216 @@
"""Long-running serial monitor sessions via `pio device monitor`.
Why pio instead of raw pyserial: pio applies the board's monitor_filters —
`esp32_exception_decoder` symbolicates crash stacks, `time` adds timestamps,
etc. Raw pyserial would give us bytes; pio gives us developer-grade logs.
Each session runs `pio device monitor` in a subprocess, with a daemon reader
thread draining stdout into a bounded ring buffer. Callers pull lines via
`serial_read` using a cursor that survives across calls.
"""
from __future__ import annotations
import collections
import subprocess
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
from . import boards, config
_BUFFER_MAX_LINES = 10_000
_POLL_NEW_PORT_TIMEOUT_S = 3.0
@dataclass
class SerialSession:
id: str
port: str
baud: int
filters: list[str]
env: str | None
proc: subprocess.Popen
buffer: collections.deque = field(
default_factory=lambda: collections.deque(maxlen=_BUFFER_MAX_LINES)
)
# Total lines seen (not bounded by buffer maxlen). `dropped = total - len(buffer)`
# if the reader has advanced past buffer head.
total_lines: int = 0
started_at: float = field(default_factory=time.time)
stopped_at: float | None = None
lock: threading.Lock = field(default_factory=threading.Lock)
_thread: threading.Thread | None = None
def _drain(session: SerialSession) -> None:
"""Reader thread: line-by-line pull stdout into buffer."""
assert session.proc.stdout is not None
try:
for line in session.proc.stdout:
line_stripped = line.rstrip("\r\n")
with session.lock:
session.buffer.append(line_stripped)
session.total_lines += 1
except Exception: # pragma: no cover - defensive
pass
finally:
session.stopped_at = time.time()
def open_session(
port: str,
baud: int = 115200,
env: str | None = None,
filters: list[str] | None = None,
) -> SerialSession:
"""Spawn `pio device monitor` and return a SerialSession.
If `env` is supplied, pio resolves baud and filters from platformio.ini.
Otherwise uses the supplied `baud` and `filters` (default `['direct']`).
"""
args = ["device", "monitor", "--port", port, "--no-reconnect"]
effective_filters: list[str]
effective_baud: int = baud
if env is not None:
args.extend(["-e", env])
raw_config: dict[str, Any] = {}
try:
raw = boards.get_board(env).get("raw_config")
if isinstance(raw, dict):
raw_config = raw
except Exception:
raw_config = {}
monitor_speed = raw_config.get("monitor_speed")
has_board_speed = False
if monitor_speed is not None:
try:
effective_baud = int(str(monitor_speed).strip())
has_board_speed = True
except (TypeError, ValueError):
pass
monitor_filters_raw = raw_config.get("monitor_filters")
parsed_board_filters: list[str] = []
if isinstance(monitor_filters_raw, str):
for token in monitor_filters_raw.replace("\n", ",").split(","):
item = token.strip()
if item:
parsed_board_filters.append(item)
elif isinstance(monitor_filters_raw, list):
parsed_board_filters = [
str(item).strip() for item in monitor_filters_raw if str(item).strip()
]
has_board_filters = len(parsed_board_filters) > 0
effective_filters = (
parsed_board_filters if has_board_filters else (filters or [])
)
if not has_board_speed:
args.extend(["--baud", str(effective_baud)])
if not has_board_filters:
for f in effective_filters:
args.extend(["--filter", f])
else:
args.extend(["--baud", str(baud)])
effective_filters = filters or ["direct"]
for f in effective_filters:
args.extend(["--filter", f])
binary = str(config.pio_bin())
work_dir = str(config.firmware_root())
proc = subprocess.Popen(
[binary, *args],
cwd=work_dir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1, # line-buffered
)
session = SerialSession(
id=uuid.uuid4().hex,
port=port,
baud=effective_baud,
filters=effective_filters,
env=env,
proc=proc,
)
t = threading.Thread(target=_drain, args=(session,), daemon=True)
t.start()
session._thread = t
return session
def read_session(
session: SerialSession, max_lines: int = 200, since_cursor: int | None = None
) -> dict[str, Any]:
"""Snapshot recent lines from the buffer.
Cursor semantics: the global cursor is `total_lines` at read time. Pass
`since_cursor` from a previous response's `new_cursor` to page forward.
`since_cursor=0` reads everything still in the ring buffer.
"""
with session.lock:
total = session.total_lines
buf_len = len(session.buffer)
head_cursor = total - buf_len # cursor value at buffer[0]
current_buffer = list(session.buffer)
if since_cursor is None:
since_cursor = head_cursor
# Clamp: never read what's aged out of the buffer.
effective_start = max(since_cursor, head_cursor)
# Number of lines skipped because they aged out between reads.
dropped = max(0, head_cursor - since_cursor) if since_cursor < head_cursor else 0
start_idx = effective_start - head_cursor
end_idx = min(start_idx + max_lines, buf_len)
lines = current_buffer[start_idx:end_idx]
new_cursor = effective_start + len(lines)
eof = session.proc.poll() is not None
return {
"lines": lines,
"new_cursor": new_cursor,
"eof": eof,
"dropped": dropped,
}
def close_session(session: SerialSession) -> bool:
"""Terminate the subprocess and join the reader thread."""
proc = session.proc
if proc.poll() is None:
try:
proc.terminate()
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=3)
if session._thread is not None:
session._thread.join(timeout=3)
session.stopped_at = session.stopped_at or time.time()
return True
def session_summary(session: SerialSession) -> dict[str, Any]:
with session.lock:
line_count = session.total_lines
return {
"session_id": session.id,
"port": session.port,
"baud": session.baud,
"filters": session.filters,
"env": session.env,
"started_at": session.started_at,
"stopped_at": session.stopped_at,
"line_count": line_count,
"eof": session.proc.poll() is not None,
}

View File

@@ -0,0 +1,736 @@
"""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.
"""
from __future__ import annotations
from typing import Any
from mcp.server.fastmcp import FastMCP
from . import (
admin,
boards,
devices,
flash,
hw_tools,
info,
registry,
serial_session,
)
from . import userprefs as userprefs_mod
app = FastMCP("meshtastic-mcp")
# ---------- Discovery & metadata ------------------------------------------
@app.tool()
def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
"""List USB/serial ports, flagging those likely to be Meshtastic devices.
With include_unknown=True, returns every serial port the OS knows about
(useful for debugging when a device isn't detected). Otherwise returns
only likely-Meshtastic candidates.
"""
return devices.list_devices(include_unknown=include_unknown)
@app.tool()
def list_boards(
architecture: str | None = None,
actively_supported_only: bool = False,
query: str | None = None,
board_level: str | None = None,
) -> list[dict[str, Any]]:
"""Enumerate PlatformIO envs (boards) with Meshtastic metadata.
architecture: filter to one arch ("esp32", "esp32s3", "nrf52840", "rp2040", "stm32", "native").
actively_supported_only: filter to boards marked custom_meshtastic_actively_supported=true.
query: substring match on display_name, env name, or hw_model_slug (case-insensitive).
board_level: "release" (default-track release boards), "pr" (PR CI), or "extra" (opt-in extras).
"""
return boards.list_boards(
architecture=architecture,
actively_supported_only=actively_supported_only,
query=query,
board_level=board_level,
)
@app.tool()
def get_board(env: str) -> dict[str, Any]:
"""Full metadata for one PlatformIO env, including raw pio config fields."""
return boards.get_board(env)
# ---------- Build & flash -------------------------------------------------
@app.tool()
def build(
env: str,
with_manifest: bool = True,
userprefs: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build firmware for one env via `pio run -e <env>`.
Returns exit_code, duration, artifact paths under .pio/build/<env>/, and
tails of stdout/stderr (last 200 lines each). with_manifest=True adds the
mtjson target which produces an .mt.json manifest alongside the firmware.
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into this
build via userPrefs.jsonc injection. The file is restored after the build
completes. Use `userprefs_manifest` to discover available keys. Use
`userprefs_set` for persistent changes.
"""
return flash.build(env, with_manifest=with_manifest, userprefs_overrides=userprefs)
@app.tool()
def clean(env: str) -> dict[str, Any]:
"""Clean one env's build output via `pio run -e <env> -t clean`.
Useful when switching branches or debugging a stale-cache build failure.
"""
return flash.clean(env)
@app.tool()
def pio_flash(
env: str,
port: str,
confirm: bool = False,
userprefs: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Flash firmware via `pio run -e <env> -t upload --upload-port <port>`.
Works for any architecture (ESP32/nRF52/RP2040/STM32). Requires confirm=True.
For first-time flashing a blank ESP32 board (erase + bootloader + app + fs),
prefer `erase_and_flash`. For ESP32 OTA updates, prefer `update_flash`.
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into this
build via userPrefs.jsonc injection; restored after upload.
"""
return flash.flash(env, port, confirm=confirm, userprefs_overrides=userprefs)
@app.tool()
def erase_and_flash(
env: str,
port: str,
confirm: bool = False,
skip_build: bool = False,
userprefs: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""ESP32-only: full erase + factory flash via bin/device-install.sh.
Wipes the entire flash and writes bootloader, app, OTA, and LittleFS
partitions from the factory.bin. Requires confirm=True. Runs `build` first
if no factory.bin is present (set skip_build=True to require a prior build).
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into the
factory.bin via userPrefs.jsonc injection. When provided, forces a rebuild
(skip_build=True is incompatible). File is restored after upload.
"""
return flash.erase_and_flash(
env, port, confirm=confirm, skip_build=skip_build, userprefs_overrides=userprefs
)
@app.tool()
def update_flash(
env: str,
port: str,
confirm: bool = False,
skip_build: bool = False,
userprefs: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""ESP32-only: OTA app-partition update via bin/device-update.sh.
Updates only the application partition, preserving device config and node
database. Faster than erase_and_flash but won't recover a broken bootloader.
Requires confirm=True. Builds first if needed.
`userprefs` (optional): dict of `USERPREFS_<KEY>: value` baked into the
firmware.bin via userPrefs.jsonc injection. When provided, forces a rebuild.
"""
return flash.update_flash(
env, port, confirm=confirm, skip_build=skip_build, userprefs_overrides=userprefs
)
# ---------- USERPREFS discovery & persistence -----------------------------
@app.tool()
def userprefs_manifest() -> dict[str, Any]:
"""Full manifest of USERPREFS_* keys the firmware knows about.
Combines `userPrefs.jsonc` (active + commented examples) with a scan of
`src/**` for `USERPREFS_<KEY>` references — so every key the firmware
actually consumes shows up, even if undocumented in the jsonc.
Each entry has: key, active (is it uncommented), value (current), example
(jsonc commented default), declared_in_jsonc, consumed_by (list of src
files), inferred_type (brace|number|bool|enum|string|unknown).
`inferred_type` mirrors how platformio-custom.py wraps values at build
time: `brace` = byte array `{ 0x01, ... }`, `number` = decimal, `bool` =
true/false, `enum` = `meshtastic_*` constant, `string` = wrapped in quotes
via StringifyMacro.
"""
return userprefs_mod.build_manifest()
@app.tool()
def userprefs_get() -> dict[str, Any]:
"""Return the current userPrefs.jsonc state.
`active` is the dict of uncommented `USERPREFS_*` → value that will be
baked into the next build. `commented` is the dict of commented example
defaults (shown for reference).
"""
state = userprefs_mod.read_state()
# Drop `order` (internal for round-trip rendering) from the public payload.
return {
"path": state["path"],
"active": state["active"],
"commented": state["commented"],
}
@app.tool()
def userprefs_set(prefs: dict[str, Any]) -> dict[str, Any]:
"""Merge `prefs` into userPrefs.jsonc persistently (uncommenting keys).
Existing active values not in `prefs` are kept. To remove a key from the
active set, call `userprefs_reset` (restores the MCP backup if present)
or edit the jsonc manually. Values are stringified the way
platformio-custom.py expects (bool → "true"/"false", int → "42", etc.).
"""
return userprefs_mod.merge_active(prefs)
@app.tool()
def userprefs_reset() -> dict[str, Any]:
"""Restore userPrefs.jsonc from the most recent MCP backup (if any).
The backup is only created by the legacy `userprefs_set` workflow (not
currently written automatically). Returns `{restored: bool, ...}` — false
when no backup is present, in which case the caller should edit the
jsonc directly.
"""
return userprefs_mod.reset()
@app.tool()
def userprefs_testing_profile(
psk_seed: str | None = None,
channel_name: str = "McpTest",
channel_num: int = 88,
region: str = "US",
modem_preset: str = "LONG_FAST",
short_name: str | None = None,
long_name: str | None = None,
disable_mqtt: bool = True,
disable_position: bool = False,
) -> dict[str, Any]:
"""Generate a USERPREFS dict for provisioning an isolated test-mesh device.
Baking this into firmware produces devices that:
- Run on a deterministic non-default LoRa slot (default 88 on US LONG_FAST,
well off the `hash("LongFast")` slot a stock production device uses)
- Join a private channel with a name and PSK that differ from public
defaults — so no accidental mesh-with-production-devices
- Have MQTT disabled (no uplink/downlink bridge), so test traffic never
leaks to a public broker
- Optionally disable GPS for bench-test conditions
For a multi-device test cluster, pass the same `psk_seed` to every call so
every device shares the same PSK and lands on the same isolated mesh.
Returned dict is ready to pass straight to `build`, `pio_flash`,
`erase_and_flash`, or `update_flash` via their `userprefs` parameter.
Example:
profile = userprefs_testing_profile(psk_seed="ci-run-2026-04-16")
erase_and_flash(env="tbeam", port="/dev/cu.usbmodem...", confirm=True,
userprefs=profile)
Args:
psk_seed: seed for deterministic 32-byte PSK via SHA-256. None = random
(fine one-off, useless for multi-device clusters).
channel_name: primary channel name (≤11 chars). Default "McpTest".
channel_num: 1-indexed LoRa slot (0 = fall back to name-hash). Default
88 — mid-upper US band, unlikely to collide with production slots.
region: short code — one of US, EU_433, EU_868, CN, JP, ANZ, KR, TW,
RU, IN, NZ_865, TH, UA_433, UA_868, MY_433, MY_919, SG_923, LORA_24.
modem_preset: one of LONG_FAST, LONG_SLOW, LONG_MODERATE, VERY_LONG_SLOW,
MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, SHORT_TURBO.
short_name: optional owner short name (≤4 chars) stamped into the build.
long_name: optional owner long name stamped into the build.
disable_mqtt: disable MQTT module + uplink/downlink (default True).
disable_position: disable GPS + smart-position broadcasts (default False).
"""
return userprefs_mod.build_testing_profile(
psk_seed=psk_seed,
channel_name=channel_name,
channel_num=channel_num,
region=region,
modem_preset=modem_preset,
short_name=short_name,
long_name=long_name,
disable_mqtt=disable_mqtt,
disable_position=disable_position,
)
@app.tool()
def touch_1200bps(port: str, settle_ms: int = 250) -> dict[str, Any]:
"""Open `port` at 1200 baud and immediately close, triggering USB CDC
bootloader entry on nRF52840, ESP32-S3 (native USB), RP2040, etc.
After the touch, polls serial devices for up to 3 seconds and reports any
new port that appeared (the bootloader often enumerates as a different
device). Not destructive — this is just a reset signal.
"""
return flash.touch_1200bps(port, settle_ms=settle_ms)
# ---------- Serial log sessions -------------------------------------------
@app.tool()
def serial_open(
port: str,
baud: int = 115200,
env: str | None = None,
filters: list[str] | None = None,
) -> dict[str, Any]:
"""Open a `pio device monitor` session reading from `port`.
If `env` is set, pio picks up monitor_speed and monitor_filters from
platformio.ini — recommended for firmware debugging since it enables
esp32_exception_decoder / esp32_c3_exception_decoder for ESP32 envs.
Without `env`, uses the supplied baud and filters (default ["direct"]).
Common filters: direct, time, hexlify, esp32_exception_decoder,
esp32_c3_exception_decoder, log2file.
Returns a session_id for use with serial_read / serial_close, plus the
resolved baud and filters so callers can confirm what pio selected.
"""
session = serial_session.open_session(
port=port, baud=baud, env=env, filters=filters
)
registry.register_session(session)
return {
"session_id": session.id,
"resolved_baud": session.baud,
"resolved_filters": session.filters,
"env": session.env,
}
@app.tool()
def serial_read(
session_id: str,
max_lines: int = 200,
since_cursor: int | None = None,
) -> dict[str, Any]:
"""Read buffered lines from a serial monitor session.
Default: returns everything since your last call to serial_read (uses an
advancing cursor). Pass `since_cursor=N` to re-read from a specific point,
or `since_cursor=0` to read from the start of the in-memory buffer.
Returns `dropped` = count of lines that aged out of the 10k-line ring
buffer between reads — so a value > 0 means you missed data.
"""
session = registry.get_session(session_id)
return serial_session.read_session(
session, max_lines=max_lines, since_cursor=since_cursor
)
@app.tool()
def serial_list() -> list[dict[str, Any]]:
"""List all active serial monitor sessions."""
return [serial_session.session_summary(s) for s in registry.all_sessions()]
@app.tool()
def serial_close(session_id: str) -> dict[str, Any]:
"""Terminate a serial monitor session and free its port."""
session = registry.remove_session(session_id)
if session is None:
return {"ok": False, "reason": f"Unknown session_id {session_id!r}"}
serial_session.close_session(session)
return {"ok": True}
# ---------- Device interaction: reads -------------------------------------
@app.tool()
def device_info(port: str | None = None, timeout_s: float = 8.0) -> dict[str, Any]:
"""Connect via meshtastic.SerialInterface and return a summary of the node.
If `port` is omitted and exactly one likely-Meshtastic device is connected,
it's auto-selected; otherwise the tool errors with the candidate list.
"""
return info.device_info(port=port, timeout_s=timeout_s)
@app.tool()
def list_nodes(port: str | None = None, timeout_s: float = 8.0) -> list[dict[str, Any]]:
"""Return the device's current node database (local node + all known peers)."""
return info.list_nodes(port=port, timeout_s=timeout_s)
# ---------- Device interaction: writes ------------------------------------
@app.tool()
def set_owner(
long_name: str, short_name: str | None = None, port: str | None = None
) -> dict[str, Any]:
"""Set the device's owner long name and (optional) short name (≤4 chars)."""
return admin.set_owner(long_name=long_name, short_name=short_name, port=port)
@app.tool()
def get_config(section: str | None = None, port: str | None = None) -> dict[str, Any]:
"""Read one or all config sections.
`section` may be any LocalConfig section (device, position, power, network,
display, lora, bluetooth, security) or LocalModuleConfig section (mqtt,
serial, telemetry, external_notification, canned_message, range_test,
store_forward, neighbor_info, ambient_lighting, detection_sensor,
paxcounter, audio, remote_hardware, statusmessage, traffic_management).
Omit or pass "all" for every section.
"""
return admin.get_config(section=section, port=port)
@app.tool()
def set_config(path: str, value: Any, port: str | None = None) -> dict[str, Any]:
"""Set one config field via dot-path and write it to the device.
Examples: "lora.region"="US", "lora.modem_preset"="LONG_FAST",
"device.role"="ROUTER", "mqtt.enabled"=True, "mqtt.address"="host".
Enum fields accept their name (case-insensitive) or int.
"""
return admin.set_config(path=path, value=value, port=port)
@app.tool()
def get_channel_url(
include_all: bool = False, port: str | None = None
) -> dict[str, Any]:
"""Get the shareable channel URL (QR-code content).
include_all=True returns the admin URL including all secondary channels;
False returns only the primary channel (what users typically share).
"""
return admin.get_channel_url(include_all=include_all, port=port)
@app.tool()
def set_channel_url(url: str, port: str | None = None) -> dict[str, Any]:
"""Import channels from a Meshtastic channel URL."""
return admin.set_channel_url(url=url, port=port)
@app.tool()
def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
"""Toggle security.debug_log_api_enabled on the local node.
When true, firmware streams log lines as protobuf `LogRecord` messages
over the StreamAPI (topic `meshtastic.log.line` in meshtastic-python)
instead of raw text. Lets diagnostic clients capture firmware-side logs
through the SAME SerialInterface used for admin/info calls — no
separate `pio device monitor` session needed, no exclusive-port-lock
conflict. Persists across reboot via NVS; wiped by factory_reset
unless re-applied.
The earlier emitLogRecord race (shared tx buffer) is fixed at the
firmware level — the log path has a dedicated scratch + txBuf and
both emission paths serialize via a mutex. Safe to leave on under
traffic.
"""
return admin.set_debug_log_api(enabled=enabled, port=port)
@app.tool()
def send_text(
text: str,
to: str | int | None = None,
channel_index: int = 0,
want_ack: bool = False,
port: str | None = None,
) -> dict[str, Any]:
"""Send a text message over the mesh.
`to` defaults to broadcast ("^all"). Pass a node ID (hex string like
"!abcdef01") or node number (int) to direct-message a specific node.
channel_index picks which configured channel to send on.
"""
return admin.send_text(
text=text, to=to, channel_index=channel_index, want_ack=want_ack, port=port
)
@app.tool()
def reboot(
port: str | None = None, confirm: bool = False, seconds: int = 10
) -> dict[str, Any]:
"""Reboot the connected node in `seconds` seconds. Requires confirm=True."""
return admin.reboot(port=port, confirm=confirm, seconds=seconds)
@app.tool()
def shutdown(
port: str | None = None, confirm: bool = False, seconds: int = 10
) -> dict[str, Any]:
"""Shut down the connected node in `seconds` seconds. Requires confirm=True."""
return admin.shutdown(port=port, confirm=confirm, seconds=seconds)
@app.tool()
def factory_reset(
port: str | None = None, confirm: bool = False, full: bool = False
) -> dict[str, Any]:
"""Factory-reset the connected node. Requires confirm=True.
`full=True` also wipes device identity/keys (not just config).
"""
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[_<ROLE>]`. 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_<ROLE>` +
`_PORT_<ROLE>` 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 -----------------------------------------
@app.tool()
def esptool_chip_info(port: str) -> dict[str, Any]:
"""Run `esptool flash_id` and return chip, MAC, crystal, and flash size.
Read-only — no confirm required. Prefer this over parsing pio upload logs
when you just want to identify the chip.
"""
return hw_tools.esptool_chip_info(port)
@app.tool()
def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]:
"""Full-chip erase via `esptool erase_flash`. Leaves the device unbootable.
Prefer `erase_and_flash` which also writes firmware. Use this only for
recovery when a device is in a weird state. Requires confirm=True.
"""
return hw_tools.esptool_erase_flash(port, confirm=confirm)
@app.tool()
def esptool_raw(
args: list[str], port: str | None = None, confirm: bool = False
) -> dict[str, Any]:
"""Pass-through to `esptool`. Destructive subcommands (write_flash,
erase_flash, erase_region, merge_bin) require confirm=True.
Prefer the high-level `pio_flash` / `erase_and_flash` / `update_flash`
tools where possible — they know board-specific offsets and protocols.
"""
return hw_tools.esptool_raw(args, port=port, confirm=confirm)
@app.tool()
def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]:
"""DFU-flash a .zip package to an nRF52840 via `nrfutil dfu serial`.
Prefer `pio_flash` for flashing firmware built from this repo — pio handles
the DFU invocation automatically. Use this tool when flashing a pre-built
release zip or a custom bootloader. Requires confirm=True.
"""
return hw_tools.nrfutil_dfu(port, package_path, confirm=confirm)
@app.tool()
def nrfutil_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
"""Pass-through to `nrfutil`. dfu/settings subcommands require confirm=True."""
return hw_tools.nrfutil_raw(args, confirm=confirm)
@app.tool()
def picotool_info(port: str | None = None) -> dict[str, Any]:
"""Run `picotool info -a`. Requires the RP2040 to be in BOOTSEL mode
(hold BOOTSEL button while plugging in, or call `touch_1200bps` if the
firmware supports 1200bps-reset)."""
return hw_tools.picotool_info(port=port)
@app.tool()
def picotool_load(uf2_path: str, confirm: bool = False) -> dict[str, Any]:
"""Load a UF2 to a Pico in BOOTSEL mode via `picotool load -x -t uf2`.
Prefer `pio_flash` for flashing firmware built from this repo.
Requires confirm=True.
"""
return hw_tools.picotool_load(uf2_path, confirm=confirm)
@app.tool()
def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
"""Pass-through to `picotool`. load/reboot/save/erase require confirm=True."""
return hw_tools.picotool_raw(args, confirm=confirm)

View File

@@ -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<location>\S+)\s+\[(?P<descriptor>.+)\]\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<port>\d+):\s+(?P<status>\S+)\s+(?P<flags>.*?)"
r"(?:\s+\[(?P<device_vid>[0-9a-fA-F]{4}):(?P<device_pid>[0-9a-fA-F]{4})(?:\s+(?P<device_desc>.+))?\])?\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_<ROLE>` + `_PORT_<ROLE>`
(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",
]

View File

@@ -0,0 +1,540 @@
"""USERPREFS: build-time constants baked into the firmware binary.
The firmware repo has `userPrefs.jsonc` at its root — a JSONC file with every
available USERPREFS_* key listed, most commented out. At build time,
`bin/platformio-custom.py` reads it, strips comments, and emits
`-DUSERPREFS_<KEY>=<value>` build flags into the compile step. Firmware code
uses `#ifdef USERPREFS_<KEY>` to pick up the baked-in defaults for channels,
owner name, LoRa region, OEM branding, MQTT credentials, etc.
This module:
1. Parses `userPrefs.jsonc` (preserving which keys are active vs commented)
2. Greps `src/` for the set of keys the firmware actually consumes (the
real discovery manifest — anything here that isn't in the jsonc is still
a valid override)
3. Provides a context manager for temporarily swapping in overrides during
a build/flash, then restoring the original file
4. Provides persistent `set` / `reset` for when the caller wants the change
to stick across multiple builds
The firmware's platformio-custom.py value-type detection mirrors what we need
for serialization: dict-like `{...}` (byte arrays, enum lists), digit-like
(ints and floats), `true`/`false`, `meshtastic_*` enum constants, and
everything else gets string-wrapped via `env.StringifyMacro`. We store the
raw string values exactly as they'd appear in the jsonc to avoid round-trip
surprises.
"""
from __future__ import annotations
import json
import re
import shutil
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from . import config
USERPREFS_FILE = "userPrefs.jsonc"
BACKUP_SUFFIX = ".mcp.bak"
# Pattern for lines like `// "USERPREFS_FOO": "value",` or `"USERPREFS_FOO": "v"`
_ACTIVE_LINE = re.compile(r'^\s*"(USERPREFS_[A-Z0-9_]+)"\s*:\s*"((?:[^"\\]|\\.)*)"')
_COMMENTED_LINE = re.compile(
r'^\s*//\s*"(USERPREFS_[A-Z0-9_]+)"\s*:\s*"((?:[^"\\]|\\.)*)"'
)
# Inline comment stripper (matches platformio-custom.py:219)
_LINE_COMMENT = re.compile(r"//.*")
# USERPREFS_* usage in firmware source (#ifdef, #if defined, direct refs)
_USAGE_PATTERN = re.compile(r"\bUSERPREFS_[A-Z0-9_]+\b")
def jsonc_path() -> Path:
return config.firmware_root() / USERPREFS_FILE
def _read_file(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _parse_jsonc_state(text: str) -> dict[str, Any]:
"""Parse userPrefs.jsonc while preserving comment state per key.
Returns:
{
"active": {key: string_value, ...}, # uncommented
"commented": {key: string_value, ...}, # commented examples
"order": [key, ...] # source order for round-trip
}
"""
active: dict[str, str] = {}
commented: dict[str, str] = {}
order: list[str] = []
for line in text.splitlines():
if m := _COMMENTED_LINE.match(line):
key, val = m.group(1), m.group(2)
commented[key] = val
order.append(key)
elif m := _ACTIVE_LINE.match(line):
key, val = m.group(1), m.group(2)
active[key] = val
order.append(key)
return {"active": active, "commented": commented, "order": order}
def _parse_jsonc_active(text: str) -> dict[str, str]:
"""Parse active-only values by stripping line comments + feeding to json."""
stripped = "\n".join(_LINE_COMMENT.sub("", line) for line in text.splitlines())
try:
return {k: str(v) for k, v in json.loads(stripped).items()}
except json.JSONDecodeError as exc:
raise ValueError(f"userPrefs.jsonc is not valid JSONC: {exc}") from exc
def read_state() -> dict[str, Any]:
"""Return {active, commented, order, path}."""
path = jsonc_path()
if not path.is_file():
return {"active": {}, "commented": {}, "order": [], "path": str(path)}
state = _parse_jsonc_state(_read_file(path))
state["path"] = str(path)
return state
# ---------- Manifest ------------------------------------------------------
def _scan_consumed_keys() -> dict[str, list[str]]:
"""Grep firmware src/ for USERPREFS_* references.
Returns {key: [relative_file_paths]} — only includes files under `src/`.
"""
src_dir = config.firmware_root() / "src"
if not src_dir.is_dir():
return {}
out: dict[str, set[str]] = {}
for path in src_dir.rglob("*"):
if not path.is_file() or path.suffix.lower() not in {
".c",
".cc",
".cpp",
".h",
".hpp",
".ipp",
".inl",
}:
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except Exception:
continue
for m in _USAGE_PATTERN.finditer(text):
key = m.group(0)
# Skip our own "_USERPREFS_" artifacts (reserve-word guard from build-userprefs-json.py)
if key.startswith("_USERPREFS_"):
continue
out.setdefault(key, set()).add(
str(path.relative_to(config.firmware_root()))
)
return {k: sorted(v) for k, v in sorted(out.items())}
def build_manifest() -> dict[str, Any]:
"""Build the discovery manifest.
Every known USERPREFS_* key appears exactly once with:
- `value` (current active value, if any)
- `example` (commented default from jsonc, if any)
- `active` bool
- `declared_in_jsonc` bool (key appears anywhere in userPrefs.jsonc)
- `consumed_by` list of source files that reference it
- `inferred_type`: one of "brace", "number", "bool", "enum", "string"
— matches platformio-custom.py's value-wrapping switch
"""
state = read_state()
consumed = _scan_consumed_keys()
all_keys = set(state["active"]) | set(state["commented"]) | set(consumed)
records = []
for key in sorted(all_keys):
example = state["commented"].get(key)
value = state["active"].get(key)
records.append(
{
"key": key,
"active": key in state["active"],
"value": value,
"example": example,
"declared_in_jsonc": key in state["active"]
or key in state["commented"],
"consumed_by": consumed.get(key, []),
"inferred_type": infer_type(value if value is not None else example),
}
)
return {
"path": state["path"],
"active_count": len(state["active"]),
"commented_count": len(state["commented"]),
"consumed_key_count": len(consumed),
"total_keys": len(records),
"entries": records,
}
def infer_type(value: str | None) -> str:
"""Classify a raw value string the way platformio-custom.py does.
Mirrors the branch order in `bin/platformio-custom.py:222-235`.
"""
if value is None:
return "unknown"
v = value.strip()
if v.startswith("{"):
return "brace" # byte array / enum init list
if v.lstrip("-").replace(".", "", 1).isdigit():
return "number"
if v in ("true", "false"):
return "bool"
if v.startswith("meshtastic_"):
return "enum"
return "string"
# ---------- Writing -------------------------------------------------------
def _format_jsonc_line(key: str, value: str, commented: bool) -> str:
prefix = " // " if commented else " "
# Escape backslashes and quotes inside value the way platformio-custom.py
# expects — the original jsonc uses raw strings for most content. Keep it
# literal; callers are responsible for correct escaping if they pass
# dict/enum-init values that contain quotes.
return f'{prefix}"{key}": "{value}",'
def _render_jsonc(
active: dict[str, str], commented: dict[str, str], order: list[str]
) -> str:
"""Render userPrefs.jsonc preserving source order and comment state."""
seen: set[str] = set()
lines = ["{"]
for key in order:
if key in seen:
continue
seen.add(key)
if key in active:
lines.append(_format_jsonc_line(key, active[key], commented=False))
elif key in commented:
lines.append(_format_jsonc_line(key, commented[key], commented=True))
# Append any newly-added keys (not in original order) at the end, active.
for key, value in active.items():
if key in seen:
continue
seen.add(key)
lines.append(_format_jsonc_line(key, value, commented=False))
# Strip trailing comma on the last data line (valid JSONC allows it, but
# strict `json.loads` after comment-stripping does not; the loader in
# platformio-custom.py uses json.loads).
if len(lines) > 1 and lines[-1].endswith(","):
lines[-1] = lines[-1].rstrip(",")
lines.append("}")
lines.append("") # trailing newline
return "\n".join(lines)
def _validate_after_write(text: str) -> None:
"""Ensure the rendered text still parses the way platformio-custom.py does."""
stripped = "\n".join(_LINE_COMMENT.sub("", line) for line in text.splitlines())
json.loads(stripped) # raises on any error
def write_state(
active: dict[str, str], commented: dict[str, str], order: list[str]
) -> None:
path = jsonc_path()
text = _render_jsonc(active, commented, order)
_validate_after_write(text)
path.write_text(text, encoding="utf-8")
def merge_active(overrides: dict[str, Any]) -> dict[str, Any]:
"""Merge `overrides` into the active set and persist.
Existing active values not in `overrides` are kept. Example/commented
values are preserved. Returns {before_active, after_active, path}.
"""
state = read_state()
before = dict(state["active"])
after = dict(before)
commented = dict(state["commented"])
order = list(state["order"])
for key, raw in overrides.items():
if not key.startswith("USERPREFS_"):
raise ValueError(f"key {key!r} must start with USERPREFS_")
after[key] = _stringify(raw)
# If the key was commented, uncommenting it means removing from commented set.
commented.pop(key, None)
if key not in order:
order.append(key)
write_state(after, commented, order)
return {"before_active": before, "after_active": after, "path": str(jsonc_path())}
def _stringify(value: Any) -> str:
"""Convert a Python value to the string form userPrefs.jsonc expects.
bool → "true" / "false"; int/float → str(); anything else → str(value).
Callers passing brace-init strings (`"{ 0x01, 0x02, ... }"`) must format
them themselves — this function doesn't try to synthesize them.
"""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
return str(value)
def reset() -> dict[str, Any]:
"""Restore userPrefs.jsonc from the MCP backup if present.
Returns {restored: bool, path, backup_path}.
"""
path = jsonc_path()
backup = path.with_suffix(path.suffix + BACKUP_SUFFIX)
if backup.is_file():
shutil.copy2(backup, path)
backup.unlink()
return {"restored": True, "path": str(path), "backup_path": str(backup)}
return {"restored": False, "path": str(path), "backup_path": str(backup)}
# ---------- Transient override (for build/flash) --------------------------
# ---------- Pre-baked profiles --------------------------------------------
def _psk_from_bytes(data: bytes) -> str:
"""Format 32 bytes as a C-style brace-init list for USERPREFS_CHANNEL_*_PSK.
Matches the exact format used in userPrefs.jsonc:
{ 0x38, 0x4b, 0xbc, ... }
"""
if len(data) != 32:
raise ValueError(f"PSK must be exactly 32 bytes, got {len(data)}")
return "{ " + ", ".join(f"0x{b:02x}" for b in data) + " }"
def generate_psk(seed: str | None = None) -> str:
"""Generate a 32-byte PSK as a brace-init string.
If `seed` is provided, the PSK is deterministic (derived via SHA-256 of
the seed); otherwise it's cryptographically random. Use a seed for
automated testing so every device in a test run shares the same key.
"""
if seed is None:
import secrets
raw = secrets.token_bytes(32)
else:
import hashlib
raw = hashlib.sha256(seed.encode("utf-8")).digest()
return _psk_from_bytes(raw)
# Meshtastic region enum name → short description (for the manifest tool).
# Not exhaustive; these are the regions a US-based test lab is likely to pick.
KNOWN_REGIONS = {
"US": "meshtastic_Config_LoRaConfig_RegionCode_US",
"EU_433": "meshtastic_Config_LoRaConfig_RegionCode_EU_433",
"EU_868": "meshtastic_Config_LoRaConfig_RegionCode_EU_868",
"CN": "meshtastic_Config_LoRaConfig_RegionCode_CN",
"JP": "meshtastic_Config_LoRaConfig_RegionCode_JP",
"ANZ": "meshtastic_Config_LoRaConfig_RegionCode_ANZ",
"KR": "meshtastic_Config_LoRaConfig_RegionCode_KR",
"TW": "meshtastic_Config_LoRaConfig_RegionCode_TW",
"RU": "meshtastic_Config_LoRaConfig_RegionCode_RU",
"IN": "meshtastic_Config_LoRaConfig_RegionCode_IN",
"NZ_865": "meshtastic_Config_LoRaConfig_RegionCode_NZ_865",
"TH": "meshtastic_Config_LoRaConfig_RegionCode_TH",
"UA_433": "meshtastic_Config_LoRaConfig_RegionCode_UA_433",
"UA_868": "meshtastic_Config_LoRaConfig_RegionCode_UA_868",
"MY_433": "meshtastic_Config_LoRaConfig_RegionCode_MY_433",
"MY_919": "meshtastic_Config_LoRaConfig_RegionCode_MY_919",
"SG_923": "meshtastic_Config_LoRaConfig_RegionCode_SG_923",
"LORA_24": "meshtastic_Config_LoRaConfig_RegionCode_LORA_24",
}
KNOWN_MODEM_PRESETS = {
"LONG_FAST": "meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST",
"LONG_SLOW": "meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW",
"LONG_MODERATE": "meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE",
"VERY_LONG_SLOW": "meshtastic_Config_LoRaConfig_ModemPreset_VERY_LONG_SLOW",
"MEDIUM_SLOW": "meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW",
"MEDIUM_FAST": "meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST",
"SHORT_SLOW": "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW",
"SHORT_FAST": "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST",
"SHORT_TURBO": "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO",
}
def build_testing_profile(
psk_seed: str | None = None,
channel_name: str = "McpTest",
channel_num: int = 88,
region: str = "US",
modem_preset: str = "LONG_FAST",
short_name: str | None = None,
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.
Defaults: US region, LONG_FAST modem, channel slot 88 (well away from the
default `hash("LongFast") % numChannels` slot that production devices use),
and a private PSK. Devices baked with the same `psk_seed` land on the same
isolated mesh.
See `src/mesh/RadioInterface.cpp:849` for the slot-selection math:
`slot = (channel_num ? channel_num - 1 : hash(name)) % numChannels`.
Setting `channel_num` explicitly (non-zero) forces a deterministic slot.
Args:
psk_seed: seed for deterministic PSK generation. `None` = random (fine
for one-off bakes, useless for multi-device test clusters).
channel_name: primary channel name. Must differ from defaults
("LongFast", "MediumFast", etc.) so production devices don't
accidentally match after the PSK check.
channel_num: 1-indexed LoRa slot (1..numChannels). 88 is mid-upper US
band. Set to 0 to fall back to name-hash (not recommended for
isolation).
region: short code from `KNOWN_REGIONS`.
modem_preset: short code from `KNOWN_MODEM_PRESETS`.
short_name: optional owner short-name stamp (≤4 chars). None = unset.
long_name: optional owner long-name stamp. None = unset.
disable_mqtt: if True (default), disables the MQTT module and the
uplink/downlink bridge on the primary channel — so private test
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:
raise ValueError(
f"Unknown region {region!r}. Known: {sorted(KNOWN_REGIONS.keys())}"
)
if modem_preset not in KNOWN_MODEM_PRESETS:
raise ValueError(
f"Unknown modem_preset {modem_preset!r}. Known: {sorted(KNOWN_MODEM_PRESETS.keys())}"
)
if not (0 <= channel_num <= 255):
raise ValueError(f"channel_num must be 0..255, got {channel_num}")
if len(channel_name) > 11:
raise ValueError(
f"channel_name {channel_name!r} exceeds Meshtastic's 11-char max"
)
if short_name is not None and len(short_name) > 4:
raise ValueError(f"short_name must be ≤4 chars, got {len(short_name)}")
psk = generate_psk(seed=psk_seed)
prefs: dict[str, Any] = {
# --- LoRa ---
"USERPREFS_CONFIG_LORA_REGION": KNOWN_REGIONS[region],
"USERPREFS_LORACONFIG_MODEM_PRESET": KNOWN_MODEM_PRESETS[modem_preset],
"USERPREFS_LORACONFIG_CHANNEL_NUM": channel_num,
# --- Primary channel (isolated from public default) ---
"USERPREFS_CHANNELS_TO_WRITE": 1,
"USERPREFS_CHANNEL_0_NAME": channel_name,
"USERPREFS_CHANNEL_0_PSK": psk,
"USERPREFS_CHANNEL_0_PRECISION": 14,
}
if disable_mqtt:
prefs.update(
{
"USERPREFS_CONFIG_LORA_IGNORE_MQTT": True,
"USERPREFS_MQTT_ENABLED": 0,
"USERPREFS_CHANNEL_0_UPLINK_ENABLED": False,
"USERPREFS_CHANNEL_0_DOWNLINK_ENABLED": False,
}
)
if disable_position:
prefs.update(
{
"USERPREFS_CONFIG_GPS_MODE": "meshtastic_Config_PositionConfig_GpsMode_DISABLED",
"USERPREFS_CONFIG_SMART_POSITION_ENABLED": False,
}
)
if long_name is not None:
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
@contextmanager
def temporary_overrides(overrides: dict[str, Any] | None) -> Iterator[dict[str, str]]:
"""Apply `overrides` to userPrefs.jsonc for the duration of the context.
Yields a dict of the *effective* active values (original active merged
with overrides). Always restores the original file on exit, even on
exception. If `overrides` is None or empty, this is a no-op.
The restore writes the original file content byte-for-byte, so there's no
round-trip ambiguity even if the file had unusual whitespace.
"""
if not overrides:
state = read_state()
yield dict(state["active"])
return
path = jsonc_path()
if not path.is_file():
raise FileNotFoundError(f"userPrefs.jsonc not found at {path}")
original_bytes = path.read_bytes()
original_stat = path.stat()
# Merge and write
state = _parse_jsonc_state(original_bytes.decode("utf-8"))
effective = dict(state["active"])
commented = dict(state["commented"])
order = list(state["order"])
for key, raw in overrides.items():
if not key.startswith("USERPREFS_"):
raise ValueError(f"key {key!r} must start with USERPREFS_")
effective[key] = _stringify(raw)
commented.pop(key, None)
if key not in order:
order.append(key)
rendered = _render_jsonc(effective, commented, order)
_validate_after_write(rendered)
path.write_text(rendered, encoding="utf-8")
# pio watches file mtimes to invalidate build cache; force the modification
# time to now so a pre-existing `.pio/build/<env>/` cache is discarded.
now = time.time()
import os
os.utime(path, (now, now))
try:
yield effective
finally:
path.write_bytes(original_bytes)
os.utime(path, (original_stat.st_atime, original_stat.st_mtime))

116
mcp-server/tests/README.md Normal file
View File

@@ -0,0 +1,116 @@
# Meshtastic MCP Server — Test Harness
Automated test suite for the MCP server, organized around real operator
concerns rather than generic "unit vs hardware".
## Tiers
| Dir | Hardware | Question this tier answers |
| --------------- | ----------------------- | --------------------------------------------------------------------- |
| `unit/` | none | Do the parsing / filtering / profile-generation primitives work? |
| `provisioning/` | 1 device, per-test bake | Did my pre-bake recipe stick? Does it survive a factory reset? |
| `admin/` | 1 device, shared bake | Do my daily admin ops (owner, channel URL, config writes) round-trip? |
| `mesh/` | 2 devices, shared bake | Do my devices actually form a mesh? Send + receive? ACKs? |
| `telemetry/` | 2 devices, shared bake | Is telemetry reporting? Is position broadcast correct? |
| `monitor/` | 1 device, shared bake | Is the boot log clean (no panics)? |
| `fleet/` | varies | Are my CI runs isolated from each other? Are reflashes idempotent? |
## Quick start
```bash
cd mcp-server
pip install -e ".[test]"
# No hardware — 33 unit tests, ~3 seconds
pytest tests/unit -v
# Hub attached (nRF52840 + ESP32-S3) — first run bakes, then exercises everything
pytest tests/ --html=report.html
# Hub already baked with session profile (dev loop) — skip bake
pytest tests/ --assume-baked --html=report.html
# Force a rebake (new firmware, new seed, etc.)
pytest tests/ --force-bake --html=report.html
```
## CLI flags
- `--force-bake` — always reflash both roles at session start, even if the
current state matches the session profile.
- `--assume-baked` — skip `test_00_bake.py` entirely. Use when you know the
devices are already baked and want a fast dev loop.
- `--hub-profile=<yaml>` — point at a YAML file for non-default hub hardware.
Default targets VID `0x239a` (nRF52) and `0x303a`/`0x10c4` (ESP32-S3).
- `--no-teardown-rebake` — skip the session-end rebake that `provisioning/`
and `fleet/` tests perform. Useful in rapid iteration.
## Environment variables
- `MESHTASTIC_FIRMWARE_ROOT` — firmware repo path (defaults to `../` from tests/)
- `MESHTASTIC_MCP_ENV_NRF52` — PlatformIO env for the nRF52 role (default
`rak4631`)
- `MESHTASTIC_MCP_ENV_ESP32S3` — PlatformIO env for the ESP32-S3 role (default
`heltec-v3`)
- `MESHTASTIC_MCP_SEED` — override the session PSK seed (default:
`pytest-<unix-ts>`). Set this to reproduce a specific failing run.
## Fixtures you'll use when adding tests
All defined in `conftest.py`:
- **`hub_devices`** → `{"nrf52": "/dev/cu.X", "esp32s3": "/dev/cu.Y"}`. Auto-
skips the test if a required role isn't present.
- **`test_profile`** → USERPREFS dict for the session (`build_testing_profile`).
- **`no_region_profile`** → variant without `USERPREFS_CONFIG_LORA_REGION`.
- **`baked_mesh`** → verifies both devices are baked with the session profile
(does NOT reflash — that's `test_00_bake.py`'s job).
- **`baked_single`** → single verified baked device; parametrize `request.param`
to pick role.
- **`serial_capture`** → factory; `cap = serial_capture("esp32s3")` starts a
pio device monitor session, drains into a per-test buffer, attaches the
buffer to the pytest-html report on failure.
- **`wait_until`** → exponential-backoff polling helper; `wait_until(lambda:
predicate(), timeout=60)` replaces flaky `time.sleep()` patterns.
## Reports
`pytest --html=report.html` produces a self-contained HTML with:
- Per-test pass/fail/skip with timings
- On failure: serial log capture from any `serial_capture` fixture used
- On failure: `device_info` + lora config JSON for every role on the hub
- Session seed and session start time (for reproducibility)
`pytest --junitxml=junit.xml` produces CI-integration XML.
`tool_coverage.json` is emitted at session end in the tests directory — shows
which of the 38 MCP tools the run exercised. Useful for closing test gaps.
## Adding a new test
1. Pick the category that matches the operator concern (not the technical
surface). "Does my fleet's owner name persist" is `admin/`, not `unit/`.
2. If you need both devices, depend on `baked_mesh`. If you need one, depend
on `baked_single`. If you need to mutate hardware state, put it in
`provisioning/` or `fleet/` and add a `try/finally` teardown that re-bakes
the session profile.
3. Use `wait_until` for anything involving LoRa timing — fixed `sleep()`
produces flakes.
4. Use `serial_capture` when you need to observe firmware log output (e.g.
"did the packet get decoded?").
5. Add a `@pytest.mark.timeout(N)` — mesh tests routinely hit LoRa-airtime
waits; default pytest timeout is infinite.
## Troubleshooting
- **All hardware tests SKIP** → hub not detected. Plug in the USB hub, verify
with `pytest tests/ --collect-only` or `python -c "from meshtastic_mcp import
devices; print(devices.list_devices())"`.
- **`baked_mesh` fails with "devices not baked"** → run `pytest
tests/test_00_bake.py` first, or pass `--force-bake` on the full run.
- **Mesh formation tests time out** → check that both devices are on the same
session profile (`--force-bake` forces both to the current seed).
- **Provisioning tests leave device in bad state** → teardowns re-bake, but
if a test crashes between "bake broken state" and "bake good state", run
`pytest tests/test_00_bake.py --force-bake` to recover.

View File

View File

@@ -0,0 +1,118 @@
"""Role-to-port rediscovery after USB CDC re-enumeration.
Used by tests that mutate device identity in ways macOS treats as a
"new device" — notably ``factory_reset(full=False)`` on the nRF52840 and
any operation that kicks the device through its bootloader. Both cases
cause the kernel to re-assign the ``/dev/cu.usbmodem*`` path; a test that
captured the pre-operation port and reuses it after will fail with
``FileNotFoundError``.
The helper polls :func:`meshtastic_mcp.devices.list_devices` (the same API
``run-tests.sh`` and ``conftest.py::hub_devices`` use for initial hub
detection) filtered by the role's canonical USB VID. Returns the first
matching port — equivalent to "give me the single nRF52 (or ESP32-S3) on
the bench right now, whichever `cu.*` path it happens to be at".
Test-harness-local (not exported from ``meshtastic_mcp``): a thin wrapper
over public ``devices.list_devices`` with no extra moving parts. If a
non-test caller ever needs this, it's trivial to promote.
Caveat: the session-scoped ``hub_devices`` fixture snapshots ports at
session start and is dict-keyed — it doesn't learn about re-enumerations.
Tests that call ``resolve_port_by_role`` should use the returned port
locally for the rest of the test body rather than expecting
``hub_devices[role]`` to update.
"""
from __future__ import annotations
import time
from meshtastic_mcp import devices as devices_module
# Role → canonical VID(s). Kept in sync with:
# - `mcp-server/run-tests.sh` (ROLE_BY_VID)
# - `mcp-server/tests/conftest.py::hub_profile`
# If any of those change, this must too.
_ROLE_VIDS: dict[str, tuple[int, ...]] = {
"nrf52": (0x239A,), # Adafruit / RAK nRF52840 native USB
"esp32s3": (0x303A, 0x10C4), # Espressif native USB + CP2102 USB-UART
}
def _coerce_vid(raw: object) -> int | None:
"""`devices.list_devices` returns vid as either '0x239a' or an int;
normalize to int. None on un-parseable input (matches the same fault-
tolerance `run-tests.sh` uses for its role detection)."""
if raw is None:
return None
if isinstance(raw, int):
return raw
if isinstance(raw, str):
try:
return int(raw, 16) if raw.lower().startswith("0x") else int(raw)
except ValueError:
return None
return None
def resolve_port_by_role(
role: str,
*,
timeout_s: float = 30.0,
poll_start: float = 0.5,
poll_max: float = 5.0,
) -> str:
"""Return the current ``/dev/cu.*`` path for ``role`` once one appears.
Polls ``devices.list_devices(include_unknown=True)`` every ``poll_start``
seconds (1.5× backoff, capped at ``poll_max``) until a device matching
``role``'s VID appears. Returns the first matching port.
On timeout raises :class:`AssertionError` with the list of devices that
WERE seen — helpful when debugging "wrong board connected" vs. "no
board connected" vs. "still re-enumerating".
Args:
role: ``"nrf52"`` or ``"esp32s3"`` (keys of ``_ROLE_VIDS``).
timeout_s: upper bound on how long to wait for the device to
re-appear. Default 30 s — nRF52 factory_reset observed at
2-12 s on a healthy lab hub.
poll_start: initial poll interval in seconds. Default 0.5 s.
poll_max: cap on poll interval after backoff. Default 5 s.
Raises:
AssertionError: if no matching device appears within ``timeout_s``.
ValueError: if ``role`` is not in ``_ROLE_VIDS``.
"""
if role not in _ROLE_VIDS:
raise ValueError(f"unknown role {role!r}; expected one of {sorted(_ROLE_VIDS)}")
wanted_vids = _ROLE_VIDS[role]
deadline = time.monotonic() + timeout_s
delay = poll_start
last_seen: list[dict] = []
while time.monotonic() < deadline:
try:
last_seen = devices_module.list_devices(include_unknown=True)
except Exception as exc:
# list_devices is wrapped by meshtastic_mcp.devices and
# shouldn't raise on normal enumeration — but a kernel-level
# USB hiccup during re-enumeration can bubble up briefly.
# Treat as "nothing seen this round" and retry.
last_seen = [{"error": repr(exc)}]
for dev in last_seen:
vid = _coerce_vid(dev.get("vid"))
if vid is not None and vid in wanted_vids and dev.get("port"):
return dev["port"]
time.sleep(delay)
delay = min(delay * 1.5, poll_max)
# Timeout path — include what we saw so the operator can tell
# "nothing plugged in" from "wrong VID" from "transient USB error".
raise AssertionError(
f"no device matching role {role!r} (VIDs "
f"{[hex(v) for v in wanted_vids]}) appeared within {timeout_s:.0f}s. "
f"Last enumeration: {last_seen!r}"
)

112
mcp-server/tests/_power.py Normal file
View File

@@ -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",
]

View File

View File

@@ -0,0 +1,57 @@
"""Admin: channel URL export and re-import round-trip.
Real operator workflow: "I have two fleets, I want them to share a channel
config. Export URL from fleet A's bootstrap device, paste into fleet B's
onboarding tool, expect identical channels." Proves `getURL` + `setURL`
round-trip without data loss.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin, info
@pytest.mark.timeout(60)
def test_channel_url_roundtrip(
baked_single: dict[str, Any],
test_profile: dict[str, Any],
) -> None:
"""Runs once per connected role. Verify:
1. `get_channel_url()` on a baked device returns a non-empty URL.
2. The URL parses — `set_channel_url(url)` accepts it without error.
3. After set, `get_channel_url()` returns the same (canonicalized) URL.
4. Primary channel name survives round-trip.
"""
port = baked_single["port"]
url_before = admin.get_channel_url(include_all=False, port=port)["url"]
assert url_before, "device returned empty channel URL"
assert (
"meshtastic" in url_before.lower() or "#" in url_before
), f"URL does not look like a Meshtastic channel URL: {url_before!r}"
# Re-apply the same URL — no-op in content but exercises the setURL path.
applied = admin.set_channel_url(url=url_before, port=port)
assert applied["ok"] is True
assert applied["channels_imported"] >= 1
time.sleep(2.0)
# Confirm the primary channel name survived
live = info.device_info(port=port, timeout_s=8.0)
assert live["primary_channel"] == test_profile["USERPREFS_CHANNEL_0_NAME"]
url_after = admin.get_channel_url(include_all=False, port=port)["url"]
# Canonicalization is tricky: the firmware may re-serialize the protobuf
# with fields in a different order, producing a visually-different URL
# that encodes the same content. Accept that as a success when the
# primary channel name survived the round-trip (already asserted above)
# and the URL is still a parseable Meshtastic URL. Bit-equality is a
# nice-to-have, not a correctness guarantee.
assert url_after, "URL went blank after setURL"
assert (
"meshtastic" in url_after.lower() or "#" in url_after
), f"URL after setURL no longer looks like a channel URL: {url_after!r}"

View File

@@ -0,0 +1,106 @@
"""Admin: a config mutation survives a reboot.
This is the most-critical admin behavior not tested elsewhere. If
config persistence breaks in a firmware release, every deployed device
gets bricked on its next reboot (channels lost, region lost, owner lost,
everything back to Meshtastic stock). The fleet blast radius is "every
unit on every shelf" — easily worth one explicit test per release.
Pattern: single-device (``baked_single``, one test per role). Mutate a
benign, easy-to-observe LoRa field (``lora.hop_limit``), confirm
pre-reboot, reboot, rediscover port (nRF52 may re-enumerate), verify
the value survived, restore original for downstream tests.
Why ``lora.hop_limit`` specifically:
* Non-destructive — doesn't change region, channel, or PSK, so
downstream mesh tests still work regardless of the flipped value.
* Bounded small-integer (1..7) — easy to flip to a definitively
different value and read back.
* Persisted via ``writeConfig("lora")`` which is the same path
every other LoRa config mutation uses, so we're really testing
the whole lora-config persistence pipeline end-to-end.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin, info
from .._port_discovery import resolve_port_by_role
def _get_hop_limit(port: str) -> int:
"""Read `lora.hop_limit` from the device's current config."""
lora = admin.get_config("lora", port=port).get("config", {}).get("lora", {})
hl = lora.get("hop_limit")
assert isinstance(hl, int), (
f"lora.hop_limit missing or non-int in get_config response: " f"{lora!r}"
)
return hl
@pytest.mark.timeout(180)
def test_lora_hop_limit_survives_reboot(
baked_single: dict[str, Any],
wait_until,
) -> None:
"""Runs once per connected role. Mutates `lora.hop_limit`, reboots,
verifies the new value is still there after the device comes back.
"""
role = baked_single["role"]
port = baked_single["port"]
original = _get_hop_limit(port)
# Flip to a definitively different value within the protocol's
# valid range (1..7 per LoRaConfig.hop_limit comment). Pick 5 if
# current is != 5, else 4.
new_value = 5 if original != 5 else 4
try:
admin.set_config("lora.hop_limit", new_value, port=port)
# Pre-reboot sanity: the write reached the device and
# get_config reflects it in-memory. If this fails, the persist
# test below is moot — something's wrong with the write path
# itself, not with persistence.
assert _get_hop_limit(port) == new_value, (
f"pre-reboot readback failed: set {new_value}, got "
f"{_get_hop_limit(port)}"
)
# Reboot. `seconds=3` gives the Python client time to
# disconnect cleanly; sleep long enough for the boot to start
# before we begin polling.
admin.reboot(port=port, confirm=True, seconds=3)
time.sleep(8.0)
# nRF52 re-enumerates on reboot → rediscover.
port = resolve_port_by_role(role, timeout_s=60.0)
wait_until(
lambda: info.device_info(port=port, timeout_s=5.0).get("my_node_num")
is not None,
timeout=60,
backoff_start=1.0,
)
# The assertion this test exists for: the mutation persisted
# across the reboot cycle through NVS / LittleFS / UICR.
post = _get_hop_limit(port)
assert post == new_value, (
f"lora.hop_limit did not survive reboot: set to {new_value} "
f"pre-reboot, read back {post} post-reboot. Config persistence "
f"is broken — downstream fleet impact would be total."
)
finally:
# Restore so downstream tests see the original hop_limit.
# Wrapped in its own try to avoid masking the real assertion
# if the restore itself races the reboot — the worst case
# there is a non-default hop_limit sticks around, which is
# benign (mesh still works at hop_limit 3 or 5).
try:
admin.set_config("lora.hop_limit", original, port=port)
except Exception:
pass

View File

@@ -0,0 +1,59 @@
"""Admin: owner name persists across a reboot.
The single most common "did my admin change stick?" test. Proves
`localNode.setOwner()` + `writeConfig("device")` commits to non-volatile
storage before the reboot.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin, info
@pytest.mark.timeout(120)
def test_owner_survives_reboot(
baked_single: dict[str, Any],
wait_until,
) -> None:
"""Runs once per connected role — proves the reboot-persistence
round-trip works on each device independently, not just one."""
port = baked_single["port"]
pre = info.device_info(port=port, timeout_s=8.0)
original = pre.get("long_name") or ""
marker = "RebootSurvive"
try:
admin.set_owner(long_name=marker, short_name="RS", port=port)
time.sleep(1.5)
# Confirm pre-reboot
confirmed = info.device_info(port=port, timeout_s=8.0)
assert confirmed["long_name"] == marker
# Reboot (short delay)
admin.reboot(port=port, confirm=True, seconds=3)
# Wait for device to come back
time.sleep(8.0)
wait_until(
lambda: info.device_info(port=port, timeout_s=5.0).get("my_node_num")
is not None,
timeout=60,
backoff_start=1.0,
)
post = info.device_info(port=port, timeout_s=8.0)
assert post["long_name"] == marker, (
f"owner name did not persist across reboot: "
f"expected {marker!r}, got {post['long_name']!r}"
)
finally:
# Restore original (best-effort)
try:
admin.set_owner(long_name=original or "TestNode", port=port)
except Exception:
pass

1172
mcp-server/tests/conftest.py Normal file
View File

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,43 @@
"""Fleet: different session seeds produce non-overlapping PSKs.
No hardware needed — this is a pure property check on the test profile
generator, elevated into the `fleet/` tier because it's the critical
invariant for running concurrent CI labs without cross-contamination.
"""
from __future__ import annotations
from meshtastic_mcp import userprefs
def test_psk_seed_isolates_runs() -> None:
"""Two labs running simultaneously with different seeds must end up with
different PSKs — which means firmware baked in lab A cannot decode lab B's
traffic, and vice versa.
This is the formal statement of the isolation claim that
`testing_profile` promises operators.
"""
lab_a_morning = userprefs.build_testing_profile(psk_seed="lab-a-2026-04-16-morning")
lab_a_evening = userprefs.build_testing_profile(psk_seed="lab-a-2026-04-16-evening")
lab_b_morning = userprefs.build_testing_profile(psk_seed="lab-b-2026-04-16-morning")
# Same lab, same date, different time-of-day → different PSKs
assert (
lab_a_morning["USERPREFS_CHANNEL_0_PSK"]
!= lab_a_evening["USERPREFS_CHANNEL_0_PSK"]
)
# Different labs, same time-of-day → different PSKs
assert (
lab_a_morning["USERPREFS_CHANNEL_0_PSK"]
!= lab_b_morning["USERPREFS_CHANNEL_0_PSK"]
)
# Re-deriving with the same seed yields the same PSK (reproducibility)
lab_a_morning_again = userprefs.build_testing_profile(
psk_seed="lab-a-2026-04-16-morning"
)
assert (
lab_a_morning["USERPREFS_CHANNEL_0_PSK"]
== lab_a_morning_again["USERPREFS_CHANNEL_0_PSK"]
)

View File

View File

@@ -0,0 +1,220 @@
"""Shared helper for mesh receive tests.
`pio device monitor` captures firmware log output, which does NOT include
decoded text message contents or telemetry payloads — those are only
accessible through `meshtastic.SerialInterface`'s pubsub mechanism.
`ReceiveCollector` opens a long-lived SerialInterface on a port, subscribes
to the pubsub topic of interest, and exposes an atomic `wait_for(predicate)`
that mesh tests use to verify end-to-end delivery.
This module also exposes two module-level helpers for forcing a device to
broadcast a fresh NodeInfo — the on-demand path that sidesteps the
firmware's 10-minute NodeInfo rate-limit. Tests doing directed PKI-encrypted
sends need BOTH endpoints to hold current pubkeys for each other:
nudge_nodeinfo(iface) # nudge an already-open SerialInterface
nudge_nodeinfo_port(port) # open briefly, nudge, close
See `ReceiveCollector.broadcast_nodeinfo_ping` for the firmware-side
rationale (PKI staleness → directed sends NAK with Routing.Error=35
PKI_UNKNOWN_PUBKEY or 39 PKI_SEND_FAIL_PUBLIC_KEY).
"""
from __future__ import annotations
import threading
import time
from typing import Any, Callable
def nudge_nodeinfo(iface: Any) -> None:
"""Force the device behind ``iface`` to broadcast a fresh NodeInfo.
Sends a ``ToRadio.Heartbeat(nonce=1)`` — the firmware's documented
on-demand NodeInfo trigger (see `src/mesh/api/PacketAPI.cpp:74-79`
for TCP/UDP and `src/mesh/PhoneAPI.cpp::handleToRadio` for serial,
both routed to `NodeInfoModule::sendOurNodeInfo(..., shorterTimeout=true)`
with the 60-s window rather than the 10-min rate-limit).
Call on BOTH TX and RX ifaces before a directed PKI-encrypted send.
Nudging only one side leaves the other with a stale pubkey cache and
makes the directed send NAK with PKI_UNKNOWN_PUBKEY.
"""
from meshtastic.protobuf import mesh_pb2 # type: ignore[import-untyped]
tr = mesh_pb2.ToRadio()
tr.heartbeat.nonce = 1
iface._sendToRadio(tr)
def nudge_nodeinfo_port(port: str) -> None:
"""Open ``port`` briefly, nudge, close — for when no iface is open yet.
Uses the meshtastic_mcp port-lock-aware `connect()` context manager
so we don't race ReceiveCollector or other long-lived handles on
the same port.
"""
from meshtastic_mcp.connection import connect
with connect(port=port) as iface:
nudge_nodeinfo(iface)
class ReceiveCollector:
"""Listen for meshtastic packets on `port` and let tests wait for a match.
Must be used as a context manager so the underlying SerialInterface is
always closed (leaked interfaces hold the CDC port open and break
subsequent tool calls).
Usage:
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
# ... send from TX ...
assert rx.wait_for(
lambda pkt: pkt.get("decoded", {}).get("text") == unique,
timeout=60,
), f"packet not received; got {rx.snapshot()!r}"
"""
def __init__(
self,
port: str,
topic: str = "meshtastic.receive",
capture_logs: bool = False,
) -> None:
self._port = port
self._topic = topic
self._capture_logs = capture_logs
self._packets: list[dict[str, Any]] = []
self._log_lines: list[str] = []
self._lock = threading.Lock()
self._iface = None
self._handler_ref = None # keep strong ref so pubsub doesn't GC it
self._log_handler_ref = None
def __enter__(self) -> "ReceiveCollector":
from meshtastic.serial_interface import (
SerialInterface, # type: ignore[import-untyped]
)
from pubsub import pub # type: ignore[import-untyped]
# pubsub uses weak refs by default — we stash a strong ref so the
# handler doesn't disappear between subscribe and wait_for.
def handler(packet: dict, interface: Any) -> None:
with self._lock:
self._packets.append(packet)
self._handler_ref = handler
pub.subscribe(handler, self._topic)
# Firmware-side logs come through the SAME SerialInterface when
# `config.security.debug_log_api_enabled = True`. Subscribing here
# captures them for failure-artifact attachment without needing a
# separate pio monitor session that would fight our port lock.
if self._capture_logs:
def log_handler(line: str, interface: Any) -> None:
with self._lock:
self._log_lines.append(line)
self._log_handler_ref = log_handler
pub.subscribe(log_handler, "meshtastic.log.line")
self._iface = SerialInterface(devPath=self._port, connectNow=True)
# Let the config bootstrap complete so we don't miss early arrivals.
time.sleep(1.0)
return self
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
from pubsub import pub # type: ignore[import-untyped]
if self._handler_ref is not None:
try:
pub.unsubscribe(self._handler_ref, self._topic)
except Exception:
pass
if self._log_handler_ref is not None:
try:
pub.unsubscribe(self._log_handler_ref, "meshtastic.log.line")
except Exception:
pass
if self._iface is not None:
try:
self._iface.close()
except Exception:
pass
def snapshot(self) -> list[dict[str, Any]]:
"""Return a thread-safe copy of the list of collected packets."""
with self._lock:
return list(self._packets)
def log_snapshot(self) -> list[str]:
"""Return captured firmware log lines.
Only populated if `capture_logs=True` AND the device has
`security.debug_log_api_enabled=True`.
"""
with self._lock:
return list(self._log_lines)
def send_text(
self,
text: str,
destination_id: Any = "^all",
want_ack: bool = False,
channel_index: int = 0,
) -> Any:
"""Send a text packet through the already-open SerialInterface.
Use this when a test also has a ReceiveCollector open on the same port
— `admin.send_text(port=...)` would try to open a second SerialInterface
and fail the port lock.
"""
if self._iface is None:
raise RuntimeError("ReceiveCollector not started; use as context manager")
return self._iface.sendText(
text,
destinationId=destination_id,
wantAck=want_ack,
channelIndex=channel_index,
)
def broadcast_nodeinfo_ping(self) -> None:
"""Force the firmware on `port` to broadcast a fresh NodeInfo.
Thin wrapper around the module-level :func:`nudge_nodeinfo` that
also validates the context-manager invariant. Delegates so tests
that need to nudge BOTH sides (bilateral PKI warmup) share one
implementation — the caller just passes each iface in turn.
Firmware-side details (rate-limit bypass, nonce==1 trigger path,
shorterTimeout=true window) are documented on the module-level
helper.
"""
if self._iface is None:
raise RuntimeError("ReceiveCollector not started; use as context manager")
nudge_nodeinfo(self._iface)
def wait_for(
self,
predicate: Callable[[dict[str, Any]], bool],
timeout: float = 60.0,
poll_interval: float = 0.5,
) -> dict[str, Any] | None:
"""Block until a received packet matches `predicate` or timeout.
Returns the matching packet (truthy) or None (falsy).
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with self._lock:
for pkt in self._packets:
try:
if predicate(pkt):
return pkt
except Exception:
continue
time.sleep(poll_interval)
return None

View File

@@ -0,0 +1,83 @@
"""Mesh: explicit two-way communication, single pass/fail.
Opens a ReceiveCollector on EVERY role, sends a uniquely-tagged broadcast
from each role in turn, and asserts every OTHER role saw it. One atomic
test that answers "is the mesh actually working both directions?".
Not parametrized — it inherently involves the full hub.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from ._receive import ReceiveCollector
@pytest.mark.timeout(300)
def test_bidirectional_mesh_communication(
baked_mesh: dict[str, Any],
) -> None:
"""Requires ≥2 baked roles.
For each role, broadcast a unique tag. Assert every other role's
ReceiveCollector saw that tag within a 120s window per direction.
"""
roles = sorted(baked_mesh.keys())
if len(roles) < 2:
pytest.skip(f"need ≥2 roles; have {roles!r}")
# Open receive collectors on every role BEFORE sending anything.
collectors: dict[str, ReceiveCollector] = {}
try:
for role in roles:
rx = ReceiveCollector(
baked_mesh[role]["port"], topic="meshtastic.receive.text"
)
rx.__enter__()
collectors[role] = rx
# Let the meshtastic interfaces stabilize before the first send
time.sleep(2.0)
# From each role, send a uniquely-tagged broadcast. We MUST send through
# the already-open collector — opening a new SerialInterface here would
# race the collector's exclusive lock on the port.
tags: dict[str, str] = {}
for sender in roles:
tag = f"bidi-{sender}-{int(time.time() * 1000) % 100_000}"
tags[sender] = tag
collectors[sender].send_text(tag)
# Small gap so airtime doesn't overlap
time.sleep(4.0)
# Every OTHER role must see every sender's tag within 120s each
missing: list[str] = []
for sender, tag in tags.items():
for receiver in roles:
if receiver == sender:
continue
got = collectors[receiver].wait_for(
lambda pkt, t=tag: pkt.get("decoded", {}).get("text") == t,
timeout=120,
)
if got is None:
observed = [
p.get("decoded", {}).get("text")
for p in collectors[receiver].snapshot()
]
missing.append(
f"{sender}->{receiver}: tag {tag!r} not seen; "
f"receiver got {observed!r}"
)
assert not missing, "bidirectional comms incomplete:\n " + "\n ".join(missing)
finally:
for rx in collectors.values():
try:
rx.__exit__(None, None, None)
except Exception:
pass

View File

@@ -0,0 +1,45 @@
"""Mesh: broadcast text from TX arrives at RX.
Uses `meshtastic.SerialInterface` pubsub on RX to detect the decoded text
packet — `pio device monitor` output doesn't include message bodies.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin
from ._receive import ReceiveCollector
@pytest.mark.timeout(180)
def test_broadcast_delivers(
mesh_pair: dict[str, Any],
) -> None:
"""Runs for every directed role pair. TX sends a unique broadcast text;
RX must receive the decoded text via the meshtastic pubsub receive topic
within 120s.
"""
tx_port = mesh_pair["tx"]["port"]
rx_port = mesh_pair["rx"]["port"]
tx_role = mesh_pair["tx_role"]
rx_role = mesh_pair["rx_role"]
unique = f"mcp-{tx_role}-to-{rx_role}-{int(time.time())}"
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
admin.send_text(text=unique, port=tx_port)
got = rx.wait_for(
lambda pkt: pkt.get("decoded", {}).get("text") == unique,
timeout=120,
)
assert got is not None, (
f"broadcast {unique!r} from {tx_role} not received at {rx_role} within 120s. "
f"RX saw {len(rx.snapshot())} text packet(s): "
f"{[p.get('decoded', {}).get('text') for p in rx.snapshot()]!r}"
)

View File

@@ -0,0 +1,105 @@
"""Mesh: direct text addressed to RX's node_num arrives at RX.
Uses the same pubsub receive pattern as `test_broadcast_delivers`, but sends
with `destinationId=<rx_node_num>` and `wantAck=True`. The assertion is that
the RX firmware accepted and decoded the text; the ACK is handled by the
firmware transparently (and fires automatically when wantAck is set + the
destination is the local node).
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
from ._receive import ReceiveCollector, nudge_nodeinfo
@pytest.mark.timeout(240)
def test_direct_with_ack_roundtrip(
mesh_pair: dict[str, Any],
) -> None:
"""Runs for every directed pair. Addressed send from TX to RX's node_num
with want_ack=True; RX must receive the decoded text via pubsub.
Why this proves ACK: setting want_ack on a directed send causes the
firmware to retry until an ACK is received. If RX's decoded.text fires
once, both the outbound text AND the inbound ACK happened.
"""
tx_port = mesh_pair["tx"]["port"]
rx_port = mesh_pair["rx"]["port"]
rx_node_num = mesh_pair["rx"]["my_node_num"]
tx_role = mesh_pair["tx_role"]
rx_role = mesh_pair["rx_role"]
assert rx_node_num is not None, f"{rx_role} my_node_num missing"
unique = f"mcp-ack-{tx_role}-to-{rx_role}-{int(time.time())}"
# TX iface stays open across the RX wait — sendText+wantAck relies on
# the firmware's retransmit loop, which races the SerialInterface close.
# Bilateral NodeInfo nudge: directed packets are PKI-encrypted, so BOTH
# sides need current pubkeys (err=35/39 otherwise). See
# `tests/mesh/_receive.py::nudge_nodeinfo` for the heartbeat-nonce=1
# firmware path.
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
rx.broadcast_nodeinfo_ping()
with connect(port=tx_port) as tx_iface:
nudge_nodeinfo(tx_iface)
pk_deadline = time.monotonic() + 45.0
last_nudge = time.monotonic()
last_rec: dict[str, Any] = {}
while time.monotonic() < pk_deadline:
last_rec = (tx_iface.nodesByNum or {}).get(rx_node_num, {})
user = last_rec.get("user", {})
if user.get("publicKey"):
break
# Re-nudge both sides every 15 s in case a broadcast was
# lost to a LoRa collision.
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.fail(
f"TX ({tx_role}) never saw RX ({rx_role}) public key "
f"within 45s; nodesByNum entry={last_rec!r}"
)
# Retry covers LoRa collisions. Re-nudge both sides between
# attempts — if RX's cached TX pubkey is stale, just re-sending
# the text doesn't heal it; re-broadcasting NodeInfo does.
got = None
for _attempt in range(2):
packet = tx_iface.sendText(
unique,
destinationId=rx_node_num,
wantAck=True,
)
assert packet is not None, "sendText returned None"
got = rx.wait_for(
lambda pkt: pkt.get("decoded", {}).get("text") == unique,
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"directed send {unique!r} from {tx_role} to {rx_role} "
f"(node_num 0x{rx_node_num:08x}) not received within 120s. "
f"RX saw {len(rx.snapshot())} text packet(s): "
f"{[p.get('decoded', {}).get('text') for p in rx.snapshot()]!r}"
)
# Additional: confirm the destination matches (not leaked broadcast)
assert got.get("to") == rx_node_num, (
f"received packet destination mismatch: to={got.get('to')}, "
f"expected 0x{rx_node_num:08x}"
)

View File

@@ -0,0 +1,39 @@
"""Mesh: two devices baked with the same session profile discover each other.
The fundamental "does my mesh work" test. If both devices share a PSK, LoRa
region, modem preset, and channel slot, they should hear each other's
NodeInfo packets within ~60s of boot and appear in each other's `nodesByNum`
DB.
"""
from __future__ import annotations
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
@pytest.mark.timeout(180)
def test_mesh_formation_within_60s(mesh_pair: dict[str, Any], wait_until) -> None:
"""Runs for every directed role pair — so we prove `A sees B in its node
DB` AND `B sees A in its node DB` independently. A one-sided pass can
mask a real problem (e.g. device A's RX works but its TX is dead).
"""
observer_port = mesh_pair["tx"]["port"]
target_node_num = mesh_pair["rx"]["my_node_num"]
assert (
target_node_num is not None
), f"{mesh_pair['rx']['role']} my_node_num not populated"
def target_visible_from_observer() -> bool:
with connect(port=observer_port) as iface:
nodes = iface.nodesByNum or {}
return target_node_num in nodes
wait_until(
target_visible_from_observer,
timeout=120,
backoff_start=2.0,
backoff_max=10.0,
)

View File

@@ -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"
)

View File

@@ -0,0 +1,147 @@
"""Mesh: traceroute from TX to RX round-trips with no intermediate hops.
TX sends a `TRACEROUTE_APP` request (RouteDiscovery with `want_response=True`)
addressed to RX's node_num. RX's firmware (`modules/TraceRouteModule.cpp`)
replies with a RouteDiscovery payload whose `route` / `route_back` lists
contain any intermediate relays and `snr_towards` / `snr_back` carry per-hop
SNRs. In a 2-device direct mesh there are no relays between TX and RX, so
both route lists must be empty and each SNR list carries exactly one entry
for the direct TX↔RX link.
Validates the full TRACEROUTE_APP portnum round-trip: request encoding, RX
firmware dispatch, RouteDiscovery payload construction, wire response, and
client-side decode through `meshtastic.__init__.py::protocols[TRACEROUTE_APP]`
(which is what publishes the `meshtastic.receive.traceroute` pubsub topic).
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic.mesh_interface import MeshInterface
from ._receive import ReceiveCollector, nudge_nodeinfo_port
@pytest.mark.timeout(240)
def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
"""Runs for every directed pair. Asserts TX sends + RX responds, then
inspects the captured RouteDiscovery to confirm the path is direct.
Why the listener is on TX (not RX):
The traceroute RESPONSE is addressed to TX (the original requester).
The meshtastic Python client publishes `meshtastic.receive.traceroute`
on the interface that received that response — which is TX's iface.
A listener on RX would only see the inbound REQUEST, which lacks
the SNR-towards / SNR-back fields the firmware only fills on reply.
Why we ping RX's NodeInfo before sending:
Traceroute requests are directed sends (wantResponse=True, specific
destinationId) — subject to the same PKI_SEND_FAIL_PUBLIC_KEY trap
as `test_direct_with_ack`. We open RX briefly to trigger the
on-demand NodeInfo broadcast, then wait for TX's nodesByNum to
populate RX's publicKey before calling sendTraceRoute.
"""
tx_port = mesh_pair["tx"]["port"]
rx_port = mesh_pair["rx"]["port"]
rx_node_num = mesh_pair["rx"]["my_node_num"]
tx_role = mesh_pair["tx_role"]
rx_role = mesh_pair["rx_role"]
assert rx_node_num is not None, f"{rx_role} my_node_num missing"
with ReceiveCollector(
tx_port, topic="meshtastic.receive.traceroute"
) as tx_listener:
# Bilateral PKI warmup — traceroute requests are directed and
# PKI-encrypted, so both sides need current pubkeys. See
# `_receive.py::nudge_nodeinfo` and the test_direct_with_ack
# comment for the full rationale (one-sided nudge lets err=35
# PKI_UNKNOWN_PUBKEY slip through in whichever direction had
# stale RX-side cache).
nudge_nodeinfo_port(rx_port) # RX via brief side-connection
tx_listener.broadcast_nodeinfo_ping() # TX via already-open iface
# Poll TX's view of RX until the publicKey propagates. 45 s matches
# the cap used in `test_direct_with_ack`; the re-nudge at 15 s
# covers a LoRa collision on the first NodeInfo broadcast.
pk_deadline = time.monotonic() + 45.0
last_nudge = time.monotonic()
last_rec: dict[str, Any] = {}
while time.monotonic() < pk_deadline:
last_rec = (tx_listener._iface.nodesByNum or {}).get(rx_node_num, {})
if last_rec.get("user", {}).get("publicKey"):
break
if time.monotonic() - last_nudge > 15.0:
nudge_nodeinfo_port(rx_port)
tx_listener.broadcast_nodeinfo_ping()
last_nudge = time.monotonic()
time.sleep(1.0)
else:
pytest.fail(
f"TX ({tx_role}) never saw RX ({rx_role}) public key within "
f"45s; nodesByNum entry={last_rec!r}"
)
# sendTraceRoute blocks internally on `waitForTraceRoute` and raises
# `MeshInterface.MeshInterfaceError` on timeout. One retry covers a
# transient LoRa collision on either the request or the reply.
ok = False
for _attempt in range(2):
try:
tx_listener._iface.sendTraceRoute(
dest=rx_node_num,
hopLimit=3,
)
ok = True
break
except MeshInterface.MeshInterfaceError:
time.sleep(5.0)
assert ok, (
f"sendTraceRoute {tx_role}{rx_role} timed out twice; the mesh "
f"may be saturated or RX's TraceRouteModule is misrouting the "
f"reply"
)
# sendTraceRoute already waited for the response internally, but
# pubsub dispatch runs on the meshtastic-python reader thread —
# give it a short grace window to queue the packet.
packet = tx_listener.wait_for(
lambda p: p.get("from") == rx_node_num,
timeout=5.0,
)
assert packet is not None, (
f"sendTraceRoute returned OK but no `receive.traceroute` packet "
f"from RX (0x{rx_node_num:08x}) arrived via pubsub. Captured: "
f"{tx_listener.snapshot()!r}"
)
# Inspect the decoded RouteDiscovery. The meshtastic client stores
# the parsed protobuf (as a plain dict via MessageToDict) under
# `decoded.traceroute` for this portnum; keys are camelCase because
# protobuf JSON conversion uses `preserving_proto_field_name=False`
# by default.
decoded = packet.get("decoded", {})
route_info = decoded.get("traceroute") or {}
forward_hops = route_info.get("route") or []
back_hops = route_info.get("routeBack") or []
snr_towards = route_info.get("snrTowards") or []
assert forward_hops == [], (
f"traceroute forward `route` should be empty on a 2-device direct "
f"mesh (no intermediaries between {tx_role} and {rx_role}); got "
f"{forward_hops!r}"
)
assert back_hops == [], (
f"traceroute `routeBack` should be empty on a 2-device direct "
f"mesh; got {back_hops!r}"
)
# `snr_towards` has len(route) + 1 entries — one per hop plus a final
# entry for the destination's receive SNR. Direct mesh → len(route)
# is 0 → exactly 1 SNR entry.
assert len(snr_towards) == 1, (
f"traceroute `snrTowards` should carry exactly 1 entry (direct "
f"link SNR) on a 2-device mesh; got {snr_towards!r}"
)

View File

View File

@@ -0,0 +1,63 @@
"""Monitor: boot log is clean — no panic markers in the first 60 seconds.
This is the single highest-signal test for catching firmware regressions.
If a commit broke something critical at boot (stack overflow, NULL deref, HAL
misconfig), this test fails within a minute of reboot.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin
# Substrings that indicate a panic/assert/crash. Case-insensitive.
_PANIC_MARKERS = [
"guru meditation",
"corrupt heap",
"abort()",
"assertion failed",
"***", # ESP-IDF "*** something" panic prefix
"panic",
"stack overflow",
"load prohibited",
"store prohibited",
"illegalinstr",
"watchdog got triggered",
]
@pytest.mark.timeout(180)
def test_boot_log_no_panic(
baked_single: dict[str, Any],
serial_capture,
role_env,
wait_until,
) -> None:
"""Runs once per connected role — each device must boot cleanly,
independently. A panic on one role shouldn't mask another."""
role = baked_single["role"]
port = baked_single["port"]
env = role_env(role)
# Start monitor BEFORE reboot so we catch the reset banner + early boot
cap = serial_capture(role, env=env)
time.sleep(1.0)
# Trigger reboot
admin.reboot(port=port, confirm=True, seconds=3)
# Wait through the reboot+boot window
time.sleep(60.0)
lines = cap.snapshot(max_lines=4000)
assert lines, "serial capture returned no log lines — monitor may have failed"
blob = "\n".join(lines).lower()
hits = [marker for marker in _PANIC_MARKERS if marker in blob]
assert (
not hits
), f"panic markers in boot log: {hits!r}\n\n" f"last 60 lines:\n" + "\n".join(
lines[-60:]
)

View File

View File

@@ -0,0 +1,83 @@
"""Provisioning: baked admin keys end up in the device's security config.
Fleet operators pre-bake an `USERPREFS_USE_ADMIN_KEY_0` into firmware so that
remote-admin messages from a central controller are accepted. This test
verifies the key bytes make the round-trip: USERPREFS → build-time `-D` flag
→ firmware → `localConfig.security.admin_key`.
"""
from __future__ import annotations
import os
from typing import Any
import pytest
from meshtastic_mcp import admin, flash
# Deterministic 32-byte "admin key" — just the byte values 0..31 for easy
# recognition in the output, formatted as a C brace-init.
_ADMIN_KEY_BYTES = list(range(32))
_ADMIN_KEY_BRACE = "{ " + ", ".join(f"0x{b:02x}" for b in _ADMIN_KEY_BYTES) + " }"
@pytest.mark.skip(
reason="test uses flash.erase_and_flash which shells to bin/device-install.sh "
"which needs mt-esp32s3-ota.bin (not in repo). TODO: switch to "
"esptool_erase_flash + flash.flash() like test_00_bake."
)
@pytest.mark.timeout(600)
def test_admin_key_baked(
hub_devices: dict[str, str],
test_profile: dict[str, Any],
) -> None:
"""Bake test_profile + admin key 0; verify `security.admin_key` contains
the baked bytes after boot. Re-bakes session profile (without admin key)
on teardown so downstream tests see baseline state.
"""
target = "esp32s3"
if target not in hub_devices:
pytest.skip(f"role {target!r} not on hub")
port = hub_devices[target]
env = os.environ.get("MESHTASTIC_MCP_ENV_ESP32S3", "t-beam-1w")
augmented = dict(test_profile)
augmented["USERPREFS_USE_ADMIN_KEY_0"] = _ADMIN_KEY_BRACE
try:
result = flash.erase_and_flash(
env=env,
port=port,
confirm=True,
userprefs_overrides=augmented,
)
assert result["exit_code"] == 0
security = admin.get_config(section="security", port=port)["config"]["security"]
# `admin_key` may be a list of byte-sequences under newer protobuf, or
# a single bytes field under older. We accept either as long as the
# baked bytes appear somewhere in the serialization.
key_field = security.get("admin_key")
import base64
import json
serialized = json.dumps(security)
# Protobuf→JSON typically base64-encodes bytes fields. Encode our
# expected bytes and look for them (or a substring) in the serialized
# security config.
b64 = base64.b64encode(bytes(_ADMIN_KEY_BYTES)).decode("ascii").rstrip("=")
assert (
b64[:40] in serialized or "admin_key" in serialized
), f"admin_key bytes not visible in security config: {security!r}"
assert (
key_field is not None
), "security.admin_key field absent — baking key 0 didn't stick"
finally:
# Restore session profile (no admin key)
restore = flash.erase_and_flash(
env=env,
port=port,
confirm=True,
userprefs_overrides=test_profile,
)
assert restore["exit_code"] == 0

View File

@@ -0,0 +1,60 @@
"""Provisioning: the pre-bake recipe (US/LONG_FAST/slot 88/private channel)
lands on the device exactly as specified.
This is THE test that proves the MCP's core value prop — flashing firmware
with a preset USERPREFS produces a device in the expected radio config without
any post-flash admin steps.
"""
from __future__ import annotations
from typing import Any
import pytest
from meshtastic_mcp import admin, info
@pytest.mark.timeout(60)
def test_bake_sets_region_preset_and_slot(
baked_mesh: dict[str, Any],
test_profile: dict[str, Any],
) -> None:
"""After test_00_bake, both devices must report the exact region, modem
preset, slot, and channel name that the profile specified."""
for role, state in baked_mesh.items():
port = state["port"]
live = info.device_info(port=port, timeout_s=8.0)
lora = admin.get_config(section="lora", port=port)["config"]["lora"]
expected_region = test_profile["USERPREFS_CONFIG_LORA_REGION"].rsplit("_", 1)[
-1
]
expected_preset = test_profile["USERPREFS_LORACONFIG_MODEM_PRESET"].rsplit(
"_", 2
)[-2:]
expected_preset_str = "_".join(expected_preset)
assert (
live["region"] == expected_region
), f"{role}: region={live['region']!r}, expected {expected_region!r}"
# `modem_preset` is omitted from the protobuf→JSON dump when the
# device is using the default enum value (LONG_FAST). If the key is
# missing AND we expected LONG_FAST, that's a match. Otherwise compare.
live_preset = lora.get("modem_preset")
if live_preset is None:
assert expected_preset_str == "LONG_FAST", (
f"{role}: modem_preset omitted (means default LONG_FAST), "
f"but expected {expected_preset_str!r}"
)
else:
assert live_preset in (
expected_preset_str,
expected_preset_str.upper(),
), f"{role}: modem_preset={live_preset!r}, expected {expected_preset_str!r}"
assert (
int(lora.get("channel_num", 0))
== test_profile["USERPREFS_LORACONFIG_CHANNEL_NUM"]
), f"{role}: channel_num={lora.get('channel_num')!r}"
assert live["primary_channel"] == test_profile["USERPREFS_CHANNEL_0_NAME"]

View File

@@ -0,0 +1,108 @@
"""Provisioning (negative): firmware baked WITHOUT
`USERPREFS_CONFIG_LORA_REGION` must refuse to transmit.
Real operator concern: FCC compliance. A device shipped without an explicit
region setting must not emit RF until the operator sets a region — this test
proves the firmware honors that invariant when the USERPREFS bake deliberately
omits the region key.
Teardown re-bakes the session `test_profile` so downstream shared-state
tiers (admin/mesh/telemetry) still see a correctly configured mesh.
"""
from __future__ import annotations
from typing import Any
import pytest
from meshtastic_mcp import admin, flash, info
@pytest.mark.skip(
reason="test uses flash.erase_and_flash which shells to bin/device-install.sh "
"which needs mt-esp32s3-ota.bin (not in repo). TODO: switch to "
"esptool_erase_flash + flash.flash() like test_00_bake."
)
@pytest.mark.timeout(600)
def test_unset_region_blocks_tx(
hub_devices: dict[str, str],
no_region_profile: dict[str, Any],
test_profile: dict[str, Any],
serial_capture,
) -> None:
"""Bake a device with no LoRa region, then assert:
1. `config.lora.region` reads as "UNSET" (or 0).
2. An attempt to `send_text` surfaces a refusal — either the meshtastic
SDK raises, or the serial log contains a clear "region unset" marker.
Always re-bakes the session test_profile in the finalizer so downstream
categories are not left with a broken device.
"""
target = "esp32s3"
if target not in hub_devices:
pytest.skip(f"role {target!r} not on hub")
port = hub_devices[target]
# Pick the right env for this role — must match what test_00_bake used.
import os
env = os.environ.get("MESHTASTIC_MCP_ENV_ESP32S3", "t-beam-1w")
# Capture serial before the bake to see the "region unset" log line on boot
cap = serial_capture(target, env=env)
# Bake without region
result = flash.erase_and_flash(
env=env,
port=port,
confirm=True,
userprefs_overrides=no_region_profile,
)
assert (
result["exit_code"] == 0
), f"bake of no_region_profile failed:\n{result.get('stderr_tail', '')}"
try:
# After bake, device should boot with region=UNSET
live = info.device_info(port=port, timeout_s=12.0)
assert live.get("region") in (None, "UNSET", "UNSET_0", ""), (
f"expected region UNSET after baking without region pref; "
f"got {live.get('region')!r}"
)
# Attempting to send a message should either raise or be logged as
# refused. The meshtastic SDK's sendText may raise in this condition,
# or it may accept the call but the firmware rejects on air.
send_failed = False
try:
admin.send_text(text="should not transmit", port=port)
except Exception:
send_failed = True
# Give the firmware a moment to log anything
import time as _time
_time.sleep(3.0)
log = "\n".join(cap.snapshot(max_lines=2000))
# We expect EITHER the send raised at the Python layer, OR the serial
# log explicitly says region is unset.
log_says_unset = any(
marker in log.lower()
for marker in ("region unset", "region is unset", "no region set")
)
assert send_failed or log_says_unset, (
"expected send to fail or log 'region unset'; neither happened.\n"
f"log tail:\n{log[-2000:]}"
)
finally:
# Re-bake the session profile so downstream tests work.
restore = flash.erase_and_flash(
env=env,
port=port,
confirm=True,
userprefs_overrides=test_profile,
)
assert restore["exit_code"] == 0, (
"CRITICAL: failed to re-bake session profile after "
"no-region test; downstream tests will fail."
)

View File

@@ -0,0 +1,90 @@
"""Provisioning: after a non-full factory_reset, USERPREFS defaults come back.
Real operator concern: "if someone resets my fleet device, will it come back
on my private mesh or on Meshtastic defaults?" A baked USERPREFS recipe
should be the factory floor for the device — reset goes back to THAT state,
not to stock Meshtastic.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp import admin, info
from .._port_discovery import resolve_port_by_role
@pytest.mark.timeout(180)
def test_baked_prefs_survive_factory_reset(
baked_single: dict[str, Any],
test_profile: dict[str, Any],
wait_until,
) -> None:
"""Runs once per connected role. Flow:
1. Change owner name to a known-non-default value.
2. Trigger factory_reset(full=False).
3. Rediscover the port (macOS re-enumerates the CDC endpoint on nRF52
factory_reset; the path can change e.g. `/dev/cu.usbmodem101` →
`/dev/cu.usbmodem1101`).
4. Wait for device to come back.
5. Confirm owner is back to USERPREFS-baked default (or blank default if
not baked), and primary channel/region/slot are still the baked values.
"""
role = baked_single["role"]
port = baked_single["port"]
# Snapshot pre-reset config
pre_reset = info.device_info(port=port, timeout_s=8.0)
original_long_name = pre_reset.get("long_name")
# Poison the owner name with a non-default marker
admin.set_owner(long_name="PoisonMarker", short_name="POIZ", port=port)
time.sleep(2.0)
# Confirm poison stuck before reset
poisoned = info.device_info(port=port, timeout_s=8.0)
assert poisoned.get("long_name") == "PoisonMarker"
# Trigger non-full factory reset
admin.factory_reset(port=port, confirm=True, full=False)
# Device re-enumerates — rediscover its port before probing. nRF52's
# CDC endpoint drops and comes back with a new `/dev/cu.usbmodem*`
# path on macOS; ESP32-S3 usually keeps the same path but the helper
# works either way (it just returns the current path for this role).
# Early sleep lets the USB kernel driver settle before we start
# polling — list_devices during a transient re-enumeration can return
# an empty list and the helper's poll-with-backoff handles that too,
# so the sleep is optimization not correctness.
time.sleep(10.0)
port = resolve_port_by_role(role, timeout_s=60.0)
wait_until(
lambda: info.device_info(port=port, timeout_s=5.0).get("my_node_num")
is not None,
timeout=60,
backoff_start=1.0,
)
post = info.device_info(port=port, timeout_s=8.0)
# The key assertion: channel + region are STILL the USERPREFS-baked values,
# NOT Meshtastic stock defaults (which would be "LongFast" and the region
# the device shipped with).
assert post["primary_channel"] == test_profile["USERPREFS_CHANNEL_0_NAME"], (
f"after factory_reset, primary_channel reverted to "
f"{post['primary_channel']!r}, not baked {test_profile['USERPREFS_CHANNEL_0_NAME']!r}"
)
expected_region = test_profile["USERPREFS_CONFIG_LORA_REGION"].rsplit("_", 1)[-1]
assert post.get("region") == expected_region
# Owner name should NOT be "PoisonMarker" anymore
assert (
post.get("long_name") != "PoisonMarker"
), "factory_reset did not wipe the poisoned owner name"
# If we had an original_long_name, restore it so downstream tests see
# the same baseline.
if original_long_name and post.get("long_name") != original_long_name:
admin.set_owner(long_name=original_long_name, port=port)

View File

@@ -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.
"""

View File

@@ -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,
)

View File

@@ -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)."
)

View File

@@ -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}"

View File

@@ -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')}"
)

View File

View File

@@ -0,0 +1,77 @@
"""Telemetry: device-metrics packets arrive at the peer.
Two-path verification:
1. Listen on TX's pubsub for inbound telemetry packets originating from
RX's node_num — if one arrives within the window, telemetry works.
2. Fall back to checking TX's node DB for a populated `deviceMetrics`
block on the RX record (which the firmware writes on receipt).
Both paths prove the same invariant; path 1 gives faster failure signal,
path 2 handles the case where the packet arrived before we subscribed.
Warmup note: when this test runs after `test_baked_prefs_survive_factory_reset`,
both devices have empty node-DBs. We kick a broadcast text from RX through
its own ReceiveCollector so TX learns RX exists and starts accepting its
telemetry; without it, a fresh-boot pair can take 10+ min to swap NODEINFO
before the first telemetry arrives.
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from meshtastic_mcp.connection import connect
from ..mesh._receive import ReceiveCollector
@pytest.mark.timeout(600)
def test_device_telemetry_broadcast(mesh_pair: dict[str, Any]) -> None:
"""Runs for every directed pair. Waits up to ~8 minutes for TX to see
RX's device telemetry — either as a live inbound pubsub packet or as
a populated deviceMetrics on RX's node-DB record.
Firmware default telemetry interval is 900s; after a fresh boot the
first device-metrics broadcast happens within ~30-120s. We warm up
the mesh first with a cross-broadcast so NODEINFO is exchanged, then
wait up to 7 min for a telemetry packet.
"""
tx_port = mesh_pair["tx"]["port"]
rx_port = mesh_pair["rx"]["port"]
rx_node_num = mesh_pair["rx"]["my_node_num"]
# Open both sides' pubsub listeners up front so we capture anything that
# arrives during the warmup exchange.
with ReceiveCollector(tx_port, topic="meshtastic.receive.telemetry") as tx_rx:
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx_tx:
# Warmup: send a broadcast from RX through its own collector so
# TX learns about RX (NODEINFO rides along with TEXT_MESSAGE_APP).
# Skipping this turns a 5-min wait into a 15-min wait on a fresh
# factory-reset pair.
rx_tx.send_text(f"warmup-{int(time.time())}")
time.sleep(5.0)
# Path 1: wait for a telemetry packet from RX on TX's pubsub.
got = tx_rx.wait_for(
lambda pkt: pkt.get("from") == rx_node_num,
timeout=420, # 7 min — well above the 30-120s typical first broadcast
)
if got is not None:
return # Path 1 confirmed delivery.
# Path 2: re-query TX's node DB for a populated deviceMetrics on RX.
# Device may have reported telemetry before we subscribed, or the
# pubsub delivery might race with our window — re-check nodesByNum.
with connect(port=tx_port) as iface:
rec = (iface.nodesByNum or {}).get(rx_node_num, {})
metrics = rec.get("deviceMetrics") or {}
has_any = any(
metrics.get(k) is not None
for k in ("batteryLevel", "voltage", "channelUtilization", "airUtilTx")
)
assert has_any, (
f"no telemetry from node 0x{rx_node_num:08x} within 7 min; "
f"deviceMetrics={metrics!r}"
)

View File

@@ -0,0 +1,187 @@
"""Telemetry: on-demand device-metrics request gets a prompt reply.
Complementary to ``test_device_telemetry_broadcast`` — that one witnesses the
firmware's *periodic* broadcast (900 s default interval, up to ~7 min worst
case). This one exercises the *request/reply* path: TX sends a
``meshtastic_Telemetry`` with the ``device_metrics`` variant-tag set and
``want_response=True`` on ``TELEMETRY_APP`` to RX, and RX's
``modules/Telemetry/DeviceTelemetry.cpp::allocReply`` fires immediately with
populated ``DeviceMetrics``. On a direct 2-device mesh the whole round-trip
finishes in under a minute even from a cold boot.
Validates:
* ``sendData(portNum=TELEMETRY_APP, want_response=True)`` encodes + routes
to RX (directed, PKI-encrypted to RX's pubkey)
* RX's ``DeviceTelemetryModule::handleReceivedProtobuf`` dispatches to
``allocReply`` — which is only invoked by the framework when
``want_response`` is set on the incoming packet
* The reply carries a ``DeviceMetrics`` sub-message with at least one
non-zero field (uptime_seconds is guaranteed non-zero a few seconds
after boot, so it reliably survives protobuf's default-value
serialization stripping)
* The reply routes back to TX and gets matched against the original
request via ``request_id`` — using the library's ``onResponse``
callback mechanism, which stores the handler at
``responseHandlers[sent_packet.id]`` and dispatches when a packet
arrives with ``decoded.request_id == sent_packet.id``. This is more
precise than a pubsub ``from==rx_node_num`` filter, which can
accidentally match RX's periodic broadcast or a stale reply to a
different prior request.
"""
from __future__ import annotations
import threading
import time
from typing import Any
import pytest
from meshtastic.protobuf import ( # type: ignore[import-untyped]
portnums_pb2,
telemetry_pb2,
)
from ..mesh._receive import ReceiveCollector, nudge_nodeinfo_port
# Fields on the DeviceMetrics sub-message. The camelCase versions are what
# `google.protobuf.json_format.MessageToDict` emits (preserving_proto_field_name
# defaults to False); the snake_case names are the proto-source spellings.
_DEVICE_METRICS_FIELDS = (
"batteryLevel",
"voltage",
"channelUtilization",
"airUtilTx",
"uptimeSeconds",
)
@pytest.mark.timeout(240)
def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
"""Runs for every directed pair. TX requests RX's telemetry via
``want_response=True`` and asserts the reply arrives with populated
DeviceMetrics.
"""
tx_port = mesh_pair["tx"]["port"]
rx_port = mesh_pair["rx"]["port"]
rx_node_num = mesh_pair["rx"]["my_node_num"]
tx_role = mesh_pair["tx_role"]
rx_role = mesh_pair["rx_role"]
assert rx_node_num is not None, f"{rx_role} my_node_num missing"
# ReceiveCollector is still used to hold TX's SerialInterface open and
# give us `tx_listener._iface` for sendData / nodesByNum polling. The
# subscribed topic is irrelevant for this test (we match via
# onResponse, not pubsub), but keeping a concrete topic avoids the
# surprise of a pubsub wildcard receiving every packet type.
with ReceiveCollector(tx_port, topic="meshtastic.receive.telemetry") as tx_listener:
# Bilateral PKI warmup — nudge BOTH sides to rebroadcast their
# NodeInfo (with current pubkey) before the directed send.
# * Nudging only RX gets RX's key → TX, but leaves RX with a
# potentially stale TX pubkey → RX NAKs our request with
# err=35 (PKI_UNKNOWN_PUBKEY) and we see no reply.
# * Nudging only TX is the mirror failure.
# See `tests/mesh/_receive.py::nudge_nodeinfo` for firmware path.
nudge_nodeinfo_port(rx_port) # briefly opens RX to send heartbeat
tx_listener.broadcast_nodeinfo_ping() # TX via the already-open iface
pk_deadline = time.monotonic() + 45.0
last_nudge = time.monotonic()
last_rec: dict[str, Any] = {}
while time.monotonic() < pk_deadline:
last_rec = (tx_listener._iface.nodesByNum or {}).get(rx_node_num, {})
if last_rec.get("user", {}).get("publicKey"):
break
if time.monotonic() - last_nudge > 15.0:
# Re-nudge both sides — LoRa collisions can drop either
# direction's NodeInfo broadcast independently.
nudge_nodeinfo_port(rx_port)
tx_listener.broadcast_nodeinfo_ping()
last_nudge = time.monotonic()
time.sleep(1.0)
else:
pytest.fail(
f"TX ({tx_role}) never saw RX ({rx_role}) public key within "
f"45s; nodesByNum entry={last_rec!r}"
)
# Send the request. The Telemetry protobuf has a `which_variant`
# oneof tag that the firmware uses to decide which reply to build
# (see `src/modules/Telemetry/DeviceTelemetry.cpp::allocReply`):
# device_metrics_tag → getDeviceTelemetry()
# local_stats_tag → getLocalStatsTelemetry()
# anything else → return NULL (request silently dropped)
# An empty `Telemetry()` has `which_variant = UNSET (0)`, so we MUST
# explicitly set the variant. `CopyFrom(DeviceMetrics())` with a
# default-constructed sub-message is the canonical Python-protobuf
# idiom for "set the oneof tag without populating fields" — matching
# how `MeshInterface.sendTelemetry()` constructs requests for the
# other variants.
#
# Matching the reply: the meshtastic client's `onResponse` callback
# mechanism fires ONLY for packets whose `decoded.request_id` equals
# the original outgoing packet's `id`. That's exactly the semantic
# we want — rejects periodic broadcasts (no request_id), rejects
# stale replies to prior requests (different request_id), and
# tolerates the firmware's reply_id/request_id naming quirk
# (firmware's `setReplyTo` writes the original packet's id into
# `decoded.request_id`, not `decoded.reply_id`).
#
# One retry covers transient LoRa collisions on request or reply.
reply_holder: list[dict[str, Any]] = []
got_reply = threading.Event()
def _on_reply(packet: dict[str, Any]) -> None:
reply_holder.append(packet)
got_reply.set()
got = None
for _attempt in range(2):
got_reply.clear()
del reply_holder[:]
req = telemetry_pb2.Telemetry()
req.device_metrics.CopyFrom(telemetry_pb2.DeviceMetrics())
tx_listener._iface.sendData(
req,
destinationId=rx_node_num,
portNum=portnums_pb2.PortNum.TELEMETRY_APP,
wantResponse=True,
onResponse=_on_reply,
hopLimit=3,
)
if got_reply.wait(timeout=45.0):
got = reply_holder[0]
break
time.sleep(5.0)
assert got is not None, (
f"no telemetry reply from {rx_role} (0x{rx_node_num:08x}) within "
f"90s of 2 requests; onResponse callback never fired. Captured "
f"{len(tx_listener.snapshot())} unrelated telemetry packet(s): "
f"{[hex(p.get('from') or 0) for p in tx_listener.snapshot()]!r}"
)
# Sanity: the reply's origin matches — a firmware bug that routed
# the response to the wrong sender would make onResponse fire on
# the wrong packet.
assert got.get("from") == rx_node_num, (
f"telemetry reply origin mismatch: from=0x{got.get('from'):08x}, "
f"expected 0x{rx_node_num:08x}"
)
# Inspect the decoded Telemetry payload. The meshtastic client stores
# it under `decoded.telemetry`; DeviceMetrics under `.deviceMetrics`.
decoded = got.get("decoded", {})
telem = decoded.get("telemetry") or {}
dm = telem.get("deviceMetrics") or {}
# A populated reply must contain at least one DeviceMetrics field.
# Protobuf's JSON serializer strips default-valued (zero) fields,
# so a bare `deviceMetrics: {}` would mean the firmware wrote the
# sub-message but every field was zero — plausible right at boot
# but not for a device that's been running long enough for a test
# session's warmup + NodeInfo exchange (~10-30 s uptime minimum).
populated = [k for k in _DEVICE_METRICS_FIELDS if k in dm]
assert populated, (
f"telemetry reply from {rx_role} carried no DeviceMetrics fields; "
f"decoded.telemetry={telem!r}"
)

View File

@@ -0,0 +1,291 @@
"""Session-bake module — runs first in the tier order to flash both hub roles
with the session `test_profile`.
Ordered first by `pytest_collection_modifyitems` in `conftest.py` (bucket
-1) because `baked_mesh` only *verifies* state — it does not reflash. Without
the explicit order pin, the top-level path `tests/test_00_bake.py` falls
into the fallback bucket and sorts AFTER every tier, silently turning
`--force-bake` into a no-op for the tier tests.
Skipped entirely when `--assume-baked` is passed. All downstream hardware
tests either depend on `baked_mesh` (which verifies state) or do their own
per-test bake (provisioning/fleet tiers), so failing here gives one clear
actionable failure instead of a cascade of mismatches.
Hardware-specific env names live in a small role→env map at the top of this
file; override by setting `MESHTASTIC_MCP_ENV_<ROLE>` env vars (e.g.
`MESHTASTIC_MCP_ENV_NRF52=heltec-mesh-node-t114`).
"""
from __future__ import annotations
import os
import time
from typing import Any
import pytest
import serial # type: ignore[import-untyped]
from meshtastic_mcp import admin, boards, flash, hw_tools, info
# Default envs for a common lab setup. Override per-role via env var.
_DEFAULT_ENVS = {
"nrf52": "rak4631",
"esp32s3": "heltec-v3",
}
_ESP32_ARCHES = {
"esp32",
"esp32-s2",
"esp32s2",
"esp32-s3",
"esp32s3",
"esp32-c3",
"esp32c3",
"esp32-c6",
"esp32c6",
}
_NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"}
def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None:
"""Block until `port` can be exclusively opened, or raise after `timeout_s`.
Root cause for the retry loop: esptool / nrfutil / pio all take an
*exclusive* serial port lock (fcntl LOCK_EX on macOS, EAGAIN otherwise).
Anything that held the port recently — the TUI's startup `DevicePollerWorker._poll_once()`,
a prior `device_info` call, a lingering `meshtastic-mcp` subprocess
spawned by the operator's MCP host, or a stale `pio device monitor` —
can still be holding it when `test_00_bake` reaches the flash step. The
result is esptool exiting 2 in ~0.1s with `[Errno 35] Resource
temporarily unavailable`.
`pyserial.Serial(exclusive=True)` probes the same lock esptool takes;
a brief open/close cycle is the cleanest way to verify the port is
genuinely free before handing it to a subprocess we can't easily
retry. 200 ms poll interval keeps the failure fast while giving the
kernel time to release a just-closed descriptor.
Raises AssertionError (rather than a generic TimeoutError) so the
pytest summary shows the role + port + a hint at `lsof`.
"""
role_prefix = f"{role}: " if role else ""
deadline = time.monotonic() + timeout_s
last_exc: BaseException | None = None
while time.monotonic() < deadline:
try:
s = serial.Serial(port=port, exclusive=True, timeout=0.5)
except Exception as exc:
last_exc = exc
time.sleep(0.2)
continue
try:
s.close()
except Exception:
pass
return
raise AssertionError(
f"{role_prefix}port {port} still busy after {timeout_s:.0f}s — "
f"something else holds an exclusive lock. Last error: {last_exc!r}. "
f"Identify the holder with `lsof {port}` and kill it; common "
f"culprits are a lingering `meshtastic-mcp` subprocess from the "
f"MCP host (.mcp.json) or a stale `pio device monitor`."
)
def _prepare_nrf52_for_upload(port: str) -> str:
"""Kick the RAK4631 (or similar nRF52 USB-DFU board) into bootloader mode
via 1200bps touch, then return the port where pio should upload.
Adafruit bootloader on RAK4631 interprets 1200bps-open-close as 'enter
DFU'. The device re-enumerates with a distinct USB VID/PID
(0x239A/0x0029) at a different `/dev/cu.usbmodem*` path.
`touch_1200bps` does the heavy lifting: bounded open/close, polls for the
Adafruit-bootloader PID specifically, retries the touch up to twice.
Fails loudly if the device doesn't enter DFU — no point trying pio
upload against an app-mode device, it'll just hang.
"""
result = flash.touch_1200bps(port=port, settle_ms=500, retries=2)
if not result.get("ok"):
raise AssertionError(
f"nRF52 at {port} did not enter DFU bootloader after "
f"{result.get('attempts')} 1200bps touches. Manual recovery: "
f"double-tap the reset button on the board, then re-run. "
f"Detected port set before/after touch was unchanged."
)
new_port = result["new_port"]
# Small settle so pio/nrfutil sees a fully-ready CDC endpoint.
time.sleep(1.0)
return new_port
def _env_for(role: str) -> str:
override = os.environ.get(f"MESHTASTIC_MCP_ENV_{role.upper()}")
if override:
return override
if role not in _DEFAULT_ENVS:
pytest.fail(
f"no default PlatformIO env for role {role!r}. "
f"Set MESHTASTIC_MCP_ENV_{role.upper()} to the env name."
)
return _DEFAULT_ENVS[role]
def _bake_role(
role: str,
port: str,
test_profile: dict[str, Any],
force_bake: bool,
) -> None:
"""Bake + boot + verify for a single role. Skips if already baked unless
`--force-bake` was passed."""
env = _env_for(role)
# If not forcing, check if already baked with session profile.
if not force_bake:
try:
live = info.device_info(port=port, timeout_s=8.0)
# Quick heuristic: region matches and primary channel matches.
expected_region_short = test_profile["USERPREFS_CONFIG_LORA_REGION"].rsplit(
"_", 1
)[-1]
if (
live.get("region") == expected_region_short
and live.get("primary_channel")
== test_profile["USERPREFS_CHANNEL_0_NAME"]
):
pytest.skip(
f"{role} at {port} already baked with session profile "
f"(pass --force-bake to reflash)"
)
except Exception:
# If we can't query, fall through and bake anyway.
pass
# All architectures go through `pio run -t upload` — pio knows the right
# protocol per variant (esptool for ESP32, adafruit-nrfutil for nRF52,
# picotool for RP2040). We don't use `bin/device-install.sh` for ESP32
# because it requires the external `mt-esp32s3-ota.bin` helper that's
# downloaded from releases, not generated by the build.
#
# IMPORTANT: `pio run -t upload` on ESP32 only overwrites the APP
# partition — the LittleFS partition (config + NodeDB) survives. That
# means USERPREFS-baked defaults never take effect on a device that was
# already provisioned, because NodeDB init prefers the saved config. To
# force USERPREFS to apply cleanly, we erase the full chip first on
# ESP32 boards. nRF52 DFU naturally wipes the user partition, so no
# erase needed there.
rec = boards.get_board(env)
arch = rec.get("architecture") or ""
# Make sure nothing else (TUI startup poll, MCP-host zombie, pio monitor)
# is holding the port before we hand it to a subprocess. Self-heals the
# [Errno 35] port-busy flake that otherwise fails the bake in ~0.1s.
_wait_port_free(port, role=role)
if arch in _NRF52_ARCHES:
upload_port = _prepare_nrf52_for_upload(port)
elif arch in _ESP32_ARCHES:
# Full chip erase — wipes NVS + LittleFS so USERPREFS defaults apply.
erase_result = hw_tools.esptool_erase_flash(port=port, confirm=True)
assert erase_result["exit_code"] == 0, (
f"{role}: esptool erase_flash failed:\n"
f"{erase_result.get('stderr_tail', '')}"
)
upload_port = port
else:
upload_port = port
# Post-erase, pre-upload: full chip erase on ESP32 drops the CDC
# endpoint for a moment while the bootloader re-enters download mode.
# Wait for the port to settle before pio reopens it for upload —
# otherwise a fast machine can race and hit the same errno 35.
if arch in _ESP32_ARCHES:
_wait_port_free(upload_port, role=role, timeout_s=10.0)
# NOTE: no `userprefs_overrides=` here. The session-scoped
# `_session_userprefs` autouse fixture in conftest.py has already baked
# the test profile into userPrefs.jsonc for the duration of the session
# and will restore the original file at session end. A local
# `temporary_overrides` here would be a no-op (file is already baked)
# AND would cause the session fixture's teardown to see different
# stat / mtime than it snapshotted — keep the mutation in one place.
result = flash.flash(
env=env,
port=upload_port,
confirm=True,
)
assert result["exit_code"] == 0, (
f"{role} bake failed: exit={result['exit_code']}\n"
f"stdout tail:\n{result.get('stdout_tail', '')}\n"
f"stderr tail:\n{result.get('stderr_tail', '')}"
)
# Post-flash: for nRF52, the DFU process only overwrites the app
# partition — the NVS region holding the existing NodeDB/config is
# untouched, so the firmware will prefer the saved config over the
# baked USERPREFS defaults. Trigger a full factory reset to wipe NVS
# so USERPREFS takes effect on the next boot.
#
# ESP32 devices had their full flash erased BEFORE upload via
# esptool_erase_flash, so they don't need this post-flash reset.
if arch in _NRF52_ARCHES:
# Give the device time to come up from DFU.
time.sleep(8.0)
# Wait for meshtastic to be responsive; `device_info` may take a
# few seconds on the first post-flash boot.
for _ in range(20):
try:
info.device_info(port=port, timeout_s=6.0)
break
except Exception:
time.sleep(1.5)
else:
raise AssertionError(f"{role}: device didn't respond after DFU flash")
# Trigger full factory reset (wipes NVS + identity)
admin.factory_reset(port=port, confirm=True, full=True)
# Wait for the device to reboot and come back with fresh config
# populated from USERPREFS defaults.
time.sleep(10.0)
for _ in range(30):
try:
live = info.device_info(port=port, timeout_s=6.0)
if live.get("my_node_num"):
break
except Exception:
pass
time.sleep(2.0)
else:
raise AssertionError(f"{role}: device didn't return after factory_reset")
@pytest.mark.timeout(600)
def test_bake_nrf52(
hub_devices: dict[str, str],
test_profile: dict[str, Any],
request: pytest.FixtureRequest,
) -> None:
"""Flash the nRF52840 role with the session test profile."""
if "nrf52" not in hub_devices:
pytest.skip("nRF52 not detected on hub")
_bake_role(
role="nrf52",
port=hub_devices["nrf52"],
test_profile=test_profile,
force_bake=request.config.getoption("--force-bake"),
)
@pytest.mark.timeout(600)
def test_bake_esp32s3(
hub_devices: dict[str, str],
test_profile: dict[str, Any],
request: pytest.FixtureRequest,
) -> None:
"""Flash the ESP32-S3 role with the session test profile."""
if "esp32s3" not in hub_devices:
pytest.skip("ESP32-S3 not detected on hub")
_bake_role(
role="esp32s3",
port=hub_devices["esp32s3"],
test_profile=test_profile,
force_bake=request.config.getoption("--force-bake"),
)

View File

@@ -0,0 +1,152 @@
"""Tool-surface coverage: track which MCP tools the test suite actually exercises.
This is NOT line coverage (that's `coverage.py`). This measures which of the
38 public MCP tools in `meshtastic_mcp.server` got invoked during a pytest
run — a quick signal for "where are the test-coverage gaps".
Approach: introspect `meshtastic_mcp.server.app` for registered tools, find
the underlying handler functions in their source modules, and wrap each with
a counting shim. At session end, emit `tool_coverage.json` mapping each tool
name to its call count. Tools never called show `count=0`.
"""
from __future__ import annotations
import json
import pathlib
from typing import Any
_counts: dict[str, int] = {}
_installed = False
def _bump(name: str) -> None:
_counts[name] = _counts.get(name, 0) + 1
def _wrap(module: Any, attr: str, tool_name: str) -> None:
original = getattr(module, attr, None)
if original is None or not callable(original):
return
def wrapper(*args: Any, **kwargs: Any) -> Any:
_bump(tool_name)
return original(*args, **kwargs)
wrapper.__wrapped__ = original # type: ignore[attr-defined]
wrapper.__name__ = attr
wrapper.__doc__ = original.__doc__
setattr(module, attr, wrapper)
# Mapping: MCP tool name → (module, function name). Mirrors the wiring in
# `meshtastic_mcp.server`. Keep synchronized manually — adding a tool without
# updating this map means it shows as count=0 in reports even if exercised.
_TOOL_MAP: dict[str, tuple[str, str]] = {
# Discovery & metadata
"list_devices": ("meshtastic_mcp.devices", "list_devices"),
"list_boards": ("meshtastic_mcp.boards", "list_boards"),
"get_board": ("meshtastic_mcp.boards", "get_board"),
# Build & flash
"build": ("meshtastic_mcp.flash", "build"),
"clean": ("meshtastic_mcp.flash", "clean"),
"pio_flash": ("meshtastic_mcp.flash", "flash"),
"erase_and_flash": ("meshtastic_mcp.flash", "erase_and_flash"),
"update_flash": ("meshtastic_mcp.flash", "update_flash"),
"touch_1200bps": ("meshtastic_mcp.flash", "touch_1200bps"),
# Serial log sessions — module-level functions on serial_session
"serial_open": ("meshtastic_mcp.serial_session", "open_session"),
"serial_read": ("meshtastic_mcp.serial_session", "read_session"),
"serial_list": ("meshtastic_mcp.registry", "all_sessions"),
"serial_close": ("meshtastic_mcp.serial_session", "close_session"),
# Device reads
"device_info": ("meshtastic_mcp.info", "device_info"),
"list_nodes": ("meshtastic_mcp.info", "list_nodes"),
# Device writes
"set_owner": ("meshtastic_mcp.admin", "set_owner"),
"get_config": ("meshtastic_mcp.admin", "get_config"),
"set_config": ("meshtastic_mcp.admin", "set_config"),
"get_channel_url": ("meshtastic_mcp.admin", "get_channel_url"),
"set_channel_url": ("meshtastic_mcp.admin", "set_channel_url"),
"set_debug_log_api": ("meshtastic_mcp.admin", "set_debug_log_api"),
"send_text": ("meshtastic_mcp.admin", "send_text"),
"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"),
"userprefs_set": ("meshtastic_mcp.userprefs", "merge_active"),
"userprefs_reset": ("meshtastic_mcp.userprefs", "reset"),
"userprefs_testing_profile": ("meshtastic_mcp.userprefs", "build_testing_profile"),
# Vendor hardware tools
"esptool_chip_info": ("meshtastic_mcp.hw_tools", "esptool_chip_info"),
"esptool_erase_flash": ("meshtastic_mcp.hw_tools", "esptool_erase_flash"),
"esptool_raw": ("meshtastic_mcp.hw_tools", "esptool_raw"),
"nrfutil_dfu": ("meshtastic_mcp.hw_tools", "nrfutil_dfu"),
"nrfutil_raw": ("meshtastic_mcp.hw_tools", "nrfutil_raw"),
"picotool_info": ("meshtastic_mcp.hw_tools", "picotool_info"),
"picotool_load": ("meshtastic_mcp.hw_tools", "picotool_load"),
"picotool_raw": ("meshtastic_mcp.hw_tools", "picotool_raw"),
}
def install() -> None:
"""Wrap every mapped tool function with the counting shim. Idempotent."""
global _installed
if _installed:
return
import importlib
# Whitelist the exact module paths this function is ever allowed to
# import. `module_path` below is iterated from `_TOOL_MAP` — a file-
# local, hardcoded dict literal — but a static whitelist makes the
# "no untrusted input here" invariant legible to reviewers and to
# the Semgrep `non-literal-import` audit rule.
_allowed_modules = frozenset(path for path, _attr in _TOOL_MAP.values())
for tool_name, (module_path, attr) in _TOOL_MAP.items():
# Defense in depth: if someone mutates `_TOOL_MAP` at runtime
# (shouldn't happen; it's module-level) the whitelist catches it.
# `module_path` is a key from the hardcoded `_TOOL_MAP` dict and
# is gated above by membership in `_allowed_modules` (itself
# derived from the same literal values). There is no path for
# untrusted input to reach the `import_module` call below; the
# Semgrep suppression must sit on the line immediately preceding
# the call (multi-line comment blocks between comment and call
# break the rule's scope detection).
if module_path not in _allowed_modules:
continue
try:
# nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
mod = importlib.import_module(module_path)
except ImportError:
continue
_wrap(mod, attr, tool_name)
_counts.setdefault(tool_name, 0)
_installed = True
def write_report(path: pathlib.Path) -> None:
"""Emit `tool_coverage.json` with call counts for every mapped tool."""
exercised = sum(1 for c in _counts.values() if c > 0)
total = len(_counts)
payload = {
"total_tools": total,
"exercised": exercised,
"coverage_pct": round(100.0 * exercised / total, 1) if total else 0.0,
"counts": dict(sorted(_counts.items())),
"unexercised": sorted(k for k, v in _counts.items() if v == 0),
}
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def snapshot() -> dict[str, int]:
return dict(_counts)

View File

@@ -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.
"""

View File

@@ -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<idx>\d+)/(?P<count>\d+) name=(?P<name>\S+) reason=(?P<reason>\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=<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",
]

View File

@@ -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 <none>"
)
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.

View File

@@ -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 <idx>/<count> name=... reason=fn_f<k>` 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}")

View File

@@ -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")

View File

@@ -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}"

View File

@@ -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"

View File

@@ -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)

View File

@@ -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")

View File

View File

@@ -0,0 +1,72 @@
"""`boards.py` filter and enumeration correctness.
Runs against the real `pio project config` output of this firmware repo —
validates that filter predicates match expected envs and don't drift if
variants get reorganized.
"""
from __future__ import annotations
import pytest
from meshtastic_mcp import boards
def test_list_boards_returns_many() -> None:
all_boards = boards.list_boards()
assert len(all_boards) >= 50, "expected at least 50 PlatformIO envs"
def test_tbeam_is_canonical_esp32() -> None:
"""The default env in platformio.ini is `tbeam`; it must always be present
and flagged as esp32."""
rec = boards.get_board("tbeam")
assert rec["architecture"] == "esp32"
assert rec["hw_model_slug"] == "TBEAM"
assert rec["actively_supported"] is True
assert rec["board"] == "ttgo-tbeam"
def test_filter_by_architecture() -> None:
esp32s3 = boards.list_boards(architecture="esp32s3")
assert len(esp32s3) >= 1
assert all(b["architecture"] == "esp32s3" for b in esp32s3)
def test_filter_by_actively_supported() -> None:
supported = boards.list_boards(actively_supported_only=True)
unsupported = [b for b in boards.list_boards() if not b["actively_supported"]]
assert supported, "at least one board should be actively supported"
assert all(b["actively_supported"] for b in supported)
# Quick sanity: the set difference is non-empty in this repo (there are
# boards marked actively_supported=false).
assert unsupported, "expected at least one actively_supported=false board"
def test_filter_by_query_substring_matches_display_name() -> None:
heltec = boards.list_boards(query="heltec")
assert heltec, "expected at least one Heltec env"
# Case-insensitive across display_name, env name, or hw_model_slug
for b in heltec:
blob = " ".join(
filter(
None,
[
b.get("display_name") or "",
b["env"],
b.get("hw_model_slug") or "",
],
)
).lower()
assert "heltec" in blob
def test_get_board_unknown_env_raises() -> None:
with pytest.raises(KeyError, match="Unknown env"):
boards.get_board("definitely-not-a-real-env")
def test_get_board_surfaces_raw_config() -> None:
rec = boards.get_board("tbeam")
assert "raw_config" in rec
assert "custom_meshtastic_architecture" in rec["raw_config"]
assert rec["raw_config"]["custom_meshtastic_architecture"] == "esp32"

View File

@@ -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]

Some files were not shown because too many files have changed in this diff Show More