From 3becaf2d951ce445562ee530adee1f7811b448a6 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:01:27 +0100 Subject: [PATCH] emdashes begone (#10847) --- .claude/commands/README.md | 4 +- .claude/commands/diagnose.md | 26 +-- .claude/commands/leakhunt.md | 40 ++-- .claude/commands/repro.md | 16 +- .claude/commands/test.md | 16 +- .github/copilot-instructions.md | 188 +++++++++--------- .github/prompts/mcp-diagnose.prompt.md | 12 +- .github/prompts/mcp-repro.prompt.md | 32 +-- .github/prompts/mcp-test.prompt.md | 22 +- .github/prompts/new-module.prompt.md | 8 +- .github/prompts/new-sensor.prompt.md | 16 +- .github/prompts/new-variant.prompt.md | 34 ++-- .github/workflows/build_portduino_wasm.yml | 4 +- .github/workflows/flasher-link-comment.yml | 10 +- .../workflows/flasher-link-placeholder.yml | 6 +- .github/workflows/main_matrix.yml | 4 +- .github/workflows/test_native.yml | 2 +- AGENTS.md | 46 ++--- README.md | 4 +- ...region_preset_compatibility_client_spec.md | 22 +- docs/mesh_beacon_module.md | 120 +++++------ docs/nexthop-routing-reliability.md | 92 ++++----- extra_scripts/nrf52_warm_region.py | 2 +- extra_scripts/nrf54l15_linker.py | 6 +- extra_scripts/wasm_link_flags.py | 8 +- mcp-server/README.md | 83 ++++---- mcp-server/pyproject.toml | 8 +- mcp-server/run-tests.sh | 24 +-- mcp-server/scripts/datadog-dashboard.json | 4 +- mcp-server/scripts/mtlog_to_datadog.py | 8 +- mcp-server/src/meshtastic_mcp/__init__.py | 2 +- mcp-server/src/meshtastic_mcp/admin.py | 8 +- mcp-server/src/meshtastic_mcp/boards.py | 6 +- mcp-server/src/meshtastic_mcp/camera.py | 18 +- mcp-server/src/meshtastic_mcp/cli/__init__.py | 2 +- .../src/meshtastic_mcp/cli/_flashlog.py | 4 +- mcp-server/src/meshtastic_mcp/cli/_fwlog.py | 8 +- mcp-server/src/meshtastic_mcp/cli/_history.py | 2 +- .../src/meshtastic_mcp/cli/_reproducer.py | 16 +- mcp-server/src/meshtastic_mcp/cli/_uicap.py | 6 +- mcp-server/src/meshtastic_mcp/cli/test_tui.py | 122 ++++++------ mcp-server/src/meshtastic_mcp/connection.py | 12 +- mcp-server/src/meshtastic_mcp/devices.py | 6 +- mcp-server/src/meshtastic_mcp/fixtures.py | 24 +-- mcp-server/src/meshtastic_mcp/flash.py | 14 +- mcp-server/src/meshtastic_mcp/hw_tools.py | 4 +- mcp-server/src/meshtastic_mcp/input_events.py | 2 +- mcp-server/src/meshtastic_mcp/log_query.py | 2 +- mcp-server/src/meshtastic_mcp/ocr.py | 6 +- mcp-server/src/meshtastic_mcp/pio.py | 12 +- .../src/meshtastic_mcp/recorder/parsers.py | 14 +- .../src/meshtastic_mcp/recorder/recorder.py | 28 +-- .../src/meshtastic_mcp/recorder/rotating.py | 2 +- .../src/meshtastic_mcp/serial_session.py | 4 +- mcp-server/src/meshtastic_mcp/server.py | 56 +++--- mcp-server/src/meshtastic_mcp/uhubctl.py | 18 +- mcp-server/src/meshtastic_mcp/userprefs.py | 16 +- mcp-server/tests/README.md | 32 +-- mcp-server/tests/_port_discovery.py | 14 +- mcp-server/tests/_power.py | 8 +- .../tests/admin/test_channel_url_roundtrip.py | 4 +- .../tests/admin/test_config_roundtrip.py | 12 +- .../tests/admin/test_owner_survives_reboot.py | 2 +- mcp-server/tests/conftest.py | 84 ++++---- .../fleet/test_psk_seed_isolates_runs.py | 4 +- mcp-server/tests/mesh/_receive.py | 14 +- mcp-server/tests/mesh/test_bidirectional.py | 4 +- .../tests/mesh/test_broadcast_delivers.py | 2 +- mcp-server/tests/mesh/test_direct_with_ack.py | 4 +- mcp-server/tests/mesh/test_mesh_formation.py | 2 +- .../mesh/test_nexthop_multihop_recovery.py | 36 ++-- .../tests/mesh/test_peer_offline_recovery.py | 16 +- mcp-server/tests/mesh/test_traceroute.py | 10 +- .../tests/monitor/test_boot_log_no_panic.py | 6 +- .../provisioning/test_admin_key_baked.py | 4 +- .../test_bake_region_modem_slot.py | 2 +- .../test_unset_region_blocks_tx.py | 6 +- .../test_userprefs_survive_factory_reset.py | 6 +- mcp-server/tests/recovery/__init__.py | 2 +- mcp-server/tests/recovery/conftest.py | 4 +- mcp-server/tests/recovery/test_list_hubs.py | 6 +- .../test_device_telemetry_broadcast.py | 8 +- .../telemetry/test_telemetry_request_reply.py | 18 +- mcp-server/tests/test_00_bake.py | 24 +-- mcp-server/tests/tool_coverage.py | 12 +- mcp-server/tests/ui/__init__.py | 2 +- mcp-server/tests/ui/_screen_log.py | 2 +- mcp-server/tests/ui/conftest.py | 30 +-- mcp-server/tests/ui/test_input_fn_jump.py | 2 +- mcp-server/tests/ui/test_input_fn_oob.py | 10 +- mcp-server/tests/ui/test_input_menu.py | 10 +- .../tests/ui/test_input_message_scroll.py | 8 +- mcp-server/tests/ui/test_input_navigation.py | 4 +- mcp-server/tests/ui/test_input_node_scroll.py | 2 +- mcp-server/tests/unit/test_boards.py | 2 +- mcp-server/tests/unit/test_build_flags.py | 2 +- mcp-server/tests/unit/test_connection_tcp.py | 14 +- .../tests/unit/test_fake_nodedb_generator.py | 8 +- .../tests/unit/test_input_event_codes.py | 2 +- mcp-server/tests/unit/test_recorder.py | 14 +- mcp-server/tests/unit/test_testing_profile.py | 2 +- mcp-server/tests/unit/test_uhubctl_parser.py | 4 +- mcp-server/tests/unit/test_ui_screen_log.py | 6 +- mcp-server/tests/unit/test_userprefs_parse.py | 6 +- protobufs | 2 +- scripts/add_mbedtls_sources.py | 6 +- src/DebugConfiguration.h | 2 +- src/DisplayFormatters.cpp | 2 +- src/FSCommon.h | 2 +- src/MessageStore.h | 4 +- src/Power.cpp | 4 +- src/PowerFSM.cpp | 2 +- src/configuration.h | 20 +- src/gps/GPS.cpp | 2 +- src/gps/GPSUpdateScheduling.cpp | 2 +- src/graphics/Screen.cpp | 22 +- src/graphics/Screen.h | 2 +- src/graphics/TFTDisplay.cpp | 6 +- src/graphics/draw/DebugRenderer.cpp | 6 +- src/graphics/draw/MenuHandler.cpp | 6 +- src/graphics/draw/NotificationRenderer.cpp | 2 +- src/graphics/niche/Drivers/EInk/ED047TC1.cpp | 4 +- src/graphics/niche/Drivers/EInk/ED047TC1.h | 8 +- .../InkHUD/Applets/Bases/Map/MapApplet.cpp | 12 +- .../InkHUD/Applets/Bases/Map/MapApplet.h | 2 +- .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 4 +- .../ThreadedMessage/ThreadedMessageApplet.cpp | 2 +- src/graphics/niche/InkHUD/docs/README.md | 6 +- src/input/InputBroker.cpp | 8 +- src/main.cpp | 30 +-- src/mbedtls_user_config.h | 8 +- src/mesh/Channels.cpp | 4 +- src/mesh/Default.cpp | 2 +- src/mesh/HardwareRNG.cpp | 2 +- src/mesh/LR11x0Interface.cpp | 4 +- src/mesh/LR20x0Interface.cpp | 4 +- src/mesh/NextHopRouter.cpp | 10 +- src/mesh/NodeDB.cpp | 50 ++--- src/mesh/NodeDB.h | 12 +- src/mesh/NodeDBLegacyMigration.cpp | 2 +- src/mesh/PacketHistory.cpp | 4 +- src/mesh/PhoneAPI.cpp | 104 +++++----- src/mesh/PhoneAPI.h | 8 +- src/mesh/PositionPrecision.h | 2 +- src/mesh/RadioInterface.cpp | 4 +- src/mesh/RadioLibInterface.cpp | 8 +- src/mesh/RadioLibInterface.h | 2 +- src/mesh/Router.cpp | 4 +- src/mesh/Router.h | 2 +- src/mesh/StreamAPI.cpp | 2 +- src/mesh/StreamAPI.h | 2 +- src/mesh/TransmitHistory.cpp | 6 +- src/mesh/TransmitHistory.h | 2 +- src/mesh/TypeConversions.cpp | 2 +- src/mesh/WarmNodeStore.cpp | 22 +- src/mesh/WarmNodeStore.h | 10 +- src/mesh/eth/ethApiHandlers.cpp | 8 +- src/mesh/eth/ethApiHandlers.h | 6 +- src/mesh/eth/ethApiServer.cpp | 4 +- src/mesh/eth/ethApiServer.h | 2 +- src/mesh/eth/ethCert.cpp | 12 +- src/mesh/eth/ethCert.h | 2 +- src/mesh/eth/ethClient.cpp | 8 +- src/mesh/eth/ethOTA.cpp | 14 +- src/mesh/eth/ethTlsApiServer.cpp | 16 +- src/mesh/generated/meshtastic/admin.pb.h | 6 +- src/mesh/generated/meshtastic/atak.pb.h | 46 ++--- src/mesh/generated/meshtastic/mesh.pb.h | 18 +- .../generated/meshtastic/mesh_beacon.pb.h | 2 +- .../generated/meshtastic/module_config.pb.h | 4 +- src/mesh/mesh-pb-constants.h | 6 +- src/mesh/raspihttp/PiWebServer.cpp | 2 +- src/mesh/udp/UdpMulticastHandler.h | 2 +- src/meshUtils.cpp | 4 +- src/modules/AdminModule.cpp | 20 +- src/modules/AtakPluginModule.cpp | 2 +- src/modules/CannedMessageModule.cpp | 4 +- src/modules/HopScalingModule.cpp | 8 +- src/modules/HopScalingModule.h | 36 ++-- src/modules/MeshBeaconModule.cpp | 34 ++-- src/modules/MeshBeaconModule.h | 6 +- src/modules/PositionModule.cpp | 6 +- src/modules/PositionModule.h | 2 +- src/modules/ReplyBotModule.cpp | 6 +- src/modules/StatusLEDModule.h | 2 +- .../Telemetry/EnvironmentTelemetry.cpp | 2 +- src/modules/TraceRouteModule.cpp | 2 +- src/modules/TrafficManagementModule.cpp | 42 ++-- src/modules/TrafficManagementModule.h | 10 +- src/mqtt/MQTT.cpp | 2 +- src/platform/esp32/main-esp32.cpp | 2 +- src/platform/nrf52/NRF52Bluetooth.cpp | 2 +- src/platform/nrf52/Nrf52Twim.cpp | 2 +- src/platform/nrf52/hardfault.cpp | 2 +- src/platform/nrf54l15/Arduino.h | 30 +-- src/platform/nrf54l15/IPAddress.h | 2 +- src/platform/nrf54l15/InternalFileSystem.cpp | 6 +- src/platform/nrf54l15/InternalFileSystem.h | 4 +- src/platform/nrf54l15/NRF52Bluetooth.h | 2 +- src/platform/nrf54l15/NRF54L15Bluetooth.cpp | 50 ++--- src/platform/nrf54l15/NRF54L15Bluetooth.h | 2 +- src/platform/nrf54l15/Nrf52SaadcLock.h | 2 +- src/platform/nrf54l15/Print.h | 2 +- src/platform/nrf54l15/SPI.h | 8 +- src/platform/nrf54l15/Stream.h | 2 +- src/platform/nrf54l15/Tone.h | 2 +- src/platform/nrf54l15/WProgram.h | 2 +- src/platform/nrf54l15/Wire.cpp | 8 +- src/platform/nrf54l15/Wire.h | 10 +- src/platform/nrf54l15/architecture.h | 12 +- src/platform/nrf54l15/bluefruit.h | 2 +- src/platform/nrf54l15/main-nrf54l15.cpp | 10 +- src/platform/nrf54l15/nrf54l15_arduino.cpp | 32 +-- src/platform/nrf54l15/nrf54l15_main.cpp | 8 +- src/platform/nrf54l15/utility/bonding.h | 2 +- src/platform/portduino/PortduinoGlue.cpp | 6 +- src/platform/portduino/wasm/README.md | 12 +- .../portduino/wasm/include/libpinedio-usb.h | 2 +- .../portduino/wasm/portduino_glue_wasm.cpp | 32 +-- .../portduino/wasm/portduino_main_wasm.cpp | 10 +- src/platform/portduino/wasm/stubs/argp.h | 2 +- src/platform/stm32wl/LittleFS.cpp | 4 +- src/platform/stm32wl/STM32_LittleFS_File.cpp | 2 +- src/platform/stm32wl/main-stm32wl.cpp | 2 +- src/power/SGM41562.h | 8 +- src/security/APProtect.cpp | 2 +- src/security/APProtect.h | 4 +- src/security/EncryptedStorage.cpp | 82 ++++---- src/security/EncryptedStorage.h | 38 ++-- src/security/LockdownDisplay.cpp | 2 +- src/security/LockdownDisplay.h | 8 +- src/xmodem.cpp | 2 +- test/README.md | 40 ++-- test/fixtures/nodedb/README.md | 4 +- test/test_admin_radio/test_main.cpp | 2 +- test/test_crypto/test_main.cpp | 2 +- test/test_default/test_main.cpp | 2 +- test/test_hop_scaling/test_main.cpp | 30 +-- test/test_mac_from_string/test_main.cpp | 6 +- test/test_mesh_beacon/test_main.cpp | 36 ++-- test/test_mqtt/MQTT.cpp | 2 +- test/test_nexthop_routing/test_main.cpp | 14 +- test/test_nodedb_blocked/test_main.cpp | 10 +- test/test_packet_history/test_main.cpp | 66 +++--- test/test_packet_signing/test_main.cpp | 18 +- test/test_position_module/test_main.cpp | 2 +- test/test_radio/test_main.cpp | 2 +- test/test_traffic_management/test_main.cpp | 68 +++---- test/test_transmit_history/test_main.cpp | 8 +- test/test_utf8/test_main.cpp | 8 +- test/test_warm_store/test_main.cpp | 4 +- tools/lockdown_provision.py | 30 +-- variants/esp32c6/m5stack_unitc6l/variant.cpp | 3 +- variants/esp32c6/m5stack_unitc6l/variant.h | 2 +- variants/esp32p4/esp32p4.ini | 2 +- .../esp32s3/station-common/station_common.h | 2 +- variants/esp32s3/t5s3_epaper/nicheGraphics.h | 4 +- variants/esp32s3/t5s3_epaper/variant.cpp | 4 +- variants/native/portduino/malloc.h | 2 +- variants/native/portduino/platformio.ini | 34 ++-- variants/nrf52840/diy/xiao_ble/README.md | 2 +- .../nrf52840/heltec_mesh_node_t1/variant.h | 14 +- .../nrf52840/heltec_mesh_tower_v2/variant.cpp | 5 +- .../nrf52840/heltec_mesh_tower_v2/variant.h | 24 +-- variants/nrf52840/t-echo-card/variant.cpp | 2 +- variants/nrf52840/t-echo-card/variant.h | 4 +- variants/nrf52840/t-impulse-plus/variant.h | 4 +- variants/nrf54l15/nrf54l15dk/README.md | 41 ++-- variants/nrf54l15/nrf54l15dk/platformio.ini | 2 +- variants/nrf54l15/nrf54l15dk/variant.h | 36 ++-- variants/rp2350/diy/pico2_w5500_e22/README.md | 22 +- .../rp2350/diy/pico2_w5500_e22/platformio.ini | 2 +- variants/rp2350/diy/pico2_w5500_e22/variant.h | 12 +- .../diy/wiznet_5500_evb_pico2_e22p/README.md | 26 +-- .../wiznet_5500_evb_pico2_e22p/platformio.ini | 4 +- variants/stm32/russell/variant.h | 4 +- 276 files changed, 1795 insertions(+), 1793 deletions(-) diff --git a/.claude/commands/README.md b/.claude/commands/README.md index 3767dac98..87ad24aed 100644 --- a/.claude/commands/README.md +++ b/.claude/commands/README.md @@ -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. diff --git a/.claude/commands/diagnose.md b/.claude/commands/diagnose.md index d664f6312..d8db2e48f 100644 --- a/.claude/commands/diagnose.md +++ b/.claude/commands/diagnose.md @@ -3,7 +3,7 @@ description: Produce a device health report using the meshtastic MCP tools (devi argument-hint: [role=all|nrf52|esp32s3|] --- -# `/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=

)` — captures `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel`. - - `mcp__meshtastic__list_nodes(port=

)` — count of peers, which ones have `publicKey` set, SNR/RSSI distribution. - - `mcp__meshtastic__get_config(section="lora", port=

)` — 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=

)` - captures `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel`. + - `mcp__meshtastic__list_nodes(port=

)` - count of peers, which ones have `publicKey` set, SNR/RSSI distribution. + - `mcp__meshtastic__get_config(section="lora", port=

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

, env=)`, wait 3s, `serial_read(session_id=, max_lines=100)`, `serial_close(session_id=)`. The env should be inferred from the VID map in `mcp-server/run-tests.sh` (nrf52 → rak4631, esp32s3 → heltec-v3) unless `MESHTASTIC_MCP_ENV*` is set. -4. **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 `⚠︎ `. 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. diff --git a/.claude/commands/leakhunt.md b/.claude/commands/leakhunt.md index ef90b133e..3950af8c6 100644 --- a/.claude/commands/leakhunt.md +++ b/.claude/commands/leakhunt.md @@ -5,40 +5,40 @@ argument-hint: [window=1h] [field=free_heap] [variant=local] -# `/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 diff --git a/.claude/commands/repro.md b/.claude/commands/repro.md index 84513e45b..d5728dec3 100644 --- a/.claude/commands/repro.md +++ b/.claude/commands/repro.md @@ -5,7 +5,7 @@ argument-hint: [count=5] -# `/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=, end=, dest_dir="mcp-server/tests/repro_artifacts//attempt_/")`. 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. diff --git a/.claude/commands/test.md b/.claude/commands/test.md index 46a753749..5609b3d33 100644 --- a/.claude/commands/test.md +++ b/.claude/commands/test.md @@ -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 `. 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 `. 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///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///transcript.md` - it records each step's frame + OCR. 5. **Classify the failure** as one of: - **Transient/flake**: LoRa collision, timing-sensitive assertion, first-attempt NAK + successful retry pattern. Propose `/repro ` to confirm. - - **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery 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). diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8296daf86..37773d3b1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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` 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//trunk--linux-x86_64.tar.gz` and place the `trunk` binary at `~/.cache/trunk/cli/-linux-x86_64/trunk` (chmod +x), where `` 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///` 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_` + `_PORT_` env vars first, falls back to VID auto-detection. -- **Observability** (UI tier + operator ad-hoc): `capture_screen(role, ocr=True)` — grabs a USB-webcam frame of the device OLED and optionally OCRs it. Requires `mcp-server[ui]` extras (`opencv-python-headless`, `easyocr`) and `MESHTASTIC_UI_CAMERA_DEVICE_` env var; falls through to a 1×1 black PNG `NullBackend` when unconfigured. +- **Observability** (UI tier + operator ad-hoc): `capture_screen(role, ocr=True)` - grabs a USB-webcam frame of the device OLED and optionally OCRs it. Requires `mcp-server[ui]` extras (`opencv-python-headless`, `easyocr`) and `MESHTASTIC_UI_CAMERA_DEVICE_` env var; falls through to a 1×1 black PNG `NullBackend` when unconfigured. -`confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `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=` 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=` 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--`). +- **`_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--`). ### 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 diff --git a/.github/prompts/mcp-diagnose.prompt.md b/.github/prompts/mcp-diagnose.prompt.md index 1049858f8..3ef6bf22d 100644 --- a/.github/prompts/mcp-diagnose.prompt.md +++ b/.github/prompts/mcp-diagnose.prompt.md @@ -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=

)` → `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel` - `list_nodes(port=

)` → peer count, which peers have `publicKey`, SNR/RSSI distribution - `get_config(section="lora", port=

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

, env=)`, wait 3 seconds, `serial_read(session_id, max_lines=100)`, `serial_close(session_id)`. Infer env from VID (0x239a → `rak4631`, 0x303a/0x10c4 → `heltec-v3`) unless an `MESHTASTIC_MCP_ENV_` env var overrides it. -4. **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 `⚠︎ ` — missing pubkey on a known peer, region UNSET, mismatched channel name, device on non-PPPS hub, etc. + Flag abnormalities inline with `⚠︎ ` - 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. diff --git a/.github/prompts/mcp-repro.prompt.md b/.github/prompts/mcp-repro.prompt.md index 3a7c5c3de..e260bdd19 100644 --- a/.github/prompts/mcp-repro.prompt.md +++ b/.github/prompts/mcp-repro.prompt.md @@ -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. diff --git a/.github/prompts/mcp-test.prompt.md b/.github/prompts/mcp-test.prompt.md index 148569e83..89d691624 100644 --- a/.github/prompts/mcp-test.prompt.md +++ b/.github/prompts/mcp-test.prompt.md @@ -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 `. 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///transcript.md` (per-step frame + OCR) 5. **Classify each failure** as one of: - - **Transient flake** — LoRa collision, first-attempt NAK with self-heal pattern, timing-sensitive assertion. Suggest `/mcp-repro ` to confirm. - - **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest 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 ` 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. diff --git a/.github/prompts/new-module.prompt.md b/.github/prompts/new-module.prompt.md index 08b239597..9c6635aa8 100644 --- a/.github/prompts/new-module.prompt.md +++ b/.github/prompts/new-module.prompt.md @@ -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`** — 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`** - 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, 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) diff --git a/.github/prompts/new-sensor.prompt.md b/.github/prompts/new-sensor.prompt.md index e02fc2462..67eaaba81 100644 --- a/.github/prompts/new-sensor.prompt.md +++ b/.github/prompts/new-sensor.prompt.md @@ -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` diff --git a/.github/prompts/new-variant.prompt.md b/.github/prompts/new-variant.prompt.md index 666e264e0..a5e401951 100644 --- a/.github/prompts/new-variant.prompt.md +++ b/.github/prompts/new-variant.prompt.md @@ -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 ``` diff --git a/.github/workflows/build_portduino_wasm.yml b/.github/workflows/build_portduino_wasm.yml index 7195c3dbe..c6c32583a 100644 --- a/.github/workflows/build_portduino_wasm.yml +++ b/.github/workflows/build_portduino_wasm.yml @@ -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). diff --git a/.github/workflows/flasher-link-comment.yml b/.github/workflows/flasher-link-comment.yml index cbc1528d9..e71b8bb50 100644 --- a/.github/workflows/flasher-link-comment.yml +++ b/.github/workflows/flasher-link-comment.yml @@ -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) => diff --git a/.github/workflows/flasher-link-placeholder.yml b/.github/workflows/flasher-link-placeholder.yml index f6670eafe..c239225af 100644 --- a/.github/workflows/flasher-link-placeholder.yml +++ b/.github/workflows/flasher-link-placeholder.yml @@ -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, diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index b62ebf401..c71afe16d 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -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 diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 3c4f3ee3c..3b1b95ee8 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.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 diff --git a/AGENTS.md b/AGENTS.md index 7c3d01e8a..b1949cb7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,11 +18,11 @@ > | New module skeleton | inherit `ProtobufModule` 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 -t clean && pio run -e ` | | Flash a device | `pio run -e -t upload --upload-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 [count]` — re-runs the test N times, diffs firmware logs between passes and failures +1. `/repro [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 ` 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 `. 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 `. 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_` | 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_` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection - use when multiple devices share a VID. | | `MESHTASTIC_UHUBCTL_PORT_` | Pin a role to a specific hub port number. Required alongside `LOCATION_`. | | `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). | | `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. | diff --git a/README.md b/README.md index f34bf1839..de76f4bd3 100644 --- a/README.md +++ b/README.md @@ -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! 🚀 diff --git a/docs/lora_region_preset_compatibility_client_spec.md b/docs/lora_region_preset_compatibility_client_spec.md index b62fe7757..fb2019ec0 100644 --- a/docs/lora_region_preset_compatibility_client_spec.md +++ b/docs/lora_region_preset_compatibility_client_spec.md @@ -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 diff --git a/docs/mesh_beacon_module.md b/docs/mesh_beacon_module.md index 617dd797e..67a391fe0 100644 --- a/docs/mesh_beacon_module.md +++ b/docs/mesh_beacon_module.md @@ -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__*` 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 | diff --git a/docs/nexthop-routing-reliability.md b/docs/nexthop-routing-reliability.md index 114bd5687..27abb860a 100644 --- a/docs/nexthop-routing-reliability.md +++ b/docs/nexthop-routing-reliability.md @@ -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 diff --git a/extra_scripts/nrf52_warm_region.py b/extra_scripts/nrf52_warm_region.py index e1a942bef..b5993375a 100644 --- a/extra_scripts/nrf52_warm_region.py +++ b/extra_scripts/nrf52_warm_region.py @@ -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 diff --git a/extra_scripts/nrf54l15_linker.py b/extra_scripts/nrf54l15_linker.py index fa1041553..824aae7ce 100644 --- a/extra_scripts/nrf54l15_linker.py +++ b/extra_scripts/nrf54l15_linker.py @@ -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): diff --git a/extra_scripts/wasm_link_flags.py b/extra_scripts/wasm_link_flags.py index 4b7747364..9c187abe3 100644 --- a/extra_scripts/wasm_link_flags.py +++ b/extra_scripts/wasm_link_flags.py @@ -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 diff --git a/mcp-server/README.md b/mcp-server/README.md index 22ce77fbc..44bebff9a 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -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= .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 ` (+ mtjson target) | | `clean` | `pio run -e -t clean` | -| `pio_flash` | `pio run -e -t upload --upload-port ` — any architecture | +| `pio_flash` | `pio run -e -t upload --upload-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=`; 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. diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml index 3241c843f..d2bab7841 100644 --- a/mcp-server/pyproject.toml +++ b/mcp-server/pyproject.toml @@ -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" diff --git a/mcp-server/run-tests.sh b/mcp-server/run-tests.sh index 95640bffd..c34fcc71c 100755 --- a/mcp-server/run-tests.sh +++ b/mcp-server/run-tests.sh @@ -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 diff --git a/mcp-server/scripts/datadog-dashboard.json b/mcp-server/scripts/datadog-dashboard.json index 73aa35201..37d87b680 100644 --- a/mcp-server/scripts/datadog-dashboard.json +++ b/mcp-server/scripts/datadog-dashboard.json @@ -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": [ diff --git a/mcp-server/scripts/mtlog_to_datadog.py b/mcp-server/scripts/mtlog_to_datadog.py index 51496adc4..d8c34e2a6 100755 --- a/mcp-server/scripts/mtlog_to_datadog.py +++ b/mcp-server/scripts/mtlog_to_datadog.py @@ -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 diff --git a/mcp-server/src/meshtastic_mcp/__init__.py b/mcp-server/src/meshtastic_mcp/__init__.py index bd696afe0..14b6275e5 100644 --- a/mcp-server/src/meshtastic_mcp/__init__.py +++ b/mcp-server/src/meshtastic_mcp/__init__.py @@ -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" diff --git a/mcp-server/src/meshtastic_mcp/admin.py b/mcp-server/src/meshtastic_mcp/admin.py index 33f3865dd..f9520e8bf 100644 --- a/mcp-server/src/meshtastic_mcp/admin.py +++ b/mcp-server/src/meshtastic_mcp/admin.py @@ -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 diff --git a/mcp-server/src/meshtastic_mcp/boards.py b/mcp-server/src/meshtastic_mcp/boards.py index df5024800..9e2378a94 100644 --- a/mcp-server/src/meshtastic_mcp/boards.py +++ b/mcp-server/src/meshtastic_mcp/boards.py @@ -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 diff --git a/mcp-server/src/meshtastic_mcp/camera.py b/mcp-server/src/meshtastic_mcp/camera.py index 5f1e5ede3..e69ec20b2 100644 --- a/mcp-server/src/meshtastic_mcp/camera.py +++ b/mcp-server/src/meshtastic_mcp/camera.py @@ -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_` — per-role override, e.g. +- `MESHTASTIC_UI_CAMERA_DEVICE` - generic default (index or path). +- `MESHTASTIC_UI_CAMERA_DEVICE_` - per-role override, e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3. Role suffix is uppercased before lookup. @@ -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() diff --git a/mcp-server/src/meshtastic_mcp/cli/__init__.py b/mcp-server/src/meshtastic_mcp/cli/__init__.py index 04729b643..ff3b8e03d 100644 --- a/mcp-server/src/meshtastic_mcp/cli/__init__.py +++ b/mcp-server/src/meshtastic_mcp/cli/__init__.py @@ -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. """ diff --git a/mcp-server/src/meshtastic_mcp/cli/_flashlog.py b/mcp-server/src/meshtastic_mcp/cli/_flashlog.py index 889183bb3..32c95d012 100644 --- a/mcp-server/src/meshtastic_mcp/cli/_flashlog.py +++ b/mcp-server/src/meshtastic_mcp/cli/_flashlog.py @@ -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. """ diff --git a/mcp-server/src/meshtastic_mcp/cli/_fwlog.py b/mcp-server/src/meshtastic_mcp/cli/_fwlog.py index 7db20f81c..74ef4946e 100644 --- a/mcp-server/src/meshtastic_mcp/cli/_fwlog.py +++ b/mcp-server/src/meshtastic_mcp/cli/_fwlog.py @@ -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. diff --git a/mcp-server/src/meshtastic_mcp/cli/_history.py b/mcp-server/src/meshtastic_mcp/cli/_history.py index 639dcec5f..7cfc43ef6 100644 --- a/mcp-server/src/meshtastic_mcp/cli/_history.py +++ b/mcp-server/src/meshtastic_mcp/cli/_history.py @@ -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): diff --git a/mcp-server/src/meshtastic_mcp/cli/_reproducer.py b/mcp-server/src/meshtastic_mcp/cli/_reproducer.py index 420da3c76..ffc553c70 100644 --- a/mcp-server/src/meshtastic_mcp/cli/_reproducer.py +++ b/mcp-server/src/meshtastic_mcp/cli/_reproducer.py @@ -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 diff --git a/mcp-server/src/meshtastic_mcp/cli/_uicap.py b/mcp-server/src/meshtastic_mcp/cli/_uicap.py index 448459954..e87128b75 100644 --- a/mcp-server/src/meshtastic_mcp/cli/_uicap.py +++ b/mcp-server/src/meshtastic_mcp/cli/_uicap.py @@ -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 diff --git a/mcp-server/src/meshtastic_mcp/cli/test_tui.py b/mcp-server/src/meshtastic_mcp/cli/test_tui.py index 7f3a2da36..32718577a 100644 --- a/mcp-server/src/meshtastic_mcp/cli/test_tui.py +++ b/mcp-server/src/meshtastic_mcp/cli/test_tui.py @@ -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=