* feat(portduino): admin keys in config.yaml, beyond the protobuf's three meshtasticd can now list the public keys authorized to administer the node in its own config, as Security.AdminKeys, and list more than the three config.security.admin_key holds. The host's file is the authority: it is root-owned, so unlike the persisted protobuf an authorized remote cannot rewrite it, and applyHostKeys() re-asserts it at boot and after every remote write of the security config. A key removed from the file loses access on the next boot instead of lingering in flash. The protobuf array stays at three. It is not an arbitrary cap: a security set_config already encodes to ~207 bytes against the 221 a PKC admin packet can carry (233 payload bytes less 12 of PKC overhead), so a fourth key would not fit in one admin message on any platform. Four sites decided who may administer a node, each unrolling admin_key[0..2] by hand - the PKC gate in AdminModule, the same gate in DMShell, the is_managed guard, and the admin-key trial decrypt in Router::perhapsDecode. They now share AdminKeys::isAuthorized(), so a key source reaches all four at once, and enumeration skips empty slots rather than trusting admin_key_count. Each configured key costs one trial decryption per PKI packet from an unknown sender, so the host list is capped at 16 and duplicates are dropped. Files in config.d append to the list, one per operator. A key that is not exactly 32 canonical base64 bytes stops meshtasticd rather than silently authorizing something else, and --check reports it with its line instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP5hpFwdTBC2K9ggGFLpcs * test(portduino): config-check fixtures for Security.AdminKeys bin/test-config-check.sh drives a built meshtasticd against the fixtures, which is the only place the two process-level behaviours here are reachable: what --check reports, and that a normal boot refuses the file. A clean two-key list, a key three characters short of 32 bytes, and one key written as a scalar instead of a list - the last two as errors under --check and as a non-zero exit on a normal boot, since a truncated admin key would authorize something other than what was written and a file whose only key is unreadable would otherwise boot a node nobody can administer. adminkeys-append/ pins the append-across-config.d rule by value through --output-yaml, with the first key in config.yaml so the effective list does not depend on the filesystem's order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SP5hpFwdTBC2K9ggGFLpcs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
26 KiB
Agent instructions
TL;DR
Local tests ./bin/run-tests.sh(exit 0 GREEN · 1 RED · 2 AMBER · 3 FILTERED · 4 BUSY · 5 ABORTED · 6 UNSUPPORTED);--statusfirstHardware tests meshtastic/meshtastic-mcp ( MESHTASTIC_FIRMWARE_ROOT→ this checkout)Format trunk fmtMirror docs .github/copilot-instructions.md(canonical) ·CLAUDE.md(Claude Code)Need this? It's here.
General helpers (clamp, UTF-8, string fmt…) src/meshUtils.hLogging macros (LOG_DEBUG / INFO / WARN…) src/DebugConfiguration.hNew module skeleton inherit ProtobufModule<T>insrc/mesh/ProtobufModule.hObserver / event wiring src/Observer.h
This repository is the Meshtastic firmware - a C++17 embedded codebase targeting ESP32 / nRF52 / RP2040 / STM32WL / Linux-Portduino LoRa mesh radios. The Python MCP server that AI agents use to flash, configure, and test connected devices now lives in its own repo, meshtastic/meshtastic-mcp; this repo registers it via .mcp.json (run through uvx) so its tools are available automatically.
Primary instruction file
Read .github/copilot-instructions.md first. That file is the canonical agent-facing document for this repo. It covers project layout, coding conventions (naming, module framework, Observer pattern, thread safety), the build system, CI/CD, the native C++ test suite, and - most importantly for automation work - the MCP Server & Hardware Test Harness section. Read it top-to-bottom before starting any non-trivial change.
This file (AGENTS.md) is a short pointer + quick reference for agents that don't read .github/copilot-instructions.md by default.
Quick command reference
| Action | Command |
|---|---|
| Build a firmware variant | pio run -e <env> (e.g. pio run -e rak4631, pio run -e heltec-v3) |
| Build native macOS host binary | pio run -e native-macos (Homebrew prereqs + CH341 LoRa setup in variants/native/portduino/platformio.ini) |
| Clean + rebuild | pio run -e <env> -t clean && pio run -e <env> |
| Flash a device | pio run -e <env> -t upload --upload-port <port> (or use the pio_flash MCP tool) |
| Run firmware unit tests (native) | ./bin/run-tests.sh (preferred - ASan/LSan + RED/AMBER/GREEN verdict); or raw: ~/.platformio/penv/bin/python -m platformio test -e native > /tmp/test_out.txt 2>&1 |
| Run MCP hardware tests | From a meshtastic-mcp checkout: MESHTASTIC_FIRMWARE_ROOT=/path/to/firmware ./run-tests.sh |
| Live TUI test runner | uvx --from git+https://github.com/meshtastic/meshtastic-mcp meshtastic-mcp-test-tui |
| Format before commit | trunk fmt |
| Regenerate protobuf bindings | bin/regen-protos.sh |
| Generate CI matrix | ./bin/generate_ci_matrix.py all [--level pr] |
MCP server (device + test automation)
The meshtastic-mcp server exposes ~32 MCP tools for device discovery, building, flashing, serial monitoring, and live-node administration. Tools are grouped as:
- Discovery:
list_devices,list_boards,get_board - Build & flash:
build,clean,pio_flash,erase_and_flash(ESP32 factory),update_flash(ESP32 OTA),touch_1200bps - Serial sessions:
serial_open,serial_read,serial_list,serial_close - Device reads:
device_info,list_nodes - Device writes (require
confirm=True):set_owner,get_config,set_config,get_channel_url,set_channel_url,send_text,reboot,shutdown,factory_reset,set_debug_log_api - Frame injection:
inject_frame- deliver a crafted frame into a board's real RX pipeline as if received off LoRa (reachesfrom != 0/ decrypt / remote-admin paths thetoRadioAPI can't). Needs firmware built with-D MESHTASTIC_ENABLE_FRAME_INJECTION=1(MeshService::injectAsReceived); sim nodes always. See the copilot-instructions Frame injection section. - userPrefs admin:
userprefs_get,userprefs_set,userprefs_reset,userprefs_manifest,userprefs_testing_profile - Vendor escape hatches:
esptool_*,nrfutil_*,picotool_*
Setup: nothing to build - .mcp.json runs the server via uvx --from git+https://github.com/meshtastic/meshtastic-mcp meshtastic-mcp, so Claude Code picks it up automatically. To run the pytest hardware harness, clone meshtastic-mcp and set MESHTASTIC_FIRMWARE_ROOT to this firmware checkout.
See the meshtastic-mcp repo's README for argument shapes and the MCP Server & Hardware Test Harness section of .github/copilot-instructions.md for agent usage rules (tool surface, fixture contract, firmware integration points, recovery playbooks).
Slash commands (AI-assisted workflows)
The test-and-diagnose workflows (/test, /diagnose, /repro, /leakhunt) now ship with the meshtastic-mcp repo as skills - run them from a checkout of that repo (with MESHTASTIC_FIRMWARE_ROOT pointed here). The MCP tools they build on are still available in this repo via .mcp.json.
Encryption at a glance
Two layers, both in src/mesh/CryptoEngine.cpp:
- Channel (symmetric) - AES-CTR with a channel-wide PSK (AES-128 or AES-256). Nonce = packet_id ‖ from_node ‖ block_counter. No AEAD; integrity is soft (channel-hash filter). The well-known default PSK lives in
src/mesh/Channels.h; a 1-byte PSK is a short-form index into it. - Per-peer PKI - X25519 ECDH (Curve25519, 32-byte keys) → SHA-256 → AES-256-CCM with an 8-byte MAC. Fresh 32-bit
extraNonceper 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 byAdminKeys::isAuthorized()(config.security.admin_key[0..2], plus meshtasticd'sSecurity.AdminKeys). Disabled entirely in Ham mode (user.is_licensed=true).
Key rotation to never trigger casually: only the full factory reset (factory_reset_device, eraseBleBonds=true) wipes security.private_key and regenerates the keypair - every peer holds the old public key, so DMs silently fail PKI decrypt until NodeInfo re-exchanges. The partial config reset (factory_reset_config) preserves the private key and doesn't invalidate peer relationships. Explicitly blanking security.private_key via admin also triggers regen. See the Encryption & Key Management section of .github/copilot-instructions.md for the full spec (nonce layout, send/receive selection logic including infrastructure-portnum exceptions, admin-key + session-passkey authorization, is_managed scope, key-rotation hazards).
House rules
-
No destructive device operations without operator approval.
factory_reset,erase_and_flash,reboot,shutdown, history-rewriting git ops - describe the action and stop. Operator authorizes. -
One MCP call per serial port at a time. The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device.
-
userPrefs.jsoncis session state during tests. The_session_userprefsfixture 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 fmtbefore proposing a commit. Thetrunk_checkCI gate will reject unformatted code. Claude Code runs it automatically via the PostToolUse hook in.claude/settings.json; trunk's launcher needscurlorwgetto bootstrap its pinned CLI - see Formatting & the trunk toolchain in.github/copilot-instructions.mdfor the no-curl bootstrap procedure. -
Never edit or commit files under
src/mesh/generated/. They are regenerated from themeshtastic/protobufsrepo by theupdate_protobufs.ymlworkflow (entry point:bin/regen-protos.sh). Local edits will be overwritten and create merge conflicts. If a.protochange is needed, open a PR against the protobufs repo first, then let the workflow re-sync this repo. -
confirm=Trueon destructive MCP tools is a real gate, not a formality. Don't bypass it via auto-approve settings. -
The
src/naming rule does not apply undertest/. Suite directories andtest_*functions follow their own rule and must not be renamed to matchsrc/. What that rule is, and why: Test naming in.github/copilot-instructions.md- authoritative there, not restated here. -
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.
-
That limit does not bind a test's header comment. A test header states what it pins and the regression it guards, at whatever length that takes, and must not be cut to two lines. What it must contain, and why: Test comments in
.github/copilot-instructions.md- authoritative there, not restated here. -
Documentation does not live in this repo. Do not add it here. This repository holds firmware code. There is no
docs/directory - the design documents that used to sit there were published to meshtastic/meshtastic in #11488 and the directory was deleted - and it must not come back. Do not create a.mdfile to describe a feature, a configuration surface, an API, a wire format, or a design; write it in the docs repo and link that PR instead. Never leave a write-up behind in the tree: no investigation notes, no mitigation plans, no migration checklists, no "how we got here" narrative, no summaries of what a change did. That is what the PR description and the commit message are for, and they are the only place it belongs. When you do write documentation upstream, write a technical manual, not a novel - what the feature does, the settings it exposes in the user's terms, and the exact API or protocol a client speaks. No story of the debugging journey, no rationale essays, no changelog prose. Concise and factual, as short as the facts allow. -
Never compare against
millis()directly. UseThrottle.src/mesh/Throttle.his the sanctioned way to ask about time, and CI enforces this (millis-deadline-checkin.github/workflows/test_native.ymlfails the PR on a newmillis() >/< millis()comparison).Throttle::isWithinTimespanMs(lastMs, intervalMs)- true while still inside the cooldown.Throttle::hasElapsed(lastMs, intervalMs)- its complement, true once the interval has passed (inclusive>=). Prefer this to spelling!isWithinTimespanMs(...).Throttle::execute(&lastMs, intervalMs, func)- function-pointer form that updates the timestamp on fire.Throttle::deadlinePassed(deadlineMs)- for a stored absolute deadline that cannot be re-expressed as "interval since an event".Throttle::deadlinePassedAt(nowMs, deadlineMs)- the same test against a caller-suppliednow, for a loop that snapshots the clock once and tests many deadlines. Snapshot fromTime::getMillis().
Raw
millis() > deadlineordeadline < millis()is rollover-unsafe: the comparison inverts while the deadline sits on the far side of the 32-bit wrap, so the action fires immediately or blocks for roughly the interval it should have waited. All five helpers subtract first, so unsigned wraparound cancels out.Throttlereads the clock throughTime::getMillis()(src/UptimeClock.h), so all ~94 of its call sites are time-injectable and a native test can drive the wrap withTime::setTestMillis().Sentinel hazard. If a deadline variable also encodes "inactive" (
0forrebootAtMsec,shutdownAtMsec,alertBannerUntil,fixHoldEnds;UINT32_MAXfornagCycleCutoff), test that sentinel before the elapsed comparison - every such value is arithmetically far in the past, so a correct comparison fires on it immediately. Match the test to the sentinel in use:if (deadline && Throttle::deadlinePassed(deadline))covers the0family,nagCycleCutoffneedsdeadline != UINT32_MAXor a separate armed flag (isNagging).Then decide which way the sentinel should fall - "inactive" does not always mean "suppress". At the GPS fix-hold site
fixHoldEnds == 0means no hold is in force, which is exactly when one must be armed; guarding it withfixHoldEnds != 0 &&looks like this rule and inverts the site. SeefixHoldInForce()insrc/gps/GPS.cppandtest/test_gps_fix_hold/.
Typical agent workflows
Flashing a device
list_devices→ find the port + likely VIDlist_boards→ confirm the env, or use the known default for the hardwarepio_flash(env=..., port=..., confirm=True)for any arch, orerase_and_flash(env=..., port=..., confirm=True)for an ESP32 factory install
Inspecting live node state
device_info(port=...)- short summary (node num, firmware version, region, peer count)list_nodes(port=...)- full peer table (SNR, RSSI, pubkey presence, last_heard)get_config(section="lora", port=...)- LoRa settings for cross-device comparison
Sequence these; don't parallelize on the same port.
Testing a firmware change
- Build locally:
pio run -e <env> - Flash the test device:
pio_flash(env=..., port=..., confirm=True) - Run the suite from a meshtastic-mcp checkout:
MESHTASTIC_FIRMWARE_ROOT=/path/to/firmware ./run-tests.sh tests/<tier>(or the/testskill) - On failure, open the run's
tests/report.html→Meshtastic debugsection for the firmware log tail + device state dump - Iterate
Debugging a native unit-test failure
- Run the full suite before believing a filtered one.
-fis not a gate: it removes the suites that create the shared state a later suite trips over. - A signal name is not a crash.
exit(UNITY_END())returns the failure count and PlatformIO renders it as a signal (4 ->SIGILL, 5 ->SIGTRAP), reporting[ERRORED]. Match it against the failure count first. - Check the CLEAN/DIRTY axis. Each suite runs in its own scratch
$HOME; deliberate writes are declared intest/state-manifest.tsv. A DIRTY verdict names the suite and the undeclared path, and the kept sandbox under.pio/test-state/<suite>/is a replayable reproduction. - Sanitizers are per env -
coveragehas ASan/LSan,nativehas none. Don't reason from ASan on a-e nativerun. - Reproduce a shuffled order.
--shuffleprints its seed and puts it on theRESULT:line;--seed <n>replays that exact order. One green seed proves nothing about order independence. - Exit 2 with "Linux-only" is the host, not the tests. The harness needs bash 4+ and GNU coreutils/find and rejects any other
unamerather than degrade quietly.native-macosis a build target, not a test host; elsewhere use./bin/test-native-docker.sh.
Debugging a flaky hardware test
/repro <test-node-id> [count]- re-runs the test N times, diffs firmware logs between passes and failures- If the first attempt always fails and the rest pass, that's a state-leak pattern → suggest
--force-bakeor a clean device state, don't chase the first failure - If all N fail, this isn't a flake - it's a regression. Stop iterating and escalate to
/testfor full-suite context.
Where to look
| Path | What's there |
|---|---|
src/ |
Firmware C++ source (mesh/, modules/, platform/, graphics/, gps/, motion/, mqtt/, …) |
src/mesh/ |
Core: NodeDB, Router, Channels, CryptoEngine, radio interfaces, StreamAPI, PhoneAPI |
src/modules/ |
Feature modules; Telemetry/Sensor/ has 50+ I2C sensor drivers |
variants/ |
200+ hardware variant definitions (variant.h + platformio.ini per board) |
protobufs/ |
.proto definitions; regenerate with bin/regen-protos.sh |
test/ |
Firmware unit tests (count = the test_* dirs, detected on the fly; ./bin/run-tests.sh preferred, falls back to pio test -e native) |
| meshtastic-mcp | Standalone MCP server + tiered pytest hardware harness (unit/, mesh/, telemetry/, monitor/, recovery/, ui/, fleet/, admin/, provisioning/) - registered here via .mcp.json |
.github/prompts/ |
Copilot prompt bodies (firmware scaffolding: new module / sensor / variant) |
.github/copilot-instructions.md |
Primary agent instructions - read this |
.github/workflows/ |
CI pipelines |
.mcp.json |
MCP server registration for Claude Code |
Recovery one-liners
userPrefs.jsoncdirty after a test run? Re-run the meshtastic-mcp harness once (pre-flight self-heals from the sidecar). If still dirty:git checkout userPrefs.jsonc.- nRF52 not responding?
mcp__meshtastic__touch_1200bps(port=...)drops it into the DFU bootloader, thenpio_flashre-installs. - Device fully wedged (no DFU)?
mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)hard-power-cycles it via USB hub PPPS. Needsuhubctlinstalled (brew install uhubctl/apt install uhubctl); on Linux without udev rules, permission errors fail fast, so usesudo uhubctlyourself or configure udev access. - Port busy?
lsof <port>to find the holder. Usually a stalepio device monitoror zombiemeshtastic_mcpprocess. 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_BUSYon a CH341 LoRa adapter? A third-party WCHCH34xVCPDriveris claiming interface 0. Find the bundle ID withioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512, thensudo kmutil unload -b <bundleID>. Apple's bundled CH34x kext targets the CH340 UART (PID 0x7523), not the SPI bridge - it's never the culprit.
Environment variables (test harness)
| Var | Purpose |
|---|---|
MESHTASTIC_MCP_ENV_<ROLE> |
Override PlatformIO env for a role (e.g. MESHTASTIC_MCP_ENV_NRF52=rak4631-dap). Default map: nrf52→rak4631, esp32s3→heltec-v3. |
MESHTASTIC_MCP_SEED |
PSK seed for the session test profile. Defaults to mcp-<user>-<host>. |
MESHTASTIC_MCP_FLASH_LOG |
File path to tee pio/esptool/nrfutil/picotool output. run-tests.sh sets this to tests/flash.log so the TUI can stream live flash progress. |
MESHTASTIC_MCP_TCP_HOST |
host or host:port of a meshtasticd daemon (e.g. the native-macos build). Surfaces it in list_devices as tcp://host:port so connect()-based tools target it transparently. Default port 4403. |
MESHTASTIC_UHUBCTL_BIN |
Absolute path to uhubctl binary. Default: PATH lookup. |
MESHTASTIC_UHUBCTL_LOCATION_<ROLE> |
Pin a role to a specific uhubctl hub location (e.g. 1-1.3). Wins over VID auto-detection - use when multiple devices share a VID. |
MESHTASTIC_UHUBCTL_PORT_<ROLE> |
Pin a role to a specific hub port number. Required alongside LOCATION_<ROLE>. |
MESHTASTIC_UI_CAMERA_BACKEND |
Camera backend for UI tier + capture_screen tool: opencv / ffmpeg / null / auto (default). |
MESHTASTIC_UI_CAMERA_DEVICE |
Generic camera device (index or path). Used by the UI tier when no per-role var is set. |
MESHTASTIC_UI_CAMERA_DEVICE_<ROLE> |
Per-role camera pinning (e.g. MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0 for the OLED-bearing heltec-v3). |
MESHTASTIC_UI_OCR_BACKEND |
OCR engine selection: easyocr / pytesseract / null / auto (default). |
MESHTASTIC_UI_TUI_CAMERA |
Set to 1 to mount the live camera-feed panel in meshtastic-mcp-test-tui. |