mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-04 03:18:10 +10:00
Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3bdb5830b | ||
|
|
f9f450977b | ||
|
|
f74d3e4816 | ||
|
|
55954c20b7 | ||
|
|
55a580e19e | ||
|
|
6b15571e14 | ||
|
|
8fd0a7f283 | ||
|
|
e589de2d6e | ||
|
|
a277108c84 | ||
|
|
9e26cc3795 | ||
|
|
84bb909437 | ||
|
|
2beebea453 | ||
|
|
d0cd8ec366 | ||
|
|
d31d0f85fe | ||
|
|
2c8dec2fbd | ||
|
|
c48b5ab556 | ||
|
|
8e55a6e4c1 | ||
|
|
f14ef121ef | ||
|
|
92263859a7 | ||
|
|
23f92c1cbd | ||
|
|
79e7ed30f1 | ||
|
|
d5af07e458 | ||
|
|
7d957f8c7b | ||
|
|
5cae9e0183 | ||
|
|
edf660ccb3 | ||
|
|
e7b66281fa | ||
|
|
c1bee82baf | ||
|
|
47e129f4bd | ||
|
|
96dd647882 | ||
|
|
e42ff3590c | ||
|
|
7527233130 | ||
|
|
197226365b | ||
|
|
eeb95d6bff | ||
|
|
323830c7cc | ||
|
|
a9acd506a8 | ||
|
|
a1d6c6db62 | ||
|
|
0f2d224e74 | ||
|
|
381cefa6b2 | ||
|
|
a50cbdc95b | ||
|
|
e7c7af40ae | ||
|
|
48ae4b6c7a | ||
|
|
ae5019bec6 | ||
|
|
3cd3fd3386 | ||
|
|
839cf554b7 | ||
|
|
216655f05f | ||
|
|
2c633b6458 | ||
|
|
7fdee353b5 | ||
|
|
16cf962351 | ||
|
|
12f0a74557 | ||
|
|
2dd9c5eef2 | ||
|
|
98963218ad | ||
|
|
6628c9e66e | ||
|
|
d96770007d | ||
|
|
1370b234eb | ||
|
|
9ba44bfbfb | ||
|
|
934c3fa8be | ||
|
|
7f0cd70c07 | ||
|
|
d5fe7dc9e6 | ||
|
|
98efb25a0c | ||
|
|
8dc3c0541b | ||
|
|
fc6c89abeb | ||
|
|
c7feef00c0 | ||
|
|
8c905427a1 | ||
|
|
90befeeeb1 | ||
|
|
9e61c44629 | ||
|
|
3ad80b80ee | ||
|
|
8fe12e9fda | ||
|
|
4e05f20c58 | ||
|
|
33d526f6f9 | ||
|
|
0abd3cdde8 | ||
|
|
870ba80092 | ||
|
|
2955c12d2a | ||
|
|
198339873a | ||
|
|
29d70853e6 | ||
|
|
976d201fc1 | ||
|
|
80763ca235 | ||
|
|
844f5b46d0 | ||
|
|
aec98b61b9 | ||
|
|
99abfebc4a | ||
|
|
068f5af4d8 | ||
|
|
993c242202 | ||
|
|
3d4f0b895b | ||
|
|
3b29eea577 | ||
|
|
e7e34e86d5 | ||
|
|
5a009889cc | ||
|
|
c4bac04be3 | ||
|
|
b9bdc87367 | ||
|
|
3b079c91bf | ||
|
|
d693fd4232 | ||
|
|
163c54877c | ||
|
|
2e2993f180 | ||
|
|
450f2adab0 | ||
|
|
0ad1b66387 | ||
|
|
5716aeba3b | ||
|
|
723209b198 | ||
|
|
5a8ce60d68 | ||
|
|
c77b10a317 | ||
|
|
8fa5b4f7ce | ||
|
|
e30294b6da | ||
|
|
948c28afff | ||
|
|
15b474172a |
@@ -0,0 +1,49 @@
|
||||
# Claude Code slash commands for the mcp-server test suite
|
||||
|
||||
Three AI-assisted workflows wrapping `mcp-server/run-tests.sh` and the meshtastic MCP tools. Each one has a twin in `.github/prompts/` for Copilot users.
|
||||
|
||||
| Slash command | What it does | Copilot equivalent |
|
||||
| --------------------- | ------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `/test [args]` | Runs the test suite (auto-detects hardware) and interprets failures | `.github/prompts/mcp-test.prompt.md` |
|
||||
| `/diagnose [role]` | Read-only device health report via the meshtastic MCP tools | `.github/prompts/mcp-diagnose.prompt.md` |
|
||||
| `/repro <test> [n=5]` | Re-runs one test N times, diffs firmware logs between passes and failures | `.github/prompts/mcp-repro.prompt.md` |
|
||||
|
||||
## Why two surfaces
|
||||
|
||||
The Claude Code commands and Copilot prompts cover the same three workflows but each speaks its host's idiom:
|
||||
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
## Adding a new command
|
||||
|
||||
1. Write the Claude Code version at `.claude/commands/<name>.md` with YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
description: one-line purpose (used for auto-invocation by the model)
|
||||
argument-hint: [optional-hint]
|
||||
---
|
||||
```
|
||||
|
||||
2. Write the Copilot equivalent at `.github/prompts/mcp-<name>.prompt.md` with:
|
||||
|
||||
```yaml
|
||||
---
|
||||
mode: agent
|
||||
description: ...
|
||||
---
|
||||
```
|
||||
|
||||
3. Add the row to the table above. Cross-link in both bodies.
|
||||
|
||||
4. Smoke-test on Claude Code first (`/<name>` should appear in autocomplete), then in VS Code Copilot (`/mcp-<name>` in Chat).
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
description: Produce a device health report using the meshtastic MCP tools (device_info, list_nodes, get_config, short serial log capture)
|
||||
argument-hint: [role=all|nrf52|esp32s3|<port>]
|
||||
---
|
||||
|
||||
# `/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.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Enumerate hardware.** Call `mcp__meshtastic__list_devices(include_unknown=True)`. For each entry where `likely_meshtastic=True`, capture `port`, `vid`, `pid`, `description`.
|
||||
|
||||
2. **Filter by `$ARGUMENTS`**:
|
||||
- No args, `all` → every likely-meshtastic device.
|
||||
- `nrf52` → only devices with `vid == 0x239a`.
|
||||
- `esp32s3` → only devices with `vid == 0x303a` or `vid == 0x10c4`.
|
||||
- A `/dev/cu.*` path → only that one port.
|
||||
- Anything else → treat as a substring match against the `port` string.
|
||||
|
||||
3. **For each selected device, in sequence (NOT parallel — SerialInterface holds an exclusive port lock):**
|
||||
- `mcp__meshtastic__device_info(port=<p>)` — captures `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel`.
|
||||
- `mcp__meshtastic__list_nodes(port=<p>)` — count of peers, which ones have `publicKey` set, SNR/RSSI distribution.
|
||||
- `mcp__meshtastic__get_config(section="lora", port=<p>)` — region, preset, channel_num, tx_power, hop_limit.
|
||||
- Optionally, if the device seems unhappy (fails to connect, `num_nodes==1` when ≥2 are plugged in, missing firmware*version), open a short firmware log window: `mcp__meshtastic__serial_open(port=<p>, env=<inferred-env>)`, wait 3s, `serial_read(session_id=<s>, max_lines=100)`, `serial_close(session_id=<s>)`. The env should be inferred from the VID map in `mcp-server/run-tests.sh` (nrf52 → rak4631, esp32s3 → heltec-v3) unless `MESHTASTIC_MCP_ENV*<ROLE>` is set.
|
||||
|
||||
4. **Hub health** (call once, not per-device): `mcp__meshtastic__uhubctl_list()` — enumerates every USB hub the host can see. Note which hubs advertise `ppps=true` and which hub hosts each Meshtastic device (cross-reference by VID). Flag it in the report if:
|
||||
- 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.
|
||||
|
||||
5. **Render per-device report** as:
|
||||
|
||||
```text
|
||||
[nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631
|
||||
owner : Meshtastic 40eb / 40eb
|
||||
region/band : US, channel 88, LONG_FAST
|
||||
tx_power : 30 dBm, hop_limit=3
|
||||
peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm)
|
||||
primary ch : McpTest
|
||||
hub : 1-1.3 port 2 (PPPS, uhubctl-controllable)
|
||||
firmware : no panics in last 3s; NodeInfoModule emitted 2 broadcasts
|
||||
```
|
||||
|
||||
Keep it scannable. If a field is missing or abnormal (no pubkey for a known peer, region=UNSET, num_nodes inconsistent with the hub, device on non-PPPS hub), flag it inline with a short `⚠︎ <one-line reason>`.
|
||||
|
||||
6. **Cross-device correlation** (only when >1 device is inspected):
|
||||
- Do both sides see each other in `nodesByNum`? If one does and the other doesn't, that's asymmetric NodeInfo — flag it.
|
||||
- Do 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. **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."
|
||||
- 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`.
|
||||
|
||||
## 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 `flash` / `erase_and_flash`. Those are separate escalations.
|
||||
- No holding SerialInterface across tool calls — open, query, close; next device. The port lock is exclusive.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
description: Re-run a specific test N times in isolation to triage flakes, diff firmware logs between passes and failures
|
||||
argument-hint: <test-node-id> [count=5]
|
||||
---
|
||||
|
||||
# `/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."
|
||||
|
||||
## What to do
|
||||
|
||||
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.
|
||||
|
||||
3. **Loop N times**. For each iteration:
|
||||
|
||||
```bash
|
||||
./mcp-server/run-tests.sh <test-id> --tb=short -p no:cacheprovider
|
||||
```
|
||||
|
||||
Capture: exit code, duration, and (on failure) the `Meshtastic debug` firmware log section from `mcp-server/tests/report.html`. `-p no:cacheprovider` suppresses pytest's `.pytest_cache` writes so iterations don't influence each other.
|
||||
|
||||
4. **Track a small structured tally**:
|
||||
|
||||
```text
|
||||
attempt 1: PASS (42s)
|
||||
attempt 2: FAIL (128s) ← firmware log 200-line tail captured
|
||||
attempt 3: PASS (39s)
|
||||
attempt 4: FAIL (121s)
|
||||
attempt 5: PASS (41s)
|
||||
--------------------------------------
|
||||
pass rate: 3/5 (60%) | mean duration: 74s
|
||||
```
|
||||
|
||||
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?
|
||||
- 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.
|
||||
|
||||
6. **Classify the flake** into one of:
|
||||
- **LoRa airtime collision** → pass rate improves with fewer concurrent transmitters; propose a `time.sleep` gap or retry bump in the test body.
|
||||
- **PKI key staleness** → fails on first attempt, passes after self-heal; existing retry loop in `test_direct_with_ack.py` handles this.
|
||||
- **NodeInfo cooldown** → `Skip send NodeInfo since we sent it <600s ago` in fail-only logs; needs `broadcast_nodeinfo_ping()` warmup.
|
||||
- **Hardware-specific** (one direction fails, other passes; one device's firmware is older; driver wedged) → specific recovery pointer. For a device that's wedged past `touch_1200bps`, the next escalation is `uhubctl_cycle(role=..., confirm=True)` to hard-power-cycle its hub port (requires `uhubctl` installed).
|
||||
- **Device went dark mid-run** → fails from some attempt onward, never recovers, firmware log stops arriving. Almost always hardware: a Guru crash + frozen CDC. Hard-power-cycle via `uhubctl_cycle(role=..., confirm=True)` before the next iteration; if that also fails, escalate to replug.
|
||||
- **Genuinely unknown** → say so; don't invent a root cause.
|
||||
|
||||
7. **Report back** with:
|
||||
- Pass rate and mean duration.
|
||||
- Classification + evidence (the specific log lines that support it).
|
||||
- A suggested next step (re-run with specific args, open `/diagnose`, edit a specific test file, nothing).
|
||||
|
||||
## 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.
|
||||
|
||||
## Constraints
|
||||
|
||||
- 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.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
description: Run the mcp-server test suite (auto-detects devices) and interpret the results
|
||||
argument-hint: [pytest-args]
|
||||
---
|
||||
|
||||
# `/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.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Invoke the wrapper.** From the firmware repo root, run:
|
||||
|
||||
```bash
|
||||
./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`.
|
||||
|
||||
2. **Read the pre-flight header.** First ~6 lines print the detected hub (role → port → env). If that line reads `detected hub : (none)`, the wrapper will narrow to `tests/unit` only — say so explicitly in your summary so the operator knows hardware tiers were skipped.
|
||||
|
||||
3. **On pass**: one-line summary of the form `N passed, M skipped in <duration>`. Don't enumerate the test names — the user can read those. Do mention any SKIPPED tests and name the cause:
|
||||
- `"role not present on hub"` → device unplugged; operator knows to reconnect.
|
||||
- `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped because the macro isn't in firmware yet; suggest `--force-bake`.
|
||||
- `"uhubctl not installed"` → tests/recovery + peer-offline skipped; suggest `brew install uhubctl` / `apt install uhubctl`.
|
||||
- `"no PPPS-capable hubs detected"` → tests/recovery skipped because the hub doesn't support per-port power; the tier will never run on that setup.
|
||||
- `"opencv-python-headless is not installed"` → tests/ui auto-deselected by run-tests.sh; suggest `pip install -e 'mcp-server/.[ui]'`.
|
||||
|
||||
4. **On failure**: for every FAILED test, open `mcp-server/tests/report.html` and extract the `Meshtastic debug` section for that test. pytest-html embeds the firmware log stream + device state dump there; the 200-line firmware log tail is usually enough to explain the failure. Summarise: which test, one-line assertion message, the firmware log lines that matter (things like `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`). For UI-tier failures also glance at `mcp-server/tests/ui_captures/<session>/<test>/transcript.md` — it records each step's frame + OCR.
|
||||
|
||||
5. **Classify the failure** as one of:
|
||||
- **Transient/flake**: LoRa collision, timing-sensitive assertion, first-attempt NAK + successful retry pattern. Propose `/repro <test_node_id>` to confirm.
|
||||
- **Environmental**: device unreachable, port busy, CP2102 driver wedged. Suggest the specific recovery in escalation order: (a) replug USB, (b) `touch_1200bps(port=...)` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role="nrf52", confirm=True)` when a device is fully wedged past DFU (needs `uhubctl` installed — `baked_single`'s auto-recovery hook does this once automatically). Also check `git status userPrefs.jsonc`.
|
||||
- **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.
|
||||
|
||||
## Arguments handling
|
||||
|
||||
- No args → wrapper's defaults (full suite).
|
||||
- `$ARGUMENTS` passed verbatim to the wrapper, which passes them to pytest.
|
||||
- Common operator invocations: `/test tests/mesh`, `/test tests/mesh/test_direct_with_ack.py::test_direct_with_ack_roundtrip`, `/test --force-bake`, `/test -k telemetry`.
|
||||
|
||||
## 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.
|
||||
- `mcp-server/tests/report.html` and `junit.xml` are regenerated on every run; the HTML is self-contained (shareable).
|
||||
+332
-27
@@ -4,11 +4,11 @@ This document provides context and guidelines for AI assistants working with the
|
||||
|
||||
## 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.
|
||||
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) - Most common platform
|
||||
- **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
|
||||
@@ -80,21 +80,46 @@ firmware/
|
||||
│ │ ├── 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
|
||||
│ ├── platform/ # Platform-specific code
|
||||
│ ├── input/ # Input device handling
|
||||
│ └── concurrency/ # Threading utilities
|
||||
│ │ └── 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
|
||||
│ ├── nrf52/ # nRF52 variants
|
||||
│ └── rp2xxx/ # RP2040/RP2350 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
|
||||
```
|
||||
|
||||
@@ -105,6 +130,7 @@ firmware/
|
||||
- Follow existing code style - run `trunk fmt` before commits
|
||||
- Prefer `LOG_DEBUG`, `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` for logging
|
||||
- Use `assert()` for invariants that should never fail
|
||||
- C++17 features are available (`std::optional`, structured bindings, `if constexpr`, etc.)
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
@@ -118,70 +144,151 @@ firmware/
|
||||
|
||||
#### Module System
|
||||
|
||||
Modules inherit from `MeshModule` or `ProtobufModule<T>` and implement:
|
||||
Modules use a three-tier class hierarchy:
|
||||
|
||||
- `handleReceivedProtobuf()` - Process incoming packets
|
||||
- `allocReply()` - Generate response packets
|
||||
- `runOnce()` - Periodic task execution (returns next run interval in ms)
|
||||
1. **`MeshModule`** - Base class. Implement `wantPacket()` and `handleReceived()`. Returns `ProcessMessage::STOP` or `ProcessMessage::CONTINUE`.
|
||||
2. **`SinglePortModule`** - Handles a single portnum. Simplified `wantPacket()` that checks `decoded.portnum`.
|
||||
3. **`ProtobufModule<T>`** - Template for protobuf-based modules. Handles encoding/decoding automatically.
|
||||
|
||||
Most modules also inherit from **`OSThread`** for periodic tasks (the "mixin" pattern):
|
||||
|
||||
```cpp
|
||||
class MyModule : public ProtobufModule<meshtastic_MyMessage>
|
||||
class MyModule : public ProtobufModule<meshtastic_MyMessage>, private concurrency::OSThread
|
||||
{
|
||||
public:
|
||||
MyModule();
|
||||
|
||||
protected:
|
||||
virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MyMessage *msg) override;
|
||||
virtual int32_t runOnce() 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`:
|
||||
|
||||
```cpp
|
||||
// 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 configuration
|
||||
- `channels.*` - Channel configuration and management
|
||||
- `owner` - Device owner info
|
||||
- `myNodeInfo` - 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 0
|
||||
- `Default::getConfiguredOrDefault(configured, default)` - Generic configured/default getter
|
||||
- `Default::getConfiguredOrMinimumValue(configured, min)` - Enforces minimum values
|
||||
- `Default::getConfiguredOrDefaultMsScaled(configured, default, numNodes)` - Scales based on network size
|
||||
|
||||
#### Thread Safety
|
||||
|
||||
- Use `concurrency::Lock` for mutex protection
|
||||
- Use `concurrency::Lock` and `concurrency::LockGuard` for mutex protection
|
||||
- Radio SPI access uses `SPILock`
|
||||
- Prefer `OSThread` for 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 capabilities
|
||||
- `platformio.ini` - Build configuration
|
||||
- Optional: `pins_arduino.h`, `rfswitch.h`
|
||||
- Optional: `pins_arduino.h`, `rfswitch.h`, `nicheGraphics.h` (for InkHUD variants)
|
||||
|
||||
Key defines in variant.h:
|
||||
|
||||
```cpp
|
||||
#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`
|
||||
- Generated code in `src/mesh/generated/`
|
||||
- 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 `.options` files control field sizes and encoding
|
||||
|
||||
### Conditional Compilation
|
||||
|
||||
```cpp
|
||||
#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
|
||||
@@ -192,7 +299,7 @@ Key defines in variant.h:
|
||||
Uses **PlatformIO** with custom scripts:
|
||||
|
||||
- `bin/platformio-pre.py` - Pre-build script
|
||||
- `bin/platformio-custom.py` - Custom build logic
|
||||
- `bin/platformio-custom.py` - Custom build logic, manifest generation
|
||||
|
||||
Build commands:
|
||||
|
||||
@@ -202,21 +309,38 @@ 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 via `custom_meshtastic_has_mui`, `custom_meshtastic_has_ink_hud`)
|
||||
- Architecture normalization (e.g., `esp32s3` → `esp32-s3` for API compatibility)
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Module
|
||||
|
||||
1. Create `src/modules/MyModule.cpp` and `.h`
|
||||
2. Inherit from appropriate base class
|
||||
3. Register in `src/modules/Modules.cpp`
|
||||
4. Add protobuf messages if needed in `protobufs/`
|
||||
2. Inherit from appropriate base class (`MeshModule`, `SinglePortModule`, or `ProtobufModule<T>`)
|
||||
3. Mix in `concurrency::OSThread` if periodic work is needed
|
||||
4. Register in `src/modules/Modules.cpp` guarded by `#if !MESHTASTIC_EXCLUDE_MYMODULE`
|
||||
5. Add protobuf messages if needed in `protobufs/meshtastic/`
|
||||
6. Add test suite in `test/test_mymodule/` if applicable
|
||||
|
||||
### Adding a New Hardware Variant
|
||||
|
||||
1. Create directory under `variants/<arch>/<name>/`
|
||||
2. Add `variant.h` with pin definitions
|
||||
3. Add `platformio.ini` with build config
|
||||
4. Reference common configs with `extends`
|
||||
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`)
|
||||
4. Set `custom_meshtastic_support_level = 1` (PR builds) or `2` (merge builds)
|
||||
5. For e-ink displays, add `nicheGraphics.h` for InkHUD configuration
|
||||
|
||||
### Adding a New Telemetry Sensor
|
||||
|
||||
1. Create driver in `src/modules/Telemetry/Sensor/` following existing sensor pattern
|
||||
2. Register I2C address in `src/detect/ScanI2C` for auto-detection
|
||||
3. Integrate with the appropriate telemetry module (Environment, Health, Power, AirQuality)
|
||||
4. Add proto fields in `protobufs/meshtastic/telemetry.proto` if new data types are needed
|
||||
|
||||
### Modifying Configuration Defaults
|
||||
|
||||
@@ -305,9 +429,190 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests in `test/` directory
|
||||
- Run with `pio test -e native`
|
||||
- Use `bin/test-simulator.sh` for simulation testing
|
||||
### Native unit tests (C++)
|
||||
|
||||
Unit tests in `test/` directory with 12 test suites:
|
||||
|
||||
- `test_crypto/` - Cryptography
|
||||
- `test_mqtt/` - MQTT integration
|
||||
- `test_radio/` - Radio interface
|
||||
- `test_mesh_module/` - Module framework
|
||||
- `test_meshpacket_serializer/` - Packet serialization
|
||||
- `test_transmit_history/` - Retransmission tracking
|
||||
- `test_atak/` - ATAK integration
|
||||
- `test_default/` - Default configuration
|
||||
- `test_http_content_handler/` - HTTP handling
|
||||
- `test_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](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]'`).
|
||||
|
||||
### 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 require `confirm=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 by `role` (`"nrf52"`, `"esp32s3"`); role lookup checks `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` + `_PORT_<ROLE>` env vars first, falls back to VID auto-detection.
|
||||
- **Observability** (UI tier + operator ad-hoc): `capture_screen(role, ocr=True)` — grabs a USB-webcam frame of the device OLED and optionally OCRs it. Requires `mcp-server[ui]` extras (`opencv-python-headless`, `easyocr`) and `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` env var; falls through to a 1×1 black PNG `NullBackend` when unconfigured.
|
||||
|
||||
`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`):
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
./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 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`)
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
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.html` and 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) — snapshots `userPrefs.jsonc` at session start, merges the session test profile via `userprefs.merge_active(test_profile)`, restores at teardown. Four layers of safety: pytest teardown + `atexit` + sidecar file (`userPrefs.jsonc.mcp-session-bak`) + startup self-heal in `run-tests.sh`. **Do not edit `userPrefs.jsonc` from inside a test.**
|
||||
- **`_firmware_log_stream`** (autouse session) — subscribes to `meshtastic.log.line` pubsub on every connected `SerialInterface` and mirrors lines to `tests/fwlog.jsonl`. Drives the TUI firmware-log pane.
|
||||
- **`_debug_log_buffer`** (autouse per-test) — captures last 200 firmware log lines + device state for attachment to the pytest-html `Meshtastic debug` section on failure.
|
||||
- **`hub_devices`** (session) — `dict[role, SerialInterface]` with session-long exclusive port locks. Reason the TUI's device poller is gated to startup + post-run only.
|
||||
- **`baked_mesh`** — parametrized mesh-pair fixture; depends on `test_00_bake`. `pytest_generate_tests` in `conftest.py` auto-generates `[nrf52->esp32s3]` and `[esp32s3->nrf52]` variants.
|
||||
- **`test_profile`** — session-scoped dict: region, primary channel, admin key, PSK seed. Derived from `MESHTASTIC_MCP_SEED` (defaults to `mcp-<user>-<host>`).
|
||||
|
||||
### Firmware integration points tied to the test harness
|
||||
|
||||
Two firmware changes exist specifically so the test harness works reliably. **Keep these in mind when touching related code.**
|
||||
|
||||
- **`src/mesh/StreamAPI.cpp` + `StreamAPI.h`** — `emitLogRecord` uses a dedicated `fromRadioScratchLog` + `txBufLog` pair and a `concurrency::Lock streamLock`. Before this fix, `debug_log_api_enabled=true` would tear `FromRadio` protobufs on the serial transport because `emitTxBuffer` and `emitLogRecord` shared a single scratch buffer. The conftest enables the log stream session-wide; without this fix the device would corrupt its own FromRadio replies mid-session.
|
||||
- **`src/mesh/PhoneAPI.cpp`** — `ToRadio` `Heartbeat(nonce=1)` triggers `nodeInfoModule->sendOurNodeInfo(NODENUM_BROADCAST, true, 0, true)` for serial clients, mirroring the pre-existing behavior for TCP/UDP clients in `PacketAPI.cpp`. The mesh tests rely on this to force a NodeInfo broadcast right after connect so the peer discovers them before the test's first assertion.
|
||||
|
||||
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_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.
|
||||
|
||||
## Resources
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
This prompt assumes the meshtastic MCP server is registered with your VS Code Copilot agent. If it isn't, fall back to running `./mcp-server/run-tests.sh tests/unit` plus a short `device_info` script via the terminal.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Enumerate hardware** via the `list_devices` MCP tool (with `include_unknown=True`). For each entry where `likely_meshtastic=True`, capture `port`, `vid`, `pid`, `description`.
|
||||
|
||||
2. **Apply the operator's filter** (if any):
|
||||
- No filter → every likely-meshtastic device.
|
||||
- `nrf52` → `vid == 0x239a`
|
||||
- `esp32s3` → `vid == 0x303a` or `vid == 0x10c4`
|
||||
- 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):**
|
||||
- `device_info(port=<p>)` → `my_node_num`, `long_name`, `short_name`, `firmware_version`, `hw_model`, `region`, `num_nodes`, `primary_channel`
|
||||
- `list_nodes(port=<p>)` → peer count, which peers have `publicKey`, SNR/RSSI distribution
|
||||
- `get_config(section="lora", port=<p>)` → region, preset, channel_num, tx_power, hop_limit
|
||||
- If anything looks off (can't connect, `num_nodes` wrong, missing `firmware_version`), open a short firmware-log window: `serial_open(port=<p>, env=<inferred>)`, wait 3 seconds, `serial_read(session_id, max_lines=100)`, `serial_close(session_id)`. Infer env from VID (0x239a → `rak4631`, 0x303a/0x10c4 → `heltec-v3`) unless an `MESHTASTIC_MCP_ENV_<ROLE>` env var overrides it.
|
||||
|
||||
4. **Hub health** (call once, not per-device): `uhubctl_list()` — enumerates every USB hub the host sees. Cross-reference each Meshtastic device's VID to find which hub + port it's on. Flag in the report if:
|
||||
- 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.
|
||||
|
||||
5. **Render per-device report** as a compact block:
|
||||
|
||||
```text
|
||||
[nrf52 @ /dev/cu.usbmodem1101] fw=2.7.23.bce2825, hw=RAK4631
|
||||
owner : Meshtastic 40eb / 40eb
|
||||
region/band : US, channel 88, LONG_FAST
|
||||
tx_power : 30 dBm, hop_limit=3
|
||||
peers : 1 (esp32s3 0x433c2428, pubkey ✓, SNR 6.0 / RSSI -24 dBm)
|
||||
primary ch : McpTest
|
||||
hub : 1-1.3 port 2 (PPPS, uhubctl-controllable)
|
||||
firmware : no panics in last 3s
|
||||
```
|
||||
|
||||
Flag abnormalities inline with `⚠︎ <short reason>` — missing pubkey on a known peer, region UNSET, mismatched channel name, device on non-PPPS hub, etc.
|
||||
|
||||
6. **Cross-device correlation** (when >1 device selected):
|
||||
- Do both see each other in `nodesByNum`?
|
||||
- Do `region`, `channel_num`, `modem_preset` match across devices?
|
||||
- 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."
|
||||
- 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.
|
||||
|
||||
## Hard constraints
|
||||
|
||||
- **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.
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
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
|
||||
|
||||
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).
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
./mcp-server/run-tests.sh <test-id> --tb=short -p no:cacheprovider
|
||||
```
|
||||
|
||||
`-p no:cacheprovider` keeps pytest from caching anything between iterations. Capture: exit code, duration, and (on failure) the `Meshtastic debug` firmware-log section from `mcp-server/tests/report.html`.
|
||||
|
||||
4. **Tally** results as you go:
|
||||
|
||||
```text
|
||||
attempt 1: PASS (42s)
|
||||
attempt 2: FAIL (128s) ← fw log captured
|
||||
attempt 3: PASS (39s)
|
||||
attempt 4: FAIL (121s)
|
||||
attempt 5: PASS (41s)
|
||||
--------------------------------------------------
|
||||
pass rate: 3/5 (60%) | mean duration: 74s
|
||||
```
|
||||
|
||||
5. **On mixed outcomes, diff the firmware logs** between one representative pass and one representative fail. Focus on:
|
||||
- Error-level lines present only in failures (`PKI_UNKNOWN_PUBKEY`, `Alloc an err=`, `Skip send`, `No suitable channel`, `NAK`)
|
||||
- Timing around the assertion point (broadcast sent? ACK received? retry fired?)
|
||||
- Device-state fields that changed between attempts
|
||||
|
||||
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.
|
||||
|
||||
7. **Report back** with:
|
||||
- Pass rate + mean duration.
|
||||
- Classification + the specific log evidence for it.
|
||||
- A concrete next step (tighter assertion, more retries, open `/mcp-diagnose`, file a bug, nothing).
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
- Don't rebuild firmware during triage. Flakes that only reproduce under different firmware belong in a separate session with a plan.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## What to do
|
||||
|
||||
1. **Invoke the wrapper** from the firmware repo root:
|
||||
|
||||
```bash
|
||||
./mcp-server/run-tests.sh [pytest-args]
|
||||
```
|
||||
|
||||
If the operator specified a subset (e.g. "just the mesh tests"), pass it through as `tests/mesh` or a pytest `-k filter`. If they said nothing, use the wrapper's defaults (full suite with pytest-html report).
|
||||
|
||||
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.
|
||||
|
||||
3. **On pass**: one-line summary like `N passed, M skipped in <duration>`. Don't enumerate test names. DO mention any non-placeholder SKIPs and name the cause:
|
||||
- `"role not present on hub"` → device unplugged; operator should reconnect.
|
||||
- `"firmware not baked with USERPREFS_UI_TEST_LOG"` → tests/ui skipped; the UI-log compile macro isn't in the baked firmware. Suggest `--force-bake`.
|
||||
- `"uhubctl not installed"` → tests/recovery + `test_peer_offline_recovery` skipped. Suggest `brew install uhubctl` / `apt install uhubctl`.
|
||||
- `"no PPPS-capable hubs detected"` → tests/recovery skipped because the attached hub doesn't support per-port power switching; won't 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**: open `mcp-server/tests/report.html` (pytest-html output, self-contained) and extract the `Meshtastic debug` section for each failed test. That section includes a firmware log stream (last 200 lines) and device state dump. For each failure, summarise:
|
||||
- test name
|
||||
- one-line assertion message
|
||||
- the specific firmware log lines that explain why (look for `PKI_UNKNOWN_PUBKEY`, `Skip send NodeInfo`, `Error=`, `Guru Meditation`, `assertion failed`, `No suitable channel`)
|
||||
- for UI-tier failures also check `mcp-server/tests/ui_captures/<session>/<test>/transcript.md` (per-step frame + OCR)
|
||||
|
||||
5. **Classify each failure** as one of:
|
||||
- **Transient flake** — LoRa collision, first-attempt NAK with self-heal pattern, timing-sensitive assertion. Suggest `/mcp-repro <test-id>` to confirm.
|
||||
- **Environmental** — device unreachable, port busy, CP2102 driver wedged on macOS. Suggest recovery in escalation order: (a) replug USB, (b) `touch_1200bps` + `pio_flash` for nRF52 DFU, (c) `uhubctl_cycle(role=..., confirm=True)` for a device wedged past DFU (needs `uhubctl` installed; `baked_single` does this once automatically when available). Also check `git status userPrefs.jsonc`.
|
||||
- **Regression** — same assertion fails repeatedly on re-runs, firmware log shows novel errors. Identify the firmware module likely responsible.
|
||||
|
||||
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
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,138 @@
|
||||
# New Meshtastic Module
|
||||
|
||||
Guide for developing a new Meshtastic firmware module.
|
||||
|
||||
## Module Hierarchy
|
||||
|
||||
Choose the appropriate base class:
|
||||
|
||||
1. **`MeshModule`** — Raw base class. Override `wantPacket()` and `handleReceived()`. Returns `ProcessMessage::STOP` or `ProcessMessage::CONTINUE`.
|
||||
2. **`SinglePortModule`** — Handles a single `meshtastic_PortNum`. Constructor takes `(name, portNum)`. Simplified `wantPacket()` checking `decoded.portnum`. Use `allocDataPacket()` to create outgoing packets.
|
||||
3. **`ProtobufModule<T>`** — Template for protobuf-encoded modules. Constructor takes `(name, portNum, fields)`. Override `handleReceivedProtobuf()`. Use `allocDataProtobuf(payload)` to create outgoing packets.
|
||||
|
||||
Most modules also mix in `concurrency::OSThread` for periodic background tasks.
|
||||
|
||||
## Implementation Pattern
|
||||
|
||||
```cpp
|
||||
// src/modules/MyModule.h
|
||||
#pragma once
|
||||
#include "ProtobufModule.h"
|
||||
#include "concurrency/OSThread.h"
|
||||
|
||||
class MyModule : public ProtobufModule<meshtastic_MyMessage>, private concurrency::OSThread
|
||||
{
|
||||
public:
|
||||
MyModule();
|
||||
|
||||
protected:
|
||||
// Process incoming protobuf packet. Return true to stop further processing.
|
||||
virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MyMessage *msg) override;
|
||||
|
||||
// Generate response packet (optional)
|
||||
virtual meshtastic_MeshPacket *allocReply() override;
|
||||
|
||||
// Periodic task — return next run interval in ms, or disable()
|
||||
virtual int32_t runOnce() override;
|
||||
|
||||
// Modify packet in-flight before delivery (optional)
|
||||
virtual bool alterReceivedProtobuf(meshtastic_MeshPacket &mp, meshtastic_MyMessage *msg);
|
||||
|
||||
// Request a UI display frame (optional)
|
||||
virtual bool wantUIFrame();
|
||||
};
|
||||
```
|
||||
|
||||
## Registration
|
||||
|
||||
Register in `src/modules/Modules.cpp` inside `setupModules()`:
|
||||
|
||||
```cpp
|
||||
#if !MESHTASTIC_EXCLUDE_MYMODULE
|
||||
new MyModule();
|
||||
#endif
|
||||
```
|
||||
|
||||
If other code needs to reference the module instance:
|
||||
|
||||
```cpp
|
||||
#if !MESHTASTIC_EXCLUDE_MYMODULE
|
||||
myModule = new MyModule();
|
||||
#endif
|
||||
```
|
||||
|
||||
And declare the global in the header:
|
||||
|
||||
```cpp
|
||||
extern MyModule *myModule;
|
||||
```
|
||||
|
||||
Some modules also conditionally instantiate based on `moduleConfig`:
|
||||
|
||||
```cpp
|
||||
#if !MESHTASTIC_EXCLUDE_MYMODULE
|
||||
if (moduleConfig.has_my_module && moduleConfig.my_module.enabled) {
|
||||
new MyModule();
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
## Conditional Compilation
|
||||
|
||||
Add a `MESHTASTIC_EXCLUDE_MYMODULE` guard. This allows the module to be excluded from constrained builds. The flag name must follow the pattern: `MESHTASTIC_EXCLUDE_` + uppercase module name.
|
||||
|
||||
## Protobuf Messages (if needed)
|
||||
|
||||
1. Define messages in `protobufs/meshtastic/` (e.g., `mymodule.proto`)
|
||||
2. Add a `.options` file for nanopb field size constraints
|
||||
3. Regenerate with `bin/regen-protos.sh`
|
||||
4. Generated code appears in `src/mesh/generated/meshtastic/`
|
||||
5. Assign a `meshtastic_PortNum` if the module uses a new port number
|
||||
|
||||
## Timing and Defaults
|
||||
|
||||
Use `Default` class helpers for configurable intervals:
|
||||
|
||||
```cpp
|
||||
int32_t MyModule::runOnce()
|
||||
{
|
||||
uint32_t interval = Default::getConfiguredOrDefaultMs(moduleConfig.my_module.update_interval,
|
||||
default_my_module_interval);
|
||||
// ... do work ...
|
||||
return interval;
|
||||
}
|
||||
```
|
||||
|
||||
On public/default channels, enforce minimums with `Default::getConfiguredOrMinimumValue()`.
|
||||
|
||||
## Observer Pattern
|
||||
|
||||
Subscribe to system events:
|
||||
|
||||
```cpp
|
||||
CallbackObserver<MyModule, const meshtastic::Status *> statusObserver =
|
||||
CallbackObserver<MyModule, const meshtastic::Status *>(this, &MyModule::handleStatusUpdate);
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Add test suite in `test/test_mymodule/`:
|
||||
|
||||
```
|
||||
test/
|
||||
└── test_mymodule/
|
||||
└── test_main.cpp
|
||||
```
|
||||
|
||||
Run with: `pio test -e native`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Header and implementation files in `src/modules/`
|
||||
- [ ] Inherit from appropriate base class (MeshModule / SinglePortModule / ProtobufModule)
|
||||
- [ ] Mix in OSThread if periodic work is needed
|
||||
- [ ] Register in `src/modules/Modules.cpp` with `MESHTASTIC_EXCLUDE_` guard
|
||||
- [ ] Add protobuf definitions if needed (`protobufs/meshtastic/`)
|
||||
- [ ] Use `Default::getConfiguredOrDefaultMs()` for timing
|
||||
- [ ] Respect bandwidth limits on public channels
|
||||
- [ ] Add test suite in `test/`
|
||||
@@ -0,0 +1,149 @@
|
||||
# New Telemetry Sensor
|
||||
|
||||
Guide for adding a new I2C telemetry sensor driver to Meshtastic firmware.
|
||||
|
||||
## Overview
|
||||
|
||||
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
|
||||
|
||||
## Sensor Driver Pattern
|
||||
|
||||
Each sensor has a `.h` and `.cpp` file pair following this pattern:
|
||||
|
||||
```cpp
|
||||
// src/modules/Telemetry/Sensor/MySensor.h
|
||||
#pragma once
|
||||
#include "TelemetrySensor.h"
|
||||
#include <MySensorLibrary.h> // Arduino/PlatformIO library
|
||||
|
||||
class MySensor : virtual public TelemetrySensor
|
||||
{
|
||||
private:
|
||||
MySensorLibrary sensor;
|
||||
|
||||
public:
|
||||
MySensor() : TelemetrySensor(meshtastic_TelemetrySensorType_MY_SENSOR, "MySensor") {}
|
||||
|
||||
// Initialize sensor hardware. Return true on success.
|
||||
virtual void setup() override;
|
||||
|
||||
// Read sensor data into the telemetry protobuf. Return true on success.
|
||||
virtual bool getMetrics(meshtastic_Telemetry *measurement) override;
|
||||
};
|
||||
```
|
||||
|
||||
```cpp
|
||||
// src/modules/Telemetry/Sensor/MySensor.cpp
|
||||
#include "MySensor.h"
|
||||
#include "TelemetrySensor.h"
|
||||
|
||||
void MySensor::setup()
|
||||
{
|
||||
sensor.begin();
|
||||
// Configure sensor parameters...
|
||||
}
|
||||
|
||||
bool MySensor::getMetrics(meshtastic_Telemetry *measurement)
|
||||
{
|
||||
// Read from hardware
|
||||
float value = sensor.readValue();
|
||||
|
||||
// Populate the appropriate protobuf variant
|
||||
measurement->variant.environment_metrics.temperature = value;
|
||||
// ... other fields ...
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
## I2C Address Registration
|
||||
|
||||
Register the sensor's I2C address(es) in `src/detect/ScanI2C` so it's auto-detected at boot:
|
||||
|
||||
1. Add a `DeviceType` enum entry in `src/detect/ScanI2C.h`
|
||||
2. Add the I2C address mapping in `src/detect/ScanI2CTwoWire.cpp`
|
||||
|
||||
The scan runs at boot and populates a device map that telemetry modules use to decide which sensors to initialize.
|
||||
|
||||
## Protobuf Fields
|
||||
|
||||
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
|
||||
2. Add a `.options` constraint if needed (field sizes for nanopb)
|
||||
3. Regenerate: `bin/regen-protos.sh`
|
||||
|
||||
## Sensor Type Enum
|
||||
|
||||
Add the sensor to `meshtastic_TelemetrySensorType` enum in `protobufs/meshtastic/telemetry.proto`:
|
||||
|
||||
```protobuf
|
||||
enum TelemetrySensorType {
|
||||
// ... existing entries ...
|
||||
MY_SENSOR = XX;
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Telemetry Module
|
||||
|
||||
Wire the sensor into the appropriate telemetry module. For environment sensors, this is typically in `src/modules/Telemetry/EnvironmentTelemetry.cpp`:
|
||||
|
||||
1. Include the sensor header
|
||||
2. Add initialization in `setupSensor()` guarded by detection results
|
||||
3. Call `getMetrics()` in the measurement collection path
|
||||
|
||||
Example pattern from existing sensors:
|
||||
|
||||
```cpp
|
||||
#include "Sensor/MySensor.h"
|
||||
|
||||
MySensor mySensor;
|
||||
|
||||
// In setup:
|
||||
if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_MY_SENSOR].first > 0) {
|
||||
mySensor.setup();
|
||||
}
|
||||
|
||||
// In measurement collection:
|
||||
if (nodeTelemetrySensorsMap[meshtastic_TelemetrySensorType_MY_SENSOR].first > 0) {
|
||||
mySensor.getMetrics(&measurement);
|
||||
}
|
||||
```
|
||||
|
||||
## Library Dependencies
|
||||
|
||||
If the sensor needs an external library, add it to the `lib_deps` in the relevant base platformio.ini configs:
|
||||
|
||||
```ini
|
||||
lib_deps =
|
||||
${env.lib_deps}
|
||||
mysensorlibrary@^1.0.0
|
||||
```
|
||||
|
||||
Or use a conditional dependency if it's platform-specific.
|
||||
|
||||
## Unit Conversions
|
||||
|
||||
If the sensor reports values in non-standard units, use `src/modules/Telemetry/UnitConversions.h` for conversion helpers (e.g., Celsius ↔ Fahrenheit, hPa ↔ inHg).
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Create `src/modules/Telemetry/Sensor/MySensor.h` and `.cpp`
|
||||
- [ ] Inherit from `TelemetrySensor` base class
|
||||
- [ ] Implement `setup()` and `getMetrics()` methods
|
||||
- [ ] Add `meshtastic_TelemetrySensorType` enum entry in `telemetry.proto`
|
||||
- [ ] Add I2C address to `src/detect/ScanI2C` for auto-detection
|
||||
- [ ] Add protobuf fields in `telemetry.proto` if new data types needed
|
||||
- [ ] Regenerate protos: `bin/regen-protos.sh`
|
||||
- [ ] Wire into the appropriate telemetry module (Environment/AirQuality/Power/Health)
|
||||
- [ ] Add library dependency if external library required
|
||||
- [ ] Test on hardware or native build
|
||||
@@ -0,0 +1,178 @@
|
||||
# New Hardware Variant
|
||||
|
||||
Guide for adding a new Meshtastic hardware variant to the firmware.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Create under `variants/<arch>/<name>/`:
|
||||
|
||||
```
|
||||
variants/
|
||||
├── esp32/ # ESP32
|
||||
├── esp32s3/ # ESP32-S3
|
||||
├── esp32c3/ # ESP32-C3
|
||||
├── esp32c6/ # ESP32-C6
|
||||
├── nrf52840/ # nRF52840
|
||||
├── rp2040/ # RP2040/RP2350
|
||||
├── stm32/ # STM32WL
|
||||
└── native/ # Linux/Portduino
|
||||
```
|
||||
|
||||
Each variant needs at minimum:
|
||||
|
||||
- `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
|
||||
|
||||
## variant.h Template
|
||||
|
||||
```cpp
|
||||
// Pin definitions
|
||||
#define I2C_SDA 21
|
||||
#define I2C_SCL 22
|
||||
|
||||
// LoRa radio
|
||||
#define USE_SX1262 // Radio chip: USE_SX1262, USE_SX1268, USE_SX1280, USE_RF95, USE_LLCC68, USE_LR1110, USE_LR1120, USE_LR1121
|
||||
#define LORA_CS 18
|
||||
#define LORA_SCK 5
|
||||
#define LORA_MOSI 27
|
||||
#define LORA_MISO 19
|
||||
#define LORA_DIO1 33 // SX126x: DIO1, SX128x: DIO1, RF95: IRQ
|
||||
#define LORA_RESET 23
|
||||
#define LORA_BUSY 32 // SX126x/SX128x only
|
||||
#define SX126X_DIO2_AS_RF_SWITCH // Common for SX1262 boards
|
||||
|
||||
// GPS
|
||||
#define HAS_GPS 1
|
||||
#define GPS_RX_PIN 34
|
||||
#define GPS_TX_PIN 12
|
||||
// #define PIN_GPS_EN 47 // Optional GPS enable pin
|
||||
// #define GPS_BAUDRATE 9600 // Override default 9600
|
||||
|
||||
// Display
|
||||
#define HAS_SCREEN 1
|
||||
// #define USE_SSD1306 // OLED type
|
||||
// #define USE_SH1106 // Alternative OLED
|
||||
// #define USE_ST7789 // TFT type
|
||||
// #define SCREEN_WIDTH 128
|
||||
// #define SCREEN_HEIGHT 64
|
||||
|
||||
// LEDs
|
||||
#define LED_PIN 2 // Status LED (optional)
|
||||
// #define HAS_NEOPIXEL 1 // WS2812 support
|
||||
|
||||
// Buttons
|
||||
#define BUTTON_PIN 38
|
||||
// #define BUTTON_PIN_ALT 0 // Secondary button
|
||||
|
||||
// Power management
|
||||
// #define HAS_AXP192 1 // AXP192 PMU (T-Beam v1.0)
|
||||
// #define HAS_AXP2101 1 // AXP2101 PMU (T-Beam v1.2+)
|
||||
// #define BATTERY_PIN 35 // ADC battery voltage pin
|
||||
// #define ADC_MULTIPLIER 2.0 // Voltage divider ratio
|
||||
|
||||
// Optional I2C devices
|
||||
// #define HAS_RTC 1 // Real-time clock
|
||||
// #define HAS_TELEMETRY 1 // Enable telemetry sensor support
|
||||
// #define HAS_SENSOR 1 // I2C sensors present
|
||||
```
|
||||
|
||||
## platformio.ini Template
|
||||
|
||||
```ini
|
||||
[env:my_variant]
|
||||
extends = esp32s3_base ; Use architecture-specific base
|
||||
board = esp32-s3-devkitc-1 ; PlatformIO board definition (or custom in boards/)
|
||||
board_level = extra ; Build level: extra, or omit for default
|
||||
custom_meshtastic_support_level = 1 ; 1 = PR builds, 2 = merge builds only
|
||||
|
||||
build_flags =
|
||||
${esp32s3_base.build_flags}
|
||||
-D MY_VARIANT_SPECIFIC_FLAG=1
|
||||
-I variants/esp32s3/my_variant ; Include path for variant.h
|
||||
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
## 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
|
||||
- Architecture names are normalized (e.g., `esp32s3` → `esp32-s3`)
|
||||
|
||||
## InkHUD E-Ink Variants
|
||||
|
||||
For e-ink display variants using the InkHUD framework, add `nicheGraphics.h`:
|
||||
|
||||
```cpp
|
||||
// nicheGraphics.h — InkHUD configuration for this variant
|
||||
#define INKHUD // Enable InkHUD
|
||||
// Configure display, applets, and refresh behavior per device
|
||||
```
|
||||
|
||||
InkHUD has its own PlatformIO config: `src/graphics/niche/InkHUD/PlatformioConfig.ini`
|
||||
|
||||
## I2C Device Detection
|
||||
|
||||
If the variant has I2C devices, ensure `src/detect/ScanI2C` will detect them. The auto-detection system handles 80+ device types including displays, sensors, RTCs, keyboards, PMUs, and touch controllers at boot.
|
||||
|
||||
## Custom Board Definitions
|
||||
|
||||
If the PlatformIO board doesn't exist, create a custom board JSON in `boards/`:
|
||||
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"arduino": { "ldscript": "esp32s3_out.ld" },
|
||||
"core": "esp32",
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"mcu": "esp32s3",
|
||||
"variant": "esp32s3"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth"],
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "My Custom Board",
|
||||
"upload": {
|
||||
"flash_size": "8MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 8388608
|
||||
},
|
||||
"url": "https://example.com",
|
||||
"vendor": "MyVendor"
|
||||
}
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Create `variants/<arch>/<name>/variant.h` with pin definitions
|
||||
- [ ] Create `variants/<arch>/<name>/platformio.ini` extending correct base
|
||||
- [ ] Set `custom_meshtastic_support_level` (1 or 2)
|
||||
- [ ] Verify radio chip define matches hardware (`USE_SX1262`, etc.)
|
||||
- [ ] Set hardware capability flags (`HAS_GPS`, `HAS_SCREEN`, etc.)
|
||||
- [ ] Add custom board JSON in `boards/` if needed
|
||||
- [ ] Test build: `pio run -e my_variant`
|
||||
- [ ] For e-ink: add `nicheGraphics.h` with InkHUD config
|
||||
@@ -56,6 +56,25 @@ jobs:
|
||||
echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT
|
||||
id: version
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
cache-image: true
|
||||
|
||||
- name: Docker setup
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
# Add Google/Amazon DockerHub mirrors to buildkit config
|
||||
# https://docs.docker.com/build/ci/github-actions/configure-builder/#registry-mirror
|
||||
buildkitd-config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["mirror.gcr.io", "public.ecr.aws"]
|
||||
|
||||
- name: Sanitize platform string
|
||||
id: sanitize_platform
|
||||
# Replace slashes with underscores
|
||||
run: echo "cleaned_platform=${{ inputs.platform }}" | sed 's/\//_/g' >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker login
|
||||
if: ${{ inputs.push }}
|
||||
uses: docker/login-action@v4
|
||||
@@ -63,17 +82,6 @@ jobs:
|
||||
username: meshtastic
|
||||
password: ${{ secrets.DOCKER_FIRMWARE_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Docker setup
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Sanitize platform string
|
||||
id: sanitize_platform
|
||||
# Replace slashes with underscores
|
||||
run: echo "cleaned_platform=${{ inputs.platform }}" | sed 's/\//_/g' >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Docker tag
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
@@ -95,3 +103,6 @@ jobs:
|
||||
platforms: ${{ inputs.platform }}
|
||||
build-args: |
|
||||
PIO_ENV=${{ inputs.pio_env }}
|
||||
# Cache image layers in GitHub Actions cache to speed up subsequent builds.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -301,15 +301,17 @@ jobs:
|
||||
id: release_notes
|
||||
run: |
|
||||
chmod +x ./bin/generate_release_notes.py
|
||||
NOTES=$(./bin/generate_release_notes.py ${{ needs.version.outputs.long }})
|
||||
NOTES=$(./bin/generate_release_notes.py ${{ needs.version.outputs.long }} --compare-ref HEAD 2>release_notes.log)
|
||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
echo "### Release note range" >> $GITHUB_STEP_SUMMARY
|
||||
cat release_notes.log >> $GITHUB_STEP_SUMMARY
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
id: create_release
|
||||
with:
|
||||
draft: true
|
||||
@@ -466,7 +468,7 @@ jobs:
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
chmod +x ./bin/generate_release_notes.py
|
||||
./bin/generate_release_notes.py ${{ needs.version.outputs.long }} > ./publish/release_notes.md
|
||||
./bin/generate_release_notes.py ${{ needs.version.outputs.long }} --compare-ref HEAD > ./publish/release_notes.md
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Apply quality label if needed
|
||||
if: steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
QUALITY_LABEL: ${{ steps.quality.outputs.response }}
|
||||
with:
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
- name: Determine if completeness check should be skipped
|
||||
if: steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == ''
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
id: check-skip
|
||||
with:
|
||||
script: |
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
- name: Process analysis result
|
||||
if: (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '') && steps.check-skip.outputs.should_skip != 'true' && steps.analysis.outputs.response != ''
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
id: process
|
||||
env:
|
||||
AI_RESPONSE: ${{ steps.analysis.outputs.response }}
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
|
||||
- name: Apply triage label
|
||||
if: steps.process.outputs.label != '' && steps.process.outputs.label != 'none'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
LABEL_NAME: ${{ steps.process.outputs.label }}
|
||||
with:
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
|
||||
- name: Comment on issue
|
||||
if: steps.process.outputs.should_comment == 'true'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
COMMENT_BODY: ${{ steps.process.outputs.comment_body }}
|
||||
with:
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
# Step 1: Check if PR already has automation/type labels (skip if so)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
- name: Check existing labels
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
id: check-labels
|
||||
with:
|
||||
script: |
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
- name: Apply quality label if needed
|
||||
if: steps.check-labels.outputs.skip_all != 'true' && steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
id: quality-label
|
||||
env:
|
||||
QUALITY_LABEL: ${{ steps.quality.outputs.response }}
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Apply type label
|
||||
if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && steps.classify.outputs.response != ''
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
TYPE_LABEL: ${{ steps.classify.outputs.response }}
|
||||
with:
|
||||
|
||||
@@ -5,6 +5,8 @@ on:
|
||||
secrets:
|
||||
PPA_GPG_PRIVATE_KEY:
|
||||
required: true
|
||||
PPA_SFTP_PRIVATE_KEY:
|
||||
required: true
|
||||
inputs:
|
||||
ppa_repo:
|
||||
description: Meshtastic PPA to target
|
||||
@@ -27,6 +29,7 @@ jobs:
|
||||
build_location: ppa
|
||||
|
||||
package-ppa:
|
||||
if: ${{ github.event_name != 'pull_request_target' && github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-debian-src
|
||||
steps:
|
||||
@@ -40,7 +43,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update -y --fix-missing
|
||||
sudo apt-get install -y dput
|
||||
sudo apt-get install -y dput openssh-client
|
||||
|
||||
- name: Import GPG key
|
||||
uses: crazy-max/ghaction-import-gpg@v7
|
||||
@@ -65,8 +68,42 @@ jobs:
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -lah
|
||||
|
||||
- name: Publish with dput
|
||||
if: ${{ github.event_name != 'pull_request_target' && github.event_name != 'pull_request' }}
|
||||
timeout-minutes: 15 # dput is terrible, sometimes runs 'forever'
|
||||
- name: Trust Launchpad's SSH key
|
||||
run: |
|
||||
dput ${{ inputs.ppa_repo }} meshtasticd_${{ steps.version.outputs.deb }}~${{ inputs.series }}_source.changes
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ppa.launchpad.net >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Setup dput config
|
||||
env:
|
||||
ppa_login: meshtasticorg
|
||||
run: |
|
||||
sudo tee /etc/meshtastic-dput.cf >/dev/null <<EOF
|
||||
[ppa]
|
||||
fqdn = ppa.launchpad.net
|
||||
method = ftp
|
||||
incoming = ~%(ppa)s
|
||||
login = anonymous
|
||||
|
||||
[ssh-ppa]
|
||||
fqdn = ppa.launchpad.net
|
||||
method = sftp
|
||||
incoming = ~%(ssh-ppa)s
|
||||
login = ${ppa_login}
|
||||
EOF
|
||||
|
||||
- name: Import SSH key
|
||||
uses: webfactory/ssh-agent@v0.10.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.PPA_SFTP_PRIVATE_KEY }}
|
||||
id: ssh
|
||||
|
||||
- name: Publish with dput (sftp)
|
||||
timeout-minutes: 30 # dput is terrible, sometimes runs 'forever'
|
||||
env:
|
||||
up_ppa_repo: ${{ inputs.ppa_repo }}
|
||||
up_series: ${{ inputs.series }}
|
||||
up_version: ${{ steps.version.outputs.deb }}
|
||||
run: >
|
||||
dput -c /etc/meshtastic-dput.cf
|
||||
ssh-${up_ppa_repo}
|
||||
meshtasticd_${up_version}~${up_series}_source.changes
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for PR labels
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const labels = context.payload.pull_request.labels.map(label => label.name);
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
|
||||
- name: Comment test results on PR
|
||||
if: github.event_name == 'pull_request' && needs.native-tests.result != 'skipped'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Test Report
|
||||
uses: dorny/test-reporter@v2.6.0
|
||||
uses: dorny/test-reporter@v3.0.0
|
||||
with:
|
||||
name: PlatformIO Tests
|
||||
path: testreport.xml
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: latest
|
||||
|
||||
|
||||
@@ -54,3 +54,5 @@ CMakeLists.txt
|
||||
|
||||
# PYTHONPATH used by the Nix shell
|
||||
.python3
|
||||
.claude/scheduled_tasks.lock
|
||||
userPrefs.jsonc.mcp-session-bak
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"meshtastic": {
|
||||
"command": "./mcp-server/.venv/bin/python",
|
||||
"args": ["-m", "meshtastic_mcp"],
|
||||
"env": {
|
||||
"MESHTASTIC_FIRMWARE_ROOT": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -1,2 +1,28 @@
|
||||
[bandit]
|
||||
skips = B101
|
||||
# Rule IDs: https://bandit.readthedocs.io/en/latest/plugins/index.html
|
||||
#
|
||||
# B101 assert_used
|
||||
# pytest assertions + internal invariants; required for pytest.
|
||||
# B110 try_except_pass
|
||||
# best-effort cleanup paths (atexit handlers, pubsub unsubscribe,
|
||||
# session-end file close, socket shutdown). Logging inside the
|
||||
# except block would be worse than the silent pass — teardown is
|
||||
# already at end-of-session and the surrounding caller has context.
|
||||
# B112 try_except_continue
|
||||
# defensive loops over flaky sources (pubsub handlers, device
|
||||
# re-enumeration polls). One failed iteration shouldn't abort the loop.
|
||||
# B404 import_subprocess
|
||||
# mcp-server wraps PlatformIO, esptool, nrfutil, picotool, and the
|
||||
# pytest test-runner — subprocess is a load-bearing import here, not
|
||||
# a smell. The "consider possible security implications" advisory is
|
||||
# redundant given the file-level review already applied.
|
||||
# B603 subprocess_without_shell_equals_true
|
||||
# all subprocess calls use a static argv list; `shell=False` is the
|
||||
# default and we never string-interpolate user input into the command.
|
||||
# B606 start_process_with_no_shell
|
||||
# same invariant as B603 — running a binary via argv list (not
|
||||
# `shell=True`) is the safe pattern bandit is asking for.
|
||||
#
|
||||
# Higher-severity checks (B102 exec_used, B301 pickle, B307 eval,
|
||||
# B602 shell=True, etc.) remain enabled.
|
||||
skips = B101,B110,B112,B404,B603,B606
|
||||
+6
-6
@@ -8,27 +8,27 @@ plugins:
|
||||
uri: https://github.com/trunk-io/plugins
|
||||
lint:
|
||||
enabled:
|
||||
- checkov@3.2.510
|
||||
- renovate@43.84.0
|
||||
- checkov@3.2.517
|
||||
- renovate@43.110.9
|
||||
- prettier@3.8.1
|
||||
- trufflehog@3.93.8
|
||||
- trufflehog@3.94.3
|
||||
- yamllint@1.38.0
|
||||
- bandit@1.9.4
|
||||
- trivy@0.69.3
|
||||
- taplo@0.10.0
|
||||
- ruff@0.15.7
|
||||
- ruff@0.15.9
|
||||
- isort@8.0.1
|
||||
- markdownlint@0.48.0
|
||||
- oxipng@10.1.0
|
||||
- svgo@4.0.1
|
||||
- actionlint@1.7.11
|
||||
- actionlint@1.7.12
|
||||
- flake8@7.3.0
|
||||
- hadolint@2.14.0
|
||||
- shfmt@3.6.0
|
||||
- shellcheck@0.11.0
|
||||
- black@26.3.1
|
||||
- git-diff-check
|
||||
- gitleaks@8.30.0
|
||||
- gitleaks@8.30.1
|
||||
- clang-format@16.0.3
|
||||
ignore:
|
||||
- linters: [ALL]
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Agent instructions
|
||||
|
||||
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.
|
||||
|
||||
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`) |
|
||||
| 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) | `pio test -e native` |
|
||||
| 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` |
|
||||
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
|
||||
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
|
||||
|
||||
## MCP server (device + test automation)
|
||||
|
||||
The `mcp-server/` package 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`
|
||||
- **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.
|
||||
|
||||
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).
|
||||
|
||||
## Slash commands (AI-assisted workflows)
|
||||
|
||||
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
|
||||
|
||||
Bodies live in `.claude/commands/` and `.github/prompts/` respectively. `.claude/commands/README.md` is the index.
|
||||
|
||||
## 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.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.
|
||||
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
|
||||
|
||||
## Typical agent workflows
|
||||
|
||||
### Flashing a device
|
||||
|
||||
1. `list_devices` → find the port + likely VID
|
||||
2. `list_boards` → confirm the env, or use the known default for the hardware
|
||||
3. `pio_flash(env=..., port=..., confirm=True)` for any arch, or `erase_and_flash(env=..., port=..., confirm=True)` for an ESP32 factory install
|
||||
|
||||
### 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
|
||||
|
||||
Sequence these; don't parallelize on the same port.
|
||||
|
||||
### Testing a firmware change
|
||||
|
||||
1. Build locally: `pio run -e <env>`
|
||||
2. Flash the test device: `pio_flash(env=..., port=..., confirm=True)`
|
||||
3. Run the suite: `./mcp-server/run-tests.sh tests/<tier>` or `/test tests/<tier>`
|
||||
4. On failure, open `mcp-server/tests/report.html` → `Meshtastic debug` section for the firmware log tail + device state dump
|
||||
5. Iterate
|
||||
|
||||
### Debugging a flaky test
|
||||
|
||||
1. `/repro <test-node-id> [count]` — re-runs the test N times, diffs firmware logs between passes and failures
|
||||
2. If the first attempt always fails and the rest pass, that's a state-leak pattern → suggest `--force-bake` or a clean device state, don't chase the first failure
|
||||
3. If all N fail, this isn't a flake — it's a regression. Stop iterating and escalate to `/test` for full-suite context.
|
||||
|
||||
## 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 (12 suites; `pio test -e native`) |
|
||||
| `mcp-server/` | Python MCP server + pytest hardware integration tests |
|
||||
| `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/workflows/` | CI pipelines |
|
||||
| `.mcp.json` | MCP server registration for Claude Code |
|
||||
|
||||
## Recovery one-liners
|
||||
|
||||
- **`userPrefs.jsonc` dirty after a test run?** Re-run `./mcp-server/run-tests.sh` 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, then `pio_flash` re-installs.
|
||||
- **Device fully wedged (no DFU)?** `mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles it via USB hub PPPS. Needs `uhubctl` installed (`brew install uhubctl` / `apt install uhubctl`); on Linux without udev rules, permission errors fail fast, so use `sudo uhubctl` yourself or configure udev access.
|
||||
- **Port busy?** `lsof <port>` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it.
|
||||
- **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned.
|
||||
|
||||
## 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_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`. |
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: BananaPi-BPI-R4-sx1262
|
||||
support: community
|
||||
compatible:
|
||||
- bananapi_bpi-r4 # OpenWrt target
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # BananaPi-BPI-R4 SPI via 26p GPIO Header
|
||||
## CS: 28
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
## https://www.mikroe.com/lr-iot-click
|
||||
Meta:
|
||||
name: OpenWRT One mikroBUS LR-IOT-CLICK
|
||||
support: community
|
||||
compatible:
|
||||
- openwrt_one # OpenWrt target
|
||||
|
||||
Lora:
|
||||
Module: lr1110 # OpenWRT ONE mikroBUS with LR-IOT-CLICK
|
||||
# CS: 25
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: OpenWRT One mikroBUS sx1262
|
||||
support: community
|
||||
compatible:
|
||||
- openwrt_one # OpenWrt target
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: 10
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# meshtasticd configuration files
|
||||
|
||||
This directory contains YAML configuration files for meshtasticd. Each file describes a specific hardware configuration, including the LoRa module and pin assignments. These configurations are used by meshtasticd to correctly interface with the hardware.
|
||||
|
||||
## Metadata structure
|
||||
|
||||
Each configuration file includes a `Meta` section that provides information about the configuration.
|
||||
This configuration is consumed by configuration-selection tools.
|
||||
|
||||
```yaml
|
||||
Meta:
|
||||
name: MeshAdv-Pi E22-900M30S # A unique identifier for this configuration.
|
||||
support: community # community, official, or deprecated; determined by Meshtastic Leads.
|
||||
compatible: # A list of compatible products or platforms.
|
||||
- raspberry-pi
|
||||
```
|
||||
`name`: A unique identifier for the configuration, typically reflecting the hardware it supports.
|
||||
|
||||
`support`: Indicates the level of support for this configuration. It can be one of the following:
|
||||
|
||||
- `community`: Supported by the Meshtastic community. Meshtastic Members may not possess, or have not tested this configuration.
|
||||
- `official`: Fully supported by Meshtastic. Meshtastic Members have tested and verified this configuration.
|
||||
- `deprecated`: No longer recommended for deployment by Meshtastic.
|
||||
|
||||
`compatible`: A list of compatible products or platforms that can use this configuration.
|
||||
This will vary depending on the intended use case / platform.
|
||||
Multiple compatible entries can be included. E.g. Armbian `BOARD` value or OpenWrt `TARGET` value.
|
||||
These tags can be consumed by different configuration-selection tools, filtering based upon their platform/etc.
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: Waveshare 1.44inch LCD HAT
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
### Waveshare 1.44inch LCD HAT
|
||||
Display:
|
||||
Panel: ST7735S
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: Waveshare 2.8inch LCD HAT
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Display:
|
||||
|
||||
### Waveshare 2.8inch RPi LCD
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: Adafruit RFM9x
|
||||
support: deprecated
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: RF95 # Adafruit RFM9x
|
||||
Reset: 25
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# MeshAdv-Pi E22-900M30S
|
||||
# https://github.com/chrismyers2000/MeshAdv-Pi-Hat
|
||||
Meta:
|
||||
name: MeshAdv-Pi E22-900M30S
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
CS: 21
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# MeshAdv Mini E22-900M22S
|
||||
# https://github.com/chrismyers2000/MeshAdv-Mini
|
||||
Meta:
|
||||
name: MeshAdv Mini E22-900M22S
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Ebyte E22-900M22S
|
||||
CS: 8
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13300 Slot 1
|
||||
support: official
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
|
||||
### RAK13300in Slot 1
|
||||
### RAK13300 in Slot 1
|
||||
Module: sx1262
|
||||
IRQ: 22 #IO6
|
||||
Reset: 16 # IO4
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13300 Slot 2
|
||||
support: official
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
### RAK13300in Slot 2 pins
|
||||
### RAK13300 in Slot 2 pins
|
||||
IRQ: 18 #IO6
|
||||
Reset: 24 # IO4
|
||||
Busy: 19 # IO5
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13302 Slot 1
|
||||
support: official
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
|
||||
### RAK13300in Slot 1
|
||||
### RAK13302 in Slot 1
|
||||
Module: sx1262
|
||||
IRQ: 22 #IO6
|
||||
Reset: 16 # IO4
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13302 Slot 2
|
||||
support: official
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
### RAK13300in Slot 2 pins
|
||||
### RAK13302 in Slot 2 pins
|
||||
IRQ: 18 #IO6
|
||||
Reset: 24 # IO4
|
||||
Busy: 19 # IO5
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# MeshAdv-Pi E22-900M30S
|
||||
# https://github.com/chrismyers2000/MeshAdv-Pi-Hat
|
||||
Meta:
|
||||
name: MeshAdv-Pi E22-900M30S
|
||||
support: community
|
||||
compatible:
|
||||
- ebyte-ecb41-pge # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
CS: # GPIO0_A1 (physical 40)
|
||||
pin: 1
|
||||
gpiochip: 0
|
||||
line: 1
|
||||
IRQ: # GPIO0_A3 (physical 36)
|
||||
pin: 3
|
||||
gpiochip: 0
|
||||
line: 3
|
||||
Busy: # GPIO0_A0 (physical 38)
|
||||
pin: 0
|
||||
gpiochip: 0
|
||||
line: 0
|
||||
Reset: # GPIO0_B4 (physical 12)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
TXen: # GPIO1_D1 (physical 33)
|
||||
pin: 57
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
RXen: # GPIO1_B3 (physical 32)
|
||||
pin: 43
|
||||
gpiochip: 1
|
||||
line: 11
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
# Only for E22-900M33S:
|
||||
# Limit the output power to 8 dBm
|
||||
# SX126X_MAX_POWER: 8
|
||||
spidev: spidev0.0
|
||||
@@ -0,0 +1,33 @@
|
||||
# MeshAdv Mini E22-900M22S
|
||||
# https://github.com/chrismyers2000/MeshAdv-Mini
|
||||
Meta:
|
||||
name: MeshAdv Mini E22-900M22S
|
||||
support: community
|
||||
compatible:
|
||||
- ebyte-ecb41-pge # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Ebyte E22-900M22S
|
||||
CS: # GPIO0_B6 (physical 24, SPI1_CSN0)
|
||||
pin: 14
|
||||
gpiochip: 0
|
||||
line: 14
|
||||
IRQ: # GPIO0_A3 (physical 36)
|
||||
pin: 3
|
||||
gpiochip: 0
|
||||
line: 3
|
||||
Busy: # GPIO0_A0 (physical 38)
|
||||
pin: 0
|
||||
gpiochip: 0
|
||||
line: 0
|
||||
Reset: # GPIO1_C3 (physical 18)
|
||||
pin: 51
|
||||
gpiochip: 1
|
||||
line: 19
|
||||
RXen: # GPIO1_B3 (physical 32)
|
||||
pin: 43
|
||||
gpiochip: 1
|
||||
line: 11
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
spidev: spidev0.0
|
||||
@@ -0,0 +1,38 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13300 Slot 1
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- ebyte-ecb41-pge # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_A5 (IO6, physical 15)
|
||||
pin: 5
|
||||
gpiochip: 0
|
||||
line: 5
|
||||
Reset: # GPIO0_A3 (IO4, physical 36)
|
||||
pin: 3
|
||||
gpiochip: 0
|
||||
line: 3
|
||||
Busy: # GPIO1_C3 (IO5, physical 18)
|
||||
pin: 51
|
||||
gpiochip: 1
|
||||
line: 19
|
||||
# Ant_sw: # GPIO1_C2 (IO3, physical 16)
|
||||
# pin: 50
|
||||
# gpiochip: 1
|
||||
# line: 18
|
||||
Enable_Pins:
|
||||
- pin: 43 # GPIO1_B3 (physical 32)
|
||||
gpiochip: 1
|
||||
line: 11
|
||||
- pin: 57 # GPIO1_D1 (physical 33)
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: # GPIO0_B6 (SPI1_CSN0, physical 24)
|
||||
# pin: 14
|
||||
# gpiochip: 0
|
||||
# line: 14
|
||||
@@ -0,0 +1,36 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13300 Slot 2
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- ebyte-ecb41-pge # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_B4 (IO6, physical 12)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
Reset: # GPIO1_C3 (IO4, physical 18)
|
||||
pin: 51
|
||||
gpiochip: 1
|
||||
line: 19
|
||||
Busy: # GPIO0_B3 (IO5, physical 35)
|
||||
pin: 11
|
||||
gpiochip: 0
|
||||
line: 11
|
||||
# Ant_sw: # GPIO1_C2 (IO3, physical 16)
|
||||
# pin: 50
|
||||
# gpiochip: 1
|
||||
# line: 18
|
||||
Enable_Pins:
|
||||
- pin: 2 # GPIO0_A2 (physical 37)
|
||||
gpiochip: 0
|
||||
line: 2
|
||||
- pin: 50 # GPIO1_C2 (physical 16)
|
||||
gpiochip: 1
|
||||
line: 18
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_A7 (SPI1_CSN1, physical 26)
|
||||
# pin: 7
|
||||
# gpiochip: 0
|
||||
# line: 7
|
||||
@@ -0,0 +1,39 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13302 Slot 1
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- ebyte-ecb41-pge # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_A5 (IO6, physical 15)
|
||||
pin: 5
|
||||
gpiochip: 0
|
||||
line: 5
|
||||
Reset: # GPIO0_A3 (IO4, physical 36)
|
||||
pin: 3
|
||||
gpiochip: 0
|
||||
line: 3
|
||||
Busy: # GPIO1_C3 (IO5, physical 18)
|
||||
pin: 51
|
||||
gpiochip: 1
|
||||
line: 19
|
||||
# Ant_sw: # GPIO1_C2 (IO3, physical 16)
|
||||
# pin: 50
|
||||
# gpiochip: 1
|
||||
# line: 18
|
||||
Enable_Pins:
|
||||
- pin: 43 # GPIO1_B3 (physical 32)
|
||||
gpiochip: 1
|
||||
line: 11
|
||||
- pin: 57 # GPIO1_D1 (physical 33)
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: # GPIO0_B6 (SPI1_CSN0, physical 24)
|
||||
# pin: 14
|
||||
# gpiochip: 0
|
||||
# line: 14
|
||||
TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
|
||||
@@ -0,0 +1,37 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13302 Slot 2
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- ebyte-ecb41-pge # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_B4 (IO6, physical 12)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
Reset: # GPIO1_C3 (IO4, physical 18)
|
||||
pin: 51
|
||||
gpiochip: 1
|
||||
line: 19
|
||||
Busy: # GPIO0_B3 (IO5, physical 35)
|
||||
pin: 11
|
||||
gpiochip: 0
|
||||
line: 11
|
||||
# Ant_sw: # GPIO1_C2 (IO3, physical 16)
|
||||
# pin: 50
|
||||
# gpiochip: 1
|
||||
# line: 18
|
||||
Enable_Pins:
|
||||
- pin: 2 # GPIO0_A2 (physical 37)
|
||||
gpiochip: 0
|
||||
line: 2
|
||||
- pin: 50 # GPIO1_C2 (physical 16)
|
||||
gpiochip: 1
|
||||
line: 18
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_A7 (SPI1_CSN1, physical 26)
|
||||
# pin: 7
|
||||
# gpiochip: 0
|
||||
# line: 7
|
||||
TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
|
||||
@@ -0,0 +1,30 @@
|
||||
Meta:
|
||||
name: Waveshare SX1262
|
||||
support: deprecated
|
||||
compatible:
|
||||
- ebyte-ecb41-pge # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Waveshare SX126X XXXM
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
CS: # GPIO0_A1 (physical 40)
|
||||
pin: 1
|
||||
gpiochip: 0
|
||||
line: 1
|
||||
IRQ: # GPIO0_A3 (physical 36)
|
||||
pin: 3
|
||||
gpiochip: 0
|
||||
line: 3
|
||||
Busy: # GPIO0_A0 (physical 38)
|
||||
pin: 0
|
||||
gpiochip: 0
|
||||
line: 0
|
||||
Reset: # GPIO0_B4 (physical 12)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
SX126X_ANT_SW: # GPIO1_B2 (physical 31)
|
||||
pin: 42
|
||||
gpiochip: 1
|
||||
line: 10
|
||||
spidev: spidev0.0
|
||||
+22
-20
@@ -1,20 +1,22 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
---
|
||||
Meta:
|
||||
name: Femtofox Ebyte E80-900M22S with TCXO
|
||||
support: community
|
||||
compatible:
|
||||
- luckfox-pico-mini # Armbian
|
||||
|
||||
Lora:
|
||||
## Ebyte E80-900M22S
|
||||
## This is a bit experimental
|
||||
##
|
||||
##
|
||||
Module: lr1121
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO3_TCXO_VOLTAGE: 1.8
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
+24
-21
@@ -1,21 +1,24 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E22-900M30S, E22-900M22S with or without external RF switching setup
|
||||
## HT-RA62 (Has internal switching, but whatever)
|
||||
## Seeed WIO SX1262 (already has TXEN-DIO2 link, but needs RXEN)
|
||||
## Will work with any module with or without RF switching, and with TCXO
|
||||
Module: sx1262
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
RXen: 24 #pin12 / GPIO56 1D0 # Not strictly needed for auto-switching, but why complicate things?
|
||||
# TXen: bridge to DIO2 on E22 module
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
---
|
||||
Meta:
|
||||
name: Femtofox SX1262 TCXO
|
||||
support: community
|
||||
compatible:
|
||||
- luckfox-pico-mini # Armbian
|
||||
|
||||
Lora:
|
||||
## Ebyte E22-900M30S, E22-900M22S with or without external RF switching setup
|
||||
## HT-RA62 (Has internal switching, but whatever)
|
||||
## Seeed WIO SX1262 (already has TXEN-DIO2 link, but needs RXEN)
|
||||
## Will work with any module with or without RF switching, and with TCXO
|
||||
Module: sx1262
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
RXen: 24 #pin12 / GPIO56 1D0 # Not strictly needed for auto-switching, but why complicate things?
|
||||
# TXen: bridge to DIO2 on E22 module
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
+24
-21
@@ -1,21 +1,24 @@
|
||||
---
|
||||
Lora:
|
||||
## Ebyte E22-900MM22S with no external RF switching setup
|
||||
## Waveshare SX126X XXXM, AI Thinker RA-01SH
|
||||
## Will work with any module with or without RF switching and no TCXO
|
||||
|
||||
Module: sx1262
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: false
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
RXen: 24 #pin12 / GPIO56 1D0 # Not strictly needed for auto-switching, but why complicate things?
|
||||
# TXen: bridge to DIO2 on E22 module
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
---
|
||||
Meta:
|
||||
name: Femtofox SX1262 XTAL
|
||||
support: community
|
||||
compatible:
|
||||
- luckfox-pico-mini # Armbian
|
||||
|
||||
Lora:
|
||||
## Ebyte E22-900MM22S with no external RF switching setup
|
||||
## Waveshare SX126X XXXM, AI Thinker RA-01SH
|
||||
## Will work with any module with or without RF switching and no TCXO
|
||||
|
||||
Module: sx1262
|
||||
gpiochip: 1 # subtract 32 from the gpio numbers
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: false
|
||||
CS: 16 #pin6 / GPIO48 1C0
|
||||
IRQ: 23 #pin17 / GPIO55 1C7
|
||||
Busy: 22 #pin16 / GPIO54 1C6
|
||||
Reset: 25 #pin13 / GPIO57 1D1
|
||||
RXen: 24 #pin12 / GPIO56 1D0 # Not strictly needed for auto-switching, but why complicate things?
|
||||
# TXen: bridge to DIO2 on E22 module
|
||||
spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19)
|
||||
spiSpeed: 2000000
|
||||
@@ -1,6 +1,12 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13300 Slot 1 (Autoconf default)
|
||||
support: official
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
|
||||
### RAK13300in Slot 1
|
||||
### RAK13300 in Slot 1
|
||||
Module: sx1262
|
||||
IRQ: 22 #IO6
|
||||
Reset: 16 # IO4
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: Luckfox Lyra PicoCalc Wio LoRa SX1262
|
||||
support: official
|
||||
compatible:
|
||||
- luckfox-lyra-plus # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
# Enable overlay 'luckfox-lyra-ultra-w-spi0-cs0-spidev' with armbian-config
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/Luckfox%20Ultra%20Hat
|
||||
# 1 Watt Lyra Ultra hat
|
||||
Meta:
|
||||
name: wehooper4 Luckfox Ultra 1W
|
||||
support: community
|
||||
compatible:
|
||||
- luckfox-pico-ultra # Armbian
|
||||
- luckfox-lyra-ultra # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
# Enable overlay 'luckfox-lyra-ultra-w-spi0-cs0-spidev' with armbian-config
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/Luckfox%20Ultra%20Hat
|
||||
# 2 Watt Lyra Ultra hat
|
||||
Meta:
|
||||
name: wehooper4 Luckfox Ultra 2W
|
||||
support: community
|
||||
compatible:
|
||||
- luckfox-pico-ultra # Armbian
|
||||
- luckfox-lyra-ultra # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
# Enable overlay 'luckfox-lyra-plus-spi0-cs0_rmio13-spidev' with armbian-config
|
||||
# Waveshare LoRa HAT for Raspberry Pi Pico
|
||||
# https://www.waveshare.com/wiki/Pico-LoRa-SX1262
|
||||
Meta:
|
||||
name: Waveshare LoRa HAT for Raspberry Pi Pico
|
||||
support: community
|
||||
compatible:
|
||||
- luckfox-lyra-plus # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# MeshAdv-Pi E22-900M30S
|
||||
# https://github.com/chrismyers2000/MeshAdv-Pi-Hat
|
||||
Meta:
|
||||
name: MeshAdv-Pi E22-900M30S
|
||||
support: community
|
||||
compatible:
|
||||
- luckfox-lyra-zero-w # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
CS: # GPIO0_C2 (physical 40)
|
||||
pin: 18
|
||||
gpiochip: 0
|
||||
line: 18
|
||||
IRQ: # GPIO1_D1 (physical 36)
|
||||
pin: 57
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
Busy: # GPIO0_C1 (physical 38)
|
||||
pin: 17
|
||||
gpiochip: 0
|
||||
line: 17
|
||||
Reset: # GPIO0_B6 (physical 12)
|
||||
pin: 14
|
||||
gpiochip: 0
|
||||
line: 14
|
||||
TXen: # GPIO1_C2 (physical 33)
|
||||
pin: 50
|
||||
gpiochip: 1
|
||||
line: 18
|
||||
RXen: # GPIO1_D2 (physical 32)
|
||||
pin: 58
|
||||
gpiochip: 1
|
||||
line: 26
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
# Only for E22-900M33S:
|
||||
# Limit the output power to 8 dBm
|
||||
# SX126X_MAX_POWER: 8
|
||||
spidev: spidev0.0
|
||||
@@ -0,0 +1,33 @@
|
||||
# MeshAdv Mini E22-900M22S
|
||||
# https://github.com/chrismyers2000/MeshAdv-Mini
|
||||
Meta:
|
||||
name: MeshAdv Mini E22-900M22S
|
||||
support: community
|
||||
compatible:
|
||||
- luckfox-lyra-zero-w # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Ebyte E22-900M22S
|
||||
CS: # GPIO0_B2_d (phys 24, RPi CE0)
|
||||
pin: 10
|
||||
gpiochip: 0
|
||||
line: 10
|
||||
IRQ: # GPIO1_D1_d (phys 36, RPi GPIO16)
|
||||
pin: 57
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
Busy: # GPIO0_C1_d (phys 38, RPi GPIO20)
|
||||
pin: 17
|
||||
gpiochip: 0
|
||||
line: 17
|
||||
Reset: # GPIO0_B4_d (phys 18, RPi GPIO24)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
RXen: # GPIO1_D2_d (phys 32, RPi GPIO12)
|
||||
pin: 58
|
||||
gpiochip: 1
|
||||
line: 26
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
spidev: spidev0.0
|
||||
@@ -0,0 +1,38 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13300 Slot 1
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- luckfox-lyra-zero-w # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_A5 (IO6)
|
||||
pin: 5
|
||||
gpiochip: 0
|
||||
line: 5
|
||||
Reset: # GPIO1_D1 (IO4)
|
||||
pin: 57
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
Busy: # GPIO0_B4 (IO5)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
# Ant_sw: # GPIO1_C2 (IO3)
|
||||
# pin: 50
|
||||
# gpiochip: 1
|
||||
# line: 18
|
||||
Enable_Pins:
|
||||
- pin: 58 # GPIO1_D2
|
||||
gpiochip: 1
|
||||
line: 26
|
||||
- pin: 50 # GPIO1_C2
|
||||
gpiochip: 1
|
||||
line: 18
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: # GPIO0_B2
|
||||
# pin: 10
|
||||
# gpiochip: 0
|
||||
# line: 10
|
||||
@@ -0,0 +1,36 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13300 Slot 2
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- luckfox-lyra-zero-w # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_B6 (IO6)
|
||||
pin: 14
|
||||
gpiochip: 0
|
||||
line: 14
|
||||
Reset: # GPIO0_B4 (IO4)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
Busy: # GPIO1_C0 (IO5)
|
||||
pin: 48
|
||||
gpiochip: 1
|
||||
line: 16
|
||||
# Ant_sw: # GPIO0_B5 (IO3)
|
||||
# pin: 13
|
||||
# gpiochip: 0
|
||||
# line: 13
|
||||
Enable_Pins:
|
||||
- pin: 51 # GPIO1_C3
|
||||
gpiochip: 1
|
||||
line: 19
|
||||
- pin: 13 # GPIO0_B5
|
||||
gpiochip: 0
|
||||
line: 13
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B1
|
||||
# pin: 9
|
||||
# gpiochip: 0
|
||||
# line: 9
|
||||
@@ -0,0 +1,39 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13300 Slot 1
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- luckfox-lyra-zero-w # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_A5 (IO6)
|
||||
pin: 5
|
||||
gpiochip: 0
|
||||
line: 5
|
||||
Reset: # GPIO1_D1 (IO4)
|
||||
pin: 57
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
Busy: # GPIO0_B4 (IO5)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
# Ant_sw: # GPIO1_C2 (IO3)
|
||||
# pin: 50
|
||||
# gpiochip: 1
|
||||
# line: 18
|
||||
Enable_Pins:
|
||||
- pin: 58 # GPIO1_D2
|
||||
gpiochip: 1
|
||||
line: 26
|
||||
- pin: 50 # GPIO1_C2
|
||||
gpiochip: 1
|
||||
line: 18
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: # GPIO0_B2
|
||||
# pin: 10
|
||||
# gpiochip: 0
|
||||
# line: 10
|
||||
TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
|
||||
@@ -0,0 +1,37 @@
|
||||
Meta:
|
||||
name: RAK6421 + RAK13300 Slot 2
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- luckfox-lyra-zero-w # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_B6 (IO6)
|
||||
pin: 14
|
||||
gpiochip: 0
|
||||
line: 14
|
||||
Reset: # GPIO0_B4 (IO4)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
Busy: # GPIO1_C0 (IO5)
|
||||
pin: 48
|
||||
gpiochip: 1
|
||||
line: 16
|
||||
# Ant_sw: # GPIO0_B5 (IO3)
|
||||
# pin: 13
|
||||
# gpiochip: 0
|
||||
# line: 13
|
||||
Enable_Pins:
|
||||
- pin: 51 # GPIO1_C3
|
||||
gpiochip: 1
|
||||
line: 19
|
||||
- pin: 13 # GPIO0_B5
|
||||
gpiochip: 0
|
||||
line: 13
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B1
|
||||
# pin: 9
|
||||
# gpiochip: 0
|
||||
# line: 9
|
||||
TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
|
||||
@@ -0,0 +1,30 @@
|
||||
Meta:
|
||||
name: Waveshare SX1262
|
||||
support: deprecated
|
||||
compatible:
|
||||
- luckfox-lyra-zero-w # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Waveshare SX126X XXXM
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
CS: # GPIO0_C2 (physical 40)
|
||||
pin: 18
|
||||
gpiochip: 0
|
||||
line: 18
|
||||
IRQ: # GPIO1_D1 (physical 36)
|
||||
pin: 57
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
Busy: # GPIO0_C1 (physical 38)
|
||||
pin: 17
|
||||
gpiochip: 0
|
||||
line: 17
|
||||
Reset: # GPIO0_B6 (physical 12)
|
||||
pin: 14
|
||||
gpiochip: 0
|
||||
line: 14
|
||||
SX126X_ANT_SW: # GPIO1_B3 (physical 31)
|
||||
pin: 43
|
||||
gpiochip: 1
|
||||
line: 11
|
||||
spidev: spidev0.0
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: Lora Meshstick SX1262
|
||||
support: official
|
||||
compatible:
|
||||
- usb
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
CS: 0
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# !! WARNING: Hats on the OK3506 board are installed "backwards" (facing outwards)
|
||||
|
||||
# MeshAdv-Pi E22-900M30S
|
||||
# https://github.com/chrismyers2000/MeshAdv-Pi-Hat
|
||||
Meta:
|
||||
name: MeshAdv-Pi E22-900M30S
|
||||
support: community
|
||||
compatible:
|
||||
- forlinx-ok3506-s12 # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
CS: # GPIO0_B0 (physical 40)
|
||||
pin: 8
|
||||
gpiochip: 0
|
||||
line: 8
|
||||
IRQ: # GPIO3_B0 (physical 36)
|
||||
pin: 104
|
||||
gpiochip: 3
|
||||
line: 8
|
||||
Busy: # GPIO0_B1 (physical 38)
|
||||
pin: 9
|
||||
gpiochip: 0
|
||||
line: 9
|
||||
Reset: # GPIO0_B6 (physical 12)
|
||||
pin: 14
|
||||
gpiochip: 0
|
||||
line: 14
|
||||
TXen: # GPIO0_A3 (physical 33)
|
||||
pin: 3
|
||||
gpiochip: 0
|
||||
line: 3
|
||||
RXen: # GPIO0_A2 (physical 32)
|
||||
pin: 2
|
||||
gpiochip: 0
|
||||
line: 2
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
# Only for E22-900M33S:
|
||||
# Limit the output power to 8 dBm
|
||||
# SX126X_MAX_POWER: 8
|
||||
spidev: spidev0.0
|
||||
@@ -0,0 +1,35 @@
|
||||
# !! WARNING: Hats on the OK3506 board are installed "backwards" (facing outwards)
|
||||
|
||||
# MeshAdv Mini E22-900M22S
|
||||
# https://github.com/chrismyers2000/MeshAdv-Mini
|
||||
Meta:
|
||||
name: MeshAdv Mini E22-900M22S
|
||||
support: community
|
||||
compatible:
|
||||
- forlinx-ok3506-s12 # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Ebyte E22-900M22S
|
||||
CS: # GPIO0_C3 (physical 24, SPI0_CSN0)
|
||||
pin: 19
|
||||
gpiochip: 0
|
||||
line: 19
|
||||
IRQ: # GPIO3_B0 (physical 36)
|
||||
pin: 104
|
||||
gpiochip: 3
|
||||
line: 8
|
||||
Busy: # GPIO0_B1 (physical 38)
|
||||
pin: 9
|
||||
gpiochip: 0
|
||||
line: 9
|
||||
Reset: # GPIO3_A6 (physical 18)
|
||||
pin: 102
|
||||
gpiochip: 3
|
||||
line: 6
|
||||
RXen: # GPIO0_A2 (physical 32)
|
||||
pin: 2
|
||||
gpiochip: 0
|
||||
line: 2
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
spidev: spidev0.0
|
||||
@@ -0,0 +1,40 @@
|
||||
# !! WARNING: Hats on the OK3506 board are installed "backwards" (facing outwards)
|
||||
|
||||
Meta:
|
||||
name: RAK6421 + RAK13300 Slot 1
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- forlinx-ok3506-s12 # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO3_B5 (IO6, physical 15)
|
||||
pin: 109
|
||||
gpiochip: 3
|
||||
line: 13
|
||||
Reset: # GPIO3_B0 (IO4, physical 36)
|
||||
pin: 104
|
||||
gpiochip: 3
|
||||
line: 8
|
||||
Busy: # GPIO3_A6 (IO5, physical 18)
|
||||
pin: 102
|
||||
gpiochip: 3
|
||||
line: 6
|
||||
# Ant_sw: # GPIO0_A3 (IO3, physical 33)
|
||||
# pin: 3
|
||||
# gpiochip: 0
|
||||
# line: 3
|
||||
Enable_Pins:
|
||||
- pin: 2 # GPIO0_A2 (physical 32)
|
||||
gpiochip: 0
|
||||
line: 2
|
||||
- pin: 3 # GPIO0_A3 (physical 33)
|
||||
gpiochip: 0
|
||||
line: 3
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: # GPIO0_C3 (SPI0_CSN0, physical 24)
|
||||
# pin: 19
|
||||
# gpiochip: 0
|
||||
# line: 19
|
||||
@@ -0,0 +1,38 @@
|
||||
# !! WARNING: Hats on the OK3506 board are installed "backwards" (facing outwards)
|
||||
|
||||
Meta:
|
||||
name: RAK6421 + RAK13300 Slot 2
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- forlinx-ok3506-s12 # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_B6 (IO6, physical 12)
|
||||
pin: 14
|
||||
gpiochip: 0
|
||||
line: 14
|
||||
Reset: # GPIO3_A6 (IO4, physical 18)
|
||||
pin: 102
|
||||
gpiochip: 3
|
||||
line: 6
|
||||
Busy: # GPIO0_B2 (IO5, physical 35)
|
||||
pin: 10
|
||||
gpiochip: 0
|
||||
line: 10
|
||||
# Ant_sw: # GPIO3_A7 (IO3, physical 16)
|
||||
# pin: 103
|
||||
# gpiochip: 3
|
||||
# line: 7
|
||||
Enable_Pins:
|
||||
- pin: 106 # GPIO3_B2 (physical 37)
|
||||
gpiochip: 3
|
||||
line: 10
|
||||
- pin: 103 # GPIO3_A7 (physical 16)
|
||||
gpiochip: 3
|
||||
line: 7
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B7 (SPI0_CSN1, physical 26)
|
||||
# pin: 15
|
||||
# gpiochip: 0
|
||||
# line: 15
|
||||
@@ -0,0 +1,41 @@
|
||||
# !! WARNING: Hats on the OK3506 board are installed "backwards" (facing outwards)
|
||||
|
||||
Meta:
|
||||
name: RAK6421 + RAK13302 Slot 1
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- forlinx-ok3506-s12 # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO3_B5 (IO6, physical 15)
|
||||
pin: 109
|
||||
gpiochip: 3
|
||||
line: 13
|
||||
Reset: # GPIO3_B0 (IO4, physical 36)
|
||||
pin: 104
|
||||
gpiochip: 3
|
||||
line: 8
|
||||
Busy: # GPIO3_A6 (IO5, physical 18)
|
||||
pin: 102
|
||||
gpiochip: 3
|
||||
line: 6
|
||||
# Ant_sw: # GPIO0_A3 (IO3, physical 33)
|
||||
# pin: 3
|
||||
# gpiochip: 0
|
||||
# line: 3
|
||||
Enable_Pins:
|
||||
- pin: 2 # GPIO0_A2 (physical 32)
|
||||
gpiochip: 0
|
||||
line: 2
|
||||
- pin: 3 # GPIO0_A3 (physical 33)
|
||||
gpiochip: 0
|
||||
line: 3
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: # GPIO0_C3 (SPI0_CSN0, physical 24)
|
||||
# pin: 19
|
||||
# gpiochip: 0
|
||||
# line: 19
|
||||
TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
|
||||
@@ -0,0 +1,39 @@
|
||||
# !! WARNING: Hats on the OK3506 board are installed "backwards" (facing outwards)
|
||||
|
||||
Meta:
|
||||
name: RAK6421 + RAK13302 Slot 2
|
||||
support: community # Promote when tested
|
||||
compatible:
|
||||
- forlinx-ok3506-s12 # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_B6 (IO6, physical 12)
|
||||
pin: 14
|
||||
gpiochip: 0
|
||||
line: 14
|
||||
Reset: # GPIO3_A6 (IO4, physical 18)
|
||||
pin: 102
|
||||
gpiochip: 3
|
||||
line: 6
|
||||
Busy: # GPIO0_B2 (IO5, physical 35)
|
||||
pin: 10
|
||||
gpiochip: 0
|
||||
line: 10
|
||||
# Ant_sw: # GPIO3_A7 (IO3, physical 16)
|
||||
# pin: 103
|
||||
# gpiochip: 3
|
||||
# line: 7
|
||||
Enable_Pins:
|
||||
- pin: 106 # GPIO3_B2 (physical 37)
|
||||
gpiochip: 3
|
||||
line: 10
|
||||
- pin: 103 # GPIO3_A7 (physical 16)
|
||||
gpiochip: 3
|
||||
line: 7
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B7 (SPI0_CSN1, physical 26)
|
||||
# pin: 15
|
||||
# gpiochip: 0
|
||||
# line: 15
|
||||
TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
|
||||
@@ -0,0 +1,32 @@
|
||||
# !! WARNING: Hats on the OK3506 board are installed "backwards" (facing outwards)
|
||||
|
||||
Meta:
|
||||
name: Waveshare SX1262
|
||||
support: deprecated
|
||||
compatible:
|
||||
- forlinx-ok3506-s12 # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Waveshare SX126X XXXM
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
CS: # GPIO0_B0 (physical 40)
|
||||
pin: 8
|
||||
gpiochip: 0
|
||||
line: 8
|
||||
IRQ: # GPIO3_B0 (physical 36)
|
||||
pin: 104
|
||||
gpiochip: 3
|
||||
line: 8
|
||||
Busy: # GPIO0_B1 (physical 38)
|
||||
pin: 9
|
||||
gpiochip: 0
|
||||
line: 9
|
||||
Reset: # GPIO0_B6 (physical 12)
|
||||
pin: 14
|
||||
gpiochip: 0
|
||||
line: 14
|
||||
SX126X_ANT_SW: # GPIO3_B3 (physical 31)
|
||||
pin: 107
|
||||
gpiochip: 3
|
||||
line: 11
|
||||
spidev: spidev0.0
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: Lora Meshstick SX1262
|
||||
support: community
|
||||
compatible:
|
||||
- usb
|
||||
|
||||
Lora:
|
||||
Module: lr1121
|
||||
CS: 0
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
Meta:
|
||||
name: Pinedio USB SX1262
|
||||
support: deprecated
|
||||
compatible:
|
||||
- usb
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
CS: 0
|
||||
IRQ: 10
|
||||
spidev: ch341
|
||||
spidev: ch341
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: raxda-rock2f-starter-edition-hat
|
||||
support: community
|
||||
compatible:
|
||||
- rock-2f # Armbian
|
||||
|
||||
Lora:
|
||||
|
||||
### Raxda Rock 2F running Armbian Linux 6.1.99-vendor-rk35xx
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# https://www.waveshare.com/core1262-868m.htm
|
||||
# https://github.com/markbirss/lora-starter-edition-sx1262-i2c
|
||||
Meta:
|
||||
name: lora-starter-edition-sx1262-i2c
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Starter Edition SX1262 I2C Raspberry Pi HAT
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: meshstick-1262
|
||||
support: official
|
||||
compatible:
|
||||
- usb
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
CS: 0
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: meshtoad-e22
|
||||
support: official
|
||||
compatible:
|
||||
- usb
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
CS: 0
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: umesh-1262-30dbm
|
||||
support: community
|
||||
compatible:
|
||||
- clockwork-uconsole
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
CS: 0
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: umesh-1268-30dbm
|
||||
support: community
|
||||
compatible:
|
||||
- clockwork-uconsole
|
||||
|
||||
Lora:
|
||||
Module: sx1268
|
||||
CS: 0
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
Meta:
|
||||
name: Waveshare SX1262
|
||||
support: deprecated
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Waveshare SX126X XXXM
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# https://www.waveshare.com/pico-lora-sx1262-868m.htm
|
||||
# https://github.com/markbirss/lora-ws-raspberry-pi-pico-to-rpi-adapter
|
||||
|
||||
Meta:
|
||||
name: ws-raspberry-pico-to-rpi-adapter
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Waveshare Raspberry Pi Pico to Raspberry Pi HAT Adapter
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
# 5 CS 24
|
||||
# 26 DIO1/IRQ 26
|
||||
|
||||
Meta:
|
||||
name: ws-raspberry-pico-to-orangepi-03
|
||||
support: community
|
||||
compatible:
|
||||
- orange-pi-zero-3 # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Waveshare Raspberry Pico Lora module
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
|
||||
@@ -43,7 +43,7 @@ for pio_env in pio_envs:
|
||||
env = {
|
||||
"ci": {"board": pio_env, "platform": env_platform},
|
||||
"board_level": cfg.get(f"env:{pio_env}", "board_level", default=None),
|
||||
"board_check": bool(cfg.get(f"env:{pio_env}", "board_check", default=False)),
|
||||
"board_check": cfg.get(f"env:{pio_env}", "board_check", default="false").strip().lower() == "true",
|
||||
}
|
||||
all_envs.append(env)
|
||||
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate release notes from merged PRs on develop and master branches.
|
||||
Categorizes PRs into Enhancements and Bug Fixes/Maintenance sections.
|
||||
"""
|
||||
"""Generate release notes from the actual release commit range."""
|
||||
|
||||
import subprocess
|
||||
import re
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def get_last_release_tag():
|
||||
"""Get the most recent release tag."""
|
||||
def get_last_release_tag(compare_ref, exclude_tag=None):
|
||||
"""Get the most recent version tag merged into compare_ref."""
|
||||
result = subprocess.run(
|
||||
["git", "describe", "--tags", "--abbrev=0"],
|
||||
["git", "tag", "--merged", compare_ref, "--sort=-version:refname", "v*"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
candidate = line.strip()
|
||||
if not candidate:
|
||||
continue
|
||||
if exclude_tag and candidate == exclude_tag:
|
||||
continue
|
||||
return candidate
|
||||
|
||||
raise subprocess.CalledProcessError(result.returncode, result.args, output=result.stdout, stderr=result.stderr)
|
||||
|
||||
|
||||
def get_tag_date(tag):
|
||||
@@ -33,18 +39,18 @@ def get_tag_date(tag):
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def get_merged_prs_since_tag(tag, branch):
|
||||
"""Get all merged PRs since the given tag on the specified branch."""
|
||||
# Get commits since tag on the branch - look for PR numbers in parentheses
|
||||
def get_merged_prs_in_range(tag, compare_ref):
|
||||
"""Get all merged PRs in the git range between tag and compare_ref."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
f"{tag}..origin/{branch}",
|
||||
f"{tag}..{compare_ref}",
|
||||
"--oneline",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
prs = []
|
||||
@@ -65,6 +71,25 @@ def get_merged_prs_since_tag(tag, branch):
|
||||
return prs
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""Parse CLI arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate release notes from the actual release commit range."
|
||||
)
|
||||
parser.add_argument("new_version", help="Version that will be tagged for this release")
|
||||
parser.add_argument(
|
||||
"--base-tag",
|
||||
dest="base_tag",
|
||||
help="Existing version tag to diff from. Defaults to the latest version tag merged into the compare ref.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compare-ref",
|
||||
default="HEAD",
|
||||
help="Git ref to diff to. Defaults to HEAD.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def get_pr_details(pr_number):
|
||||
"""Get PR details from GitHub API via gh CLI."""
|
||||
try:
|
||||
@@ -268,28 +293,28 @@ def get_new_contributors(pr_details_list, tag, repo="meshtastic/firmware"):
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: generate_release_notes.py <new_version>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
new_version = sys.argv[1]
|
||||
args = parse_args()
|
||||
new_version = args.new_version
|
||||
compare_ref = args.compare_ref
|
||||
current_tag = f"v{new_version}"
|
||||
|
||||
# Get last release tag
|
||||
try:
|
||||
last_tag = get_last_release_tag()
|
||||
last_tag = args.base_tag or get_last_release_tag(compare_ref, exclude_tag=current_tag)
|
||||
except subprocess.CalledProcessError:
|
||||
print("Error: Could not find last release tag", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Collect PRs from both branches
|
||||
all_pr_numbers = set()
|
||||
print(
|
||||
f"Resolved release note range: {last_tag}..{compare_ref}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
for branch in ["develop", "master"]:
|
||||
try:
|
||||
prs = get_merged_prs_since_tag(last_tag, branch)
|
||||
all_pr_numbers.update(prs)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not get PRs from {branch}: {e}", file=sys.stderr)
|
||||
try:
|
||||
all_pr_numbers = set(get_merged_prs_in_range(last_tag, compare_ref))
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error: Could not get PRs for range {last_tag}..{compare_ref}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Get details for all PRs
|
||||
enhancements = []
|
||||
|
||||
@@ -87,6 +87,12 @@
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="2.7.23" date="2026-04-14">
|
||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.23</url>
|
||||
</release>
|
||||
<release version="2.7.22" date="2026-04-06">
|
||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.22</url>
|
||||
</release>
|
||||
<release version="2.7.21" date="2026-03-11">
|
||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.21</url>
|
||||
</release>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32s3_out.ld",
|
||||
"memory_type": "qio_opi",
|
||||
"partitions": "default_16MB.csv"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=0",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1"
|
||||
],
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"hwids": [["0x303A", "0x1001"]],
|
||||
"mcu": "esp32s3",
|
||||
"variant": "esp32s3"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "lora"],
|
||||
"debug": {
|
||||
"openocd_target": "esp32s3.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "LilyGo T5-ePaper-S3",
|
||||
"upload": {
|
||||
"flash_size": "16MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 16777216,
|
||||
"require_upload_port": true,
|
||||
"speed": 921600
|
||||
},
|
||||
"url": "https://lilygo.cc/products/t5-e-paper-s3-pro",
|
||||
"vendor": "LILYGO"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "esp32_out.ld"
|
||||
},
|
||||
"core": "esp32",
|
||||
"extra_flags": "-DARDUINO_T_Beam",
|
||||
"f_cpu": "240000000L",
|
||||
"f_flash": "40000000L",
|
||||
"flash_mode": "dio",
|
||||
"mcu": "esp32",
|
||||
"variant": "tbeam"
|
||||
},
|
||||
"connectivity": ["wifi", "bluetooth", "can", "ethernet"],
|
||||
"debug": {
|
||||
"openocd_board": "esp-wroom-32.cfg"
|
||||
},
|
||||
"frameworks": ["arduino", "espidf"],
|
||||
"name": "TTGO T-Beam (PSRAM Disabled)",
|
||||
"upload": {
|
||||
"flash_size": "4MB",
|
||||
"maximum_ram_size": 1310720,
|
||||
"maximum_size": 4194304,
|
||||
"require_upload_port": true,
|
||||
"speed": 460800
|
||||
},
|
||||
"url": "https://github.com/LilyGO/TTGO-T-Beam",
|
||||
"vendor": "TTGO"
|
||||
}
|
||||
Vendored
+12
@@ -1,3 +1,15 @@
|
||||
meshtasticd (2.7.23.0) unstable; urgency=medium
|
||||
|
||||
* Version 2.7.23
|
||||
|
||||
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Tue, 14 Apr 2026 12:29:48 +0000
|
||||
|
||||
meshtasticd (2.7.22.0) unstable; urgency=medium
|
||||
|
||||
* Version 2.7.22
|
||||
|
||||
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Mon, 06 Apr 2026 11:34:12 +0000
|
||||
|
||||
meshtasticd (2.7.21.0) unstable; urgency=medium
|
||||
|
||||
* Version 2.7.21
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Test harness artifacts
|
||||
tests/report.html
|
||||
tests/junit.xml
|
||||
tests/reportlog.jsonl
|
||||
tests/fwlog.jsonl
|
||||
# Subprocess-output tee from pio/esptool/nrfutil/picotool (live flash
|
||||
# progress for the TUI; also a post-run diagnostic for plain CLI runs).
|
||||
tests/flash.log
|
||||
tests/tool_coverage.json
|
||||
tests/.coverage
|
||||
htmlcov/
|
||||
# Persistent run counter for meshtastic-mcp-test-tui header.
|
||||
tests/.tui-runs
|
||||
# Cross-run history (TUI duration sparkline).
|
||||
tests/.history/
|
||||
# Reproducer bundles (TUI `x` export on failed tests).
|
||||
tests/reproducers/
|
||||
# UI-tier camera captures + per-test transcripts. Regenerated every run;
|
||||
# left on disk for human review between runs.
|
||||
tests/ui_captures/
|
||||
@@ -0,0 +1,354 @@
|
||||
# Meshtastic MCP Server
|
||||
|
||||
An [MCP](https://modelcontextprotocol.io) server for working with the Meshtastic firmware repo and connected devices. Lets Claude Code / Claude Desktop:
|
||||
|
||||
- Discover USB-connected Meshtastic devices
|
||||
- Enumerate PlatformIO board variants (166+) with Meshtastic metadata
|
||||
- Build, clean, flash, erase-and-flash (factory), and OTA-update firmware
|
||||
- Read serial logs via `pio device monitor` (with board-specific exception decoders)
|
||||
- Trigger 1200bps touch-reset for bootloader entry (nRF52, ESP32-S3, RP2040)
|
||||
- Query and administer a running node via the [`meshtastic` Python API](https://github.com/meshtastic/python): owner name, config (LocalConfig + ModuleConfig), channels, messaging, reboot/shutdown/factory-reset
|
||||
- Call `esptool`, `nrfutil`, `picotool` directly when PlatformIO doesn't cover the operation
|
||||
|
||||
## Design principle
|
||||
|
||||
**PlatformIO first.** Its `pio run -t upload` knows the correct protocol, offsets, and post-build chain for every variant in `variants/`. Direct vendor-tool wrappers (`esptool_*`, `nrfutil_*`, `picotool_*`) exist as escape hatches for operations pio doesn't cover (blank-chip erase, DFU `.zip` packages, BOOTSEL-mode inspection).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python ≥ 3.11
|
||||
- [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
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd <firmware-repo>/mcp-server
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -e .
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
MESHTASTIC_FIRMWARE_ROOT=<firmware-repo> .venv/bin/python -m meshtastic_mcp
|
||||
```
|
||||
|
||||
The server blocks on stdin (that's correct — it speaks MCP over stdio). Ctrl-C to exit.
|
||||
|
||||
## Register with Claude Code
|
||||
|
||||
Edit `~/.claude/settings.json` (global) or `<firmware-repo>/.claude/settings.local.json` (project-only):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"meshtastic": {
|
||||
"command": "<firmware-repo>/mcp-server/.venv/bin/python",
|
||||
"args": ["-m", "meshtastic_mcp"],
|
||||
"env": {
|
||||
"MESHTASTIC_FIRMWARE_ROOT": "<firmware-repo>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace `<firmware-repo>` with the absolute path, e.g. `/Users/you/GitHub/firmware`. Restart Claude Code after editing.
|
||||
|
||||
## Register with Claude Desktop
|
||||
|
||||
Same `mcpServers` block, but in `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).
|
||||
|
||||
## Tools (43)
|
||||
|
||||
### Discovery & metadata
|
||||
|
||||
| Tool | What it does |
|
||||
| -------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `list_devices` | USB/serial port listing, flags likely-Meshtastic candidates |
|
||||
| `list_boards` | PlatformIO envs with `custom_meshtastic_*` metadata; filters by arch/supported/query/level |
|
||||
| `get_board` | Full env dict incl. raw pio config |
|
||||
|
||||
### Build & flash
|
||||
|
||||
| Tool | What it does |
|
||||
| ----------------- | -------------------------------------------------------------------- |
|
||||
| `build` | `pio run -e <env>` (+ mtjson target) |
|
||||
| `clean` | `pio run -e <env> -t clean` |
|
||||
| `pio_flash` | `pio run -e <env> -t upload --upload-port <port>` — any architecture |
|
||||
| `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 |
|
||||
|
||||
### Serial log sessions
|
||||
|
||||
Backed by long-running `pio device monitor` subprocesses with a 10k-line ring buffer per session and board-specific filters (`esp32_exception_decoder` auto-selected when you pass `env=`).
|
||||
|
||||
| Tool | What it does |
|
||||
| -------------- | ------------------------------------------------------------------ |
|
||||
| `serial_open` | Start a monitor session; returns `session_id` |
|
||||
| `serial_read` | Cursor-based pull; reports `dropped` if lines aged out of the ring |
|
||||
| `serial_list` | All active sessions |
|
||||
| `serial_close` | Terminate a session |
|
||||
|
||||
### Device reads
|
||||
|
||||
| Tool | What it does |
|
||||
| ------------- | --------------------------------------------------------------------------- |
|
||||
| `device_info` | my_node_num, long/short name, firmware version, region, channel, node count |
|
||||
| `list_nodes` | Full node database with position, SNR, RSSI, last_heard, battery |
|
||||
|
||||
_The tool tables below document 38 currently registered MCP server tools._
|
||||
|
||||
### Device writes
|
||||
|
||||
| Tool | What it does |
|
||||
| ------------------- | -------------------------------------------------------------------------- |
|
||||
| `set_owner` | Long name + optional short name (≤4 chars) |
|
||||
| `get_config` | One section or all (LocalConfig + ModuleConfig) |
|
||||
| `set_config` | Dot-path field write: `lora.region`=`"US"`, `device.role`=`"ROUTER"`, etc. |
|
||||
| `get_channel_url` | Primary-only or include_all=admin URL |
|
||||
| `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` |
|
||||
|
||||
### Direct hardware tools (escape hatches)
|
||||
|
||||
| Tool | What it does |
|
||||
| --------------------- | --------------------------------------------------------- |
|
||||
| `esptool_chip_info` | Read chip, MAC, crystal, flash size |
|
||||
| `esptool_erase_flash` | Full-chip erase (destructive) |
|
||||
| `esptool_raw` | Pass-through; confirm=True required for write/erase/merge |
|
||||
| `nrfutil_dfu` | DFU-flash a `.zip` package |
|
||||
| `nrfutil_raw` | Pass-through |
|
||||
| `picotool_info` | Read Pico BOOTSEL-mode info |
|
||||
| `picotool_load` | Load a UF2 |
|
||||
| `picotool_raw` | Pass-through |
|
||||
|
||||
### USB power control (uhubctl)
|
||||
|
||||
| Tool | What it does |
|
||||
| --------------- | ----------------------------------------------------------- |
|
||||
| `uhubctl_list` | Enumerate USB hubs + attached-device VID/PID (read-only) |
|
||||
| `uhubctl_power` | Drive a hub port `on` or `off`; `off` requires confirm=True |
|
||||
| `uhubctl_cycle` | Off → wait `delay_s` → on; confirm=True required |
|
||||
|
||||
Target a port by explicit `(location, port)` (raw uhubctl syntax like
|
||||
`location="1-1.3", port=2`) or by `role` (`"nrf52"`, `"esp32s3"`). Role
|
||||
lookup checks `MESHTASTIC_UHUBCTL_LOCATION_<ROLE>` +
|
||||
`MESHTASTIC_UHUBCTL_PORT_<ROLE>` env vars first, then auto-detects via VID
|
||||
against `uhubctl`'s output.
|
||||
|
||||
Requires [`uhubctl`](https://github.com/mvp/uhubctl) on PATH:
|
||||
|
||||
```bash
|
||||
brew install uhubctl # macOS
|
||||
apt install uhubctl # Debian/Ubuntu
|
||||
```
|
||||
|
||||
Modern macOS + PPPS-capable hubs generally work without root. On Linux
|
||||
without udev rules, or on old macOS with driver quirks, you may need
|
||||
`sudo`. If uhubctl returns a permission error the MCP tool raises a
|
||||
clear `UhubctlError` pointing at the
|
||||
[udev-rules / sudo fallback](https://github.com/mvp/uhubctl#linux-usb-permissions)
|
||||
rather than auto-`sudo`'ing mid-run.
|
||||
|
||||
## Safety
|
||||
|
||||
- **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.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Var | Default | Purpose |
|
||||
| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo |
|
||||
| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location |
|
||||
| `MESHTASTIC_ESPTOOL_BIN` | `<firmware>/.venv/bin/esptool` → `$PATH` | Override esptool |
|
||||
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
|
||||
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
|
||||
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
|
||||
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
|
||||
|
||||
## Hardware Test Suite
|
||||
|
||||
`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.
|
||||
|
||||
### Invocation
|
||||
|
||||
```bash
|
||||
./mcp-server/run-tests.sh # full suite (auto-detect + auto-bake-if-needed)
|
||||
./mcp-server/run-tests.sh --force-bake # reflash devices before testing
|
||||
./mcp-server/run-tests.sh --assume-baked # skip the bake step (caller vouches for state)
|
||||
./mcp-server/run-tests.sh tests/mesh # one tier
|
||||
./mcp-server/run-tests.sh tests/mesh/test_traceroute.py # one file
|
||||
./mcp-server/run-tests.sh -k telemetry # pytest name filter
|
||||
```
|
||||
|
||||
The wrapper auto-detects connected devices (VID `0x239A` → `nrf52` → env
|
||||
`rak4631`; `0x303A` or `0x10C4` → `esp32s3` → env `heltec-v3`), exports
|
||||
`MESHTASTIC_MCP_ENV_<ROLE>` env vars, and invokes pytest. Overrides via
|
||||
per-role env vars: `MESHTASTIC_MCP_ENV_NRF52=heltec-mesh-node-t114 ./run-tests.sh`.
|
||||
|
||||
No hardware connected? The wrapper narrows to `tests/unit/` only and says so
|
||||
in the pre-flight header.
|
||||
|
||||
### Tiers (run in this order)
|
||||
|
||||
- **`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 /
|
||||
userPrefs-parse / testing-profile fixtures.
|
||||
- **`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_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
|
||||
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`
|
||||
injection → `Screen::handleInputEvent` → frame transition). Parametrized
|
||||
on the screen-bearing role (heltec-v3 OLED). Captures images via USB
|
||||
webcam + OCRs them for HTML-report evidence. Requires `pip install -e '.[ui]'`
|
||||
and `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=<index>`; tier is auto-deselected
|
||||
if `cv2` isn't importable.
|
||||
- **`fleet`** — PSK-seed isolation: two labs with different seeds never
|
||||
overlap.
|
||||
- **`admin`** — owner persistence across reboot, channel URL round-trip,
|
||||
`lora.hop_limit` persistence.
|
||||
- **`provisioning`** — region/channel baking, userPrefs survive
|
||||
`factory_reset(full=False)`.
|
||||
|
||||
#### UI tier setup
|
||||
|
||||
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
|
||||
`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.
|
||||
2. Point a USB webcam at the heltec-v3 OLED. Discover its index:
|
||||
```bash
|
||||
.venv/bin/python -c "import cv2; [print(i, cv2.VideoCapture(i).read()[0]) for i in range(5)]"
|
||||
```
|
||||
3. Export the per-role device env var:
|
||||
```bash
|
||||
export MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0
|
||||
```
|
||||
4. Run:
|
||||
```bash
|
||||
./run-tests.sh tests/ui -v
|
||||
```
|
||||
Captures land under `tests/ui_captures/<session_seed>/<test_id>/`, one
|
||||
PNG + `.ocr.txt` per `frame_capture()` call, with a per-test
|
||||
`transcript.md` stepping through event → frame → OCR. The HTML report
|
||||
embeds the full image strip inline (pass or fail).
|
||||
|
||||
On macOS, `cv2.VideoCapture(0)` triggers the TCC Camera permission prompt
|
||||
on first use. Pre-grant Terminal (or your IDE's terminal) before running.
|
||||
The `OpenCVBackend` fails fast on 10 consecutive black frames so a silent
|
||||
permission denial surfaces as a clear error, not an empty PNG strip.
|
||||
|
||||
No camera? Set `MESHTASTIC_UI_CAMERA_BACKEND=null` (or leave the device var
|
||||
unset). Tests still exercise the event-injection path and log assertions;
|
||||
captures just become 1×1 black PNGs.
|
||||
|
||||
### Artifacts (regenerated every run, under `tests/`)
|
||||
|
||||
- `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
|
||||
output during the run (driven by `MESHTASTIC_MCP_FLASH_LOG`).
|
||||
|
||||
### Live TUI
|
||||
|
||||
```bash
|
||||
.venv/bin/meshtastic-mcp-test-tui
|
||||
.venv/bin/meshtastic-mcp-test-tui tests/mesh # pytest args pass through
|
||||
```
|
||||
|
||||
Textual-based wrapper over `run-tests.sh` with a live test tree, tier
|
||||
counters, pytest output pane, firmware-log pane, and a device-status strip.
|
||||
Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open
|
||||
`report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q`
|
||||
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.
|
||||
|
||||
### Slash commands
|
||||
|
||||
Three AI-assisted workflows are wired up for Claude Code operators
|
||||
(`.claude/commands/`) and Copilot operators (`.github/prompts/`):
|
||||
`/test` (run + interpret), `/diagnose` (read-only health report), `/repro`
|
||||
(flake triage, N-times re-run with log diff).
|
||||
|
||||
### House rules (for human + agent contributors)
|
||||
|
||||
- Session-scoped fixtures in `tests/conftest.py` snapshot + restore
|
||||
`userPrefs.jsonc`; **never edit `userPrefs.jsonc` from inside a test**.
|
||||
Use the `test_profile` / `no_region_profile` fixtures for ephemeral
|
||||
overrides.
|
||||
- `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** —
|
||||
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`,
|
||||
`test_telemetry_request_reply`) for the canonical pattern.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
mcp-server/
|
||||
├── pyproject.toml
|
||||
├── README.md
|
||||
└── src/meshtastic_mcp/
|
||||
├── __main__.py # entry: python -m meshtastic_mcp
|
||||
├── server.py # FastMCP app + @app.tool() registrations (thin)
|
||||
├── config.py # firmware_root, pio_bin, esptool_bin, etc.
|
||||
├── pio.py # subprocess wrapper (timeouts, JSON, tail_lines)
|
||||
├── devices.py # list_devices (findPorts + comports)
|
||||
├── boards.py # list_boards / get_board (pio project config parse + cache)
|
||||
├── 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
|
||||
├── 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
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,54 @@
|
||||
[project]
|
||||
name = "meshtastic-mcp"
|
||||
version = "0.1.0"
|
||||
description = "MCP server for Meshtastic firmware development: device discovery, PlatformIO tooling, flashing, serial monitoring, and device administration via the meshtastic Python API."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "GPL-3.0-only" }
|
||||
authors = [{ name = "thebentern" }]
|
||||
dependencies = ["mcp>=1.2", "pyserial>=3.5", "meshtastic>=2.7.8"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7"]
|
||||
test = [
|
||||
"pytest>=8",
|
||||
"pytest-html>=4",
|
||||
"pytest-reportlog>=0.4",
|
||||
"pytest-timeout>=2.3",
|
||||
"coverage[toml]>=7",
|
||||
"pyyaml>=6",
|
||||
# textual is required by the `meshtastic-mcp-test-tui` script (see
|
||||
# `src/meshtastic_mcp/cli/test_tui.py`). Bundled into `test` rather than a
|
||||
# separate `[tui]` extra because v1 expects test operators are the only
|
||||
# consumers; revisit if install cost pushes back.
|
||||
"textual>=0.50",
|
||||
]
|
||||
# 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
|
||||
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
|
||||
# dependency. Pure Python, tiny.
|
||||
"rich-pixels>=3.0",
|
||||
]
|
||||
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
|
||||
# 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"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/meshtastic_mcp"]
|
||||
Executable
+270
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env bash
|
||||
# mcp-server hardware test runner.
|
||||
#
|
||||
# Auto-detects connected Meshtastic devices, maps each to its PlatformIO env
|
||||
# via the same role table the pytest fixtures use, exports the right
|
||||
# MESHTASTIC_MCP_ENV_* env vars, and invokes pytest.
|
||||
#
|
||||
# Usage:
|
||||
# ./run-tests.sh # full suite, default pytest args
|
||||
# ./run-tests.sh tests/mesh # subset (any pytest args pass through)
|
||||
# ./run-tests.sh --force-bake # override one default with another
|
||||
# MESHTASTIC_MCP_ENV_NRF52=foo ./run-tests.sh # override env per role
|
||||
# MESHTASTIC_MCP_SEED=ci-run-42 ./run-tests.sh # override PSK seed
|
||||
#
|
||||
# If zero supported devices are detected, only the unit tier runs.
|
||||
#
|
||||
# Also restores `userPrefs.jsonc` from the session-backup sidecar if a prior
|
||||
# run exited abnormally (belt to conftest.py's atexit suspenders).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# cd to the script's directory so relative paths resolve consistently no
|
||||
# matter where the user invoked from.
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
VENV_PY="$SCRIPT_DIR/.venv/bin/python"
|
||||
if [[ ! -x $VENV_PY ]]; then
|
||||
echo "error: $VENV_PY not found or not executable." >&2
|
||||
echo " Bootstrap the venv first:" >&2
|
||||
echo " cd $SCRIPT_DIR && python3 -m venv .venv && .venv/bin/pip install -e '.[test]'" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Resolve firmware root the same way conftest.py does (this script sits in
|
||||
# mcp-server/, firmware repo root is one level up).
|
||||
FIRMWARE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
USERPREFS_PATH="$FIRMWARE_ROOT/userPrefs.jsonc"
|
||||
USERPREFS_SIDECAR="$USERPREFS_PATH.mcp-session-bak"
|
||||
|
||||
# ---------- Pre-flight: recover stale userPrefs.jsonc from prior crash ----
|
||||
# If conftest.py's atexit hook didn't fire (SIGKILL, kernel panic, OS
|
||||
# restart), the sidecar is the ground truth. Self-heal before running so we
|
||||
# don't bake the previous run's dirty state into this run's firmware.
|
||||
if [[ -f $USERPREFS_SIDECAR ]]; then
|
||||
echo "[pre-flight] found $USERPREFS_SIDECAR from a prior abnormal exit;" >&2
|
||||
echo " restoring userPrefs.jsonc before starting." >&2
|
||||
cp "$USERPREFS_SIDECAR" "$USERPREFS_PATH"
|
||||
rm -f "$USERPREFS_SIDECAR"
|
||||
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
|
||||
# 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
|
||||
# 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.
|
||||
_git_status_porcelain="$(git status --porcelain userPrefs.jsonc 2>/dev/null || true)"
|
||||
if [[ -n $_git_status_porcelain ]]; then
|
||||
echo "[pre-flight] warning: userPrefs.jsonc has uncommitted changes." >&2
|
||||
echo " Tests will snapshot THIS state and restore to it" >&2
|
||||
echo " at teardown. If that's not intended, run:" >&2
|
||||
echo " git checkout userPrefs.jsonc" >&2
|
||||
echo " and re-invoke." >&2
|
||||
fi
|
||||
cd "$SCRIPT_DIR"
|
||||
fi
|
||||
|
||||
# ---------- Seed default --------------------------------------------------
|
||||
# Per-machine default so repeated runs from the same operator land on the
|
||||
# same PSK (makes --assume-baked valid across invocations). Operator can
|
||||
# override with an explicit env var if they want isolation (e.g. CI).
|
||||
if [[ -z ${MESHTASTIC_MCP_SEED-} ]]; then
|
||||
WHO="$(whoami 2>/dev/null || echo anon)"
|
||||
HOST="$(hostname -s 2>/dev/null || echo host)"
|
||||
export MESHTASTIC_MCP_SEED="mcp-${WHO}-${HOST}"
|
||||
fi
|
||||
|
||||
# ---------- Flash progress log --------------------------------------------
|
||||
# pio.py / hw_tools.py tee subprocess output (pio run -t upload, esptool,
|
||||
# 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.
|
||||
# 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"
|
||||
|
||||
# ---------- Detect connected hardware -------------------------------------
|
||||
# In-process call to the same Python API the test fixtures use, so the
|
||||
# script never drifts from what pytest sees. Returns a JSON object
|
||||
# {role: port, ...}.
|
||||
ROLES_JSON="$(
|
||||
"$VENV_PY" - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "src")
|
||||
from meshtastic_mcp import devices
|
||||
|
||||
# Role → canonical VID map. Kept in sync with
|
||||
# `tests/conftest.py::hub_profile` defaults; if that changes, this must too.
|
||||
ROLE_BY_VID = {
|
||||
0x239A: "nrf52", # Adafruit / RAK nRF52 native USB (app + DFU)
|
||||
0x303A: "esp32s3", # Espressif native USB (ESP32-S3)
|
||||
0x10C4: "esp32s3", # CP2102 USB-UART (common on Heltec/LilyGO ESP32 boards)
|
||||
}
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for dev in devices.list_devices(include_unknown=True):
|
||||
vid_raw = dev.get("vid") or ""
|
||||
try:
|
||||
if isinstance(vid_raw, str) and vid_raw.startswith("0x"):
|
||||
vid = int(vid_raw, 16)
|
||||
else:
|
||||
vid = int(vid_raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
role = ROLE_BY_VID.get(vid)
|
||||
# First port wins per role — matches hub_devices fixture semantics.
|
||||
if role and role not in out:
|
||||
out[role] = dev["port"]
|
||||
|
||||
json.dump(out, sys.stdout)
|
||||
PY
|
||||
)"
|
||||
|
||||
# ---------- Map role → pio env --------------------------------------------
|
||||
# Honor MESHTASTIC_MCP_ENV_<ROLE> operator overrides; fall back to the
|
||||
# same defaults hardcoded in tests/conftest.py::_DEFAULT_ROLE_ENVS.
|
||||
resolve_env() {
|
||||
local role="$1"
|
||||
local default="$2"
|
||||
local upper
|
||||
upper="$(echo "$role" | tr '[:lower:]' '[:upper:]')"
|
||||
local var="MESHTASTIC_MCP_ENV_${upper}"
|
||||
eval "local override=\${$var:-}"
|
||||
if [[ -n $override ]]; then
|
||||
echo "$override"
|
||||
else
|
||||
echo "$default"
|
||||
fi
|
||||
}
|
||||
|
||||
NRF52_PORT="$(echo "$ROLES_JSON" | "$VENV_PY" -c 'import json,sys; print(json.loads(sys.stdin.read()).get("nrf52", ""))')"
|
||||
ESP32S3_PORT="$(echo "$ROLES_JSON" | "$VENV_PY" -c 'import json,sys; print(json.loads(sys.stdin.read()).get("esp32s3", ""))')"
|
||||
|
||||
DETECTED=""
|
||||
if [[ -n $NRF52_PORT ]]; then
|
||||
NRF52_ENV="$(resolve_env nrf52 rak4631)"
|
||||
export MESHTASTIC_MCP_ENV_NRF52="$NRF52_ENV"
|
||||
DETECTED="${DETECTED} nrf52 @ ${NRF52_PORT} -> env=${NRF52_ENV}\n"
|
||||
fi
|
||||
if [[ -n $ESP32S3_PORT ]]; then
|
||||
ESP32S3_ENV="$(resolve_env esp32s3 heltec-v3)"
|
||||
export MESHTASTIC_MCP_ENV_ESP32S3="$ESP32S3_ENV"
|
||||
DETECTED="${DETECTED} esp32s3 @ ${ESP32S3_PORT} -> env=${ESP32S3_ENV}\n"
|
||||
fi
|
||||
|
||||
# ---------- Pre-flight summary --------------------------------------------
|
||||
# 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.
|
||||
_bake_mode="auto (verify + bake if needed)"
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
--assume-baked) _bake_mode="skip (--assume-baked)" ;;
|
||||
--force-bake) _bake_mode="force (--force-bake)" ;;
|
||||
*) ;; # any other arg: pass-through; bake mode unchanged
|
||||
esac
|
||||
done
|
||||
|
||||
echo "mcp-server test runner"
|
||||
echo " firmware root : $FIRMWARE_ROOT"
|
||||
echo " seed : $MESHTASTIC_MCP_SEED"
|
||||
echo " bake : $_bake_mode"
|
||||
if [[ -n $DETECTED ]]; then
|
||||
echo " detected hub :"
|
||||
printf "%b" "$DETECTED"
|
||||
else
|
||||
echo " detected hub : (none)"
|
||||
fi
|
||||
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
|
||||
# narrow to tests/unit explicitly so the summary reads as "no hardware,
|
||||
# unit suite only" instead of "big skip count looks suspicious".
|
||||
if [[ -z $DETECTED && $# -eq 0 ]]; then
|
||||
echo "[pre-flight] no supported devices detected; running unit tier only."
|
||||
echo
|
||||
exec "$VENV_PY" -m pytest tests/unit -v --report-log=tests/reportlog.jsonl
|
||||
fi
|
||||
|
||||
# Default pytest args when the user passed none. Power users can invoke
|
||||
# `./run-tests.sh tests/mesh -v --tb=long` and skip all of these defaults.
|
||||
#
|
||||
# NOTE: `--assume-baked` is DELIBERATELY omitted here. `tests/test_00_bake.py`
|
||||
# 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
|
||||
# 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"
|
||||
# errors. Power users who know their hardware is current and want to shave
|
||||
# those seconds can pass `--assume-baked` explicitly.
|
||||
if [[ $# -eq 0 ]]; then
|
||||
set -- tests/ \
|
||||
--html=tests/report.html --self-contained-html \
|
||||
--junitxml=tests/junit.xml \
|
||||
-v --tb=short
|
||||
fi
|
||||
|
||||
# UI tier requires opencv-python-headless (and ideally easyocr). If it's
|
||||
# not installed, auto-deselect tests/ui so operators without the [ui]
|
||||
# extra still get a green run. Printed in yellow; silent when cv2 is
|
||||
# present.
|
||||
_cv2_ok=0
|
||||
if "$VENV_PY" -c "import cv2" >/dev/null 2>&1; then
|
||||
_cv2_ok=1
|
||||
fi
|
||||
_running_ui=0
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
*tests/ui* | tests/) _running_ui=1 ;;
|
||||
*) ;;
|
||||
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 ' 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
|
||||
# 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 " 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.
|
||||
# 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
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
--report-log | --report-log=*) _has_report_log=1 ;;
|
||||
*) ;; # any other arg: no-op; loop continues
|
||||
esac
|
||||
done
|
||||
if [[ $_has_report_log -eq 0 ]]; then
|
||||
set -- "$@" --report-log=tests/reportlog.jsonl
|
||||
fi
|
||||
|
||||
exec "$VENV_PY" -m pytest "$@"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Meshtastic MCP server — device discovery, PlatformIO tooling, and device admin."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Entry point for `python -m meshtastic_mcp`."""
|
||||
|
||||
from meshtastic_mcp.server import app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Device administration: owner, config, channels, messaging, admin actions.
|
||||
|
||||
All operations use the same `connect()` context manager so port selection,
|
||||
port-busy detection, and cleanup are handled uniformly.
|
||||
|
||||
Config writes use a dot-path: the first segment names a section (e.g.
|
||||
`"lora"` in LocalConfig or `"mqtt"` in LocalModuleConfig), remaining segments
|
||||
walk protobuf fields. Enum fields accept their string names (`"US"` for
|
||||
`lora.region`) so callers don't need to know the numeric values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from google.protobuf import descriptor as pb_descriptor
|
||||
from google.protobuf import json_format
|
||||
from meshtastic.protobuf import localonly_pb2
|
||||
|
||||
from .connection import connect
|
||||
|
||||
|
||||
class AdminError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
LOCAL_CONFIG_SECTIONS = {f.name for f in localonly_pb2.LocalConfig.DESCRIPTOR.fields}
|
||||
MODULE_CONFIG_SECTIONS = {
|
||||
f.name for f in localonly_pb2.LocalModuleConfig.DESCRIPTOR.fields
|
||||
}
|
||||
|
||||
|
||||
def _require_confirm(confirm: bool, operation: str) -> None:
|
||||
if not confirm:
|
||||
raise AdminError(f"{operation} is destructive and requires confirm=True.")
|
||||
|
||||
|
||||
def _message_to_dict(msg: Any) -> dict[str, Any]:
|
||||
# `including_default_value_fields` was renamed to
|
||||
# `always_print_fields_with_no_presence` in protobuf 5.26+. Pick whichever
|
||||
# kwarg the installed version accepts so we work against both.
|
||||
kwargs: dict[str, Any] = {"preserving_proto_field_name": True}
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(json_format.MessageToDict)
|
||||
if "always_print_fields_with_no_presence" in sig.parameters:
|
||||
kwargs["always_print_fields_with_no_presence"] = False
|
||||
elif "including_default_value_fields" in sig.parameters:
|
||||
kwargs["including_default_value_fields"] = False
|
||||
return json_format.MessageToDict(msg, **kwargs)
|
||||
|
||||
|
||||
# ---------- owner ----------------------------------------------------------
|
||||
|
||||
|
||||
def set_owner(
|
||||
long_name: str,
|
||||
short_name: str | None = None,
|
||||
port: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if short_name is not None and len(short_name) > 4:
|
||||
raise AdminError("short_name must be 4 characters or fewer")
|
||||
with connect(port=port) as iface:
|
||||
iface.localNode.setOwner(long_name=long_name, short_name=short_name)
|
||||
return {
|
||||
"ok": True,
|
||||
"long_name": long_name,
|
||||
"short_name": short_name,
|
||||
}
|
||||
|
||||
|
||||
# ---------- config reads ---------------------------------------------------
|
||||
|
||||
|
||||
def _section_container(node, section: str) -> tuple[Any, str]:
|
||||
"""Return (container_message, parent_name) for a section name.
|
||||
|
||||
Parent is 'localConfig' or 'moduleConfig' so callers know where to call
|
||||
writeConfig() after mutating.
|
||||
"""
|
||||
if section in LOCAL_CONFIG_SECTIONS:
|
||||
return getattr(node.localConfig, section), "localConfig"
|
||||
if section in MODULE_CONFIG_SECTIONS:
|
||||
return getattr(node.moduleConfig, section), "moduleConfig"
|
||||
raise AdminError(
|
||||
f"Unknown config section: {section!r}. "
|
||||
f"Valid sections: {sorted(LOCAL_CONFIG_SECTIONS | MODULE_CONFIG_SECTIONS)}"
|
||||
)
|
||||
|
||||
|
||||
def get_config(section: str | None = None, port: str | None = None) -> dict[str, Any]:
|
||||
"""Read one or all config sections.
|
||||
|
||||
`section` may be any name in LocalConfig (device, lora, position, power,
|
||||
network, display, bluetooth, security) or LocalModuleConfig (mqtt, serial,
|
||||
telemetry, ...). Omit `section` or pass `"all"` for everything.
|
||||
"""
|
||||
with connect(port=port) as iface:
|
||||
node = iface.localNode
|
||||
if section in (None, "all"):
|
||||
lc = _message_to_dict(node.localConfig)
|
||||
mc = _message_to_dict(node.moduleConfig)
|
||||
return {
|
||||
"config": {
|
||||
"localConfig": lc,
|
||||
"moduleConfig": mc,
|
||||
}
|
||||
}
|
||||
container, _parent = _section_container(node, section)
|
||||
return {"config": {section: _message_to_dict(container)}}
|
||||
|
||||
|
||||
# ---------- config writes --------------------------------------------------
|
||||
|
||||
|
||||
def _coerce_enum(field: pb_descriptor.FieldDescriptor, value: Any) -> int:
|
||||
"""Accept an enum value as either its int or its string name."""
|
||||
enum_type = field.enum_type
|
||||
if isinstance(value, bool):
|
||||
raise AdminError(f"{field.name}: expected enum {enum_type.name}, got bool")
|
||||
if isinstance(value, int):
|
||||
if enum_type.values_by_number.get(value) is None:
|
||||
raise AdminError(
|
||||
f"{field.name}: {value} is not a valid {enum_type.name} value"
|
||||
)
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
upper = value.upper()
|
||||
ev = enum_type.values_by_name.get(upper)
|
||||
if ev is None:
|
||||
valid = sorted(enum_type.values_by_name.keys())
|
||||
raise AdminError(
|
||||
f"{field.name}: {value!r} is not a valid {enum_type.name}. "
|
||||
f"Valid: {valid}"
|
||||
)
|
||||
return ev.number
|
||||
raise AdminError(
|
||||
f"{field.name}: expected enum {enum_type.name}, got {type(value).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def _coerce_scalar(field: pb_descriptor.FieldDescriptor, value: Any) -> Any:
|
||||
t = field.type
|
||||
FT = pb_descriptor.FieldDescriptor
|
||||
if t == FT.TYPE_ENUM:
|
||||
return _coerce_enum(field, value)
|
||||
if t == FT.TYPE_BOOL:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("true", "yes", "1", "on")
|
||||
if isinstance(value, int):
|
||||
return bool(value)
|
||||
if t in (
|
||||
FT.TYPE_INT32,
|
||||
FT.TYPE_INT64,
|
||||
FT.TYPE_UINT32,
|
||||
FT.TYPE_UINT64,
|
||||
FT.TYPE_SINT32,
|
||||
FT.TYPE_SINT64,
|
||||
FT.TYPE_FIXED32,
|
||||
FT.TYPE_FIXED64,
|
||||
):
|
||||
return int(value)
|
||||
if t in (FT.TYPE_FLOAT, FT.TYPE_DOUBLE):
|
||||
return float(value)
|
||||
if t == FT.TYPE_STRING:
|
||||
return str(value)
|
||||
if t == FT.TYPE_BYTES:
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return bytes(value)
|
||||
return str(value).encode("utf-8")
|
||||
raise AdminError(
|
||||
f"{field.name}: unsupported field type {t}. Use raw protobuf for this field."
|
||||
)
|
||||
|
||||
|
||||
def _walk_to_field(
|
||||
root_msg: Any, path_segments: list[str]
|
||||
) -> tuple[Any, pb_descriptor.FieldDescriptor]:
|
||||
"""Walk `root_msg` by field names until the leaf; return (parent_msg, leaf_field_descriptor)."""
|
||||
msg = root_msg
|
||||
for i, name in enumerate(path_segments):
|
||||
desc = msg.DESCRIPTOR
|
||||
field = desc.fields_by_name.get(name)
|
||||
if field is None:
|
||||
trail = ".".join(path_segments[:i] or ["<root>"])
|
||||
valid = [f.name for f in desc.fields]
|
||||
raise AdminError(f"No field {name!r} in {trail}. Valid: {valid}")
|
||||
is_last = i == len(path_segments) - 1
|
||||
if is_last:
|
||||
return msg, field
|
||||
if field.type != pb_descriptor.FieldDescriptor.TYPE_MESSAGE:
|
||||
raise AdminError(
|
||||
f"{'.'.join(path_segments[:i+1])} is a scalar; cannot descend into it"
|
||||
)
|
||||
msg = getattr(msg, name)
|
||||
# path_segments was empty
|
||||
raise AdminError("Empty config path")
|
||||
|
||||
|
||||
def set_config(path: str, value: Any, port: str | None = None) -> dict[str, Any]:
|
||||
"""Set a single config field by dot-path and write it to the device.
|
||||
|
||||
Examples:
|
||||
set_config("lora.region", "US")
|
||||
set_config("lora.modem_preset", "LONG_FAST")
|
||||
set_config("device.role", "ROUTER")
|
||||
set_config("mqtt.enabled", True)
|
||||
set_config("mqtt.address", "mqtt.example.com")
|
||||
|
||||
"""
|
||||
segments = [s for s in path.split(".") if s]
|
||||
if not segments:
|
||||
raise AdminError("path cannot be empty")
|
||||
section = segments[0]
|
||||
|
||||
with connect(port=port) as iface:
|
||||
node = iface.localNode
|
||||
container, parent_name = _section_container(node, section)
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
if field.is_repeated:
|
||||
raise AdminError(
|
||||
f"{path!r} is a repeated field; v1 only supports scalar sets. "
|
||||
"Use the raw meshtastic CLI for now."
|
||||
)
|
||||
old_raw = getattr(leaf_parent, field.name)
|
||||
coerced = _coerce_scalar(field, value)
|
||||
try:
|
||||
setattr(leaf_parent, field.name, coerced)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AdminError(f"{path}: {exc}") from exc
|
||||
|
||||
node.writeConfig(section)
|
||||
|
||||
# Stringify enums for the response (so the caller can see the change in
|
||||
# the same vocabulary they used to set it).
|
||||
if field.type == pb_descriptor.FieldDescriptor.TYPE_ENUM:
|
||||
try:
|
||||
old_display = field.enum_type.values_by_number[old_raw].name
|
||||
new_display = field.enum_type.values_by_number[coerced].name
|
||||
except Exception:
|
||||
old_display, new_display = old_raw, coerced
|
||||
else:
|
||||
old_display, new_display = old_raw, coerced
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"path": path,
|
||||
"section": section,
|
||||
"parent": parent_name,
|
||||
"old_value": old_display,
|
||||
"new_value": new_display,
|
||||
}
|
||||
|
||||
|
||||
# ---------- channels -------------------------------------------------------
|
||||
|
||||
|
||||
def get_channel_url(
|
||||
include_all: bool = False, port: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
with connect(port=port) as iface:
|
||||
url = iface.localNode.getURL(includeAll=include_all)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
def set_channel_url(url: str, port: str | None = None) -> dict[str, Any]:
|
||||
with connect(port=port) as iface:
|
||||
# setURL replaces the channel set from the URL's contents. It does not
|
||||
# return a count; we infer by counting non-DISABLED channels after.
|
||||
iface.localNode.setURL(url)
|
||||
channels = iface.localNode.channels or []
|
||||
active = sum(1 for c in channels if getattr(c, "role", 0) != 0)
|
||||
return {"ok": True, "channels_imported": active}
|
||||
|
||||
|
||||
# ---------- messaging ------------------------------------------------------
|
||||
|
||||
|
||||
def send_text(
|
||||
text: str,
|
||||
to: str | int | None = None,
|
||||
channel_index: int = 0,
|
||||
want_ack: bool = False,
|
||||
port: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
destination = to if to is not None else "^all"
|
||||
with connect(port=port) as iface:
|
||||
packet = iface.sendText(
|
||||
text,
|
||||
destinationId=destination,
|
||||
wantAck=want_ack,
|
||||
channelIndex=channel_index,
|
||||
)
|
||||
packet_id = getattr(packet, "id", None)
|
||||
return {"ok": True, "packet_id": packet_id, "destination": destination}
|
||||
|
||||
|
||||
# ---------- diagnostics ----------------------------------------------------
|
||||
|
||||
|
||||
def set_debug_log_api(enabled: bool, port: str | None = None) -> dict[str, Any]:
|
||||
"""Toggle `config.security.debug_log_api_enabled` on the local node.
|
||||
|
||||
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`
|
||||
needed, no port-contention with admin/info calls.
|
||||
|
||||
Firmware gate: `src/SerialConsole.cpp` (`usingProtobufs &&
|
||||
config.security.debug_log_api_enabled`). Setting persists in NVS; it
|
||||
survives reboot. `factory_reset(full=False)` clears it unless it's
|
||||
re-applied after reset.
|
||||
|
||||
Previously-documented concurrency hazard (emitLogRecord sharing the
|
||||
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`
|
||||
calls via `streamLock`. Leaving the flag on under traffic is safe.
|
||||
"""
|
||||
with connect(port=port) as iface:
|
||||
sec = iface.localNode.localConfig.security
|
||||
sec.debug_log_api_enabled = bool(enabled)
|
||||
iface.localNode.writeConfig("security")
|
||||
return {"ok": True, "debug_log_api_enabled": bool(enabled)}
|
||||
|
||||
|
||||
# ---------- admin actions --------------------------------------------------
|
||||
|
||||
|
||||
def reboot(
|
||||
port: str | None = None, confirm: bool = False, seconds: int = 10
|
||||
) -> dict[str, Any]:
|
||||
_require_confirm(confirm, "reboot")
|
||||
with connect(port=port) as iface:
|
||||
iface.localNode.reboot(secs=seconds)
|
||||
return {"ok": True, "rebooting_in_s": seconds}
|
||||
|
||||
|
||||
def shutdown(
|
||||
port: str | None = None, confirm: bool = False, seconds: int = 10
|
||||
) -> dict[str, Any]:
|
||||
_require_confirm(confirm, "shutdown")
|
||||
with connect(port=port) as iface:
|
||||
iface.localNode.shutdown(secs=seconds)
|
||||
return {"ok": True, "shutting_down_in_s": seconds}
|
||||
|
||||
|
||||
def send_input_event(
|
||||
event_code: int | str,
|
||||
kb_char: int = 0,
|
||||
touch_x: int = 0,
|
||||
touch_y: int = 0,
|
||||
port: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""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
|
||||
warmup needed since the admin message is addressed to `my_node_num`.
|
||||
|
||||
`event_code` accepts an int, a case-insensitive name
|
||||
(`"RIGHT"` / `"input_broker_right"`), or an `InputEventCode`. The
|
||||
firmware-side enum lives in src/input/InputBroker.h and is mirrored in
|
||||
`meshtastic_mcp.input_events`.
|
||||
"""
|
||||
from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped]
|
||||
|
||||
from .input_events import coerce_event_code
|
||||
|
||||
code = coerce_event_code(event_code)
|
||||
if not 0 <= kb_char <= 255:
|
||||
raise ValueError(f"kb_char out of u8 range: {kb_char}")
|
||||
if not 0 <= touch_x <= 65535:
|
||||
raise ValueError(f"touch_x out of u16 range: {touch_x}")
|
||||
if not 0 <= touch_y <= 65535:
|
||||
raise ValueError(f"touch_y out of u16 range: {touch_y}")
|
||||
|
||||
with connect(port=port) as iface:
|
||||
msg = admin_pb2.AdminMessage()
|
||||
msg.send_input_event.event_code = code
|
||||
msg.send_input_event.kb_char = kb_char
|
||||
msg.send_input_event.touch_x = touch_x
|
||||
msg.send_input_event.touch_y = touch_y
|
||||
iface.localNode._sendAdmin(msg)
|
||||
return {"ok": True, "event_code": code, "kb_char": kb_char}
|
||||
|
||||
|
||||
def factory_reset(
|
||||
port: str | None = None, confirm: bool = False, full: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Tell the node to factory-reset its config.
|
||||
|
||||
Works around a meshtastic-python 2.7.8 bug: `Node.factoryReset(full=True)`
|
||||
internally does `p.factory_reset_config = True` where the field is
|
||||
int32. protobuf 5.x rejects bool→int assignment as a TypeError. We build
|
||||
the AdminMessage directly with int values (1=non-full, 2=full) and call
|
||||
`_sendAdmin` to sidestep the SDK bug entirely.
|
||||
"""
|
||||
_require_confirm(confirm, "factory_reset")
|
||||
from meshtastic.protobuf import admin_pb2 # type: ignore[import-untyped]
|
||||
|
||||
with connect(port=port) as iface:
|
||||
msg = admin_pb2.AdminMessage()
|
||||
msg.factory_reset_config = 2 if full else 1
|
||||
iface.localNode._sendAdmin(msg)
|
||||
return {"ok": True, "full": full}
|
||||
@@ -0,0 +1,159 @@
|
||||
"""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,
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from . import config, pio
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
_CACHE: dict[str, Any] = {"mtime": None, "envs": None}
|
||||
|
||||
|
||||
def _parse_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("true", "yes", "1", "on")
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _parse_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_tags(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(v).strip() for v in value if str(v).strip()]
|
||||
return [t.strip() for t in str(value).replace(",", " ").split() if t.strip()]
|
||||
|
||||
|
||||
def _env_record(env_name: str, items: list[list[Any]]) -> dict[str, Any]:
|
||||
"""Build a normalized dict for one env section."""
|
||||
d = dict(items)
|
||||
return {
|
||||
"env": env_name,
|
||||
"architecture": d.get("custom_meshtastic_architecture"),
|
||||
"hw_model": _parse_int(d.get("custom_meshtastic_hw_model")),
|
||||
"hw_model_slug": d.get("custom_meshtastic_hw_model_slug"),
|
||||
"display_name": d.get("custom_meshtastic_display_name"),
|
||||
"actively_supported": _parse_bool(
|
||||
d.get("custom_meshtastic_actively_supported")
|
||||
),
|
||||
"support_level": _parse_int(d.get("custom_meshtastic_support_level")),
|
||||
"board_level": d.get("board_level"), # "pr", "extra", or None
|
||||
"tags": _parse_tags(d.get("custom_meshtastic_tags")),
|
||||
"images": _parse_tags(d.get("custom_meshtastic_images")),
|
||||
"board": d.get("board"),
|
||||
"upload_speed": _parse_int(d.get("upload_speed")),
|
||||
"upload_protocol": d.get("upload_protocol"),
|
||||
"monitor_speed": _parse_int(d.get("monitor_speed")),
|
||||
"monitor_filters": d.get("monitor_filters") or [],
|
||||
"_raw": d, # Full dict for get_board
|
||||
}
|
||||
|
||||
|
||||
def _load_all() -> dict[str, dict[str, Any]]:
|
||||
"""Parse `pio project config` into `{env_name: record}`."""
|
||||
raw = pio.run_json(["project", "config"], timeout=pio.TIMEOUT_PROJECT_CONFIG)
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for section_name, items in raw:
|
||||
if not isinstance(section_name, str) or not section_name.startswith("env:"):
|
||||
continue
|
||||
env_name = section_name.split(":", 1)[1]
|
||||
result[env_name] = _env_record(env_name, items)
|
||||
return result
|
||||
|
||||
|
||||
def _get_cached() -> dict[str, dict[str, Any]]:
|
||||
root = config.firmware_root()
|
||||
platformio_ini = root / "platformio.ini"
|
||||
try:
|
||||
mtime = platformio_ini.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
mtime = None
|
||||
|
||||
with _CACHE_LOCK:
|
||||
if _CACHE["envs"] is not None and _CACHE["mtime"] == mtime:
|
||||
return _CACHE["envs"]
|
||||
envs = _load_all()
|
||||
_CACHE["envs"] = envs
|
||||
_CACHE["mtime"] = mtime
|
||||
return envs
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
with _CACHE_LOCK:
|
||||
_CACHE["envs"] = None
|
||||
_CACHE["mtime"] = None
|
||||
|
||||
|
||||
def _public_record(rec: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Strip the `_raw` field for list outputs."""
|
||||
return {k: v for k, v in rec.items() if not k.startswith("_")}
|
||||
|
||||
|
||||
def list_boards(
|
||||
architecture: str | None = None,
|
||||
actively_supported_only: bool = False,
|
||||
query: str | None = None,
|
||||
board_level: str | None = None, # "release" | "pr" | "extra"
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Enumerate PlatformIO envs with Meshtastic metadata.
|
||||
|
||||
Filters are cumulative (AND). `board_level="release"` means envs with no
|
||||
explicit `board_level` set (the default release targets).
|
||||
"""
|
||||
envs = _get_cached()
|
||||
q = query.lower().strip() if query else None
|
||||
|
||||
out = []
|
||||
for rec in envs.values():
|
||||
if architecture and rec.get("architecture") != architecture:
|
||||
continue
|
||||
if actively_supported_only and not rec.get("actively_supported"):
|
||||
continue
|
||||
if board_level is not None:
|
||||
rec_level = rec.get("board_level")
|
||||
if board_level == "release":
|
||||
if rec_level not in (None, ""):
|
||||
continue
|
||||
elif rec_level != board_level:
|
||||
continue
|
||||
if q:
|
||||
display = (rec.get("display_name") or "").lower()
|
||||
env_name = rec.get("env", "").lower()
|
||||
slug = (rec.get("hw_model_slug") or "").lower()
|
||||
if q not in display and q not in env_name and q not in slug:
|
||||
continue
|
||||
out.append(_public_record(rec))
|
||||
|
||||
out.sort(key=lambda r: (r.get("architecture") or "", r.get("env")))
|
||||
return out
|
||||
|
||||
|
||||
def get_board(env: str) -> dict[str, Any]:
|
||||
"""Full metadata for one env, including the raw pio config dict."""
|
||||
envs = _get_cached()
|
||||
rec = envs.get(env)
|
||||
if rec is None:
|
||||
raise KeyError(
|
||||
f"Unknown env: {env!r}. Use list_boards() to see available envs."
|
||||
)
|
||||
public = _public_record(rec)
|
||||
public["raw_config"] = rec["_raw"]
|
||||
return public
|
||||
@@ -0,0 +1,286 @@
|
||||
"""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
|
||||
per frame, but zero Python deps beyond stdlib.
|
||||
- `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` /
|
||||
`auto` (default). `auto` picks opencv if `cv2` imports, else ffmpeg if
|
||||
`ffmpeg --version` resolves, else null.
|
||||
- `MESHTASTIC_UI_CAMERA_DEVICE` — generic default (index or path).
|
||||
- `MESHTASTIC_UI_CAMERA_DEVICE_<ROLE>` — per-role override, e.g.
|
||||
`MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3.
|
||||
Role suffix is uppercased before lookup.
|
||||
|
||||
Dependencies land in the optional `[ui]` extra; imports are lazy so clients
|
||||
without `opencv-python-headless` installed can still import this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class CameraError(RuntimeError):
|
||||
"""Raised when a camera backend fails to initialize or capture."""
|
||||
|
||||
|
||||
class CameraBackend(Protocol):
|
||||
name: str
|
||||
|
||||
def capture(self) -> bytes:
|
||||
"""Return one PNG-encoded frame."""
|
||||
...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
# ---------- OpenCV backend -------------------------------------------------
|
||||
|
||||
|
||||
class OpenCVBackend:
|
||||
name = "opencv"
|
||||
|
||||
def __init__(self, device: int | str, warmup_frames: int = 5) -> None:
|
||||
try:
|
||||
import cv2 # type: ignore[import-untyped] # noqa: PLC0415
|
||||
except ImportError as exc:
|
||||
raise CameraError(
|
||||
"opencv backend requested but `cv2` is not installed. "
|
||||
"Install the mcp-server [ui] extra: pip install -e '.[ui]'"
|
||||
) from exc
|
||||
|
||||
self._cv2 = cv2
|
||||
device_arg: int | str
|
||||
if isinstance(device, str) and device.isdigit():
|
||||
device_arg = int(device)
|
||||
else:
|
||||
device_arg = device
|
||||
self._cap = cv2.VideoCapture(device_arg)
|
||||
if not self._cap.isOpened():
|
||||
raise CameraError(
|
||||
f"cv2.VideoCapture({device_arg!r}) failed to open. "
|
||||
"On macOS check TCC Camera permission; on Linux check /dev/video* and v4l2 access."
|
||||
)
|
||||
|
||||
# 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
|
||||
# producing all-black captures.
|
||||
ok, frame = self._cap.read()
|
||||
if not ok or frame is None:
|
||||
self._cap.release()
|
||||
raise CameraError(f"camera {device_arg!r} opened but returned no frames")
|
||||
|
||||
def capture(self) -> bytes:
|
||||
cv2 = self._cv2
|
||||
ok, frame = self._cap.read()
|
||||
if not ok or frame is None:
|
||||
raise CameraError("cv2.VideoCapture.read() returned no frame")
|
||||
success, buf = cv2.imencode(".png", frame)
|
||||
if not success:
|
||||
raise CameraError("cv2.imencode('.png', ...) failed")
|
||||
return bytes(buf)
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._cap.release()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
# ---------- ffmpeg subprocess backend --------------------------------------
|
||||
|
||||
|
||||
class FfmpegBackend:
|
||||
name = "ffmpeg"
|
||||
|
||||
def __init__(self, device: int | str) -> None:
|
||||
if shutil.which("ffmpeg") is None:
|
||||
raise CameraError("ffmpeg backend requested but `ffmpeg` is not on PATH")
|
||||
|
||||
self._device = str(device)
|
||||
# Platform-specific -f flag:
|
||||
# macOS → avfoundation (index like "0")
|
||||
# Linux → v4l2 (device like "/dev/video0" or "0")
|
||||
if sys.platform == "darwin":
|
||||
self._input_format = "avfoundation"
|
||||
self._input_spec = self._device # bare index for avfoundation
|
||||
else:
|
||||
self._input_format = "v4l2"
|
||||
self._input_spec = (
|
||||
self._device
|
||||
if self._device.startswith("/dev/")
|
||||
else f"/dev/video{self._device}"
|
||||
)
|
||||
|
||||
def capture(self) -> bytes:
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
self._input_format,
|
||||
"-i",
|
||||
self._input_spec,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"png",
|
||||
"-",
|
||||
]
|
||||
try:
|
||||
out = subprocess.run(
|
||||
cmd, capture_output=True, check=True, timeout=15 # noqa: S603
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise CameraError(
|
||||
f"ffmpeg capture failed (rc={exc.returncode}): {exc.stderr.decode(errors='replace')[:200]}"
|
||||
) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise CameraError("ffmpeg capture timed out after 15s") from exc
|
||||
return out.stdout
|
||||
|
||||
def close(self) -> None:
|
||||
pass # stateless — each capture spawns a new process
|
||||
|
||||
|
||||
# ---------- Null backend ---------------------------------------------------
|
||||
|
||||
|
||||
# A tiny valid 1×1 transparent PNG so callers always get a decodable image.
|
||||
_BLACK_1X1_PNG = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
|
||||
"0000000d49444154789c6300010000000500010d0a2db40000000049454e44ae426082"
|
||||
)
|
||||
|
||||
|
||||
class NullBackend:
|
||||
name = "null"
|
||||
|
||||
def capture(self) -> bytes:
|
||||
return _BLACK_1X1_PNG
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- Factory --------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_device(role: str | None) -> str | None:
|
||||
if role:
|
||||
specific = os.environ.get(f"MESHTASTIC_UI_CAMERA_DEVICE_{role.upper()}")
|
||||
if specific:
|
||||
return specific
|
||||
return os.environ.get("MESHTASTIC_UI_CAMERA_DEVICE")
|
||||
|
||||
|
||||
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
|
||||
evidence, not a blocker.
|
||||
"""
|
||||
backend = os.environ.get("MESHTASTIC_UI_CAMERA_BACKEND", "auto").lower()
|
||||
device = _resolve_device(role)
|
||||
|
||||
if backend in ("null", "none") or device is None:
|
||||
return NullBackend()
|
||||
|
||||
if backend == "auto":
|
||||
# Prefer opencv if importable; fall back to ffmpeg; else null.
|
||||
try:
|
||||
import cv2 # type: ignore[import-untyped] # noqa: F401,PLC0415
|
||||
|
||||
backend = "opencv"
|
||||
except ImportError:
|
||||
backend = "ffmpeg" if shutil.which("ffmpeg") else "null"
|
||||
|
||||
if backend == "opencv":
|
||||
try:
|
||||
return OpenCVBackend(device)
|
||||
except CameraError as exc:
|
||||
warnings.warn(
|
||||
f"camera backend {backend!r} failed to initialize for device "
|
||||
f"{device!r}: {exc}; falling back to null backend",
|
||||
RuntimeWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return NullBackend()
|
||||
if backend == "ffmpeg":
|
||||
try:
|
||||
return FfmpegBackend(device)
|
||||
except CameraError as exc:
|
||||
warnings.warn(
|
||||
f"camera backend {backend!r} failed to initialize for device "
|
||||
f"{device!r}: {exc}; falling back to null backend",
|
||||
RuntimeWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return NullBackend()
|
||||
if backend == "null":
|
||||
return NullBackend()
|
||||
|
||||
raise CameraError(f"unknown MESHTASTIC_UI_CAMERA_BACKEND: {backend!r}")
|
||||
|
||||
|
||||
def save_capture(png_bytes: bytes, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(png_bytes)
|
||||
|
||||
|
||||
def capture_to_file(role: str | None, path: Path) -> dict[str, object]:
|
||||
"""One-shot: open camera, capture, write PNG, close. Returns metadata."""
|
||||
started = time.monotonic()
|
||||
cam = get_camera(role)
|
||||
try:
|
||||
data = cam.capture()
|
||||
finally:
|
||||
cam.close()
|
||||
save_capture(data, path)
|
||||
return {
|
||||
"backend": cam.name,
|
||||
"path": str(path),
|
||||
"bytes": len(data),
|
||||
"elapsed_s": round(time.monotonic() - started, 3),
|
||||
}
|
||||
|
||||
|
||||
def _is_png(data: bytes) -> bool:
|
||||
return data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
|
||||
# Exposed so callers can sanity-check a capture without a full PIL import.
|
||||
__all__ = [
|
||||
"CameraBackend",
|
||||
"CameraError",
|
||||
"FfmpegBackend",
|
||||
"NullBackend",
|
||||
"OpenCVBackend",
|
||||
"capture_to_file",
|
||||
"get_camera",
|
||||
"save_capture",
|
||||
]
|
||||
|
||||
# Keep `io` import used (pyflakes is picky) via a small guard used at import
|
||||
# time to normalize stdin/stdout if a subclass ever needs it.
|
||||
_ = io.BytesIO # noqa: SLF001
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Command-line entry points that sit alongside the MCP server.
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Flash progress log tailer for ``meshtastic-mcp-test-tui``.
|
||||
|
||||
``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``
|
||||
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``.
|
||||
|
||||
Separate from ``_fwlog.py`` because that one parses JSONL, this one
|
||||
streams plain text lines. Same daemon-thread + EOF-backoff structure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class FlashLogTailer(threading.Thread):
|
||||
"""Tail a plain-text log file, publish each stripped line via ``post``.
|
||||
|
||||
``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
|
||||
``run-tests.sh`` at session start, but if the tailer races the shell,
|
||||
we tolerate FileNotFoundError for up to ``wait_s`` seconds.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: pathlib.Path,
|
||||
post: Callable[[str], None],
|
||||
stop: threading.Event,
|
||||
*,
|
||||
wait_s: float = 30.0,
|
||||
) -> None:
|
||||
super().__init__(daemon=True, name="flashlog-tail")
|
||||
self._path = path
|
||||
self._post = post
|
||||
self._stop = stop
|
||||
self._wait_s = wait_s
|
||||
|
||||
def run(self) -> None:
|
||||
deadline = time.monotonic() + self._wait_s
|
||||
while not self._path.is_file():
|
||||
if self._stop.is_set() or time.monotonic() > deadline:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
fh = self._path.open("r", encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
line = fh.readline()
|
||||
if not line:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
line = line.rstrip("\r\n")
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
self._post(line)
|
||||
except Exception:
|
||||
# A post failure (e.g. closed app) is terminal for this
|
||||
# thread but we still want to close the file handle.
|
||||
return
|
||||
finally:
|
||||
fh.close()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Firmware log tail worker for ``meshtastic-mcp-test-tui``.
|
||||
|
||||
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`` —
|
||||
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
|
||||
30 s, back off at EOF.
|
||||
|
||||
Kept in its own module so the (large) ``test_tui.py`` stays focused on
|
||||
the Textual App shell.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class FirmwareLogTailer(threading.Thread):
|
||||
"""Tail ``tests/fwlog.jsonl``, publish parsed records via ``post``.
|
||||
|
||||
``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 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.
|
||||
post:
|
||||
Callable invoked with a dict ``{"ts", "port", "line"}`` for every
|
||||
new line parsed from the file.
|
||||
stop:
|
||||
An event the App sets to signal shutdown.
|
||||
wait_s:
|
||||
How long to poll for the file's creation before giving up. Default
|
||||
30 s; pytest collection on a cold cache can be slow.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: pathlib.Path,
|
||||
post: Callable[[dict[str, Any]], None],
|
||||
stop: threading.Event,
|
||||
*,
|
||||
wait_s: float = 30.0,
|
||||
) -> None:
|
||||
super().__init__(daemon=True, name="fwlog-tail")
|
||||
self._path = path
|
||||
self._post = post
|
||||
self._stop = stop
|
||||
self._wait_s = wait_s
|
||||
|
||||
def run(self) -> None:
|
||||
deadline = time.monotonic() + self._wait_s
|
||||
while not self._path.is_file():
|
||||
if self._stop.is_set() or time.monotonic() > deadline:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
fh = self._path.open("r", encoding="utf-8")
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
line = fh.readline()
|
||||
if not line:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
# Defensive: require the three fields we rely on.
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
if "line" not in record:
|
||||
continue
|
||||
self._post(record)
|
||||
finally:
|
||||
fh.close()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Cross-run history for ``meshtastic-mcp-test-tui``.
|
||||
|
||||
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
|
||||
quick read on whether the suite is slowing down over time.
|
||||
|
||||
Schema (keep small; the file can grow for months):
|
||||
|
||||
{"run": 42, "ts": 1729100000.0, "duration_s": 387.2,
|
||||
"passed": 52, "failed": 0, "skipped": 23, "exit_code": 0,
|
||||
"seed": "mcp-user-host"}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Iterable
|
||||
|
||||
# Sparkline glyphs, low → high. 8 levels is the Unicode convention.
|
||||
_SPARK_BLOCKS = "▁▂▃▄▅▆▇█"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunRecord:
|
||||
run: int
|
||||
ts: float
|
||||
duration_s: float
|
||||
passed: int
|
||||
failed: int
|
||||
skipped: int
|
||||
exit_code: int
|
||||
seed: str
|
||||
|
||||
|
||||
class HistoryStore:
|
||||
"""Append-only JSONL store with bounded read.
|
||||
|
||||
Writes are fsynced after each append (the file is tiny; fsync cost
|
||||
is negligible and protects against truncation on a crash).
|
||||
"""
|
||||
|
||||
def __init__(self, path: pathlib.Path, *, keep_last: int = 50) -> None:
|
||||
self._path = path
|
||||
self._keep_last = keep_last
|
||||
|
||||
def append(self, record: RunRecord) -> None:
|
||||
try:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(asdict(record)) + "\n")
|
||||
fh.flush()
|
||||
except Exception:
|
||||
# Non-fatal: history is cosmetic.
|
||||
pass
|
||||
|
||||
def read_recent(self) -> list[RunRecord]:
|
||||
"""Return the last ``keep_last`` records in chronological order."""
|
||||
if not self._path.is_file():
|
||||
return []
|
||||
try:
|
||||
lines = self._path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
out: list[RunRecord] = []
|
||||
# Parse tail-first so we don't waste work on a huge history.
|
||||
for line in lines[-self._keep_last :]:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
raw = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
try:
|
||||
out.append(RunRecord(**raw))
|
||||
except TypeError:
|
||||
# Schema drift; skip the record rather than crash.
|
||||
continue
|
||||
return out
|
||||
|
||||
def record_run(
|
||||
self,
|
||||
*,
|
||||
run: int,
|
||||
duration_s: float,
|
||||
passed: int,
|
||||
failed: int,
|
||||
skipped: int,
|
||||
exit_code: int,
|
||||
seed: str,
|
||||
) -> RunRecord:
|
||||
rec = RunRecord(
|
||||
run=run,
|
||||
ts=time.time(),
|
||||
duration_s=float(duration_s),
|
||||
passed=int(passed),
|
||||
failed=int(failed),
|
||||
skipped=int(skipped),
|
||||
exit_code=int(exit_code),
|
||||
seed=seed,
|
||||
)
|
||||
self.append(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def sparkline(values: Iterable[float], *, width: int = 20) -> str:
|
||||
"""Render a Unicode block-character sparkline from the last ``width`` values.
|
||||
|
||||
Returns an empty string for empty input so the header handles
|
||||
"no history yet" gracefully.
|
||||
"""
|
||||
buf = [v for v in values if v >= 0][-width:]
|
||||
if not buf:
|
||||
return ""
|
||||
lo, hi = min(buf), max(buf)
|
||||
if hi - lo < 1e-9:
|
||||
return _SPARK_BLOCKS[len(_SPARK_BLOCKS) // 2] * len(buf)
|
||||
n = len(_SPARK_BLOCKS) - 1
|
||||
out = []
|
||||
for v in buf:
|
||||
idx = int(round((v - lo) / (hi - lo) * n))
|
||||
out.append(_SPARK_BLOCKS[max(0, min(n, idx))])
|
||||
return "".join(out)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Reproducer bundle builder for ``meshtastic-mcp-test-tui``.
|
||||
|
||||
When the operator presses ``x`` on a failed test leaf, we package the
|
||||
minimum viable failure context into a tarball under
|
||||
``mcp-server/tests/reproducers/``:
|
||||
|
||||
::
|
||||
|
||||
repro-<ts>-<short_nodeid>.tar.gz
|
||||
├── README.md human-readable overview
|
||||
├── test_report.json the failing TestReport event from reportlog
|
||||
├── fwlog.jsonl firmware log filtered to the failure window
|
||||
├── devices.json per-device device_info + lora config snapshot
|
||||
└── 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`
|
||||
with the focused test's state and shows the path in a modal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import tarfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
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
|
||||
collection required at export time."""
|
||||
|
||||
nodeid: str
|
||||
longrepr: str
|
||||
sections: list[tuple[str, str]]
|
||||
start_ts: float | None
|
||||
stop_ts: float | None
|
||||
seed: str
|
||||
run_number: int
|
||||
exit_code: int | None
|
||||
fwlog_path: pathlib.Path
|
||||
output_dir: pathlib.Path
|
||||
extra_device_rows: list[dict[str, Any]] # [{role, port, info, ...}, ...]
|
||||
|
||||
|
||||
def _short_nodeid(nodeid: str) -> str:
|
||||
"""Collapse a pytest nodeid into a filename-safe slug (<= 60 chars)."""
|
||||
# Drop the file path prefix; keep test name + parametrization.
|
||||
tail = nodeid.split("::", 1)[-1] if "::" in nodeid else nodeid
|
||||
slug = re.sub(r"[^A-Za-z0-9_.\-]", "_", tail)
|
||||
return slug[:60].strip("_.-") or "test"
|
||||
|
||||
|
||||
def _filtered_fwlog(
|
||||
fwlog_path: pathlib.Path,
|
||||
start_ts: float | None,
|
||||
stop_ts: float | None,
|
||||
*,
|
||||
pad_s: float = 5.0,
|
||||
) -> bytes:
|
||||
"""Return fwlog.jsonl lines whose ``ts`` lies in [start-pad, stop+pad]."""
|
||||
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
|
||||
# when a test fails in setup before pytest emitted a start ts.
|
||||
try:
|
||||
return fwlog_path.read_bytes()
|
||||
except OSError:
|
||||
return b""
|
||||
lo, hi = start_ts - pad_s, stop_ts + pad_s
|
||||
out = io.BytesIO()
|
||||
try:
|
||||
with fwlog_path.open("r", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
try:
|
||||
record = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
ts = record.get("ts")
|
||||
if not isinstance(ts, (int, float)):
|
||||
continue
|
||||
if lo <= ts <= hi:
|
||||
out.write(line.encode("utf-8"))
|
||||
except OSError:
|
||||
return b""
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def _readme(ctx: ReproContext) -> str:
|
||||
t = time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime())
|
||||
return f"""# Reproducer bundle
|
||||
|
||||
Exported by `meshtastic-mcp-test-tui` on {t}.
|
||||
|
||||
## Failing test
|
||||
|
||||
- **nodeid:** `{ctx.nodeid}`
|
||||
- **seed:** `{ctx.seed}`
|
||||
- **run #:** {ctx.run_number}
|
||||
- **suite exit code (at export time):** {ctx.exit_code if ctx.exit_code is not None else "in progress"}
|
||||
|
||||
## Files in this archive
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `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.
|
||||
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.
|
||||
|
||||
## Reproducing locally
|
||||
|
||||
```bash
|
||||
cd mcp-server
|
||||
MESHTASTIC_MCP_SEED='{ctx.seed}' .venv/bin/pytest '{ctx.nodeid}' --tb=long -v
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
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
|
||||
them; refusing to export because the device poller had a hiccup
|
||||
would be worse than the export missing a file.
|
||||
"""
|
||||
ctx.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = int(time.time())
|
||||
slug = _short_nodeid(ctx.nodeid)
|
||||
archive_path = ctx.output_dir / f"repro-{ts}-{slug}.tar.gz"
|
||||
|
||||
with tarfile.open(archive_path, "w:gz") as tar:
|
||||
|
||||
def _add(name: str, data: bytes) -> None:
|
||||
info = tarfile.TarInfo(name=name)
|
||||
info.size = len(data)
|
||||
info.mtime = ts
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
|
||||
# README
|
||||
_add("README.md", _readme(ctx).encode("utf-8"))
|
||||
|
||||
# test_report.json — reconstruct from the fields the TUI stashes.
|
||||
test_report = {
|
||||
"nodeid": ctx.nodeid,
|
||||
"outcome": "failed",
|
||||
"longrepr": ctx.longrepr,
|
||||
"sections": [list(s) for s in ctx.sections],
|
||||
"start": ctx.start_ts,
|
||||
"stop": ctx.stop_ts,
|
||||
}
|
||||
_add(
|
||||
"test_report.json",
|
||||
json.dumps(test_report, indent=2, default=str).encode("utf-8"),
|
||||
)
|
||||
|
||||
# fwlog.jsonl (filtered)
|
||||
_add("fwlog.jsonl", _filtered_fwlog(ctx.fwlog_path, ctx.start_ts, ctx.stop_ts))
|
||||
|
||||
# devices.json
|
||||
try:
|
||||
devices_payload = json.dumps(
|
||||
ctx.extra_device_rows or [], indent=2, default=str
|
||||
)
|
||||
except Exception:
|
||||
devices_payload = "[]"
|
||||
_add("devices.json", devices_payload.encode("utf-8"))
|
||||
|
||||
# env.json
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_version
|
||||
|
||||
pytest_version = _pkg_version("pytest")
|
||||
except Exception:
|
||||
pytest_version = "unknown"
|
||||
env_payload = {
|
||||
"seed": ctx.seed,
|
||||
"run": ctx.run_number,
|
||||
"exit_code": ctx.exit_code,
|
||||
"export_ts": ts,
|
||||
"python": platform.python_version(),
|
||||
"pytest": pytest_version,
|
||||
"platform": f"{platform.system()} {platform.release()} {platform.machine()}",
|
||||
"hostname": socket.gethostname(),
|
||||
}
|
||||
_add("env.json", json.dumps(env_payload, indent=2).encode("utf-8"))
|
||||
|
||||
return archive_path
|
||||
|
||||
|
||||
def iter_entries(archive_path: pathlib.Path) -> Iterable[str]:
|
||||
"""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
|
||||
@@ -0,0 +1,83 @@
|
||||
"""UI-capture transcript tailer for ``meshtastic-mcp-test-tui``.
|
||||
|
||||
Watches ``tests/ui_captures/<session_seed>/`` for new transcript lines
|
||||
(one per ``frame_capture()`` call from the UI tier) and posts them to
|
||||
the TUI. Enabled by ``MESHTASTIC_UI_TUI_CAMERA=1``.
|
||||
|
||||
Design mirrors ``_flashlog.py``:
|
||||
- Daemon thread, cooperative stop via ``threading.Event``.
|
||||
- Tolerates the captures directory not existing yet (UI tier hasn't run).
|
||||
- Per-file seek state so we only forward genuinely-new lines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: pathlib.Path,
|
||||
post: Callable[[str, str], None],
|
||||
stop: threading.Event,
|
||||
*,
|
||||
poll_interval: float = 0.5,
|
||||
) -> None:
|
||||
super().__init__(daemon=True, name="uicap-tail")
|
||||
self._root = root
|
||||
self._post = post
|
||||
self._stop = stop
|
||||
self._poll_interval = poll_interval
|
||||
# path → byte offset we've already read through
|
||||
self._offsets: dict[pathlib.Path, int] = {}
|
||||
|
||||
def run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self._scan_once()
|
||||
except Exception:
|
||||
# Best-effort tailer — never bring down the TUI because a
|
||||
# directory vanished mid-scan.
|
||||
pass
|
||||
time.sleep(self._poll_interval)
|
||||
|
||||
def _scan_once(self) -> None:
|
||||
if not self._root.is_dir():
|
||||
return
|
||||
for transcript in self._root.rglob("transcript.md"):
|
||||
test_id = transcript.parent.name
|
||||
offset = self._offsets.get(transcript, 0)
|
||||
try:
|
||||
size = transcript.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
if size < offset:
|
||||
# File truncated / rewritten — reset and re-emit.
|
||||
offset = 0
|
||||
if size == offset:
|
||||
continue
|
||||
try:
|
||||
with transcript.open("rb") as fh:
|
||||
fh.seek(offset)
|
||||
chunk = fh.read(size - offset).decode("utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
for line in chunk.splitlines():
|
||||
line = line.rstrip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
try:
|
||||
self._post(test_id, line)
|
||||
except Exception:
|
||||
return
|
||||
self._offsets[transcript] = size
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user