Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
38 KiB
Meshtastic Firmware - Copilot Instructions
This document provides context and guidelines for AI assistants working with the Meshtastic firmware codebase.
Project Overview
Meshtastic is an open-source LoRa mesh networking project for long-range, low-power communication without relying on internet or cellular infrastructure. The firmware enables text messaging, location sharing, and telemetry over a decentralized mesh network. The project uses C++17 as its language standard across all platforms.
Supported Hardware Platforms
- ESP32 (ESP32, ESP32-S3, ESP32-C3, ESP32-C6) - Most common platform
- nRF52 (nRF52840, nRF52833) - Low power Nordic chips
- RP2040/RP2350 - Raspberry Pi Pico variants
- STM32WL - STM32 with integrated LoRa
- Linux/Portduino - Native Linux builds (Raspberry Pi, etc.)
Supported Radio Chips
- SX1262/SX1268 - Sub-GHz LoRa (868/915 MHz regions)
- SX1280 - 2.4 GHz LoRa
- LR1110/LR1120/LR1121 - Wideband radios (sub-GHz and 2.4 GHz capable, but not simultaneously)
- RF95 - Legacy RFM95 modules
- LLCC68 - Low-cost LoRa
MQTT Integration
MQTT provides a bridge between Meshtastic mesh networks and the internet, enabling nodes with network connectivity to share messages with remote meshes or external services.
Key Components
src/mqtt/MQTT.cpp- Main MQTT client singleton, handles connection and message routingsrc/mqtt/ServiceEnvelope.cpp- Protobuf wrapper for mesh packets sent over MQTTmoduleConfig.mqtt- MQTT module configuration
MQTT Topic Structure
Messages are published/subscribed using a hierarchical topic format:
{root}/{channel_id}/{gateway_id}
root- Configurable prefix (default:msh)channel_id- Channel name/identifiergateway_id- Node ID of the publishing gateway
Configuration Defaults (from Default.h)
#define default_mqtt_address "mqtt.meshtastic.org"
#define default_mqtt_username "meshdev"
#define default_mqtt_password "large4cats"
#define default_mqtt_root "msh"
#define default_mqtt_encryption_enabled true
#define default_mqtt_tls_enabled false
Key Concepts
- Uplink - Mesh packets sent TO the MQTT broker (controlled by
uplink_enabledper channel) - Downlink - MQTT messages received and injected INTO the mesh (controlled by
downlink_enabledper channel) - Encryption - When
encryption_enabledis true, only encrypted packets are sent; plaintext JSON is disabled - ServiceEnvelope - Protobuf wrapper containing packet + channel_id + gateway_id for routing
- JSON Support - Optional JSON encoding for integration with external systems (disabled on nRF52 by default)
PKI Messages
PKI (Public Key Infrastructure) messages have special handling:
- Accepted on a special "PKI" channel
- Allow encrypted DMs between nodes that discovered each other on downlink-enabled channels
Project Structure
firmware/
├── src/ # Main source code
│ ├── main.cpp # Application entry point
│ ├── mesh/ # Core mesh networking
│ │ ├── NodeDB.* # Node database management
│ │ ├── Router.* # Packet routing
│ │ ├── Channels.* # Channel management
│ │ ├── CryptoEngine.* # AES-CCM encryption
│ │ ├── *Interface.* # Radio interface implementations
│ │ ├── api/ # WiFi/Ethernet server APIs (ServerAPI, PacketAPI)
│ │ ├── http/ # HTTP server (WebServer, ContentHandler)
│ │ ├── wifi/ # WiFi support (WiFiAPClient)
│ │ ├── eth/ # Ethernet support (ethClient)
│ │ ├── udp/ # UDP multicast
│ │ ├── compression/ # Message compression (unishox2)
│ │ └── generated/ # Protobuf generated code
│ ├── modules/ # Feature modules (Position, Telemetry, etc.)
│ │ └── Telemetry/ # Telemetry subsystem
│ │ └── Sensor/ # 50+ I2C sensor drivers
│ ├── gps/ # GPS handling
│ ├── graphics/ # Display drivers and UI
│ │ └── niche/ # Specialized UIs (InkHUD e-ink framework)
│ ├── platform/ # Platform-specific code (esp32, nrf52, rp2xx0, stm32wl, portduino)
│ ├── input/ # Input device handling (InputBroker, keyboards, buttons)
│ ├── detect/ # I2C hardware auto-detection (80+ device types)
│ ├── motion/ # Accelerometer drivers (BMA423, BMI270, MPU6050, etc.)
│ ├── mqtt/ # MQTT bridge client
│ ├── power/ # Power HAL
│ ├── nimble/ # BLE via NimBLE
│ ├── buzz/ # Audio/notification (buzzer, RTTTL)
│ ├── serialization/ # JSON serialization, COBS encoding
│ ├── watchdog/ # Hardware watchdog thread
│ ├── concurrency/ # Threading utilities (OSThread, Lock)
│ ├── PowerFSM.* # Power finite state machine
│ └── Observer.h # Observer/Observable event pattern
├── variants/ # Hardware variant definitions
│ ├── esp32/ # ESP32 variants
│ ├── esp32s3/ # ESP32-S3 variants
│ ├── esp32c3/ # ESP32-C3 variants
│ ├── esp32c6/ # ESP32-C6 variants
│ ├── nrf52840/ # nRF52 variants
│ ├── rp2040/ # RP2040/RP2350 variants
│ ├── stm32/ # STM32WL variants
│ └── native/ # Linux/Portduino variants
├── protobufs/ # Protocol buffer definitions
├── boards/ # Custom PlatformIO board definitions
├── test/ # Unit tests (12 test suites)
└── bin/ # Build and utility scripts
Coding Conventions
General Style
- Follow existing code style - run
trunk fmtbefore commits - Prefer
LOG_DEBUG,LOG_INFO,LOG_WARN,LOG_ERRORfor logging - Use
assert()for invariants that should never fail - C++17 features are available (
std::optional, structured bindings,if constexpr, etc.)
Naming Conventions
- Classes:
PascalCase(e.g.,PositionModule,NodeDB) - Functions/Methods:
camelCase(e.g.,sendOurPosition,getNodeNum) - Constants/Defines:
UPPER_SNAKE_CASE(e.g.,MAX_INTERVAL,ONE_DAY) - Member variables:
camelCase(e.g.,lastGpsSend,nodeDB) - Config defines:
USERPREFS_*for user-configurable options
Key Patterns
Module System
Modules use a three-tier class hierarchy:
MeshModule- Base class. ImplementwantPacket()andhandleReceived(). ReturnsProcessMessage::STOPorProcessMessage::CONTINUE.SinglePortModule- Handles a single portnum. SimplifiedwantPacket()that checksdecoded.portnum.ProtobufModule<T>- Template for protobuf-based modules. Handles encoding/decoding automatically.
Most modules also inherit from OSThread for periodic tasks (the "mixin" pattern):
class MyModule : public ProtobufModule<meshtastic_MyMessage>, private concurrency::OSThread
{
public:
MyModule();
protected:
virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MyMessage *msg) override;
virtual meshtastic_MeshPacket *allocReply() override; // Generate response packets
virtual int32_t runOnce() override; // Periodic task (returns next interval in ms)
virtual bool alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic_MyMessage *msg); // Modify in-flight
virtual bool wantUIFrame(); // Request a UI display frame
};
Modules are registered in src/modules/Modules.cpp guarded by MESHTASTIC_EXCLUDE_* flags.
Observer/Observable Pattern
Event-driven communication between subsystems uses src/Observer.h:
// Observable emits events
Observable<const meshtastic::Status *> newStatus;
newStatus.notifyObservers(&status);
// Observer receives events via callback
CallbackObserver<MyClass, const meshtastic::Status *> statusObserver =
CallbackObserver<MyClass, const meshtastic::Status *>(this, &MyClass::handleStatusUpdate);
Configuration Access
config.*- Device configuration (LoRa, position, power, etc.)moduleConfig.*- Module-specific configurationchannels.*- Channel configuration and managementowner- Device owner infomyNodeInfo- Local node info
Default Values
Use the Default class helpers in src/mesh/Default.h:
Default::getConfiguredOrDefaultMs(configured, default)- Returns ms, using default if configured is 0Default::getConfiguredOrDefault(configured, default)- Generic configured/default getterDefault::getConfiguredOrMinimumValue(configured, min)- Enforces minimum valuesDefault::getConfiguredOrDefaultMsScaled(configured, default, numNodes)- Scales based on network size
Thread Safety
- Use
concurrency::Lockandconcurrency::LockGuardfor mutex protection - Radio SPI access uses
SPILock - Prefer
OSThreadfor background tasks
Hardware Detection
src/detect/ScanI2C automatically enumerates 80+ I2C device types at boot including displays, sensors, RTCs, keyboards, PMUs, and touch controllers. This drives automatic initialization of the correct drivers.
Graphics/UI System
Multiple display driver families in src/graphics/:
- OLED: SSD1306, SH1106, ST7567
- TFT: TFTDisplay (LovyanGFX-based)
- E-Ink: EInkDisplay2, EInkDynamicDisplay, EInkParallelDisplay
InkHUD (src/graphics/niche/InkHUD/) is an event-driven e-ink UI framework:
- Applet-based architecture — modular display tiles
- Read-only, static display optimized for minimal refreshes and low power
- Configured per-variant via
nicheGraphics.h - Separate PlatformIO config:
src/graphics/niche/InkHUD/PlatformioConfig.ini
Input System
src/input/InputBroker is the centralized input event dispatcher. Supports multiple input sources: buttons, keyboards (BBQ10, Cardputer, TCA8418), touch screens, rotary encoders, and matrix keyboards.
Power Management
src/PowerFSM.* implements a finite state machine with states: stateON, statePOWER, stateSERIAL, stateDARK. Key events: EVENT_PRESS, EVENT_WAKE_TIMER, EVENT_LOW_BATTERY, EVENT_RECEIVED_MSG, EVENT_SHUTDOWN. Conditionally excluded with MESHTASTIC_EXCLUDE_POWER_FSM (falls back to FakeFsm).
Motion Sensors
src/motion/AccelerometerThread provides background motion monitoring with automatic screen wake and double-tap button press detection. Supports 10+ accelerometer/gyroscope chips (BMA423, BMI270, MPU6050, LIS3DH, LSM6DS3, STK8XXX, QMA6100P, ICM20948, BMX160).
Telemetry Sensor Library
src/modules/Telemetry/Sensor/ contains 50+ I2C sensor drivers organized by category:
- Power monitoring: INA219/226/260/3221, MAX17048
- Environmental: BME280/680, SCD4X (CO₂), SEN5X (particulate)
- Humidity/Temperature: SHT3X/4X, AHT10, MCP9808, MLX90614
- Light: BH1750, TSL2561/2591, VEML7700, LTR390UV, OPT3001
- Air quality: PMSA003I, SFA30
- Specialized: CGRadSens (radiation), NAU7802 (weight scale)
API/Networking
src/mesh/api/ provides a template-based ServerAPI for client communication over WiFi (WiFiServerAPI) and Ethernet (ethServerAPI). Default port: 4403. HTTP server in src/mesh/http/. JSON serialization in src/serialization/MeshPacketSerializer.
Hardware Variants
Each hardware variant has:
variant.h- Pin definitions and hardware capabilitiesplatformio.ini- Build configuration- Optional:
pins_arduino.h,rfswitch.h,nicheGraphics.h(for InkHUD variants)
Key defines in variant.h:
#define USE_SX1262 // Radio chip selection
#define HAS_GPS 1 // Hardware capabilities
#define HAS_SCREEN 1 // Display present
#define LORA_CS 36 // Pin assignments
#define SX126X_DIO1 14 // Radio-specific pins
Protobuf Messages
- Defined in
protobufs/meshtastic/*.proto(~32 proto files) - Generated code in
src/mesh/generated/meshtastic/ - Regenerate with
bin/regen-protos.sh - Message types prefixed with
meshtastic_ - Nanopb
.optionsfiles control field sizes and encoding
Conditional Compilation
#if !MESHTASTIC_EXCLUDE_GPS // Feature exclusion
#if !MESHTASTIC_EXCLUDE_WIFI // Network feature exclusion
#if !MESHTASTIC_EXCLUDE_BLUETOOTH // BLE exclusion
#if !MESHTASTIC_EXCLUDE_POWER_FSM // Power FSM exclusion
#ifdef ARCH_ESP32 // Architecture-specific
#ifdef ARCH_NRF52 // Nordic platform
#ifdef ARCH_RP2040 // Raspberry Pi Pico
#ifdef ARCH_PORTDUINO // Linux native
#if defined(USE_SX1262) // Radio-specific
#ifdef HAS_SCREEN // Hardware capability
#if USERPREFS_EVENT_MODE // User preferences
Build System
Uses PlatformIO with custom scripts:
bin/platformio-pre.py- Pre-build scriptbin/platformio-custom.py- Custom build logic, manifest generation
Build commands:
pio run -e tbeam # Build specific target
pio run -e tbeam -t upload # Build and upload
pio run -e native # Build native/Linux version
Build Manifest
bin/platformio-custom.py emits a build manifest with metadata:
hasMui,hasInkHud- UI capability flags (overridable viacustom_meshtastic_has_mui,custom_meshtastic_has_ink_hud)- Architecture normalization (e.g.,
esp32s3→esp32-s3for API compatibility)
Common Tasks
Adding a New Module
- Create
src/modules/MyModule.cppand.h - Inherit from appropriate base class (
MeshModule,SinglePortModule, orProtobufModule<T>) - Mix in
concurrency::OSThreadif periodic work is needed - Register in
src/modules/Modules.cppguarded by#if !MESHTASTIC_EXCLUDE_MYMODULE - Add protobuf messages if needed in
protobufs/meshtastic/ - Add test suite in
test/test_mymodule/if applicable
Adding a New Hardware Variant
- Create directory under
variants/<arch>/<name>/ - Add
variant.hwith pin definitions and hardware capability defines - Add
platformio.iniwith build config — useextendsto reference common base (e.g.,esp32s3_base) - Set
custom_meshtastic_support_level = 1(PR builds) or2(merge builds) - For e-ink displays, add
nicheGraphics.hfor InkHUD configuration
Adding a New Telemetry Sensor
- Create driver in
src/modules/Telemetry/Sensor/following existing sensor pattern - Register I2C address in
src/detect/ScanI2Cfor auto-detection - Integrate with the appropriate telemetry module (Environment, Health, Power, AirQuality)
- Add proto fields in
protobufs/meshtastic/telemetry.protoif new data types are needed
Modifying Configuration Defaults
- Check
src/mesh/Default.hfor default value defines - Check
src/mesh/NodeDB.cppfor initialization logic - Consider
isDefaultChannel()checks for public channel restrictions
Important Considerations
Traffic Management
The mesh network has limited bandwidth. When modifying broadcast intervals:
- Respect minimum intervals on default/public channels
- Use
Default::getConfiguredOrMinimumValue()to enforce minimums - Consider
numOnlineNodesscaling for congestion control
Power Management
Many devices are battery-powered:
- Use
IF_ROUTER(routerVal, normalVal)for role-based defaults - Check
config.power.is_power_savingfor power-saving modes - Implement proper
sleep()methods in radio interfaces
Channel Security
channels.isDefaultChannel(index)- Check if using default/public settings- Default channels get stricter rate limits to prevent abuse
- Private channels may have relaxed limits
GitHub Actions CI/CD
The project uses GitHub Actions extensively for CI/CD. Key workflows are in .github/workflows/:
Core CI Workflows
-
main_matrix.yml- Main CI pipeline, runs on push tomaster/developand PRs- Uses
bin/generate_ci_matrix.pyto dynamically generate build targets - Builds all supported hardware variants
- PRs build a subset (
--level pr) for faster feedback
- Uses
-
trunk_check.yml- Code quality checks on PRs- Runs Trunk.io for linting and formatting
- Must pass before merge
-
tests.yml- End-to-end and hardware tests- Runs daily on schedule
- Includes native tests and hardware-in-the-loop testing
-
test_native.yml- Native platform unit tests- Runs
pio test -e native
- Runs
Release Workflows
-
release_channels.yml- Triggered on GitHub release publish- Builds Docker images
- Packages for PPA (Ubuntu), OBS (openSUSE), and COPR (Fedora)
- Handles Alpha/Beta/Stable release channels
-
nightly.yml- Nightly builds from develop branch -
docker_build.yml/docker_manifest.yml- Docker image builds
Build Matrix Generation
The CI uses bin/generate_ci_matrix.py to dynamically select which targets to build:
# Generate full build matrix
./bin/generate_ci_matrix.py all
# Generate PR-level matrix (subset for faster builds)
./bin/generate_ci_matrix.py all --level pr
Variants can specify their support level in platformio.ini:
custom_meshtastic_support_level = 1- Actively supported, built on every PRcustom_meshtastic_support_level = 2- Supported, built on merge to main branchesboard_level = extra- Extra builds, only on full releases
Running Workflows Locally
Most workflows can be triggered manually via workflow_dispatch for testing.
Testing
Native unit tests (C++)
Unit tests in test/ directory with 12 test suites:
test_crypto/- Cryptographytest_mqtt/- MQTT integrationtest_radio/- Radio interfacetest_mesh_module/- Module frameworktest_meshpacket_serializer/- Packet serializationtest_transmit_history/- Retransmission trackingtest_atak/- ATAK integrationtest_default/- Default configurationtest_http_content_handler/- HTTP handlingtest_serial/- Serial communication
Run with: pio test -e native
Simulation testing: bin/test-simulator.sh
Hardware-in-the-loop tests (mcp-server/tests/)
Separate pytest suite that exercises real USB-connected Meshtastic devices. See the MCP Server & Hardware Test Harness section below for invocation, tier layout, and agent usage rules.
MCP Server & Hardware Test Harness
The mcp-server/ directory houses a firmware-aware MCP server plus a pytest-based integration suite. AI agents that speak MCP get a well-defined tool surface for flashing, configuring, and inspecting physical Meshtastic devices — use it instead of hand-rolling pio or meshtastic --port calls where possible. mcp-server/README.md is the operator-facing setup doc; this section is the agent-facing usage contract.
The repo registers the server via .mcp.json at the repo root — Claude Code picks it up automatically once mcp-server/.venv/ is built (cd mcp-server && python3 -m venv .venv && .venv/bin/pip install -e '.[test]').
When to use which surface
| Goal | Tool |
|---|---|
| Find a connected device | mcp__meshtastic__list_devices |
| Read a live node's config/state | mcp__meshtastic__device_info, list_nodes, get_config |
| Mutate a device (owner, region, channels, reboot) | set_owner, set_config, set_channel_url, reboot, shutdown, factory_reset — all require confirm=True |
| Flash firmware to a variant | pio_flash (any arch) or erase_and_flash (ESP32 factory install) |
| Stream serial logs while debugging | serial_open → serial_read loop → serial_close |
Administer userPrefs.jsonc build-time constants |
userprefs_get, userprefs_set, userprefs_reset, userprefs_manifest |
| Run the regression suite | ./mcp-server/run-tests.sh (or /test slash command) |
| Diagnose a specific device | /diagnose [role] slash command (read-only) |
| Triage a flaky test | /repro <node-id> [count] slash command |
One MCP call per port at a time. SerialInterface holds an exclusive OS-level lock on the serial port for its lifetime. If a serial_* session is open on /dev/cu.usbmodem101, calling device_info on the same port will fail fast pointing at the active session. Sequence calls: open → read/mutate → close, then next device. Never parallelize tool calls on the same port.
MCP tool surface (43 tools)
Grouped by purpose. Full argument shapes in mcp-server/README.md; a few high-value signatures are called out here.
- Discovery & metadata:
list_devices,list_boards,get_board - Build & flash:
build,clean,pio_flash,erase_and_flash(ESP32 only),update_flash(ESP32 OTA),touch_1200bps - Serial sessions (long-running, 10k-line ring buffer):
serial_open,serial_read,serial_list,serial_close - Device reads:
device_info,list_nodes - Device writes:
set_owner,get_config,set_config,get_channel_url,set_channel_url,send_text,send_input_event(inject a button/key press via the firmware's InputBroker),set_debug_log_api; destructive/power-state writes requireconfirm=True:reboot,shutdown,factory_reset - userPrefs admin (build-time constants, not runtime config):
userprefs_get,userprefs_set,userprefs_reset,userprefs_manifest,userprefs_testing_profile - Vendor escape hatches:
esptool_chip_info,esptool_erase_flash,esptool_raw,nrfutil_dfu,nrfutil_raw,picotool_info,picotool_load,picotool_raw - USB power control (via
uhubctl, per-port PPPS toggle):uhubctl_list(read-only),uhubctl_power(action='on'|'off', confirm=True),uhubctl_cycle(delay_s, confirm=True). Target by raw(location, port)or byrole("nrf52","esp32s3"); role lookup checksMESHTASTIC_UHUBCTL_LOCATION_<ROLE>+_PORT_<ROLE>env vars first, falls back to VID auto-detection. - Observability (UI tier + operator ad-hoc):
capture_screen(role, ocr=True)— grabs a USB-webcam frame of the device OLED and optionally OCRs it. Requiresmcp-server[ui]extras (opencv-python-headless,easyocr) andMESHTASTIC_UI_CAMERA_DEVICE_<ROLE>env var; falls through to a 1×1 black PNGNullBackendwhen unconfigured.
confirm=True is a tool-level gate on top of whatever permission prompt your MCP host shows. Don't bypass it by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for factory_reset, erase_and_flash, uhubctl_power(action='off'), and uhubctl_cycle.
Hardware test suite (mcp-server/run-tests.sh)
The wrapper auto-detects connected devices (VID → role map: 0x239A → nrf52, 0x303A/0x10C4 → esp32s3), maps each role to a PlatformIO env (nrf52 → rak4631, esp32s3 → heltec-v3, overridable via MESHTASTIC_MCP_ENV_<ROLE>), then invokes pytest. Zero pre-flight config needed from the operator.
Suite tiers (collected + run in this order via pytest_collection_modifyitems):
tests/unit/— pure Python (boards parse, pio wrapper, userPrefs parse, testing profile, uhubctl parser). No hardware.tests/test_00_bake.py— flashes each detected device with currentuserPrefs.jsoncmerged 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.tests/mesh/— multi-device mesh: bidirectional send, broadcast delivery, direct-with-ACK, mesh formation within 60s. Parametrized[nrf52->esp32s3]and[esp32s3->nrf52]. Includestest_peer_offline_recoverywhich uses uhubctl to physically power off one peer mid-conversation (requires uhubctl; skips without).tests/telemetry/—DEVICE_METRICS_APPbroadcast timing.tests/monitor/— boot-log panic check.tests/recovery/—uhubctlpower-cycle round-trip + NVS persistence across hard reset. Requiresuhubctlinstalled and a PPPS-capable hub; entire tier auto-skips otherwise.tests/ui/— input-broker-driven screen navigation with camera + OCR evidence.tests/fleet/— PSK seed session isolation.tests/admin/— channel URL roundtrip, owner persistence across reboot.tests/provisioning/— region + modem + slot bake, admin key presence,UNSETregion blocks TX, userPrefs survive factory reset.
Invocation patterns:
./mcp-server/run-tests.sh # full suite (auto-bake-if-needed)
./mcp-server/run-tests.sh --force-bake # reflash before testing
./mcp-server/run-tests.sh --assume-baked # skip bake (caller vouches for device state)
./mcp-server/run-tests.sh tests/mesh # one tier
./mcp-server/run-tests.sh tests/mesh/test_direct_with_ack.py # one file
./mcp-server/run-tests.sh -k telemetry # name filter
No hardware detected? The wrapper auto-narrows to tests/unit/ only and prints detected hub : (none) in the pre-flight header. Agents interpreting the output should call this out explicitly — a 52-test green run without hardware is qualitatively different from a 12-unit-test green run.
Artifacts every run produces:
mcp-server/tests/report.html— self-contained pytest-html. Each test gets aMeshtastic debugsection 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_typekeyed JSONL). Consumed by the live TUI.mcp-server/tests/fwlog.jsonl— firmware log mirror from themeshtastic.log.linepubsub topic. Populated by the_firmware_log_streamautouse session fixture.
Live TUI (meshtastic-mcp-test-tui)
A Textual-based live view that wraps run-tests.sh. Tails reportlog for per-test state, streams firmware logs, polls device state at startup + post-run (gated out of the active run because hub_devices holds exclusive port locks). Key bindings:
| Key | Action |
|---|---|
r |
re-run focused test (leaf → that node id; internal node → directory or -k) |
f |
filter tree by substring |
d |
failure detail modal (pulls longrepr + captured stdout from the reportlog) |
g |
export reproducer bundle (tar.gz with README, test_report.json, time-filtered fwlog, devices.json, env.json) |
l |
toggle firmware log pane |
x |
tool coverage modal |
c |
cross-run history sparkline |
q |
quit (SIGINT → SIGTERM → SIGKILL escalation, 5-s windows each) |
Launch:
cd mcp-server
.venv/bin/meshtastic-mcp-test-tui # full suite
.venv/bin/meshtastic-mcp-test-tui tests/mesh # args pass through to pytest
The plain CLI stays primary; the TUI is for operators who want a live dashboard. Both consume the same run-tests.sh.
Slash commands (Claude Code + Copilot)
Three AI-assisted workflows wrap the test harness. Claude Code operators get /test, /diagnose, /repro; Copilot operators get /mcp-test, /mcp-diagnose, /mcp-repro. Bodies:
.claude/commands/{test,diagnose,repro}.md.github/prompts/mcp-{test,diagnose,repro}.prompt.md
.claude/commands/README.md is the index.
House rules for agents running these prompts:
- Interpret failures, don't just echo them. Pull firmware log tails from
report.htmland classify each failure as transient / environmental / regression. Use the exact format in.claude/commands/test.md. - No destructive writes without operator approval. Any skill that could reflash, factory-reset, or reboot a device must describe the action and stop. The operator authorizes.
- Sequential MCP calls per port. See above.
- "Unknown" is a valid classification. If evidence doesn't support a root cause, say so and list what would disambiguate. Do not invent.
Key fixtures (test authors + agents debugging)
mcp-server/tests/conftest.py provides:
_session_userprefs(autouse session) — snapshotsuserPrefs.jsoncat session start, merges the session test profile viauserprefs.merge_active(test_profile), restores at teardown. Four layers of safety: pytest teardown +atexit+ sidecar file (userPrefs.jsonc.mcp-session-bak) + startup self-heal inrun-tests.sh. Do not edituserPrefs.jsoncfrom inside a test._firmware_log_stream(autouse session) — subscribes tomeshtastic.log.linepubsub on every connectedSerialInterfaceand mirrors lines totests/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-htmlMeshtastic debugsection 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 ontest_00_bake.pytest_generate_testsinconftest.pyauto-generates[nrf52->esp32s3]and[esp32s3->nrf52]variants.test_profile— session-scoped dict: region, primary channel, admin key, PSK seed. Derived fromMESHTASTIC_MCP_SEED(defaults tomcp-<user>-<host>).
Firmware integration points tied to the test harness
Two firmware changes exist specifically so the test harness works reliably. Keep these in mind when touching related code.
src/mesh/StreamAPI.cpp+StreamAPI.h—emitLogRecorduses a dedicatedfromRadioScratchLog+txBufLogpair and aconcurrency::Lock streamLock. Before this fix,debug_log_api_enabled=truewould tearFromRadioprotobufs on the serial transport becauseemitTxBufferandemitLogRecordshared 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—ToRadioHeartbeat(nonce=1)triggersnodeInfoModule->sendOurNodeInfo(NODENUM_BROADCAST, true, 0, true)for serial clients, mirroring the pre-existing behavior for TCP/UDP clients inPacketAPI.cpp. The mesh tests rely on this to force a NodeInfo broadcast right after connect so the peer discovers them before the test's first assertion.
If you're modifying StreamAPI, PhoneAPI, NodeInfoModule, or userPrefs flow, run ./mcp-server/run-tests.sh at minimum before asking for review.
Recovery playbooks
| Symptom | First check | Fix |
|---|---|---|
userPrefs.jsonc dirty after test run |
git status --porcelain userPrefs.jsonc |
If non-empty, re-run ./mcp-server/run-tests.sh once — the pre-flight self-heal restores from sidecar. If still dirty, git checkout userPrefs.jsonc. |
| Port busy / wedged CP2102 on macOS | lsof /dev/cu.usbserial-0001 |
Kill the holder. USB replug if the kernel still reports busy. Often a stale pio device monitor or zombie meshtastic_mcp process. |
| nRF52 appears unresponsive | list_devices shows VID 0x239A but device_info times out |
touch_1200bps(port=...) drops it into the DFU bootloader → pio_flash re-installs. |
| Device fully wedged (Guru Meditation, frozen CDC, no DFU) | list_devices shows the VID but every admin call times out |
uhubctl_cycle(role="nrf52", confirm=True) hard-power-cycles the port via USB hub PPPS. baked_single's auto-recovery hook does this once automatically if uhubctl is installed. Falls back to physical replug if no PPPS hub. |
| Multiple MCP server processes | ps aux | grep meshtastic_mcp shows >1 |
Kill all but the one your MCP host spawned. Zombies hold ports and break tests. |
| Mesh formation fails, one side sees peer but other doesn't | /diagnose (or list_nodes on both sides) |
Asymmetric NodeInfo. test_direct_with_ack has a heal path; /repro it a few times. If persistent, both devices' clocks may be out of sync with their NodeInfo cooldown. |
| "role not present on hub" in skip reasons | list_devices |
Expected if a device is unplugged. Reconnect before re-running the tier. |
Entire tests/recovery/ tier skipped |
command -v uhubctl |
Expected if uhubctl isn't on PATH. Install via brew install uhubctl (macOS) or apt install uhubctl (Debian/Ubuntu). Also skips if no hub advertises PPPS. |
Entire tests/ui/ tier skipped ("firmware not baked with USERPREFS_UI_TEST_LOG") |
reportlog.jsonl for the skip reason | Re-run with --force-bake so the UI-log macro gets compiled into the fresh firmware. First run after the Round-3 landing always re-bakes. |
tests/ui/ runs but captures are all 1×1 black PNGs |
MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3 |
Env var not set → NullBackend. Point a USB webcam at the heltec-v3 OLED and set the device index; .venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]" discovers it. |
| Tests fail only on first attempt then pass on rerun | — | State leak from a prior session. Run with --force-bake to reset to a known state. |
Never do these without asking
factory_reset— wipes node identity; regenerates PKI keypair. Mesh peers will reject old DMs until re-exchange. Legitimate only when the operator explicitly wants it.erase_and_flash— full chip erase; destroys all on-device state.esptool_erase_flash/esptool_rawwrite/erase — bypasses pio's safety chain.set_configonlora.region— changes regulatory domain; requires physical-location context the operator has and the agent doesn't.reboot/shutdownmid-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.