emdashes begone (#10847)
This commit is contained in:
@@ -15,11 +15,11 @@ The Claude Code commands and Copilot prompts cover the same three workflows but
|
||||
- **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.
|
||||
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.
|
||||
- **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.
|
||||
|
||||
@@ -3,7 +3,7 @@ description: Produce a device health report using the meshtastic MCP tools (devi
|
||||
argument-hint: [role=all|nrf52|esp32s3|<port>]
|
||||
---
|
||||
|
||||
# `/diagnose` — device health report
|
||||
# `/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.
|
||||
|
||||
@@ -18,13 +18,13 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
|
||||
- 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.
|
||||
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:
|
||||
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.
|
||||
@@ -45,17 +45,17 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
|
||||
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 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. **Recorder slice (cheap, always available).** The mcp-server runs an autouse log recorder that's been collecting from every connected device. Pull two short slices to surface anything weird that's already happened:
|
||||
- `mcp__meshtastic__logs_window(start="-2m", level="WARN|ERROR|CRIT", max_lines=20)` — recent firmware errors. If empty, say "no recent errors"; don't manufacture concern.
|
||||
- `mcp__meshtastic__telemetry_timeline(window="1h", field="free_heap", max_points=60)` — heap trend. If `slope_per_min < -50`, flag it and recommend `/leakhunt window=6h` for a deeper read; otherwise just note the current free heap.
|
||||
- If `recorder_status` shows `running:false` or `files.telemetry.last_ts` is null, note "recorder has no telemetry yet — enable `set_debug_log_api(True)` to populate" and skip this step gracefully.
|
||||
- `mcp__meshtastic__logs_window(start="-2m", level="WARN|ERROR|CRIT", max_lines=20)` - recent firmware errors. If empty, say "no recent errors"; don't manufacture concern.
|
||||
- `mcp__meshtastic__telemetry_timeline(window="1h", field="free_heap", max_points=60)` - heap trend. If `slope_per_min < -50`, flag it and recommend `/leakhunt window=6h` for a deeper read; otherwise just note the current free heap.
|
||||
- If `recorder_status` shows `running:false` or `files.telemetry.last_ts` is null, note "recorder has no telemetry yet - enable `set_debug_log_api(True)` to populate" and skip this step gracefully.
|
||||
|
||||
8. **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."
|
||||
- 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`.
|
||||
@@ -63,6 +63,6 @@ Call the meshtastic MCP tool bundle and format a structured health report for on
|
||||
|
||||
## 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 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.
|
||||
- No holding SerialInterface across tool calls - open, query, close; next device. The port lock is exclusive.
|
||||
|
||||
@@ -5,40 +5,40 @@ argument-hint: [window=1h] [field=free_heap] [variant=local]
|
||||
|
||||
<!-- markdownlint-disable MD029 -->
|
||||
|
||||
# `/leakhunt` — read the recorder, classify a memory leak
|
||||
# `/leakhunt` - read the recorder, classify a memory leak
|
||||
|
||||
Use the always-on recorder (`mcp-server/.mtlog/`) to read a heap timeline plus the matching log slice and produce a one-page verdict: **steady / slow leak / fragmentation / OOM-imminent**. No firmware changes, no special build flags — the LocalStats telemetry packet that the firmware already broadcasts every ~60 s carries `heap_free_bytes` and `heap_total_bytes`.
|
||||
Use the always-on recorder (`mcp-server/.mtlog/`) to read a heap timeline plus the matching log slice and produce a one-page verdict: **steady / slow leak / fragmentation / OOM-imminent**. No firmware changes, no special build flags - the LocalStats telemetry packet that the firmware already broadcasts every ~60 s carries `heap_free_bytes` and `heap_total_bytes`.
|
||||
|
||||
## Two signal paths — pick the right one
|
||||
## Two signal paths - pick the right one
|
||||
|
||||
| Path | Build flag | Cadence | Per-thread attribution | Cost |
|
||||
| --------------------- | ---------------- | -------------- | ---------------------- | ------------------------- |
|
||||
| LocalStats packet | (default) | ~60 s | No | Free — always on |
|
||||
| LocalStats packet | (default) | ~60 s | No | Free - always on |
|
||||
| `[heap N]` log prefix | `-DDEBUG_HEAP=1` | every log line | Yes (Thread X leaked) | Bigger flash + log volume |
|
||||
|
||||
Both feed the same `telemetry_timeline(field="free_heap")` query — when DEBUG_HEAP is on, the recorder synthesizes telemetry rows from log prefixes (tagged `source: debug_heap`), so a single timeline call gets whichever signal is available. **For a slow leak diagnosis, the default path is plenty** (60 s cadence over 6 h = 360 points; linear regression over that nails sub-100-byte/min slopes). **DEBUG_HEAP is for attribution** — when the slope is real and you need to know which thread is leaking.
|
||||
Both feed the same `telemetry_timeline(field="free_heap")` query - when DEBUG_HEAP is on, the recorder synthesizes telemetry rows from log prefixes (tagged `source: debug_heap`), so a single timeline call gets whichever signal is available. **For a slow leak diagnosis, the default path is plenty** (60 s cadence over 6 h = 360 points; linear regression over that nails sub-100-byte/min slopes). **DEBUG_HEAP is for attribution** - when the slope is real and you need to know which thread is leaking.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Parse `$ARGUMENTS`**: optional `window` (default `1h`, accepts `30m`/`6h`/`-3d`/etc.), optional `field` (default `free_heap`; alternates: `total_heap`, `battery_level`, anything in the LocalStats variant), optional `variant` (default `local`; alternates: `device`, `environment`, `power`, `airQuality`, `health`).
|
||||
|
||||
2. **Verify the recorder is alive** — call `mcp__meshtastic__recorder_status`. Check:
|
||||
2. **Verify the recorder is alive** - call `mcp__meshtastic__recorder_status`. Check:
|
||||
- `running == True`
|
||||
- `files.telemetry.lines > 0` (at least one telemetry packet recorded — if zero, the device hasn't broadcast LocalStats yet OR `set_debug_log_api` has never been on; tell the operator to run `mcp__meshtastic__set_debug_log_api(enabled=True)` and wait one device-update interval)
|
||||
- `files.telemetry.last_ts` within the last 5 minutes (if older, the device is silent — log that, not "leak detected")
|
||||
- `files.telemetry.lines > 0` (at least one telemetry packet recorded - if zero, the device hasn't broadcast LocalStats yet OR `set_debug_log_api` has never been on; tell the operator to run `mcp__meshtastic__set_debug_log_api(enabled=True)` and wait one device-update interval)
|
||||
- `files.telemetry.last_ts` within the last 5 minutes (if older, the device is silent - log that, not "leak detected")
|
||||
|
||||
3. **Detect whether DEBUG_HEAP is active** — `mcp__meshtastic__logs_window(start="-2m", grep=r"\\[heap \\d+\\]", max_lines=3)`. If any line matches, the firmware has the prefix → DEBUG_HEAP is on, expect higher-cadence data and `heap_event` rows. If zero matches over the last 2 minutes, you're on the LocalStats-only path.
|
||||
3. **Detect whether DEBUG_HEAP is active** - `mcp__meshtastic__logs_window(start="-2m", grep=r"\\[heap \\d+\\]", max_lines=3)`. If any line matches, the firmware has the prefix → DEBUG_HEAP is on, expect higher-cadence data and `heap_event` rows. If zero matches over the last 2 minutes, you're on the LocalStats-only path.
|
||||
|
||||
4. **Pull the timeline** — `mcp__meshtastic__telemetry_timeline(window=$window, variant=$variant, field=$field, max_points=200)`. Read:
|
||||
- `samples` — how many raw points contributed
|
||||
- `min`, `max` — total swing
|
||||
- `slope_per_min` — units per minute (linear regression over the whole window)
|
||||
4. **Pull the timeline** - `mcp__meshtastic__telemetry_timeline(window=$window, variant=$variant, field=$field, max_points=200)`. Read:
|
||||
- `samples` - how many raw points contributed
|
||||
- `min`, `max` - total swing
|
||||
- `slope_per_min` - units per minute (linear regression over the whole window)
|
||||
|
||||
5. **Pull the log context for the same window** — `mcp__meshtastic__logs_window(start="-${window}", grep="Heap status|leaked heap|freed heap|out of memory|Alloc an err|panic|abort", max_lines=200)`. These are the strings the firmware emits when something memory-related happens (`DEBUG_HEAP` builds emit `"Heap status:"` and `"leaked heap"` lines; production builds emit `"Alloc an err"` on failure and `"out of memory"` on OOM).
|
||||
5. **Pull the log context for the same window** - `mcp__meshtastic__logs_window(start="-${window}", grep="Heap status|leaked heap|freed heap|out of memory|Alloc an err|panic|abort", max_lines=200)`. These are the strings the firmware emits when something memory-related happens (`DEBUG_HEAP` builds emit `"Heap status:"` and `"leaked heap"` lines; production builds emit `"Alloc an err"` on failure and `"out of memory"` on OOM).
|
||||
|
||||
6. **Pull marker events** so we know if the operator labeled phases — `mcp__meshtastic__events_window(start="-${window}", kind="mark|connection_lost|connection_established")`. If a `connection_lost` overlaps a sharp drop, that's not a leak; that's a reboot.
|
||||
6. **Pull marker events** so we know if the operator labeled phases - `mcp__meshtastic__events_window(start="-${window}", kind="mark|connection_lost|connection_established")`. If a `connection_lost` overlaps a sharp drop, that's not a leak; that's a reboot.
|
||||
|
||||
6a. **(DEBUG_HEAP only) Per-thread attribution** — `mcp__meshtastic__logs_window(start="-${window}", grep="leaked heap", max_lines=200)`. Each row has a structured `heap_event` field with `{kind, thread, before, after, delta}`. Aggregate by thread: sum the `delta` over the window per thread name. The thread with the largest cumulative negative delta is your suspect. Note the count too — a thread with 50× small leaks is different from 1× big leak.
|
||||
6a. **(DEBUG_HEAP only) Per-thread attribution** - `mcp__meshtastic__logs_window(start="-${window}", grep="leaked heap", max_lines=200)`. Each row has a structured `heap_event` field with `{kind, thread, before, after, delta}`. Aggregate by thread: sum the `delta` over the window per thread name. The thread with the largest cumulative negative delta is your suspect. Note the count too - a thread with 50× small leaks is different from 1× big leak.
|
||||
|
||||
7. **Classify** based on what the data says, NOT on what you wish it said. Use these rules in order:
|
||||
- **Insufficient data** (< 5 samples): say so. Suggest a longer window or longer wait. Stop.
|
||||
@@ -47,7 +47,7 @@ Both feed the same `telemetry_timeline(field="free_heap")` query — when DEBUG_
|
||||
- **Slow leak**: `slope_per_min < -50` AND `max - min > 1000` AND no reboot. The heap is monotonically (or near-monotonically) declining. Estimate time-to-zero: `min / -slope_per_min` minutes. Surface it.
|
||||
- **Fragmentation suspect**: `slope_per_min` close to zero (|x| < 50) BUT min trends down across the window AND the log slice shows `Alloc an err` warnings WITHOUT total OOM. Means free total is OK but largest contiguous block is shrinking. Recommend a `DEBUG_HEAP` build to confirm.
|
||||
- **Steady**: |slope_per_min| < 50, no error lines. Heap is fine.
|
||||
- **Recovery curve**: slope is POSITIVE — heap recovered. Either a workload completed or GC fired. Note it; not a leak.
|
||||
- **Recovery curve**: slope is POSITIVE - heap recovered. Either a workload completed or GC fired. Note it; not a leak.
|
||||
|
||||
8. **Report**:
|
||||
|
||||
@@ -58,14 +58,14 @@ Both feed the same `telemetry_timeline(field="free_heap")` query — when DEBUG_
|
||||
build : DEBUG_HEAP=ON (per-line prefix detected)
|
||||
samples : 14,200 over 6h (cadence ~1.5s, log-line synth)
|
||||
free_heap : min 92,344 / max 124,008 / range 31,664
|
||||
slope : -82 bytes/min (negative — heap declining)
|
||||
slope : -82 bytes/min (negative - heap declining)
|
||||
reboots : none in window
|
||||
OOM events : none
|
||||
error lines : 3× "Alloc an err=ESP_ERR_NO_MEM" at +4h12m, +5h08m, +5h44m
|
||||
thread leaks : (DEBUG_HEAP) MeshPacket -3,124 B over 18 events
|
||||
Router -1,408 B over 4 events
|
||||
others -240 B
|
||||
verdict : SLOW LEAK — primary suspect MeshPacket thread
|
||||
verdict : SLOW LEAK - primary suspect MeshPacket thread
|
||||
est. time-to-OOM: ~1,127 min (~18.8 h) at current slope
|
||||
evidence : (3 log line citations with uptimes)
|
||||
```
|
||||
@@ -86,7 +86,7 @@ Both feed the same `telemetry_timeline(field="free_heap")` query — when DEBUG_
|
||||
|
||||
- Don't assume a leak from a single dip. LocalStats fires every ~60 s and the firmware naturally allocates+frees on each broadcast cycle; one packet sees the trough. Look at the slope, not the deltas.
|
||||
- Don't recommend code changes. This skill diagnoses; the operator decides what to fix.
|
||||
- Don't enable `set_debug_log_api` automatically — if it's off, telemetry isn't reaching pubsub anyway, and the recorder will be empty. Tell the operator to flip it on and wait, then re-run.
|
||||
- Don't enable `set_debug_log_api` automatically - if it's off, telemetry isn't reaching pubsub anyway, and the recorder will be empty. Tell the operator to flip it on and wait, then re-run.
|
||||
- Don't run heavy workloads to "trigger the leak." The recorder is passive; we read what's there.
|
||||
|
||||
## Companion: `mark_event` for stress runs
|
||||
|
||||
@@ -5,7 +5,7 @@ argument-hint: <test-node-id> [count=5]
|
||||
|
||||
<!-- markdownlint-disable MD029 -->
|
||||
|
||||
# `/repro` — flakiness triage for one test
|
||||
# `/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."
|
||||
|
||||
@@ -13,7 +13,7 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
@@ -37,10 +37,10 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac
|
||||
|
||||
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?
|
||||
- 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.
|
||||
Surface the top 3 differences as a "passes when / fails when" table. Don't dump full logs - pull specific lines with uptime timestamps.
|
||||
|
||||
5a. **Archive recorder slices per attempt** (no extra device interaction; the recorder runs autouse). Right after each attempt finishes, capture its `(start_ts, end_ts)` and call `mcp__meshtastic__recorder_export(start=<start>, end=<end>, dest_dir="mcp-server/tests/repro_artifacts/<safe-test-id>/attempt_<n>/")`. This drops a `logs.jsonl`, `telemetry.jsonl`, `packets.jsonl`, and `events.jsonl` snapshot scoped to the attempt window. Use these for cross-attempt diffs in step 5: `jq '.line' logs.jsonl` is faster than re-running the test, and the telemetry slice lets you compare heap behavior across attempts.
|
||||
|
||||
@@ -59,12 +59,12 @@ Re-run a single pytest node ID N times in isolation, track pass rate, and surfac
|
||||
|
||||
## 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.
|
||||
- `/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 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.
|
||||
|
||||
@@ -3,7 +3,7 @@ description: Run the mcp-server test suite (auto-detects devices) and interpret
|
||||
argument-hint: [pytest-args]
|
||||
---
|
||||
|
||||
# `/test` — mcp-server test runner with interpretation
|
||||
# `/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.
|
||||
|
||||
@@ -15,25 +15,25 @@ Run `mcp-server/run-tests.sh` and make sense of the output so the operator doesn
|
||||
./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`.
|
||||
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.
|
||||
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:
|
||||
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.
|
||||
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`.
|
||||
- **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.
|
||||
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
|
||||
|
||||
@@ -43,5 +43,5 @@ Run `mcp-server/run-tests.sh` and make sense of the output so the operator doesn
|
||||
|
||||
## 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.
|
||||
- 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).
|
||||
|
||||
@@ -91,15 +91,15 @@ PKI (Public Key Infrastructure) messages have special handling:
|
||||
|
||||
## 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`.
|
||||
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.)
|
||||
- **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.
|
||||
- **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)
|
||||
|
||||
@@ -122,55 +122,55 @@ Meshtastic packets on the air are typically encrypted one of two ways: the **per
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **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`).
|
||||
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.
|
||||
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.
|
||||
`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.
|
||||
- **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.
|
||||
|
||||
## NodeDB Layout (v25)
|
||||
|
||||
`DEVICESTATE_CUR_VER = 25`, `DEVICESTATE_MIN_VER = 24`. The on-device NodeDB was split in v25 into a slim header table plus four optional satellite stores. Older v24 saves auto-migrate at boot. Old training-data instincts (`node->user.long_name`, `node->position.latitude_i`, `node->is_favorite`, `node->device_metrics.battery_level`) are wrong now — the fields aren't there. Read this section before touching anything that walks `nodeDB->meshNodes`.
|
||||
`DEVICESTATE_CUR_VER = 25`, `DEVICESTATE_MIN_VER = 24`. The on-device NodeDB was split in v25 into a slim header table plus four optional satellite stores. Older v24 saves auto-migrate at boot. Old training-data instincts (`node->user.long_name`, `node->position.latitude_i`, `node->is_favorite`, `node->device_metrics.battery_level`) are wrong now - the fields aren't there. Read this section before touching anything that walks `nodeDB->meshNodes`.
|
||||
|
||||
### Slim `NodeInfoLite`
|
||||
|
||||
`UserLite` is flattened onto `NodeInfoLite` (no nested sub-message); `position` and `device_metrics` are removed entirely (tags reserved). MAC address is dropped. Long names are capped at 25 chars (`max_size:25` in `deviceonly.options`); `hw_model` and `role` are `int_size:8`. Encoded size dropped from ~166 B → ~105 B per node.
|
||||
|
||||
Booleans are bit-packed into `NodeInfoLite.bitfield`. **Do not read or write the bits directly** — use the inline helpers in `src/mesh/NodeDB.h`:
|
||||
Booleans are bit-packed into `NodeInfoLite.bitfield`. **Do not read or write the bits directly** - use the inline helpers in `src/mesh/NodeDB.h`:
|
||||
|
||||
```cpp
|
||||
nodeInfoLiteHasUser(n) // bit 5 — user fields populated
|
||||
nodeInfoLiteHasUser(n) // bit 5 - user fields populated
|
||||
nodeInfoLiteIsFavorite(n) // bit 3
|
||||
nodeInfoLiteIsIgnored(n) // bit 4
|
||||
nodeInfoLiteIsMuted(n) // bit 1
|
||||
nodeInfoLiteIsLicensed(n) // bit 6 — Ham mode peer
|
||||
nodeInfoLiteIsLicensed(n) // bit 6 - Ham mode peer
|
||||
nodeInfoLiteIsKeyManuallyVerified(n) // bit 0
|
||||
nodeInfoLiteHasIsUnmessagable(n) // bit 8 — "is_unmessagable was sent"
|
||||
nodeInfoLiteHasIsUnmessagable(n) // bit 8 - "is_unmessagable was sent"
|
||||
nodeInfoLiteIsUnmessagable(n) // bit 7
|
||||
// via_mqtt is bit 2 (mask exposed; predicate uses the mask directly)
|
||||
|
||||
@@ -188,9 +188,9 @@ Four `std::unordered_map<NodeNum, …>` members on `NodeDB`, each gated by its o
|
||||
| `nodeEnvironment` | `meshtastic_EnvironmentMetrics` | `MESHTASTIC_EXCLUDE_ENVIRONMENTDB` |
|
||||
| `nodeStatus` | `meshtastic_StatusMessage` | `MESHTASTIC_EXCLUDE_STATUSDB` |
|
||||
|
||||
Defaults are ON (i.e., maps **excluded**) for STM32WL only — see `src/mesh/mesh-pb-constants.h`. On every other arch all four maps are present. When excluded, the map member is absent and the corresponding accessors return `false`.
|
||||
Defaults are ON (i.e., maps **excluded**) for STM32WL only - see `src/mesh/mesh-pb-constants.h`. On every other arch all four maps are present. When excluded, the map member is absent and the corresponding accessors return `false`.
|
||||
|
||||
All four maps are guarded by **`mutable concurrency::Lock satelliteMutex`** — concurrent access from receive threads, the phone API state machine, and the renderer is the rule, not the exception.
|
||||
All four maps are guarded by **`mutable concurrency::Lock satelliteMutex`** - concurrent access from receive threads, the phone API state machine, and the renderer is the rule, not the exception.
|
||||
|
||||
### Accessor convention
|
||||
|
||||
@@ -203,22 +203,22 @@ bool copyNodeEnvironment(NodeNum, meshtastic_EnvironmentMetrics &out) const;
|
||||
bool copyNodeStatus(NodeNum, meshtastic_StatusMessage &out) const;
|
||||
```
|
||||
|
||||
Each takes the lock, copies the value if present, returns `false` if the entry is absent or the DB is excluded. Pass-by-out-param is deliberate — pointer-style accessors would invite UAF and lock-leak bugs across the renderer. The "has any X" convenience predicates (`hasValidPosition` etc.) are implemented in terms of these.
|
||||
Each takes the lock, copies the value if present, returns `false` if the entry is absent or the DB is excluded. Pass-by-out-param is deliberate - pointer-style accessors would invite UAF and lock-leak bugs across the renderer. The "has any X" convenience predicates (`hasValidPosition` etc.) are implemented in terms of these.
|
||||
|
||||
Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which dispatches on `which_variant` for device vs environment metrics) — these own the lock and the eviction hooks.
|
||||
Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which dispatches on `which_variant` for device vs environment metrics) - these own the lock and the eviction hooks.
|
||||
|
||||
### Eviction
|
||||
|
||||
Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `demoteOldestHotNodesToWarm` (the over-cap warm-tier migration), `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly. (Note: `enforceSatelliteCaps`/`evictSatelliteOverCap` call `.erase()` directly on purpose — that's a satellite-only cap trim where the node _stays_ in the header, a different operation from this chokepoint.)
|
||||
Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `demoteOldestHotNodesToWarm` (the over-cap warm-tier migration), `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly. (Note: `enforceSatelliteCaps`/`evictSatelliteOverCap` call `.erase()` directly on purpose - that's a satellite-only cap trim where the node _stays_ in the header, a different operation from this chokepoint.)
|
||||
|
||||
### Warm tier (long-tail identity)
|
||||
|
||||
On every arch except STM32WL and bare nRF52832 (`WARM_NODE_COUNT > 0`), a node evicted from the header table is not forgotten outright: `WarmNodeStore` (`src/mesh/WarmNodeStore.{h,cpp}`) keeps a 40 B `{num, last_heard, public_key}` record per evicted node — primarily so PKI DMs to/from a long-tail node keep decrypting without re-running a NodeInfo exchange (the rest of `NodeInfoLite` rebuilds from traffic in seconds).
|
||||
On every arch except STM32WL and bare nRF52832 (`WARM_NODE_COUNT > 0`), a node evicted from the header table is not forgotten outright: `WarmNodeStore` (`src/mesh/WarmNodeStore.{h,cpp}`) keeps a 40 B `{num, last_heard, public_key}` record per evicted node - primarily so PKI DMs to/from a long-tail node keep decrypting without re-running a NodeInfo exchange (the rest of `NodeInfoLite` rebuilds from traffic in seconds).
|
||||
|
||||
- **Write:** `getOrCreateMeshNode`'s eviction and `demoteOldestHotNodesToWarm` (the over-cap boot migration) call `warmStore.absorb(num, last_heard, key)` _before_ the node leaves the header.
|
||||
- **Read-back:** `getOrCreateMeshNode` calls `warmStore.take()` to rehydrate `last_heard` + key when a warm node is re-admitted; `copyPublicKey()` falls back to the warm tier so the PKI send path finds keys for evicted peers.
|
||||
- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring at `0xEA000` (below LittleFS; append + replay + compact-on-rotate, link-guarded by `nrf52840_s140_v7.ld` and `extra_scripts/nrf52_warm_region.py`). Everywhere else: a `/prefs/warm.dat` snapshot flushed by `saveIfDirty()` on the node-DB save cadence.
|
||||
- **Tunables** (`mesh-pb-constants.h`): `WARM_NODE_COUNT` (per-arch; `0` disables the tier) and `MAX_NUM_NODES` (hot cap — 120 on nRF52840/generic ESP32 to fit the 28 KB LittleFS; ESP32-S3 keeps its flash-scaled 100/200/250, portduino 250). Verbose migration/self-care tracing routes through `LOG_MIGRATION`, gated by `MESHTASTIC_NODEDB_MIGRATION_VERBOSE`.
|
||||
- **Tunables** (`mesh-pb-constants.h`): `WARM_NODE_COUNT` (per-arch; `0` disables the tier) and `MAX_NUM_NODES` (hot cap - 120 on nRF52840/generic ESP32 to fit the 28 KB LittleFS; ESP32-S3 keeps its flash-scaled 100/200/250, portduino 250). Verbose migration/self-care tracing routes through `LOG_MIGRATION`, gated by `MESHTASTIC_NODEDB_MIGRATION_VERBOSE`.
|
||||
|
||||
### Satellite caps
|
||||
|
||||
@@ -226,26 +226,26 @@ Only the freshest `MAX_SATELLITE_NODES` nodes keep satellite payloads; the rest
|
||||
|
||||
### On-boot self-care
|
||||
|
||||
`NodeDB::nodeDBSelfCare()` runs once identity is established (the constructor after key (re)gen, and `reloadFromDisk()` — _not_ inside `loadFromDisk`, where `getNodeNum()` is still 0). It confirms self is present (warns if a non-empty DB is missing us — a foreign/over-cap file), pins self to index 0, demotes/trims only **non-self** overflow into the warm tier, then rewrites `nodes.proto` **once** and only if it healed something — and never while encrypted storage is locked (it would persist placeholder defaults). `loadFromDisk` deliberately leaves the loaded store untrimmed for this pass.
|
||||
`NodeDB::nodeDBSelfCare()` runs once identity is established (the constructor after key (re)gen, and `reloadFromDisk()` - _not_ inside `loadFromDisk`, where `getNodeNum()` is still 0). It confirms self is present (warns if a non-empty DB is missing us - a foreign/over-cap file), pins self to index 0, demotes/trims only **non-self** overflow into the warm tier, then rewrites `nodes.proto` **once** and only if it healed something - and never while encrypted storage is locked (it would persist placeholder defaults). `loadFromDisk` deliberately leaves the loaded store untrimmed for this pass.
|
||||
|
||||
### Sync flow: thin NodeInfo + post-COMPLETE_ID replay (no opt-in)
|
||||
|
||||
There is no capability flag and no special "gradient" nonce. The **default** sync flow is:
|
||||
|
||||
1. Config / module-config / channel / metadata segments (same as before).
|
||||
2. `STATE_SEND_OWN_NODEINFO` — **our own** NodeInfo, still bundled with our position and device_metrics (because the replay snapshot excludes our own NodeNum). Emitted via `ConvertToNodeInfo(lite)`.
|
||||
3. `STATE_SEND_OTHER_NODEINFOS` — every other peer's NodeInfo, **always thin** (no `position`, no `device_metrics`). Emitted via `ConvertToNodeInfoThin(lite)`.
|
||||
4. `STATE_SEND_FILEMANIFEST` → `STATE_SEND_COMPLETE_ID` — the phone sees `config_complete_id` and treats sync as done.
|
||||
5. `STATE_SEND_PACKETS` — live mesh packets, with a trailing replay drain interleaved. The replay drain walks four cached satellite stores in order (positions → telemetry → environment → status) and emits each cached entry as an ordinary `MeshPacket` on the matching portnum (`POSITION_APP`, `TELEMETRY_APP` device + environment variants, `NODE_STATUS_APP`). These are indistinguishable on the wire from live mesh traffic, so clients need no special handling — any code that already updates UI on `POSITION_APP` etc. works.
|
||||
2. `STATE_SEND_OWN_NODEINFO` - **our own** NodeInfo, still bundled with our position and device_metrics (because the replay snapshot excludes our own NodeNum). Emitted via `ConvertToNodeInfo(lite)`.
|
||||
3. `STATE_SEND_OTHER_NODEINFOS` - every other peer's NodeInfo, **always thin** (no `position`, no `device_metrics`). Emitted via `ConvertToNodeInfoThin(lite)`.
|
||||
4. `STATE_SEND_FILEMANIFEST` → `STATE_SEND_COMPLETE_ID` - the phone sees `config_complete_id` and treats sync as done.
|
||||
5. `STATE_SEND_PACKETS` - live mesh packets, with a trailing replay drain interleaved. The replay drain walks four cached satellite stores in order (positions → telemetry → environment → status) and emits each cached entry as an ordinary `MeshPacket` on the matching portnum (`POSITION_APP`, `TELEMETRY_APP` device + environment variants, `NODE_STATUS_APP`). These are indistinguishable on the wire from live mesh traffic, so clients need no special handling - any code that already updates UI on `POSITION_APP` etc. works.
|
||||
|
||||
`PhoneAPI::sendConfigComplete()` arms `replayPhase = REPLAY_PHASE_POSITIONS` for default/full sync and `SPECIAL_NONCE_ONLY_NODES`, while `SPECIAL_NONCE_ONLY_CONFIG` skips replay. The drain runs inside `STATE_SEND_PACKETS` via `popReplayPacket()`, lower priority than live traffic. When all four phases drain, `replayPhase` flips back to `REPLAY_PHASE_IDLE` and the snapshot vectors get `shrink_to_fit`ed.
|
||||
|
||||
STM32WL and any other build with all four `MESHTASTIC_EXCLUDE_*DB` flags set produces zero replay packets — `popReplayPacket` advances through each phase in microseconds without emitting anything.
|
||||
STM32WL and any other build with all four `MESHTASTIC_EXCLUDE_*DB` flags set produces zero replay packets - `popReplayPacket` advances through each phase in microseconds without emitting anything.
|
||||
|
||||
Special nonces that still mean something:
|
||||
|
||||
- `SPECIAL_NONCE_ONLY_CONFIG` (69420) — skip node sync entirely, just config.
|
||||
- `SPECIAL_NONCE_ONLY_NODES` (69421) — skip config segments, jump straight to `STATE_SEND_OWN_NODEINFO`. Still gets the post-COMPLETE_ID replay drain.
|
||||
- `SPECIAL_NONCE_ONLY_CONFIG` (69420) - skip node sync entirely, just config.
|
||||
- `SPECIAL_NONCE_ONLY_NODES` (69421) - skip config segments, jump straight to `STATE_SEND_OWN_NODEINFO`. Still gets the post-COMPLETE_ID replay drain.
|
||||
|
||||
There are no other reserved nonces; everything else is a fresh random `want_config_id` from the client.
|
||||
|
||||
@@ -255,13 +255,13 @@ The legacy migration code lives in **`src/mesh/NodeDBLegacyMigration.cpp`**, not
|
||||
|
||||
### Read-site rules of thumb
|
||||
|
||||
- Never `node->position.X` / `node->device_metrics.X` — those fields no longer exist. Pull from the satellite map via `copyNodePosition` / `copyNodeTelemetry`.
|
||||
- Never `node->user.long_name` — `long_name`, `short_name`, `public_key`, `hw_model`, `role`, `macaddr` (gone), `is_licensed`, `is_unmessagable` are flat on `NodeInfoLite`.
|
||||
- Never `node->is_favorite` / `node->is_ignored` / `node->via_mqtt` / `node->is_key_manually_verified` — use the bitfield helpers.
|
||||
- Never assume `nodeDB->getMeshNode(num)->position.time` — call `copyNodePosition` and check the return.
|
||||
- Never `node->position.X` / `node->device_metrics.X` - those fields no longer exist. Pull from the satellite map via `copyNodePosition` / `copyNodeTelemetry`.
|
||||
- Never `node->user.long_name` - `long_name`, `short_name`, `public_key`, `hw_model`, `role`, `macaddr` (gone), `is_licensed`, `is_unmessagable` are flat on `NodeInfoLite`.
|
||||
- Never `node->is_favorite` / `node->is_ignored` / `node->via_mqtt` / `node->is_key_manually_verified` - use the bitfield helpers.
|
||||
- Never assume `nodeDB->getMeshNode(num)->position.time` - call `copyNodePosition` and check the return.
|
||||
- Don't lock `satelliteMutex` yourself in renderer code; the copy-out accessors already do.
|
||||
|
||||
Unit tests for the conversion layer live in `test/test_type_conversions/test_main.cpp` (Unity) — bitfield round-trips, `long_name` truncation, thin-vs-full conversions. Add cases there when extending the schema.
|
||||
Unit tests for the conversion layer live in `test/test_type_conversions/test_main.cpp` (Unity) - bitfield round-trips, `long_name` truncation, thin-vs-full conversions. Add cases there when extending the schema.
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -320,23 +320,23 @@ firmware/
|
||||
|
||||
### Formatting & the trunk toolchain
|
||||
|
||||
`trunk fmt` is the project formatter (`trunk_check` CI rejects unformatted code). For Claude Code users, `.claude/settings.json` ships a PostToolUse hook that runs `trunk fmt --force` on every file the agent writes or edits. The hook is pure sh/grep/sed — no python or jq required — but trunk itself must be able to run:
|
||||
`trunk fmt` is the project formatter (`trunk_check` CI rejects unformatted code). For Claude Code users, `.claude/settings.json` ships a PostToolUse hook that runs `trunk fmt --force` on every file the agent writes or edits. The hook is pure sh/grep/sed - no python or jq required - but trunk itself must be able to run:
|
||||
|
||||
- Trunk's launcher (`~/.cache/trunk/launcher/trunk`, or `trunk` on PATH) downloads the CLI version pinned in `.trunk/trunk.yaml` on first use and again whenever that pin is bumped. **The launcher needs `curl` or `wget`**; without one it fails with "Cannot download… please install curl or wget", and the hook surfaces that as a warning on every write.
|
||||
- No curl/wget available (e.g. a minimal WSL image)? Bootstrap by hand with any Python (PlatformIO bundles one at `~/.platformio/penv/bin/python`): download `https://trunk.io/releases/<ver>/trunk-<ver>-linux-x86_64.tar.gz` and place the `trunk` binary at `~/.cache/trunk/cli/<ver>-linux-x86_64/trunk` (chmod +x), where `<ver>` is the `cli.version` from `.trunk/trunk.yaml`.
|
||||
- The hook fails loudly by design (visible warning, non-blocking). Silent no-op formatting hooks hide real breakage — don't re-add `2>/dev/null || true` around the whole thing.
|
||||
- More generally: don't assume a stock Linux userland in hooks or helper scripts — minimal WSL/container images may lack `python3`, `curl`, `wget`, and `jq`. Prefer plain sh + coreutils, or PlatformIO's bundled Python for anything heavier.
|
||||
- The hook fails loudly by design (visible warning, non-blocking). Silent no-op formatting hooks hide real breakage - don't re-add `2>/dev/null || true` around the whole thing.
|
||||
- More generally: don't assume a stock Linux userland in hooks or helper scripts - minimal WSL/container images may lack `python3`, `curl`, `wget`, and `jq`. Prefer plain sh + coreutils, or PlatformIO's bundled Python for anything heavier.
|
||||
|
||||
### General Style
|
||||
|
||||
- Follow existing code style - run `trunk fmt` before commits
|
||||
- Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging
|
||||
- **Format node IDs and packet IDs as `0x%08x` in logs.** This covers `NodeNum`/`PacketId` and the `uint32_t` packet fields `from`, `to`, `id`, `dest`, `source`, `request_id`, and `node_id`. They are 32-bit, so 8 hex digits is exact — `%08x` never truncates or leaves a value ragged. Do **not** use `%x` (variable width) or `%0x` (a no-op typo for `%08x` — the `0` flag does nothing without a width). User-facing display uses `!%08x` (the `!xxxxxxxx` convention), e.g. `Applet::hexifyNodeNum`.
|
||||
- **Do not zero-pad one-byte values to 8.** `next_hop`, `relay_node`, and the next-hop hint are `uint8_t` last-byte route hints, and `channel` is a one-byte hash/index — log these as `0x%x` (or `%d`). Padding a byte to `0x000000ab` falsely implies a full node number. The same goes for I2C addresses, register values, flags/bitmasks, and error/reason codes: they are not IDs, so leave them `0x%x`.
|
||||
- **Format node IDs and packet IDs as `0x%08x` in logs.** This covers `NodeNum`/`PacketId` and the `uint32_t` packet fields `from`, `to`, `id`, `dest`, `source`, `request_id`, and `node_id`. They are 32-bit, so 8 hex digits is exact - `%08x` never truncates or leaves a value ragged. Do **not** use `%x` (variable width) or `%0x` (a no-op typo for `%08x` - the `0` flag does nothing without a width). User-facing display uses `!%08x` (the `!xxxxxxxx` convention), e.g. `Applet::hexifyNodeNum`.
|
||||
- **Do not zero-pad one-byte values to 8.** `next_hop`, `relay_node`, and the next-hop hint are `uint8_t` last-byte route hints, and `channel` is a one-byte hash/index - log these as `0x%x` (or `%d`). Padding a byte to `0x000000ab` falsely implies a full node number. The same goes for I2C addresses, register values, flags/bitmasks, and error/reason codes: they are not IDs, so leave them `0x%x`.
|
||||
- Use `assert()` for invariants that should never fail
|
||||
- C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.)
|
||||
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||
- **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check - raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
@@ -426,7 +426,7 @@ Multiple display driver families in `src/graphics/`:
|
||||
|
||||
**InkHUD** (`src/graphics/niche/InkHUD/`) is an event-driven e-ink UI framework:
|
||||
|
||||
- Applet-based architecture — modular display tiles
|
||||
- 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`
|
||||
@@ -483,7 +483,7 @@ Key defines in variant.h:
|
||||
- Regenerate with `bin/regen-protos.sh`
|
||||
- Message types prefixed with `meshtastic_`
|
||||
- Nanopb `.options` files control field sizes and encoding
|
||||
- **Never edit or commit files under `src/mesh/generated/`.** They are regenerated from the [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) submodule by the `update_protobufs.yml` GitHub Action and any hand edits will be overwritten — guaranteed merge conflict on the next sync. To change a wire format, open a PR against the protobufs repo first; the workflow then re-runs `bin/regen-protos.sh` and opens a PR here with the regenerated sources.
|
||||
- **Never edit or commit files under `src/mesh/generated/`.** They are regenerated from the [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) submodule by the `update_protobufs.yml` GitHub Action and any hand edits will be overwritten - guaranteed merge conflict on the next sync. To change a wire format, open a PR against the protobufs repo first; the workflow then re-runs `bin/regen-protos.sh` and opens a PR here with the regenerated sources.
|
||||
|
||||
### Conditional Compilation
|
||||
|
||||
@@ -556,7 +556,7 @@ pio run -e native-macos # Build headless macOS meshtasticd (Homebrew prere
|
||||
|
||||
1. Create directory under `variants/<arch>/<name>/`
|
||||
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`)
|
||||
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
|
||||
|
||||
@@ -683,7 +683,7 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su
|
||||
- `test_utf8/` - UTF-8 utilities
|
||||
- `test_warm_store/` - Warm-tier node store
|
||||
|
||||
**Preferred run command — `bin/run-tests.sh`** (uses the `coverage` env with ASan/LSan sanitizers; emits a machine-readable verdict on the final line; update `test/native-suite-count` when adding or removing suites):
|
||||
**Preferred run command - `bin/run-tests.sh`** (uses the `coverage` env with ASan/LSan sanitizers; emits a machine-readable verdict on the final line; update `test/native-suite-count` when adding or removing suites):
|
||||
|
||||
```bash
|
||||
./bin/run-tests.sh # all suites
|
||||
@@ -699,7 +699,7 @@ Exit codes and verdicts (exact counts will vary; examples below are illustrative
|
||||
| 2 | `AMBER` | All that ran passed, but something was lost: a suite silently went missing on a full run, individual test cases were skipped (`TEST_IGNORE`), or `test/native-suite-count` disagrees with the `test/` directory count |
|
||||
| 3 | `FILTERED` | A `-f` run completed cleanly; suites outside the filter were intentionally not run |
|
||||
|
||||
Examples — exact counts will vary by suite count and env:
|
||||
Examples - exact counts will vary by suite count and env:
|
||||
|
||||
```text
|
||||
# GREEN: all suites ran and passed
|
||||
@@ -709,21 +709,21 @@ RESULT: GREEN N/N suites passed [canonical: N/N]
|
||||
RESULT: RED 1 failed
|
||||
|
||||
# RED: sanitizer exit-time abort (all tests passed but process aborted at exit)
|
||||
RESULT: RED exit-time abort (tests passed; likely sanitizer — see hint above)
|
||||
RESULT: RED exit-time abort (tests passed; likely sanitizer - see hint above)
|
||||
|
||||
# AMBER: native-suite-count disagrees with test/ directory count (too low)
|
||||
RESULT: AMBER test/ has 24 suite directories but native-suite-count says 5 — update test/native-suite-count after registering new suites
|
||||
RESULT: AMBER test/ has 24 suite directories but native-suite-count says 5 - update test/native-suite-count after registering new suites
|
||||
|
||||
# AMBER: native-suite-count disagrees with test/ directory count (too high)
|
||||
RESULT: AMBER test/ has 24 suite directories but native-suite-count says 99 — update test/native-suite-count after removing suites
|
||||
RESULT: AMBER test/ has 24 suite directories but native-suite-count says 99 - update test/native-suite-count after removing suites
|
||||
|
||||
# FILTERED: single suite run completed cleanly
|
||||
RESULT: FILTERED 1/24 suites ran (not run: test_admin_radio test_atak …) — filtered: test_serial [canonical: 1/24]
|
||||
RESULT: FILTERED 1/24 suites ran (not run: test_admin_radio test_atak …) - filtered: test_serial [canonical: 1/24]
|
||||
```
|
||||
|
||||
> **Copilot interface note:** When running tests via the Copilot chat interface, edits made through the chat may not be reflected in the on-disk files that the test binary reads. If tests pass in chat but fail locally (or vice versa), verify the files on disk match what you expect before trusting the result. Always confirm with a local terminal run.
|
||||
|
||||
Raw `pio test` (no sanitizers, no verdict logic) — use only when you need to override the env:
|
||||
Raw `pio test` (no sanitizers, no verdict logic) - use only when you need to override the env:
|
||||
|
||||
```bash
|
||||
~/.platformio/penv/bin/python -m platformio test -e native -f test_your_suite > /tmp/test_out.txt 2>&1
|
||||
@@ -731,7 +731,7 @@ grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
|
||||
tail -15 /tmp/test_out.txt
|
||||
```
|
||||
|
||||
Do **not** pipe `pio test` — line-buffering makes the terminal appear hung and hides build errors.
|
||||
Do **not** pipe `pio test` - line-buffering makes the terminal appear hung and hides build errors.
|
||||
|
||||
Simulation testing: `bin/test-simulator.sh`
|
||||
|
||||
@@ -743,9 +743,9 @@ Separate pytest suite that exercises real USB-connected Meshtastic devices. See
|
||||
|
||||
## 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 `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]'`).
|
||||
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
|
||||
|
||||
@@ -753,7 +753,7 @@ The repo registers the server via `.mcp.json` at the repo root — Claude Code p
|
||||
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| 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` |
|
||||
| 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` |
|
||||
@@ -775,11 +775,11 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v
|
||||
- **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.
|
||||
- **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`.
|
||||
`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`.
|
||||
|
||||
**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step — use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See `mcp-server/README.md` § "TCP / native-host nodes".
|
||||
**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=<host[:port]>` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step - use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See `mcp-server/README.md` § "TCP / native-host nodes".
|
||||
|
||||
### Hardware test suite (`mcp-server/run-tests.sh`)
|
||||
|
||||
@@ -787,16 +787,16 @@ The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf5
|
||||
|
||||
Suite tiers (collected + run in this order via `pytest_collection_modifyitems`):
|
||||
|
||||
1. `tests/unit/` — pure Python (boards parse, pio wrapper, userPrefs parse, testing profile, 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.
|
||||
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:
|
||||
|
||||
@@ -809,14 +809,14 @@ Invocation patterns:
|
||||
./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.
|
||||
**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.
|
||||
- `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`)
|
||||
|
||||
@@ -863,19 +863,19 @@ House rules for agents running these prompts:
|
||||
|
||||
`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>`).
|
||||
- **`_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.
|
||||
- **`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.
|
||||
|
||||
@@ -883,7 +883,7 @@ If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` fl
|
||||
|
||||
| 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`. |
|
||||
| `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. |
|
||||
@@ -893,17 +893,17 @@ If you're modifying `StreamAPI`, `PhoneAPI`, `NodeInfoModule`, or `userPrefs` fl
|
||||
| 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. |
|
||||
| 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.
|
||||
- `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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ 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
|
||||
# `/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.
|
||||
|
||||
@@ -20,13 +20,13 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co
|
||||
- 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):**
|
||||
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:
|
||||
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.
|
||||
@@ -44,7 +44,7 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co
|
||||
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.
|
||||
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`?
|
||||
@@ -52,7 +52,7 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co
|
||||
- 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."
|
||||
- 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.
|
||||
@@ -61,4 +61,4 @@ This prompt assumes the meshtastic MCP server is registered with your VS Code Co
|
||||
|
||||
- **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.
|
||||
- **Don't infer env beyond the VID map** - if the operator has an unusual board, ask them which env to use rather than guessing.
|
||||
|
||||
@@ -3,17 +3,17 @@ 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
|
||||
# `/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?".
|
||||
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).
|
||||
- **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.
|
||||
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:
|
||||
|
||||
@@ -43,12 +43,12 @@ Equivalent of `.claude/commands/repro.md`. Use when the operator says "that one
|
||||
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.
|
||||
- **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.
|
||||
@@ -57,12 +57,12 @@ Equivalent of `.claude/commands/repro.md`. Use when the operator says "that one
|
||||
|
||||
## 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.
|
||||
- `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.
|
||||
- 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.
|
||||
|
||||
@@ -3,7 +3,7 @@ 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
|
||||
# `/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.
|
||||
|
||||
@@ -19,7 +19,7 @@ Equivalent of the Claude Code `/test` slash command in `.claude/commands/test.md
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -35,23 +35,23 @@ Equivalent of the Claude Code `/test` slash command in `.claude/commands/test.md
|
||||
- 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.
|
||||
- **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.
|
||||
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
|
||||
- `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.
|
||||
- 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.
|
||||
|
||||
@@ -6,9 +6,9 @@ Guide for developing a new Meshtastic firmware module.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -32,7 +32,7 @@ class MyModule : public ProtobufModule<meshtastic_MyMessage>, private concurrenc
|
||||
// Generate response packet (optional)
|
||||
virtual meshtastic_MeshPacket *allocReply() override;
|
||||
|
||||
// Periodic task — return next run interval in ms, or disable()
|
||||
// Periodic task - return next run interval in ms, or disable()
|
||||
virtual int32_t runOnce() override;
|
||||
|
||||
// Modify packet in-flight before delivery (optional)
|
||||
|
||||
@@ -6,10 +6,10 @@ Guide for adding a new I2C telemetry sensor driver to Meshtastic firmware.
|
||||
|
||||
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
|
||||
- **EnvironmentTelemetryModule** - Temperature, humidity, pressure, gas, light
|
||||
- **AirQualityTelemetryModule** - Particulate matter, VOCs
|
||||
- **PowerTelemetryModule** - Voltage, current, power monitoring
|
||||
- **HealthTelemetryModule** - Heart rate, SpO2, body temperature
|
||||
|
||||
## Sensor Driver Pattern
|
||||
|
||||
@@ -75,10 +75,10 @@ The scan runs at boot and populates a device map that telemetry modules use to d
|
||||
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
|
||||
- `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`
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@ variants/
|
||||
|
||||
Each variant needs at minimum:
|
||||
|
||||
- `variant.h` — Pin definitions and hardware capabilities
|
||||
- `platformio.ini` — Build configuration
|
||||
- `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
|
||||
- `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
|
||||
|
||||
@@ -101,25 +101,25 @@ 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
|
||||
- `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
|
||||
- `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
|
||||
- `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
|
||||
@@ -127,7 +127,7 @@ upload_speed = 921600
|
||||
For e-ink display variants using the InkHUD framework, add `nicheGraphics.h`:
|
||||
|
||||
```cpp
|
||||
// nicheGraphics.h — InkHUD configuration for this variant
|
||||
// nicheGraphics.h - InkHUD configuration for this variant
|
||||
#define INKHUD // Enable InkHUD
|
||||
// Configure display, applets, and refresh behavior per device
|
||||
```
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Build PortDuino WASM
|
||||
|
||||
# Reusable workflow — called as the `build-wasm` job of the main CI workflow
|
||||
# Reusable workflow - called as the `build-wasm` job of the main CI workflow
|
||||
# (main_matrix.yml), so the WebAssembly portduino node is built like every other
|
||||
# platform. Builds the [env:native-wasm] target (src/platform/portduino/wasm/) with
|
||||
# `pio run -e native-wasm` so it can't silently bit-rot. Software/CI only — asserts the
|
||||
# `pio run -e native-wasm` so it can't silently bit-rot. Software/CI only - asserts the
|
||||
# full mesh stack + RadioLib + Crypto compile and link to meshnode.{mjs,wasm}
|
||||
# via the meshtastic/platform-wasm PlatformIO platform (emcc/Asyncify). It does
|
||||
# NOT exercise the radio (that's CH341/WebUSB, hardware).
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
// Resolve the PR by matching the run's head SHA against the repo's open
|
||||
// PRs. workflow_run.pull_requests is empty for fork PRs, and
|
||||
// listPullRequestsAssociatedWithCommit won't return an open fork PR by
|
||||
// its head commit — but pulls.list includes fork PRs. Matching on head
|
||||
// its head commit - but pulls.list includes fork PRs. Matching on head
|
||||
// SHA also enforces that the run is for the PR's current commit, so stale
|
||||
// re-runs of an outdated commit won't match.
|
||||
const openPrs = await github.paginate(github.rest.pulls.list, {
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
const prNumber = pr.number;
|
||||
|
||||
// Restrict to trusted authors. NOTE: author_association is computed for
|
||||
// the GITHUB_TOKEN, which cannot see *private/concealed* org memberships —
|
||||
// the GITHUB_TOKEN, which cannot see *private/concealed* org memberships -
|
||||
// those members come back as CONTRIBUTOR, not MEMBER. So gating on MEMBER
|
||||
// alone silently excludes most maintainers. We allow the trusted set the
|
||||
// token can actually identify (members, collaborators, and anyone with a
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
// Read each built board's manifest (.mt.json). activelySupported,
|
||||
// displayName and architecture come straight from the board's
|
||||
// custom_meshtastic_* platformio config, so the list is in sync with
|
||||
// the firmware itself — no external device database needed.
|
||||
// the firmware itself - no external device database needed.
|
||||
const fs = require('fs');
|
||||
let boards = [];
|
||||
try {
|
||||
@@ -132,8 +132,8 @@ jobs:
|
||||
.join('\n');
|
||||
|
||||
// Shields.io badges. Only non-user-controlled, charset-constrained values
|
||||
// (version, commit sha, counts, dates) go into badge URLs — never board
|
||||
// names or the PR title — so the rendered comment cannot be spoofed.
|
||||
// (version, commit sha, counts, dates) go into badge URLs - never board
|
||||
// names or the PR title - so the rendered comment cannot be spoofed.
|
||||
const shieldText = (s) =>
|
||||
encodeURIComponent(String(s).replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'));
|
||||
const shield = (label, message, color) =>
|
||||
|
||||
@@ -6,7 +6,7 @@ name: Post Web Flasher Build Placeholder
|
||||
#
|
||||
# SECURITY: this uses pull_request_target (write token, runs for fork PRs) but is
|
||||
# safe because it never checks out or runs PR code and posts a fully static body
|
||||
# — no PR title, branch name, or other untrusted input is used anywhere.
|
||||
# - no PR title, branch name, or other untrusted input is used anywhere.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -31,14 +31,14 @@ jobs:
|
||||
|
||||
// Trusted authors only (matches the real workflow). author_association
|
||||
// can't reflect private org membership for the token, so concealed
|
||||
// members appear as CONTRIBUTOR — include it, or maintainers are excluded.
|
||||
// members appear as CONTRIBUTOR - include it, or maintainers are excluded.
|
||||
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (!allowedAssociations.includes(pr.author_association)) {
|
||||
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only seed a placeholder when no flasher comment exists yet — never
|
||||
// Only seed a placeholder when no flasher comment exists yet - never
|
||||
// overwrite a real (or existing placeholder) comment.
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: pr.number, per_page: 100,
|
||||
|
||||
@@ -149,8 +149,8 @@ jobs:
|
||||
build-wasm:
|
||||
# Build the WebAssembly portduino node ([env:native-wasm]) as part of normal CI,
|
||||
# like the other platforms. It's a dedicated job (not a row in the `build`
|
||||
# matrix) because its artifact is meshnode.{mjs,wasm} — not a flashable
|
||||
# .bin/.uf2/.hex — and it needs the Emscripten SDK; board_level=extra keeps
|
||||
# matrix) because its artifact is meshnode.{mjs,wasm} - not a flashable
|
||||
# .bin/.uf2/.hex - and it needs the Emscripten SDK; board_level=extra keeps
|
||||
# it out of generate_ci_matrix.py.
|
||||
uses: ./.github/workflows/build_portduino_wasm.yml
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
|
||||
# The Python harness sends exit_simulator and exits; the simulator is
|
||||
# expected to terminate on its own. Give it a moment, then verify.
|
||||
# If it is still alive the exit handshake is broken — fail loudly and
|
||||
# If it is still alive the exit handshake is broken - fail loudly and
|
||||
# do NOT fall through to `wait`, which would otherwise block until the
|
||||
# job's hard timeout.
|
||||
for i in $(seq 1 10); do
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
> | New module skeleton | inherit `ProtobufModule<T>` in `src/mesh/ProtobufModule.h` |
|
||||
> | Observer / event wiring | `src/Observer.h` |
|
||||
|
||||
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.
|
||||
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.
|
||||
**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.
|
||||
|
||||
@@ -34,7 +34,7 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don
|
||||
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
|
||||
| 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) | `./bin/run-tests.sh` (preferred — ASan/LSan + RED/AMBER/GREEN verdict); or raw: `~/.platformio/penv/bin/python -m platformio test -e native > /tmp/test_out.txt 2>&1` |
|
||||
| Run firmware unit tests (native) | `./bin/run-tests.sh` (preferred - ASan/LSan + RED/AMBER/GREEN verdict); or raw: `~/.platformio/penv/bin/python -m platformio test -e native > /tmp/test_out.txt 2>&1` |
|
||||
| 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` |
|
||||
@@ -53,7 +53,7 @@ The `mcp-server/` package exposes ~32 MCP tools for device discovery, building,
|
||||
- **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.
|
||||
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).
|
||||
|
||||
@@ -61,9 +61,9 @@ See `mcp-server/README.md` for argument shapes and the **MCP Server & Hardware T
|
||||
|
||||
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
|
||||
- **`/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.
|
||||
|
||||
@@ -71,22 +71,22 @@ Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude
|
||||
|
||||
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`).
|
||||
- **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).
|
||||
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.
|
||||
- **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. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI — see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure.
|
||||
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI - see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure.
|
||||
- **Never edit or commit files under `src/mesh/generated/`.** They are regenerated from the [`meshtastic/protobufs`](https://github.com/meshtastic/protobufs) repo by the `update_protobufs.yml` workflow (entry point: `bin/regen-protos.sh`). Local edits will be overwritten and create merge conflicts. If a `.proto` change is needed, open a PR against the protobufs repo first, then let the workflow re-sync this repo.
|
||||
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
|
||||
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||
- **Keep code comments minimal - one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check - raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||
|
||||
## Typical agent workflows
|
||||
|
||||
@@ -98,9 +98,9 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
|
||||
|
||||
### 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
|
||||
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.
|
||||
|
||||
@@ -114,9 +114,9 @@ Sequence these; don't parallelize on the same port.
|
||||
|
||||
### Debugging a flaky test
|
||||
|
||||
1. `/repro <test-node-id> [count]` — re-runs the test N times, diffs firmware logs between passes and failures
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -132,7 +132,7 @@ Sequence these; don't parallelize on the same port.
|
||||
| `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/copilot-instructions.md` | **Primary agent instructions - read this** |
|
||||
| `.github/workflows/` | CI pipelines |
|
||||
| `.mcp.json` | MCP server registration for Claude Code |
|
||||
|
||||
@@ -142,8 +142,8 @@ Sequence these; don't parallelize on the same port.
|
||||
- **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.
|
||||
- **macOS: `LIBUSB_ERROR_BUSY` on a CH341 LoRa adapter?** A third-party WCH `CH34xVCPDriver` is claiming interface 0. Find the bundle ID with `ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512`, then `sudo kmutil unload -b <bundleID>`. Apple's bundled CH34x kext targets the CH340 UART (PID 0x7523), not the SPI bridge — it's never the culprit.
|
||||
- **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` - zombies hold ports. Kill all but the one your host spawned.
|
||||
- **macOS: `LIBUSB_ERROR_BUSY` on a CH341 LoRa adapter?** A third-party WCH `CH34xVCPDriver` is claiming interface 0. Find the bundle ID with `ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512`, then `sudo kmutil unload -b <bundleID>`. Apple's bundled CH34x kext targets the CH340 UART (PID 0x7523), not the SPI bridge - it's never the culprit.
|
||||
|
||||
## Environment variables (test harness)
|
||||
|
||||
@@ -154,7 +154,7 @@ Sequence these; don't parallelize on the same port.
|
||||
| `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_MCP_TCP_HOST` | `host` or `host:port` of a `meshtasticd` daemon (e.g. the `native-macos` build). Surfaces it in `list_devices` as `tcp://host:port` so `connect()`-based tools target it transparently. Default port 4403. |
|
||||
| `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_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. |
|
||||
|
||||
@@ -29,8 +29,8 @@ Meshtastic enables text messaging, location sharing, and telemetry over a decent
|
||||
|
||||
### Get Started
|
||||
|
||||
- 🔧 **[Building Instructions](https://meshtastic.org/docs/development/firmware/build)** – Learn how to compile the firmware from source.
|
||||
- ⚡ **[Flashing Instructions](https://meshtastic.org/docs/getting-started/flashing-firmware/)** – Install or update the firmware on your device.
|
||||
- 🔧 **[Building Instructions](https://meshtastic.org/docs/development/firmware/build)** - Learn how to compile the firmware from source.
|
||||
- ⚡ **[Flashing Instructions](https://meshtastic.org/docs/getting-started/flashing-firmware/)** - Install or update the firmware on your device.
|
||||
|
||||
Join our community and help improve Meshtastic! 🚀
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# LoRa Region → Preset Compatibility — Client Implementation Spec
|
||||
# LoRa Region → Preset Compatibility - Client Implementation Spec
|
||||
|
||||
**Status:** Draft for 2.8 · **Audience:** Meshtastic client app developers (Android first,
|
||||
Apple second, then web/python) · **Firmware side:** implemented in `firmware`
|
||||
@@ -13,7 +13,7 @@ Apple second, then web/python) · **Firmware side:** implemented in `firmware`
|
||||
## 1. Why this exists
|
||||
|
||||
For 2.8 the LoRa regions and modem presets were reworked. **Not every modem preset is legal
|
||||
in every region** — narrow EU SRD bands, the EU 868 "narrow" band, amateur/ham bands, and
|
||||
in every region** - narrow EU SRD bands, the EU 868 "narrow" band, amateur/ham bands, and
|
||||
the 2.4 GHz band each accept only a specific subset of presets. The firmware already
|
||||
enforces this internally (it clamps or rejects illegal combinations), but until now a client
|
||||
had no way to _know_ the rules, so a user could pick an illegal region+preset pair in the UI
|
||||
@@ -22,7 +22,7 @@ and only discover the problem after the device silently corrected it.
|
||||
This feature has the firmware **declare the legal region→preset combinations** to the client
|
||||
during the `want_config` handshake, so the client UI can constrain the preset picker to the
|
||||
valid set for the currently selected region (and warn about licensed-only bands). It is
|
||||
purely advisory metadata — the firmware remains the source of truth and still
|
||||
purely advisory metadata - the firmware remains the source of truth and still
|
||||
validates/clamps on its own.
|
||||
|
||||
---
|
||||
@@ -74,7 +74,7 @@ share one identical preset list (the "standard" 9-preset list), so the map is de
|
||||
known region to one of those groups by index. This keeps the encoded size additive
|
||||
(`groups` + `region_groups`) rather than multiplicative, well under the cap.
|
||||
|
||||
nanopb (firmware) array bounds — clients do **not** need to enforce these, but they bound
|
||||
nanopb (firmware) array bounds - clients do **not** need to enforce these, but they bound
|
||||
what you can receive:
|
||||
|
||||
| field | max_count |
|
||||
@@ -154,7 +154,7 @@ These rules are what keep the UX correct across firmware versions. Implement all
|
||||
(`EU_868` / `EU_866` / `EU_N_868`) specially: if the user is in one of them and selects a
|
||||
preset that belongs to a sibling's list, the firmware **swaps the region** rather than
|
||||
rejecting the preset. Consequence for clients: **do not assume the region is immutable
|
||||
across a preset change** — after an admin config write, re-read the resulting
|
||||
across a preset change** - after an admin config write, re-read the resulting
|
||||
`LoRaConfig` and reflect the (possibly changed) region back into the UI.
|
||||
|
||||
6. **Use it as a UI guard, not a validator of truth.** The firmware still validates/clamps
|
||||
@@ -171,7 +171,7 @@ These rules are what keep the UX correct across firmware versions. Implement all
|
||||
that region's `default_preset`.
|
||||
- Show a **licensed badge / confirmation** for regions where `licensed_only == true`.
|
||||
- If a region is absent from the map (rule §5.1) or the whole message is absent (§5.2),
|
||||
render the full preset list as before — never show an empty picker.
|
||||
render the full preset list as before - never show an empty picker.
|
||||
|
||||
---
|
||||
|
||||
@@ -193,14 +193,14 @@ These rules are what keep the UX correct across firmware versions. Implement all
|
||||
> Verified against the `main` branch of each repo. Both have been refactored away from
|
||||
> older layouts; re-pin file paths against a specific commit if you need them durable.
|
||||
|
||||
### 8.1 Android — `meshtastic/Meshtastic-Android` (Kotlin / Compose, KMP)
|
||||
### 8.1 Android - `meshtastic/Meshtastic-Android` (Kotlin / Compose, KMP)
|
||||
|
||||
- **Protobufs are a published Maven artifact, _not_ a submodule.** Declared in
|
||||
`gradle/libs.versions.toml` (`org.meshtastic:protobufs`, currently `2.7.25`); generated
|
||||
package is **`org.meshtastic.proto`**. **A `region_presets`-aware build requires a new
|
||||
published `org.meshtastic:protobufs` release**, then bumping that one version string.
|
||||
- **The protobufs are Wire-generated**, so the `FromRadio` oneof is **not** a
|
||||
`payloadVariantCase` enum — each arm is a **nullable field**. Handle the new variant in
|
||||
`payloadVariantCase` enum - each arm is a **nullable field**. Handle the new variant in
|
||||
`FromRadioPacketHandlerImpl.handleFromRadio(...)`
|
||||
(`core/data/.../manager/FromRadioPacketHandlerImpl.kt`) by adding a
|
||||
`regionPresets != null -> …` arm to the existing `when { … }`, delegating to a handler
|
||||
@@ -213,16 +213,16 @@ These rules are what keep the UX correct across firmware versions. Implement all
|
||||
`LoRaConfigScreen`). Gate/filter the `ChannelOption` (preset) dropdown by the selected
|
||||
`RegionInfo`'s entry in the map.
|
||||
|
||||
### 8.2 Apple — `meshtastic/Meshtastic-Apple` (Swift / SwiftUI)
|
||||
### 8.2 Apple - `meshtastic/Meshtastic-Apple` (Swift / SwiftUI)
|
||||
|
||||
- **Protobufs are vendored** into a local Swift package `MeshtasticProtobufs`
|
||||
(`MeshtasticProtobufs/Sources/meshtastic/*.pb.swift`), generated from the `protobufs` git
|
||||
submodule via `scripts/gen_protos.sh`. **To get field 19:** advance the `protobufs`
|
||||
submodule, run `scripts/gen_protos.sh`, commit the regenerated `.pb.swift` + submodule
|
||||
pointer. (No published-artifact dependency — Apple can regenerate from any commit.)
|
||||
pointer. (No published-artifact dependency - Apple can regenerate from any commit.)
|
||||
- **Dispatch:** `AccessoryManager.processFromRadio(_:)`
|
||||
(`Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift`) is a real
|
||||
`switch decodedInfo.payloadVariant { … }` — add a `.regionPresets` case, with the handler
|
||||
`switch decodedInfo.payloadVariant { … }` - add a `.regionPresets` case, with the handler
|
||||
in `AccessoryManager+FromRadio.swift` (mirror `handleConfig` / `handleMetadata`).
|
||||
- **Persistence:** config is **SwiftData** (`@Model` entities), upserted via
|
||||
`MeshPackets`/`UpdateSwiftData.swift`. Store the decoded map (e.g. on a settings/connection
|
||||
|
||||
+60
-60
@@ -1,22 +1,22 @@
|
||||
# Mesh Beacon Module — Function, Settings, and Client Interface Spec
|
||||
# Mesh Beacon Module - Function, Settings, and Client Interface Spec
|
||||
|
||||
Status: draft, tracks firmware branch `feat/mesh-beacon`.
|
||||
Audience: firmware reviewers (Part 1) and client-app developers — Android / Apple / Web / Python (Part 2).
|
||||
Audience: firmware reviewers (Part 1) and client-app developers - Android / Apple / Web / Python (Part 2).
|
||||
|
||||
The Mesh Beacon module lets a node periodically **advertise the existence of a mesh** to
|
||||
nodes that are not yet on it — broadcasting a short human-readable message plus an optional
|
||||
nodes that are not yet on it - broadcasting a short human-readable message plus an optional
|
||||
"join offer" (a channel, region, and modem preset). It is the mechanism behind invitations
|
||||
like _"Join us on NarrowSlow"_: a node sitting on one preset/region can shout an invitation
|
||||
that listeners on other presets/regions can hear and surface to their user.
|
||||
|
||||
The module is deliberately **advisory**. The firmware never auto-joins an advertised
|
||||
channel or auto-switches preset/region in response to a received beacon — it delivers the
|
||||
channel or auto-switches preset/region in response to a received beacon - it delivers the
|
||||
information to the client app and stops there. All "should I act on this?" decisions belong
|
||||
to the client and, ultimately, the user.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Function and settings choices
|
||||
## Part 1 - Function and settings choices
|
||||
|
||||
### 1.1 Two roles in one module
|
||||
|
||||
@@ -25,7 +25,7 @@ to the client and, ultimately, the user.
|
||||
| **Broadcaster** | `MeshBeaconBroadcastModule` | `FLAG_BROADCAST_ENABLED` set | Periodically transmits `MESH_BEACON_APP` packets on the configured radio settings. |
|
||||
| **Listener** | `MeshBeaconListenerModule` | `FLAG_LISTEN_ENABLED` set | Receives `MESH_BEACON_APP` packets and caches the offer for the client (the packet itself flows to the client unchanged). |
|
||||
|
||||
The boolean toggles live in a single `flags` bitfield (see [§1.8](#18-settings-reference-moduleconfigmeshbeaconconfig-tag-17)) — broadcasting and
|
||||
The boolean toggles live in a single `flags` bitfield (see [§1.8](#18-settings-reference-moduleconfigmeshbeaconconfig-tag-17)) - broadcasting and
|
||||
listening can be enabled independently on the same node. The whole module compiles out under the
|
||||
`MESHTASTIC_EXCLUDE_BEACON` build flag.
|
||||
|
||||
@@ -60,16 +60,16 @@ Every outgoing beacon packet is stamped uniformly (`sendBeacon` → `stampPacket
|
||||
|
||||
- `to = NODENUM_BROADCAST`
|
||||
- `from = local node` (see [§1.6](#16-broadcast_send_as_node-currently-disabled) for the disabled spoof path)
|
||||
- **`hop_limit = 0`** — beacons are **zero-hop**. They are never rebroadcast by the mesh; only
|
||||
- **`hop_limit = 0`** - beacons are **zero-hop**. They are never rebroadcast by the mesh; only
|
||||
direct RF neighbours hear them. This is the primary spam-control mechanism. (`hop_start` is
|
||||
normally `0` too, but `FLAG_LEGACY_SPLIT` raises it to `1` for old-firmware compatibility — see
|
||||
normally `0` too, but `FLAG_LEGACY_SPLIT` raises it to `1` for old-firmware compatibility - see
|
||||
[§1.5](#15-legacy-split-flag_legacy_split).)
|
||||
- `priority = BACKGROUND`, `want_ack = false`.
|
||||
|
||||
Broadcasting is additionally gated at runtime by:
|
||||
|
||||
- airtime utilisation (`isTxAllowedAirUtil()`), and
|
||||
- device role — **`CLIENT_HIDDEN` never broadcasts**.
|
||||
- device role - **`CLIENT_HIDDEN` never broadcasts**.
|
||||
|
||||
#### Interval
|
||||
|
||||
@@ -78,7 +78,7 @@ Broadcasting is additionally gated at runtime by:
|
||||
floor are silently raised, both at config-set time (AdminModule) and at runtime.
|
||||
|
||||
The cadence is **reboot-safe**. Each broadcast's time is persisted to flash via `TransmitHistory`
|
||||
(keyed by `MESH_BEACON_APP`), and the broadcaster reads it back on boot — so a node that reboots
|
||||
(keyed by `MESH_BEACON_APP`), and the broadcaster reads it back on boot - so a node that reboots
|
||||
(or crash-loops) won't re-broadcast until a full interval has elapsed since its last real send,
|
||||
rather than firing ~30 s after every boot. The timestamp is written **before** the transmit, so a
|
||||
brown-out during the high-current LoRa TX still counts as "sent." This mirrors `NodeInfoModule` /
|
||||
@@ -90,13 +90,13 @@ A beacon's whole point is often to reach a mesh on a _different_ preset/region/c
|
||||
broadcaster currently runs. Before transmitting a beacon tagged with target radio settings, the
|
||||
module temporarily reconfigures the radio (`reconfigureForBeaconTX`), sends, then restores the
|
||||
prior config. Per-packet target settings are held in an 8-entry **sidecar table** keyed by packet
|
||||
ID — chosen so the `MeshPacket` proto carries no extra per-packet radio fields, and normal
|
||||
ID - chosen so the `MeshPacket` proto carries no extra per-packet radio fields, and normal
|
||||
(non-beacon) traffic is never touched.
|
||||
|
||||
Two safety guards run before any radio switch (`beaconTxConfigInvalid`):
|
||||
|
||||
1. **An unlicensed node never keys up on a licensed-only (ham) region.** (The reverse — a licensed
|
||||
node operating in a non-ham region — is allowed. The switch only touches preset/region/channel,
|
||||
1. **An unlicensed node never keys up on a licensed-only (ham) region.** (The reverse - a licensed
|
||||
node operating in a non-ham region - is allowed. The switch only touches preset/region/channel,
|
||||
never `owner.is_licensed`.)
|
||||
2. **The preset must be valid for the target region** (`validateConfigLora`).
|
||||
|
||||
@@ -109,13 +109,13 @@ Encryption keys off the **primary** channel slot, and the radio-thread channel s
|
||||
_after_ encryption. So when a beacon goes out on an override channel (different name/PSK), the
|
||||
module installs the beacon channel into the primary slot for the synchronous duration of
|
||||
`send()`, then restores it (`sendBeaconPacket`). This guarantees the packet is encrypted with the
|
||||
beacon channel's key and stamped with its hash — not the primary's. Meshtastic threading is
|
||||
beacon channel's key and stamped with its hash - not the primary's. Meshtastic threading is
|
||||
cooperative, so there is no preemption between swap and restore.
|
||||
|
||||
### 1.4 Where beacons are sent: single-target and multi-target
|
||||
|
||||
The broadcaster can send to one set of radio settings or to several. **Single- and multi-target
|
||||
are equal options — neither is preferred and neither is legacy.** Pick whichever matches the
|
||||
are equal options - neither is preferred and neither is legacy.** Pick whichever matches the
|
||||
deployment.
|
||||
|
||||
- **Single-target:** the scalar `broadcast_on_preset` / `broadcast_on_region` /
|
||||
@@ -124,9 +124,9 @@ deployment.
|
||||
non-empty it takes over from the scalar `broadcast_on_*` fields, and the broadcaster sends **one
|
||||
beacon copy per entry**. Each `BroadcastTarget` is `{ optional preset, region, optional channel_index }`,
|
||||
where `channel_index` references a slot in the node's own channel table (the channel must already be
|
||||
configured locally — its key is needed to encrypt the beacon). Within one cycle, targets that
|
||||
resolve to the **same** effective preset/region/channel are de-duplicated — only the first is
|
||||
transmitted — so an accidentally repeated entry costs no extra airtime.
|
||||
configured locally - its key is needed to encrypt the beacon). Within one cycle, targets that
|
||||
resolve to the **same** effective preset/region/channel are de-duplicated - only the first is
|
||||
transmitted - so an accidentally repeated entry costs no extra airtime.
|
||||
|
||||
#### Same-settings vs. other-settings
|
||||
|
||||
@@ -135,7 +135,7 @@ settings** or specify **different** ones:
|
||||
|
||||
- **Same-settings ("message of the day"):** leave the preset / region / channel unset. They fall
|
||||
back to the running config, so the beacon goes out on the node's current mesh with **no radio
|
||||
switch** — a plain periodic broadcast to whoever is already on this preset/region.
|
||||
switch** - a plain periodic broadcast to whoever is already on this preset/region.
|
||||
- **Other-settings (cross-mesh invite):** set a preset / region / channel that differs from the
|
||||
running config. The radio is temporarily switched for that copy's TX, then restored (see
|
||||
[§1.3](#radio-switching-for-tx)).
|
||||
@@ -154,8 +154,8 @@ offer, but old firmware only decodes `TEXT_MESSAGE_APP` and would never show the
|
||||
`FLAG_LEGACY_SPLIT` is set **and both text and offer content are present**, the broadcaster
|
||||
emits **two** packets on the same beacon radio settings instead of one:
|
||||
|
||||
- **Packet A** — `MESH_BEACON_APP` carrying the **offer only** (no text).
|
||||
- **Packet B** — `TEXT_MESSAGE_APP` carrying the **text only**.
|
||||
- **Packet A** - `MESH_BEACON_APP` carrying the **offer only** (no text).
|
||||
- **Packet B** - `TEXT_MESSAGE_APP` carrying the **text only**.
|
||||
|
||||
This is an independent two-packet decision, not an either/or: offer-only and text-only payloads
|
||||
still go out as a single packet in their respective cases; only the both-present case splits.
|
||||
@@ -163,7 +163,7 @@ still go out as a single packet in their respective cases; only the both-present
|
||||
**(b) `hop_start = 1` override.** When `FLAG_LEGACY_SPLIT` is set, **every** beacon packet it sends
|
||||
(combined, split-A, or split-B; even same-settings ones) is stamped with `hop_start = 1` while
|
||||
`hop_limit` stays `0`. Pre-2.7.20 firmware drops `hop_start == 0` packets in a pre-decryption check
|
||||
before it can read the bitfield, so `hop_start = 1` lets those nodes accept the beacon — and it
|
||||
before it can read the bitfield, so `hop_start = 1` lets those nodes accept the beacon - and it
|
||||
remains genuinely zero-hop (`hop_limit = 0` still prevents any rebroadcast).
|
||||
|
||||
> **Side effect for clients:** with `hop_start = 1, hop_limit = 0`, receivers compute
|
||||
@@ -181,26 +181,26 @@ AdminModule and should be treated as canonical:
|
||||
> A remote admin may only set `broadcast_send_as_node` to **their own** node ID
|
||||
> (`mp.from`). Any other value is rejected and reset to the stored value.
|
||||
|
||||
Design note for when it is re-enabled: it is a _node-ID_ spoof only — it rewrites `from` but forges
|
||||
Design note for when it is re-enabled: it is a _node-ID_ spoof only - it rewrites `from` but forges
|
||||
no signature. Once `from` is not us, the packet is no longer `isFromUs()`, so the router skips
|
||||
XEdDSA signing and receivers get an unsigned packet attributed to another node.
|
||||
|
||||
### 1.7 Reception behaviour (listener)
|
||||
|
||||
When `FLAG_LISTEN_ENABLED` is **off**, the router drops incoming `MESH_BEACON_APP` packets up front
|
||||
(`Router::handleReceived`, same pattern as a disabled NeighborInfo module) — so they reach neither
|
||||
(`Router::handleReceived`, same pattern as a disabled NeighborInfo module) - so they reach neither
|
||||
the modules nor the phone. When it is **on**, the packet flows normally and the listener's
|
||||
`wantPacket` accepts it (`has_mesh_beacon` + `FLAG_LISTEN_ENABLED` + `portnum == MESH_BEACON_APP`).
|
||||
On a valid beacon (`handleReceivedProtobuf`):
|
||||
|
||||
1. **Offer → cache.** Any offer (`offer_channel` / `offer_region` / `offer_preset`) is stored in
|
||||
the static `lastReceivedOffer` (sender, channel, region, preset, `received_at`). `received_at`
|
||||
is `0` if the node has no RTC fix yet — **consumers must not treat `0` as a valid timestamp.**
|
||||
is `0` if the node has no RTC fix yet - **consumers must not treat `0` as a valid timestamp.**
|
||||
2. **Never auto-applied.** The firmware does not switch channel/preset/region from a received
|
||||
offer. Acting on it is the client app's job.
|
||||
3. The handler returns `CONTINUE` (not `STOP`), so the original `MESH_BEACON_APP` packet **flows to
|
||||
the client unchanged** through the normal FromRadio path (see Part 2). The client reads the
|
||||
`message` field directly from that packet — there is no separate copy.
|
||||
`message` field directly from that packet - there is no separate copy.
|
||||
|
||||
The firmware deliberately does **not** unwrap a combined beacon's text into a synthesized
|
||||
`TEXT_MESSAGE_APP`, and does **not** fire `EVENT_RECEIVED_MSG`: a beacon is an advisory broadcast,
|
||||
@@ -236,35 +236,35 @@ a real `TEXT_MESSAGE_APP` over RF (see [§1.5](#15-legacy-split-flag_legacy_spli
|
||||
| 2 | `FLAG_BROADCAST_ENABLED` | Periodically broadcast beacons from this node. |
|
||||
| 4 | `FLAG_LEGACY_SPLIT` | Legacy compatibility: (a) split text+offer into separate `TEXT_MESSAGE_APP` + `MESH_BEACON_APP` packets, and (b) stamp `hop_start = 1` on every beacon so pre-2.7.20 firmware accepts it (see [§1.5](#15-legacy-split-flag_legacy_split)). |
|
||||
|
||||
`BroadcastTarget`: `1 preset` (optional, falls back to running config), `2 region` (`UNSET` = running config), `4 channel_index` (optional `uint32`, index into the node's channel table; if unset, the default channel for the preset is used). Tag `3` is an unused gap — it previously held an embedded `ChannelSettings`, dropped to keep `ModuleConfig` within the BLE `FromRadio` size budget.
|
||||
`BroadcastTarget`: `1 preset` (optional, falls back to running config), `2 region` (`UNSET` = running config), `4 channel_index` (optional `uint32`, index into the node's channel table; if unset, the default channel for the preset is used). Tag `3` is an unused gap - it previously held an embedded `ChannelSettings`, dropped to keep `ModuleConfig` within the BLE `FromRadio` size budget.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Client interface specification
|
||||
## Part 2 - Client interface specification
|
||||
|
||||
This section is what a client app needs to integrate with the beacon module. Everything goes
|
||||
through the **standard admin / ToRadio / FromRadio protocol** — there is no bespoke transport.
|
||||
through the **standard admin / ToRadio / FromRadio protocol** - there is no bespoke transport.
|
||||
|
||||
### 2.1 Capability detection
|
||||
|
||||
The module is build-flag optional. Treat it as present when the node's `LocalModuleConfig`
|
||||
contains a `mesh_beacon` sub-message (`LocalModuleConfig.mesh_beacon`, tag 18). If absent, the
|
||||
firmware was built with `MESHTASTIC_EXCLUDE_BEACON` — hide the beacon UI.
|
||||
firmware was built with `MESHTASTIC_EXCLUDE_BEACON` - hide the beacon UI.
|
||||
|
||||
### 2.2 Reading and writing configuration
|
||||
|
||||
Standard module-config flow — no new admin messages:
|
||||
Standard module-config flow - no new admin messages:
|
||||
|
||||
- **Read:** `AdminMessage.get_module_config_request = ModuleConfig.MeshBeaconConfig` (variant 17).
|
||||
Reply is `get_module_config_response` with the `mesh_beacon` payload.
|
||||
- **Write:** `AdminMessage.set_module_config { mesh_beacon = … }`.
|
||||
|
||||
The on/off toggles (listen, broadcast, legacy-split) are bits in the `flags` field, not separate
|
||||
booleans — read/write them with the `MeshBeaconConfig.Flags` values
|
||||
booleans - read/write them with the `MeshBeaconConfig.Flags` values
|
||||
(`FLAG_LISTEN_ENABLED = 1`, `FLAG_BROADCAST_ENABLED = 2`, `FLAG_LEGACY_SPLIT = 4`). To toggle one
|
||||
bit, read the current `flags`, set/clear the bit, and write the whole config back.
|
||||
|
||||
The firmware **sanitises on write** — your value may be silently adjusted. Mirror these rules
|
||||
The firmware **sanitises on write** - your value may be silently adjusted. Mirror these rules
|
||||
client-side so the UI doesn't disagree with the device:
|
||||
|
||||
| Rule | Firmware behaviour |
|
||||
@@ -276,7 +276,7 @@ client-side so the UI doesn't disagree with the device:
|
||||
| `broadcast_offer_region` not a known region | Cleared to `UNSET`. |
|
||||
| `broadcast_targets[i].region` not a known region | That entry's region cleared to `UNSET` (TX falls back to running config). |
|
||||
| `broadcast_targets[i].preset` invalid for that entry's region | That entry's `preset` and `channel_index` cleared. |
|
||||
| `broadcast_targets[i].channel_index` ≥ `MAX_NUM_CHANNELS` (8) | That entry's `channel_index` cleared (existence is **not** checked — see §2.5). |
|
||||
| `broadcast_targets[i].channel_index` ≥ `MAX_NUM_CHANNELS` (8) | That entry's `channel_index` cleared (existence is **not** checked - see §2.5). |
|
||||
| `broadcast_send_as_node` ≠ sender's node ID (remote admin) | Rejected, reset to stored value. |
|
||||
|
||||
Setting beacon config does **not** trigger a reboot (`shouldReboot = false`); changes take effect
|
||||
@@ -285,7 +285,7 @@ effective (sanitised) values.
|
||||
|
||||
### 2.3 Receiving beacons
|
||||
|
||||
A received beacon reaches the client as a normal `FromRadio.packet` (`MeshPacket`) — the listener
|
||||
A received beacon reaches the client as a normal `FromRadio.packet` (`MeshPacket`) - the listener
|
||||
returns `CONTINUE`, so the packet is **not** consumed on-device. The client must:
|
||||
|
||||
1. Subscribe to the FromRadio packet stream as usual.
|
||||
@@ -295,48 +295,48 @@ returns `CONTINUE`, so the packet is **not** consumed on-device. The client must
|
||||
4. `packet.from` is the **originating beaconer** (the firmware preserves it).
|
||||
|
||||
> **Requires `FLAG_LISTEN_ENABLED` set in `flags`.** With listening disabled the firmware drops
|
||||
> received `MESH_BEACON_APP` packets in the router — before they reach the phone or any on-device
|
||||
> handler — the same way it drops a disabled module's packets (e.g. NeighborInfo). The node still
|
||||
> received `MESH_BEACON_APP` packets in the router - before they reach the phone or any on-device
|
||||
> handler - the same way it drops a disabled module's packets (e.g. NeighborInfo). The node still
|
||||
> physically receives the RF, but the client will not see beacons over the FromRadio stream until
|
||||
> listening is enabled.
|
||||
|
||||
#### Reading the text — no duplication
|
||||
#### Reading the text - no duplication
|
||||
|
||||
For a beacon-aware client the text is **simply the `message` field of the `MESH_BEACON_APP`
|
||||
packet** you already decode for the offer (step 3 above). One packet, one field — the firmware does
|
||||
packet** you already decode for the offer (step 3 above). One packet, one field - the firmware does
|
||||
**not** inject a separate `TEXT_MESSAGE_APP` copy, so there is nothing to deduplicate.
|
||||
|
||||
The only time a beacon's text arrives as a separate `TEXT_MESSAGE_APP` is when the broadcaster set
|
||||
`FLAG_LEGACY_SPLIT`: in that mode the `MESH_BEACON_APP` carries the **offer only** (empty `message`)
|
||||
and the text is sent as a normal `TEXT_MESSAGE_APP` over RF, so legacy/non-beacon-aware clients can
|
||||
display it. These two cases are mutually exclusive — a given beacon's text appears exactly once,
|
||||
either in `MESH_BEACON_APP.message` (combined) or as a `TEXT_MESSAGE_APP` (legacy-split) — so a
|
||||
display it. These two cases are mutually exclusive - a given beacon's text appears exactly once,
|
||||
either in `MESH_BEACON_APP.message` (combined) or as a `TEXT_MESSAGE_APP` (legacy-split) - so a
|
||||
client never needs to dedup. Render whichever it receives.
|
||||
|
||||
### 2.4 Acting on an offer (the core client responsibility)
|
||||
|
||||
When a `MESH_BEACON_APP` carries offer content, present it to the user as an **invitation** —
|
||||
When a `MESH_BEACON_APP` carries offer content, present it to the user as an **invitation** -
|
||||
e.g. _"Node ⟨from⟩ invites you to join '⟨offer_channel.name⟩' on ⟨preset⟩/⟨region⟩."_ Then, only on
|
||||
explicit user confirmation, apply it by writing normal config:
|
||||
|
||||
- `offer_channel` → add/replace a `Channel` (`set_channel`), typically as a secondary channel.
|
||||
- `offer_region` / `offer_preset` → `set_config { lora = … }` (`use_preset = true`, set
|
||||
`modem_preset` and `region`). **Note this changes the node's own radio and will drop it off its
|
||||
current mesh** — make that consequence explicit in the UI.
|
||||
current mesh** - make that consequence explicit in the UI.
|
||||
|
||||
**The firmware will never do any of this for the user. No silent auto-apply.** The on-device
|
||||
`lastReceivedOffer` cache is a firmware-internal convenience and is **not** currently exposed via
|
||||
an admin message — clients should source offers from the live `MESH_BEACON_APP` packet stream
|
||||
an admin message - clients should source offers from the live `MESH_BEACON_APP` packet stream
|
||||
(§2.3), not expect a "get last offer" RPC.
|
||||
|
||||
#### Offer trust model — read before applying
|
||||
#### Offer trust model - read before applying
|
||||
|
||||
- **The advertised PSK is not a secret.** `offer_channel.psk` is a public join token sent in the
|
||||
clear inside a broadcast; it is a convenience, not a security boundary. An operator who wants a
|
||||
genuinely private channel must distribute the PSK out-of-band and leave `offer_channel` unset.
|
||||
Surface offered channels as **public/open** to the user.
|
||||
- **Validate before applying.** Reject or warn if `offer_preset` is not valid for `offer_region`,
|
||||
and **never** apply a licensed-only (ham) region for a user who is not a licensed operator —
|
||||
and **never** apply a licensed-only (ham) region for a user who is not a licensed operator -
|
||||
mirror the firmware's own guard.
|
||||
- Beacons are **unsigned** when sent as another node (the disabled send-as path), and even normal
|
||||
beacons assert nothing about the sender's authority. Treat `from` as informational.
|
||||
@@ -356,7 +356,7 @@ broadcast_offer_preset = NARROW_SLOW
|
||||
broadcast_offer_region = EU_N_868
|
||||
broadcast_offer_channel = { name: "MyChannel", psk: <32-byte key> }
|
||||
broadcast_interval_secs = 3600
|
||||
// channel_index points at slots in THIS node's channel table — configure those channels first.
|
||||
// channel_index points at slots in THIS node's channel table - configure those channels first.
|
||||
broadcast_targets = [
|
||||
{ preset: LONG_FAST, region: EU_868, channel_index: 0 },
|
||||
{ preset: NARROW_SLOW, region: EU_N_868, channel_index: 1 },
|
||||
@@ -364,13 +364,13 @@ broadcast_targets = [
|
||||
```
|
||||
|
||||
The same fields can be baked in at build time via `userPrefs.jsonc`
|
||||
(`USERPREFS_MESH_BEACON_*`) — see that file for the full list, including
|
||||
(`USERPREFS_MESH_BEACON_*`) - see that file for the full list, including
|
||||
`USERPREFS_MESH_BEACON_TARGET_<n>_*` for multi-target entries.
|
||||
|
||||
#### Single-target vs. multi-target — equal options, different channel representation
|
||||
#### Single-target vs. multi-target - equal options, different channel representation
|
||||
|
||||
Single-target and multi-target are **equal, first-class options**. Neither is preferred,
|
||||
deprecated, or a "legacy" fallback — pick whichever matches the deployment (a single-target
|
||||
deprecated, or a "legacy" fallback - pick whichever matches the deployment (a single-target
|
||||
beacon with no overrides is a plain message-of-the-day; a multi-target list reaches several
|
||||
preset/region/channel combinations). The broadcaster uses `broadcast_targets` when it is
|
||||
non-empty and the scalar `broadcast_on_*` fields when it is empty.
|
||||
@@ -379,13 +379,13 @@ The one **subtle implementation difference** is how each names its TX channel:
|
||||
|
||||
| Path | TX channel is specified by | Channel name/PSK live… |
|
||||
| ------------- | ------------------------------------------------------- | ----------------------------------------- |
|
||||
| Single-target | `broadcast_on_channel` — an embedded `ChannelSettings` | …inline in the beacon config |
|
||||
| Multi-target | `broadcast_targets[i].channel_index` — a `uint32` index | …in the node's channel table (referenced) |
|
||||
| Single-target | `broadcast_on_channel` - an embedded `ChannelSettings` | …inline in the beacon config |
|
||||
| Multi-target | `broadcast_targets[i].channel_index` - a `uint32` index | …in the node's channel table (referenced) |
|
||||
|
||||
This asymmetry is deliberate: embedding a full `ChannelSettings` in every one of the (up to
|
||||
four) targets would push `ModuleConfig` past the BLE `FromRadio` size limit, so a target
|
||||
references an already-configured channel-table slot instead. `broadcast_offer_channel` (the
|
||||
advertised join token) is **always** inline regardless of path — it is the advertisement payload
|
||||
advertised join token) is **always** inline regardless of path - it is the advertisement payload
|
||||
and must carry the actual name/PSK.
|
||||
|
||||
#### Configuring a multi-target broadcaster (two-step)
|
||||
@@ -412,19 +412,19 @@ admin writes**, in order:
|
||||
|
||||
Notes:
|
||||
|
||||
- A target may **only** reference a channel that already exists locally — the node needs that
|
||||
- A target may **only** reference a channel that already exists locally - the node needs that
|
||||
channel's key to encrypt the beacon. A `channel_index` that is out of range, or points at a
|
||||
blank/unconfigured slot, is not an error: the beacon falls back to the node's **current/primary
|
||||
channel** (its name, PSK, and slot) on the target preset/region. The channel name only defaults
|
||||
to the preset's display name (e.g. `LongFast`) when the primary channel itself is unnamed — so
|
||||
to the preset's display name (e.g. `LongFast`) when the primary channel itself is unnamed - so
|
||||
the fallback is "broadcast on my home channel," **not** a freshly-synthesised default-PSK channel
|
||||
for that preset.
|
||||
- `channel_index` must be `< MAX_NUM_CHANNELS` (8); the firmware clears it on write otherwise (see
|
||||
§2.2 sanitise rules). This is the **only** check on write — the firmware does **not** verify that
|
||||
§2.2 sanitise rules). This is the **only** check on write - the firmware does **not** verify that
|
||||
the referenced slot is actually populated, because you may legitimately write the beacon config
|
||||
before creating the channel. **Validating that a referenced channel exists is the client app's
|
||||
responsibility.** A dangling reference doesn't error; it silently falls back to the preset's
|
||||
default channel — so without a client-side check, the user can believe they're advertising
|
||||
default channel - so without a client-side check, the user can believe they're advertising
|
||||
channel _X_ while the node is really transmitting on the preset default. Before writing, confirm
|
||||
each `channel_index` maps to a configured `Channel`, and warn the user otherwise.
|
||||
- **No automatic deduplication of channels.** Neither the beacon config nor the channel table
|
||||
@@ -432,9 +432,9 @@ Notes:
|
||||
indices whose slots hold identical settings, and `set_channel` will happily store two slots with
|
||||
the same name/PSK. The broadcaster _does_ skip transmitting a target whose effective
|
||||
preset/region/channel duplicates an earlier one in the same cycle (so a duplicated entry wastes
|
||||
no airtime), but it does not rewrite or reject your config — keeping the target list free of
|
||||
no airtime), but it does not rewrite or reject your config - keeping the target list free of
|
||||
redundant entries is up to the client.
|
||||
- The single-target path needs no separate `set_channel` step — its `broadcast_on_channel` is
|
||||
- The single-target path needs no separate `set_channel` step - its `broadcast_on_channel` is
|
||||
written inline in the same beacon-config message.
|
||||
|
||||
### 2.6 Quick reference
|
||||
@@ -449,6 +449,6 @@ Notes:
|
||||
| Min broadcast interval | 3600 s (1 h) |
|
||||
| Message max length | 100 bytes |
|
||||
| Hop behaviour | Zero-hop (`hop_limit = 0`), never rebroadcast; `hop_start = 1` under `FLAG_LEGACY_SPLIT` |
|
||||
| Auto-apply offers? | **Never** — client + user decide |
|
||||
| Auto-apply offers? | **Never** - client + user decide |
|
||||
| Offer PSK | Public join token, not a secret |
|
||||
| Disabled today | `broadcast_send_as_node` application |
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# NextHop direct-message reliability on dense meshes — findings & plan
|
||||
# NextHop direct-message reliability on dense meshes - findings & plan
|
||||
|
||||
**Status:** Implemented — mitigations and tests in `PR3-tmm-nexthop`
|
||||
**Status:** Implemented - mitigations and tests in `PR3-tmm-nexthop`
|
||||
**Date:** 2026-06-13
|
||||
**Area:** `src/mesh` router stack (`NextHopRouter`, `ReliableRouter`, `FloodingRouter`, `Router`, `NodeDB`, `PacketHistory`)
|
||||
**Constraint:** No over-the-air / wire-format changes — `next_hop` and `relay_node` stay 1 byte, no `PacketHeader` changes, no breaking protobuf changes. All new state is RAM-only.
|
||||
**Constraint:** No over-the-air / wire-format changes - `next_hop` and `relay_node` stay 1 byte, no `PacketHeader` changes, no breaking protobuf changes. All new state is RAM-only.
|
||||
|
||||
This document captures the analysis and the proposed mitigations so the work can be
|
||||
continued on this branch by anyone. It is intentionally code-grounded (file:line
|
||||
references throughout) and standalone — you should not need the original investigation
|
||||
references throughout) and standalone - you should not need the original investigation
|
||||
context to pick it up.
|
||||
|
||||
---
|
||||
@@ -17,16 +17,16 @@ context to pick it up.
|
||||
NextHop routing for direct messages (DMs) is unreliable on dense meshes. The headline
|
||||
cause is the **birthday problem**: `next_hop` and `relay_node` are each a single byte
|
||||
(the last byte of a 32-bit node number), so on a mesh of N nodes the probability that
|
||||
two share the same byte hits ~50% at **~19 nodes** and is near-certain by 50–100. But
|
||||
two share the same byte hits ~50% at **~19 nodes** and is near-certain by 50-100. But
|
||||
there are **other, equally important issues**: that single byte is trusted blindly at
|
||||
five different code sites, learned routes **never decay**, routes are learned from the
|
||||
**reverse (ACK) path** (asymmetric-link hazard), and collision-driven spurious
|
||||
rebroadcasts **amplify congestion** exactly when the mesh is busy.
|
||||
|
||||
Because we can't widen the on-wire field, the fix is **interpretation-side** ("don't
|
||||
trust a byte that doesn't map to a unique reachable neighbor — flood instead") plus
|
||||
trust a byte that doesn't map to a unique reachable neighbor - flood instead") plus
|
||||
**recovery-side** ("decay stale/failing routes so they get re-discovered"). Four
|
||||
mitigations, M1–M4, all RAM-only. The net behavioral change: on dense/mobile meshes a
|
||||
mitigations, M1-M4, all RAM-only. The net behavioral change: on dense/mobile meshes a
|
||||
DM that today silently misroutes or black-holes instead falls back to managed flooding
|
||||
(which still delivers) and re-learns a fresh route quickly. Sparse-mesh happy paths are
|
||||
unchanged.
|
||||
@@ -51,25 +51,25 @@ never collides with the `0`-valued sentinels `NO_NEXT_HOP_PREFERENCE` / `NO_RELA
|
||||
route stored per destination, `meshtastic_NodeInfoLite::next_hop`, is also a single byte
|
||||
(`src/mesh/generated/meshtastic/deviceonly.pb.h:83`).
|
||||
|
||||
**Sending a DM** — `NextHopRouter::send` (`src/mesh/NextHopRouter.cpp:23`):
|
||||
**Sending a DM** - `NextHopRouter::send` (`src/mesh/NextHopRouter.cpp:23`):
|
||||
|
||||
1. `p->relay_node = getLastByteOfNodeNum(getNodeNum())` (mark ourselves as relayer).
|
||||
2. `p->next_hop = getNextHop(p->to, p->relay_node)` (`src/mesh/NextHopRouter.cpp:192`):
|
||||
look up `nodeDB->getMeshNode(to)->next_hop`; return it unless it equals the relayer
|
||||
byte; otherwise `NO_NEXT_HOP_PREFERENCE` (→ flood).
|
||||
|
||||
**Relaying** — `NextHopRouter::perhapsRebroadcast` (`src/mesh/NextHopRouter.cpp:133`):
|
||||
**Relaying** - `NextHopRouter::perhapsRebroadcast` (`src/mesh/NextHopRouter.cpp:133`):
|
||||
rebroadcast iff `next_hop == NO_NEXT_HOP_PREFERENCE` (flood) **or**
|
||||
`next_hop == getLastByteOfNodeNum(getNodeNum())` (we are the addressed next hop)
|
||||
(`:147`). Each node only ever compares against **its own** byte.
|
||||
|
||||
**Learning** — `NextHopRouter::sniffReceived` (`src/mesh/NextHopRouter.cpp:89`): on an
|
||||
**Learning** - `NextHopRouter::sniffReceived` (`src/mesh/NextHopRouter.cpp:89`): on an
|
||||
ACK/reply (`request_id`/`reply_id` set), if the relayer of the ACK was also a relayer of
|
||||
the original packet (validated via `PacketHistory::checkRelayers`), set
|
||||
`origTx->next_hop = p->relay_node` (`:114`). I.e. the **forward** next-hop is learned
|
||||
from the **reverse** path's relayer.
|
||||
|
||||
**Retransmission / fallback** — `NextHopRouter::doRetransmissions`
|
||||
**Retransmission / fallback** - `NextHopRouter::doRetransmissions`
|
||||
(`src/mesh/NextHopRouter.cpp:284`). Budgets: `NUM_RELIABLE_RETX=3` (originator: initial
|
||||
|
||||
- 2 retries), `NUM_INTERMEDIATE_RETX=2` (relayer: 1 retry). On the **last** retry
|
||||
@@ -78,7 +78,7 @@ from the **reverse** path's relayer.
|
||||
comes from `iface->getRetransmissionMsec`, whose contention window **grows with channel
|
||||
utilization** (`src/mesh/RadioInterface.cpp` `getTxDelayMsec`/`getTxDelayMsecWeighted`).
|
||||
|
||||
**Dedup / relayer history** — `PacketHistory` (`src/mesh/PacketHistory.cpp`): a bounded
|
||||
**Dedup / relayer history** - `PacketHistory` (`src/mesh/PacketHistory.cpp`): a bounded
|
||||
ring (`PACKETHISTORY_MAX = max(MAX_NUM_NODES*2, 100)`, 20 B/record) keyed by
|
||||
`(sender,id)`, tracking up to `NUM_RELAYERS=6` relayer **bytes** per packet in
|
||||
`relayed_by[]`. `wasRelayer` (`:490`) and `checkRelayers` (`:517`) match bytes against
|
||||
@@ -95,7 +95,7 @@ that array.
|
||||
| 1 | Rebroadcast self-check | `NextHopRouter.cpp:147` | A remote "impostor" node sharing the intended next-hop's byte also rebroadcasts → wasted airtime / congestion. |
|
||||
| 2 | Route learning | `NextHopRouter.cpp:111-114` | Stores an ambiguous byte as the route; later resolves to the wrong physical node. |
|
||||
| 3 | Relayer validation | `PacketHistory.cpp:490-538` | `wasRelayer(byte)` returns true for the wrong node → mis-validated ACK / mis-learn. |
|
||||
| 4 | Favorite-router hop preservation | `Router.cpp:120-145` | **First** NodeDB node whose last byte matches wins — non-deterministic; can preserve hops for the wrong relay (hop leak). |
|
||||
| 4 | Favorite-router hop preservation | `Router.cpp:120-145` | **First** NodeDB node whose last byte matches wins - non-deterministic; can preserve hops for the wrong relay (hop leak). |
|
||||
| 5 | Send-path lookup | `NextHopRouter.cpp:192-207` | Emits a byte that may address the wrong node; no check it still maps to a reachable neighbor. |
|
||||
|
||||
Collision math (uniform last byte over 255 buckets): P(collision) ≈ 50% at ~19 nodes,
|
||||
@@ -106,13 +106,13 @@ Collision math (uniform last byte over 255 buckets): P(collision) ≈ 50% at ~19
|
||||
|
||||
The learned `next_hop` byte is cleared only on the **current DM's** last retry
|
||||
(`NextHopRouter.cpp:313-321`). A route learned hours ago that has since gone dead is
|
||||
still trusted on the **next** DM's first attempt — which on a congested mesh is also the
|
||||
still trusted on the **next** DM's first attempt - which on a congested mesh is also the
|
||||
slowest attempt. Result: silent black-hole at a dead hop until the retransmission budget
|
||||
drains, then a late flood. Intermediate nodes hold stale routes indefinitely.
|
||||
|
||||
### 3. Reverse-path (asymmetric-link) learning
|
||||
|
||||
`origTx->next_hop` is learned from the ACK's relayer (`NextHopRouter.cpp:110-114`) — the
|
||||
`origTx->next_hop` is learned from the ACK's relayer (`NextHopRouter.cpp:110-114`) - the
|
||||
**reverse** direction. RF links are frequently asymmetric, so the best reverse relay can
|
||||
be a poor forward relay. Worse, the next reverse ACK immediately re-learns the same bad
|
||||
hop, so the route **flaps** back to the bad value even after a failure reset.
|
||||
@@ -124,7 +124,7 @@ grows with channel utilization, so retransmit intervals **lengthen** exactly whe
|
||||
mesh is busy. The 3-try reliable budget can then expire before delivery. On dense
|
||||
meshes, efficiency _is_ reliability.
|
||||
|
||||
### Note: pubkey-derived node numbers (develop / 2.8) — does not change the plan
|
||||
### Note: pubkey-derived node numbers (develop / 2.8) - does not change the plan
|
||||
|
||||
develop derives the node number from the public key:
|
||||
`my_node_num = crc32Buffer(public_key)` (`src/mesh/NodeDB.cpp:481`), re-derived on key
|
||||
@@ -140,9 +140,9 @@ plan rather than changing it:
|
||||
- **Resolver gets cleaner inputs.** Stable node numbers keep a learned byte bound to one
|
||||
identity (good for M3 freshness). `createNewIdentity()` retires the old entry by marking
|
||||
it **ignored** and clearing its pubkey (`src/mesh/NodeDB.cpp:3123-3125`), which M1's
|
||||
candidate gate already skips — so key rotation can't pollute resolution.
|
||||
candidate gate already skips - so key rotation can't pollute resolution.
|
||||
- **No wire-free disambiguation unlocked.** A receiver still gets only 1 byte and cannot
|
||||
recover which full node number a colliding value meant — so "detect ambiguity → flood"
|
||||
recover which full node number a colliding value meant - so "detect ambiguity → flood"
|
||||
remains the correct strategy.
|
||||
|
||||
---
|
||||
@@ -151,10 +151,10 @@ plan rather than changing it:
|
||||
|
||||
Key insight for all of M1/M2: **a 1-byte ID only needs to be unique among a node's
|
||||
direct neighbors / plausible relays, not the whole mesh.** That candidate set is small
|
||||
(typically 5–15), so a byte usually resolves unambiguously there; when it doesn't, fall
|
||||
(typically 5-15), so a byte usually resolves unambiguously there; when it doesn't, fall
|
||||
back to the _safe_ behavior (flood / decrement / don't-learn).
|
||||
|
||||
### M1 — Ambiguity-aware last-byte resolution (new NodeDB primitive)
|
||||
### M1 - Ambiguity-aware last-byte resolution (new NodeDB primitive)
|
||||
|
||||
New types + methods in `src/mesh/NodeDB.h` (near line 255) / `src/mesh/NodeDB.cpp`
|
||||
(near `getMeshNode`, ~2936):
|
||||
@@ -181,13 +181,13 @@ bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum
|
||||
**and** `sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS`.
|
||||
- `requireDirectNeighbor == false` (lenient, for learn / hop-preserve): accept if direct
|
||||
neighbor **or** `nodeInfoLiteIsFavorite` **or** role ∈ {ROUTER, ROUTER_LATE, CLIENT_BASE}.
|
||||
- **No tie-break.** A collision must return `Ambiguous` — picking "best SNR" would
|
||||
- **No tie-break.** A collision must return `Ambiguous` - picking "best SNR" would
|
||||
resurrect the silent-misroute bug. (Deliberate non-goal; document in code.)
|
||||
|
||||
New constant in `src/mesh/MeshTypes.h` (near line 44):
|
||||
`#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2)` (mirrors `NUM_ONLINE_SECS`).
|
||||
|
||||
### M2 — Only route on bytes that resolve to a unique, reachable neighbor
|
||||
### M2 - Only route on bytes that resolve to a unique, reachable neighbor
|
||||
|
||||
In `getNextHop` (`src/mesh/NextHopRouter.cpp:192-207`), after the existing split-horizon
|
||||
check (`node->next_hop != relay_node`), require the stored byte to resolve to a **unique,
|
||||
@@ -219,7 +219,7 @@ Apply M1's safe fallback at the other sites:
|
||||
**Left unchanged, by design (document why in code):**
|
||||
|
||||
- **Site 1** rebroadcast self-check (`NextHopRouter.cpp:147`) and self-identity checks
|
||||
(`ReliableRouter.cpp:127`): a node matches its **own** byte — no DB resolution helps. A
|
||||
(`ReliableRouter.cpp:127`): a node matches its **own** byte - no DB resolution helps. A
|
||||
remote impostor sharing the intended next-hop's byte will still rebroadcast. M1/M2
|
||||
shrink the blast radius by reducing how often an ambiguous byte is ever stored or
|
||||
originated; a true fix needs a wider field (out of scope). **This is the one residual
|
||||
@@ -228,7 +228,7 @@ Apply M1's safe fallback at the other sites:
|
||||
byte-domain (both sides are on-wire bytes); the consumer (learning) is now hardened.
|
||||
Add a one-line comment; do not change.
|
||||
|
||||
### M3 — Route freshness / failure memory (RAM table on NextHopRouter)
|
||||
### M3 - Route freshness / failure memory (RAM table on NextHopRouter)
|
||||
|
||||
A bounded, LRU-evicted table keyed by destination, mirroring `PacketHistory`'s
|
||||
reuse-oldest discipline (not an unbounded map) to cap RAM.
|
||||
@@ -253,20 +253,20 @@ Policy:
|
||||
| Constant | Value | Rationale |
|
||||
| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ROUTE_TTL_MSEC` | 30 min | Survives a normal conversation; re-discovers a moved node within a telemetry interval. |
|
||||
| `ROUTE_FAILURE_THRESHOLD` | 3 | 1–2 consecutive failures are transient LoRa collisions; 3 to the same hop = dead. Accumulates **across** DMs (independent of the per-DM 3-try budget). |
|
||||
| `ROUTE_FAILURE_THRESHOLD` | 3 | 1-2 consecutive failures are transient LoRa collisions; 3 to the same hop = dead. Accumulates **across** DMs (independent of the per-DM 3-try budget). |
|
||||
|
||||
`isRouteStale(h, now)` = `(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC || h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD`.
|
||||
All age math uses **unsigned subtraction** (rollover-safe, matching
|
||||
`PacketHistory.cpp:364`); treat `learnedAtMsec == 0` as "set now".
|
||||
|
||||
Wiring (as built — `src/mesh/NextHopRouter.cpp`, `src/mesh/ReliableRouter.cpp`):
|
||||
Wiring (as built - `src/mesh/NextHopRouter.cpp`, `src/mesh/ReliableRouter.cpp`):
|
||||
|
||||
- `getNextHop`: if a health record matches the stored byte and `isRouteStale`, clear
|
||||
`node->next_hop` (NodeDB) **and** `clearRouteHealth`, return `nullopt` (flood). No
|
||||
record yet (cold path, first DM after boot) → trust NodeDB, but the M2 strict-neighbor
|
||||
gate still applies.
|
||||
- `sniffReceived` learn: gate the write through `resolveUniqueLastByte` (M2), then
|
||||
`noteRouteLearned(p->from, p->relay_node, millis())` — resets `consecutiveFailures`
|
||||
`noteRouteLearned(p->from, p->relay_node, millis())` - resets `consecutiveFailures`
|
||||
**only if the hop changed** (anti-flap for asymmetric re-learn); otherwise just refreshes
|
||||
`learnedAtMsec`. (No success signal is taken on the intermediate reverse-pass: an ACK
|
||||
merely passing through us is not proof that _we_ delivered, and resetting failures there
|
||||
@@ -287,16 +287,16 @@ the current DM (reset NodeDB `next_hop` + flood, and bump the cross-DM failure c
|
||||
only place that erases a health record is the `getNextHop` decay path; the retransmission
|
||||
path leaves it intact so the counter survives a reverse-path re-learn.
|
||||
|
||||
### M4 — Earlier flood for unverified routes (gated, off by default)
|
||||
### M4 - Earlier flood for unverified routes (gated, off by default)
|
||||
|
||||
Compile-gated so healthy sparse meshes are untouched. **Default is off** — the define
|
||||
Compile-gated so healthy sparse meshes are untouched. **Default is off** - the define
|
||||
lives in `NextHopRouter.h` and must be flipped to measure:
|
||||
`#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 1`.
|
||||
|
||||
In `doRetransmissions`, the directed-retry `else` branch: if the route is **not verified**
|
||||
(`!findRouteHealth(to) || consecutiveFailures > 0 || isRouteStale`), reset `next_hop` and
|
||||
flood on this attempt instead of spending another directed try. A **verified** route
|
||||
(record present, `consecutiveFailures == 0`, within TTL — i.e. recently ACKed) takes the
|
||||
(record present, `consecutiveFailures == 0`, within TTL - i.e. recently ACKed) takes the
|
||||
unchanged directed-retry path, so the sparse-mesh happy path is untouched. Trade-off:
|
||||
airtime ↔ latency; the gate ensures we never pay the flood cost on a proven route, only on
|
||||
one we already distrust. Off by default precisely so it can be A/B-measured on the
|
||||
@@ -337,13 +337,13 @@ simulator before broad enable.
|
||||
`getNextHop` already early-returns for broadcast.
|
||||
- **Perf:** M2 adds one O(N) resolver scan per directed send/relay (early-exit on the 2nd
|
||||
match), cheaper than the crypto already on that path; site-4 is a wash. If ever hot, a
|
||||
future 256-entry last-byte index is the optimization (not now — RAM).
|
||||
future 256-entry last-byte index is the optimization (not now - RAM).
|
||||
|
||||
---
|
||||
|
||||
## Verification (all tiers)
|
||||
|
||||
### 1. Native unit tests — new `test/test_nexthop_routing/test_main.cpp`
|
||||
### 1. Native unit tests - new `test/test_nexthop_routing/test_main.cpp`
|
||||
|
||||
`pio test -e native -f test_nexthop_routing`; on macOS `./bin/test-native-docker.sh -f test_nexthop_routing`.
|
||||
Design the RouteHealth helpers to take `now` as a parameter so the 30-min TTL logic is
|
||||
@@ -366,7 +366,7 @@ testable without a clock mock.
|
||||
### 2. portduino SimRadio simulator
|
||||
|
||||
`pio run -e native && ./bin/test-simulator.sh`. Best vehicle for the **intermediate-node**
|
||||
path the 2-device bench can't reach. Line topology A — B — C: establish A→C (B learns a
|
||||
path the 2-device bench can't reach. Line topology A - B - C: establish A→C (B learns a
|
||||
directed route), stop B relaying that dest, confirm A re-discovers via flood within
|
||||
`ROUTE_FAILURE_THRESHOLD` and that B's `noteRouteFailure`/`clearRouteHealth` fires (visible
|
||||
via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this to A/B M4
|
||||
@@ -374,15 +374,15 @@ via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this
|
||||
|
||||
### 3. Hardware via meshtastic MCP (auto-detect; 3+ devices for a real hop)
|
||||
|
||||
- `mcp-server/tests/mesh/test_nexthop_multihop_recovery.py` — **the multi-hop validator
|
||||
for this work** (added on this branch). Self-discovers an A — relay — C line, asserts a
|
||||
- `mcp-server/tests/mesh/test_nexthop_multihop_recovery.py` - **the multi-hop validator
|
||||
for this work** (added on this branch). Self-discovers an A - relay - C line, asserts a
|
||||
directed DM is delivered across the relay (next_hop + M1/M2/M3 engaged), and asserts
|
||||
delivery recovers after the relay is power-cycled (M3). Skips unless the bench is a true
|
||||
multi-hop line (≥3 roles via `--hub-profile`, endpoints out of direct RF range).
|
||||
- `mcp-server/tests/mesh/test_direct_with_ack.py` — happy-path regression: a fresh/unique
|
||||
- `mcp-server/tests/mesh/test_direct_with_ack.py` - happy-path regression: a fresh/unique
|
||||
route still delivers a want_ack DM on the first/second try (M4's gate must keep this
|
||||
green).
|
||||
- `mcp-server/tests/mesh/test_peer_offline_recovery.py` — 2-device recovery validator: peer
|
||||
- `mcp-server/tests/mesh/test_peer_offline_recovery.py` - 2-device recovery validator: peer
|
||||
off mid-conversation then back. Must stay green and ideally recover in fewer attempts.
|
||||
|
||||
### 4. Build / format sanity
|
||||
@@ -406,13 +406,13 @@ production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B
|
||||
|
||||
**Pending (environment-blocked, not yet run):**
|
||||
|
||||
- **Multi-hop A–B–C recovery sim** — the `simulator/` broker hub is **not git-tracked**
|
||||
- **Multi-hop A-B-C recovery sim** - the `simulator/` broker hub is **not git-tracked**
|
||||
(only stale local `.pyc`), and two `meshtasticd -s` instances can't hear each other
|
||||
without it. The intermediate-node failure-count path and the M4 A/B therefore have unit
|
||||
coverage of their logic but no end-to-end multi-node run yet.
|
||||
- **Hardware / multi-hop tier** — a committable bench test now exists:
|
||||
- **Hardware / multi-hop tier** - a committable bench test now exists:
|
||||
`mcp-server/tests/mesh/test_nexthop_multihop_recovery.py`. It self-discovers a real
|
||||
multi-hop pair (A — relay — C), asserts a directed DM is delivered across the relay, and
|
||||
multi-hop pair (A - relay - C), asserts a directed DM is delivered across the relay, and
|
||||
asserts delivery recovers after the relay is power-cycled (the M3 path). It
|
||||
`pytest.skip`s cleanly unless the bench is a true line with endpoints out of direct RF
|
||||
range (≥3 roles via `--hub-profile`), so it's safe to commit and only asserts when the
|
||||
@@ -424,9 +424,9 @@ production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B
|
||||
|
||||
## Risks & limitations
|
||||
|
||||
- **Site-1 impostor rebroadcast** is unfixable without a wider field — documented; M1/M2
|
||||
- **Site-1 impostor rebroadcast** is unfixable without a wider field - documented; M1/M2
|
||||
only shrink its frequency.
|
||||
- **Dense meshes flood DMs more often** — intended (a flooded DM arrives; a mis-unicast one
|
||||
- **Dense meshes flood DMs more often** - intended (a flooded DM arrives; a mis-unicast one
|
||||
black-holes). Call out in the PR so reviewers expect a slightly higher DM flood rate on
|
||||
very dense meshes.
|
||||
- **M4 airtime** if the gate is too loose → default conservative + compile-gated +
|
||||
@@ -441,14 +441,14 @@ production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B
|
||||
|
||||
Each step is independently testable; land them as separate commits.
|
||||
|
||||
1. **M1 resolver + unit tests** — `NodeDB` only; no behavior change until wired. Lands the
|
||||
1. **M1 resolver + unit tests** - `NodeDB` only; no behavior change until wired. Lands the
|
||||
`resolveLastByte`/`resolveUniqueLastByte` primitive and its full unit-test matrix.
|
||||
2. **M2 + wiring + tests** — `getNextHop` strict gate, learning gate, favorite-router
|
||||
2. **M2 + wiring + tests** - `getNextHop` strict gate, learning gate, favorite-router
|
||||
preservation rewrite. Adds the `getNextHop` and site-4 tests.
|
||||
3. **M3 health table + decay + tests** — RAM `RouteHealth` table, decay-on-read, failure/
|
||||
3. **M3 health table + decay + tests** - RAM `RouteHealth` table, decay-on-read, failure/
|
||||
success accounting, reconciliation with the existing last-retry reset. Adds the
|
||||
route-health unit tests and the simulator recovery check.
|
||||
4. **M4 gated tuning** — early-flood-on-unverified behind the compile flag; simulator A/B
|
||||
4. **M4 gated tuning** - early-flood-on-unverified behind the compile flag; simulator A/B
|
||||
and hardware regression.
|
||||
|
||||
Reference plan (with the same content) was developed at
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# LTO) are reserved for the WarmNodeStore record-ring (see WarmNodeStore.h). Our
|
||||
# linker scripts (nrf52840_s140_v6.ld and nrf52840_s140_v7.ld) cap the image at
|
||||
# 0xEA000, but boards on the framework-default script (FLASH ending at 0xED000) could
|
||||
# silently place code in those pages — the first warm-store save would then brick the
|
||||
# silently place code in those pages - the first warm-store save would then brick the
|
||||
# device. This turns that into a build failure.
|
||||
#
|
||||
# Image flash end = __etext + sizeof(.data) (loaded at LMA __etext); symbols from
|
||||
|
||||
@@ -31,7 +31,7 @@ else:
|
||||
The rule format depends on the host:
|
||||
Windows (CMake's RunCMake wraps every command):
|
||||
COMMAND = cmd.exe /C "cd /D DIR && arm-none-eabi-gcc.exe ... -o linker.cmd && cmake.exe -E cmake_transform_depfile ..."
|
||||
POSIX (Linux/macOS — no wrapper):
|
||||
POSIX (Linux/macOS - no wrapper):
|
||||
COMMAND = cd DIR && arm-none-eabi-gcc ... -o linker.cmd && cmake -E cmake_transform_depfile ...
|
||||
|
||||
Returns (gcc_cmd_string, cwd_path) or raises RuntimeError.
|
||||
@@ -51,7 +51,7 @@ else:
|
||||
|
||||
command_val = stripped[len("COMMAND = ") :]
|
||||
|
||||
# On Windows the value is wrapped in `cmd.exe /C "..."` — strip
|
||||
# On Windows the value is wrapped in `cmd.exe /C "..."` - strip
|
||||
# the wrapper. On POSIX hosts the inner sequence is the value
|
||||
# itself (no quoting layer).
|
||||
m = re.search(r'/C\s+"(.*)"\s*$', command_val)
|
||||
@@ -88,7 +88,7 @@ else:
|
||||
linker_cmd = os.path.join(zephyr_dir, "linker.cmd")
|
||||
|
||||
if os.path.exists(linker_cmd):
|
||||
return # Already present — nothing to do
|
||||
return # Already present - nothing to do
|
||||
|
||||
ninja_build = os.path.join(build_dir, "build.ninja")
|
||||
if not os.path.exists(ninja_build):
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
# GROWTH, the ES6 module shape) live in the platform-wasm builder; what belongs
|
||||
# to *this firmware* is:
|
||||
#
|
||||
# * EXPORT_NAME — the ES-module factory name consumers import.
|
||||
# * EXPORTED_RUNTIME_METHODS — ccall/cwrap/callMain + FS/IDBFS/NODEFS/PATH and
|
||||
# * EXPORT_NAME - the ES-module factory name consumers import.
|
||||
# * EXPORTED_RUNTIME_METHODS - ccall/cwrap/callMain + FS/IDBFS/NODEFS/PATH and
|
||||
# the string helpers the JS host + bridge drive.
|
||||
# * EXPORTED_FUNCTIONS — the C entry points (the wasm_* API is also kept via
|
||||
# * EXPORTED_FUNCTIONS - the C entry points (the wasm_* API is also kept via
|
||||
# EMSCRIPTEN_KEEPALIVE in source; _malloc/_free are
|
||||
# needed so the host can marshal protobuf buffers).
|
||||
# * ASYNCIFY_IMPORTS — the WebUSB seam: these imported C functions suspend
|
||||
# * ASYNCIFY_IMPORTS - the WebUSB seam: these imported C functions suspend
|
||||
# the stack (Asyncify) while a WebUSB transfer awaits.
|
||||
#
|
||||
# Only attached to the wasm env (see extra_scripts in [env:native-wasm]); a guard keeps
|
||||
|
||||
+42
-41
@@ -17,7 +17,7 @@ An [MCP](https://modelcontextprotocol.io) server for working with the Meshtastic
|
||||
## Prerequisites
|
||||
|
||||
- Python ≥ 3.11
|
||||
- [PlatformIO Core](https://platformio.org/install/cli) — `pio` on `$PATH` or at `~/.platformio/penv/bin/pio`
|
||||
- [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
|
||||
|
||||
@@ -35,7 +35,7 @@ Verify:
|
||||
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.
|
||||
The server blocks on stdin (that's correct - it speaks MCP over stdio). Ctrl-C to exit.
|
||||
|
||||
## Register with Claude Code
|
||||
|
||||
@@ -77,7 +77,7 @@ Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_des
|
||||
| ----------------- | -------------------------------------------------------------------- |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -113,9 +113,9 @@ _The tool tables below document 38 currently registered MCP server tools._
|
||||
| `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` |
|
||||
| `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)
|
||||
|
||||
@@ -162,7 +162,7 @@ rather than auto-`sudo`'ing mid-run.
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -182,7 +182,7 @@ rather than auto-`sudo`'ing mid-run.
|
||||
The `native-macos` and `native` PlatformIO envs build a headless `meshtasticd`
|
||||
binary that runs on the host (Apple Silicon / Intel macOS, or Linux Portduino).
|
||||
The daemon exposes the meshtastic TCP API on port `4403` rather than a USB
|
||||
serial endpoint — point the MCP server at it via `MESHTASTIC_MCP_TCP_HOST`:
|
||||
serial endpoint - point the MCP server at it via `MESHTASTIC_MCP_TCP_HOST`:
|
||||
|
||||
```bash
|
||||
# 1. Build + run a daemon on this host (see variants/native/portduino/platformio.ini
|
||||
@@ -194,9 +194,9 @@ pio run -e native-macos
|
||||
export MESHTASTIC_MCP_TCP_HOST=localhost # or host:port, default port 4403
|
||||
```
|
||||
|
||||
**First-run gotcha — MAC address.** `meshtasticd` derives its MAC from the
|
||||
**First-run gotcha - MAC address.** `meshtasticd` derives its MAC from the
|
||||
USB adapter's serial-number / product strings. Many cheap CH341 dongles
|
||||
(MeshStick included — VID 0x1A86 / PID 0x5512) ship with `iSerialNumber=0`
|
||||
(MeshStick included - VID 0x1A86 / PID 0x5512) ship with `iSerialNumber=0`
|
||||
and `iProduct=0`, so the daemon aborts on boot with `*** Blank MAC Address
|
||||
not allowed!`. Set the MAC explicitly in `config.yaml`:
|
||||
|
||||
@@ -228,10 +228,10 @@ on) raise a clear `ConnectionError` rather than failing mysteriously:
|
||||
`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`,
|
||||
`serial_open` (use info/admin tools directly), and the vendor escape hatches
|
||||
`esptool_*`, `nrfutil_*`, `picotool_*`. `pio_flash` against a `native*` env
|
||||
similarly raises — there's no upload step; use `build` and run the binary
|
||||
similarly raises - there's no upload step; use `build` and run the binary
|
||||
directly.
|
||||
|
||||
The pytest harness in `tests/` still assumes USB-attached devices per role —
|
||||
The pytest harness in `tests/` still assumes USB-attached devices per role -
|
||||
TCP-aware fixtures are not part of this surface yet.
|
||||
|
||||
## Hardware Test Suite
|
||||
@@ -239,7 +239,7 @@ TCP-aware fixtures are not part of this surface yet.
|
||||
`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.
|
||||
directory - this one validates the device-facing behavior end-to-end.
|
||||
|
||||
### Invocation
|
||||
|
||||
@@ -262,33 +262,33 @@ in the pre-flight header.
|
||||
|
||||
### Tiers (run in this order)
|
||||
|
||||
- **`bake`** (`tests/test_00_bake.py`) — flashes both hub roles with the
|
||||
- **`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 /
|
||||
- **`unit`** - pure Python, no hardware. boards / PIO wrapper /
|
||||
userPrefs-parse / testing-profile fixtures.
|
||||
- **`mesh`** — 2-device mesh: formation, broadcast delivery, direct+ACK,
|
||||
- **`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`** - 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
|
||||
- **`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`
|
||||
- **`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
|
||||
- **`fleet`** - PSK-seed isolation: two labs with different seeds never
|
||||
overlap.
|
||||
- **`admin`** — owner persistence across reboot, channel URL round-trip,
|
||||
- **`admin`** - owner persistence across reboot, channel URL round-trip,
|
||||
`lora.hop_limit` persistence.
|
||||
- **`provisioning`** — region/channel baking, userPrefs survive
|
||||
- **`provisioning`** - region/channel baking, userPrefs survive
|
||||
`factory_reset(full=False)`.
|
||||
|
||||
#### UI tier setup
|
||||
@@ -297,7 +297,7 @@ 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
|
||||
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.
|
||||
@@ -329,13 +329,13 @@ captures just become 1×1 black PNGs.
|
||||
|
||||
### Artifacts (regenerated every run, under `tests/`)
|
||||
|
||||
- `report.html` — self-contained pytest-html report. Each test gets a
|
||||
- `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
|
||||
- `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
|
||||
@@ -353,11 +353,12 @@ 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.
|
||||
(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
|
||||
|
||||
@@ -375,7 +376,7 @@ Three AI-assisted workflows are wired up for Claude Code operators
|
||||
- `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** —
|
||||
- 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`,
|
||||
@@ -397,7 +398,7 @@ mcp-server/
|
||||
├── 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
|
||||
├── 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
|
||||
@@ -405,8 +406,8 @@ mcp-server/
|
||||
|
||||
## 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.
|
||||
- **"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.
|
||||
|
||||
@@ -25,15 +25,15 @@ test = [
|
||||
]
|
||||
# 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
|
||||
# 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
|
||||
# (MESHTASTIC_UI_TUI_CAMERA=1). Terminal-agnostic - no kitty / sixel
|
||||
# dependency. Pure Python, tiny.
|
||||
"rich-pixels>=3.0",
|
||||
]
|
||||
@@ -41,7 +41,7 @@ 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
|
||||
# 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"
|
||||
|
||||
+12
-12
@@ -50,11 +50,11 @@ if [[ -f $USERPREFS_SIDECAR ]]; then
|
||||
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
|
||||
# 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
|
||||
# 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.
|
||||
@@ -84,7 +84,7 @@ fi
|
||||
# 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.
|
||||
# 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"
|
||||
@@ -120,7 +120,7 @@ for dev in devices.list_devices(include_unknown=True):
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
role = ROLE_BY_VID.get(vid)
|
||||
# First port wins per role — matches hub_devices fixture semantics.
|
||||
# First port wins per role - matches hub_devices fixture semantics.
|
||||
if role and role not in out:
|
||||
out[role] = dev["port"]
|
||||
|
||||
@@ -164,7 +164,7 @@ fi
|
||||
# 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.
|
||||
# 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
|
||||
@@ -188,7 +188,7 @@ 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
|
||||
# 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".
|
||||
# Keep terminal output condensed (`-q -r fE`) so skip-heavy runs do not print
|
||||
@@ -206,7 +206,7 @@ fi
|
||||
# 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
|
||||
# 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"
|
||||
@@ -237,27 +237,27 @@ for _arg in "$@"; do
|
||||
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 '\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
|
||||
# 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 "\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.
|
||||
# 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "Meshtastic Firmware — Recorder Stream",
|
||||
"title": "Meshtastic Firmware - Recorder Stream",
|
||||
"description": "Live view of `.mtlog/` streams shipped by `mtlog_to_datadog.py`. Heap, packet volume, log levels, errors. One row per port.",
|
||||
"widgets": [
|
||||
{
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
{
|
||||
"definition": {
|
||||
"title": "Heap slope (bytes/min) — last 1h",
|
||||
"title": "Heap slope (bytes/min) - last 1h",
|
||||
"type": "query_value",
|
||||
"precision": 0,
|
||||
"requests": [
|
||||
|
||||
@@ -15,10 +15,10 @@ Usage:
|
||||
./scripts/mtlog_to_datadog.py --once # catch up + exit
|
||||
./scripts/mtlog_to_datadog.py --since 3600 # backfill last hour from start
|
||||
|
||||
Default `DD_SITE` is `us5.datadoghq.com` — the team's Datadog instance.
|
||||
Default `DD_SITE` is `us5.datadoghq.com` - the team's Datadog instance.
|
||||
Override via `DD_SITE=...` env var or `--site` flag for one-offs.
|
||||
|
||||
The forwarder is a separate process by design — a Datadog outage or
|
||||
The forwarder is a separate process by design - a Datadog outage or
|
||||
auth failure must not backpressure the recorder. We exit non-zero on
|
||||
fatal config errors (missing API key) and keep retrying on transient
|
||||
network/HTTP errors.
|
||||
@@ -89,7 +89,7 @@ class _StreamReader:
|
||||
# Rotation happened. Start over.
|
||||
last_pos = 0
|
||||
if last_pos > size:
|
||||
# Live file truncated/shrunk under us — recorder rotated.
|
||||
# Live file truncated/shrunk under us - recorder rotated.
|
||||
last_pos = 0
|
||||
try:
|
||||
with self.path.open("r", encoding="utf-8") as fh:
|
||||
@@ -369,7 +369,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
log_dir = Path(args.log_dir)
|
||||
if not log_dir.exists():
|
||||
print(
|
||||
f"log dir {log_dir} does not exist — start the mcp-server first.",
|
||||
f"log dir {log_dir} does not exist - start the mcp-server first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Meshtastic MCP server — device discovery, PlatformIO tooling, and device admin."""
|
||||
"""Meshtastic MCP server - device discovery, PlatformIO tooling, and device admin."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -222,7 +222,7 @@ def set_config(path: str, value: Any, port: str | None = None) -> dict[str, Any]
|
||||
# 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
|
||||
# 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.
|
||||
@@ -313,7 +313,7 @@ def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
|
||||
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`
|
||||
SerialInterface our tests already hold open - no `pio device monitor`
|
||||
needed, no port-contention with admin/info calls.
|
||||
|
||||
Firmware gate: `src/SerialConsole.cpp` (`usingProtobufs &&
|
||||
@@ -322,7 +322,7 @@ def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
|
||||
re-applied after reset.
|
||||
|
||||
Previously-documented concurrency hazard (emitLogRecord sharing the
|
||||
main packet-emission buffers) has been fixed — see `StreamAPI.h`
|
||||
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`
|
||||
@@ -366,7 +366,7 @@ def send_input_event(
|
||||
"""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
|
||||
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
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""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,
|
||||
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 1–2s pio startup cost.
|
||||
changes, so subsequent calls don't pay the 1-2s pio startup cost.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""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
|
||||
- `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
|
||||
- `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` /
|
||||
- `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` - 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.
|
||||
|
||||
@@ -76,7 +76,7 @@ class OpenCVBackend:
|
||||
"On macOS check TCC Camera permission; on Linux check /dev/video* and v4l2 access."
|
||||
)
|
||||
|
||||
# Drop the first few frames — auto-exposure + white-balance settle.
|
||||
# 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
|
||||
@@ -159,7 +159,7 @@ class FfmpegBackend:
|
||||
return out.stdout
|
||||
|
||||
def close(self) -> None:
|
||||
pass # stateless — each capture spawns a new process
|
||||
pass # stateless - each capture spawns a new process
|
||||
|
||||
|
||||
# ---------- Null backend ---------------------------------------------------
|
||||
@@ -197,7 +197,7 @@ 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
|
||||
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()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
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.
|
||||
admin/info tool surface - the MCP server stays pure stdio JSON-RPC.
|
||||
"""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
``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``
|
||||
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``.
|
||||
@@ -25,7 +25,7 @@ class FlashLogTailer(threading.Thread):
|
||||
``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
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
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`` —
|
||||
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
|
||||
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
|
||||
@@ -30,14 +30,14 @@ class FirmwareLogTailer(threading.Thread):
|
||||
|
||||
``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 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.
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
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):
|
||||
|
||||
@@ -14,7 +14,7 @@ minimum viable failure context into a tarball under
|
||||
└── 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`
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -35,7 +35,7 @@ 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
|
||||
cleanly onto the state the TUI already tracks - no extra data
|
||||
collection required at export time."""
|
||||
|
||||
nodeid: str
|
||||
@@ -70,7 +70,7 @@ def _filtered_fwlog(
|
||||
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
|
||||
# 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()
|
||||
@@ -115,14 +115,14 @@ Exported by `meshtastic-mcp-test-tui` on {t}.
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `test_report.json` | The pytest-reportlog `TestReport` event for the failing test — includes `longrepr`, captured `sections` (stdout/stderr/log), `duration`, `location`, `keywords`. |
|
||||
| `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 [start−5s, 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.
|
||||
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.
|
||||
|
||||
@@ -139,7 +139,7 @@ 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
|
||||
(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.
|
||||
"""
|
||||
@@ -159,7 +159,7 @@ def build_reproducer_bundle(ctx: ReproContext) -> pathlib.Path:
|
||||
# README
|
||||
_add("README.md", _readme(ctx).encode("utf-8"))
|
||||
|
||||
# test_report.json — reconstruct from the fields the TUI stashes.
|
||||
# test_report.json - reconstruct from the fields the TUI stashes.
|
||||
test_report = {
|
||||
"nodeid": ctx.nodeid,
|
||||
"outcome": "failed",
|
||||
@@ -208,7 +208,7 @@ def build_reproducer_bundle(ctx: ReproContext) -> pathlib.Path:
|
||||
|
||||
|
||||
def iter_entries(archive_path: pathlib.Path) -> Iterable[str]:
|
||||
"""Yield member names — used by callers that want to confirm the bundle shape."""
|
||||
"""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
|
||||
|
||||
@@ -22,7 +22,7 @@ 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.
|
||||
is derived from the path - the sanitized nodeid directory name.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -46,7 +46,7 @@ class UiCaptureTailer(threading.Thread):
|
||||
try:
|
||||
self._scan_once()
|
||||
except Exception:
|
||||
# Best-effort tailer — never bring down the TUI because a
|
||||
# Best-effort tailer - never bring down the TUI because a
|
||||
# directory vanished mid-scan.
|
||||
pass
|
||||
time.sleep(self._poll_interval)
|
||||
@@ -62,7 +62,7 @@ class UiCaptureTailer(threading.Thread):
|
||||
except OSError:
|
||||
continue
|
||||
if size < offset:
|
||||
# File truncated / rewritten — reset and re-emit.
|
||||
# File truncated / rewritten - reset and re-emit.
|
||||
offset = 0
|
||||
if size == offset:
|
||||
continue
|
||||
|
||||
@@ -6,19 +6,19 @@ The TUI *wraps* ``run-tests.sh``; it never replaces it. Same script, same
|
||||
env-var resolution, same ``userPrefs.jsonc`` session fixture. Four data
|
||||
sources drive live state:
|
||||
|
||||
1. ``tests/reportlog.jsonl`` — written by ``pytest-reportlog``. Tailed in a
|
||||
1. ``tests/reportlog.jsonl`` - written by ``pytest-reportlog``. Tailed in a
|
||||
worker thread; each JSON line is published as a :class:`ReportLogEvent`
|
||||
message. This is the authoritative source for tree population + per-test
|
||||
outcome.
|
||||
2. The pytest subprocess ``stdout`` + ``stderr`` streams — line-by-line,
|
||||
2. The pytest subprocess ``stdout`` + ``stderr`` streams - line-by-line,
|
||||
published as :class:`PytestLine` messages and rendered verbatim in the
|
||||
pytest pane.
|
||||
3. ``tests/fwlog.jsonl`` — firmware log stream. Written by the
|
||||
3. ``tests/fwlog.jsonl`` - firmware log stream. Written by the
|
||||
``_firmware_log_stream`` autouse session fixture in ``conftest.py``
|
||||
(mirrors every ``meshtastic.log.line`` pubsub event), tailed by the
|
||||
:class:`FirmwareLogTailer` worker, displayed in a wrap-enabled
|
||||
RichLog with cycleable port filter.
|
||||
4. ``devices.list_devices()`` + ``info.device_info(port)`` — polled only at
|
||||
4. ``devices.list_devices()`` + ``info.device_info(port)`` - polled only at
|
||||
startup and again after ``RunFinished``. Device polling while pytest
|
||||
holds a SerialInterface would deadlock on the exclusive port lock; the
|
||||
existing ``hub_devices`` fixture is session-scoped so there is no safe
|
||||
@@ -64,7 +64,7 @@ from typing import Any, Iterator
|
||||
# bake → unit → mesh → telemetry → monitor → fleet → admin → provisioning
|
||||
# so the counters table reads top-to-bottom in execution order.
|
||||
#
|
||||
# "bake" is the synthetic tier for `tests/test_00_bake.py` — the file sits
|
||||
# "bake" is the synthetic tier for `tests/test_00_bake.py` - the file sits
|
||||
# at the `tests/` root rather than under a tier subdirectory, so without
|
||||
# this mapping `_tier_of_nodeid` would return "other" and the bake outcomes
|
||||
# would be silently dropped from both the tier table and the history
|
||||
@@ -139,7 +139,7 @@ class LeafReport:
|
||||
duration_s: float = 0.0
|
||||
longrepr: str = ""
|
||||
# Captured stdout / stderr / firmware-log sections from the test's
|
||||
# `TestReport.sections` — shown in the failure-detail modal.
|
||||
# `TestReport.sections` - shown in the failure-detail modal.
|
||||
sections: list[tuple[str, str]] = field(default_factory=list)
|
||||
# Wall-clock start/stop from the TestReport event. Used by the
|
||||
# reproducer exporter (`x`) to filter `tests/fwlog.jsonl` down to
|
||||
@@ -175,7 +175,7 @@ class State:
|
||||
"""Shared state owned by the App; written by workers under `lock`.
|
||||
|
||||
UI code reads via Textual Message handlers which run on the UI thread
|
||||
in the order workers called `post_message` — so reads don't need the
|
||||
in the order workers called `post_message` - so reads don't need the
|
||||
lock themselves.
|
||||
"""
|
||||
|
||||
@@ -184,7 +184,7 @@ class State:
|
||||
default_factory=lambda: {t: TierCounters(tier=t) for t in TIERS}
|
||||
)
|
||||
leaves: dict[str, LeafReport] = field(default_factory=dict)
|
||||
# Ordered list of nodeids in the order they were first seen — lets us
|
||||
# Ordered list of nodeids in the order they were first seen - lets us
|
||||
# rebuild the tree deterministically.
|
||||
nodeid_order: list[str] = field(default_factory=list)
|
||||
devices: list[DeviceRow] = field(default_factory=list)
|
||||
@@ -212,13 +212,13 @@ def _tier_of_nodeid(nodeid: str) -> str:
|
||||
"""Map a pytest nodeid to its tier bucket. Unknown → 'other'.
|
||||
|
||||
`tests/test_00_bake.py::...` is special-cased to the synthetic `bake`
|
||||
tier — it's a top-level file (no tier subdirectory) so the generic
|
||||
tier - it's a top-level file (no tier subdirectory) so the generic
|
||||
"second path segment" logic would miss it and route the bake outcomes
|
||||
into the non-existent `other` bucket.
|
||||
"""
|
||||
parts = nodeid.split("/", 2)
|
||||
if len(parts) >= 2 and parts[0] == "tests":
|
||||
# Bake file sits at `tests/test_00_bake.py` — dedicated bucket.
|
||||
# Bake file sits at `tests/test_00_bake.py` - dedicated bucket.
|
||||
if parts[1].startswith("test_00_bake"):
|
||||
return "bake"
|
||||
candidate = parts[1]
|
||||
@@ -249,7 +249,7 @@ def _roles_from_nodeid(nodeid: str) -> set[str]:
|
||||
- ``test_foo[nrf52]`` → {"nrf52"} (baked_single)
|
||||
- ``test_foo[nrf52->esp32s3]`` → {"nrf52", "esp32s3"} (mesh_pair)
|
||||
|
||||
Unparametrized tests (no bracket) return an empty set — the caller
|
||||
Unparametrized tests (no bracket) return an empty set - the caller
|
||||
should fall back to "this test involves ALL detected devices" rather
|
||||
than pretending it touches none.
|
||||
"""
|
||||
@@ -329,7 +329,7 @@ def _format_duration(seconds: float) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Textual imports (lazy — only when main() runs, so `_parse_events` can be
|
||||
# Textual imports (lazy - only when main() runs, so `_parse_events` can be
|
||||
# imported by smoke tests without requiring textual installed in every env)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -367,7 +367,7 @@ def _import_textual() -> Any:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main() — the important scaffolding lives here so that when we bail out
|
||||
# main() - the important scaffolding lives here so that when we bail out
|
||||
# before entering the Textual event loop (missing terminal, --help, etc.)
|
||||
# nothing has grabbed the screen yet.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -414,7 +414,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# workers race pytest file-creation; starting from a known-empty state
|
||||
# avoids mid-line-decode confusion from the prior run. The fwlog session
|
||||
# fixture also truncates on its end, and run-tests.sh truncates the
|
||||
# flashlog — triple-truncate is deliberate (whichever side creates the
|
||||
# flashlog - triple-truncate is deliberate (whichever side creates the
|
||||
# file first, it starts empty).
|
||||
for p in (reportlog, fwlog, flashlog):
|
||||
try:
|
||||
@@ -434,7 +434,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# as it arrives. The TUI tails that file and routes each line to the
|
||||
# pytest pane so the operator sees live flash progress during long
|
||||
# `pio run -t upload` / `esptool erase_flash` operations. run-tests.sh
|
||||
# also sets this when invoked directly — `setdefault` so the wrapper's
|
||||
# also sets this when invoked directly - `setdefault` so the wrapper's
|
||||
# value wins when present.
|
||||
os.environ.setdefault("MESHTASTIC_MCP_FLASH_LOG", str(flashlog))
|
||||
|
||||
@@ -442,7 +442,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# env / argv handling without getting into Textual's alternate screen.
|
||||
if args.no_tui:
|
||||
cmd = [str(run_tests), *pytest_args]
|
||||
os.execv(str(run_tests), cmd) # noqa: S606 — intentional
|
||||
os.execv(str(run_tests), cmd) # noqa: S606 - intentional
|
||||
|
||||
# Textual UI import is deferred so `--help` and `--no-tui` do not pay
|
||||
# the ~40 MB startup cost.
|
||||
@@ -512,7 +512,7 @@ def _build_app(
|
||||
force Textual's import cost.
|
||||
"""
|
||||
|
||||
# Helper modules — lazy-imported here so the top-of-file import cost
|
||||
# Helper modules - lazy-imported here so the top-of-file import cost
|
||||
# only kicks in when main() has decided to run the TUI.
|
||||
from . import _flashlog as _flashlog_mod
|
||||
from . import _fwlog as _fwlog_mod
|
||||
@@ -540,7 +540,7 @@ def _build_app(
|
||||
super().__init__()
|
||||
|
||||
class FlashLogLine(tx.Message):
|
||||
"""Plain-text line from `tests/flash.log` — pio / esptool / nrfutil /
|
||||
"""Plain-text line from `tests/flash.log` - pio / esptool / nrfutil /
|
||||
picotool output tee'd by `pio._run_capturing`. Routed to the pytest
|
||||
pane so the operator sees live flash progress during `test_00_bake`
|
||||
instead of 3 minutes of pytest-captured silence."""
|
||||
@@ -550,7 +550,7 @@ def _build_app(
|
||||
super().__init__()
|
||||
|
||||
class UiCaptureLine(tx.Message):
|
||||
"""Live line from the UI-tier camera transcript — one per
|
||||
"""Live line from the UI-tier camera transcript - one per
|
||||
`frame_capture()` call. Posted only when the camera panel is
|
||||
enabled via `MESHTASTIC_UI_TUI_CAMERA=1`."""
|
||||
|
||||
@@ -640,7 +640,7 @@ def _build_app(
|
||||
class DevicePollerWorker(threading.Thread):
|
||||
"""Poll list_devices() + device_info() at startup and after RunFinished.
|
||||
|
||||
Deliberately NOT polling during the run — `hub_devices` is a
|
||||
Deliberately NOT polling during the run - `hub_devices` is a
|
||||
session-scoped fixture holding SerialInterfaces across the whole
|
||||
session, and device_info() would deadlock on the exclusive port
|
||||
lock. Header shows "(stale)" during the gap.
|
||||
@@ -806,7 +806,7 @@ def _build_app(
|
||||
def on_mount(self) -> None:
|
||||
log = self.query_one("#coverage-log", tx.RichLog)
|
||||
if not self._path.is_file():
|
||||
log.write("(no coverage data — tool_coverage.json not written yet)")
|
||||
log.write("(no coverage data - tool_coverage.json not written yet)")
|
||||
log.write("")
|
||||
log.write("Coverage is emitted at pytest_sessionfinish; this")
|
||||
log.write("file appears after the suite completes.")
|
||||
@@ -937,7 +937,7 @@ def _build_app(
|
||||
# Firmware-log port filter: None = all, else exact port match.
|
||||
self._fwlog_filter: str | None = None
|
||||
# Ordered set of distinct ports we've seen firmware log lines
|
||||
# from — the `l` key cycles through these.
|
||||
# from - the `l` key cycles through these.
|
||||
self._fwlog_ports: list[str] = []
|
||||
# Cross-run history.
|
||||
self._history_store = _history_mod.HistoryStore(
|
||||
@@ -970,7 +970,7 @@ def _build_app(
|
||||
highlight=False,
|
||||
markup=False,
|
||||
# `wrap=True` so long firmware log lines (some
|
||||
# hit ~200 chars — full packet hex dumps plus
|
||||
# hit ~200 chars - full packet hex dumps plus
|
||||
# source tags) don't get truncated at the
|
||||
# right edge. The right pane is ~50% of the
|
||||
# terminal so even a wide terminal has a
|
||||
@@ -981,7 +981,7 @@ def _build_app(
|
||||
)
|
||||
if self._ui_camera_enabled:
|
||||
yield tx.Static(
|
||||
"UI camera — latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)",
|
||||
"UI camera - latest capture + transcript (MESHTASTIC_UI_TUI_CAMERA=1)",
|
||||
id="uicap-header",
|
||||
)
|
||||
with tx.Horizontal(id="uicap-pane"):
|
||||
@@ -1004,11 +1004,11 @@ def _build_app(
|
||||
|
||||
def on_mount(self) -> None:
|
||||
# Tier-counters table. `add_column` (singular) lets us pick
|
||||
# the key explicitly — `add_columns` (plural) in textual 8.x
|
||||
# the key explicitly - `add_columns` (plural) in textual 8.x
|
||||
# returns auto-generated keys that are tedious to track
|
||||
# separately, and update_cell(column_key=<label>) silently
|
||||
# no-ops because the key is not the label. "Progress" is the
|
||||
# new v2 column — a small [===== ] bar; see `_progress_bar`.
|
||||
# new v2 column - a small [===== ] bar; see `_progress_bar`.
|
||||
tier_table = self.query_one("#tier-table", tx.DataTable)
|
||||
for col in (
|
||||
"Tier",
|
||||
@@ -1023,7 +1023,7 @@ def _build_app(
|
||||
for t in TIERS:
|
||||
tier_table.add_row(t, "0", "0", "0", "0", "0", "", key=t)
|
||||
# Device table. "Status" shows which test (if any) is currently
|
||||
# running on this device — derived from the running_nodeid plus
|
||||
# running on this device - derived from the running_nodeid plus
|
||||
# role inference from the nodeid's `[...]` parametrization.
|
||||
dev_table = self.query_one("#device-table", tx.DataTable)
|
||||
for col in (
|
||||
@@ -1042,14 +1042,14 @@ def _build_app(
|
||||
self._device_worker.start()
|
||||
self._reportlog_worker = ReportlogWorker(self, self._reportlog, self._stop)
|
||||
self._reportlog_worker.start()
|
||||
# Firmware log tail worker — publishes FirmwareLogLine messages.
|
||||
# Firmware log tail worker - publishes FirmwareLogLine messages.
|
||||
self._fwlog_worker = _fwlog_mod.FirmwareLogTailer(
|
||||
path=self._fwlog,
|
||||
post=lambda rec: self.post_message(FirmwareLogLine(rec)),
|
||||
stop=self._stop,
|
||||
)
|
||||
self._fwlog_worker.start()
|
||||
# Flash log tail worker — plain-text pio/esptool/nrfutil/picotool
|
||||
# Flash log tail worker - plain-text pio/esptool/nrfutil/picotool
|
||||
# output tee'd by `pio._run_capturing`. Routes each line into the
|
||||
# pytest pane so the operator has live feedback during long flash
|
||||
# operations (`pio run -t upload` is ~3 min of silence otherwise).
|
||||
@@ -1059,7 +1059,7 @@ def _build_app(
|
||||
stop=self._stop,
|
||||
)
|
||||
self._flashlog_worker.start()
|
||||
# UI-capture transcript tailer — only runs when the camera panel
|
||||
# UI-capture transcript tailer - only runs when the camera panel
|
||||
# is enabled. Watches tests/ui_captures/**/transcript.md for new
|
||||
# lines as UI tests execute.
|
||||
if self._ui_camera_enabled:
|
||||
@@ -1078,7 +1078,7 @@ def _build_app(
|
||||
# Header tick (seed / runtime / sparkline re-renders at 1 Hz).
|
||||
# Also refreshes the device-status column so the per-test elapsed
|
||||
# time climbs live during silent test bodies (flash, long mesh
|
||||
# timeouts, etc.) — cheap: device-table is 1-2 rows.
|
||||
# timeouts, etc.) - cheap: device-table is 1-2 rows.
|
||||
self.set_interval(1.0, self._on_tick)
|
||||
|
||||
def _header_text(self) -> str:
|
||||
@@ -1114,8 +1114,8 @@ def _build_app(
|
||||
|
||||
The device-status cell embeds the running test's elapsed time
|
||||
(`RUNNING: test_bake_nrf52 (1:23)`), which needs to re-render
|
||||
each second during long silent test bodies. Cheap — O(devices),
|
||||
which is 1–2 rows in practice. Skipped when no test is
|
||||
each second during long silent test bodies. Cheap - O(devices),
|
||||
which is 1-2 rows in practice. Skipped when no test is
|
||||
running so we don't burn cycles when the TUI is idle.
|
||||
"""
|
||||
self._refresh_header()
|
||||
@@ -1134,7 +1134,7 @@ def _build_app(
|
||||
# --junitxml=tests/junit.xml -v --tb=short
|
||||
# plus an unconditional `--report-log` append at the end. If we
|
||||
# pre-append `--report-log` here when `extra_args` is empty, $#
|
||||
# becomes 1 and the whole defaults block is skipped — pytest
|
||||
# becomes 1 and the whole defaults block is skipped - pytest
|
||||
# then runs without the `tests/` positional (discovers from the
|
||||
# mcp-server root and potentially drags in production modules
|
||||
# named `test_*.py`), without the HTML/junit reports the /test
|
||||
@@ -1234,11 +1234,11 @@ def _build_app(
|
||||
# bake doesn't match. Without this
|
||||
# branch, those tests would never
|
||||
# register in the tree and the tier
|
||||
# counters would silently lie — e.g.
|
||||
# counters would silently lie - e.g.
|
||||
# the telemetry tier showed 0/0/0
|
||||
# while 4 tests were actually skipped.
|
||||
# `rerun` (pytest-rerunfailures): rewind to pending.
|
||||
# Teardown outcomes are intentionally ignored — a
|
||||
# Teardown outcomes are intentionally ignored - a
|
||||
# teardown failure shouldn't overwrite the call's
|
||||
# authoritative pass/fail.
|
||||
if when == "call" and outcome in ("passed", "failed", "skipped"):
|
||||
@@ -1250,7 +1250,7 @@ def _build_app(
|
||||
return
|
||||
if rt == "SessionFinish":
|
||||
return
|
||||
# Unknown — ignore silently.
|
||||
# Unknown - ignore silently.
|
||||
|
||||
def on_pytest_line(self, message: Any) -> None:
|
||||
log = self.query_one("#pytest-log", tx.RichLog)
|
||||
@@ -1271,8 +1271,8 @@ def _build_app(
|
||||
def on_ui_capture_line(self, message: Any) -> None:
|
||||
"""Route a UI-capture transcript line into the camera panel.
|
||||
|
||||
Each line is already formatted by frame_capture — e.g.
|
||||
`1. **initial** — frame 2/8 name=home — OCR: ...`. We write
|
||||
Each line is already formatted by frame_capture - e.g.
|
||||
`1. **initial** - frame 2/8 name=home - OCR: ...`. We write
|
||||
the text into the RichLog AND try to render the corresponding
|
||||
PNG on the left side (requires rich-pixels, Pillow).
|
||||
"""
|
||||
@@ -1288,7 +1288,7 @@ def _build_app(
|
||||
def _render_latest_ui_capture(self, test_id: str, line: str) -> None:
|
||||
"""Find the PNG that corresponds to `line` and render it on the
|
||||
left of the uicap pane. Soft-fails if rich-pixels isn't
|
||||
installed or the PNG isn't found — operator still has the text
|
||||
installed or the PNG isn't found - operator still has the text
|
||||
transcript on the right.
|
||||
"""
|
||||
try:
|
||||
@@ -1297,7 +1297,7 @@ def _build_app(
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
# Transcript lines look like `1. **label** — ...`. Pull the leading
|
||||
# Transcript lines look like `1. **label** - ...`. Pull the leading
|
||||
# integer to locate the capture file.
|
||||
import re as _re
|
||||
|
||||
@@ -1306,7 +1306,7 @@ def _build_app(
|
||||
return
|
||||
step = int(m.group(1))
|
||||
|
||||
# Captures directory is sibling of tests/ — mirror the path the
|
||||
# Captures directory is sibling of tests/ - mirror the path the
|
||||
# tailer watches. Search both likely layouts (in-mcp-server vs.
|
||||
# firmware-root invocation).
|
||||
candidates = [
|
||||
@@ -1317,7 +1317,7 @@ def _build_app(
|
||||
if captures_root is None:
|
||||
return
|
||||
|
||||
# Drill into <session_seed>/<test_id>/ — test_id is the
|
||||
# Drill into <session_seed>/<test_id>/ - test_id is the
|
||||
# sanitized nodeid the tailer already passed through.
|
||||
matches = list(captures_root.rglob(f"{test_id}/{step:03d}-*.png"))
|
||||
if not matches:
|
||||
@@ -1351,7 +1351,7 @@ def _build_app(
|
||||
port = rec.get("port")
|
||||
line = rec.get("line", "")
|
||||
# Track distinct ports for `l` filter cycling. The ordered-set
|
||||
# trick — list membership — is fine here because `_fwlog_ports`
|
||||
# trick - list membership - is fine here because `_fwlog_ports`
|
||||
# is tiny (2-3 entries for a typical lab).
|
||||
if port and port not in self._fwlog_ports:
|
||||
self._fwlog_ports.append(port)
|
||||
@@ -1368,7 +1368,7 @@ def _build_app(
|
||||
log = self.query_one("#fwlog-log", tx.RichLog)
|
||||
port_tag = ""
|
||||
if port:
|
||||
# Show only the last path component — `/dev/cu.usbmodem1101`
|
||||
# Show only the last path component - `/dev/cu.usbmodem1101`
|
||||
# is long; `usbmodem1101` is enough when the filter is
|
||||
# "all".
|
||||
tail = port.rsplit("/", 1)[-1]
|
||||
@@ -1393,13 +1393,13 @@ def _build_app(
|
||||
for row in message.rows:
|
||||
info = row.info or {}
|
||||
role = row.role or "?"
|
||||
fw = info.get("firmware_version", "—")
|
||||
hw = info.get("hw_model", "—")
|
||||
region = info.get("region", "—")
|
||||
channel = info.get("primary_channel", "—")
|
||||
fw = info.get("firmware_version", "-")
|
||||
hw = info.get("hw_model", "-")
|
||||
region = info.get("region", "-")
|
||||
channel = info.get("primary_channel", "-")
|
||||
peers = info.get("num_nodes")
|
||||
if peers is None:
|
||||
peers = "—"
|
||||
peers = "-"
|
||||
else:
|
||||
peers = str(max(int(peers) - 1, 0)) # exclude self
|
||||
status = self._status_for_role(role)
|
||||
@@ -1421,7 +1421,7 @@ def _build_app(
|
||||
|
||||
A running test whose nodeid doesn't carry an explicit role
|
||||
parametrization (no `[...]` bracket) is treated as touching
|
||||
every device — that matches how `test_bidirectional` and the
|
||||
every device - that matches how `test_bidirectional` and the
|
||||
pytest_sessionstart-level tests work in practice.
|
||||
|
||||
The trailing `(M:SS)` is live-updated by `_on_tick` at 1 Hz
|
||||
@@ -1436,7 +1436,7 @@ def _build_app(
|
||||
return "idle"
|
||||
short = _testname_of_nodeid(nodeid)
|
||||
# Compute elapsed for the live counter. Budget 8 chars at the
|
||||
# end of the cell — `(12:34)` plus a space. Shorten `short`
|
||||
# end of the cell - `(12:34)` plus a space. Shorten `short`
|
||||
# first, then tack on the elapsed suffix.
|
||||
started = self._state.running_started_at
|
||||
elapsed_suffix = ""
|
||||
@@ -1454,7 +1454,7 @@ def _build_app(
|
||||
"""Update the Status cell for every detected device.
|
||||
|
||||
Called whenever `running_nodeid` transitions (setup → call).
|
||||
Cheap: O(devices) which is 1–2 rows in practice.
|
||||
Cheap: O(devices) which is 1-2 rows in practice.
|
||||
"""
|
||||
try:
|
||||
dev_table = self.query_one("#device-table", tx.DataTable)
|
||||
@@ -1468,7 +1468,7 @@ def _build_app(
|
||||
)
|
||||
except Exception:
|
||||
# Row key might not exist yet if a snapshot hasn't
|
||||
# populated it — harmless; next snapshot will carry
|
||||
# populated it - harmless; next snapshot will carry
|
||||
# the fresh status value.
|
||||
pass
|
||||
|
||||
@@ -1481,7 +1481,7 @@ def _build_app(
|
||||
# Trigger a fresh device poll now that ports are free again.
|
||||
if self._device_worker is not None:
|
||||
self._device_worker.trigger()
|
||||
# Persist a history record — one line per run, tailed by the
|
||||
# Persist a history record - one line per run, tailed by the
|
||||
# sparkline on every subsequent TUI launch.
|
||||
duration_s = time.monotonic() - self._start_time
|
||||
passed = sum(t.passed for t in self._state.tiers.values())
|
||||
@@ -1526,13 +1526,13 @@ def _build_app(
|
||||
leaf = self._state.leaves.get(nodeid)
|
||||
if leaf is None:
|
||||
# First event for this nodeid is the report itself (no
|
||||
# collection event seen) — register on the fly.
|
||||
# collection event seen) - register on the fly.
|
||||
self._register_leaf(nodeid)
|
||||
leaf = self._state.leaves[nodeid]
|
||||
prev = leaf.outcome
|
||||
leaf.outcome = outcome
|
||||
leaf.duration_s = float(ev.get("duration", 0.0) or 0.0)
|
||||
# Wall-clock start/stop — pytest-reportlog emits these as
|
||||
# Wall-clock start/stop - pytest-reportlog emits these as
|
||||
# float seconds (Unix epoch). Used by the reproducer exporter
|
||||
# to window fwlog.jsonl down to just the failure's context.
|
||||
start = ev.get("start")
|
||||
@@ -1661,7 +1661,7 @@ def _build_app(
|
||||
if getattr(node, "data", None):
|
||||
target = str(node.data) # leaf: full nodeid
|
||||
else:
|
||||
# Internal node — derive a pytest arg.
|
||||
# Internal node - derive a pytest arg.
|
||||
labels = []
|
||||
cur: Any = node
|
||||
while cur is not None and cur.parent is not None:
|
||||
@@ -1720,7 +1720,7 @@ def _build_app(
|
||||
self.bell()
|
||||
return
|
||||
try:
|
||||
# macOS + Linux cover — falls through silently on failure.
|
||||
# macOS + Linux cover - falls through silently on failure.
|
||||
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
||||
subprocess.Popen([opener, str(self._report_html)]) # noqa: S603,S607
|
||||
except Exception:
|
||||
@@ -1863,7 +1863,7 @@ def _build_app(
|
||||
# interrupted (SIGINT during a test body) it may linger.
|
||||
self._state.running_nodeid = None
|
||||
self._state.running_started_at = None
|
||||
# Device status cells need to go back to "idle" — otherwise
|
||||
# Device status cells need to go back to "idle" - otherwise
|
||||
# the prior run's RUNNING: marker sticks until the next test
|
||||
# actually starts.
|
||||
self._refresh_device_status()
|
||||
@@ -1881,7 +1881,7 @@ def _build_app(
|
||||
log = self.query_one("#pytest-log", tx.RichLog)
|
||||
log.write("")
|
||||
log.write("[tui] --- re-run ---")
|
||||
# Clear the fwlog pane too — it's fresh context for the new run.
|
||||
# Clear the fwlog pane too - it's fresh context for the new run.
|
||||
try:
|
||||
self.query_one("#fwlog-log", tx.RichLog).clear()
|
||||
except Exception:
|
||||
|
||||
@@ -42,7 +42,7 @@ def parse_tcp_port(port: str) -> tuple[str, int]:
|
||||
"""Parse `tcp://host[:port]` → (host, port). Defaults to 4403.
|
||||
|
||||
Validates host shape (non-empty, no path separators) and port range
|
||||
(1..65535). Raises `ConnectionError` on malformed input — never lets
|
||||
(1..65535). Raises `ConnectionError` on malformed input - never lets
|
||||
a raw `ValueError` bubble up to a tool surface.
|
||||
"""
|
||||
if not port.startswith(TCP_SCHEME):
|
||||
@@ -65,7 +65,7 @@ def parse_tcp_port(port: str) -> tuple[str, int]:
|
||||
if any(c in host for c in ("/", "\\")):
|
||||
raise ConnectionError(
|
||||
f"Invalid TCP endpoint {port!r}: host {host!r} contains a path "
|
||||
"separator. TCP hostnames cannot contain '/' or '\\' — did you "
|
||||
"separator. TCP hostnames cannot contain '/' or '\\' - did you "
|
||||
"pass a serial port path or a Windows drive path by mistake?"
|
||||
)
|
||||
if not (1 <= tcp_port <= 65535):
|
||||
@@ -95,7 +95,7 @@ def normalize_tcp_endpoint(endpoint: str) -> str:
|
||||
|
||||
|
||||
def reject_if_tcp(port: str | None, tool_name: str) -> None:
|
||||
"""Raise if `port` is a TCP endpoint — for tools that need real USB
|
||||
"""Raise if `port` is a TCP endpoint - for tools that need real USB
|
||||
hardware (flash, bootloader, vendor escape hatches, serial monitor).
|
||||
|
||||
Only checks the explicit arg; auto-selection via env var is the caller's
|
||||
@@ -143,7 +143,7 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
For serial: raises `ConnectionError` immediately if another serial
|
||||
session holds the port (a `pio device monitor` in `serial_sessions/`).
|
||||
For TCP: no exclusive-access requirement, so the serial-session check
|
||||
is skipped — but the `port_lock` still serializes parallel `connect()`
|
||||
is skipped - but the `port_lock` still serializes parallel `connect()`
|
||||
calls to the same daemon endpoint.
|
||||
|
||||
`timeout_s` is plumbed through to both `SerialInterface(timeout=...)`
|
||||
@@ -164,7 +164,7 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
lock = registry.port_lock(resolved)
|
||||
if not lock.acquire(blocking=False):
|
||||
raise ConnectionError(
|
||||
f"TCP endpoint {resolved} is busy — another device operation "
|
||||
f"TCP endpoint {resolved} is busy - another device operation "
|
||||
"is in flight. Retry shortly."
|
||||
)
|
||||
|
||||
@@ -204,7 +204,7 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator:
|
||||
lock = registry.port_lock(resolved)
|
||||
if not lock.acquire(blocking=False):
|
||||
raise ConnectionError(
|
||||
f"Port {resolved} is busy — another device operation is in flight. "
|
||||
f"Port {resolved} is busy - another device operation is in flight. "
|
||||
"Retry shortly."
|
||||
)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ def _tcp_endpoint_from_env() -> dict[str, Any] | None:
|
||||
|
||||
If the env var is malformed (non-integer port, path-like host, etc.),
|
||||
return an entry with `likely_meshtastic=False` and the parser error in
|
||||
the description, rather than raising — `list_devices` is the diagnostic
|
||||
the description, rather than raising - `list_devices` is the diagnostic
|
||||
tool a user reaches for when their env var isn't working, so it must
|
||||
not crash on misconfiguration.
|
||||
"""
|
||||
@@ -48,7 +48,7 @@ def _tcp_endpoint_from_env() -> dict[str, Any] | None:
|
||||
# user can see exactly what they set and why it was rejected.
|
||||
# Don't double the scheme if the user already prefixed `tcp://`.
|
||||
port = host if host.startswith(connection.TCP_SCHEME) else f"tcp://{host}"
|
||||
description = f"meshtasticd (TCP) — invalid MESHTASTIC_MCP_TCP_HOST: {e}"
|
||||
description = f"meshtasticd (TCP) - invalid MESHTASTIC_MCP_TCP_HOST: {e}"
|
||||
likely = False
|
||||
return {
|
||||
"port": port,
|
||||
@@ -122,7 +122,7 @@ def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]:
|
||||
# Stable ordering: likely_meshtastic first; within rank, TCP wins over
|
||||
# USB (explicit env-var configuration takes precedence over USB
|
||||
# enumeration); then by port path. A misconfigured TCP entry has
|
||||
# likely_meshtastic=False and lands among the other ignored entries —
|
||||
# likely_meshtastic=False and lands among the other ignored entries -
|
||||
# it does NOT pre-empt real USB devices at the top of the list.
|
||||
results.sort(
|
||||
key=lambda r: (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Fake NodeDB fixture push — Portduino file copy + hardware XModem upload.
|
||||
"""Fake NodeDB fixture push - Portduino file copy + hardware XModem upload.
|
||||
|
||||
The fixture pipeline is two-stage:
|
||||
1. `bin/gen-fake-nodedb-seed.py` produces a deterministic JSONL describing N
|
||||
@@ -78,7 +78,7 @@ def _crc16_ccitt(data: bytes, *, init: int = 0x0000) -> int:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compile step — shells out to bin/seed-json-to-proto.py so the MCP module
|
||||
# Compile step - shells out to bin/seed-json-to-proto.py so the MCP module
|
||||
# doesn't have to duplicate the proto-encoding logic.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _compile_proto(jsonl_path: pathlib.Path, out_path: pathlib.Path) -> None:
|
||||
@@ -117,7 +117,7 @@ def _resolve_seed_jsonl(size: int, custom: str | None) -> pathlib.Path:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portduino push — file copy into ~/.portduino/<config>/prefs/
|
||||
# Portduino push - file copy into ~/.portduino/<config>/prefs/
|
||||
# ---------------------------------------------------------------------------
|
||||
def _portduino_prefs_dir(config_name: str) -> pathlib.Path:
|
||||
home = pathlib.Path.home()
|
||||
@@ -152,7 +152,7 @@ def _push_portduino(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardware push — XModem over BLE/serial via the meshtastic Python interface.
|
||||
# Hardware push - XModem over BLE/serial via the meshtastic Python interface.
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class _AckEvent:
|
||||
@@ -165,7 +165,7 @@ def _wait_for_response(q: "queue.Queue[_AckEvent]", timeout_s: float) -> _AckEve
|
||||
return q.get(timeout=timeout_s)
|
||||
except queue.Empty as exc:
|
||||
raise FixtureError(
|
||||
f"XModem response timeout after {timeout_s:.1f}s — device not responding"
|
||||
f"XModem response timeout after {timeout_s:.1f}s - device not responding"
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -180,14 +180,14 @@ def _push_hardware(
|
||||
try:
|
||||
from meshtastic.protobuf import mesh_pb2, xmodem_pb2
|
||||
from pubsub import pub
|
||||
except ImportError as exc: # pragma: no cover — dep missing
|
||||
except ImportError as exc: # pragma: no cover - dep missing
|
||||
raise FixtureError(
|
||||
f"hardware push requires the meshtastic + pypubsub packages: {exc}"
|
||||
) from exc
|
||||
|
||||
if is_tcp_port(port):
|
||||
raise FixtureError(
|
||||
"hardware push over TCP/portduino is not supported — use "
|
||||
"hardware push over TCP/portduino is not supported - use "
|
||||
"target='portduino' to drop the fixture directly into the prefs dir."
|
||||
)
|
||||
|
||||
@@ -322,7 +322,7 @@ def _push_hardware(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point — registered as an MCP tool in server.py.
|
||||
# Public entry point - registered as an MCP tool in server.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
def push_fake_nodedb(
|
||||
size: int,
|
||||
@@ -338,11 +338,11 @@ def push_fake_nodedb(
|
||||
"""Compile a fresh-timestamp NodeDatabase fixture and push it to a device.
|
||||
|
||||
Args:
|
||||
size: 250, 500, 1000, or 2000 — selects which committed seed JSONL to use.
|
||||
size: 250, 500, 1000, or 2000 - selects which committed seed JSONL to use.
|
||||
target: "portduino" (file copy to ~/.portduino/<config>/prefs/) or
|
||||
"hardware" (XModem upload to /prefs/nodes.proto + reboot).
|
||||
port: required for target="hardware". Serial path (e.g. /dev/cu.usbmodemXXXX)
|
||||
or BLE identifier. TCP endpoints are rejected — use target="portduino"
|
||||
or BLE identifier. TCP endpoints are rejected - use target="portduino"
|
||||
instead.
|
||||
portduino_config: which Portduino instance dir under ~/.portduino/. Default "default".
|
||||
backup_existing: portduino only. Move nodes.proto -> nodes.proto.bak.<ts>
|
||||
@@ -354,7 +354,7 @@ def push_fake_nodedb(
|
||||
test scenario.
|
||||
|
||||
Returns:
|
||||
dict with transport, bytes, sha256, etc. — depends on target.
|
||||
dict with transport, bytes, sha256, etc. - depends on target.
|
||||
|
||||
"""
|
||||
if size not in _VALID_SIZES:
|
||||
@@ -371,7 +371,7 @@ def push_fake_nodedb(
|
||||
if target == "hardware":
|
||||
if not confirm:
|
||||
raise FixtureError(
|
||||
"hardware push writes flash and triggers a reboot — pass confirm=True."
|
||||
"hardware push writes flash and triggers a reboot - pass confirm=True."
|
||||
)
|
||||
if not port:
|
||||
raise FixtureError(
|
||||
|
||||
@@ -47,7 +47,7 @@ def _require_confirm(confirm: bool, operation: str) -> None:
|
||||
|
||||
|
||||
def _reject_native_env(env: str, operation: str) -> None:
|
||||
"""`native*` envs build a host executable, not firmware — there's no
|
||||
"""`native*` envs build a host executable, not firmware - there's no
|
||||
upload step. The user wants `build` (or just runs the binary directly).
|
||||
"""
|
||||
if env.startswith("native"):
|
||||
@@ -121,7 +121,7 @@ def build(
|
||||
`{"DEBUG_HEAP": 1}` enables per-thread leak detection + `[heap N]`
|
||||
prefix on every log line. Combines with the recorder so heap shows
|
||||
up at log cadence (much higher resolution than the ~60 s LocalStats
|
||||
packet) — see `recorder/parsers.py:_HEAP_PREFIX_RE`. Bool values
|
||||
packet) - see `recorder/parsers.py:_HEAP_PREFIX_RE`. Bool values
|
||||
expand to bare `-D<NAME>` (presence-only flags).
|
||||
"""
|
||||
args = ["run", "-e", env]
|
||||
@@ -183,10 +183,10 @@ def flash(
|
||||
) -> dict[str, Any]:
|
||||
"""`pio run -e <env> -t upload --upload-port <port>`. All architectures.
|
||||
|
||||
`userprefs_overrides` (optional): see `build()` — the rebuild-before-upload
|
||||
`userprefs_overrides` (optional): see `build()` - the rebuild-before-upload
|
||||
that pio performs will pick up the injected values.
|
||||
|
||||
`build_flags` (optional): same shape as `build()` — `PLATFORMIO_BUILD_FLAGS`
|
||||
`build_flags` (optional): same shape as `build()` - `PLATFORMIO_BUILD_FLAGS`
|
||||
is exported for the rebuild-before-upload, so the uploaded firmware
|
||||
actually carries the flags. Without this propagation, `pio run -t upload`
|
||||
would relink without the env var and silently drop them. Common use:
|
||||
@@ -361,7 +361,7 @@ def update_flash(
|
||||
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
|
||||
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
|
||||
@@ -433,7 +433,7 @@ def touch_1200bps(
|
||||
poll_timeout_s: float = 8.0,
|
||||
retries: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""Open port at 1200 baud, close immediately — triggers USB CDC bootloader.
|
||||
"""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.
|
||||
@@ -442,7 +442,7 @@ def touch_1200bps(
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""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
|
||||
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.
|
||||
|
||||
@@ -216,7 +216,7 @@ def _parse_picotool_info(stdout: str) -> dict[str, Any]:
|
||||
|
||||
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."""
|
||||
only - picotool auto-detects."""
|
||||
connection.reject_if_tcp(port, "picotool_info")
|
||||
binary = config.picotool_bin()
|
||||
res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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`
|
||||
with the firmware enum - unit test `tests/unit/test_input_event_codes.py`
|
||||
pins the mapping.
|
||||
"""
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ def telemetry_timeline(
|
||||
"""
|
||||
end = time.time()
|
||||
if isinstance(window, (int, float)):
|
||||
# Numeric `window` is a duration in seconds — "last N seconds".
|
||||
# Numeric `window` is a duration in seconds - "last N seconds".
|
||||
# Without this branch, `_parse_time(-N)` would treat -N as an
|
||||
# absolute epoch timestamp (i.e., Jan 1 1970 minus N seconds),
|
||||
# producing a wildly negative `start` and matching nothing.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""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
|
||||
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
|
||||
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`
|
||||
@@ -132,7 +132,7 @@ def warm() -> None:
|
||||
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.
|
||||
# A 64×32 white PNG - decodes clean, no text to extract.
|
||||
white_png = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000400000002008060000007ccac28e"
|
||||
"0000001c49444154785eedc1010d000000c2a0f74f6d0d370000000000000080"
|
||||
|
||||
@@ -6,12 +6,12 @@ have a single place that owns timeouts, buffer sizes, JSON parsing, and the
|
||||
|
||||
`run()` has two execution paths:
|
||||
|
||||
* Fast path (default): `subprocess.run(capture_output=True)` — buffered, one
|
||||
* 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
|
||||
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
|
||||
@@ -110,7 +110,7 @@ def _run_capturing(
|
||||
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.
|
||||
file in the order they arrived - that's what a human wants to read.
|
||||
"""
|
||||
log_path = _flash_log_path()
|
||||
t0 = time.monotonic()
|
||||
@@ -119,7 +119,7 @@ def _run_capturing(
|
||||
env = {**os.environ, **extra_env}
|
||||
|
||||
if log_path is None:
|
||||
# Fast path — unchanged.
|
||||
# Fast path - unchanged.
|
||||
proc = subprocess.run(
|
||||
list(argv),
|
||||
cwd=str(cwd) if cwd else None,
|
||||
@@ -172,7 +172,7 @@ def _run_capturing(
|
||||
log_fh.flush()
|
||||
except OSError:
|
||||
# Log file disappeared (umount, operator deleted the dir).
|
||||
# Don't let that bubble up — the subprocess output is still
|
||||
# Don't let that bubble up - the subprocess output is still
|
||||
# collected in-memory for the return value.
|
||||
try:
|
||||
log_fh.close()
|
||||
@@ -248,7 +248,7 @@ def run(
|
||||
`cwd` defaults to the firmware root. `check=True` raises `PioError` on
|
||||
non-zero exit; set `check=False` to inspect `returncode` manually.
|
||||
|
||||
`extra_env` merges into the subprocess environment — used for
|
||||
`extra_env` merges into the subprocess environment - used for
|
||||
`PLATFORMIO_BUILD_FLAGS=-DDEBUG_HEAP=1` and similar build-time
|
||||
toggles that can't be expressed as command-line args.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Two flavors of log line cross our pubsub subscription:
|
||||
accumulates bytes between protobuf frames and emits the full
|
||||
firmware-formatted line, e.g.
|
||||
"INFO | 12:34:56 12345 [Main] Booting"
|
||||
— level, HH:MM:SS, uptime seconds, thread bracket, then message.
|
||||
- level, HH:MM:SS, uptime seconds, thread bracket, then message.
|
||||
2. LogRecord protobuf path (debug_log_api enabled): the lib calls
|
||||
`_handleLogLine(record.message)` with ONLY the message body. The
|
||||
level/source/time fields on the LogRecord are dropped before
|
||||
@@ -29,7 +29,7 @@ from typing import Any
|
||||
|
||||
# Match: LEVEL | HH:MM:SS UPTIME [Thread] message
|
||||
# HH:MM:SS may be ??:??:?? when RTC isn't valid. The level alternation
|
||||
# below is the canonical list — DebugConfiguration.h's MESHTASTIC_LOG_LEVEL_*
|
||||
# below is the canonical list - DebugConfiguration.h's MESHTASTIC_LOG_LEVEL_*
|
||||
# macros must stay in sync with these strings.
|
||||
_LINE_RE = re.compile(
|
||||
r"""
|
||||
@@ -84,7 +84,7 @@ _HEAP_BRACKET_RE = re.compile(r"^heap\s+(?P<heap>\d+)$")
|
||||
def parse_log_line(line: str) -> dict[str, Any]:
|
||||
"""Best-effort decompose a raw firmware log line.
|
||||
|
||||
Returns a dict with at least `line` (the original, unmodified — ANSI
|
||||
Returns a dict with at least `line` (the original, unmodified - ANSI
|
||||
codes preserved for fidelity). Adds `level`, `tag`, `clock`,
|
||||
`uptime_s`, and `msg` when the full prefix is present.
|
||||
|
||||
@@ -93,7 +93,7 @@ def parse_log_line(line: str) -> dict[str, Any]:
|
||||
(the BLE/StreamAPI path inherited the colored body in some builds).
|
||||
We strip ANSI before regex matching so the prefix survives.
|
||||
- DEBUG_HEAP injects `[heap N]` after the thread bracket. When NO
|
||||
thread name is set, the heap takes the thread bracket position —
|
||||
thread name is set, the heap takes the thread bracket position -
|
||||
looks like `[heap 12345] msg`. We detect that shape and move it
|
||||
out of `tag` and into `heap_free`.
|
||||
|
||||
@@ -137,7 +137,7 @@ def parse_log_line(line: str) -> dict[str, Any]:
|
||||
msg = m.group("msg")
|
||||
out["msg"] = msg
|
||||
else:
|
||||
# No prefix — bare LogRecord.message body. Inspect the whole
|
||||
# No prefix - bare LogRecord.message body. Inspect the whole
|
||||
# line for DEBUG_HEAP-style content; the heap-prefix and
|
||||
# thread-leak patterns can survive on either path.
|
||||
msg = clean
|
||||
@@ -204,7 +204,7 @@ _TELEMETRY_VARIANTS = (
|
||||
|
||||
def extract_telemetry(packet: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Pull the telemetry variant + flat fields out of a `meshtastic.receive.telemetry`
|
||||
packet. Returns None when the shape isn't what we expect — so the
|
||||
packet. Returns None when the shape isn't what we expect - so the
|
||||
caller can fall back to a generic packets.jsonl row.
|
||||
"""
|
||||
if not isinstance(packet, dict):
|
||||
@@ -251,7 +251,7 @@ def summarize_packet(
|
||||
packet: dict[str, Any], *, payload_hex_len: int = 64
|
||||
) -> dict[str, Any]:
|
||||
"""Reduce a packet dict to a stable, queryable summary. Drops the
|
||||
full payload bytes — the recorder records summaries, not pcaps.
|
||||
full payload bytes - the recorder records summaries, not pcaps.
|
||||
"""
|
||||
if not isinstance(packet, dict):
|
||||
return {"raw_type": type(packet).__name__}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
Subscribes once to the meshtastic pubsub fan-out and writes four append-only
|
||||
JSONL streams under `mcp-server/.mtlog/`. The pubsub fan-out is
|
||||
process-global — a single subscription captures every active interface
|
||||
process-global - a single subscription captures every active interface
|
||||
without per-connection bookkeeping.
|
||||
|
||||
Files:
|
||||
logs.jsonl — every `meshtastic.log.line` event (best-effort prefix
|
||||
logs.jsonl - every `meshtastic.log.line` event (best-effort prefix
|
||||
parsed for level/tag/uptime; raw `line` always preserved)
|
||||
telemetry.jsonl — `meshtastic.receive.telemetry` packets, flattened by
|
||||
telemetry.jsonl - `meshtastic.receive.telemetry` packets, flattened by
|
||||
variant (device / local / environment / power / etc.)
|
||||
packets.jsonl — every other `meshtastic.receive.*` packet, summarized
|
||||
packets.jsonl - every other `meshtastic.receive.*` packet, summarized
|
||||
(portnum, hops, RSSI/SNR, payload size + 64-byte hex)
|
||||
events.jsonl — connection lifecycle, node-DB updates, and manual
|
||||
events.jsonl - connection lifecycle, node-DB updates, and manual
|
||||
`mark_event` rows. Lower volume; useful for aligning
|
||||
timelines.
|
||||
|
||||
@@ -83,7 +83,7 @@ class Recorder:
|
||||
self._started = False
|
||||
|
||||
def pause(self, reason: str | None = None) -> None:
|
||||
# Write the pause marker BEFORE flipping the flag — `_write_event`
|
||||
# Write the pause marker BEFORE flipping the flag - `_write_event`
|
||||
# short-circuits when paused, so the order matters for this event
|
||||
# to actually land in events.jsonl.
|
||||
self._write_event(
|
||||
@@ -108,7 +108,7 @@ class Recorder:
|
||||
def _wire_pubsub(self) -> None:
|
||||
from pubsub import pub # type: ignore[import-untyped]
|
||||
|
||||
# Subscribers — one per topic. Each pubsub publisher sends
|
||||
# Subscribers - one per topic. Each pubsub publisher sends
|
||||
# keyword args matching its handler's signature; pubsub
|
||||
# introspects the function signature to route args.
|
||||
bindings = [
|
||||
@@ -225,7 +225,7 @@ class Recorder:
|
||||
Same parse + heap-synthesis path as `_on_log_line`, but receives
|
||||
the raw text-formatted line (full level/clock/uptime/thread/`[heap N]`/
|
||||
body). On DEBUG_HEAP builds in text mode this gives us per-log-line
|
||||
heap data — far higher cadence than LocalStats, and works without
|
||||
heap data - far higher cadence than LocalStats, and works without
|
||||
protobuf API mode (no SerialInterface required).
|
||||
"""
|
||||
files = self._files_snapshot()
|
||||
@@ -255,7 +255,7 @@ class Recorder:
|
||||
files["logs"].write(record)
|
||||
|
||||
# Synthesize a heap_free telemetry sample whenever the line
|
||||
# carries one — same logic as _on_log_line, tagged source so
|
||||
# carries one - same logic as _on_log_line, tagged source so
|
||||
# consumers can distinguish text-mode tap from protobuf path.
|
||||
heap_free = parsed.get("heap_free")
|
||||
if isinstance(heap_free, int):
|
||||
@@ -285,7 +285,7 @@ class Recorder:
|
||||
tags = parsers.interface_label(interface)
|
||||
extracted = parsers.extract_telemetry(packet)
|
||||
if extracted is None:
|
||||
# Couldn't extract a known variant — fall through to the
|
||||
# Couldn't extract a known variant - fall through to the
|
||||
# generic `_on_receive` path, which will still fire for
|
||||
# this packet via the parent topic.
|
||||
return
|
||||
@@ -304,7 +304,7 @@ class Recorder:
|
||||
|
||||
def _on_receive(self, packet: dict[str, Any], interface: Any = None) -> None:
|
||||
# Generic-receive fires for EVERY packet. Telemetry packets get
|
||||
# recorded twice (here and in _on_telemetry) — that's intentional:
|
||||
# recorded twice (here and in _on_telemetry) - that's intentional:
|
||||
# packets.jsonl is the universal record, telemetry.jsonl is the
|
||||
# structured timeseries view.
|
||||
files = self._files_snapshot()
|
||||
@@ -338,7 +338,7 @@ class Recorder:
|
||||
def _on_node_updated(
|
||||
self, node: dict[str, Any] | None = None, interface: Any = None
|
||||
) -> None:
|
||||
# Lower-volume than packets but informative — node ID, hops away,
|
||||
# Lower-volume than packets but informative - node ID, hops away,
|
||||
# last heard. Skip the user dict if absent.
|
||||
try:
|
||||
user = (node or {}).get("user") if isinstance(node, dict) else None
|
||||
@@ -381,7 +381,7 @@ class Recorder:
|
||||
"role": "marker",
|
||||
"level": "MARK",
|
||||
"tag": "mark_event",
|
||||
"line": f"[mark] {label}" + (f" — {note}" if note else ""),
|
||||
"line": f"[mark] {label}" + (f" - {note}" if note else ""),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
@@ -399,7 +399,7 @@ class Recorder:
|
||||
) -> float:
|
||||
ts = time.time()
|
||||
# Lifecycle markers (recorder_start, recorder_pause, recorder_resume)
|
||||
# arrive at choreographed moments — `pause()` writes BEFORE flipping
|
||||
# arrive at choreographed moments - `pause()` writes BEFORE flipping
|
||||
# the flag and `resume()` writes AFTER clearing it, so those calls
|
||||
# see _paused=False here. Other event kinds short-circuit when
|
||||
# paused via the snapshot guard below.
|
||||
|
||||
@@ -6,7 +6,7 @@ it is closed, gzipped to `<name>.YYYYMMDD-HHMMSS-uuuuuu-NNNNN.jsonl.gz`,
|
||||
and the live file resets to empty. Old archives past `keep_archives` are
|
||||
unlinked oldest-first.
|
||||
|
||||
Size check is amortized — `os.fstat` runs every `check_every` writes,
|
||||
Size check is amortized - `os.fstat` runs every `check_every` writes,
|
||||
not per-write, so the hot path stays at one `fh.write` + one `fh.flush`.
|
||||
|
||||
Threading: every public method acquires `self._lock`. The recorder runs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Long-running serial monitor sessions via `pio device monitor`.
|
||||
|
||||
Why pio instead of raw pyserial: pio applies the board's monitor_filters —
|
||||
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.
|
||||
|
||||
@@ -53,7 +53,7 @@ def _drain(session: SerialSession) -> None:
|
||||
own port. This is the text-mode tap path: when no SerialInterface is
|
||||
open, the firmware emits full formatted lines (level + clock + uptime
|
||||
+ thread + `[heap N]` prefix on DEBUG_HEAP builds + body), and we
|
||||
fan them out to whoever is listening. Pubsub is best-effort —
|
||||
fan them out to whoever is listening. Pubsub is best-effort -
|
||||
publish failures must never block the reader.
|
||||
"""
|
||||
# Lazy import: pubsub isn't required just to import this module
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""FastMCP server wiring — 43 tools across 9 categories (adds uhubctl power control).
|
||||
"""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.
|
||||
@@ -32,7 +32,7 @@ app = FastMCP("meshtastic-mcp")
|
||||
|
||||
|
||||
def _start_recorder() -> None:
|
||||
# Persistent device-log capture. Starts on first import — pubsub fan-out
|
||||
# Persistent device-log capture. Starts on first import - pubsub fan-out
|
||||
# is process-global, so subscribing here captures every active interface
|
||||
# (whether opened by an MCP tool, a pytest fixture, or a serial_session).
|
||||
# Files land in mcp-server/.mtlog/ (gitignored). See recorder/recorder.py
|
||||
@@ -154,7 +154,7 @@ def pio_flash(
|
||||
|
||||
`build_flags` (optional): dict of `-D<NAME>=<VALUE>` macros for the
|
||||
rebuild-before-upload, e.g. `{"DEBUG_HEAP": 1}`. Required for the flags
|
||||
to actually land in the uploaded firmware — without it, the implicit
|
||||
to actually land in the uploaded firmware - without it, the implicit
|
||||
rebuild relinks without the env var and silently drops them.
|
||||
"""
|
||||
return flash.flash(
|
||||
@@ -219,7 +219,7 @@ 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
|
||||
`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
|
||||
@@ -268,7 +268,7 @@ 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
|
||||
currently written automatically). Returns `{restored: bool, ...}` - false
|
||||
when no backup is present, in which case the caller should edit the
|
||||
jsonc directly.
|
||||
"""
|
||||
@@ -293,7 +293,7 @@ def userprefs_testing_profile(
|
||||
- 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
|
||||
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
|
||||
@@ -314,8 +314,8 @@ def userprefs_testing_profile(
|
||||
(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,
|
||||
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.
|
||||
@@ -345,7 +345,7 @@ def touch_1200bps(port: str, settle_ms: int = 250) -> dict[str, Any]:
|
||||
|
||||
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.
|
||||
device). Not destructive - this is just a reset signal.
|
||||
"""
|
||||
return flash.touch_1200bps(port, settle_ms=settle_ms)
|
||||
|
||||
@@ -363,7 +363,7 @@ def serial_open(
|
||||
"""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
|
||||
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"]).
|
||||
@@ -398,7 +398,7 @@ def serial_read(
|
||||
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.
|
||||
buffer between reads - so a value > 0 means you missed data.
|
||||
"""
|
||||
session = registry.get_session(session_id)
|
||||
return serial_session.read_session(
|
||||
@@ -502,13 +502,13 @@ def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
|
||||
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
|
||||
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
|
||||
firmware level - the log path has a dedicated scratch + txBuf and
|
||||
both emission paths serialize via a mutex. Safe to leave on under
|
||||
traffic.
|
||||
"""
|
||||
@@ -625,7 +625,7 @@ def capture_screen(role: str | None = None, ocr: bool = True) -> dict[str, Any]:
|
||||
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
|
||||
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.
|
||||
@@ -645,12 +645,12 @@ def uhubctl_power(
|
||||
) -> 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").
|
||||
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
|
||||
`action="off"` requires `confirm=True` (destructive - the attached
|
||||
device will immediately disappear from the OS).
|
||||
"""
|
||||
from . import uhubctl as uhubctl_mod
|
||||
@@ -678,7 +678,7 @@ def uhubctl_cycle(
|
||||
) -> 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
|
||||
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`.
|
||||
"""
|
||||
@@ -714,7 +714,7 @@ def _resolve_uhubctl_target(
|
||||
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
|
||||
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)
|
||||
@@ -738,7 +738,7 @@ def esptool_raw(
|
||||
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.
|
||||
tools where possible - they know board-specific offsets and protocols.
|
||||
"""
|
||||
return hw_tools.esptool_raw(args, port=port, confirm=confirm)
|
||||
|
||||
@@ -747,7 +747,7 @@ def esptool_raw(
|
||||
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
|
||||
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.
|
||||
"""
|
||||
@@ -786,7 +786,7 @@ def picotool_raw(args: list[str], confirm: bool = False) -> dict[str, Any]:
|
||||
|
||||
# ---------- Persistent device-log capture (recorder) ----------------------
|
||||
#
|
||||
# The recorder is autouse — it starts at server import and continuously
|
||||
# The recorder is autouse - it starts at server import and continuously
|
||||
# writes every meshtastic pubsub event to JSONL files under .mtlog/. These
|
||||
# tools are query-only over those files, plus a few lifecycle controls.
|
||||
|
||||
@@ -810,7 +810,7 @@ def logs_window(
|
||||
Time strings: "-15m", "-2h", "-3d", "now", or ISO 8601.
|
||||
|
||||
Note: lines arriving via the LogRecord protobuf path (when
|
||||
set_debug_log_api(True) is on) come without level prefix — the
|
||||
set_debug_log_api(True) is on) come without level prefix - the
|
||||
meshtastic Python lib drops record.level before fan-out. For those,
|
||||
`level` filter won't match; use `grep` instead.
|
||||
"""
|
||||
@@ -840,7 +840,7 @@ def telemetry_timeline(
|
||||
heap_free_bytes) are normalized.
|
||||
|
||||
Returns slope_per_min (linear-regression slope, units/minute) so a
|
||||
leak detector can read one number — negative slope on free_heap over
|
||||
leak detector can read one number - negative slope on free_heap over
|
||||
a long window indicates a real leak.
|
||||
|
||||
LocalStats variant ("local") cadence is ~60 s (whatever the device's
|
||||
@@ -868,7 +868,7 @@ def packets_window(
|
||||
"""Recent mesh packets recorded by the recorder.
|
||||
|
||||
Each row is a summary (portnum, from/to, hop_limit, RSSI/SNR, payload
|
||||
size + first 64 bytes hex) — full payload bytes are not stored.
|
||||
size + first 64 bytes hex) - full payload bytes are not stored.
|
||||
`portnum` accepts a pipe-separated set like "TEXT_MESSAGE_APP|POSITION_APP".
|
||||
"""
|
||||
return log_query.packets_window(
|
||||
@@ -927,7 +927,7 @@ def recorder_status() -> dict[str, Any]:
|
||||
|
||||
@app.tool()
|
||||
def recorder_pause(reason: str | None = None) -> dict[str, Any]:
|
||||
"""Pause writes to all four streams. Pubsub subscriptions stay active —
|
||||
"""Pause writes to all four streams. Pubsub subscriptions stay active -
|
||||
we just drop events on the floor while paused. Resume with `recorder_resume`.
|
||||
|
||||
Use when capturing a known-good baseline that you don't want to
|
||||
@@ -983,9 +983,9 @@ def push_fake_nodedb(
|
||||
"""Push a fake-NodeDB v25 fixture (250/500/1000/2000 nodes) onto a device.
|
||||
|
||||
Two transports:
|
||||
target="portduino" — file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
|
||||
target="portduino" - file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
|
||||
Fast, no device connection needed.
|
||||
target="hardware" — XModem upload over serial/BLE to /prefs/nodes.proto.
|
||||
target="hardware" - XModem upload over serial/BLE to /prefs/nodes.proto.
|
||||
Requires `port` + `confirm=True`. Triggers a reboot
|
||||
so loadFromDisk picks up the new file at next boot.
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""USB hub power control via `uhubctl` — hard-recovery for wedged devices +
|
||||
"""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
|
||||
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:
|
||||
@@ -24,7 +24,7 @@ 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.
|
||||
would prompt in the middle of test runs - bad for CI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -60,7 +60,7 @@ class UhubctlError(RuntimeError):
|
||||
# ---------- 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.
|
||||
# Note: esp32s3 and esp32s3_alt share a logical role - we search both.
|
||||
ROLE_VIDS: dict[str, tuple[int, ...]] = {
|
||||
"nrf52": (0x239A,),
|
||||
"esp32s3": (0x303A, 0x10C4),
|
||||
@@ -75,8 +75,8 @@ def _normalize_role(role: str) -> str:
|
||||
# ---------- 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 —
|
||||
# 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 = (
|
||||
@@ -170,7 +170,7 @@ def parse_list_output(output: str) -> list[dict[str, Any]]:
|
||||
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
|
||||
Pure read - no power state changes. Useful as a pre-flight check before
|
||||
a destructive `power_off` call.
|
||||
"""
|
||||
result = _run_uhubctl([], timeout=15.0)
|
||||
@@ -189,7 +189,7 @@ def find_port_for_vid(
|
||||
) -> 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
|
||||
`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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""USERPREFS: build-time constants baked into the firmware binary.
|
||||
|
||||
The firmware repo has `userPrefs.jsonc` at its root — a JSONC file with every
|
||||
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
|
||||
@@ -10,7 +10,7 @@ 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
|
||||
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
|
||||
@@ -111,7 +111,7 @@ def read_state() -> dict[str, Any]:
|
||||
def _scan_consumed_keys() -> dict[str, list[str]]:
|
||||
"""Grep firmware src/ for USERPREFS_* references.
|
||||
|
||||
Returns {key: [relative_file_paths]} — only includes files under `src/`.
|
||||
Returns {key: [relative_file_paths]} - only includes files under `src/`.
|
||||
"""
|
||||
src_dir = config.firmware_root() / "src"
|
||||
if not src_dir.is_dir():
|
||||
@@ -153,7 +153,7 @@ def build_manifest() -> dict[str, Any]:
|
||||
- `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
|
||||
- matches platformio-custom.py's value-wrapping switch
|
||||
"""
|
||||
state = read_state()
|
||||
consumed = _scan_consumed_keys()
|
||||
@@ -210,7 +210,7 @@ def infer_type(value: str | None) -> str:
|
||||
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
|
||||
# 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}",'
|
||||
@@ -289,7 +289,7 @@ def _stringify(value: Any) -> str:
|
||||
|
||||
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.
|
||||
them themselves - this function doesn't try to synthesize them.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
@@ -420,9 +420,9 @@ def build_testing_profile(
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
+16
-16
@@ -1,4 +1,4 @@
|
||||
# Meshtastic MCP Server — Test Harness
|
||||
# Meshtastic MCP Server - Test Harness
|
||||
|
||||
Automated test suite for the MCP server, organized around real operator
|
||||
concerns rather than generic "unit vs hardware".
|
||||
@@ -21,13 +21,13 @@ concerns rather than generic "unit vs hardware".
|
||||
cd mcp-server
|
||||
pip install -e ".[test]"
|
||||
|
||||
# No hardware — 33 unit tests, ~3 seconds
|
||||
# No hardware - 33 unit tests, ~3 seconds
|
||||
pytest tests/unit -v
|
||||
|
||||
# Hub attached (nRF52840 + ESP32-S3) — first run bakes, then exercises everything
|
||||
# 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
|
||||
# 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.)
|
||||
@@ -36,23 +36,23 @@ pytest tests/ --force-bake --html=report.html
|
||||
|
||||
## CLI flags
|
||||
|
||||
- `--force-bake` — always reflash both roles at session start, even if the
|
||||
- `--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
|
||||
- `--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.
|
||||
- `--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/`
|
||||
- `--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
|
||||
- `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
|
||||
- `MESHTASTIC_MCP_ENV_ESP32S3` - PlatformIO env for the ESP32-S3 role (default
|
||||
`heltec-v3`)
|
||||
- `MESHTASTIC_MCP_SEED` — override the session PSK seed (default:
|
||||
- `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
|
||||
@@ -64,7 +64,7 @@ All defined in `conftest.py`:
|
||||
- **`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).
|
||||
(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
|
||||
@@ -84,7 +84,7 @@ predicate(), timeout=60)` replaces flaky `time.sleep()` patterns.
|
||||
|
||||
`pytest --junitxml=junit.xml` produces CI-integration XML.
|
||||
|
||||
`tool_coverage.json` is emitted at session end in the tests directory — shows
|
||||
`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
|
||||
@@ -95,11 +95,11 @@ which of the 38 MCP tools the run exercised. Useful for closing test gaps.
|
||||
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()`
|
||||
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
|
||||
5. Add a `@pytest.mark.timeout(N)` - mesh tests routinely hit LoRa-airtime
|
||||
waits; default pytest timeout is infinite.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""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
|
||||
"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
|
||||
@@ -10,7 +10,7 @@ captured the pre-operation port and reuses it after will fail with
|
||||
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
|
||||
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
|
||||
@@ -18,7 +18,7 @@ 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.
|
||||
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.
|
||||
@@ -70,13 +70,13 @@ def resolve_port_by_role(
|
||||
``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
|
||||
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
|
||||
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.
|
||||
@@ -98,7 +98,7 @@ def resolve_port_by_role(
|
||||
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
|
||||
# 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)}]
|
||||
@@ -109,7 +109,7 @@ def resolve_port_by_role(
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 1.5, poll_max)
|
||||
|
||||
# Timeout path — include what we saw so the operator can tell
|
||||
# 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 "
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""USB hub power control for tests — thin composition of the `uhubctl`
|
||||
"""USB hub power control for tests - thin composition of the `uhubctl`
|
||||
module + `_port_discovery.resolve_port_by_role`.
|
||||
|
||||
Why separate from the production module:
|
||||
@@ -11,7 +11,7 @@ Why separate from the production module:
|
||||
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
|
||||
uhubctl isn't installed - we never want "no uhubctl" to look like a test
|
||||
failure.
|
||||
"""
|
||||
|
||||
@@ -29,14 +29,14 @@ 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
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -22,7 +22,7 @@ def test_channel_url_roundtrip(
|
||||
) -> 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.
|
||||
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.
|
||||
"""
|
||||
@@ -34,7 +34,7 @@ def test_channel_url_roundtrip(
|
||||
"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.
|
||||
# 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
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.
|
||||
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
|
||||
@@ -12,9 +12,9 @@ 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
|
||||
* 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
|
||||
* 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
|
||||
@@ -64,7 +64,7 @@ def test_lora_hop_limit_survives_reboot(
|
||||
|
||||
# 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
|
||||
# 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 "
|
||||
@@ -92,12 +92,12 @@ def test_lora_hop_limit_survives_reboot(
|
||||
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."
|
||||
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
|
||||
# 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:
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_owner_survives_reboot(
|
||||
baked_single: dict[str, Any],
|
||||
wait_until,
|
||||
) -> None:
|
||||
"""Runs once per connected role — proves the reboot-persistence
|
||||
"""Runs once per connected role - proves the reboot-persistence
|
||||
round-trip works on each device independently, not just one."""
|
||||
port = baked_single["port"]
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import pytest
|
||||
# Ensure the MCP server is on `sys.path` without requiring installation in
|
||||
# development mode for every checkout (we DO install in .venv but this makes
|
||||
# `pytest tests/` work from a fresh clone too). The path mutation must
|
||||
# happen before `meshtastic_mcp.*` imports below — hence the `noqa: E402`
|
||||
# happen before `meshtastic_mcp.*` imports below - hence the `noqa: E402`
|
||||
# markers on those imports (ruff's "module-level import not at top of file"
|
||||
# rule doesn't understand path-bootstrapping patterns).
|
||||
_HERE = pathlib.Path(__file__).resolve().parent
|
||||
@@ -106,8 +106,8 @@ def pytest_collection_modifyitems(
|
||||
|
||||
def sort_key(item: pytest.Item) -> tuple[int, str]:
|
||||
path = str(getattr(item, "fspath", "") or item.nodeid)
|
||||
# Session-start bake runs FIRST. `baked_mesh` only verifies state —
|
||||
# nothing else actually reflashes — so if test_00_bake doesn't run
|
||||
# Session-start bake runs FIRST. `baked_mesh` only verifies state -
|
||||
# nothing else actually reflashes - so if test_00_bake doesn't run
|
||||
# before the tier tests, `--force-bake` silently becomes a no-op for
|
||||
# the tier tests and only flashes at the very end of the session.
|
||||
# Top-level nodeid ("tests/test_00_bake.py") otherwise falls into the
|
||||
@@ -128,7 +128,7 @@ def pytest_collection_modifyitems(
|
||||
# after it starts from a known re-enumerated + re-verified state.
|
||||
if "/recovery/" in path or "tests/recovery" in path:
|
||||
return (4, item.nodeid)
|
||||
# UI tier slots here — read-only w.r.t. mesh state, only mutates
|
||||
# UI tier slots here - read-only w.r.t. mesh state, only mutates
|
||||
# the on-screen UI (BACK×5 guard restores home before each test).
|
||||
if "/ui/" in path or "tests/ui" in path:
|
||||
return (5, item.nodeid)
|
||||
@@ -152,12 +152,12 @@ def pytest_collection_modifyitems(
|
||||
def session_seed(request: pytest.FixtureRequest) -> str:
|
||||
"""Deterministic PSK seed for this pytest session.
|
||||
|
||||
Logged in the HTML report header so two runs can be correlated — and so a
|
||||
Logged in the HTML report header so two runs can be correlated - and so a
|
||||
flaky-looking test can be reproduced exactly by passing the seed back via
|
||||
an env var (future extension).
|
||||
"""
|
||||
# Pytest session `starttime` isn't directly exposed on the pytest API we
|
||||
# care about, so derive from process start time — unique enough for human
|
||||
# care about, so derive from process start time - unique enough for human
|
||||
# purposes and stable across the session.
|
||||
seed = os.environ.get("MESHTASTIC_MCP_SEED") or f"pytest-{int(time.time())}"
|
||||
return seed
|
||||
@@ -169,7 +169,7 @@ def test_profile(session_seed: str) -> dict[str, Any]:
|
||||
|
||||
`enable_ui_log=True` stamps `USERPREFS_UI_TEST_LOG` so the firmware
|
||||
emits `Screen: frame N/M name=... reason=...` log lines per UI
|
||||
transition — consumed by the `tests/ui/` tier. Harmless on boards
|
||||
transition - consumed by the `tests/ui/` tier. Harmless on boards
|
||||
without a screen (the `#ifdef` sits behind `HAS_SCREEN`).
|
||||
"""
|
||||
return userprefs.build_testing_profile(
|
||||
@@ -186,25 +186,25 @@ def test_profile(session_seed: str) -> dict[str, Any]:
|
||||
def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
"""Snapshot `userPrefs.jsonc`, apply the session test profile, restore at
|
||||
session end. Guards against the suite leaving test-profile USERPREFS
|
||||
values baked into the file — if that happened, any firmware build a
|
||||
values baked into the file - if that happened, any firmware build a
|
||||
contributor ran next would silently inherit the test PSK / test channel
|
||||
name / test admin key etc.
|
||||
|
||||
Layered safety:
|
||||
1. In-memory snapshot taken before any mutation; teardown writes it back.
|
||||
2. Sidecar `userPrefs.jsonc.mcp-session-bak` on disk — belt to the
|
||||
2. Sidecar `userPrefs.jsonc.mcp-session-bak` on disk - belt to the
|
||||
in-memory suspenders. If Python segfaults or SIGKILLs, the next
|
||||
session self-heals from this file at startup.
|
||||
3. `atexit.register()` fallback: if pytest exits abnormally (Ctrl-C
|
||||
mid-test, fatal exception before teardown), the atexit hook still
|
||||
restores from the in-memory snapshot.
|
||||
4. Startup self-heal: if the sidecar exists at session start, a prior
|
||||
session crashed without cleanup — the sidecar IS the truth; restore
|
||||
session crashed without cleanup - the sidecar IS the truth; restore
|
||||
from it before taking this session's snapshot. That way a crash
|
||||
during test A doesn't propagate dirty state into test B's baseline.
|
||||
|
||||
Autouse + depends on `test_profile` so it applies on every run (even
|
||||
unit-only) — cheap, unified code path, no ordering surprises.
|
||||
unit-only) - cheap, unified code path, no ordering surprises.
|
||||
"""
|
||||
path = userprefs.jsonc_path()
|
||||
backup_path = path.with_name(path.name + ".mcp-session-bak")
|
||||
@@ -214,7 +214,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
yield
|
||||
return
|
||||
|
||||
# (4) Startup self-heal — prior session crashed without teardown.
|
||||
# (4) Startup self-heal - prior session crashed without teardown.
|
||||
if backup_path.is_file():
|
||||
try:
|
||||
sidecar_bytes = backup_path.read_bytes()
|
||||
@@ -241,7 +241,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
except Exception as exc:
|
||||
print(f"[userprefs] could not write sidecar: {exc!r}", file=sys.stderr)
|
||||
|
||||
# (3) atexit fallback — fires even if pytest aborts before fixture teardown.
|
||||
# (3) atexit fallback - fires even if pytest aborts before fixture teardown.
|
||||
restored = {"done": False}
|
||||
|
||||
def _atexit_restore() -> None:
|
||||
@@ -263,8 +263,8 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
# Apply the session test profile on top of the snapshot. The firmware
|
||||
# reads userPrefs.jsonc at build time via `bin/platformio-custom.py`,
|
||||
# so every `pio run` during the session picks up the test values.
|
||||
# Delegate to `userprefs.merge_active` — the public API that already
|
||||
# parses, merges, validates, and writes — rather than reaching into
|
||||
# Delegate to `userprefs.merge_active` - the public API that already
|
||||
# parses, merges, validates, and writes - rather than reaching into
|
||||
# the private parser/renderer machinery from here.
|
||||
try:
|
||||
userprefs.merge_active(test_profile)
|
||||
@@ -276,7 +276,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
# tests that don't (unit) still run. But the restore below is
|
||||
# unconditional, so we can't leave a half-written file behind.
|
||||
print(
|
||||
f"[userprefs] failed to apply test profile: {exc!r} — "
|
||||
f"[userprefs] failed to apply test profile: {exc!r} - "
|
||||
f"file left at original state",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -298,7 +298,7 @@ def _session_userprefs(test_profile: dict[str, Any]) -> Any:
|
||||
# exception from the yielded body); use a flag so the cleanup
|
||||
# control-flow stays linear and exceptions propagate normally.
|
||||
print(
|
||||
f"[userprefs] teardown restore failed: {exc!r} — "
|
||||
f"[userprefs] teardown restore failed: {exc!r} - "
|
||||
f"sidecar {backup_path} retained for manual recovery",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -388,7 +388,7 @@ def hub_devices(hub_profile: dict[str, dict[str, Any]]) -> dict[str, str]:
|
||||
"""
|
||||
# include_unknown=True so non-whitelisted VIDs (e.g. CP2102 at 0x10c4) that
|
||||
# are configured as hub roles still match. The hub_profile itself gates
|
||||
# which VIDs we consider — no risk of unrelated serial ports sneaking in.
|
||||
# which VIDs we consider - no risk of unrelated serial ports sneaking in.
|
||||
found = devices_module.list_devices(include_unknown=True)
|
||||
# Coalesce alt roles into their base name (esp32s3_alt → esp32s3)
|
||||
resolved: dict[str, str] = {}
|
||||
@@ -413,7 +413,7 @@ def hub_devices(hub_profile: dict[str, dict[str, Any]]) -> dict[str, str]:
|
||||
def _reset_transmit_history_state(role: str, port: str) -> str:
|
||||
"""Wipe `/prefs/transmit_history.dat` + in-memory throttle cache via
|
||||
delete_file_request + reboot. Returns the post-reboot port (nRF52
|
||||
re-enumerates). Best-effort — errors log to stderr + return original
|
||||
re-enumerates). Best-effort - errors log to stderr + return original
|
||||
port so a flaky start doesn't block the session.
|
||||
"""
|
||||
from ._port_discovery import resolve_port_by_role
|
||||
@@ -467,7 +467,7 @@ def _session_clear_transmit_history(hub_devices: dict[str, str]) -> None:
|
||||
if not hub_devices:
|
||||
yield
|
||||
return
|
||||
# Iterate over a snapshot — _reset_transmit_history_state can mutate
|
||||
# Iterate over a snapshot - _reset_transmit_history_state can mutate
|
||||
# hub_devices mid-loop via the update below, and dict-iteration isn't
|
||||
# safe during mutation.
|
||||
for role, port in list(hub_devices.items()):
|
||||
@@ -491,12 +491,12 @@ def baked_mesh(
|
||||
comparing the live config to the expected profile.
|
||||
|
||||
Raises with an actionable error if state is missing or mismatched:
|
||||
"device nrf52 at /dev/cu.X not baked with session profile —
|
||||
"device nrf52 at /dev/cu.X not baked with session profile -
|
||||
run test_00_bake.py first or pass --force-bake"
|
||||
|
||||
Returns a per-role dict with `{port, iface_fresh: callable, my_node_num}`.
|
||||
"""
|
||||
# Verify every role that's present — don't require a fixed set.
|
||||
# Verify every role that's present - don't require a fixed set.
|
||||
# Tests that NEED a specific role (mesh_pair, bidirectional) check
|
||||
# presence in their own fixtures and skip there with an actionable
|
||||
# message. That keeps single-device tests runnable on a one-device
|
||||
@@ -518,7 +518,7 @@ def baked_mesh(
|
||||
try:
|
||||
live = info.device_info(port=port, timeout_s=12.0)
|
||||
except Exception as exc:
|
||||
# Per-role failure — drop this role from the baked set and let
|
||||
# Per-role failure - drop this role from the baked set and let
|
||||
# any test parametrized against it skip with the actionable
|
||||
# message. Other roles still proceed.
|
||||
per_role_errors[role] = f"device_info failed: {exc!r}"
|
||||
@@ -576,11 +576,11 @@ def baked_mesh(
|
||||
# the SerialInterface. Operators who want log capture can opt in via the
|
||||
# `set_debug_log_api` MCP tool (or `admin.set_debug_log_api` directly) on
|
||||
# a case-by-case basis. The autouse `_debug_log_buffer` fixture is still
|
||||
# armed below — if a test explicitly enables the flag, its output will
|
||||
# armed below - if a test explicitly enables the flag, its output will
|
||||
# be captured and attached to failures. Firmware-side fix would need
|
||||
# a separate tx buffer or a mutex — out of scope for the MCP harness.
|
||||
# a separate tx buffer or a mutex - out of scope for the MCP harness.
|
||||
|
||||
# If EVERY detected role errored, skip the session — nothing testable.
|
||||
# If EVERY detected role errored, skip the session - nothing testable.
|
||||
# Otherwise yield the partial set. Tests parametrized against a role
|
||||
# not in `out` will skip via the `baked_single`/`mesh_pair` presence
|
||||
# check with "role not present on the hub".
|
||||
@@ -608,7 +608,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
||||
the test still COLLECTS cleanly (it'll just skip via the
|
||||
`hub_devices` missing-role check inside the fixture).
|
||||
|
||||
Honors `--hub-profile=<yaml>` for non-default hardware — when set, only
|
||||
Honors `--hub-profile=<yaml>` for non-default hardware - when set, only
|
||||
roles defined in the YAML are parametrized. (So e.g. a yaml with only
|
||||
`esp32s3` skips every `[nrf52]` variant at collection time.)
|
||||
"""
|
||||
@@ -675,7 +675,7 @@ def baked_single(
|
||||
"""Function-scoped: a single verified baked device.
|
||||
|
||||
Auto-parametrized by `pytest_generate_tests` over every detected hub
|
||||
role — so any test taking this fixture runs once per connected device
|
||||
role - so any test taking this fixture runs once per connected device
|
||||
(e.g. `test_owner_survives_reboot[nrf52]` +
|
||||
`test_owner_survives_reboot[esp32s3]`). Tests never hardcode a role
|
||||
and never skip a device that happens to be connected.
|
||||
@@ -727,7 +727,7 @@ def power_cycle(
|
||||
) -> Callable[..., str]:
|
||||
"""Return a callable `(role, delay_s=2) -> new_port` that hard-resets the
|
||||
hub port hosting `role`. Skips the test cleanly when uhubctl isn't
|
||||
installed — never want "no uhubctl" to look like a test failure.
|
||||
installed - never want "no uhubctl" to look like a test failure.
|
||||
|
||||
The callable mutates `hub_devices[role]` in place so subsequent fixture
|
||||
lookups pick up the post-cycle port (mirrors the pattern in
|
||||
@@ -905,19 +905,19 @@ def _firmware_log_stream() -> Any:
|
||||
firmware logs *in memory* for pytest-html failure attachments, but a
|
||||
live viewer (``meshtastic-mcp-test-tui``) can't read in-process
|
||||
pubsub events from a different process. This fixture adds a
|
||||
session-long, durable mirror — one JSON object per line, with
|
||||
``port``, ``ts``, and ``line`` fields — that the TUI tails from a
|
||||
session-long, durable mirror - one JSON object per line, with
|
||||
``port``, ``ts``, and ``line`` fields - that the TUI tails from a
|
||||
worker thread.
|
||||
|
||||
Schema (kept trivially small so the file grows slowly):
|
||||
|
||||
{"ts": 1729100000.123, "port": "/dev/cu.usbmodem1101", "line": "INFO | ... [SerialConsole] Boot..."}
|
||||
|
||||
The file is truncated at session start (no append across runs — the
|
||||
The file is truncated at session start (no append across runs - the
|
||||
TUI also unlinks it on launch, so double-truncate is deliberate).
|
||||
Gitignored via ``mcp-server/.gitignore``.
|
||||
|
||||
Runs alongside ``_debug_log_buffer`` — both subscribe to the same
|
||||
Runs alongside ``_debug_log_buffer`` - both subscribe to the same
|
||||
pubsub topic; pubsub fans out to every subscriber so there's no
|
||||
interference.
|
||||
"""
|
||||
@@ -943,7 +943,7 @@ def _firmware_log_stream() -> Any:
|
||||
def handler(line: str, interface: Any) -> None:
|
||||
# `interface` is the meshtastic SerialInterface; `.devPath`
|
||||
# carries the /dev/cu.* we care about. Defensive about missing
|
||||
# attribute — the pubsub handler must never raise.
|
||||
# attribute - the pubsub handler must never raise.
|
||||
try:
|
||||
port = getattr(interface, "devPath", None) or getattr(
|
||||
interface, "stream", None
|
||||
@@ -959,7 +959,7 @@ def _firmware_log_stream() -> Any:
|
||||
fh.write(json.dumps(record) + "\n")
|
||||
fh.flush()
|
||||
except Exception:
|
||||
# Swallow — firmware log mirroring is best-effort.
|
||||
# Swallow - firmware log mirroring is best-effort.
|
||||
pass
|
||||
|
||||
pub.subscribe(handler, "meshtastic.log.line")
|
||||
@@ -980,7 +980,7 @@ def _firmware_log_stream() -> Any:
|
||||
def _debug_log_buffer(request: pytest.FixtureRequest) -> Any:
|
||||
"""Per-test capture of `meshtastic.log.line` pubsub events.
|
||||
|
||||
Automatic — every test gets this for free. The pubsub topic fires when
|
||||
Automatic - every test gets this for free. The pubsub topic fires when
|
||||
a connected device has `security.debug_log_api_enabled=True` AND the
|
||||
client (us) is talking protobufs over its SerialInterface. `baked_mesh`
|
||||
flips the flag on at session start, so every subsequent test that opens
|
||||
@@ -989,7 +989,7 @@ def _debug_log_buffer(request: pytest.FixtureRequest) -> Any:
|
||||
|
||||
The captured lines are attached to the test's pytest-html failure report
|
||||
by `pytest_runtest_makereport`, so mesh/telemetry failures ship with the
|
||||
firmware-side log context inline — no separate pio monitor, no
|
||||
firmware-side log context inline - no separate pio monitor, no
|
||||
port-lock conflict.
|
||||
"""
|
||||
import threading as _threading
|
||||
@@ -1026,7 +1026,7 @@ def _run_with_timeout(fn: Callable[[], Any], timeout: float) -> Any:
|
||||
`meshtastic.SerialInterface` construction can hang indefinitely on a
|
||||
misconfigured or unresponsive port. pytest-timeout fires from the main
|
||||
thread via SIGALRM, which doesn't protect code running inside
|
||||
`pytest_runtest_makereport` — that hook runs outside the test's timer. So
|
||||
`pytest_runtest_makereport` - that hook runs outside the test's timer. So
|
||||
we wrap each device query in a bounded worker.
|
||||
"""
|
||||
import concurrent.futures
|
||||
@@ -1064,7 +1064,7 @@ def _attach_ui_captures(item: pytest.Item, report: Any) -> None:
|
||||
label = f"{cap.get('step', '?')}: {cap.get('label', '')}"
|
||||
frame = cap.get("frame") or {}
|
||||
frame_str = (
|
||||
f" — frame {frame.get('idx')} {frame.get('name')!r}" if frame else ""
|
||||
f" - frame {frame.get('idx')} {frame.get('name')!r}" if frame else ""
|
||||
)
|
||||
if png_path:
|
||||
try:
|
||||
@@ -1085,7 +1085,7 @@ def _attach_ui_captures(item: pytest.Item, report: Any) -> None:
|
||||
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Any:
|
||||
"""On test failure, attach serial capture + device state as report artifacts.
|
||||
|
||||
Hard-bounded by `_run_with_timeout` — if the device is unreachable (stuck
|
||||
Hard-bounded by `_run_with_timeout` - if the device is unreachable (stuck
|
||||
port, unbaked firmware, dead board), the dump is skipped rather than
|
||||
hanging the session.
|
||||
|
||||
@@ -1096,7 +1096,7 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) ->
|
||||
outcome = yield
|
||||
report = outcome.get_result()
|
||||
|
||||
# Attach UI captures on any outcome (pass + fail) — these are the whole
|
||||
# Attach UI captures on any outcome (pass + fail) - these are the whole
|
||||
# point of the UI tier. Do this before the failure-only branch below so
|
||||
# passing tests still get their image strip.
|
||||
if report.when == "call":
|
||||
@@ -1108,7 +1108,7 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) ->
|
||||
extras: list[str] = []
|
||||
|
||||
# Attach firmware log stream captured via the StreamAPI (populated only
|
||||
# when the device has security.debug_log_api_enabled=True — baked_mesh
|
||||
# when the device has security.debug_log_api_enabled=True - baked_mesh
|
||||
# flips this on at session start). Cheap and high-signal: last 200 lines
|
||||
# of firmware log interleaved with whatever the test was doing.
|
||||
log_buffer = getattr(item, "_debug_log_buffer", None)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Fleet: different session seeds produce non-overlapping PSKs.
|
||||
|
||||
No hardware needed — this is a pure property check on the test profile
|
||||
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.
|
||||
"""
|
||||
@@ -12,7 +12,7 @@ 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
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""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
|
||||
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
|
||||
@@ -9,7 +9,7 @@ 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
|
||||
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:
|
||||
|
||||
@@ -31,7 +31,7 @@ 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
|
||||
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)`
|
||||
@@ -49,7 +49,7 @@ def nudge_nodeinfo(iface: Any) -> None:
|
||||
|
||||
|
||||
def nudge_nodeinfo_port(port: str) -> None:
|
||||
"""Open ``port`` briefly, nudge, close — for when no iface is open yet.
|
||||
"""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
|
||||
@@ -99,7 +99,7 @@ class ReceiveCollector:
|
||||
)
|
||||
from pubsub import pub # type: ignore[import-untyped]
|
||||
|
||||
# pubsub uses weak refs by default — we stash a strong ref so the
|
||||
# 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:
|
||||
@@ -169,7 +169,7 @@ class ReceiveCollector:
|
||||
"""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
|
||||
- `admin.send_text(port=...)` would try to open a second SerialInterface
|
||||
and fail the port lock.
|
||||
"""
|
||||
if self._iface is None:
|
||||
@@ -187,7 +187,7 @@ class ReceiveCollector:
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.
|
||||
Not parametrized - it inherently involves the full hub.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -44,7 +44,7 @@ def test_bidirectional_mesh_communication(
|
||||
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
|
||||
# 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:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""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.
|
||||
packet - `pio device monitor` output doesn't include message bodies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_direct_with_ack_roundtrip(
|
||||
|
||||
unique = f"mcp-ack-{tx_role}-to-{rx_role}-{int(time.time())}"
|
||||
|
||||
# TX iface stays open across the RX wait — sendText+wantAck relies on
|
||||
# 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
|
||||
@@ -72,7 +72,7 @@ def test_direct_with_ack_roundtrip(
|
||||
)
|
||||
|
||||
# Retry covers LoRa collisions. Re-nudge both sides between
|
||||
# attempts — if RX's cached TX pubkey is stale, just re-sending
|
||||
# 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):
|
||||
|
||||
@@ -16,7 +16,7 @@ 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
|
||||
"""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).
|
||||
"""
|
||||
|
||||
@@ -11,15 +11,15 @@ mesh exercises:
|
||||
* when the established relay drops and returns, delivery recovers rather than
|
||||
black-holing (the M3 stale-route decay / re-learn path).
|
||||
|
||||
TOPOLOGY REQUIREMENT — why this usually SKIPS:
|
||||
TOPOLOGY REQUIREMENT - why this usually SKIPS:
|
||||
A NextHop relay only happens when the two endpoints are NOT direct neighbors.
|
||||
Three co-located radios all hear each other, so A→C is a single direct hop and
|
||||
next_hop never engages. To run this test the bench must be a *line* — A — B — C
|
||||
— with the endpoints out of each other's direct RF range (physical distance or
|
||||
next_hop never engages. To run this test the bench must be a *line* - A - B - C
|
||||
- with the endpoints out of each other's direct RF range (physical distance or
|
||||
attenuators). The `multihop_topology` fixture detects this automatically: it
|
||||
warms the mesh, looks for a pair that is ≥1 hop apart, confirms the relay via
|
||||
traceroute, and `pytest.skip`s cleanly when the bench is all-direct. So this
|
||||
file is safe to commit and run anywhere — it only *asserts* when the topology
|
||||
file is safe to commit and run anywhere - it only *asserts* when the topology
|
||||
genuinely requires a relay.
|
||||
|
||||
REQUIREMENTS:
|
||||
@@ -56,12 +56,12 @@ def _hops_away(rec: dict[str, Any]) -> int | None:
|
||||
def _warm_mesh(ports: list[str], rounds: int = 2, settle: float = 6.0) -> None:
|
||||
"""Flood a fresh NodeInfo from every node so the whole mesh (including
|
||||
multi-hop pairs, reached via relayed broadcasts) populates pubkeys and hop
|
||||
distances. Best-effort — a single node failing to nudge shouldn't abort."""
|
||||
distances. Best-effort - a single node failing to nudge shouldn't abort."""
|
||||
for _ in range(rounds):
|
||||
for port in ports:
|
||||
try:
|
||||
nudge_nodeinfo_port(port)
|
||||
except Exception: # noqa: BLE001 — warmup is best-effort
|
||||
except Exception: # noqa: BLE001 - warmup is best-effort
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
time.sleep(settle)
|
||||
@@ -135,7 +135,7 @@ def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
|
||||
_warm_mesh([port for port, _ in by_role.values()])
|
||||
|
||||
# Find an ordered pair that is ≥1 hop apart, using each node's own nodeDB
|
||||
# (cheap — no traceroute yet). On an all-direct bench nothing qualifies.
|
||||
# (cheap - no traceroute yet). On an all-direct bench nothing qualifies.
|
||||
multihop_pair: tuple[str, str] | None = None
|
||||
for a_role in roles:
|
||||
a_port, _ = by_role[a_role]
|
||||
@@ -157,8 +157,8 @@ def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
if not multihop_pair:
|
||||
pytest.skip(
|
||||
"no multi-hop pair found — every device appears to be a direct "
|
||||
"neighbor. Arrange the bench as a line (A — B — C) with the "
|
||||
"no multi-hop pair found - every device appears to be a direct "
|
||||
"neighbor. Arrange the bench as a line (A - B - C) with the "
|
||||
"endpoints out of direct RF range (distance or attenuators) so a "
|
||||
"relay is actually required, then re-run."
|
||||
)
|
||||
@@ -231,21 +231,21 @@ def test_multihop_dm_delivers(multihop_topology: dict[str, Any]) -> None:
|
||||
|
||||
assert got is not None, (
|
||||
f"multi-hop directed DM {tx_role}→{rx_role} via relay "
|
||||
f"{relay_role!r} never landed — NextHop multi-hop delivery is broken"
|
||||
f"{relay_role!r} never landed - NextHop multi-hop delivery is broken"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(600)
|
||||
def test_multihop_relay_recovery(
|
||||
multihop_topology: dict[str, Any],
|
||||
power_cycle, # noqa: ARG001 — forces the uhubctl-availability skip
|
||||
power_cycle, # noqa: ARG001 - forces the uhubctl-availability skip
|
||||
) -> None:
|
||||
"""Delivery recovers after the established relay drops and returns.
|
||||
|
||||
Establishes a baseline DM (route via relay learned), powers the relay OFF
|
||||
(confirming TX survives sending across a downed relay), then powers it back
|
||||
ON and asserts directed delivery resumes — the M3 stale-route decay /
|
||||
re-learn path. With a strict A — B — C line there is no path while B is down,
|
||||
ON and asserts directed delivery resumes - the M3 stale-route decay /
|
||||
re-learn path. With a strict A - B - C line there is no path while B is down,
|
||||
so we only assert TX doesn't crash during the outage; the delivery assertion
|
||||
is after B returns.
|
||||
"""
|
||||
@@ -266,7 +266,7 @@ def test_multihop_relay_recovery(
|
||||
post = f"mh-recover-post-{int(time.time())}"
|
||||
|
||||
# Baseline: confirm delivery works (so the route via the relay is learned)
|
||||
# before we perturb anything — otherwise a later failure is ambiguous.
|
||||
# before we perturb anything - otherwise a later failure is ambiguous.
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
@@ -279,7 +279,7 @@ def test_multihop_relay_recovery(
|
||||
lambda p: p.get("decoded", {}).get("text") == base, timeout=45
|
||||
)
|
||||
is not None
|
||||
), "baseline multi-hop delivery failed — skipping recovery to avoid a false result"
|
||||
), "baseline multi-hop delivery failed - skipping recovery to avoid a false result"
|
||||
|
||||
# Power the relay OFF.
|
||||
try:
|
||||
@@ -304,7 +304,7 @@ def test_multihop_relay_recovery(
|
||||
)
|
||||
assert pkt is not None
|
||||
time.sleep(8.0) # let retransmissions + route decay run
|
||||
except Exception as exc: # noqa: BLE001 — restore bench state before failing
|
||||
except Exception as exc: # noqa: BLE001 - restore bench state before failing
|
||||
_power.power_on(relay_role)
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
raise AssertionError(
|
||||
@@ -316,7 +316,7 @@ def test_multihop_relay_recovery(
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
except Exception: # noqa: BLE001 — relay port isn't one we connect to directly
|
||||
except Exception: # noqa: BLE001 - relay port isn't one we connect to directly
|
||||
pass
|
||||
time.sleep(8.0)
|
||||
_warm_mesh([tx_port, rx_port], rounds=1) # re-flood so the relay re-learns
|
||||
@@ -343,5 +343,5 @@ def test_multihop_relay_recovery(
|
||||
|
||||
assert got is not None, (
|
||||
f"after relay {relay_role!r} returned, multi-hop DM {tx_role}→{rx_role} "
|
||||
"never resumed — stale-route recovery (M3) may be broken"
|
||||
"never resumed - stale-route recovery (M3) may be broken"
|
||||
)
|
||||
|
||||
@@ -5,16 +5,16 @@ 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.
|
||||
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
|
||||
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,
|
||||
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
|
||||
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
|
||||
@@ -38,7 +38,7 @@ 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
|
||||
power_cycle, # noqa: ARG001 - forces uhubctl-availability skip
|
||||
hub_devices: dict[str, str],
|
||||
) -> None:
|
||||
tx_port = mesh_pair["tx"]["port"]
|
||||
@@ -80,7 +80,7 @@ def test_peer_offline_then_recovers(
|
||||
timeout=30,
|
||||
)
|
||||
assert got is not None, (
|
||||
f"baseline directed send ({tx_role}→{rx_role}) didn't land — "
|
||||
f"baseline directed send ({tx_role}→{rx_role}) didn't land - "
|
||||
"skipping offline test to avoid false positive"
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@ def test_peer_offline_then_recovers(
|
||||
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
|
||||
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)
|
||||
@@ -151,5 +151,5 @@ def test_peer_offline_then_recovers(
|
||||
|
||||
assert got is not None, (
|
||||
f"post-recovery directed send {unique_post!r} ({tx_role}→{rx_role}) "
|
||||
"never landed — recovery path may be broken"
|
||||
"never landed - recovery path may be broken"
|
||||
)
|
||||
|
||||
@@ -33,13 +33,13 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
|
||||
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.
|
||||
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
|
||||
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.
|
||||
@@ -54,7 +54,7 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
|
||||
with ReceiveCollector(
|
||||
tx_port, topic="meshtastic.receive.traceroute"
|
||||
) as tx_listener:
|
||||
# Bilateral PKI warmup — traceroute requests are directed and
|
||||
# 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
|
||||
@@ -105,7 +105,7 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
# sendTraceRoute already waited for the response internally, but
|
||||
# pubsub dispatch runs on the meshtastic-python reader thread —
|
||||
# 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,
|
||||
@@ -138,7 +138,7 @@ def test_traceroute_one_hop(mesh_pair: dict[str, Any]) -> None:
|
||||
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
|
||||
# `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, (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Monitor: boot log is clean — no panic markers in the first 60 seconds.
|
||||
"""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
|
||||
@@ -36,7 +36,7 @@ def test_boot_log_no_panic(
|
||||
role_env,
|
||||
wait_until,
|
||||
) -> None:
|
||||
"""Runs once per connected role — each device must boot cleanly,
|
||||
"""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"]
|
||||
@@ -52,7 +52,7 @@ def test_boot_log_no_panic(
|
||||
time.sleep(60.0)
|
||||
|
||||
lines = cap.snapshot(max_lines=4000)
|
||||
assert lines, "serial capture returned no log lines — monitor may have failed"
|
||||
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]
|
||||
|
||||
@@ -14,7 +14,7 @@ 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
|
||||
# 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) + " }"
|
||||
@@ -71,7 +71,7 @@ def test_admin_key_baked(
|
||||
), 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"
|
||||
), "security.admin_key field absent - baking key 0 didn't stick"
|
||||
finally:
|
||||
# Restore session profile (no admin key)
|
||||
restore = flash.erase_and_flash(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""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
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
`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
|
||||
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.
|
||||
|
||||
@@ -32,7 +32,7 @@ def test_unset_region_blocks_tx(
|
||||
) -> 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
|
||||
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
|
||||
@@ -43,7 +43,7 @@ def test_unset_region_blocks_tx(
|
||||
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.
|
||||
# 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")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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,
|
||||
should be the factory floor for the device - reset goes back to THAT state,
|
||||
not to stock Meshtastic.
|
||||
"""
|
||||
|
||||
@@ -51,12 +51,12 @@ def test_baked_prefs_survive_factory_reset(
|
||||
# Trigger non-full factory reset
|
||||
admin.factory_reset(port=port, confirm=True, full=False)
|
||||
|
||||
# Device re-enumerates — rediscover its port before probing. nRF52's
|
||||
# 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
|
||||
# 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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Recovery tier — exercises `uhubctl` power control end-to-end.
|
||||
"""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
|
||||
|
||||
@@ -25,7 +25,7 @@ def _recovery_tier_guard() -> None:
|
||||
)
|
||||
|
||||
# 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
|
||||
# permission error here - we'd rather find out once at tier-start than
|
||||
# 6 tests later.)
|
||||
from meshtastic_mcp import uhubctl
|
||||
|
||||
@@ -39,6 +39,6 @@ def _recovery_tier_guard() -> None:
|
||||
|
||||
if not any(h["ppps"] for h in hubs):
|
||||
pytest.skip(
|
||||
"no PPPS-capable hubs detected — recovery tier has nothing to exercise.",
|
||||
"no PPPS-capable hubs detected - recovery tier has nothing to exercise.",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 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."
|
||||
@@ -31,13 +31,13 @@ def test_list_hubs_structure(hub_devices: dict[str, str]) -> None:
|
||||
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.
|
||||
# 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 "
|
||||
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)."
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
@@ -30,7 +30,7 @@ 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
|
||||
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
|
||||
@@ -56,14 +56,14 @@ def test_device_telemetry_broadcast(mesh_pair: dict[str, Any]) -> None:
|
||||
# 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
|
||||
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.
|
||||
# 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 {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Telemetry: on-demand device-metrics request gets a prompt reply.
|
||||
|
||||
Complementary to ``test_device_telemetry_broadcast`` — that one witnesses the
|
||||
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
|
||||
@@ -13,14 +13,14 @@ 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
|
||||
``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``
|
||||
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
|
||||
@@ -74,7 +74,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
|
||||
# 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
|
||||
# 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
|
||||
@@ -92,7 +92,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
|
||||
if last_rec.get("user", {}).get("publicKey"):
|
||||
break
|
||||
if time.monotonic() - last_nudge > 15.0:
|
||||
# Re-nudge both sides — LoRa collisions can drop either
|
||||
# Re-nudge both sides - LoRa collisions can drop either
|
||||
# direction's NodeInfo broadcast independently.
|
||||
nudge_nodeinfo_port(rx_port)
|
||||
tx_listener.broadcast_nodeinfo_ping()
|
||||
@@ -113,14 +113,14 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
@@ -160,7 +160,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
|
||||
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
|
||||
# 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, (
|
||||
@@ -177,7 +177,7 @@ def test_telemetry_request_reply(mesh_pair: dict[str, Any]) -> None:
|
||||
# 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
|
||||
# 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]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Session-bake module — runs first in the tier order to flash both hub roles
|
||||
"""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
|
||||
-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.
|
||||
@@ -52,9 +52,9 @@ def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> No
|
||||
|
||||
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()`,
|
||||
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` —
|
||||
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`.
|
||||
@@ -84,7 +84,7 @@ def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> No
|
||||
pass
|
||||
return
|
||||
raise AssertionError(
|
||||
f"{role_prefix}port {port} still busy after {timeout_s:.0f}s — "
|
||||
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 "
|
||||
@@ -102,7 +102,7 @@ def _prepare_nrf52_for_upload(port: str) -> str:
|
||||
|
||||
`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
|
||||
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)
|
||||
@@ -162,14 +162,14 @@ def _bake_role(
|
||||
# If we can't query, fall through and bake anyway.
|
||||
pass
|
||||
|
||||
# All architectures go through `pio run -t upload` — pio knows the right
|
||||
# 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
|
||||
# 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
|
||||
@@ -184,7 +184,7 @@ def _bake_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.
|
||||
# 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"
|
||||
@@ -196,7 +196,7 @@ def _bake_role(
|
||||
|
||||
# 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 —
|
||||
# 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)
|
||||
@@ -207,7 +207,7 @@ def _bake_role(
|
||||
# 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.
|
||||
# stat / mtime than it snapshotted - keep the mutation in one place.
|
||||
result = flash.flash(
|
||||
env=env,
|
||||
port=upload_port,
|
||||
@@ -220,7 +220,7 @@ def _bake_role(
|
||||
)
|
||||
|
||||
# Post-flash: for nRF52, the DFU process only overwrites the app
|
||||
# partition — the NVS region holding the existing NodeDB/config is
|
||||
# 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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".
|
||||
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
|
||||
@@ -40,7 +40,7 @@ def _wrap(module: Any, attr: str, tool_name: str) -> None:
|
||||
|
||||
|
||||
# Mapping: MCP tool name → (module, function name). Mirrors the wiring in
|
||||
# `meshtastic_mcp.server`. Keep synchronized manually — adding a tool without
|
||||
# `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
|
||||
@@ -54,7 +54,7 @@ _TOOL_MAP: dict[str, tuple[str, str]] = {
|
||||
"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 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"),
|
||||
@@ -74,7 +74,7 @@ _TOOL_MAP: dict[str, tuple[str, str]] = {
|
||||
"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` 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"),
|
||||
@@ -106,8 +106,8 @@ def install() -> None:
|
||||
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
|
||||
# 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())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""UI tier — input-broker-driven screen navigation tests.
|
||||
"""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
|
||||
|
||||
@@ -122,7 +122,7 @@ def wait_for_reason(
|
||||
) -> FrameEvent:
|
||||
"""Wait for a frame event with `reason=<reason>` after call-start.
|
||||
|
||||
Matches only on `reason` — useful when the caller knows *why* a
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -24,11 +24,11 @@ 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.
|
||||
# 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.
|
||||
# sanitized test nodeid - identical pattern to other pytest artifacts.
|
||||
CAPTURES_ROOT = Path(__file__).resolve().parent.parent / "ui_captures"
|
||||
|
||||
|
||||
@@ -104,11 +104,11 @@ 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.
|
||||
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
|
||||
except Exception: # noqa: BLE001 - belt: never block the suite on OCR init
|
||||
pass
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ def _ui_screen_kept_on(
|
||||
"""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
|
||||
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
|
||||
@@ -156,7 +156,7 @@ def _ui_screen_kept_on(
|
||||
time.sleep(1.5) # Let the screen finish its wake transition.
|
||||
except (
|
||||
Exception
|
||||
): # noqa: BLE001 — best-effort; ui_home_state surfaces the real error
|
||||
): # noqa: BLE001 - best-effort; ui_home_state surfaces the real error
|
||||
pass
|
||||
|
||||
try:
|
||||
@@ -193,7 +193,7 @@ class FrameCapture:
|
||||
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",
|
||||
f"# {nodeid} - {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@@ -242,7 +242,7 @@ class FrameCapture:
|
||||
)
|
||||
ocr_summary = (ocr_str or "").replace("\n", " / ")[:80]
|
||||
fh.write(
|
||||
f"{self._step}. **{label}** — {frame_str} — OCR: `{ocr_summary}`\n"
|
||||
f"{self._step}. **{label}** - {frame_str} - OCR: `{ocr_summary}`\n"
|
||||
)
|
||||
return entry
|
||||
|
||||
@@ -273,7 +273,7 @@ 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
|
||||
# Treat a failed event as soft - the subsequent frame-log assertion
|
||||
# surfaces the real problem with better context.
|
||||
pass
|
||||
|
||||
@@ -298,7 +298,7 @@ def ui_home_state(
|
||||
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
|
||||
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).
|
||||
"""
|
||||
@@ -313,7 +313,7 @@ def ui_home_state(
|
||||
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
|
||||
# `_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.
|
||||
@@ -322,14 +322,14 @@ def ui_home_state(
|
||||
_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
|
||||
# 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.
|
||||
# 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)
|
||||
@@ -345,12 +345,12 @@ def ui_home_state(
|
||||
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`."
|
||||
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 — "
|
||||
"Firmware not baked with USERPREFS_UI_TEST_LOG - "
|
||||
"run with `--force-bake` to reflash, or verify the "
|
||||
"macro is active in the bake."
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_fn_jump_direct_frame(
|
||||
) -> 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 is not None, "no frame log yet - USERPREFS_UI_TEST_LOG not wired?"
|
||||
assert start.name in (
|
||||
"home",
|
||||
"deviceFocused",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
`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.
|
||||
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
|
||||
@@ -33,7 +33,7 @@ def test_fn_f5_out_of_bounds(
|
||||
|
||||
if start.count > 5:
|
||||
pytest.skip(
|
||||
f"device has {start.count} frames; FN_F5 is in-bounds — not testing OOB here"
|
||||
f"device has {start.count} frames; FN_F5 is in-bounds - not testing OOB here"
|
||||
)
|
||||
|
||||
frame_capture("initial-home")
|
||||
@@ -43,11 +43,11 @@ def test_fn_f5_out_of_bounds(
|
||||
try:
|
||||
wait_for_reason(lines, "fn_f5", timeout_s=3.0)
|
||||
except TimeoutError:
|
||||
# Firmware may have ignored the event entirely — acceptable.
|
||||
# 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
|
||||
# happened). Device must remain responsive - subsequent events should
|
||||
# still land.
|
||||
frame_capture("after-fn_f5-oob")
|
||||
|
||||
@@ -57,5 +57,5 @@ def test_fn_f5_out_of_bounds(
|
||||
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"
|
||||
), "UI wedged after OOB FN_F5 - RIGHT no longer produces frame log"
|
||||
frame_capture("after-recovery-right")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""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
|
||||
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.
|
||||
@@ -39,25 +39,25 @@ def test_select_opens_home_menu(
|
||||
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
|
||||
# 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.
|
||||
# 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.
|
||||
# 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
|
||||
# (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.
|
||||
|
||||
@@ -3,7 +3,7 @@ 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
|
||||
handler in that case launches CannedMessage - which DOES rebuild
|
||||
frames. We just verify the path doesn't crash + produce captures for
|
||||
visual inspection.
|
||||
"""
|
||||
@@ -28,7 +28,7 @@ def test_up_down_on_textmessage_survives(
|
||||
lines: list[str] = request.node._debug_log_buffer
|
||||
frame_capture("initial")
|
||||
|
||||
# Walk RIGHT until we land on textMessage — up to 15 hops.
|
||||
# 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)
|
||||
@@ -37,7 +37,7 @@ def test_up_down_on_textmessage_survives(
|
||||
break
|
||||
else:
|
||||
pytest.skip(
|
||||
"couldn't reach textMessage frame within 15 RIGHTs — not present on this board"
|
||||
"couldn't reach textMessage frame within 15 RIGHTs - not present on this board"
|
||||
)
|
||||
|
||||
wait_for_frame(lines, "textMessage", timeout_s=5.0)
|
||||
@@ -57,4 +57,4 @@ def test_up_down_on_textmessage_survives(
|
||||
# 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"
|
||||
assert final is not None, "no frame log after UP/DOWN - event path broke"
|
||||
|
||||
@@ -25,9 +25,9 @@ def test_input_right_cycles_frames(
|
||||
) -> 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 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.
|
||||
# by board (home on heltec-v3, deviceFocused on others) - accept either.
|
||||
assert start.name in (
|
||||
"home",
|
||||
"deviceFocused",
|
||||
|
||||
@@ -38,7 +38,7 @@ def test_up_down_on_nodelist_no_frame_change(
|
||||
frame_capture("on-nodelist")
|
||||
|
||||
# UP/DOWN on nodelist scroll internally + `return 0` before
|
||||
# notifyObservers — no frame-change log. Verify.
|
||||
# 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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""`boards.py` filter and enumeration correctness.
|
||||
|
||||
Runs against the real `pio project config` output of this firmware repo —
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Unit tests for the `build_flags` injection on `flash.build()`.
|
||||
|
||||
We don't actually run pio here — too slow, requires hardware-aware envs.
|
||||
We don't actually run pio here - too slow, requires hardware-aware envs.
|
||||
We test the translation layer (`_build_flags_env`) and that the env vars
|
||||
are threaded through pio.run correctly via mock.
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""TCP transport plumbing in connection.py + devices.py.
|
||||
|
||||
Pure-Python tests — no real device or daemon required. Mocks `TCPInterface`
|
||||
Pure-Python tests - no real device or daemon required. Mocks `TCPInterface`
|
||||
when exercising `connect()`.
|
||||
"""
|
||||
|
||||
@@ -171,7 +171,7 @@ class TestResolvePort:
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost")
|
||||
# Don't patch list_devices — let the real env-var path run, but stub
|
||||
# Don't patch list_devices - let the real env-var path run, but stub
|
||||
# the USB enumeration to keep the test hermetic.
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
assert connection.resolve_port(None) == "tcp://localhost:4403"
|
||||
@@ -208,7 +208,7 @@ class TestDevicesTcpEntry:
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# `list_devices` is the diagnostic tool reached for when an env var
|
||||
# isn't working — it must not throw on misconfiguration.
|
||||
# isn't working - it must not throw on misconfiguration.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
ds = devices.list_devices(include_unknown=True)
|
||||
@@ -222,7 +222,7 @@ class TestDevicesTcpEntry:
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# `likely_meshtastic=False` keeps the bad TCP entry out of the
|
||||
# auto-select path — `resolve_port(None)` should still report
|
||||
# auto-select path - `resolve_port(None)` should still report
|
||||
# "no Meshtastic devices" rather than picking a broken endpoint.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport")
|
||||
with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]):
|
||||
@@ -274,7 +274,7 @@ class TestDevicesTcpEntry:
|
||||
ds = devices.list_devices(include_unknown=True)
|
||||
|
||||
assert ds, "expected at least the USB + TCP entries"
|
||||
# Real USB candidate must be at position 0 — it's likely_meshtastic.
|
||||
# Real USB candidate must be at position 0 - it's likely_meshtastic.
|
||||
assert ds[0]["port"] == "/dev/cu.usbmodem4201"
|
||||
assert ds[0]["likely_meshtastic"] is True
|
||||
# The malformed TCP entry exists but lands among the unlikely entries.
|
||||
@@ -287,7 +287,7 @@ class TestDevicesTcpEntry:
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Conversely, a *valid* TCP env var should sort ahead of USB
|
||||
# candidates of equal likely_meshtastic rank — explicit env-var
|
||||
# candidates of equal likely_meshtastic rank - explicit env-var
|
||||
# configuration is a precedence signal.
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost:4403")
|
||||
|
||||
@@ -375,7 +375,7 @@ class TestConnectRoutesTcp:
|
||||
with connection.connect(port="tcp://locktest:4403"):
|
||||
pass
|
||||
|
||||
# Lock should be released — a second connect attempt must not fail
|
||||
# Lock should be released - a second connect attempt must not fail
|
||||
# with "busy".
|
||||
with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp:
|
||||
mock_tcp.return_value.close.return_value = None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the fake-NodeDB fixture pipeline (bin/gen-fake-nodedb-seed.py
|
||||
+ bin/seed-json-to-proto.py + mcp-server fixtures.push_fake_nodedb).
|
||||
|
||||
Lives under tests/unit/ because none of these touch real hardware — they
|
||||
Lives under tests/unit/ because none of these touch real hardware - they
|
||||
shell out to the bin/ scripts and decode the resulting protobufs in-process.
|
||||
"""
|
||||
|
||||
@@ -42,7 +42,7 @@ def _require_v25_bindings() -> None:
|
||||
)
|
||||
if "positions" not in NodeDatabase.DESCRIPTOR.fields_by_name:
|
||||
pytest.skip(
|
||||
"Loaded NodeDatabase predates v25 — run `./bin/regen-py-protos.sh`."
|
||||
"Loaded NodeDatabase predates v25 - run `./bin/regen-py-protos.sh`."
|
||||
)
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ def test_committed_seed_compiles_and_decodes(size: int, tmp_path: pathlib.Path)
|
||||
proto = tmp_path / "out.proto"
|
||||
jsonl = FIXTURES_DIR / f"seed_v25_{size:04d}.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip(f"{jsonl} not present — run ./bin/regen-fake-nodedbs.sh")
|
||||
pytest.skip(f"{jsonl} not present - run ./bin/regen-fake-nodedbs.sh")
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(proto)])
|
||||
|
||||
db = NodeDatabase()
|
||||
@@ -199,7 +199,7 @@ def test_compile_freshens_timestamps(tmp_path: pathlib.Path) -> None:
|
||||
_require_v25_bindings()
|
||||
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip("250-node seed not present — run ./bin/regen-fake-nodedbs.sh")
|
||||
pytest.skip("250-node seed not present - run ./bin/regen-fake-nodedbs.sh")
|
||||
a = tmp_path / "a.proto"
|
||||
b = tmp_path / "b.proto"
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(a)])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""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
|
||||
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.
|
||||
|
||||
@@ -75,7 +75,7 @@ class TestParseLogLine:
|
||||
assert out["msg"] == "raw message body"
|
||||
|
||||
def test_bare_message(self) -> None:
|
||||
# LogRecord.message path — no level prefix at all.
|
||||
# LogRecord.message path - no level prefix at all.
|
||||
out = parse_log_line("just a bare message")
|
||||
assert "level" not in out or out.get("level") is None
|
||||
assert out["line"] == "just a bare message"
|
||||
@@ -155,7 +155,7 @@ class TestRecorderDebugHeapSynthesis:
|
||||
assert synth[-1]["fields"]["heap_total_bytes"] == 200000
|
||||
|
||||
def test_no_heap_no_synthesis(self, recorder: "Recorder") -> None:
|
||||
# Plain log line (no [heap N], no Heap status) — telemetry.jsonl
|
||||
# Plain log line (no [heap N], no Heap status) - telemetry.jsonl
|
||||
# should NOT gain a synth row.
|
||||
before = (recorder.base_dir / "telemetry.jsonl").read_text().count("\n")
|
||||
recorder._on_log_line("INFO | 00:00:00 1 [Main] just a message", _FakeIface())
|
||||
@@ -234,7 +234,7 @@ class TestSerialTap:
|
||||
def test_serial_line_handler_swallows_exceptions(
|
||||
self, recorder: "Recorder"
|
||||
) -> None:
|
||||
# Hostile input — should not raise.
|
||||
# Hostile input - should not raise.
|
||||
recorder._on_serial_line(None, port="/dev/cu.tap") # type: ignore[arg-type]
|
||||
recorder._on_serial_line(b"\x00\x01\x02\x03", port="/dev/cu.tap") # type: ignore[arg-type]
|
||||
# Survived.
|
||||
@@ -320,11 +320,11 @@ class TestRecorderWrites:
|
||||
recorder._on_log_line("INFO | 12:34:56 99 [T] hi", _FakeIface())
|
||||
path = recorder.base_dir / "logs.jsonl"
|
||||
rows = [json.loads(line) for line in path.read_text().splitlines() if line]
|
||||
# First row is recorder_start_event mirror? No — that's events.jsonl only.
|
||||
# First row is recorder_start_event mirror? No - that's events.jsonl only.
|
||||
assert any(r.get("level") == "INFO" and r.get("tag") == "T" for r in rows)
|
||||
|
||||
def test_telemetry_recorded_and_packet_double(self, recorder: Recorder) -> None:
|
||||
# _on_telemetry alone — only telemetry.jsonl
|
||||
# _on_telemetry alone - only telemetry.jsonl
|
||||
recorder._on_telemetry(
|
||||
{
|
||||
"fromId": "!abc",
|
||||
@@ -367,13 +367,13 @@ class TestRecorderWrites:
|
||||
assert "kept" in post_resume
|
||||
|
||||
def test_pubsub_handler_swallows_exceptions(self, recorder: Recorder) -> None:
|
||||
# If the writer dies, the pubsub callback must NOT raise — that
|
||||
# If the writer dies, the pubsub callback must NOT raise - that
|
||||
# would crash the meshtastic receive thread.
|
||||
bad_packet = object() # not a dict
|
||||
recorder._on_receive(bad_packet, _FakeIface()) # type: ignore[arg-type]
|
||||
recorder._on_telemetry(bad_packet, _FakeIface()) # type: ignore[arg-type]
|
||||
recorder._on_log_line(None, _FakeIface()) # type: ignore[arg-type]
|
||||
# No assertion needed — survival is the test.
|
||||
# No assertion needed - survival is the test.
|
||||
|
||||
|
||||
# -- log_query read side ---------------------------------------------
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user