* docs(agents): exempt test headers from the two-line comment limit The one-or-two-line comment rule rests on "the diff and commit message carry the rationale". For a test that premise is false. A test outlives the PR that added it, and the next person to read it is reading it because it failed - months later, in someone else's change, with the original discussion out of reach. That reader has one decision to make: real regression, or an expectation that has gone stale? The assertions alone cannot answer it, so the justification has to live in the file. The new "Test comments" section requires three things of a test header - what is under test by symbol and file, why that behavior is required, and the specific regression that returns if the assertions are deleted or relaxed - and grants whatever length they need. Authoritative statement lives in that section of .github/copilot-instructions.md, anchored at #test-comments. AGENTS.md and CLAUDE.md link to it rather than restate it. The test/** entry in .coderabbit.yaml carries the one permitted copy, since a YAML instruction cannot follow a link. This documents existing practice rather than changing it: 57 of the 72 suites with a test_main.cpp already open with three or more comment lines, test/test_gps_fix_hold/test_main.cpp with eleven. The rule as written forbade all of them, and automated reviewers acted on it. Scoped to the single comment bullet, not to the whole "General Style" section. Unlike "Naming Conventions", that section also holds the logging tiers and the Throttle rule, which bind test code as hard as src/; a blanket preamble would have quietly exempted tests from millis() discipline as well. The exception is bounded. The CodeRabbit entry still flags narrative that carries no contract - debugging journey, changelog prose, restating what the assertions plainly do - and per-case comments that merely repeat the test name. The documentation-does-not-live-here rule is untouched. * docs(agents): cut the test-comment rule to its essential statements A section about comment length had no business running to 373 words. Canonical section down to 194: dropped the elaboration of why the commit message is unavailable, the aside about which bullet reviewers most often cut, the "not licence for narrative" preamble, and the closing flourish. What remains is what an agent has to act on - the premise that fails for tests, the three things a header states, the instruction to reject a shorten-this review comment, and the worked example. Pointer files carry scope, not argument. The AGENTS.md bullet is one sentence and a link, matching the Test naming pointer directly above it. The .coderabbit.yaml copy keeps its imperatives, since a YAML instruction cannot follow the link to find them, but loses the causal explanation it did not need in order to act.
26 KiB
Agent instructions
TL;DR
Local tests ./bin/run-tests.sh(exit 0 GREEN · 1 RED · 2 AMBER · 3 FILTERED)Hardware 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 byconfig.security.admin_key[0..2]. Disabled entirely in Ham mode (user.is_licensed=true).
Key rotation to never trigger casually: only the full factory reset (factory_reset_device, eraseBleBonds=true) wipes security.private_key and regenerates the keypair - every peer holds the old public key, so DMs silently fail PKI decrypt until NodeInfo re-exchanges. The partial config reset (factory_reset_config) preserves the private key and doesn't invalidate peer relationships. Explicitly blanking security.private_key via admin also triggers regen. See the Encryption & Key Management section of .github/copilot-instructions.md for the full spec (nonce layout, send/receive selection logic including infrastructure-portnum exceptions, admin-key + session-passkey authorization, is_managed scope, key-rotation hazards).
House rules
-
No destructive device operations without operator approval.
factory_reset,erase_and_flash,reboot,shutdown, history-rewriting git ops - describe the action and stop. Operator authorizes. -
One MCP call per serial port at a time. The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device.
-
userPrefs.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. |