Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce23bcf91f | ||
|
|
d3d606c1d5 | ||
|
|
c7f17a80b2 | ||
|
|
41a558cc5e | ||
|
|
be42d00728 | ||
|
|
6cd9a3a42b | ||
|
|
f56536417b | ||
|
|
b5940fc9f0 | ||
|
|
9b11352d42 | ||
|
|
1daeb2edce | ||
|
|
f6956160bb | ||
|
|
63d0781e29 | ||
|
|
266c143359 | ||
|
|
cd56674429 | ||
|
|
9e672db74c | ||
|
|
367cb9208a | ||
|
|
fe08803321 | ||
|
|
51f56ccb4e | ||
|
|
7ddecd1dd7 | ||
|
|
747fc01669 | ||
|
|
c8bc76bc60 | ||
|
|
c355215122 | ||
|
|
4d799cf66a | ||
|
|
ec80eb7d66 | ||
|
|
da1ec940dd | ||
|
|
b6caf1e6e7 | ||
|
|
cb867cc6c8 | ||
|
|
8e4ea08b98 | ||
|
|
f6c9b9aab0 | ||
|
|
7a3b4395f2 | ||
|
|
0abd202b82 | ||
|
|
8049d77522 | ||
|
|
0ee360295b | ||
|
|
1c0182f329 | ||
|
|
472b14c4e4 | ||
|
|
3851fbfec3 | ||
|
|
8b22448285 | ||
|
|
73e79797b2 | ||
|
|
a96f83fd01 | ||
|
|
0f761d930b | ||
|
|
4d4906772f | ||
|
|
711abb56f3 | ||
|
|
bfc536f96c | ||
|
|
7d7bfbcf24 | ||
|
|
8fcaf5ff30 | ||
|
|
3773683df6 | ||
|
|
2c782223a5 | ||
|
|
dcabb85000 | ||
|
|
9c3ceaf6e9 | ||
|
|
b2c07708bd | ||
|
|
b5737d089b | ||
|
|
3ccec98b58 | ||
|
|
30a3f39ed0 | ||
|
|
55da30ab58 | ||
|
|
078cd68191 | ||
|
|
e932c9786f | ||
|
|
8d1659a993 | ||
|
|
d04509bbf0 | ||
|
|
8d1674c3e5 | ||
|
|
af87c55530 | ||
|
|
1898aec281 | ||
|
|
dd2a4a3121 | ||
|
|
a77836279a | ||
|
|
ebc41e571c |
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(tr -d '\\n' | grep -o '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | head -1 | sed 's/.*:[[:space:]]*\"//; s/\"$//'); [ -n \"$f\" ] && [ -f \"$f\" ] || exit 0; t=$(command -v trunk || echo \"$HOME/.cache/trunk/launcher/trunk\"); [ -x \"$t\" ] || { echo \"trunk-fmt hook: trunk not found; its launcher needs curl or wget to bootstrap the CLI (see 'Formatting & the trunk toolchain' in .github/copilot-instructions.md)\" >&2; exit 1; }; out=$(\"$t\" fmt --force \"$f\" 2>&1) || { echo \"trunk-fmt hook: trunk fmt failed on $f: $out\" >&2; exit 1; }",
|
||||
"timeout": 120,
|
||||
"statusMessage": "Formatting (trunk)..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ ENV PIP_ROOT_USER_ACTION=ignore
|
||||
# trunk-ignore(hadolint/DL3008): apt packages are not pinned.
|
||||
# trunk-ignore(terrascan/AC_DOCKER_0002): apt packages are not pinned.
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
cmake git zip libgpiod-dev libjsoncpp-dev libbluetooth-dev libi2c-dev \
|
||||
cmake git zip libgpiod-dev libbluetooth-dev libi2c-dev \
|
||||
libunistring-dev libmicrohttpd-dev libgnutls28-dev libgcrypt20-dev \
|
||||
libusb-1.0-0-dev libssl-dev pkg-config libsqlite3-dev libsdl2-dev && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* && \
|
||||
|
||||
@@ -31,7 +31,7 @@ cmake --install "$WORK/ulfius/$SANITIZER" --prefix /usr
|
||||
cd "$SRC/firmware"
|
||||
|
||||
PLATFORMIO_EXTRA_SCRIPTS=$(echo -e "pre:.clusterfuzzlite/platformio-clusterfuzzlite-pre.py\npost:.clusterfuzzlite/platformio-clusterfuzzlite-post.py")
|
||||
STATIC_LIBS=$(pkg-config --libs --static libulfius openssl libgpiod yaml-cpp jsoncpp bluez --silence-errors)
|
||||
STATIC_LIBS=$(pkg-config --libs --static libulfius openssl libgpiod yaml-cpp bluez --silence-errors)
|
||||
export PLATFORMIO_EXTRA_SCRIPTS
|
||||
export STATIC_LIBS
|
||||
export PLATFORMIO_WORKSPACE_DIR="$WORK/pio/$SANITIZER"
|
||||
|
||||
@@ -16,7 +16,6 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
libssl-dev \
|
||||
libulfius-dev \
|
||||
libyaml-cpp-dev \
|
||||
libjsoncpp-dev \
|
||||
pipx \
|
||||
pkg-config \
|
||||
python3 \
|
||||
|
||||
@@ -13,7 +13,7 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get -y update --fix-missing
|
||||
sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev lsb-release
|
||||
sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev lsb-release
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -11,4 +11,4 @@ runs:
|
||||
- name: Install libs needed for native build
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev
|
||||
sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev
|
||||
|
||||
@@ -135,116 +135,6 @@ On top of authorization, any remote admin message that **mutates** state (not a
|
||||
- **Channel 0 PSK change** → every peer must re-learn the channel hash; cached NodeInfo becomes temporarily unreachable until the next broadcast.
|
||||
- **`security.private_key` blanked via admin** → regenerates both halves (unless in Ham mode) and propagates the new public key via NodeInfo.
|
||||
|
||||
## NodeDB Layout (v25)
|
||||
|
||||
`DEVICESTATE_CUR_VER = 25`, `DEVICESTATE_MIN_VER = 24`. The on-device NodeDB was split in v25 into a slim header table plus four optional satellite stores. Older v24 saves auto-migrate at boot. Old training-data instincts (`node->user.long_name`, `node->position.latitude_i`, `node->is_favorite`, `node->device_metrics.battery_level`) are wrong now — the fields aren't there. Read this section before touching anything that walks `nodeDB->meshNodes`.
|
||||
|
||||
### Slim `NodeInfoLite`
|
||||
|
||||
`UserLite` is flattened onto `NodeInfoLite` (no nested sub-message); `position` and `device_metrics` are removed entirely (tags reserved). MAC address is dropped. Long names are capped at 25 chars (`max_size:25` in `deviceonly.options`); `hw_model` and `role` are `int_size:8`. Encoded size dropped from ~166 B → ~105 B per node.
|
||||
|
||||
Booleans are bit-packed into `NodeInfoLite.bitfield`. **Do not read or write the bits directly** — use the inline helpers in `src/mesh/NodeDB.h`:
|
||||
|
||||
```cpp
|
||||
nodeInfoLiteHasUser(n) // bit 5 — user fields populated
|
||||
nodeInfoLiteIsFavorite(n) // bit 3
|
||||
nodeInfoLiteIsIgnored(n) // bit 4
|
||||
nodeInfoLiteIsMuted(n) // bit 1
|
||||
nodeInfoLiteIsLicensed(n) // bit 6 — Ham mode peer
|
||||
nodeInfoLiteIsKeyManuallyVerified(n) // bit 0
|
||||
nodeInfoLiteHasIsUnmessagable(n) // bit 8 — "is_unmessagable was sent"
|
||||
nodeInfoLiteIsUnmessagable(n) // bit 7
|
||||
// via_mqtt is bit 2 (mask exposed; predicate uses the mask directly)
|
||||
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); // setter
|
||||
```
|
||||
|
||||
### Satellite stores
|
||||
|
||||
Four `std::unordered_map<NodeNum, …>` members on `NodeDB`, each gated by its own build flag:
|
||||
|
||||
| Map | Value type | Build flag |
|
||||
| ----------------- | ------------------------------- | ---------------------------------- |
|
||||
| `nodePositions` | `meshtastic_PositionLite` | `MESHTASTIC_EXCLUDE_POSITIONDB` |
|
||||
| `nodeTelemetry` | `meshtastic_DeviceMetrics` | `MESHTASTIC_EXCLUDE_TELEMETRYDB` |
|
||||
| `nodeEnvironment` | `meshtastic_EnvironmentMetrics` | `MESHTASTIC_EXCLUDE_ENVIRONMENTDB` |
|
||||
| `nodeStatus` | `meshtastic_StatusMessage` | `MESHTASTIC_EXCLUDE_STATUSDB` |
|
||||
|
||||
Defaults are ON (i.e., maps **excluded**) for STM32WL only — see `src/mesh/mesh-pb-constants.h`. On every other arch all four maps are present. When excluded, the map member is absent and the corresponding accessors return `false`.
|
||||
|
||||
All four maps are guarded by **`mutable concurrency::Lock satelliteMutex`** — concurrent access from receive threads, the phone API state machine, and the renderer is the rule, not the exception.
|
||||
|
||||
### Accessor convention
|
||||
|
||||
**Never hand out pointers into the maps.** Use the copy-out accessors on `NodeDB`:
|
||||
|
||||
```cpp
|
||||
bool copyNodePosition(NodeNum, meshtastic_PositionLite &out) const;
|
||||
bool copyNodeTelemetry(NodeNum, meshtastic_DeviceMetrics &out) const;
|
||||
bool copyNodeEnvironment(NodeNum, meshtastic_EnvironmentMetrics &out) const;
|
||||
bool copyNodeStatus(NodeNum, meshtastic_StatusMessage &out) const;
|
||||
```
|
||||
|
||||
Each takes the lock, copies the value if present, returns `false` if the entry is absent or the DB is excluded. Pass-by-out-param is deliberate — pointer-style accessors would invite UAF and lock-leak bugs across the renderer. The "has any X" convenience predicates (`hasValidPosition` etc.) are implemented in terms of these.
|
||||
|
||||
Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which dispatches on `which_variant` for device vs environment metrics) — these own the lock and the eviction hooks.
|
||||
|
||||
### Eviction
|
||||
|
||||
Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `demoteOldestHotNodesToWarm` (the over-cap warm-tier migration), `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly. (Note: `enforceSatelliteCaps`/`evictSatelliteOverCap` call `.erase()` directly on purpose — that's a satellite-only cap trim where the node _stays_ in the header, a different operation from this chokepoint.)
|
||||
|
||||
### Warm tier (long-tail identity)
|
||||
|
||||
On every arch except STM32WL and bare nRF52832 (`WARM_NODE_COUNT > 0`), a node evicted from the header table is not forgotten outright: `WarmNodeStore` (`src/mesh/WarmNodeStore.{h,cpp}`) keeps a 40 B `{num, last_heard, public_key}` record per evicted node — primarily so PKI DMs to/from a long-tail node keep decrypting without re-running a NodeInfo exchange (the rest of `NodeInfoLite` rebuilds from traffic in seconds).
|
||||
|
||||
- **Write:** `getOrCreateMeshNode`'s eviction and `demoteOldestHotNodesToWarm` (the over-cap boot migration) call `warmStore.absorb(num, last_heard, key)` _before_ the node leaves the header.
|
||||
- **Read-back:** `getOrCreateMeshNode` calls `warmStore.take()` to rehydrate `last_heard` + key when a warm node is re-admitted; `copyPublicKey()` falls back to the warm tier so the PKI send path finds keys for evicted peers.
|
||||
- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring at `0xEA000` (below LittleFS; append + replay + compact-on-rotate, link-guarded by `nrf52840_s140_v7.ld` and `extra_scripts/nrf52_warm_region.py`). Everywhere else: a `/prefs/warm.dat` snapshot flushed by `saveIfDirty()` on the node-DB save cadence.
|
||||
- **Tunables** (`mesh-pb-constants.h`): `WARM_NODE_COUNT` (per-arch; `0` disables the tier) and `MAX_NUM_NODES` (hot cap — 120 on nRF52840/generic ESP32 to fit the 28 KB LittleFS; ESP32-S3 keeps its flash-scaled 100/200/250, portduino 250). Verbose migration/self-care tracing routes through `LOG_MIGRATION`, gated by `MESHTASTIC_NODEDB_MIGRATION_VERBOSE`.
|
||||
|
||||
### Satellite caps
|
||||
|
||||
Only the freshest `MAX_SATELLITE_NODES` nodes keep satellite payloads; the rest of the header table carries just the `NodeInfoLite`. The cap is **per-platform**: 40 on RAM-constrained parts (nRF52840, generic ESP32) since the four maps live in internal SRAM (not PSRAM, ~408 B/node across the four), and 250 on flash-rich hosts (ESP32-S3, portduino) so every hot node can carry rich data as before the cap existed. `enforceSatelliteCaps()` trims each map to the cap on load (returns whether it trimmed); `evictSatelliteOverCap()` trims before each insert. Eviction is by the owning node's hot `last_heard` (stalest first, demoted/absent nodes rank as `last_heard==0`); self is never trimmed.
|
||||
|
||||
### On-boot self-care
|
||||
|
||||
`NodeDB::nodeDBSelfCare()` runs once identity is established (the constructor after key (re)gen, and `reloadFromDisk()` — _not_ inside `loadFromDisk`, where `getNodeNum()` is still 0). It confirms self is present (warns if a non-empty DB is missing us — a foreign/over-cap file), pins self to index 0, demotes/trims only **non-self** overflow into the warm tier, then rewrites `nodes.proto` **once** and only if it healed something — and never while encrypted storage is locked (it would persist placeholder defaults). `loadFromDisk` deliberately leaves the loaded store untrimmed for this pass.
|
||||
|
||||
### Sync flow: thin NodeInfo + post-COMPLETE_ID replay (no opt-in)
|
||||
|
||||
There is no capability flag and no special "gradient" nonce. The **default** sync flow is:
|
||||
|
||||
1. Config / module-config / channel / metadata segments (same as before).
|
||||
2. `STATE_SEND_OWN_NODEINFO` — **our own** NodeInfo, still bundled with our position and device_metrics (because the replay snapshot excludes our own NodeNum). Emitted via `ConvertToNodeInfo(lite)`.
|
||||
3. `STATE_SEND_OTHER_NODEINFOS` — every other peer's NodeInfo, **always thin** (no `position`, no `device_metrics`). Emitted via `ConvertToNodeInfoThin(lite)`.
|
||||
4. `STATE_SEND_FILEMANIFEST` → `STATE_SEND_COMPLETE_ID` — the phone sees `config_complete_id` and treats sync as done.
|
||||
5. `STATE_SEND_PACKETS` — live mesh packets, with a trailing replay drain interleaved. The replay drain walks four cached satellite stores in order (positions → telemetry → environment → status) and emits each cached entry as an ordinary `MeshPacket` on the matching portnum (`POSITION_APP`, `TELEMETRY_APP` device + environment variants, `NODE_STATUS_APP`). These are indistinguishable on the wire from live mesh traffic, so clients need no special handling — any code that already updates UI on `POSITION_APP` etc. works.
|
||||
|
||||
`PhoneAPI::sendConfigComplete()` arms `replayPhase = REPLAY_PHASE_POSITIONS` for default/full sync and `SPECIAL_NONCE_ONLY_NODES`, while `SPECIAL_NONCE_ONLY_CONFIG` skips replay. The drain runs inside `STATE_SEND_PACKETS` via `popReplayPacket()`, lower priority than live traffic. When all four phases drain, `replayPhase` flips back to `REPLAY_PHASE_IDLE` and the snapshot vectors get `shrink_to_fit`ed.
|
||||
|
||||
STM32WL and any other build with all four `MESHTASTIC_EXCLUDE_*DB` flags set produces zero replay packets — `popReplayPacket` advances through each phase in microseconds without emitting anything.
|
||||
|
||||
Special nonces that still mean something:
|
||||
|
||||
- `SPECIAL_NONCE_ONLY_CONFIG` (69420) — skip node sync entirely, just config.
|
||||
- `SPECIAL_NONCE_ONLY_NODES` (69421) — skip config segments, jump straight to `STATE_SEND_OWN_NODEINFO`. Still gets the post-COMPLETE_ID replay drain.
|
||||
|
||||
There are no other reserved nonces; everything else is a fresh random `want_config_id` from the client.
|
||||
|
||||
### v24 → v25 migration
|
||||
|
||||
The legacy migration code lives in **`src/mesh/NodeDBLegacyMigration.cpp`**, not in `NodeDB.cpp`. It owns the `meshtastic_NodeDatabase_Legacy` callback and `NodeDB::migrateLegacyNodeDatabase()`. The legacy proto descriptor is `protobufs/meshtastic/deviceonly_legacy.proto` (only included by the migration TU). The boot path peeks the file's leading version tag, runs the migration if `version < 25`, then re-saves in v25 layout. The legacy descriptor is scheduled for removal once `DEVICESTATE_MIN_VER` is bumped.
|
||||
|
||||
### Read-site rules of thumb
|
||||
|
||||
- Never `node->position.X` / `node->device_metrics.X` — those fields no longer exist. Pull from the satellite map via `copyNodePosition` / `copyNodeTelemetry`.
|
||||
- Never `node->user.long_name` — `long_name`, `short_name`, `public_key`, `hw_model`, `role`, `macaddr` (gone), `is_licensed`, `is_unmessagable` are flat on `NodeInfoLite`.
|
||||
- Never `node->is_favorite` / `node->is_ignored` / `node->via_mqtt` / `node->is_key_manually_verified` — use the bitfield helpers.
|
||||
- Never assume `nodeDB->getMeshNode(num)->position.time` — call `copyNodePosition` and check the return.
|
||||
- Don't lock `satelliteMutex` yourself in renderer code; the copy-out accessors already do.
|
||||
|
||||
Unit tests for the conversion layer live in `test/test_type_conversions/test_main.cpp` (Unity) — bitfield round-trips, `long_name` truncation, thin-vs-full conversions. Add cases there when extending the schema.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -300,15 +190,6 @@ firmware/
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
### Formatting & the trunk toolchain
|
||||
|
||||
`trunk fmt` is the project formatter (`trunk_check` CI rejects unformatted code). For Claude Code users, `.claude/settings.json` ships a PostToolUse hook that runs `trunk fmt --force` on every file the agent writes or edits. The hook is pure sh/grep/sed — no python or jq required — but trunk itself must be able to run:
|
||||
|
||||
- Trunk's launcher (`~/.cache/trunk/launcher/trunk`, or `trunk` on PATH) downloads the CLI version pinned in `.trunk/trunk.yaml` on first use and again whenever that pin is bumped. **The launcher needs `curl` or `wget`**; without one it fails with "Cannot download… please install curl or wget", and the hook surfaces that as a warning on every write.
|
||||
- No curl/wget available (e.g. a minimal WSL image)? Bootstrap by hand with any Python (PlatformIO bundles one at `~/.platformio/penv/bin/python`): download `https://trunk.io/releases/<ver>/trunk-<ver>-linux-x86_64.tar.gz` and place the `trunk` binary at `~/.cache/trunk/cli/<ver>-linux-x86_64/trunk` (chmod +x), where `<ver>` is the `cli.version` from `.trunk/trunk.yaml`.
|
||||
- The hook fails loudly by design (visible warning, non-blocking). Silent no-op formatting hooks hide real breakage — don't re-add `2>/dev/null || true` around the whole thing.
|
||||
- More generally: don't assume a stock Linux userland in hooks or helper scripts — minimal WSL/container images may lack `python3`, `curl`, `wget`, and `jq`. Prefer plain sh + coreutils, or PlatformIO's bundled Python for anything heavier.
|
||||
|
||||
### General Style
|
||||
|
||||
- Follow existing code style - run `trunk fmt` before commits
|
||||
@@ -635,35 +516,20 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing.
|
||||
|
||||
### Native unit tests (C++)
|
||||
|
||||
Unit tests in `test/` directory with 17 test suites:
|
||||
Unit tests in `test/` directory with 12 test suites:
|
||||
|
||||
- `test_admin_radio/` - LoRa region/config validation and AdminModule dispatch
|
||||
- `test_atak/` - ATAK integration
|
||||
- `test_crypto/` - Cryptography
|
||||
- `test_default/` - Default configuration
|
||||
- `test_http_content_handler/` - HTTP handling
|
||||
- `test_mac_from_string/` - MAC address parsing
|
||||
- `test_mqtt/` - MQTT integration
|
||||
- `test_radio/` - Radio interface
|
||||
- `test_mesh_module/` - Module framework
|
||||
- `test_meshpacket_serializer/` - Packet serialization
|
||||
- `test_mqtt/` - MQTT integration
|
||||
- `test_packet_history/` - Packet history tracking
|
||||
- `test_position_precision/` - Position precision helpers
|
||||
- `test_radio/` - Radio interface
|
||||
- `test_serial/` - Serial communication
|
||||
- `test_traffic_management/` - Traffic management
|
||||
- `test_transmit_history/` - Retransmission tracking
|
||||
- `test_type_conversions/` - NodeDB v25 type conversion (bitfield round-trips, NodeInfoLite)
|
||||
- `test_utf8/` - UTF-8 utilities
|
||||
- `test_atak/` - ATAK integration
|
||||
- `test_default/` - Default configuration
|
||||
- `test_http_content_handler/` - HTTP handling
|
||||
- `test_serial/` - Serial communication
|
||||
|
||||
Run command (preferred — avoids pipe-buffering and the Ubuntu externally-managed-environment error):
|
||||
|
||||
```bash
|
||||
~/.platformio/penv/bin/python -m platformio test -e native -f test_your_suite > /tmp/test_out.txt 2>&1
|
||||
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
|
||||
tail -15 /tmp/test_out.txt
|
||||
```
|
||||
|
||||
Do **not** use `pio test … | tail -N` — it discards build errors and shows stale cached results. Do **not** use `pio test … | grep` — line-buffering makes the terminal appear hung until the process exits. Redirect to a file first, then grep.
|
||||
Run with: `pio test -e native`
|
||||
|
||||
Simulation testing: `bin/test-simulator.sh`
|
||||
|
||||
|
||||
@@ -4,14 +4,9 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
# trunk-ignore(checkov/CKV_GHA_7)
|
||||
target:
|
||||
type: string
|
||||
required: false
|
||||
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
|
||||
arch:
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- esp32
|
||||
- esp32s3
|
||||
- esp32c3
|
||||
@@ -20,18 +15,32 @@ on:
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
description: Choose an arch to limit the search, or 'all' to search all architectures.
|
||||
default: all
|
||||
target:
|
||||
type: string
|
||||
required: false
|
||||
description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets.
|
||||
# find-target:
|
||||
# type: boolean
|
||||
# default: true
|
||||
# description: 'Find the available targets'
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
find-targets:
|
||||
if: ${{ inputs.target == '' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch:
|
||||
- all
|
||||
- esp32
|
||||
- esp32s3
|
||||
- esp32c3
|
||||
- esp32c6
|
||||
- nrf52840
|
||||
- rp2040
|
||||
- rp2350
|
||||
- stm32
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -42,37 +51,14 @@ jobs:
|
||||
- run: pip install -U platformio
|
||||
- name: Generate matrix
|
||||
id: jsonStep
|
||||
env:
|
||||
BUILDTARGET: ${{ inputs.target }}
|
||||
MATRIXARCH: ${{ inputs.arch }}
|
||||
run: |
|
||||
TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level extra)
|
||||
if [ "$BUILDTARGET" = "" ]; then
|
||||
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Arch: $MATRIXARCH" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 🎯 The following target boards are available to build:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo $TARGETS | jq -r 'sort_by(.board) | sort_by(.platform) |.[] | "| " + .platform + " | " + .board + " |" ' >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "We build this one:" >> $GITHUB_STEP_SUMMARY
|
||||
ARCH=$(echo "$TARGETS" | jq --arg BUILDTARGET "$BUILDTARGET" -r '.[] | select(.board==$BUILDTARGET) | .platform')
|
||||
echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| $ARCH | "$BUILDTARGET" |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ "$ARCH" == "" ]]; then
|
||||
echo "## ❌ Error: Target "$BUILDTARGET" not found!" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "## ✅ Target "$BUILDTARGET" found, proceeding to build." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "You may need to refresh this page to make the built firmware appear below." >> $GITHUB_STEP_SUMMARY
|
||||
echo "arch=$ARCH" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
outputs:
|
||||
arch: ${{ steps.jsonStep.outputs.arch }}
|
||||
echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Arch: ${{matrix.arch}}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Targets:" >> $GITHUB_STEP_SUMMARY
|
||||
echo $TARGETS | jq -r 'sort_by(.board) |.[] | "- " + .board' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
version:
|
||||
if: ${{ inputs.target != '' }}
|
||||
@@ -92,12 +78,12 @@ jobs:
|
||||
|
||||
build:
|
||||
if: ${{ inputs.target != '' && inputs.arch != 'native' }}
|
||||
needs: [version, find-targets]
|
||||
needs: [version]
|
||||
uses: ./.github/workflows/build_firmware.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.long }}
|
||||
pio_env: ${{ inputs.target }}
|
||||
platform: ${{ needs.find-targets.outputs.arch }}
|
||||
platform: ${{ inputs.arch }}
|
||||
|
||||
gather-artifacts:
|
||||
permissions:
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
name: Post Firmware Size Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
post-size-comment:
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion != 'cancelled' &&
|
||||
github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download size report
|
||||
id: download
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: size-report
|
||||
path: ./
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.download.outcome == 'success'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const marker = '<!-- firmware-size-report -->';
|
||||
const body = fs.readFileSync('./size-report.md', 'utf8');
|
||||
const prNumber = parseInt(fs.readFileSync('./pr-number.txt', 'utf8').trim(), 10);
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
name: Post Web Flasher Link Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
post-flasher-link:
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion != 'cancelled' &&
|
||||
github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Per-board manifests carry the firmware's own metadata (activelySupported,
|
||||
# displayName, ...) generated from each target's custom_meshtastic_* config.
|
||||
- name: Download board manifests
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: manifest-*
|
||||
path: ./manifests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Post or update web flasher link comment
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- web-flasher-link -->';
|
||||
const run = context.payload.workflow_run;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Resolve the PR by matching the run's head SHA against the repo's open
|
||||
// PRs. workflow_run.pull_requests is empty for fork PRs, and
|
||||
// listPullRequestsAssociatedWithCommit won't return an open fork PR by
|
||||
// its head commit — but pulls.list includes fork PRs. Matching on head
|
||||
// SHA also enforces that the run is for the PR's current commit, so stale
|
||||
// re-runs of an outdated commit won't match.
|
||||
const openPrs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
const pr = openPrs.find((p) => p.head.sha === run.head_sha);
|
||||
if (!pr) {
|
||||
core.info(`No open pull request matches commit ${run.head_sha}; skipping.`);
|
||||
return;
|
||||
}
|
||||
const prNumber = pr.number;
|
||||
|
||||
// Restrict to trusted authors. NOTE: author_association is computed for
|
||||
// the GITHUB_TOKEN, which cannot see *private/concealed* org memberships —
|
||||
// those members come back as CONTRIBUTOR, not MEMBER. So gating on MEMBER
|
||||
// alone silently excludes most maintainers. We allow the trusted set the
|
||||
// token can actually identify (members, collaborators, and anyone with a
|
||||
// previously merged PR). For strict members-only you'd need an org-read
|
||||
// App/PAT token to call orgs.checkMembershipForUser.
|
||||
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (!allowedAssociations.includes(pr.author_association)) {
|
||||
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Require at least one per-arch firmware artifact from gather-artifacts
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner, repo, run_id: run.id, per_page: 100,
|
||||
});
|
||||
const archRe = /^firmware-(esp32|esp32s3|esp32c3|esp32c6|nrf52840|rp2040|rp2350|stm32)-(\d+\.\d+\.\d+\.[0-9a-f]+)$/;
|
||||
const archArtifacts = artifacts.filter((a) => archRe.test(a.name) && !a.expired);
|
||||
if (archArtifacts.length === 0) {
|
||||
core.info('No per-arch firmware artifacts found; skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = archRe.exec(archArtifacts[0].name)[2];
|
||||
const expiresAt = archArtifacts[0].expires_at
|
||||
? new Date(archArtifacts[0].expires_at).toISOString().slice(0, 10)
|
||||
: null;
|
||||
|
||||
// Read each built board's manifest (.mt.json). activelySupported,
|
||||
// displayName and architecture come straight from the board's
|
||||
// custom_meshtastic_* platformio config, so the list is in sync with
|
||||
// the firmware itself — no external device database needed.
|
||||
const fs = require('fs');
|
||||
let boards = [];
|
||||
try {
|
||||
boards = fs.readdirSync('./manifests')
|
||||
.filter((f) => f.endsWith('.mt.json'))
|
||||
.map((f) => {
|
||||
try { return JSON.parse(fs.readFileSync(`./manifests/${f}`, 'utf8')); }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter((m) => m && m.activelySupported === true && m.platformioTarget)
|
||||
.map((m) => ({
|
||||
board: m.platformioTarget,
|
||||
platform: m.architecture || '',
|
||||
// displayName is maintainer-authored text; escape table-breaking pipes
|
||||
displayName: String(m.displayName || m.platformioTarget).replace(/\|/g, '\\|'),
|
||||
image: Array.isArray(m.images) && m.images[0] ? String(m.images[0]) : '',
|
||||
}))
|
||||
.sort((a, b) => a.board.localeCompare(b.board));
|
||||
} catch (e) {
|
||||
core.warning(`Could not read board manifests: ${e.message}`);
|
||||
}
|
||||
|
||||
const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`;
|
||||
// Device illustrations are served by the flasher from the same image
|
||||
// names the manifest declares (custom_meshtastic_images). The flasher
|
||||
// serves its SPA shell (HTML, 200) for unknown paths, so confirm each
|
||||
// image really resolves to an image before linking it.
|
||||
const imageBase = 'https://flasher.meshtastic.org/img/devices/';
|
||||
await Promise.all(boards.map(async (b) => {
|
||||
if (!b.image) return;
|
||||
try {
|
||||
const res = await fetch(`${imageBase}${encodeURIComponent(b.image)}`);
|
||||
const type = res.headers.get('content-type') || '';
|
||||
if (!res.ok || !type.startsWith('image/')) b.image = '';
|
||||
} catch { b.image = ''; }
|
||||
}));
|
||||
|
||||
const boardLines = boards
|
||||
.map((b) => {
|
||||
const img = b.image ? `<img src="${imageBase}${encodeURIComponent(b.image)}" alt="" height="34">` : '';
|
||||
return `| ${img} | ${b.displayName} | [\`${b.board}\`](${flasherUrl}&device=${encodeURIComponent(b.board)}) | ${b.platform} |`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// Shields.io badges. Only non-user-controlled, charset-constrained values
|
||||
// (version, commit sha, counts, dates) go into badge URLs — never board
|
||||
// names or the PR title — so the rendered comment cannot be spoofed.
|
||||
const shieldText = (s) =>
|
||||
encodeURIComponent(String(s).replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'));
|
||||
const shield = (label, message, color) =>
|
||||
`https://img.shields.io/badge/${shieldText(label)}-${shieldText(message)}-${color}`;
|
||||
const buttonUrl =
|
||||
`https://img.shields.io/badge/${shieldText('Flash this PR in the Web Flasher')}-2C2D3C?style=for-the-badge`;
|
||||
const badges = [
|
||||
`})`,
|
||||
`, '2C2D3C')})`,
|
||||
`})`,
|
||||
];
|
||||
if (expiresAt) badges.push(`})`);
|
||||
|
||||
// Only render the board table when there are supported boards to list
|
||||
const boardTable = boards.length > 0 ? [
|
||||
`<details><summary>Supported boards built by this PR (${boards.length})</summary>`,
|
||||
'',
|
||||
'| | Device | Board | Platform |',
|
||||
'| --- | --- | --- | --- |',
|
||||
boardLines,
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
] : [];
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'## ⚡ Try this PR in the Web Flasher',
|
||||
'',
|
||||
`[](${flasherUrl})`,
|
||||
'',
|
||||
badges.join(' '),
|
||||
'',
|
||||
'> [!WARNING]',
|
||||
'> This is an automated, unreviewed CI test build. Back up your device configuration',
|
||||
'> before flashing, and only flash devices you are able to recover.',
|
||||
'',
|
||||
...boardTable,
|
||||
`*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for \`${run.head_sha.slice(0, 7)}\`.*`,
|
||||
].join('\n');
|
||||
|
||||
// Sticky comment: update in place when the marker is found
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: prNumber, per_page: 100,
|
||||
});
|
||||
const existing = comments.find((c) => c.body?.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
name: Post Web Flasher Build Placeholder
|
||||
|
||||
# Drops an immediate "build in progress" comment when a PR opens, so the web
|
||||
# flasher entry shows up right away. The real CI-driven workflow
|
||||
# (flasher-link-comment.yml) later replaces it in place via the shared marker.
|
||||
#
|
||||
# SECURITY: this uses pull_request_target (write token, runs for fork PRs) but is
|
||||
# safe because it never checks out or runs PR code and posts a fully static body
|
||||
# — no PR title, branch name, or other untrusted input is used anywhere.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
post-placeholder:
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Post web flasher build-in-progress placeholder
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- web-flasher-link -->';
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Trusted authors only (matches the real workflow). author_association
|
||||
// can't reflect private org membership for the token, so concealed
|
||||
// members appear as CONTRIBUTOR — include it, or maintainers are excluded.
|
||||
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (!allowedAssociations.includes(pr.author_association)) {
|
||||
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only seed a placeholder when no flasher comment exists yet — never
|
||||
// overwrite a real (or existing placeholder) comment.
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: pr.number, per_page: 100,
|
||||
});
|
||||
if (comments.some((c) => c.body?.includes(marker))) {
|
||||
core.info('Flasher comment already exists; nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'## ⚡ Try this PR in the Web Flasher',
|
||||
'',
|
||||
'> [!NOTE]',
|
||||
'> Building this pull request… the flash button, badges and supported-board',
|
||||
'> list will appear here automatically once CI finishes.',
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: pr.number, body,
|
||||
});
|
||||
@@ -82,9 +82,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
check: ${{ fromJson(needs.setup.outputs.check) }}
|
||||
# Runs on GitHub-hosted runners so checks don't compete with builds for the
|
||||
# self-hosted 'arctastic' pool (which builds use).
|
||||
runs-on: ubuntu-latest
|
||||
# Use 'arctastic' self-hosted runner pool when checking in the main repo
|
||||
runs-on: ${{ github.repository_owner == 'meshtastic' && 'arctastic' || 'ubuntu-latest' }}
|
||||
if: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -246,126 +245,47 @@ jobs:
|
||||
path: ./*.elf
|
||||
retention-days: 30
|
||||
|
||||
firmware-size-report:
|
||||
shame:
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download current manifests
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
filter: blob:none # means we download all the git history but none of the commit (except ones with checkout like the head)
|
||||
fetch-depth: 0
|
||||
- name: Download the current manifests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ./manifests/
|
||||
path: ./manifests-new/
|
||||
pattern: manifest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Collect current firmware sizes
|
||||
run: python3 bin/collect_sizes.py ./manifests/ ./current-sizes.json
|
||||
|
||||
- name: Upload size report artifact
|
||||
- name: Upload combined manifests for later commit and global stats crunching.
|
||||
uses: actions/upload-artifact@v7
|
||||
id: upload-manifest
|
||||
with:
|
||||
name: firmware-sizes-${{ github.sha }}
|
||||
name: manifests-${{ github.sha }}
|
||||
overwrite: true
|
||||
path: ./current-sizes.json
|
||||
retention-days: 90
|
||||
|
||||
- name: Download baseline sizes from develop
|
||||
path: manifests-new/*.mt.json
|
||||
- name: Find the merge base
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
id: baseline-develop
|
||||
run: echo "MERGE_BASE=$(git merge-base "origin/$base" "$head")" >> $GITHUB_ENV
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
RUN_ID=$(gh run list -R "${{ github.repository }}" \
|
||||
--workflow CI --branch develop --status success \
|
||||
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
|
||||
if [ -n "$RUN_ID" ]; then
|
||||
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
|
||||
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
|
||||
if [ -n "$ARTIFACT_NAME" ]; then
|
||||
gh run download "$RUN_ID" -R "${{ github.repository }}" \
|
||||
--name "$ARTIFACT_NAME" --dir ./baseline-develop/
|
||||
cp "./baseline-develop/current-sizes.json" ./develop-sizes.json
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Download baseline sizes from master
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
id: baseline-master
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
RUN_ID=$(gh run list -R "${{ github.repository }}" \
|
||||
--workflow CI --branch master --status success \
|
||||
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
|
||||
if [ -n "$RUN_ID" ]; then
|
||||
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
|
||||
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
|
||||
if [ -n "$ARTIFACT_NAME" ]; then
|
||||
gh run download "$RUN_ID" -R "${{ github.repository }}" \
|
||||
--name "$ARTIFACT_NAME" --dir ./baseline-master/
|
||||
cp "./baseline-master/current-sizes.json" ./master-sizes.json
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Generate size comparison report
|
||||
if: github.event_name == 'pull_request'
|
||||
id: report
|
||||
run: |
|
||||
ARGS="./current-sizes.json"
|
||||
if [ -f ./develop-sizes.json ]; then
|
||||
ARGS="$ARGS --baseline develop:./develop-sizes.json"
|
||||
fi
|
||||
if [ -f ./master-sizes.json ]; then
|
||||
ARGS="$ARGS --baseline master:./master-sizes.json"
|
||||
fi
|
||||
REPORT=$(python3 bin/size_report.py $ARGS)
|
||||
if [ -z "$REPORT" ]; then
|
||||
echo "has_report=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_report=true" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo '<!-- firmware-size-report -->'
|
||||
echo '# Firmware Size Report'
|
||||
echo ''
|
||||
echo "$REPORT"
|
||||
echo ''
|
||||
echo '---'
|
||||
echo "*Updated for ${{ github.sha }}*"
|
||||
} > ./size-report.md
|
||||
cat ./size-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Save PR number
|
||||
if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true'
|
||||
run: echo "${{ github.event.pull_request.number }}" > ./pr-number.txt
|
||||
|
||||
- name: Upload size report
|
||||
if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: size-report
|
||||
path: |
|
||||
./size-report.md
|
||||
./pr-number.txt
|
||||
retention-days: 5
|
||||
base: ${{ github.base_ref }}
|
||||
head: ${{ github.sha }}
|
||||
# Currently broken (for-loop through EVERY artifact -- rate limiting)
|
||||
# - name: Download the old manifests
|
||||
# if: github.event_name == 'pull_request'
|
||||
# run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/
|
||||
# env:
|
||||
# GH_TOKEN: ${{ github.token }}
|
||||
# merge_base: ${{ env.MERGE_BASE }}
|
||||
# repo: ${{ github.repository }}
|
||||
# - name: Do scan and post comment
|
||||
# if: github.event_name == 'pull_request'
|
||||
# run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/
|
||||
|
||||
release-artifacts:
|
||||
permissions: # Needed for 'gh release upload'.
|
||||
|
||||
@@ -17,13 +17,13 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Trunk Check
|
||||
uses: trunk-io/trunk-action@v1
|
||||
uses: trunk-io/trunk-action@v1.3.1
|
||||
with:
|
||||
trunk-token: ${{ secrets.TRUNK_TOKEN }}
|
||||
|
||||
trunk_upgrade:
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
# See: https://github.com/trunk-io/trunk-action/blob/v1/readme.md#automatic-upgrades
|
||||
# See: https://github.com/trunk-io/trunk-action/blob/main/readme.md#automatic-upgrades
|
||||
name: Trunk Upgrade (PR)
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
@@ -34,6 +34,6 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Trunk Upgrade
|
||||
uses: trunk-io/trunk-action/upgrade@v1
|
||||
uses: trunk-io/trunk-action/upgrade@v1.3.1
|
||||
with:
|
||||
base: master
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Stale PR+Issues
|
||||
uses: actions/stale@v10.2.0
|
||||
uses: actions/stale@v10.3.0
|
||||
with:
|
||||
days-before-stale: 45
|
||||
stale-issue-message: This issue has not had any comment or update in the last month. If it is still relevant, please post update comments. If no comments are made, this issue will be closed automagically in 7 days.
|
||||
|
||||
@@ -37,33 +37,13 @@ jobs:
|
||||
sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative.
|
||||
|
||||
- name: Integration test
|
||||
# Cap the whole step: if the simulator ever fails to exit (e.g. the
|
||||
# exit_simulator admin path regresses again) the job must fail fast,
|
||||
# not run to GitHub's 6-hour limit.
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
.pio/build/coverage/meshtasticd -s &
|
||||
PID=$!
|
||||
trap 'kill "$PID" 2>/dev/null || true' EXIT
|
||||
timeout 20 bash -c "until ls -al /proc/$PID/fd | grep socket; do sleep 1; done"
|
||||
echo "Simulator started, launching python test..."
|
||||
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
|
||||
# The Python harness sends exit_simulator and exits; the simulator is
|
||||
# expected to terminate on its own. Give it a moment, then verify.
|
||||
# If it is still alive the exit handshake is broken — fail loudly and
|
||||
# do NOT fall through to `wait`, which would otherwise block until the
|
||||
# job's hard timeout.
|
||||
for i in $(seq 1 10); do
|
||||
kill -0 "$PID" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "::error title=Simulator did not exit::meshtasticd ignored exit_simulator and is still running after the integration test. The exit_simulator admin path is broken (see AdminModule::handleReceivedProtobuf, ARCH_PORTDUINO bypass). Killing it to avoid a 6-hour CI overrun."
|
||||
kill -9 "$PID" 2>/dev/null || true
|
||||
wait "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
wait "$PID" 2>/dev/null || true
|
||||
wait
|
||||
|
||||
- name: Capture coverage information
|
||||
if: always() # run this step even if previous step failed
|
||||
@@ -106,13 +86,7 @@ jobs:
|
||||
run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini
|
||||
|
||||
- name: PlatformIO Tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
# Filter out SKIPPED summary rows for hardware variants that can't run on the
|
||||
# native host. They flood the log and make it harder to spot real failures.
|
||||
# The JUnit XML is written directly to testreport.xml before the pipe, so
|
||||
# the test artifact is unaffected.
|
||||
platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$"
|
||||
run: platformio test -e coverage -v --junit-output-path testreport.xml
|
||||
|
||||
- name: Save test results
|
||||
if: always() # run this step even if previous step failed
|
||||
@@ -161,14 +135,6 @@ jobs:
|
||||
name: platformio-test-report-${{ steps.version.outputs.long }}
|
||||
merge-multiple: true
|
||||
|
||||
- name: Drop no-status testsuites from the report
|
||||
# PlatformIO emits a self-closing <testsuite tests="0"/> row for every test_* dir
|
||||
# crossed with every hardware variant it cannot run on the native host (~4900 rows).
|
||||
# They carry no pass/fail/skip status and bury the suites that actually ran. Strip
|
||||
# them so the Test Report lists only suites with a real status. Only the copy the
|
||||
# reporter renders is trimmed; the uploaded artifact keeps the full XML.
|
||||
run: sed -i -E 's#<testsuite [^>]*tests="0"[^>]*/>##g' testreport.xml
|
||||
|
||||
- name: Test Report
|
||||
uses: dorny/test-reporter@v3.0.0
|
||||
with:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Annotate PR with trunk issues
|
||||
# See: https://github.com/trunk-io/trunk-action/blob/v1/readme.md#getting-inline-annotations-for-fork-prs
|
||||
# See: https://github.com/trunk-io/trunk-action/blob/main/readme.md#getting-inline-annotations-for-fork-prs
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
@@ -21,6 +21,6 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Trunk Check
|
||||
uses: trunk-io/trunk-action@v1
|
||||
uses: trunk-io/trunk-action@v1.3.1
|
||||
with:
|
||||
post-annotations: true
|
||||
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Trunk Check
|
||||
uses: trunk-io/trunk-action@v1
|
||||
uses: trunk-io/trunk-action@v1.3.1
|
||||
with:
|
||||
save-annotations: true
|
||||
|
||||
@@ -16,18 +16,13 @@ jobs:
|
||||
submodules: true
|
||||
|
||||
- name: Update submodule
|
||||
if: ${{ github.ref_name == 'master' || github.ref_name == 'develop' }}
|
||||
working-directory: protobufs
|
||||
env:
|
||||
# Use the branch that triggered the workflow as the protobuf branch.
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' }}
|
||||
run: |
|
||||
git fetch --prune origin $GIT_BRANCH
|
||||
git checkout origin/$GIT_BRANCH
|
||||
git submodule update --remote protobufs
|
||||
|
||||
- name: Download nanopb
|
||||
run: |
|
||||
wget https://github.com/nanopb/nanopb/releases/download/nanopb-0.4.9.1/nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
wget https://jpa.kapsi.fi/nanopb/download/nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
tar xvzf nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
mv nanopb-0.4.9.1-linux-x86 nanopb-0.4.9
|
||||
|
||||
@@ -38,7 +33,7 @@ jobs:
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
branch: create-pull-request/update-protobufs-${{ github.ref_name }}
|
||||
branch: create-pull-request/update-protobufs
|
||||
labels: submodules
|
||||
title: Update protobufs and classes
|
||||
commit-message: Update protobufs
|
||||
|
||||
-10
@@ -47,10 +47,6 @@ data/boot/logo.*
|
||||
managed_components/*
|
||||
arduino-lib-builder*
|
||||
dependencies.lock
|
||||
|
||||
# JLink / RTT debug artifacts (nRF SoCs)
|
||||
flash.jlink
|
||||
rtt_*.txt
|
||||
idf_component.yml
|
||||
CMakeLists.txt
|
||||
/sdkconfig.*
|
||||
@@ -60,9 +56,3 @@ CMakeLists.txt
|
||||
.python3
|
||||
.claude/scheduled_tasks.lock
|
||||
userPrefs.jsonc.mcp-session-bak
|
||||
|
||||
# Fake-NodeDB fixture pipeline (bin/regen-fake-nodedbs.sh)
|
||||
# JSONL seeds are committed (test/fixtures/nodedb/seed_v25_*.jsonl);
|
||||
# compiled .proto outputs are ephemeral build artifacts.
|
||||
build/fixtures/
|
||||
bin/_generated/
|
||||
|
||||
+5
-12
@@ -8,15 +8,15 @@ plugins:
|
||||
uri: https://github.com/trunk-io/plugins
|
||||
lint:
|
||||
enabled:
|
||||
- checkov@3.2.529
|
||||
- renovate@43.150.0
|
||||
- checkov@3.2.532
|
||||
- renovate@43.209.1
|
||||
- prettier@3.8.3
|
||||
- trufflehog@3.95.3
|
||||
- trufflehog@3.95.5
|
||||
- yamllint@1.38.0
|
||||
- bandit@1.9.4
|
||||
- trivy@0.70.0
|
||||
- trivy@0.71.0
|
||||
- taplo@0.10.0
|
||||
- ruff@0.15.13
|
||||
- ruff@0.15.15
|
||||
- isort@8.0.1
|
||||
- markdownlint@0.48.0
|
||||
- oxipng@10.1.1
|
||||
@@ -34,13 +34,6 @@ lint:
|
||||
- linters: [ALL]
|
||||
paths:
|
||||
- bin/**
|
||||
# Fake-NodeDB fixture JSONL files contain deterministic synthetic
|
||||
# public_key_hex (64-char hex) values that gitleaks misidentifies as
|
||||
# generic-api-key. These are not secrets — they're test fixtures
|
||||
# produced by bin/gen-fake-nodedb-seed.py with a fixed RNG seed.
|
||||
- linters: [gitleaks]
|
||||
paths:
|
||||
- test/fixtures/nodedb/seed_v25_*.jsonl
|
||||
runtimes:
|
||||
enabled:
|
||||
- python@3.14.4
|
||||
|
||||
Vendored
+1
-2
@@ -10,6 +10,5 @@
|
||||
},
|
||||
"[powershell]": {
|
||||
"editor.defaultFormatter": "ms-vscode.powershell"
|
||||
},
|
||||
"idf.currentSetup": "C:/Espressif/frameworks/esp-idf-v5.3.1/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,18 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don
|
||||
|
||||
## Quick command reference
|
||||
|
||||
| Action | Command |
|
||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `~/.platformio/penv/bin/python -m platformio test -e native > /tmp/test_out.txt 2>&1` then `grep -E 'error:\|PASS\|FAIL\|succeeded\|failed' /tmp/test_out.txt` (redirect first — piping causes line-buffering) |
|
||||
| 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]` |
|
||||
| Action | Command |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `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)
|
||||
|
||||
@@ -64,7 +64,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
|
||||
- **One MCP call per serial port at a time.** The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device.
|
||||
- **`userPrefs.jsonc` is session state during tests.** The `_session_userprefs` fixture snapshots + restores it; never edit it from inside a test.
|
||||
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
|
||||
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI — see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure.
|
||||
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
|
||||
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
|
||||
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||
@@ -108,7 +108,7 @@ Sequence these; don't parallelize on the same port.
|
||||
| `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 (17 suites; `pio test -e native`) |
|
||||
| `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 |
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
curl wget g++ zip git ca-certificates pkg-config \
|
||||
python3-pip python3-grpc-tools \
|
||||
libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
|
||||
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
@@ -53,7 +53,7 @@ ENV TZ=Etc/UTC
|
||||
USER root
|
||||
|
||||
RUN apt-get update && apt-get --no-install-recommends -y install \
|
||||
libc-bin libc6 libgpiod3 libyaml-cpp0.8 libjsoncpp26 libi2c0 libuv1t64 libusb-1.0-0-dev \
|
||||
libc-bin libc6 libgpiod3 libyaml-cpp0.8 libi2c0 libuv1t64 libusb-1.0-0-dev \
|
||||
liborcania2.3 libulfius2.7t64 libssl3t64 \
|
||||
libx11-6 libinput10 libxkbcommon-x11-0 libsdl2-2.0-0 \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ ENV PIP_ROOT_USER_ACTION=ignore
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
g++ git ca-certificates pkg-config \
|
||||
libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
|
||||
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
|
||||
-32
@@ -10,35 +10,3 @@
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We support the private reporting of potential security vulnerabilities. Please go to the Security tab to file a report with a description of the potential vulnerability and reproduction scripts (preferred) or steps, and our developers will review.
|
||||
|
||||
Before filing, please read the Security Model below. Behavior whose only precondition is local API access to a node, or possession of a channel's pre-shared key, is intended by design and is not considered a vulnerability.
|
||||
|
||||
## Security Model
|
||||
|
||||
Meshtastic is an off-grid mesh protocol that runs on constrained microcontrollers within a 256 byte LoRa packet limit. These constraints shape its security design and rule out the heavier schemes used by IP-based protocols. This section summarizes what the firmware protects, the assumptions it rests on, and its known limits. Fuller write-ups are in the documentation:
|
||||
|
||||
- Encryption overview: https://meshtastic.org/docs/overview/encryption/
|
||||
- Technical reference: https://meshtastic.org/docs/development/reference/encryption-technical/
|
||||
- Known limitations and future work: https://meshtastic.org/docs/about/overview/encryption/limitations/
|
||||
|
||||
### Cryptographic mechanisms
|
||||
|
||||
- Channels are encrypted with a pre-shared key (PSK) using AES256-CTR. Channel traffic is encrypted but not authenticated, so anyone holding the PSK can read channel messages and can send messages as any node on that channel.
|
||||
- Direct messages and admin messages use public key cryptography (x25519 key exchange with AES-CCM), providing confidentiality, authentication, and integrity between nodes on 2.5.0 or newer that have exchanged keys.
|
||||
- Admin sessions use short-lived session IDs to limit replay of control messages.
|
||||
|
||||
### Local trust boundary
|
||||
|
||||
A client connected to a node over Bluetooth, USB serial, WiFi, or Ethernet has full local API access. From that connection it can read decrypted traffic, send messages as the node, change configuration (subject to managed mode), and read the node's private key for backup. This is intended behavior. The firmware trusts the local link the same way a phone or laptop trusts a directly attached device, and anything within reach of that connection (a shared LAN, a USB cable to an untrusted host, a paired phone) should be treated as part of the node itself.
|
||||
|
||||
### Node identity (Trust On First Use)
|
||||
|
||||
There is no central authority to sign node keys. The first public key a node hears for a given node number is the one it binds to that node number, a Trust On First Use (TOFU) model that is a hard requirement of a decentralized mesh. Clients and firmware reduce the impact of this by keeping favorited nodes from rolling out of the node database and by flagging public-key changes in the client UI.
|
||||
|
||||
Firmware 2.8.X adds XEdDSA packet signing to further secure node identity claims and the authenticity of subsequent messages. It reuses each node's existing x25519 key pair to produce signatures, so a receiver can verify that a packet came from the holder of the bound key. Once a node has been seen signing, unsigned packets claiming that identity can be rejected.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- No perfect forward secrecy. Traffic captured today can be decrypted later if a key is compromised, for example through a lost node or a mishandled channel key.
|
||||
- Channel messages are not authenticated, as noted above. Although as of 2.8, channel messages will be xedDSA signed as a means of verification that is non-breaking.
|
||||
- Setting WiFi credentials, or performing any other local administration, on an ESP32 over an untrusted network exposes that traffic, including the credentials, to the network. Provision and administer nodes over a trusted channel instead: Bluetooth, USB serial, or remote admin over the mesh. There is no current roadmap item to secure local administration over untrusted WiFi, though it may be addressed in a future release.
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apk --no-cache add \
|
||||
bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \
|
||||
py3-pip py3-grpcio-tools \
|
||||
libgpiod-dev yaml-cpp-dev jsoncpp-dev bluez-dev \
|
||||
libgpiod-dev yaml-cpp-dev bluez-dev \
|
||||
libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \
|
||||
libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
@@ -48,7 +48,7 @@ LABEL org.opencontainers.image.title="Meshtastic" \
|
||||
USER root
|
||||
|
||||
RUN apk --no-cache add \
|
||||
shadow libstdc++ libbsd libgpiod yaml-cpp jsoncpp libusb \
|
||||
shadow libstdc++ libbsd libgpiod yaml-cpp libusb \
|
||||
i2c-tools libuv libx11 libinput libxkbcommon sdl2 \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& mkdir -p /var/lib/meshtasticd \
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Post-process protoc-generated Python files to live under a local namespace.
|
||||
|
||||
Called by bin/regen-py-protos.sh. Walks the generated *_pb2.py files in the
|
||||
target directory and rewrites every `meshtastic` reference (imports, dotted
|
||||
attribute access) to use the new namespace (e.g., `meshtastic_v25`).
|
||||
|
||||
Why: the .proto files declare `package meshtastic;`, so protoc emits
|
||||
`from meshtastic import mesh_pb2 as ...` lines. That would shadow the PyPI
|
||||
`meshtastic` package which other parts of the mcp-server depend on. Renaming
|
||||
to a local namespace keeps both available.
|
||||
|
||||
Usage:
|
||||
_rewrite_proto_namespace.py <generated_dir> <new_namespace>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def rewrite(dir_path: pathlib.Path, new_ns: str) -> int:
|
||||
# Standard protoc import forms:
|
||||
# from meshtastic.X_pb2 import ... (rare, for direct symbol pulls)
|
||||
# from meshtastic import X_pb2 as ... (common, the cross-file ref)
|
||||
# import meshtastic.X_pb2 (also possible)
|
||||
pattern_dotted_from = re.compile(r"^from meshtastic\.", re.MULTILINE)
|
||||
pattern_bare_from = re.compile(r"^from meshtastic import ", re.MULTILINE)
|
||||
pattern_dotted_import = re.compile(r"^import meshtastic\.", re.MULTILINE)
|
||||
|
||||
count = 0
|
||||
for p in dir_path.glob("*.py"):
|
||||
text = p.read_text(encoding="utf-8")
|
||||
new = pattern_dotted_from.sub(f"from {new_ns}.", text)
|
||||
new = pattern_bare_from.sub(f"from {new_ns} import ", new)
|
||||
new = pattern_dotted_import.sub(f"import {new_ns}.", new)
|
||||
# NOTE: we deliberately leave `meshtastic/X.proto` source-filename
|
||||
# references inside descriptor strings alone. The descriptor pool is
|
||||
# keyed by source filename (independent of Python package layout), so
|
||||
# those don't collide with the PyPI package's descriptors.
|
||||
if new != text:
|
||||
p.write_text(new, encoding="utf-8")
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print("usage: _rewrite_proto_namespace.py <generated_dir> <new_namespace>", file=sys.stderr)
|
||||
return 2
|
||||
dir_path = pathlib.Path(argv[0])
|
||||
new_ns = argv[1]
|
||||
if not dir_path.is_dir():
|
||||
print(f"directory not found: {dir_path}", file=sys.stderr)
|
||||
return 2
|
||||
n = rewrite(dir_path, new_ns)
|
||||
print(f"rewrote {n} file(s) in {dir_path} → namespace {new_ns}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
+1
-1
@@ -38,4 +38,4 @@ cp bin/device-install.* $OUTDIR/
|
||||
cp bin/device-update.* $OUTDIR/
|
||||
|
||||
echo "Copying manifest"
|
||||
cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json
|
||||
cp $BUILDDIR/$basename.mt.json $OUTDIR/$basename.mt.json || true
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Collect firmware binary sizes from manifest (.mt.json) files into a single report."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def collect_sizes(manifest_dir):
|
||||
"""Scan manifest_dir for .mt.json files and return {board: size_bytes} dict."""
|
||||
sizes = {}
|
||||
for fname in sorted(os.listdir(manifest_dir)):
|
||||
if not fname.endswith(".mt.json"):
|
||||
continue
|
||||
path = os.path.join(manifest_dir, fname)
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
board = data.get("platformioTarget", fname.replace(".mt.json", ""))
|
||||
# Find the main firmware .bin size (largest .bin, excluding OTA/littlefs/bleota)
|
||||
bin_size = None
|
||||
for entry in data.get("files", []):
|
||||
name = entry.get("name", "")
|
||||
if name.startswith("firmware-") and name.endswith(".bin"):
|
||||
bin_size = entry["bytes"]
|
||||
break
|
||||
# Fallback: any .bin that isn't ota/littlefs/bleota
|
||||
if bin_size is None:
|
||||
for entry in data.get("files", []):
|
||||
name = entry.get("name", "")
|
||||
if name.endswith(".bin") and not any(
|
||||
x in name for x in ["littlefs", "bleota", "ota"]
|
||||
):
|
||||
bin_size = entry["bytes"]
|
||||
break
|
||||
if bin_size is not None:
|
||||
sizes[board] = bin_size
|
||||
return sizes
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <manifest_dir> <output.json>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
manifest_dir = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
sizes = collect_sizes(manifest_dir)
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(sizes, f, indent=2, sort_keys=True)
|
||||
|
||||
print(f"Collected sizes for {len(sizes)} targets -> {output_path}")
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
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
|
||||
|
||||
rfswitch_table:
|
||||
pins: [DIO5, DIO6, DIO7]
|
||||
MODE_STBY: [LOW, LOW, LOW]
|
||||
MODE_RX: [LOW, HIGH, LOW]
|
||||
MODE_TX: [HIGH, HIGH, LOW]
|
||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
||||
MODE_TX_HF: [LOW, LOW, LOW]
|
||||
MODE_GNSS: [LOW, LOW, HIGH]
|
||||
MODE_WIFI: [LOW, LOW, LOW]
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
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
|
||||
|
||||
rfswitch_table:
|
||||
pins:
|
||||
- DIO5
|
||||
- DIO6
|
||||
MODE_STBY:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_RX:
|
||||
- HIGH
|
||||
- LOW
|
||||
MODE_TX:
|
||||
- HIGH
|
||||
- HIGH
|
||||
MODE_TX_HP:
|
||||
- LOW
|
||||
- HIGH
|
||||
MODE_TX_HF:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_GNSS:
|
||||
- LOW
|
||||
- LOW
|
||||
MODE_WIFI:
|
||||
- LOW
|
||||
- LOW
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
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
|
||||
|
||||
rfswitch_table:
|
||||
pins: [DIO5, DIO6, DIO7]
|
||||
MODE_STBY: [LOW, LOW, LOW]
|
||||
MODE_RX: [LOW, LOW, LOW]
|
||||
MODE_TX: [LOW, HIGH, LOW]
|
||||
MODE_TX_HP: [HIGH, LOW, LOW]
|
||||
# MODE_TX_HF: []
|
||||
# MODE_GNSS: []
|
||||
MODE_WIFI: [LOW, LOW, LOW]
|
||||
|
||||
General:
|
||||
MACAddressSource: eth0
|
||||
@@ -1,19 +0,0 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/NebraHat
|
||||
# Use for 1 watt hat
|
||||
Meta:
|
||||
name: NebraHat 1W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Nebra SX1262 Pi Hat - 1W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
# CS: 8 # Newer version of MeshtasticD do not need this? If issues uncomment this line
|
||||
IRQ: 22
|
||||
Busy: 4
|
||||
Reset: 18
|
||||
RXen: 25
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -1,20 +0,0 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/NebraHat
|
||||
# Use for 2 watt hat
|
||||
Meta:
|
||||
name: NebraHat 2W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Nebra SX1262 Pi Hat - 2W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
SX126X_MAX_POWER: 8
|
||||
# CS: 8 # Newer version of MeshtasticD do not need this? If issues uncomment this line
|
||||
IRQ: 22
|
||||
Busy: 4
|
||||
Reset: 18
|
||||
RXen: 25
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -18,4 +18,4 @@ Lora:
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: 8
|
||||
# CS: 8
|
||||
@@ -18,4 +18,4 @@ Lora:
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: 7
|
||||
# CS: 7
|
||||
@@ -1,19 +0,0 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/ZebraHAT
|
||||
# Use for 1 watt hat
|
||||
Meta:
|
||||
name: ZebraHat 1W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Zebra SX1262 Pi Hat - 1W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
SX126X_MAX_POWER: 18
|
||||
CS: 24
|
||||
IRQ: 22
|
||||
Busy: 27
|
||||
Reset: 17
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -1,20 +0,0 @@
|
||||
# https://github.com/wehooper4/Meshtastic-Hardware/tree/main/ZebraHAT
|
||||
# Use for 2 watt hat
|
||||
Meta:
|
||||
name: ZebraHat 2W
|
||||
support: community
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262 # Zebra SX1262 Pi Hat - 2W
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
SX126X_MAX_POWER: 8
|
||||
CS: 24
|
||||
IRQ: 22
|
||||
Busy: 27
|
||||
Reset: 17
|
||||
RXen: 25
|
||||
I2C:
|
||||
I2CDevice: /dev/i2c-1
|
||||
@@ -29,8 +29,6 @@ Lora:
|
||||
- pin: 50 # GPIO1_C2 (physical 16)
|
||||
gpiochip: 1
|
||||
line: 18
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_A7 (SPI1_CSN1, physical 26)
|
||||
# pin: 7
|
||||
|
||||
@@ -29,8 +29,6 @@ Lora:
|
||||
- pin: 50 # GPIO1_C2 (physical 16)
|
||||
gpiochip: 1
|
||||
line: 18
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_A7 (SPI1_CSN1, physical 26)
|
||||
# pin: 7
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Station G3 + BQ35LORA900V1M Primary Slot
|
||||
# Board Doc: https://wiki.bqvoy.com/en/devkits/station-g3
|
||||
Meta:
|
||||
name: B&Q Station G3 + BQ35LORA900V1M Primary Slot
|
||||
support: official
|
||||
compatible:
|
||||
- luckfox-lyra-zero-w # Armbian
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: # GPIO0_A5 (physical 15)
|
||||
pin: 5
|
||||
gpiochip: 0
|
||||
line: 5
|
||||
Reset: # GPIO1_D1 (physical 36)
|
||||
pin: 57
|
||||
gpiochip: 1
|
||||
line: 25
|
||||
Busy: # GPIO0_B4 (physical 18)
|
||||
pin: 12
|
||||
gpiochip: 0
|
||||
line: 12
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
# CS: # GPIO0_B2 (physical 24)
|
||||
# pin: 10
|
||||
# gpiochip: 0
|
||||
# line: 10
|
||||
@@ -29,8 +29,6 @@ Lora:
|
||||
- pin: 13 # GPIO0_B5
|
||||
gpiochip: 0
|
||||
line: 13
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B1
|
||||
# pin: 9
|
||||
|
||||
@@ -29,8 +29,6 @@ Lora:
|
||||
- pin: 13 # GPIO0_B5
|
||||
gpiochip: 0
|
||||
line: 13
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B1
|
||||
# pin: 9
|
||||
|
||||
@@ -31,8 +31,6 @@ Lora:
|
||||
- pin: 103 # GPIO3_A7 (physical 16)
|
||||
gpiochip: 3
|
||||
line: 7
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B7 (SPI0_CSN1, physical 26)
|
||||
# pin: 15
|
||||
|
||||
@@ -31,8 +31,6 @@ Lora:
|
||||
- pin: 103 # GPIO3_A7 (physical 16)
|
||||
gpiochip: 3
|
||||
line: 7
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: # GPIO0_B7 (SPI0_CSN1, physical 26)
|
||||
# pin: 15
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Station G3 + BQ35LORA900V1M Primary Slot
|
||||
# Board Doc: https://wiki.bqvoy.com/en/devkits/station-g3
|
||||
Meta:
|
||||
name: B&Q Station G3 + BQ35LORA900V1M Primary Slot
|
||||
support: official
|
||||
compatible:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
Module: sx1262
|
||||
IRQ: 22
|
||||
Reset: 16
|
||||
Busy: 24
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.0
|
||||
#CS: 8
|
||||
@@ -1,5 +1,3 @@
|
||||
# This config works with all revisions of the Meshtoad USB radio.
|
||||
|
||||
Meta:
|
||||
name: meshtoad-e22
|
||||
support: official
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Meshtastic Ethernet OTA Upload Tool
|
||||
|
||||
Uploads firmware to RP2350-based Meshtastic devices via Ethernet (W5500).
|
||||
Compresses firmware with GZIP and sends it over TCP using the MOTA protocol.
|
||||
Authenticates using SHA256 challenge-response with a pre-shared key (PSK).
|
||||
|
||||
Usage:
|
||||
python bin/eth-ota-upload.py --host 192.168.1.100 firmware.bin
|
||||
python bin/eth-ota-upload.py --host 192.168.1.100 --psk mySecretKey firmware.bin
|
||||
python bin/eth-ota-upload.py --host 192.168.1.100 --psk-hex 6d65736874... firmware.bin
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Default PSK matching the firmware default: "meshtastic_ota_default_psk_v1!!!"
|
||||
DEFAULT_PSK = b"meshtastic_ota_default_psk_v1!!!"
|
||||
|
||||
|
||||
def crc32(data: bytes) -> int:
|
||||
"""Compute CRC32 matching ErriezCRC32 (standard CRC32 with final XOR)."""
|
||||
import binascii
|
||||
|
||||
return binascii.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def load_firmware(path: str) -> bytes:
|
||||
"""Load firmware file, compressing with GZIP if not already compressed."""
|
||||
# Reject UF2 files — OTA requires raw .bin firmware
|
||||
if path.lower().endswith(".uf2"):
|
||||
bin_path = path.rsplit(".", 1)[0] + ".bin"
|
||||
print(f"ERROR: UF2 files cannot be used for OTA updates.")
|
||||
print(f" The Updater/picoOTA expects raw .bin firmware.")
|
||||
print(f" Try: {bin_path}")
|
||||
sys.exit(1)
|
||||
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
# Check if already GZIP compressed (magic bytes 1f 8b)
|
||||
if data[:2] == b"\x1f\x8b":
|
||||
print(f"Firmware already GZIP compressed: {len(data):,} bytes")
|
||||
return data
|
||||
|
||||
print(f"Firmware raw size: {len(data):,} bytes")
|
||||
compressed = gzip.compress(data, compresslevel=9)
|
||||
ratio = len(compressed) / len(data) * 100
|
||||
print(f"GZIP compressed: {len(compressed):,} bytes ({ratio:.1f}%)")
|
||||
return compressed
|
||||
|
||||
|
||||
def authenticate(sock: socket.socket, psk: bytes) -> bool:
|
||||
"""Perform SHA256 challenge-response authentication with the device."""
|
||||
# Receive 32-byte nonce from server
|
||||
nonce = b""
|
||||
while len(nonce) < 32:
|
||||
chunk = sock.recv(32 - len(nonce))
|
||||
if not chunk:
|
||||
print("ERROR: Connection closed during authentication")
|
||||
return False
|
||||
nonce += chunk
|
||||
|
||||
# Compute SHA256(nonce || PSK)
|
||||
h = hashlib.sha256()
|
||||
h.update(nonce)
|
||||
h.update(psk)
|
||||
response = h.digest()
|
||||
|
||||
# Send 32-byte response
|
||||
sock.sendall(response)
|
||||
|
||||
# Wait for auth result (1 byte)
|
||||
result = sock.recv(1)
|
||||
if not result:
|
||||
print("ERROR: No authentication response")
|
||||
return False
|
||||
|
||||
if result[0] == 0x06: # ACK
|
||||
print("Authentication successful.")
|
||||
return True
|
||||
elif result[0] == 0x07: # OTA_ERR_AUTH
|
||||
print("ERROR: Authentication failed — wrong PSK")
|
||||
return False
|
||||
else:
|
||||
print(f"ERROR: Unexpected auth response 0x{result[0]:02X}")
|
||||
return False
|
||||
|
||||
|
||||
def upload_firmware(host: str, port: int, firmware: bytes, psk: bytes, timeout: float) -> bool:
|
||||
"""Upload firmware over TCP using the MOTA protocol with PSK authentication."""
|
||||
fw_crc = crc32(firmware)
|
||||
fw_size = len(firmware)
|
||||
|
||||
print(f"Connecting to {host}:{port}...")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
print("Connected.")
|
||||
|
||||
# Step 1: Authenticate
|
||||
print("Authenticating...")
|
||||
if not authenticate(sock, psk):
|
||||
return False
|
||||
|
||||
# Step 2: Send 12-byte MOTA header: magic(4) + size(4) + crc32(4)
|
||||
header = struct.pack("<4sII", b"MOTA", fw_size, fw_crc)
|
||||
sock.sendall(header)
|
||||
print(f"Header sent: size={fw_size:,}, CRC32=0x{fw_crc:08X}")
|
||||
|
||||
# Wait for ACK (1 byte)
|
||||
ack = sock.recv(1)
|
||||
if not ack or ack[0] != 0x06:
|
||||
error_codes = {
|
||||
0x02: "Size error",
|
||||
0x04: "Invalid magic",
|
||||
0x05: "Update.begin() failed",
|
||||
}
|
||||
code = ack[0] if ack else 0xFF
|
||||
msg = error_codes.get(code, f"Unknown error 0x{code:02X}")
|
||||
print(f"ERROR: Server rejected header: {msg}")
|
||||
return False
|
||||
|
||||
print("Header accepted. Uploading firmware...")
|
||||
|
||||
# Send firmware in 1KB chunks
|
||||
chunk_size = 1024
|
||||
sent = 0
|
||||
start_time = time.time()
|
||||
|
||||
while sent < fw_size:
|
||||
end = min(sent + chunk_size, fw_size)
|
||||
chunk = firmware[sent:end]
|
||||
sock.sendall(chunk)
|
||||
sent = end
|
||||
|
||||
# Progress bar
|
||||
pct = sent * 100 // fw_size
|
||||
bar_len = 40
|
||||
filled = bar_len * sent // fw_size
|
||||
bar = "█" * filled + "░" * (bar_len - filled)
|
||||
elapsed = time.time() - start_time
|
||||
speed = sent / elapsed if elapsed > 0 else 0
|
||||
sys.stdout.write(f"\r [{bar}] {pct:3d}% {sent:,}/{fw_size:,} ({speed/1024:.1f} KB/s)")
|
||||
sys.stdout.flush()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"\n Transfer complete in {elapsed:.1f}s")
|
||||
|
||||
# Wait for final result (1 byte)
|
||||
print("Waiting for verification...")
|
||||
result = sock.recv(1)
|
||||
if not result:
|
||||
print("ERROR: No response from device")
|
||||
return False
|
||||
|
||||
result_codes = {
|
||||
0x00: "OK — Update staged, device rebooting",
|
||||
0x01: "CRC mismatch",
|
||||
0x02: "Size error",
|
||||
0x03: "Write error",
|
||||
0x04: "Magic mismatch",
|
||||
0x05: "Updater.begin() failed",
|
||||
0x07: "Auth failed",
|
||||
0x08: "Timeout",
|
||||
}
|
||||
code = result[0]
|
||||
msg = result_codes.get(code, f"Unknown result 0x{code:02X}")
|
||||
|
||||
if code == 0x00:
|
||||
print(f"SUCCESS: {msg}")
|
||||
return True
|
||||
else:
|
||||
print(f"ERROR: {msg}")
|
||||
return False
|
||||
|
||||
except socket.timeout:
|
||||
print("ERROR: Connection timed out")
|
||||
return False
|
||||
except ConnectionRefusedError:
|
||||
print(f"ERROR: Connection refused by {host}:{port}")
|
||||
return False
|
||||
except OSError as e:
|
||||
print(f"ERROR: {e}")
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Upload firmware to Meshtastic RP2350 devices via Ethernet OTA"
|
||||
)
|
||||
parser.add_argument("firmware", help="Path to firmware .bin or .bin.gz file")
|
||||
parser.add_argument("--host", required=True, help="Device IP address")
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=4243, help="OTA port (default: 4243)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=60.0,
|
||||
help="Socket timeout in seconds (default: 60)",
|
||||
)
|
||||
psk_group = parser.add_mutually_exclusive_group()
|
||||
psk_group.add_argument(
|
||||
"--psk",
|
||||
type=str,
|
||||
help="Pre-shared key as UTF-8 string (default: meshtastic_ota_default_psk_v1!!!)",
|
||||
)
|
||||
psk_group.add_argument(
|
||||
"--psk-hex",
|
||||
type=str,
|
||||
help="Pre-shared key as hex string (e.g., 6d65736874...)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve PSK
|
||||
if args.psk:
|
||||
psk = args.psk.encode("utf-8")
|
||||
elif args.psk_hex:
|
||||
try:
|
||||
psk = bytes.fromhex(args.psk_hex)
|
||||
except ValueError:
|
||||
print("ERROR: Invalid hex string for --psk-hex")
|
||||
sys.exit(1)
|
||||
else:
|
||||
psk = DEFAULT_PSK
|
||||
|
||||
print("Meshtastic Ethernet OTA Upload")
|
||||
print("=" * 40)
|
||||
|
||||
firmware = load_firmware(args.firmware)
|
||||
|
||||
if upload_firmware(args.host, args.port, firmware, psk, args.timeout):
|
||||
print("\nDevice is rebooting with new firmware.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\nUpload failed.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,439 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic seed-data generator for the fake NodeDB fixture pipeline.
|
||||
|
||||
Writes a JSONL file describing N fake-but-realistic Meshtastic peers.
|
||||
The output is hand-editable and committed; a sibling compile step
|
||||
(bin/seed-json-to-proto.py) turns it into a binary `meshtastic_NodeDatabase`
|
||||
v25 protobuf with fresh "now-relative" timestamps.
|
||||
|
||||
Determinism contract:
|
||||
Same --seed -> byte-identical JSONL output, regardless of wall clock.
|
||||
All timestamps are stored as `*_offset_sec` (seconds before "now"); the
|
||||
compile step resolves them to absolute epochs at compile time.
|
||||
|
||||
Structural fields covered:
|
||||
* NodeInfoLite header: num, long_name, short_name, hw_model, role,
|
||||
public_key, snr, channel, hops_away, next_hop, bitfield flags
|
||||
* PositionLite: lat/long Gaussian around --centroid, altitude, source
|
||||
* DeviceMetrics: battery/voltage/util/uptime
|
||||
* EnvironmentMetrics: temp/humidity/pressure/iaq
|
||||
* StatusMessage: error_code (usually zero)
|
||||
|
||||
Active-board allow-list:
|
||||
hw_model values are restricted to the intersection of
|
||||
(a) variants with `custom_meshtastic_support_level = 1` in
|
||||
variants/*/*/platformio.ini, AND
|
||||
(b) values present in the `HardwareModel` enum in mesh.proto.
|
||||
See HW_MODEL_WEIGHTS below. Deprecated boards (legacy TLORA / Heltec V1-2 /
|
||||
classic TBEAM / TBEAM_V0P7 / Nano G1 / etc.) and fuzzer-only sentinels
|
||||
(PORTDUINO, ANDROID_SIM, DIY_V1, ...) are excluded.
|
||||
|
||||
Active-role allow-list:
|
||||
Excludes ROUTER_CLIENT (deprecated v2.3.15) and REPEATER (deprecated v2.7.11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as _dt
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import random
|
||||
import sys
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Active-board allow-list (intersection of tier-1 variants + HardwareModel enum).
|
||||
# Refresh by running:
|
||||
# for f in $(find variants -name 'platformio.ini' | xargs grep -lE 'custom_meshtastic_support_level = 1'); do
|
||||
# grep custom_meshtastic_hw_model_slug $f | awk -F= '{print $2}' | tr -d ' ';
|
||||
# done | sort -u | comm -12 - <(python3 -c "from meshtastic.protobuf.mesh_pb2 import HardwareModel; print('\\n'.join(HardwareModel.keys()))" | sort)
|
||||
# --------------------------------------------------------------------------
|
||||
HW_MODEL_WEIGHTS: dict[str, float] = {
|
||||
"HELTEC_V3": 14.0,
|
||||
"T_DECK": 9.0,
|
||||
"HELTEC_V4": 8.0,
|
||||
"RAK4631": 8.0,
|
||||
"HELTEC_MESH_POCKET": 6.0,
|
||||
"TRACKER_T1000_E": 5.0,
|
||||
"HELTEC_MESH_NODE_T114": 5.0,
|
||||
"T_DECK_PRO": 5.0,
|
||||
"LILYGO_TBEAM_S3_CORE": 4.0,
|
||||
"HELTEC_WIRELESS_PAPER": 4.0,
|
||||
"HELTEC_WSL_V3": 3.0,
|
||||
"T_ECHO": 3.0,
|
||||
"HELTEC_WIRELESS_TRACKER": 3.0,
|
||||
"HELTEC_WIRELESS_TRACKER_V2": 2.0,
|
||||
"HELTEC_VISION_MASTER_E290": 2.0,
|
||||
"HELTEC_MESH_SOLAR": 2.0,
|
||||
"SEEED_WIO_TRACKER_L1": 2.0,
|
||||
"T_LORA_PAGER": 1.5,
|
||||
"HELTEC_VISION_MASTER_E213": 1.5,
|
||||
"T_ECHO_PLUS": 1.0,
|
||||
"MUZI_BASE": 1.0,
|
||||
"WISMESH_TAP_V2": 1.0,
|
||||
"THINKNODE_M2": 1.0,
|
||||
"THINKNODE_M5": 1.0,
|
||||
"TLORA_T3_S3": 1.0,
|
||||
# Long tail (uniform low weight across remaining tier-1 boards):
|
||||
"HELTEC_V4_R8": 0.3,
|
||||
"HELTEC_VISION_MASTER_T190": 0.3,
|
||||
"HELTEC_HT62": 0.3,
|
||||
"HELTEC_MESH_NODE_T096": 0.3,
|
||||
"M5STACK_C6L": 0.3,
|
||||
"MINI_EPAPER_S3": 0.3,
|
||||
"MUZI_R1_NEO": 0.3,
|
||||
"NOMADSTAR_METEOR_PRO": 0.3,
|
||||
"RAK3312": 0.3,
|
||||
"RAK3401": 0.3,
|
||||
"SEEED_SOLAR_NODE": 0.3,
|
||||
"SEEED_WIO_TRACKER_L1_EINK": 0.3,
|
||||
"SENSECAP_INDICATOR": 0.3,
|
||||
"TBEAM_1_WATT": 0.3,
|
||||
"THINKNODE_M1": 0.3,
|
||||
"THINKNODE_M3": 0.3,
|
||||
"THINKNODE_M6": 0.3,
|
||||
"T_ECHO_LITE": 0.3,
|
||||
"WISMESH_TAG": 0.3,
|
||||
"WISMESH_TAP": 0.3,
|
||||
"XIAO_NRF52_KIT": 0.3,
|
||||
"CROWPANEL": 0.3,
|
||||
}
|
||||
|
||||
# Non-deprecated roles only.
|
||||
ROLE_WEIGHTS: dict[str, float] = {
|
||||
"CLIENT": 75.0,
|
||||
"CLIENT_MUTE": 5.0,
|
||||
"ROUTER": 7.0,
|
||||
"TRACKER": 3.0,
|
||||
"SENSOR": 2.0,
|
||||
"CLIENT_HIDDEN": 2.0,
|
||||
"ROUTER_LATE": 2.0,
|
||||
"CLIENT_BASE": 2.0,
|
||||
"TAK": 1.0,
|
||||
"TAK_TRACKER": 0.5,
|
||||
"LOST_AND_FOUND": 0.5,
|
||||
}
|
||||
|
||||
# Name pools — 60 firsts × 60 lasts = 3600 combinations.
|
||||
FIRSTS = [
|
||||
"Quick", "Brave", "Silent", "Wild", "Lone", "Bright", "Red", "Blue",
|
||||
"Green", "Black", "White", "Iron", "Steel", "Copper", "Silver", "Gold",
|
||||
"Stone", "River", "Forest", "Mountain", "Canyon", "Desert", "Storm", "Sky",
|
||||
"Solar", "Lunar", "Dawn", "Dusk", "Misty", "Frosty", "Sunny", "Shady",
|
||||
"Happy", "Sleepy", "Drowsy", "Sneaky", "Sharp", "Smooth", "Rough", "Loud",
|
||||
"Soft", "Slow", "Fast", "Tall", "Short", "Old", "New", "Tiny",
|
||||
"Giant", "Hidden", "Lost", "Found", "Wandering", "Roving", "Drifting", "Floating",
|
||||
"Burning", "Frozen", "Whispering", "Howling",
|
||||
]
|
||||
LASTS = [
|
||||
"Phoenix", "Lion", "Bear", "Wolf", "Hawk", "Eagle", "Fox", "Lynx",
|
||||
"Cougar", "Coyote", "Raven", "Owl", "Crow", "Falcon", "Heron", "Crane",
|
||||
"Otter", "Badger", "Bison", "Elk", "Moose", "Stag", "Doe", "Hare",
|
||||
"Marmot", "Mole", "Beaver", "Squirrel", "Mustang", "Bronco", "Pony", "Colt",
|
||||
"Cobra", "Viper", "Mamba", "Adder", "Gecko", "Iguana", "Tortoise", "Turtle",
|
||||
"Salmon", "Trout", "Bass", "Pike", "Shark", "Whale", "Dolphin", "Seal",
|
||||
"Cactus", "Yucca", "Sage", "Juniper", "Pine", "Cedar", "Aspen", "Oak",
|
||||
"Bluff", "Mesa", "Arroyo", "Ridge",
|
||||
]
|
||||
|
||||
# Brief callsign pool for licensed-looking suffixes.
|
||||
CALLSIGN_PREFIXES = ["KX", "WD", "N5", "KE", "AB", "W5", "K1", "KQ", "AE", "NM"]
|
||||
|
||||
# Only emojis that fit in 4 UTF-8 bytes (no variation selectors). short_name's
|
||||
# nanopb max_size:5 (incl. NUL) limits content to 4 bytes. ❄️ / ☀️ would be
|
||||
# 6 bytes due to U+FE0F variation selector — explicitly excluded.
|
||||
EMOJI_SHORTNAMES = ["🦊", "🐺", "🦅", "🐢", "🌵", "🔥", "🌙",
|
||||
"🌊", "🗻", "🌲", "🦌", "🐝", "🦂", "🦉",
|
||||
"🦇", "🦋"]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
NUM_RESERVED = 4 # firmware reserves 0..3 (per NodeDB constants)
|
||||
NUM_MAX_EXCLUSIVE = 0x80000000 # restrict to positive int32 range for readability
|
||||
|
||||
|
||||
def _weighted_choice(rng: random.Random, weights: dict[str, float]) -> str:
|
||||
"""Deterministic weighted pick. Uses sorted keys so dict order is fixed."""
|
||||
keys = sorted(weights.keys())
|
||||
totals = [weights[k] for k in keys]
|
||||
return rng.choices(keys, weights=totals, k=1)[0]
|
||||
|
||||
|
||||
def _gen_long_name(rng: random.Random, is_licensed: bool) -> str:
|
||||
base = f"{rng.choice(FIRSTS)} {rng.choice(LASTS)}"
|
||||
if is_licensed:
|
||||
prefix = rng.choice(CALLSIGN_PREFIXES)
|
||||
# Two trailing alpha chars after the digit; keep within 25 - len(base) - 1
|
||||
suffix = f" {prefix}{rng.randint(0,9)}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}{rng.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ')}"
|
||||
# nanopb max_size:25 means C string fits 24 bytes + NUL.
|
||||
if len(base) + len(suffix) <= 24:
|
||||
base = base + suffix
|
||||
# Hard cap to 24 chars (nanopb max_size:25 minus NUL).
|
||||
return base[:24]
|
||||
|
||||
|
||||
def _gen_short_name(rng: random.Random, long_name: str) -> str:
|
||||
# 10% emoji-only short_name
|
||||
if rng.random() < 0.10:
|
||||
return rng.choice(EMOJI_SHORTNAMES)
|
||||
first_char = long_name[0].upper() if long_name else "X"
|
||||
alphanums = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
return first_char + "".join(rng.choices(alphanums, k=3))
|
||||
|
||||
|
||||
def _gen_hops_away(rng: random.Random) -> int:
|
||||
# Geometric-ish: 0→55%, 1→25%, 2→12%, 3→5%, 4→2%, 5+→1%
|
||||
r = rng.random()
|
||||
if r < 0.55:
|
||||
return 0
|
||||
if r < 0.80:
|
||||
return 1
|
||||
if r < 0.92:
|
||||
return 2
|
||||
if r < 0.97:
|
||||
return 3
|
||||
if r < 0.99:
|
||||
return 4
|
||||
return rng.randint(5, 7)
|
||||
|
||||
|
||||
def _gen_position(
|
||||
rng: random.Random,
|
||||
centroid_lat: float,
|
||||
centroid_lon: float,
|
||||
spread_km: float,
|
||||
last_heard_offset_sec: int,
|
||||
) -> dict:
|
||||
# 1 deg ≈ 111 km at the equator; we use this as a flat approximation.
|
||||
lat = centroid_lat + rng.gauss(0.0, spread_km / 111.0)
|
||||
lon = centroid_lon + rng.gauss(0.0, spread_km / 111.0)
|
||||
altitude = max(0, round(rng.gauss(1376.0, 250.0))) # T or C valley floor + relief
|
||||
# Position was reported up to 300s before last_heard.
|
||||
time_offset_sec = last_heard_offset_sec + rng.randint(0, 300)
|
||||
return {
|
||||
"latitude": round(lat, 6),
|
||||
"longitude": round(lon, 6),
|
||||
"altitude": altitude,
|
||||
"time_offset_sec": time_offset_sec,
|
||||
"location_source": "LOC_INTERNAL",
|
||||
}
|
||||
|
||||
|
||||
def _gen_telemetry(rng: random.Random) -> dict:
|
||||
# 5% plugged-in (battery_level == 101); rest uniform [10..100].
|
||||
if rng.random() < 0.05:
|
||||
battery_level = 101
|
||||
voltage = 4.20
|
||||
else:
|
||||
battery_level = rng.randint(10, 100)
|
||||
voltage = round(3.3 + (battery_level / 100.0) * 0.9, 3)
|
||||
# Beta distributions for low/right-skewed metrics; randomly draw via gammavariate.
|
||||
def _beta(a: float, b: float) -> float:
|
||||
x = rng.gammavariate(a, 1.0)
|
||||
y = rng.gammavariate(b, 1.0)
|
||||
return x / (x + y)
|
||||
channel_utilization = round(_beta(2.0, 15.0) * 100.0, 2)
|
||||
air_util_tx = round(_beta(1.5, 20.0) * 10.0, 3)
|
||||
uptime_seconds = int(rng.expovariate(1.0 / 86400.0))
|
||||
return {
|
||||
"battery_level": battery_level,
|
||||
"voltage": voltage,
|
||||
"channel_utilization": channel_utilization,
|
||||
"air_util_tx": air_util_tx,
|
||||
"uptime_seconds": uptime_seconds,
|
||||
}
|
||||
|
||||
|
||||
def _gen_environment(rng: random.Random) -> dict:
|
||||
return {
|
||||
"temperature": round(rng.gauss(22.0, 8.0), 2),
|
||||
"relative_humidity": round(min(100.0, max(0.0, rng.gauss(55.0, 20.0))), 2),
|
||||
"barometric_pressure": round(rng.gauss(1013.0, 8.0), 2),
|
||||
"iaq": int(min(500, max(0, round(rng.gauss(50.0, 30.0))))),
|
||||
}
|
||||
|
||||
|
||||
def _gen_status(rng: random.Random) -> dict:
|
||||
# `StatusMessage` (mesh.proto:1445) has a single free-form `string status`.
|
||||
# Most peers report a healthy short status; occasional alert string.
|
||||
healthy = ["OK", "online", "active", "running", "ready", "nominal"]
|
||||
alert = ["low-batt", "no-gps", "weak-signal", "rebooted", "offline-soon"]
|
||||
if rng.random() < 0.92:
|
||||
return {"status": rng.choice(healthy)}
|
||||
return {"status": rng.choice(alert)}
|
||||
|
||||
|
||||
def _gen_node(
|
||||
rng: random.Random,
|
||||
num: int,
|
||||
centroid_lat: float,
|
||||
centroid_lon: float,
|
||||
spread_km: float,
|
||||
coverage: dict[str, float],
|
||||
last_heard_mean_sec: int,
|
||||
last_heard_max_sec: int,
|
||||
) -> dict:
|
||||
is_licensed = rng.random() < 0.05
|
||||
long_name = _gen_long_name(rng, is_licensed)
|
||||
short_name = _gen_short_name(rng, long_name)
|
||||
hw_model = _weighted_choice(rng, HW_MODEL_WEIGHTS)
|
||||
role = _weighted_choice(rng, ROLE_WEIGHTS)
|
||||
has_public_key = rng.random() < 0.92
|
||||
public_key_hex = (
|
||||
"".join(f"{rng.randint(0,255):02x}" for _ in range(32)) if has_public_key else ""
|
||||
)
|
||||
snr = round(max(-20.0, min(12.0, rng.gauss(6.0, 4.0))), 2)
|
||||
channel = 0 if rng.random() < 0.90 else rng.randint(1, 7)
|
||||
hops_away = _gen_hops_away(rng)
|
||||
next_hop = rng.randint(0, 255) if hops_away > 0 else 0
|
||||
last_heard_offset_sec = int(min(rng.expovariate(1.0 / last_heard_mean_sec), last_heard_max_sec))
|
||||
|
||||
bitfield = {
|
||||
"has_user": True,
|
||||
"is_favorite": rng.random() < 0.08,
|
||||
"is_muted": rng.random() < 0.03,
|
||||
"via_mqtt": rng.random() < 0.12,
|
||||
"is_ignored": rng.random() < 0.01,
|
||||
"is_licensed": is_licensed,
|
||||
"has_is_unmessagable": True,
|
||||
"is_unmessagable": rng.random() < 0.02,
|
||||
"is_key_manually_verified": rng.random() < 0.04,
|
||||
}
|
||||
|
||||
node: dict = {
|
||||
"num": f"0x{num:08x}",
|
||||
"long_name": long_name,
|
||||
"short_name": short_name,
|
||||
"hw_model": hw_model,
|
||||
"role": role,
|
||||
"public_key_hex": public_key_hex,
|
||||
"snr": snr,
|
||||
"channel": channel,
|
||||
"hops_away": hops_away,
|
||||
"next_hop": next_hop,
|
||||
"last_heard_offset_sec": last_heard_offset_sec,
|
||||
"bitfield": bitfield,
|
||||
"position": (
|
||||
_gen_position(rng, centroid_lat, centroid_lon, spread_km, last_heard_offset_sec)
|
||||
if rng.random() < coverage["position"]
|
||||
else None
|
||||
),
|
||||
"telemetry": _gen_telemetry(rng) if rng.random() < coverage["telemetry"] else None,
|
||||
"environment": _gen_environment(rng) if rng.random() < coverage["environment"] else None,
|
||||
"status": _gen_status(rng) if rng.random() < coverage["status"] else None,
|
||||
}
|
||||
return node
|
||||
|
||||
|
||||
def _parse_my_node_num(s: str | None) -> int | None:
|
||||
if s is None:
|
||||
return None
|
||||
s = s.strip()
|
||||
if s.startswith("0x") or s.startswith("0X"):
|
||||
return int(s, 16)
|
||||
return int(s)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Deterministic JSONL seed for the fake NodeDB fixture.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
p.add_argument("--count", type=int, required=True, help="Number of fake nodes to emit.")
|
||||
p.add_argument("--seed", type=int, required=True, help="Deterministic seed.")
|
||||
p.add_argument("--out", required=True, help="Output JSONL path.")
|
||||
p.add_argument(
|
||||
"--centroid",
|
||||
default="33.1284,-107.2528",
|
||||
help="LAT,LON centroid (default: Truth or Consequences, NM).",
|
||||
)
|
||||
p.add_argument("--spread-km", type=float, default=60.0, help="Gaussian std-dev in km.")
|
||||
p.add_argument("--position-coverage", type=float, default=0.85)
|
||||
p.add_argument("--telemetry-coverage", type=float, default=0.70)
|
||||
p.add_argument("--environment-coverage", type=float, default=0.25)
|
||||
p.add_argument("--status-coverage", type=float, default=0.40)
|
||||
p.add_argument("--my-node-num", default=None, help="Exclude this NodeNum from generated set (hex or dec).")
|
||||
p.add_argument("--last-heard-mean-sec", type=int, default=3600)
|
||||
p.add_argument("--last-heard-max-sec", type=int, default=7 * 86400)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if args.count <= 0:
|
||||
print("--count must be positive", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
centroid_lat, centroid_lon = (float(s) for s in args.centroid.split(","))
|
||||
except ValueError:
|
||||
print(f"--centroid must be LAT,LON; got {args.centroid!r}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
my_node_num = _parse_my_node_num(args.my_node_num)
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
|
||||
# 1) Generate a unique deterministic set of NodeNums.
|
||||
nums: set[int] = set()
|
||||
while len(nums) < args.count:
|
||||
n = rng.randrange(NUM_RESERVED, NUM_MAX_EXCLUSIVE)
|
||||
if my_node_num is not None and n == my_node_num:
|
||||
continue
|
||||
nums.add(n)
|
||||
ordered_nums = sorted(nums) # sort to fix output order independent of set hash
|
||||
|
||||
# 2) Per-node generation (in num order, single RNG continues).
|
||||
coverage = {
|
||||
"position": args.position_coverage,
|
||||
"telemetry": args.telemetry_coverage,
|
||||
"environment": args.environment_coverage,
|
||||
"status": args.status_coverage,
|
||||
}
|
||||
nodes = [
|
||||
_gen_node(
|
||||
rng,
|
||||
n,
|
||||
centroid_lat,
|
||||
centroid_lon,
|
||||
args.spread_km,
|
||||
coverage,
|
||||
args.last_heard_mean_sec,
|
||||
args.last_heard_max_sec,
|
||||
)
|
||||
for n in ordered_nums
|
||||
]
|
||||
|
||||
# 3) Write JSONL.
|
||||
out_path = pathlib.Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# `generated_at_iso` is informational; it does NOT affect determinism because
|
||||
# we derive it from the seed, not from wall clock. (Same seed -> same string.)
|
||||
generated_at = _dt.datetime.fromtimestamp(args.seed, tz=_dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
meta = {
|
||||
"_meta": {
|
||||
"version": 25,
|
||||
"seed": args.seed,
|
||||
"count": args.count,
|
||||
"centroid": [centroid_lat, centroid_lon],
|
||||
"spread_km": args.spread_km,
|
||||
"generated_at_iso": generated_at,
|
||||
"my_node_num_excluded": (None if my_node_num is None else f"0x{my_node_num:08x}"),
|
||||
"coverage": coverage,
|
||||
"last_heard_mean_sec": args.last_heard_mean_sec,
|
||||
"last_heard_max_sec": args.last_heard_max_sec,
|
||||
}
|
||||
}
|
||||
with out_path.open("w", encoding="utf-8") as f:
|
||||
# `ensure_ascii=False` so emoji short_names survive. `sort_keys=True` for
|
||||
# determinism (insertion order varies by Python version otherwise).
|
||||
f.write(json.dumps(meta, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
for node in nodes:
|
||||
f.write(json.dumps(node, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
|
||||
print(f"wrote {args.count} nodes to {out_path} ({out_path.stat().st_size} bytes)", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -87,6 +87,9 @@
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="2.7.25" date="2026-05-23">
|
||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.25</url>
|
||||
</release>
|
||||
<release version="2.7.24" date="2026-05-08">
|
||||
<url type="details">https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.24</url>
|
||||
</release>
|
||||
|
||||
+33
-33
@@ -219,37 +219,41 @@ with open(jsonLoc) as f:
|
||||
jsonStr = re.sub("//.*","", f.read(), flags=re.MULTILINE)
|
||||
userPrefs = json.loads(jsonStr)
|
||||
|
||||
pref_flags = []
|
||||
# Pre-process the userPrefs
|
||||
for pref in userPrefs:
|
||||
if userPrefs[pref].startswith("{"):
|
||||
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
|
||||
elif userPrefs[pref].lstrip("-").replace(".", "").isdigit():
|
||||
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
|
||||
elif userPrefs[pref] == "true" or userPrefs[pref] == "false":
|
||||
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
|
||||
elif userPrefs[pref].startswith("meshtastic_"):
|
||||
pref_flags.append("-D" + pref + "=" + userPrefs[pref])
|
||||
# If the value is a string, we need to wrap it in quotes
|
||||
else:
|
||||
pref_flags.append("-D" + pref + "=" + env.StringifyMacro(userPrefs[pref]) + "")
|
||||
|
||||
# General options that are passed to the C and C++ compilers
|
||||
# Calculate unix epoch for current day (midnight)
|
||||
current_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
build_epoch = int(current_date.timestamp())
|
||||
|
||||
cpp_defines = [
|
||||
("APP_VERSION", verObj["long"]),
|
||||
("APP_VERSION_SHORT", verObj["short"]),
|
||||
("APP_ENV", env.get("PIOENV")),
|
||||
("APP_REPO", repo_owner),
|
||||
("BUILD_EPOCH", str(build_epoch)),
|
||||
]
|
||||
flags = [
|
||||
"-DAPP_VERSION=" + verObj["long"],
|
||||
"-DAPP_VERSION_SHORT=" + verObj["short"],
|
||||
"-DAPP_ENV=" + env.get("PIOENV"),
|
||||
"-DAPP_REPO=" + repo_owner,
|
||||
"-DBUILD_EPOCH=" + str(build_epoch),
|
||||
] + pref_flags
|
||||
|
||||
# Process userPrefs into CPPDEFINES
|
||||
for pref in userPrefs:
|
||||
val = userPrefs[pref]
|
||||
if val.startswith("{"):
|
||||
cpp_defines.append((pref, val))
|
||||
elif val.lstrip("-").replace(".", "").isdigit():
|
||||
cpp_defines.append((pref, val))
|
||||
elif val == "true" or val == "false":
|
||||
cpp_defines.append((pref, val))
|
||||
elif val.startswith("meshtastic_"):
|
||||
cpp_defines.append((pref, val))
|
||||
else:
|
||||
cpp_defines.append((pref, env.StringifyMacro(val)))
|
||||
print("Using flags:")
|
||||
for flag in flags:
|
||||
print(flag)
|
||||
|
||||
print("Using CPPDEFINES:")
|
||||
for d in cpp_defines:
|
||||
print(f" -D{d[0]}={d[1]}")
|
||||
|
||||
projenv.Append(CPPDEFINES=cpp_defines)
|
||||
projenv.Append(
|
||||
CCFLAGS=flags,
|
||||
)
|
||||
|
||||
for lb in env.GetLibBuilders():
|
||||
if lb.name == "meshtastic-device-ui":
|
||||
@@ -289,12 +293,9 @@ if ("HAS_TFT", 1) in env.get("CPPDEFINES", []):
|
||||
board_arch = infer_architecture(env.BoardConfig())
|
||||
should_skip_manifest = board_arch is None
|
||||
|
||||
# Most platforms can generate the manifest as part of the default 'buildprog' target.
|
||||
# Typically this passes success/failure properly.
|
||||
mtjson_deps = ["buildprog"]
|
||||
if platform.name == "espressif32":
|
||||
# On ESP32, we need to explicitly depend upon the binary to prevent fake-success upon failure.
|
||||
mtjson_deps = ["$BUILD_DIR/${PROGNAME}.bin"]
|
||||
# For host/native envs, avoid depending on 'buildprog' (some targets don't define it)
|
||||
mtjson_deps = [] if should_skip_manifest else ["buildprog"]
|
||||
if not should_skip_manifest and platform.name == "espressif32":
|
||||
# Build littlefs image as part of mtjson target
|
||||
# Equivalent to `pio run -t buildfs`
|
||||
target_lfs = env.DataToBin(
|
||||
@@ -308,8 +309,7 @@ if should_skip_manifest:
|
||||
|
||||
env.AddCustomTarget(
|
||||
name="mtjson",
|
||||
# For host/native envs, avoid depending on 'buildprog' (some targets don't define it)
|
||||
dependencies=[],
|
||||
dependencies=mtjson_deps,
|
||||
actions=[skip_manifest],
|
||||
title="Meshtastic Manifest (skipped)",
|
||||
description="mtjson generation is skipped for native environments",
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate the fake-NodeDB fixtures: produces 250 / 500 / 1000 / 2000-node
|
||||
# JSONL seed files + their compiled v25 protobufs.
|
||||
#
|
||||
# Layout:
|
||||
# test/fixtures/nodedb/seed_v25_<N>.jsonl — COMMITTED, hand-editable.
|
||||
# build/fixtures/nodedb/nodes_v25_<N>.proto — .gitignored, build artifact.
|
||||
# Drop into /prefs/nodes.proto.
|
||||
#
|
||||
# Daily use: ./bin/regen-fake-nodedbs.sh
|
||||
# - Recompiles protos from committed seeds (fresh wall-clock timestamps).
|
||||
# Intentional seed bump: REGEN_SEEDS=yes ./bin/regen-fake-nodedbs.sh
|
||||
# - Overwrites the committed JSONL files with freshly-seeded data.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# 1) Make sure the Python protobuf bindings exist (in-tree generation; .gitignored).
|
||||
if [[ ! -d bin/_generated/meshtastic ]]; then
|
||||
echo "regenerating Python protobuf bindings (one-time)..."
|
||||
./bin/regen-py-protos.sh
|
||||
fi
|
||||
|
||||
# 2) Pick a Python interpreter that has the meshtastic deps installed.
|
||||
# Prefer the mcp-server venv (most likely to be set up by the operator).
|
||||
PY="python3"
|
||||
for cand in mcp-server/.venv/bin/python3 .venv/bin/python3; do
|
||||
if [[ -x "$cand" ]]; then
|
||||
PY="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# 3) Pinned seeds per size — bump only when you intentionally want different
|
||||
# structural data committed. Parallel arrays so the script works on
|
||||
# macOS bash 3.2 (no `declare -A`).
|
||||
SIZES=(250 500 1000 2000)
|
||||
SEEDS=(20260511 20260512 20260513 20260514)
|
||||
|
||||
REGEN_SEEDS="${REGEN_SEEDS:-no}"
|
||||
|
||||
mkdir -p build/fixtures/nodedb test/fixtures/nodedb
|
||||
|
||||
for i in 0 1 2 3; do
|
||||
n="${SIZES[$i]}"
|
||||
seed="${SEEDS[$i]}"
|
||||
jsonl=$(printf "test/fixtures/nodedb/seed_v25_%04d.jsonl" "$n")
|
||||
proto=$(printf "build/fixtures/nodedb/nodes_v25_%04d.proto" "$n")
|
||||
|
||||
if [[ "$REGEN_SEEDS" == "yes" || ! -f "$jsonl" ]]; then
|
||||
$PY bin/gen-fake-nodedb-seed.py \
|
||||
--count "$n" \
|
||||
--seed "$seed" \
|
||||
--out "$jsonl" \
|
||||
--centroid 33.1284,-107.2528 \
|
||||
--spread-km 60 \
|
||||
--position-coverage 0.85 \
|
||||
--telemetry-coverage 0.70 \
|
||||
--environment-coverage 0.25 \
|
||||
--status-coverage 0.40
|
||||
echo " seed: $jsonl ($(wc -c < "$jsonl") bytes)"
|
||||
fi
|
||||
|
||||
$PY bin/seed-json-to-proto.py --in "$jsonl" --out "$proto"
|
||||
echo " proto: $proto ($(wc -c < "$proto") bytes)"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done. To load on Portduino native:"
|
||||
echo " cp build/fixtures/nodedb/nodes_v25_1000.proto ~/.portduino/default/prefs/nodes.proto"
|
||||
echo ""
|
||||
echo "To push to a hardware device:"
|
||||
echo " Use the mcp-server tool: push_fake_nodedb(size=1000, target=\"hardware\", port=\"/dev/cu.usbmodemXXXX\", confirm=True)"
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerate Python protobuf bindings from the in-tree `protobufs/` submodule
|
||||
# into `bin/_generated/`. Called by bin/regen-fake-nodedbs.sh; also useful as
|
||||
# a standalone refresh after any change to a .proto file.
|
||||
#
|
||||
# Output is .gitignored — bindings are a build artifact.
|
||||
#
|
||||
# Namespace rewrite:
|
||||
# The .proto files declare `package meshtastic;`, which makes protoc emit
|
||||
# imports like `from meshtastic import mesh_pb2`. That conflicts with the
|
||||
# PyPI `meshtastic` package (which the mcp-server relies on for its
|
||||
# SerialInterface/BLEInterface transport). We post-process the generated
|
||||
# files to live under `meshtastic_v25` instead — both the directory layout
|
||||
# and all internal imports — so they coexist cleanly with the PyPI package.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if ! command -v protoc >/dev/null 2>&1; then
|
||||
echo "ERROR: protoc not found in PATH." >&2
|
||||
echo " macOS: brew install protobuf" >&2
|
||||
echo " Ubuntu/Debian: apt install protobuf-compiler" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUT=bin/_generated
|
||||
LOCAL_NS=meshtastic_v25
|
||||
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# 1) Generate from the in-tree protos. nanopb.proto first so its descriptor
|
||||
# is available for the [(nanopb).*] options on other messages.
|
||||
protoc \
|
||||
--proto_path=protobufs \
|
||||
--python_out="$OUT" \
|
||||
protobufs/nanopb.proto \
|
||||
protobufs/meshtastic/*.proto
|
||||
|
||||
# 2) Move the generated `meshtastic/` directory to `meshtastic_v25/`.
|
||||
mv "$OUT/meshtastic" "$OUT/$LOCAL_NS"
|
||||
|
||||
# 3) Rewrite internal imports: any reference to `meshtastic.X_pb2` or
|
||||
# `from meshtastic import X_pb2` becomes `meshtastic_v25.*`.
|
||||
python3 bin/_rewrite_proto_namespace.py "$OUT/$LOCAL_NS" "$LOCAL_NS"
|
||||
|
||||
# 4) Make the package importable.
|
||||
touch "$OUT/__init__.py"
|
||||
touch "$OUT/$LOCAL_NS/__init__.py"
|
||||
|
||||
echo "regenerated Python protobuf bindings -> $OUT/$LOCAL_NS/ (namespace: $LOCAL_NS)" >&2
|
||||
@@ -1,249 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run native PlatformIO unit tests and emit a single, unambiguous RED/AMBER/GREEN verdict.
|
||||
#
|
||||
# Why this exists: PlatformIO reports failures three different ways ([FAILED], :FAIL:,
|
||||
# [ERRORED]) and an all-pass run prints "N succeeded" with NO "0 failed" clause — so naive
|
||||
# greps produce false greens (see .notes/test-passfail-filter.md). This script encodes the
|
||||
# correct logic once, and cross-checks the number of suites that actually ran against the
|
||||
# canonical set in test/ so a suite silently going missing shows up as AMBER, not green.
|
||||
#
|
||||
# Usage:
|
||||
# ./bin/run-tests.sh # run all suites, full RAG + count cross-check
|
||||
# ./bin/run-tests.sh -f test_utf8 # run one suite (no count cross-check)
|
||||
# ./bin/run-tests.sh -e native # override env (default: coverage)
|
||||
# ./bin/run-tests.sh --quiet # only print the final RESULT line
|
||||
#
|
||||
# Exit codes: 0 = GREEN, 1 = RED, 2 = AMBER.
|
||||
#
|
||||
# The final line is machine-readable, e.g.:
|
||||
# RESULT: GREEN 19/19 suites passed
|
||||
# RESULT: AMBER 17/19 suites ran (missing: test_radio test_serial) — all that ran passed
|
||||
# RESULT: RED test_traffic_management: 1 failed (or: build/crash error)
|
||||
# RESULT: RED sanitizer fault — SUMMARY: AddressSanitizer: 1272 byte(s) leaked (tests may have
|
||||
# all passed; the coverage build aborts at exit on an ASan/LSan fault — often shown only
|
||||
# as [ERRORED]/SIGHUP. The script names it and points at running the binary bare.)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
ENV="coverage"
|
||||
FILTER=""
|
||||
QUIET=false
|
||||
PASSTHRU=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-f)
|
||||
FILTER="$2"
|
||||
PASSTHRU+=("-f" "$2")
|
||||
shift 2
|
||||
;;
|
||||
-e)
|
||||
ENV="$2"
|
||||
shift 2
|
||||
;;
|
||||
--quiet)
|
||||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
PASSTHRU+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Locate pio (PATH, then the standard PlatformIO venv).
|
||||
PIO="$(command -v pio || command -v platformio || echo "$HOME/.platformio/penv/bin/pio")"
|
||||
if [[ ! -x $PIO ]] && ! command -v "$PIO" >/dev/null 2>&1; then
|
||||
echo "RESULT: RED pio not found (looked in PATH and ~/.platformio/penv/bin)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOG="$(mktemp -t meshtest.XXXXXX.log)"
|
||||
MARKER=""
|
||||
PROGRESS_PID=""
|
||||
trap 'rm -f "$LOG" "${MARKER:-}"; [[ -n ${PROGRESS_PID:-} ]] && kill "$PROGRESS_PID" 2>/dev/null' EXIT
|
||||
|
||||
# Canonical suite set = the directories in test/. This is the source of truth for
|
||||
# "what should run"; a filtered run only expects its filtered suite.
|
||||
mapfile -t ALL_SUITES < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
|
||||
EXPECTED_COUNT=${#ALL_SUITES[@]}
|
||||
|
||||
# Cached object-count for this env, written after each completed build (in the gitignored build
|
||||
# dir). Used as the progress denominator: accurate for a full rebuild (every object recompiles),
|
||||
# only a rough upper bound for an incremental run.
|
||||
BASELINE_FILE=".pio/build/${ENV}/.runtests-objcount"
|
||||
|
||||
# Progress trail file (gitignored build dir). ALWAYS written so a backgrounded/piped run can be
|
||||
# checked mid-build with `tail -f` — that's the whole point: don't fly blind on a 20-min rebuild.
|
||||
PROGRESS_FILE=".pio/build/${ENV}/.runtests-progress"
|
||||
|
||||
# --- Progress heartbeat ------------------------------------------------------
|
||||
# Emit ONE status line every few seconds: build = objects (re)compiled this run / cached total +
|
||||
# best-effort ETA; test = suites finished / expected. Appends to $PROGRESS_FILE always (tail it to
|
||||
# check on a backgrounded run); also live-updates the tty when $5=1 (interactive --quiet). Never
|
||||
# touches $LOG, which is parsed for the verdict, so piped/CI captures stay clean.
|
||||
progress_monitor() {
|
||||
local marker="$1" objtotal="$2" testtotal="$3" pfile="$4" totty="$5" start now el done ran eta line
|
||||
start=$(date +%s)
|
||||
while :; do
|
||||
now=$(date +%s)
|
||||
el=$((now - start))
|
||||
if grep -q 'Testing\.\.\.' "$LOG" 2>/dev/null; then
|
||||
ran=$(grep -cE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" 2>/dev/null)
|
||||
line=$(printf '[test] %s/%s suites done — %dm%02ds' "$ran" "$testtotal" $((el / 60)) $((el % 60)))
|
||||
else
|
||||
done=$(find ".pio/build/${ENV}" -name '*.o' -newer "$marker" 2>/dev/null | wc -l)
|
||||
if ((objtotal > 0 && done > 0)); then
|
||||
eta=$((objtotal > done ? (objtotal - done) * el / done : 0))
|
||||
line=$(printf '[build] %d/%d objs — %dm%02ds — ETA ~%dm%02ds' \
|
||||
"$done" "$objtotal" $((el / 60)) $((el % 60)) $((eta / 60)) $((eta % 60)))
|
||||
else
|
||||
# done==0 (incremental: nothing to rebuild yet) or no cached baseline — no ETA yet.
|
||||
line=$(printf '[build] %d objs compiled — %dm%02ds' "$done" $((el / 60)) $((el % 60)))
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "$line" >>"$pfile" 2>/dev/null # file trail (always)
|
||||
[[ $totty == 1 ]] && printf '\r\033[K%s' "$line" >/dev/tty 2>/dev/null # live line (human)
|
||||
sleep 4
|
||||
done
|
||||
}
|
||||
|
||||
# Launch the heartbeat for every run. It writes the progress file unconditionally; the live tty
|
||||
# line only when interactive AND --quiet (where pio's own output is hidden — otherwise pio's
|
||||
# streamed compile lines already show progress and a \r line would just fight them).
|
||||
mkdir -p ".pio/build/${ENV}" 2>/dev/null || true
|
||||
: >"$PROGRESS_FILE" 2>/dev/null || true
|
||||
MARKER="$(mktemp -t meshtest-mark.XXXXXX)"
|
||||
TOTTY=0
|
||||
{ $QUIET && [[ -t 1 ]]; } && TOTTY=1
|
||||
progress_monitor "$MARKER" "$(cat "$BASELINE_FILE" 2>/dev/null || echo 0)" \
|
||||
"$([[ -n $FILTER ]] && echo 1 || echo "$EXPECTED_COUNT")" "$PROGRESS_FILE" "$TOTTY" &
|
||||
PROGRESS_PID=$!
|
||||
|
||||
if ! $QUIET; then
|
||||
echo "Running: $PIO test -e $ENV ${PASSTHRU[*]-} (expecting $EXPECTED_COUNT suites)"
|
||||
fi
|
||||
echo "progress: tail -f $PROGRESS_FILE" >&2
|
||||
|
||||
# Run pio, tee to log. PIPESTATUS[0] is pio's real exit (NOT tee's).
|
||||
if $QUIET; then
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" >"$LOG" 2>&1
|
||||
else
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" 2>&1 | tee "$LOG"
|
||||
fi
|
||||
PIO_RC=${PIPESTATUS[0]}
|
||||
|
||||
# Stop the heartbeat, clear its line, and cache this build's object total for next time.
|
||||
if [[ -n $PROGRESS_PID ]]; then
|
||||
kill "$PROGRESS_PID" 2>/dev/null
|
||||
wait "$PROGRESS_PID" 2>/dev/null
|
||||
PROGRESS_PID=""
|
||||
# Clear the live line only if we were writing one — opening /dev/tty when there is none is
|
||||
# itself a redirect-open error the trailing 2>/dev/null cannot suppress.
|
||||
[[ $TOTTY == 1 ]] && printf '\r\033[K' >/dev/tty 2>/dev/null
|
||||
fi
|
||||
[[ -d ".pio/build/${ENV}" ]] && find ".pio/build/${ENV}" -name '*.o' 2>/dev/null | wc -l >"$BASELINE_FILE" 2>/dev/null || true
|
||||
|
||||
# --- Outcome detection -------------------------------------------------------
|
||||
# The SAME outcome is spelled differently depending on which layer emitted the line — this is
|
||||
# the trap that produces false greens (grepping ":PASS" misses pio's "[PASSED]", grepping
|
||||
# "[FAILED]" misses Unity's ":FAIL:"). So every regex below matches BOTH spellings:
|
||||
# pass: Unity per-assertion ":PASS" | pio per-suite "[PASSED]" | summary "N succeeded"
|
||||
# fail: Unity per-assertion ":FAIL:" | pio per-suite "[FAILED]" | summary "M failed"
|
||||
# error: pio build/crash "[ERRORED]" | Unity "M Failures" | compiler "error:"
|
||||
# Match \b after :PASS/:FAIL so ":PASSED"/":FAILED" forms are also caught either way.
|
||||
FAIL_RE=':FAIL\b|\[FAILED\]|\[ERRORED\]|[1-9][0-9]* failed|[0-9]+ Tests [1-9][0-9]* Failures|error:|undefined reference|Segmentation fault|terminate called|SIGHUP|SIGSEGV|SIGABRT'
|
||||
# Positive proof tests actually ran & passed (absence != success). Accept any pass spelling:
|
||||
# the per-test/per-suite tokens OR a success summary line.
|
||||
PASS_RE=':PASS\b|\[PASSED\]|test cases: *[0-9]+ succeeded|[0-9]+ Tests 0 Failures'
|
||||
# Sanitizer (ASan/LSan/UBSan/TSan) fault signatures. The coverage build is sanitizer-instrumented
|
||||
# and aborts NON-ZERO at exit on a fault — most often a LeakSanitizer leak — AFTER every test has
|
||||
# already printed [PASSED]. pio then reports [ERRORED]/SIGHUP with no :FAIL: anywhere, so it
|
||||
# masquerades as a phantom "N-1 of N succeeded". See .notes/test-passfail-filter.md.
|
||||
# Match only real FAULT lines, never the benign "AddressSanitizer: failed to intercept '...'"
|
||||
# startup noise that prints on every sanitizer run (it'd mislabel a normal [FAILED] as a leak).
|
||||
# Formats per LLVM/Google sanitizer docs: ASan/LSan emit "==PID==ERROR: <San>: ...", UBSan emits
|
||||
# "file:line:col: runtime error: ...", TSan emits "WARNING: ThreadSanitizer: ..."; all close with
|
||||
# a "SUMMARY: <San>: ..." line (LSan-under-ASan reports its SUMMARY as "AddressSanitizer").
|
||||
SAN_RE='(ERROR|WARNING): (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|SUMMARY: (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|Direct leak of|Indirect leak of|detected memory leaks|heap-use-after-free|heap-buffer-overflow|stack-buffer-overflow|attempting double-free|LeakSanitizer has encountered a fatal error|runtime error:'
|
||||
|
||||
# Suites that produced a per-suite verdict. pio emits "coverage:test_x [PASSED|FAILED|ERRORED]";
|
||||
# a SKIPPED suite (hardware-only on native) is "accounted for" too, so it doesn't read as missing.
|
||||
mapfile -t RAN_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" |
|
||||
sed -E "s/^${ENV}:(test_[a-z0-9_]+) .*/\1/" | sort -u)
|
||||
RAN_COUNT=${#RAN_SUITES[@]}
|
||||
# Suites pio explicitly skipped (don't count these as "missing" in the canonical cross-check).
|
||||
mapfile -t SKIPPED_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+.*\bSKIPPED\b" "$LOG" |
|
||||
grep -oE "test_[a-z0-9_]+" | sort -u)
|
||||
|
||||
verdict_red() {
|
||||
local detail bin
|
||||
detail="$(grep -nE '\[FAILED\]|:FAIL:|\[ERRORED\]' "$LOG" | head -3 | sed 's/^/ /')"
|
||||
echo ""
|
||||
echo "RED — failures detected:"
|
||||
[[ -n $detail ]] && echo "$detail"
|
||||
grep -E 'test cases:' "$LOG" | tail -1 | sed 's/^/ /'
|
||||
|
||||
# Path to the test binary for the "run it bare" hint. For native/coverage the test program is
|
||||
# the env executable (e.g. .pio/build/coverage/meshtasticd), NOT a file named 'program'.
|
||||
bin="$(find ".pio/build/${ENV}" -maxdepth 1 -type f -executable ! -name '*.so' 2>/dev/null | head -1)"
|
||||
[[ -z $bin ]] && bin=".pio/build/${ENV}/<program> (build it first: $PIO test -e ${ENV} ${FILTER:+-f $FILTER} --without-testing)"
|
||||
|
||||
# Sanitizer fault (ASan/LSan/UBSan/TSan): name the real cause instead of "build/crash error".
|
||||
if grep -qE "$SAN_RE" "$LOG"; then
|
||||
grep -nE "$SAN_RE" "$LOG" | head -4 | sed 's/^/ /'
|
||||
echo " -> sanitizer fault: if every test above is PASS, this is an exit-time abort, not a failed assertion."
|
||||
echo " -> read the full report by running the binary BARE (gdb hides it via ptrace): ./$bin 2>&1 | tail -40"
|
||||
echo "RESULT: RED sanitizer fault — $(grep -ohE 'SUMMARY: [A-Za-z]+Sanitizer:.*' "$LOG" | tail -1 || echo 'see report above')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# All tests passed but the process still aborted at EXIT (ERRORED/SIGHUP/SIGABRT) and the
|
||||
# sanitizer report was swallowed by the runner (often surfaced only as SIGHUP). Almost always a
|
||||
# sanitizer fault — point at how to surface it rather than calling it a generic crash.
|
||||
if grep -qE "$PASS_RE" "$LOG" && grep -qE '\[ERRORED\]|SIGHUP|SIGABRT' "$LOG" && ! grep -qE ':FAIL\b|\[FAILED\]' "$LOG"; then
|
||||
echo " -> all tests passed but the process aborted at EXIT — likely an ASan/LSan fault whose report"
|
||||
echo " the runner swallowed (commonly shown as SIGHUP). Run the binary BARE to see it: ./$bin 2>&1 | tail -40"
|
||||
echo "RESULT: RED exit-time abort (tests passed; likely sanitizer — see hint above)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: RED $(grep -oE '[0-9]+ failed' "$LOG" | tail -1 || echo 'build/crash error')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# RED: pio non-zero, any failure marker, or no positive summary at all (build died early).
|
||||
if [[ $PIO_RC -ne 0 ]] || grep -qE "$FAIL_RE" "$LOG"; then
|
||||
verdict_red
|
||||
fi
|
||||
if ! grep -qE "$PASS_RE" "$LOG"; then
|
||||
echo ""
|
||||
echo "RESULT: RED no success summary found (build error / no tests ran?) — see log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# AMBER: everything that ran passed, but (full run only) a canonical suite neither ran NOR was
|
||||
# explicitly skipped — i.e. it silently went missing. SKIPPED suites are accounted for.
|
||||
ACCOUNTED_COUNT=$((RAN_COUNT + ${#SKIPPED_SUITES[@]}))
|
||||
if [[ -z $FILTER && $ACCOUNTED_COUNT -lt $EXPECTED_COUNT ]]; then
|
||||
missing=()
|
||||
for s in "${ALL_SUITES[@]}"; do
|
||||
printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || missing+=("$s")
|
||||
done
|
||||
echo ""
|
||||
echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) — all that ran passed"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# GREEN.
|
||||
if [[ -n $FILTER ]]; then
|
||||
echo "RESULT: GREEN ${RAN_COUNT} suite(s) passed (filtered: $FILTER)"
|
||||
else
|
||||
echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed"
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,342 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile a committed seed JSONL into a binary meshtastic_NodeDatabase v25 proto.
|
||||
|
||||
The input is produced by `bin/gen-fake-nodedb-seed.py`. Timestamps in the JSONL
|
||||
are stored as `*_offset_sec` (seconds before "now"); this script resolves them
|
||||
to absolute epochs using `--now-epoch` (default: current wall clock).
|
||||
|
||||
Output is a raw `pb_encode`-compatible binary that can be dropped at
|
||||
`/prefs/nodes.proto` on the device (Portduino prefs dir or hardware via
|
||||
XModem) and loaded by `NodeDB::loadFromDisk` at boot.
|
||||
|
||||
Wire format reference:
|
||||
protobufs/meshtastic/deviceonly.proto (NodeDatabase, NodeInfoLite, sat entries)
|
||||
src/mesh/NodeDB.h:467-484 (bitfield bit positions)
|
||||
src/mesh/NodeDB.cpp:1523-1524 (pb_decode entry point)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
# Prefer the in-tree generated Python protobuf bindings (bin/_generated/meshtastic_v25/)
|
||||
# because the firmware branch's protos (v25 NodeDatabase satellite arrays, slim
|
||||
# NodeInfoLite) are typically newer than what the PyPI `meshtastic` package
|
||||
# ships. Run `bin/regen-py-protos.sh` to (re)generate.
|
||||
#
|
||||
# Namespace note: the local bindings live under `meshtastic_v25` (NOT `meshtastic`)
|
||||
# to avoid shadowing the PyPI `meshtastic` package — bin/regen-py-protos.sh
|
||||
# post-processes the protoc output to rename the package.
|
||||
_HERE = pathlib.Path(__file__).resolve().parent
|
||||
_LOCAL_PROTO_DIR = _HERE / "_generated"
|
||||
if _LOCAL_PROTO_DIR.is_dir():
|
||||
sys.path.insert(0, str(_LOCAL_PROTO_DIR))
|
||||
|
||||
try:
|
||||
from meshtastic_v25.deviceonly_pb2 import ( # type: ignore[import-not-found]
|
||||
NodeDatabase,
|
||||
NodeInfoLite,
|
||||
NodePositionEntry,
|
||||
NodeTelemetryEntry,
|
||||
NodeEnvironmentEntry,
|
||||
NodeStatusEntry,
|
||||
PositionLite,
|
||||
)
|
||||
from meshtastic_v25.mesh_pb2 import HardwareModel, Position, StatusMessage # type: ignore[import-not-found]
|
||||
from meshtastic_v25.config_pb2 import Config # type: ignore[import-not-found]
|
||||
from meshtastic_v25.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics # type: ignore[import-not-found]
|
||||
except ImportError as local_err:
|
||||
# Fall back to the PyPI package if in-tree bindings haven't been generated.
|
||||
# Will fail the v25 assertion below if the PyPI package predates the
|
||||
# satellite-DB schema, but at least gives a clear "run regen-py-protos.sh"
|
||||
# error message instead of an opaque ImportError.
|
||||
try:
|
||||
from meshtastic.protobuf.deviceonly_pb2 import (
|
||||
NodeDatabase,
|
||||
NodeInfoLite,
|
||||
NodePositionEntry,
|
||||
NodeTelemetryEntry,
|
||||
NodeEnvironmentEntry,
|
||||
NodeStatusEntry,
|
||||
PositionLite,
|
||||
)
|
||||
from meshtastic.protobuf.mesh_pb2 import HardwareModel, Position, StatusMessage
|
||||
from meshtastic.protobuf.config_pb2 import Config
|
||||
from meshtastic.protobuf.telemetry_pb2 import DeviceMetrics, EnvironmentMetrics
|
||||
except ImportError as pypi_err:
|
||||
print(
|
||||
"ERROR: could not import meshtastic protobuf bindings.\n"
|
||||
" In-tree generation: run `bin/regen-py-protos.sh` (requires protoc).\n"
|
||||
" PyPI fallback: `pip install meshtastic` (may lag firmware branch).\n"
|
||||
f" local error (meshtastic_v25): {local_err}\n"
|
||||
f" pypi error (meshtastic.protobuf): {pypi_err}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Fail loudly if bindings predate v25 (no satellite arrays).
|
||||
assert (
|
||||
hasattr(NodeDatabase, "DESCRIPTOR")
|
||||
and "positions" in NodeDatabase.DESCRIPTOR.fields_by_name
|
||||
), (
|
||||
"Loaded meshtastic bindings are older than v25 (NodeDatabase.positions missing). "
|
||||
"Run `bin/regen-py-protos.sh` against the in-tree protobufs/ submodule."
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bitfield bit positions (mirror src/mesh/NodeDB.h:467-484).
|
||||
# ---------------------------------------------------------------------------
|
||||
BIT_IS_KEY_MANUALLY_VERIFIED = 0
|
||||
BIT_IS_MUTED = 1
|
||||
BIT_VIA_MQTT = 2
|
||||
BIT_IS_FAVORITE = 3
|
||||
BIT_IS_IGNORED = 4
|
||||
BIT_HAS_USER = 5
|
||||
BIT_IS_LICENSED = 6
|
||||
BIT_IS_UNMESSAGABLE = 7
|
||||
BIT_HAS_IS_UNMESSAGABLE = 8
|
||||
|
||||
BITFIELD_LAYOUT = (
|
||||
# JSON key bit position
|
||||
("is_key_manually_verified", BIT_IS_KEY_MANUALLY_VERIFIED),
|
||||
("is_muted", BIT_IS_MUTED),
|
||||
("via_mqtt", BIT_VIA_MQTT),
|
||||
("is_favorite", BIT_IS_FAVORITE),
|
||||
("is_ignored", BIT_IS_IGNORED),
|
||||
("has_user", BIT_HAS_USER),
|
||||
("is_licensed", BIT_IS_LICENSED),
|
||||
("is_unmessagable", BIT_IS_UNMESSAGABLE),
|
||||
("has_is_unmessagable", BIT_HAS_IS_UNMESSAGABLE),
|
||||
)
|
||||
|
||||
|
||||
def _pack_bitfield(bf: dict[str, bool]) -> int:
|
||||
out = 0
|
||||
for key, shift in BITFIELD_LAYOUT:
|
||||
if bf.get(key, False):
|
||||
out |= (1 << shift)
|
||||
return out
|
||||
|
||||
|
||||
def _validate_node(node: dict[str, Any]) -> None:
|
||||
"""Friendly errors so hand-editors get clear feedback."""
|
||||
if "num" not in node or not isinstance(node["num"], str):
|
||||
raise ValueError(f"node missing/invalid 'num' (must be hex string): {node!r}")
|
||||
if "long_name" not in node:
|
||||
raise ValueError(f"node {node['num']}: missing 'long_name'")
|
||||
if len(node["long_name"]) > 24:
|
||||
raise ValueError(
|
||||
f"node {node['num']}: long_name {node['long_name']!r} is "
|
||||
f"{len(node['long_name'])} chars; max 24 (nanopb max_size:25 minus NUL)"
|
||||
)
|
||||
if "short_name" in node:
|
||||
# short_name max_size:5 (incl. NUL) → 4 bytes of content.
|
||||
# Char count is irrelevant — emojis with variation selectors (e.g. ❄️ = 6 B)
|
||||
# would slip past a `len(str) > 4` check. Always measure bytes.
|
||||
b = node["short_name"].encode("utf-8")
|
||||
if len(b) > 4:
|
||||
raise ValueError(
|
||||
f"node {node['num']}: short_name {node['short_name']!r} is "
|
||||
f"{len(b)} bytes UTF-8; max 4 (nanopb max_size:5 minus NUL)"
|
||||
)
|
||||
pk = node.get("public_key_hex", "")
|
||||
if pk and len(pk) != 64:
|
||||
raise ValueError(
|
||||
f"node {node['num']}: public_key_hex must be 64 hex chars or empty; "
|
||||
f"got {len(pk)} chars"
|
||||
)
|
||||
if pk:
|
||||
try:
|
||||
bytes.fromhex(pk)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"node {node['num']}: public_key_hex is not valid hex: {e}")
|
||||
|
||||
|
||||
def _resolve_time(
|
||||
node: dict[str, Any],
|
||||
field_absolute: str,
|
||||
field_offset: str,
|
||||
now_epoch: int,
|
||||
) -> int:
|
||||
"""If `field_absolute` is set, use it; else compute `now_epoch - offset`."""
|
||||
if field_absolute in node and node[field_absolute] is not None:
|
||||
return int(node[field_absolute])
|
||||
offset = node.get(field_offset, 0)
|
||||
return max(0, int(now_epoch) - int(offset))
|
||||
|
||||
|
||||
def _build_node_info_lite(node: dict[str, Any], now_epoch: int) -> NodeInfoLite:
|
||||
_validate_node(node)
|
||||
info = NodeInfoLite()
|
||||
info.num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"])
|
||||
info.long_name = node.get("long_name", "")
|
||||
info.short_name = node.get("short_name", "")
|
||||
# Enum lookups will raise ValueError on unknown names — that's exactly what we want.
|
||||
info.hw_model = HardwareModel.Value(node.get("hw_model", "UNSET"))
|
||||
info.role = Config.DeviceConfig.Role.Value(node.get("role", "CLIENT"))
|
||||
pk_hex = node.get("public_key_hex", "")
|
||||
if pk_hex:
|
||||
info.public_key = bytes.fromhex(pk_hex)
|
||||
info.snr = float(node.get("snr", 0.0))
|
||||
info.channel = int(node.get("channel", 0))
|
||||
if "hops_away" in node:
|
||||
# `optional uint32 hops_away = 9;` — in Python protobuf, assigning the
|
||||
# field implicitly sets HasField("hops_away") to True. No has_hops_away
|
||||
# setter exists (unlike the C++ nanopb-generated header).
|
||||
info.hops_away = int(node["hops_away"])
|
||||
info.next_hop = int(node.get("next_hop", 0))
|
||||
info.last_heard = _resolve_time(node, "last_heard", "last_heard_offset_sec", now_epoch)
|
||||
info.bitfield = _pack_bitfield(node.get("bitfield", {}))
|
||||
return info
|
||||
|
||||
|
||||
def _build_position_entry(num: int, pos: dict[str, Any], now_epoch: int) -> NodePositionEntry:
|
||||
entry = NodePositionEntry()
|
||||
entry.num = num
|
||||
pl = PositionLite()
|
||||
# Firmware stores lat/long as int32 in 1e-7 degrees.
|
||||
pl.latitude_i = int(round(float(pos["latitude"]) * 1e7))
|
||||
pl.longitude_i = int(round(float(pos["longitude"]) * 1e7))
|
||||
pl.altitude = int(pos.get("altitude", 0))
|
||||
pl.time = _resolve_time(pos, "time", "time_offset_sec", now_epoch)
|
||||
pl.location_source = Position.LocSource.Value(pos.get("location_source", "LOC_UNSET"))
|
||||
entry.position.CopyFrom(pl)
|
||||
return entry
|
||||
|
||||
|
||||
def _build_telemetry_entry(num: int, tel: dict[str, Any]) -> NodeTelemetryEntry:
|
||||
entry = NodeTelemetryEntry()
|
||||
entry.num = num
|
||||
dm = DeviceMetrics()
|
||||
if "battery_level" in tel:
|
||||
dm.battery_level = int(tel["battery_level"])
|
||||
if "voltage" in tel:
|
||||
dm.voltage = float(tel["voltage"])
|
||||
if "channel_utilization" in tel:
|
||||
dm.channel_utilization = float(tel["channel_utilization"])
|
||||
if "air_util_tx" in tel:
|
||||
dm.air_util_tx = float(tel["air_util_tx"])
|
||||
if "uptime_seconds" in tel:
|
||||
dm.uptime_seconds = int(tel["uptime_seconds"])
|
||||
entry.device_metrics.CopyFrom(dm)
|
||||
return entry
|
||||
|
||||
|
||||
def _build_environment_entry(num: int, env: dict[str, Any]) -> NodeEnvironmentEntry:
|
||||
entry = NodeEnvironmentEntry()
|
||||
entry.num = num
|
||||
em = EnvironmentMetrics()
|
||||
if "temperature" in env:
|
||||
em.temperature = float(env["temperature"])
|
||||
if "relative_humidity" in env:
|
||||
em.relative_humidity = float(env["relative_humidity"])
|
||||
if "barometric_pressure" in env:
|
||||
em.barometric_pressure = float(env["barometric_pressure"])
|
||||
if "iaq" in env:
|
||||
em.iaq = int(env["iaq"])
|
||||
entry.environment_metrics.CopyFrom(em)
|
||||
return entry
|
||||
|
||||
|
||||
def _build_status_entry(num: int, status: dict[str, Any]) -> NodeStatusEntry:
|
||||
# `StatusMessage` (mesh.proto:1445) has a single `string status` field.
|
||||
entry = NodeStatusEntry()
|
||||
entry.num = num
|
||||
sm = StatusMessage()
|
||||
if "status" in status:
|
||||
sm.status = str(status["status"])
|
||||
entry.status.CopyFrom(sm)
|
||||
return entry
|
||||
|
||||
|
||||
def compile_jsonl_to_proto(jsonl_path: pathlib.Path, now_epoch: int) -> bytes:
|
||||
"""Read a seed JSONL and return the encoded NodeDatabase bytes."""
|
||||
lines = jsonl_path.read_text(encoding="utf-8").splitlines()
|
||||
if not lines:
|
||||
raise ValueError(f"{jsonl_path} is empty")
|
||||
meta_line = lines[0]
|
||||
meta_obj = json.loads(meta_line)
|
||||
meta = meta_obj.get("_meta", {})
|
||||
version = meta.get("version")
|
||||
if version != 25:
|
||||
raise ValueError(
|
||||
f"{jsonl_path}: meta version is {version!r}; this compiler "
|
||||
f"requires version=25. Regenerate the seed with the matching tooling."
|
||||
)
|
||||
|
||||
db = NodeDatabase()
|
||||
db.version = 25
|
||||
|
||||
for ln, raw in enumerate(lines[1:], start=2):
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
node = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"{jsonl_path}:{ln} JSON parse error: {e}")
|
||||
|
||||
num = int(node["num"], 16) if isinstance(node["num"], str) else int(node["num"])
|
||||
|
||||
# Header
|
||||
info = _build_node_info_lite(node, now_epoch)
|
||||
db.nodes.append(info)
|
||||
|
||||
# Satellites (nullable)
|
||||
if node.get("position"):
|
||||
db.positions.append(_build_position_entry(num, node["position"], now_epoch))
|
||||
if node.get("telemetry"):
|
||||
db.telemetry.append(_build_telemetry_entry(num, node["telemetry"]))
|
||||
if node.get("environment"):
|
||||
db.environment.append(_build_environment_entry(num, node["environment"]))
|
||||
if node.get("status"):
|
||||
db.status.append(_build_status_entry(num, node["status"]))
|
||||
|
||||
return db.SerializeToString()
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Compile a seed JSONL into a binary v25 NodeDatabase proto.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
p.add_argument("--in", dest="in_path", required=True, help="Input seed JSONL.")
|
||||
p.add_argument("--out", required=True, help="Output binary .proto path.")
|
||||
p.add_argument(
|
||||
"--now-epoch",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Pin 'now' to this Unix epoch (for byte-identical CI). Default: time.time().",
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
in_path = pathlib.Path(args.in_path)
|
||||
if not in_path.is_file():
|
||||
print(f"input not found: {in_path}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
now_epoch = args.now_epoch if args.now_epoch is not None else int(time.time())
|
||||
|
||||
try:
|
||||
encoded = compile_jsonl_to_proto(in_path, now_epoch)
|
||||
except ValueError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
out_path = pathlib.Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(encoded)
|
||||
print(
|
||||
f"compiled {in_path} -> {out_path} ({len(encoded)} bytes, now_epoch={now_epoch})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,95 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from github import Github
|
||||
|
||||
def parseFile(path):
|
||||
with open(path, "r") as f:
|
||||
data = json.loads(f)
|
||||
for file in data["files"]:
|
||||
if file["name"].endswith(".bin"):
|
||||
return file["name"], file["bytes"]
|
||||
|
||||
if len(sys.argv) != 4:
|
||||
print(f"expected usage: {sys.argv[0]} <PR number> <path to old-manifests> <path to new-manifests>")
|
||||
sys.exit(1)
|
||||
|
||||
pr_number = int(sys.argv[1])
|
||||
|
||||
token = os.getenv("GITHUB_TOKEN")
|
||||
if not token:
|
||||
raise EnvironmentError("GITHUB_TOKEN not found in environment.")
|
||||
|
||||
repo_name = os.getenv("GITHUB_REPOSITORY") # "owner/repo"
|
||||
if not repo_name:
|
||||
raise EnvironmentError("GITHUB_REPOSITORY not found in environment.")
|
||||
|
||||
oldFiles = sys.argv[2]
|
||||
old = set(os.path.join(oldFiles, f) for f in os.listdir(oldFiles) if os.path.isfile(f))
|
||||
newFiles = sys.argv[3]
|
||||
new = set(os.path.join(newFiles, f) for f in os.listdir(newFiles) if os.path.isfile(f))
|
||||
|
||||
startMarkdown = "# Target Size Changes\n\n"
|
||||
markdown = ""
|
||||
|
||||
newlyIntroduced = new - old
|
||||
if len(newlyIntroduced) > 0:
|
||||
markdown += "## Newly Introduced Targets\n\n"
|
||||
# create a table
|
||||
markdown += "| File | Size |\n"
|
||||
markdown += "| ---- | ---- |\n"
|
||||
for f in newlyIntroduced:
|
||||
name, size = parseFile(f)
|
||||
markdown += f"| `{name}` | {size}b |\n"
|
||||
|
||||
# do not log removed targets
|
||||
# PRs only run a small subset of builds, so removed targets are not meaningful
|
||||
# since they are very likely to just be not ran in PR CI
|
||||
|
||||
both = old & new
|
||||
degradations = []
|
||||
improvements = []
|
||||
for f in both:
|
||||
oldName, oldSize = parseFile(f)
|
||||
_, newSize = parseFile(f)
|
||||
if oldSize != newSize:
|
||||
if newSize < oldSize:
|
||||
improvements.append((oldName, oldSize, newSize))
|
||||
else:
|
||||
degradations.append((oldName, oldSize, newSize))
|
||||
|
||||
if len(degradations) > 0:
|
||||
markdown += "\n## Degradation\n\n"
|
||||
# create a table
|
||||
markdown += "| File | Difference | Old Size | New Size |\n"
|
||||
markdown += "| ---- | ---------- | -------- | -------- |\n"
|
||||
for oldName, oldSize, newSize in degradations:
|
||||
markdown += f"| `{oldName}` | **{oldSize - newSize}b** | {oldSize}b | {newSize}b |\n"
|
||||
|
||||
if len(improvements) > 0:
|
||||
markdown += "\n## Improvement\n\n"
|
||||
# create a table
|
||||
markdown += "| File | Difference | Old Size | New Size |\n"
|
||||
markdown += "| ---- | ---------- | -------- | -------- |\n"
|
||||
for oldName, oldSize, newSize in improvements:
|
||||
markdown += f"| `{oldName}` | **{oldSize - newSize}b** | {oldSize}b | {newSize}b |\n"
|
||||
|
||||
if len(markdown) == 0:
|
||||
markdown = "No changes in target sizes detected."
|
||||
|
||||
g = Github(token)
|
||||
repo = g.get_repo(repo_name)
|
||||
pr = repo.get_pull(pr_number)
|
||||
|
||||
existing_comment = None
|
||||
for comment in pr.get_issue_comments():
|
||||
if comment.body.startswith(startMarkdown):
|
||||
existing_comment = comment
|
||||
break
|
||||
|
||||
final_markdown = startMarkdown + markdown
|
||||
|
||||
if existing_comment:
|
||||
existing_comment.edit(body=final_markdown)
|
||||
else:
|
||||
pr.create_issue_comment(body=final_markdown)
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Compare firmware size reports and generate a markdown summary.
|
||||
|
||||
Usage:
|
||||
size_report.py <new_sizes.json> [--baseline <label>:<old_sizes.json>]...
|
||||
|
||||
Examples:
|
||||
# Compare PR against develop and master baselines
|
||||
size_report.py pr.json --baseline develop:develop.json --baseline master:master.json
|
||||
|
||||
# Single baseline comparison
|
||||
size_report.py pr.json --baseline develop:develop.json
|
||||
|
||||
# No baselines — shows sizes with blank delta columns
|
||||
size_report.py pr.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def load_sizes(path):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def format_delta(n):
|
||||
"""Format byte delta with sign and human-friendly suffix."""
|
||||
sign = "+" if n > 0 else ""
|
||||
if abs(n) >= 1024:
|
||||
return f"{sign}{n:,} ({sign}{n / 1024:.1f} KB)"
|
||||
return f"{sign}{n:,}"
|
||||
|
||||
|
||||
def generate_markdown(new_sizes, baselines, top_n=5):
|
||||
"""Generate a single table with current size and delta columns per baseline.
|
||||
|
||||
baselines: list of (label, old_sizes_dict), may be empty
|
||||
"""
|
||||
labels = [label for label, _ in baselines]
|
||||
|
||||
# Build rows: (board, current_size, [(delta, abs_delta) per baseline])
|
||||
rows = []
|
||||
for board in sorted(new_sizes):
|
||||
current = new_sizes[board]
|
||||
deltas = []
|
||||
for _, old_sizes in baselines:
|
||||
old = old_sizes.get(board)
|
||||
if old is not None:
|
||||
d = current - old
|
||||
deltas.append((d, abs(d)))
|
||||
else:
|
||||
deltas.append((None, 0))
|
||||
# Sort key: max abs delta across baselines (biggest changes first)
|
||||
max_abs = max((ad for _, ad in deltas), default=0)
|
||||
rows.append((board, current, deltas, max_abs))
|
||||
|
||||
rows.sort(key=lambda r: r[3], reverse=True)
|
||||
|
||||
# Summary line
|
||||
sections = []
|
||||
summary_parts = [f"{len(new_sizes)} targets"]
|
||||
for i, (label, old_sizes) in enumerate(baselines):
|
||||
increases = sum(
|
||||
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] > 0
|
||||
)
|
||||
decreases = sum(
|
||||
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] < 0
|
||||
)
|
||||
net = sum(
|
||||
deltas[i][0] for _, _, deltas, _ in rows if deltas[i][0] is not None
|
||||
)
|
||||
parts = []
|
||||
if increases:
|
||||
parts.append(f"{increases} increased")
|
||||
if decreases:
|
||||
parts.append(f"{decreases} decreased")
|
||||
if parts:
|
||||
parts.append(f"net {format_delta(net)}")
|
||||
summary_parts.append(f"vs `{label}`: {', '.join(parts)}")
|
||||
else:
|
||||
summary_parts.append(f"vs `{label}`: no changes")
|
||||
|
||||
if not baselines:
|
||||
summary_parts.append("no baseline available yet")
|
||||
|
||||
sections.append(f"**{' | '.join(summary_parts)}**\n")
|
||||
|
||||
# Table header
|
||||
header = "| Target | Size |"
|
||||
separator = "|--------|-----:|"
|
||||
for label in labels:
|
||||
header += f" vs `{label}` |"
|
||||
separator += "----------:|"
|
||||
sections.append(header)
|
||||
sections.append(separator)
|
||||
|
||||
def format_row(board, current, deltas):
|
||||
row = f"| `{board}` | {current:,} |"
|
||||
for d, _ in deltas:
|
||||
if d is None:
|
||||
row += " |"
|
||||
elif d == 0:
|
||||
row += " 0 |"
|
||||
else:
|
||||
icon = "📈" if d > 0 else "📉"
|
||||
row += f" {icon} {format_delta(d)} |"
|
||||
return row
|
||||
|
||||
# Top N rows always visible
|
||||
top = rows[:top_n]
|
||||
for board, current, deltas, _ in top:
|
||||
sections.append(format_row(board, current, deltas))
|
||||
|
||||
# Remaining rows in expandable section
|
||||
rest = rows[top_n:]
|
||||
if rest:
|
||||
sections.append("")
|
||||
sections.append(
|
||||
f"<details><summary>Show {len(rest)} more target(s)</summary>\n"
|
||||
)
|
||||
sections.append(header)
|
||||
sections.append(separator)
|
||||
for board, current, deltas, _ in rest:
|
||||
sections.append(format_row(board, current, deltas))
|
||||
sections.append("\n</details>")
|
||||
|
||||
sections.append("")
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Compare firmware size reports")
|
||||
parser.add_argument("new_sizes", help="Path to new sizes JSON")
|
||||
parser.add_argument(
|
||||
"--baseline",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="LABEL:PATH",
|
||||
help="Baseline to compare against (e.g. develop:develop.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of top changes to show before collapsing (default: 5)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
new_sizes = load_sizes(args.new_sizes)
|
||||
|
||||
# Silence output when no targets were built — repo maintainer choice
|
||||
if not new_sizes:
|
||||
return
|
||||
|
||||
baselines = []
|
||||
for b in args.baseline:
|
||||
if ":" not in b:
|
||||
print(f"Error: baseline must be LABEL:PATH, got '{b}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
label, path = b.split(":", 1)
|
||||
baselines.append((label, load_sizes(path)))
|
||||
|
||||
md = generate_markdown(new_sizes, baselines, top_n=args.top)
|
||||
print(md)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,262 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Tests for bin/collect_sizes.py and bin/size_report.py."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "bin")
|
||||
|
||||
|
||||
def make_manifest(target, firmware_bytes, extra_files=None):
|
||||
"""Create a minimal .mt.json manifest dict."""
|
||||
files = [{"name": f"firmware-{target}-2.6.0.bin", "bytes": firmware_bytes}]
|
||||
if extra_files:
|
||||
files.extend(extra_files)
|
||||
return {
|
||||
"platformioTarget": target,
|
||||
"version": "2.6.0.test",
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def write_manifests(tmpdir, manifests):
|
||||
"""Write manifest dicts as .mt.json files into tmpdir."""
|
||||
for target, data in manifests.items():
|
||||
path = os.path.join(tmpdir, f"firmware-{target}.mt.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
|
||||
def run_script(script, args):
|
||||
"""Run a Python script and return (returncode, stdout, stderr)."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, script)] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def test_collect_sizes_basic():
|
||||
"""collect_sizes picks up firmware-*.bin entries from manifests."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
manifests = {
|
||||
"heltec-v3": make_manifest("heltec-v3", 1048576),
|
||||
"rak4631": make_manifest("rak4631", 524288),
|
||||
"tbeam": make_manifest("tbeam", 786432),
|
||||
}
|
||||
write_manifests(tmpdir, manifests)
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0, f"collect_sizes failed: {stderr}"
|
||||
assert "3 targets" in stdout
|
||||
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
assert sizes == {"heltec-v3": 1048576, "rak4631": 524288, "tbeam": 786432}
|
||||
|
||||
|
||||
def test_collect_sizes_fallback_bin():
|
||||
"""collect_sizes falls back to non-firmware-prefixed .bin if no firmware-*.bin."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
# Manifest with only a generic .bin (no firmware- prefix)
|
||||
data = {
|
||||
"platformioTarget": "custom-board",
|
||||
"files": [
|
||||
{"name": "littlefs-custom-board.bin", "bytes": 100000},
|
||||
{"name": "custom-board.bin", "bytes": 500000},
|
||||
],
|
||||
}
|
||||
path = os.path.join(tmpdir, "firmware-custom-board.mt.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0, f"collect_sizes failed: {stderr}"
|
||||
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
assert sizes == {"custom-board": 500000}
|
||||
|
||||
|
||||
def test_collect_sizes_skips_ota_littlefs():
|
||||
"""collect_sizes ignores ota/littlefs/bleota .bin files in fallback."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
data = {
|
||||
"platformioTarget": "board-x",
|
||||
"files": [
|
||||
{"name": "littlefs-board-x.bin", "bytes": 100000},
|
||||
{"name": "bleota-board-x.bin", "bytes": 50000},
|
||||
{"name": "mt-board-x-ota.bin", "bytes": 60000},
|
||||
],
|
||||
}
|
||||
path = os.path.join(tmpdir, "firmware-board-x.mt.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
# No valid firmware .bin found, board should be absent
|
||||
assert sizes == {}
|
||||
|
||||
|
||||
def test_collect_sizes_ignores_non_mt_json():
|
||||
"""collect_sizes skips non .mt.json files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
# Write a valid manifest
|
||||
manifests = {"rak4631": make_manifest("rak4631", 500000)}
|
||||
write_manifests(tmpdir, manifests)
|
||||
# Write a decoy file
|
||||
with open(os.path.join(tmpdir, "readme.txt"), "w") as f:
|
||||
f.write("not a manifest")
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
assert list(sizes.keys()) == ["rak4631"]
|
||||
|
||||
|
||||
def test_size_report_no_baseline():
|
||||
"""size_report with no baselines shows sizes only."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sizes_file = os.path.join(tmpdir, "new.json")
|
||||
with open(sizes_file, "w") as f:
|
||||
json.dump({"heltec-v3": 1000000, "rak4631": 500000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script("size_report.py", [sizes_file])
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "2 targets" in stdout
|
||||
assert "no baseline available yet" in stdout
|
||||
assert "`heltec-v3`" in stdout
|
||||
assert "`rak4631`" in stdout
|
||||
|
||||
|
||||
def test_size_report_with_baseline():
|
||||
"""size_report shows deltas against a baseline."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
new_file = os.path.join(tmpdir, "new.json")
|
||||
old_file = os.path.join(tmpdir, "old.json")
|
||||
with open(new_file, "w") as f:
|
||||
json.dump({"heltec-v3": 1050000, "rak4631": 500000, "tbeam": 800000}, f)
|
||||
with open(old_file, "w") as f:
|
||||
json.dump({"heltec-v3": 1000000, "rak4631": 500000, "tbeam": 810000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [new_file, "--baseline", f"develop:{old_file}"]
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "3 targets" in stdout
|
||||
assert "1 increased" in stdout
|
||||
assert "1 decreased" in stdout
|
||||
# heltec-v3 grew by 50000
|
||||
assert "📈" in stdout
|
||||
# tbeam shrank by 10000
|
||||
assert "📉" in stdout
|
||||
# rak4631 unchanged
|
||||
assert "vs `develop`" in stdout
|
||||
|
||||
|
||||
def test_size_report_multiple_baselines():
|
||||
"""size_report handles multiple baselines."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
new_file = os.path.join(tmpdir, "new.json")
|
||||
dev_file = os.path.join(tmpdir, "develop.json")
|
||||
master_file = os.path.join(tmpdir, "master.json")
|
||||
with open(new_file, "w") as f:
|
||||
json.dump({"board-a": 100000}, f)
|
||||
with open(dev_file, "w") as f:
|
||||
json.dump({"board-a": 95000}, f)
|
||||
with open(master_file, "w") as f:
|
||||
json.dump({"board-a": 90000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py",
|
||||
[new_file, "--baseline", f"develop:{dev_file}", "--baseline", f"master:{master_file}"],
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "vs `develop`" in stdout
|
||||
assert "vs `master`" in stdout
|
||||
|
||||
|
||||
def test_size_report_new_target_no_baseline_entry():
|
||||
"""size_report handles targets not present in baseline (new boards)."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
new_file = os.path.join(tmpdir, "new.json")
|
||||
old_file = os.path.join(tmpdir, "old.json")
|
||||
with open(new_file, "w") as f:
|
||||
json.dump({"new-board": 300000, "existing": 500000}, f)
|
||||
with open(old_file, "w") as f:
|
||||
json.dump({"existing": 500000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [new_file, "--baseline", f"develop:{old_file}"]
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "`new-board`" in stdout
|
||||
assert "no changes" in stdout # only existing is compared, delta=0
|
||||
|
||||
|
||||
def test_size_report_all_unchanged():
|
||||
"""size_report shows 'no changes' when all sizes match."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sizes_file = os.path.join(tmpdir, "sizes.json")
|
||||
with open(sizes_file, "w") as f:
|
||||
json.dump({"board-a": 100000, "board-b": 200000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [sizes_file, "--baseline", f"develop:{sizes_file}"]
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "no changes" in stdout
|
||||
|
||||
|
||||
def test_collect_sizes_bad_args():
|
||||
"""collect_sizes exits with error on wrong arg count."""
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [])
|
||||
assert rc == 1
|
||||
assert "Usage" in stderr
|
||||
|
||||
|
||||
def test_size_report_bad_baseline_format():
|
||||
"""size_report exits with error on malformed --baseline."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sizes_file = os.path.join(tmpdir, "sizes.json")
|
||||
with open(sizes_file, "w") as f:
|
||||
json.dump({"x": 1}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [sizes_file, "--baseline", "no-colon-here"]
|
||||
)
|
||||
assert rc == 1
|
||||
assert "LABEL:PATH" in stderr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [v for k, v in globals().items() if k.startswith("test_")]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
print(f" PASS: {test.__name__}")
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f" FAIL: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {test.__name__}: {type(e).__name__}: {e}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n{passed} passed, {failed} failed out of {passed + failed}")
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -7,7 +7,7 @@
|
||||
"extra_flags": [
|
||||
"-D CDEBYTE_EORA_S3",
|
||||
"-D ARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-D ARDUINO_USB_MODE=1",
|
||||
"-D ARDUINO_USB_MODE=0",
|
||||
"-D ARDUINO_RUNNING_CORE=1",
|
||||
"-D ARDUINO_EVENT_RUNNING_CORE=1",
|
||||
"-D BOARD_HAS_PSRAM"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1",
|
||||
"-DBOARD_HAS_PSRAM"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52840_s140_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DNRF52840_XXAA",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x239A", "0x4405"],
|
||||
["0x239A", "0x0029"],
|
||||
["0x239A", "0x002A"],
|
||||
["0x239A", "0x0071"]
|
||||
],
|
||||
"usb_product": "HT-n5262",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "heltec_mesh_tower_v2",
|
||||
"variants_dir": "variants",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS140",
|
||||
"sd_name": "s140",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B6"
|
||||
},
|
||||
"bootloader": {
|
||||
"settings_addr": "0xFF000"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"onboard_tools": ["jlink"],
|
||||
"svd_path": "nrf52840.svd",
|
||||
"openocd_target": "nrf52840-mdk-rs"
|
||||
},
|
||||
"frameworks": ["arduino"],
|
||||
"name": "Heltec MeshTower V2 (Adafruit BSP)",
|
||||
"upload": {
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_size": 815104,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"],
|
||||
"use_1200bps_touch": true,
|
||||
"require_upload_port": true,
|
||||
"wait_for_upload_port": true
|
||||
},
|
||||
"url": "https://heltec.org",
|
||||
"vendor": "Heltec"
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"extra_flags": [
|
||||
"-DHELTEC_WIRELESS_TRACKER",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"core": "esp32",
|
||||
"extra_flags": [
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"cpu": "cortex-m33",
|
||||
"f_cpu": "128000000L",
|
||||
"mcu": "nrf54l15",
|
||||
"zephyr": {
|
||||
"variant": "nrf54l15dk/nrf54l15/cpuapp"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"default_tools": ["jlink"],
|
||||
"jlink_device": "nRF54L15_M33",
|
||||
"svd_path": "nrf54l15.svd"
|
||||
},
|
||||
"frameworks": ["zephyr"],
|
||||
"name": "Nordic nRF54L15-DK (PCA10156)",
|
||||
"upload": {
|
||||
"maximum_ram_size": 262144,
|
||||
"maximum_size": 1572864,
|
||||
"protocol": "jlink",
|
||||
"protocols": ["jlink"]
|
||||
},
|
||||
"url": "https://www.nordicsemi.com/Products/nRF54L15",
|
||||
"vendor": "Nordic Semiconductor"
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=0"
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DLILYGO_TBEAM_1W",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"extra_flags": [
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DLILYGO_TBEAM_S3_CORE",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"extra_flags": [
|
||||
"-DLILYGO_T3S3_V1",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1",
|
||||
"-DBOARD_HAS_PSRAM"
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"-DBOARD_HAS_PSRAM",
|
||||
"-DUNPHONE_SPIN=9",
|
||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||
"-DARDUINO_USB_MODE=1",
|
||||
"-DARDUINO_USB_MODE=0",
|
||||
"-DARDUINO_RUNNING_CORE=1",
|
||||
"-DARDUINO_EVENT_RUNNING_CORE=1"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52832_s132_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DNRF52832_XXAA -DNRF52",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x239A", "0x8029"],
|
||||
["0x239A", "0x0029"],
|
||||
["0x239A", "0x002A"],
|
||||
["0x239A", "0x802A"]
|
||||
],
|
||||
"usb_product": "Feather nRF52832 Express",
|
||||
"mcu": "nrf52832",
|
||||
"variant": "WisCore_RAK4600_Board",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS132",
|
||||
"sd_name": "s132",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B7"
|
||||
},
|
||||
"zephyr": {
|
||||
"variant": "nrf52_adafruit_feather"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52832_xxAA",
|
||||
"svd_path": "nrf52.svd",
|
||||
"openocd_target": "nrf52840-mdk-rs"
|
||||
},
|
||||
"frameworks": ["arduino", "zephyr"],
|
||||
"name": "Adafruit Bluefruit nRF52832 Feather",
|
||||
"upload": {
|
||||
"maximum_ram_size": 65536,
|
||||
"maximum_size": 524288,
|
||||
"require_upload_port": true,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"]
|
||||
},
|
||||
"url": "https://www.adafruit.com/product/3406",
|
||||
"vendor": "Adafruit"
|
||||
}
|
||||
Vendored
+6
@@ -1,3 +1,9 @@
|
||||
meshtasticd (2.7.25.0) unstable; urgency=medium
|
||||
|
||||
* Version 2.7.25
|
||||
|
||||
-- GitHub Actions <github-actions[bot]@users.noreply.github.com> Sat, 23 May 2026 01:16:20 +0000
|
||||
|
||||
meshtasticd (2.7.24.0) unstable; urgency=medium
|
||||
|
||||
* Version 2.7.24
|
||||
|
||||
Vendored
-1
@@ -14,7 +14,6 @@ Build-Depends: debhelper-compat (= 13),
|
||||
g++,
|
||||
pkg-config,
|
||||
libyaml-cpp-dev,
|
||||
libjsoncpp-dev,
|
||||
libgpiod-dev,
|
||||
libbluetooth-dev,
|
||||
libusb-1.0-0-dev,
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xe000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x640000,
|
||||
app1, app, ota_1, 0x650000,0x640000,
|
||||
spiffs, data, spiffs, 0xc90000,0x360000,
|
||||
coredump, data, coredump,0xFF0000,0x10000,
|
||||
|
@@ -1,7 +0,0 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xe000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x330000,
|
||||
app1, app, ota_1, 0x340000,0x330000,
|
||||
spiffs, data, spiffs, 0x670000,0x180000,
|
||||
coredump, data, coredump,0x7F0000,0x10000,
|
||||
|
@@ -1,277 +0,0 @@
|
||||
# LoRa Region → Preset Compatibility — Client Implementation Spec
|
||||
|
||||
**Status:** Draft for 2.8 · **Audience:** Meshtastic client app developers (Android first,
|
||||
Apple second, then web/python) · **Firmware side:** implemented in `firmware`
|
||||
(`FromRadio.region_presets`, see below).
|
||||
|
||||
> This document lives in the firmware repo while the feature is developed. It is meant to
|
||||
> graduate to `meshtastic/protobufs` (and/or the docs site) alongside the upstream protobuf
|
||||
> PR that reserves `FromRadio` field **19**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
For 2.8 the LoRa regions and modem presets were reworked. **Not every modem preset is legal
|
||||
in every region** — narrow EU SRD bands, the EU 868 "narrow" band, amateur/ham bands, and
|
||||
the 2.4 GHz band each accept only a specific subset of presets. The firmware already
|
||||
enforces this internally (it clamps or rejects illegal combinations), but until now a client
|
||||
had no way to _know_ the rules, so a user could pick an illegal region+preset pair in the UI
|
||||
and only discover the problem after the device silently corrected it.
|
||||
|
||||
This feature has the firmware **declare the legal region→preset combinations** to the client
|
||||
during the `want_config` handshake, so the client UI can constrain the preset picker to the
|
||||
valid set for the currently selected region (and warn about licensed-only bands). It is
|
||||
purely advisory metadata — the firmware remains the source of truth and still
|
||||
validates/clamps on its own.
|
||||
|
||||
---
|
||||
|
||||
## 2. Protocol additions
|
||||
|
||||
Three new messages in `meshtastic/mesh.proto`, plus one new `FromRadio` oneof variant.
|
||||
|
||||
### 2.1 `FromRadio.region_presets` (field 19)
|
||||
|
||||
```proto
|
||||
message FromRadio {
|
||||
uint32 id = 1;
|
||||
oneof payload_variant {
|
||||
// ... fields 2..18 unchanged ...
|
||||
LoRaRegionPresetMap region_presets = 19;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Messages
|
||||
|
||||
```proto
|
||||
// A distinct set of legal modem presets shared by one or more LoRa regions.
|
||||
message LoRaPresetGroup {
|
||||
repeated Config.LoRaConfig.ModemPreset presets = 1; // legal presets for this group
|
||||
Config.LoRaConfig.ModemPreset default_preset = 2; // always one of `presets`
|
||||
bool licensed_only = 3; // ham/amateur band → warn/gate
|
||||
}
|
||||
|
||||
// Associates a single LoRa region with its preset group (by index).
|
||||
message LoRaRegionPresets {
|
||||
Config.LoRaConfig.RegionCode region = 1;
|
||||
uint32 group_index = 2; // index into LoRaRegionPresetMap.groups
|
||||
}
|
||||
|
||||
// The full map, delivered grouped to fit one FromRadio packet.
|
||||
message LoRaRegionPresetMap {
|
||||
repeated LoRaPresetGroup groups = 1; // each distinct preset list
|
||||
repeated LoRaRegionPresets region_groups = 2; // every known region → a group index
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Why grouped (and the size envelope clients should respect)
|
||||
|
||||
A `FromRadio` packet is capped at **512 bytes** (`MAX_TO_FROM_RADIO_SIZE`). Most regions
|
||||
share one identical preset list (the "standard" 9-preset list), so the map is delivered
|
||||
**grouped**: `groups` holds each _distinct_ preset list once, and `region_groups` maps every
|
||||
known region to one of those groups by index. This keeps the encoded size additive
|
||||
(`groups` + `region_groups`) rather than multiplicative, well under the cap.
|
||||
|
||||
nanopb (firmware) array bounds — clients do **not** need to enforce these, but they bound
|
||||
what you can receive:
|
||||
|
||||
| field | max_count |
|
||||
| ----------------------------------- | ------------------------------------ |
|
||||
| `LoRaRegionPresetMap.groups` | 8 |
|
||||
| `LoRaRegionPresetMap.region_groups` | 38 (= number of `RegionCode` values) |
|
||||
| `LoRaPresetGroup.presets` | 11 |
|
||||
|
||||
---
|
||||
|
||||
## 3. When it is delivered
|
||||
|
||||
`region_presets` is sent **once** during the `want_config` handshake, as a single
|
||||
`FromRadio` message, in this position:
|
||||
|
||||
```text
|
||||
my_info → (deviceuiConfig) → node_info(self) → metadata → region_presets → channel… → config… → moduleConfig… → node_info(others)… → fileInfo… → config_complete_id → (live packets)
|
||||
```
|
||||
|
||||
i.e. **immediately after `metadata` and before the first `channel`**.
|
||||
|
||||
- It is included for a normal full `want_config` and for the **config-only** nonce.
|
||||
- It is **omitted** for the **nodes-only** nonce (that path skips metadata/config entirely).
|
||||
- A client must **not** assume it always arrives (see §5).
|
||||
|
||||
---
|
||||
|
||||
## 4. Decoding into a usable lookup
|
||||
|
||||
Flatten the grouped wire form into `Map<RegionCode, RegionPresetInfo>`:
|
||||
|
||||
```text
|
||||
struct RegionPresetInfo { Set<ModemPreset> presets; ModemPreset default; bool licensedOnly }
|
||||
|
||||
fun decode(map: LoRaRegionPresetMap): Map<RegionCode, RegionPresetInfo> {
|
||||
result = {}
|
||||
for (rg in map.region_groups) {
|
||||
if (rg.group_index >= map.groups.size) continue // defensive: malformed/forward data
|
||||
g = map.groups[rg.group_index]
|
||||
result[rg.region] = RegionPresetInfo(
|
||||
presets = g.presets.toSet(),
|
||||
default = g.default_preset,
|
||||
licensedOnly = g.licensed_only)
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Persist this map alongside the rest of the downloaded config so the LoRa config screen can
|
||||
read it synchronously.
|
||||
|
||||
---
|
||||
|
||||
## 5. Semantics & rules (the load-bearing part)
|
||||
|
||||
These rules are what keep the UX correct across firmware versions. Implement all of them.
|
||||
|
||||
1. **Absent region ⇒ no constraint.** If a `RegionCode` does not appear in `region_groups`,
|
||||
the client has _no_ compatibility info for it and **must not restrict** its preset
|
||||
choices (fall back to allowing the full `ModemPreset` list). This happens for a handful
|
||||
of `RegionCode` enum values that have no firmware band table entry (today: `EU_874`,
|
||||
`EU_917`, `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`).
|
||||
|
||||
2. **Absent message ⇒ no constraint.** Firmware older than 2.8 never sends `region_presets`.
|
||||
New clients **must** tolerate the message being absent entirely and keep their existing
|
||||
(unconstrained) behavior. Do not block the config screen waiting for it.
|
||||
|
||||
3. **`default_preset`** is always a member of that group's `presets`. Use it to pre-select a
|
||||
preset when the user switches to a region whose valid set does not include the currently
|
||||
selected preset (instead of leaving an illegal selection or guessing).
|
||||
|
||||
4. **`licensed_only`** marks ham/amateur bands. Surface a warning or gate (the firmware also
|
||||
requires the operator's `is_licensed` flag for these regions; coordinate the two so the
|
||||
user isn't allowed to pick a licensed band without acknowledging licensing).
|
||||
|
||||
5. **EU region auto-swap caveat.** The firmware treats the EU sibling regions
|
||||
(`EU_868` / `EU_866` / `EU_N_868`) specially: if the user is in one of them and selects a
|
||||
preset that belongs to a sibling's list, the firmware **swaps the region** rather than
|
||||
rejecting the preset. Consequence for clients: **do not assume the region is immutable
|
||||
across a preset change** — after an admin config write, re-read the resulting
|
||||
`LoRaConfig` and reflect the (possibly changed) region back into the UI.
|
||||
|
||||
6. **Use it as a UI guard, not a validator of truth.** The firmware still validates/clamps
|
||||
on its own. The map exists to prevent the user from _selecting_ an illegal combo; it is
|
||||
not a security or correctness boundary.
|
||||
|
||||
---
|
||||
|
||||
## 6. UI/UX recommendations
|
||||
|
||||
- In the LoRa config screen, when a region is selected, **filter/enable the modem-preset
|
||||
picker to that region's `presets`** (when `use_preset`/`use_modem_preset` is on).
|
||||
- If the current preset is not in the newly selected region's set, switch the selection to
|
||||
that region's `default_preset`.
|
||||
- Show a **licensed badge / confirmation** for regions where `licensed_only == true`.
|
||||
- If a region is absent from the map (rule §5.1) or the whole message is absent (§5.2),
|
||||
render the full preset list as before — never show an empty picker.
|
||||
|
||||
---
|
||||
|
||||
## 7. Forward / backward compatibility
|
||||
|
||||
- **Old clients, new firmware:** an unknown `FromRadio` oneof variant (field 19) is ignored
|
||||
by protobuf/nanopb decoders; the relative ordering of the known messages is unchanged, so
|
||||
existing apps are unaffected.
|
||||
- **New clients, old firmware:** message simply never arrives → treat as "no constraints"
|
||||
(§5.2).
|
||||
- **Enum growth:** new `RegionCode`/`ModemPreset` values may appear over time. Decoders
|
||||
should pass through unknown enum values rather than crashing; an unknown region in
|
||||
`region_groups` is harmless (the client just won't have a localized name for it).
|
||||
|
||||
---
|
||||
|
||||
## 8. Platform notes
|
||||
|
||||
> Verified against the `main` branch of each repo. Both have been refactored away from
|
||||
> older layouts; re-pin file paths against a specific commit if you need them durable.
|
||||
|
||||
### 8.1 Android — `meshtastic/Meshtastic-Android` (Kotlin / Compose, KMP)
|
||||
|
||||
- **Protobufs are a published Maven artifact, _not_ a submodule.** Declared in
|
||||
`gradle/libs.versions.toml` (`org.meshtastic:protobufs`, currently `2.7.25`); generated
|
||||
package is **`org.meshtastic.proto`**. **A `region_presets`-aware build requires a new
|
||||
published `org.meshtastic:protobufs` release**, then bumping that one version string.
|
||||
- **The protobufs are Wire-generated**, so the `FromRadio` oneof is **not** a
|
||||
`payloadVariantCase` enum — each arm is a **nullable field**. Handle the new variant in
|
||||
`FromRadioPacketHandlerImpl.handleFromRadio(...)`
|
||||
(`core/data/.../manager/FromRadioPacketHandlerImpl.kt`) by adding a
|
||||
`regionPresets != null -> …` arm to the existing `when { … }`, delegating to a handler
|
||||
(mirror `handleLocalMetadata` / `handleConfigComplete`).
|
||||
- **State holder:** expose the decoded map from `RadioConfigRepository` /
|
||||
`RadioConfigRepositoryImpl` as a `Flow` (mirroring `localConfigFlow`/`channelSetFlow`),
|
||||
consumed by `feature/settings/.../radio/RadioConfigViewModel.kt`.
|
||||
- **UI:** the region & preset dropdowns are `DropDownPreference`s in
|
||||
`feature/settings/.../radio/component/LoRaConfigItemList.kt` (public composable
|
||||
`LoRaConfigScreen`). Gate/filter the `ChannelOption` (preset) dropdown by the selected
|
||||
`RegionInfo`'s entry in the map.
|
||||
|
||||
### 8.2 Apple — `meshtastic/Meshtastic-Apple` (Swift / SwiftUI)
|
||||
|
||||
- **Protobufs are vendored** into a local Swift package `MeshtasticProtobufs`
|
||||
(`MeshtasticProtobufs/Sources/meshtastic/*.pb.swift`), generated from the `protobufs` git
|
||||
submodule via `scripts/gen_protos.sh`. **To get field 19:** advance the `protobufs`
|
||||
submodule, run `scripts/gen_protos.sh`, commit the regenerated `.pb.swift` + submodule
|
||||
pointer. (No published-artifact dependency — Apple can regenerate from any commit.)
|
||||
- **Dispatch:** `AccessoryManager.processFromRadio(_:)`
|
||||
(`Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift`) is a real
|
||||
`switch decodedInfo.payloadVariant { … }` — add a `.regionPresets` case, with the handler
|
||||
in `AccessoryManager+FromRadio.swift` (mirror `handleConfig` / `handleMetadata`).
|
||||
- **Persistence:** config is **SwiftData** (`@Model` entities), upserted via
|
||||
`MeshPackets`/`UpdateSwiftData.swift`. Store the decoded map (e.g. on a settings/connection
|
||||
model) so the LoRa view can read it.
|
||||
- **UI:** `Meshtastic/Views/Settings/Config/LoRaConfig.swift` (`struct LoRaConfig: View`)
|
||||
has the `Picker("Region", …)` (`RegionCodes.userSelectable`) and `Picker("Presets", …)`
|
||||
(`ModemPresets.userSelectable`, gated on `usePreset`). Filter the presets picker by the
|
||||
selected region's entry. Enums live in `Meshtastic/Enums/LoraConfigEnums.swift`.
|
||||
|
||||
### 8.3 Other clients
|
||||
|
||||
- **python (`meshtastic` / Meshtastic-python)** and **web** consume the published protobufs;
|
||||
they will see `region_presets` once their protobuf dependency includes field 19, and can
|
||||
ignore it until then (it decodes as an unknown field).
|
||||
|
||||
---
|
||||
|
||||
## 9. Reference payload (current firmware table)
|
||||
|
||||
For decoder unit tests. With the 2.8 region table, the firmware emits **6 groups**. Group
|
||||
indices are assigned in region-table order (first region to use a profile creates its group),
|
||||
so they are stable as listed here:
|
||||
|
||||
| group_index | default_preset | licensed_only | presets |
|
||||
| ----------------------- | -------------- | ------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO |
|
||||
| 1 (EU 868) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE |
|
||||
| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW |
|
||||
| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW |
|
||||
| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW |
|
||||
| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW |
|
||||
|
||||
`region_groups` (region → group_index):
|
||||
|
||||
| group | regions |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 | US, EU_433, CN, JP, ANZ, ANZ_433, RU, KR, TW, IN, NZ_865, TH, UA_433, UA_868, MY_433, MY_919, SG_923, PH_433, PH_868, PH_915, KZ_433, KZ_863, NP_865, BR_902, LORA_24 |
|
||||
| 1 | EU_868 |
|
||||
| 2 | EU_866 |
|
||||
| 3 | EU_N_868 |
|
||||
| 4 | ITU1_2M, ITU2_2M, ITU3_2M |
|
||||
| 5 | ITU2_125CM |
|
||||
|
||||
> Note groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups
|
||||
> because they differ in `licensed_only`. Decoders must key on the group, not on the preset
|
||||
> list, to preserve the licensing flag.
|
||||
>
|
||||
> Regions **absent** from the table (no constraint info; see §5.1): `EU_874`, `EU_917`,
|
||||
> `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`.
|
||||
|
||||
This table is generated from the firmware's region table at runtime; treat the firmware as
|
||||
authoritative and these values as the expected snapshot for the 2.8 table.
|
||||
@@ -1,456 +0,0 @@
|
||||
# NextHop direct-message reliability on dense meshes — findings & plan
|
||||
|
||||
**Status:** Implemented — mitigations and tests in `PR3-tmm-nexthop`
|
||||
**Date:** 2026-06-13
|
||||
**Area:** `src/mesh` router stack (`NextHopRouter`, `ReliableRouter`, `FloodingRouter`, `Router`, `NodeDB`, `PacketHistory`)
|
||||
**Constraint:** No over-the-air / wire-format changes — `next_hop` and `relay_node` stay 1 byte, no `PacketHeader` changes, no breaking protobuf changes. All new state is RAM-only.
|
||||
|
||||
This document captures the analysis and the proposed mitigations so the work can be
|
||||
continued on this branch by anyone. It is intentionally code-grounded (file:line
|
||||
references throughout) and standalone — you should not need the original investigation
|
||||
context to pick it up.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
NextHop routing for direct messages (DMs) is unreliable on dense meshes. The headline
|
||||
cause is the **birthday problem**: `next_hop` and `relay_node` are each a single byte
|
||||
(the last byte of a 32-bit node number), so on a mesh of N nodes the probability that
|
||||
two share the same byte hits ~50% at **~19 nodes** and is near-certain by 50–100. But
|
||||
there are **other, equally important issues**: that single byte is trusted blindly at
|
||||
five different code sites, learned routes **never decay**, routes are learned from the
|
||||
**reverse (ACK) path** (asymmetric-link hazard), and collision-driven spurious
|
||||
rebroadcasts **amplify congestion** exactly when the mesh is busy.
|
||||
|
||||
Because we can't widen the on-wire field, the fix is **interpretation-side** ("don't
|
||||
trust a byte that doesn't map to a unique reachable neighbor — flood instead") plus
|
||||
**recovery-side** ("decay stale/failing routes so they get re-discovered"). Four
|
||||
mitigations, M1–M4, all RAM-only. The net behavioral change: on dense/mobile meshes a
|
||||
DM that today silently misroutes or black-holes instead falls back to managed flooding
|
||||
(which still delivers) and re-learns a fresh route quickly. Sparse-mesh happy paths are
|
||||
unchanged.
|
||||
|
||||
---
|
||||
|
||||
## How NextHop routing works today (mechanics)
|
||||
|
||||
Inheritance chain: `Router` → `FloodingRouter` → `NextHopRouter` → `ReliableRouter`.
|
||||
|
||||
**The single-byte identifiers.** Both routing bytes come from one helper:
|
||||
|
||||
```cpp
|
||||
// src/mesh/NodeDB.h:255
|
||||
uint8_t getLastByteOfNodeNum(NodeNum num) { return (uint8_t)((num & 0xFF) ? (num & 0xFF) : 0xFF); }
|
||||
```
|
||||
|
||||
It projects a 32-bit node number onto 255 values (`0x00` is remapped to `0xFF` so it
|
||||
never collides with the `0`-valued sentinels `NO_NEXT_HOP_PREFERENCE` / `NO_RELAY_NODE`,
|
||||
`src/mesh/MeshTypes.h:44-46`). `next_hop` and `relay_node` in the packet header are
|
||||
`uint8_t` (`src/mesh/mesh.pb.h`, comments "Last byte of the node number…"). The learned
|
||||
route stored per destination, `meshtastic_NodeInfoLite::next_hop`, is also a single byte
|
||||
(`src/mesh/generated/meshtastic/deviceonly.pb.h:83`).
|
||||
|
||||
**Sending a DM** — `NextHopRouter::send` (`src/mesh/NextHopRouter.cpp:23`):
|
||||
|
||||
1. `p->relay_node = getLastByteOfNodeNum(getNodeNum())` (mark ourselves as relayer).
|
||||
2. `p->next_hop = getNextHop(p->to, p->relay_node)` (`src/mesh/NextHopRouter.cpp:192`):
|
||||
look up `nodeDB->getMeshNode(to)->next_hop`; return it unless it equals the relayer
|
||||
byte; otherwise `NO_NEXT_HOP_PREFERENCE` (→ flood).
|
||||
|
||||
**Relaying** — `NextHopRouter::perhapsRebroadcast` (`src/mesh/NextHopRouter.cpp:133`):
|
||||
rebroadcast iff `next_hop == NO_NEXT_HOP_PREFERENCE` (flood) **or**
|
||||
`next_hop == getLastByteOfNodeNum(getNodeNum())` (we are the addressed next hop)
|
||||
(`:147`). Each node only ever compares against **its own** byte.
|
||||
|
||||
**Learning** — `NextHopRouter::sniffReceived` (`src/mesh/NextHopRouter.cpp:89`): on an
|
||||
ACK/reply (`request_id`/`reply_id` set), if the relayer of the ACK was also a relayer of
|
||||
the original packet (validated via `PacketHistory::checkRelayers`), set
|
||||
`origTx->next_hop = p->relay_node` (`:114`). I.e. the **forward** next-hop is learned
|
||||
from the **reverse** path's relayer.
|
||||
|
||||
**Retransmission / fallback** — `NextHopRouter::doRetransmissions`
|
||||
(`src/mesh/NextHopRouter.cpp:284`). Budgets: `NUM_RELIABLE_RETX=3` (originator: initial
|
||||
|
||||
- 2 retries), `NUM_INTERMEDIATE_RETX=2` (relayer: 1 retry). On the **last** retry
|
||||
(`numRetransmissions==1`) it resets `next_hop` to `NO_NEXT_HOP_PREFERENCE` on the packet
|
||||
**and** clears `sentTo->next_hop` in NodeDB, then floods (`:313-321`). Retransmit timing
|
||||
comes from `iface->getRetransmissionMsec`, whose contention window **grows with channel
|
||||
utilization** (`src/mesh/RadioInterface.cpp` `getTxDelayMsec`/`getTxDelayMsecWeighted`).
|
||||
|
||||
**Dedup / relayer history** — `PacketHistory` (`src/mesh/PacketHistory.cpp`): a bounded
|
||||
ring (`PACKETHISTORY_MAX = max(MAX_NUM_NODES*2, 100)`, 20 B/record) keyed by
|
||||
`(sender,id)`, tracking up to `NUM_RELAYERS=6` relayer **bytes** per packet in
|
||||
`relayed_by[]`. `wasRelayer` (`:490`) and `checkRelayers` (`:517`) match bytes against
|
||||
that array.
|
||||
|
||||
---
|
||||
|
||||
## Root-cause analysis
|
||||
|
||||
### 1. The single byte is trusted blindly at five sites (the birthday problem)
|
||||
|
||||
| # | Site | File:line | Failure on collision |
|
||||
| --- | -------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Rebroadcast self-check | `NextHopRouter.cpp:147` | A remote "impostor" node sharing the intended next-hop's byte also rebroadcasts → wasted airtime / congestion. |
|
||||
| 2 | Route learning | `NextHopRouter.cpp:111-114` | Stores an ambiguous byte as the route; later resolves to the wrong physical node. |
|
||||
| 3 | Relayer validation | `PacketHistory.cpp:490-538` | `wasRelayer(byte)` returns true for the wrong node → mis-validated ACK / mis-learn. |
|
||||
| 4 | Favorite-router hop preservation | `Router.cpp:120-145` | **First** NodeDB node whose last byte matches wins — non-deterministic; can preserve hops for the wrong relay (hop leak). |
|
||||
| 5 | Send-path lookup | `NextHopRouter.cpp:192-207` | Emits a byte that may address the wrong node; no check it still maps to a reachable neighbor. |
|
||||
|
||||
Collision math (uniform last byte over 255 buckets): P(collision) ≈ 50% at ~19 nodes,
|
||||
|
||||
> 99% by ~75 nodes. Dense meshes are squarely in the "always colliding" regime.
|
||||
|
||||
### 2. Stale routes never decay
|
||||
|
||||
The learned `next_hop` byte is cleared only on the **current DM's** last retry
|
||||
(`NextHopRouter.cpp:313-321`). A route learned hours ago that has since gone dead is
|
||||
still trusted on the **next** DM's first attempt — which on a congested mesh is also the
|
||||
slowest attempt. Result: silent black-hole at a dead hop until the retransmission budget
|
||||
drains, then a late flood. Intermediate nodes hold stale routes indefinitely.
|
||||
|
||||
### 3. Reverse-path (asymmetric-link) learning
|
||||
|
||||
`origTx->next_hop` is learned from the ACK's relayer (`NextHopRouter.cpp:110-114`) — the
|
||||
**reverse** direction. RF links are frequently asymmetric, so the best reverse relay can
|
||||
be a poor forward relay. Worse, the next reverse ACK immediately re-learns the same bad
|
||||
hop, so the route **flaps** back to the bad value even after a failure reset.
|
||||
|
||||
### 4. Congestion amplification
|
||||
|
||||
Collision-driven impostor rebroadcasts (issue 1) add airtime; the contention window
|
||||
grows with channel utilization, so retransmit intervals **lengthen** exactly when the
|
||||
mesh is busy. The 3-try reliable budget can then expire before delivery. On dense
|
||||
meshes, efficiency _is_ reliability.
|
||||
|
||||
### Note: pubkey-derived node numbers (develop / 2.8) — does not change the plan
|
||||
|
||||
develop derives the node number from the public key:
|
||||
`my_node_num = crc32Buffer(public_key)` (`src/mesh/NodeDB.cpp:481`), re-derived on key
|
||||
change in `createNewIdentity()` (`src/mesh/NodeDB.cpp:3113`). This **reinforces** the
|
||||
plan rather than changing it:
|
||||
|
||||
- **Birthday problem unchanged and now textbook-exact.** CRC32 mixes well → the last
|
||||
byte is uniformly distributed over 256 values. Derivation adds no wire bits.
|
||||
- **Node numbers are now immutable / identity-bound.** Pre-2.8 `pickNewNodeNum()` could
|
||||
renumber a node to dodge a conflict; now the number is fixed by the key, so a last-byte
|
||||
collision **cannot be resolved operationally by renumbering** → M1/M2/M3 become _more_
|
||||
necessary.
|
||||
- **Resolver gets cleaner inputs.** Stable node numbers keep a learned byte bound to one
|
||||
identity (good for M3 freshness). `createNewIdentity()` retires the old entry by marking
|
||||
it **ignored** and clearing its pubkey (`src/mesh/NodeDB.cpp:3123-3125`), which M1's
|
||||
candidate gate already skips — so key rotation can't pollute resolution.
|
||||
- **No wire-free disambiguation unlocked.** A receiver still gets only 1 byte and cannot
|
||||
recover which full node number a colliding value meant — so "detect ambiguity → flood"
|
||||
remains the correct strategy.
|
||||
|
||||
---
|
||||
|
||||
## Proposed mitigations
|
||||
|
||||
Key insight for all of M1/M2: **a 1-byte ID only needs to be unique among a node's
|
||||
direct neighbors / plausible relays, not the whole mesh.** That candidate set is small
|
||||
(typically 5–15), so a byte usually resolves unambiguously there; when it doesn't, fall
|
||||
back to the _safe_ behavior (flood / decrement / don't-learn).
|
||||
|
||||
### M1 — Ambiguity-aware last-byte resolution (new NodeDB primitive)
|
||||
|
||||
New types + methods in `src/mesh/NodeDB.h` (near line 255) / `src/mesh/NodeDB.cpp`
|
||||
(near `getMeshNode`, ~2936):
|
||||
|
||||
```cpp
|
||||
enum class LastByteResolution : uint8_t { None, Unique, Ambiguous };
|
||||
struct ResolvedNode { LastByteResolution status = LastByteResolution::None; NodeNum num = 0; };
|
||||
|
||||
// Resolve a single on-wire last-byte to a unique full NodeNum among relevant candidates.
|
||||
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
|
||||
// Convenience: true iff exactly one relevant candidate (Ambiguous and None both -> false = SAFE).
|
||||
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
|
||||
```
|
||||
|
||||
- **One linear pass** over `meshNodes`, reusing `getNumMeshNodes()`/`getMeshNodeByIndex()`,
|
||||
the bitfield helpers (`nodeInfoLiteIsFavorite/HasUser/IsIgnored`), `sinceLastSeen()`,
|
||||
and `getLastByteOfNodeNum()`. **Early-exit** on the 2nd match (return `Ambiguous`).
|
||||
- **Guard:** `if (lastByte == 0) return {None, 0};` (covers `NO_RELAY_NODE` / MQTT-invalid).
|
||||
- **Candidate gate** (skip): `num == getNodeNum()` (never resolve to ourselves), `num == 0`,
|
||||
`num == NODENUM_BROADCAST`, `nodeInfoLiteIsIgnored`. Then match
|
||||
`getLastByteOfNodeNum(node->num) == lastByte` (cheapest test last, mirroring `Router.cpp:119`).
|
||||
- **Relevance gate:**
|
||||
- `requireDirectNeighbor == true` (strict, for SEND): `has_hops_away && hops_away == 0`
|
||||
**and** `sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS`.
|
||||
- `requireDirectNeighbor == false` (lenient, for learn / hop-preserve): accept if direct
|
||||
neighbor **or** `nodeInfoLiteIsFavorite` **or** role ∈ {ROUTER, ROUTER_LATE, CLIENT_BASE}.
|
||||
- **No tie-break.** A collision must return `Ambiguous` — picking "best SNR" would
|
||||
resurrect the silent-misroute bug. (Deliberate non-goal; document in code.)
|
||||
|
||||
New constant in `src/mesh/MeshTypes.h` (near line 44):
|
||||
`#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2)` (mirrors `NUM_ONLINE_SECS`).
|
||||
|
||||
### M2 — Only route on bytes that resolve to a unique, reachable neighbor
|
||||
|
||||
In `getNextHop` (`src/mesh/NextHopRouter.cpp:192-207`), after the existing split-horizon
|
||||
check (`node->next_hop != relay_node`), require the stored byte to resolve to a **unique,
|
||||
currently-fresh direct neighbor**; else flood:
|
||||
|
||||
```cpp
|
||||
if (node->next_hop != relay_node) {
|
||||
ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
|
||||
if (r.status == LastByteResolution::Unique) return node->next_hop;
|
||||
LOG_WARN("Next hop 0x%x for 0x%x %s -> flood", node->next_hop, to,
|
||||
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "no longer a neighbor");
|
||||
return std::nullopt;
|
||||
}
|
||||
```
|
||||
|
||||
This self-heals when a neighbor goes away (unicast-into-a-void becomes a flood). It
|
||||
applies to originating, relaying, and retrying, since all route through `getNextHop`.
|
||||
|
||||
Apply M1's safe fallback at the other sites:
|
||||
|
||||
- **Learning** (`NextHopRouter.cpp:111-114`): gate `origTx->next_hop = p->relay_node` on
|
||||
`resolveUniqueLastByte(p->relay_node, /*direct=*/false)`. Ambiguous/unknown → don't
|
||||
learn (leave route unset → flood).
|
||||
- **Favorite-router preservation** (`Router.cpp:120-145`): replace the "first match wins"
|
||||
loop with `resolveUniqueLastByte(p->relay_node, /*direct=*/false)` + a re-check that the
|
||||
resolved node is favorite/has_user/router. Ambiguous/none/not-favorite → **decrement**
|
||||
(safe). Net: removes one full DB scan, adds one resolver scan (wash).
|
||||
|
||||
**Left unchanged, by design (document why in code):**
|
||||
|
||||
- **Site 1** rebroadcast self-check (`NextHopRouter.cpp:147`) and self-identity checks
|
||||
(`ReliableRouter.cpp:127`): a node matches its **own** byte — no DB resolution helps. A
|
||||
remote impostor sharing the intended next-hop's byte will still rebroadcast. M1/M2
|
||||
shrink the blast radius by reducing how often an ambiguous byte is ever stored or
|
||||
originated; a true fix needs a wider field (out of scope). **This is the one residual
|
||||
the plan cannot fully close.**
|
||||
- **Site 3** `wasRelayer`/`checkRelayers` (`PacketHistory.cpp:490-538`): intentionally
|
||||
byte-domain (both sides are on-wire bytes); the consumer (learning) is now hardened.
|
||||
Add a one-line comment; do not change.
|
||||
|
||||
### M3 — Route freshness / failure memory (RAM table on NextHopRouter)
|
||||
|
||||
A bounded, LRU-evicted table keyed by destination, mirroring `PacketHistory`'s
|
||||
reuse-oldest discipline (not an unbounded map) to cap RAM.
|
||||
|
||||
`src/mesh/NextHopRouter.h` (near `pending`, line 99):
|
||||
|
||||
```cpp
|
||||
struct RouteHealth {
|
||||
NodeNum dest = 0; // 0 == empty slot
|
||||
uint32_t learnedAtMsec = 0; // millis() at last (re)learn; rollover-aware
|
||||
uint8_t consecutiveFailures = 0;
|
||||
uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; // byte this health refers to
|
||||
};
|
||||
static constexpr uint8_t ROUTE_HEALTH_MAX = 32; // ~384B; drop to 16 if RAM-tight
|
||||
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
|
||||
// Helpers take `now` (pure/testable): findRouteHealth, getOrAllocRouteHealth,
|
||||
// noteRouteLearned, noteRouteSuccess, noteRouteFailure, isRouteStale, clearRouteHealth
|
||||
```
|
||||
|
||||
Policy:
|
||||
|
||||
| Constant | Value | Rationale |
|
||||
| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ROUTE_TTL_MSEC` | 30 min | Survives a normal conversation; re-discovers a moved node within a telemetry interval. |
|
||||
| `ROUTE_FAILURE_THRESHOLD` | 3 | 1–2 consecutive failures are transient LoRa collisions; 3 to the same hop = dead. Accumulates **across** DMs (independent of the per-DM 3-try budget). |
|
||||
|
||||
`isRouteStale(h, now)` = `(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC || h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD`.
|
||||
All age math uses **unsigned subtraction** (rollover-safe, matching
|
||||
`PacketHistory.cpp:364`); treat `learnedAtMsec == 0` as "set now".
|
||||
|
||||
Wiring (as built — `src/mesh/NextHopRouter.cpp`, `src/mesh/ReliableRouter.cpp`):
|
||||
|
||||
- `getNextHop`: if a health record matches the stored byte and `isRouteStale`, clear
|
||||
`node->next_hop` (NodeDB) **and** `clearRouteHealth`, return `nullopt` (flood). No
|
||||
record yet (cold path, first DM after boot) → trust NodeDB, but the M2 strict-neighbor
|
||||
gate still applies.
|
||||
- `sniffReceived` learn: gate the write through `resolveUniqueLastByte` (M2), then
|
||||
`noteRouteLearned(p->from, p->relay_node, millis())` — resets `consecutiveFailures`
|
||||
**only if the hop changed** (anti-flap for asymmetric re-learn); otherwise just refreshes
|
||||
`learnedAtMsec`. (No success signal is taken on the intermediate reverse-pass: an ACK
|
||||
merely passing through us is not proof that _we_ delivered, and resetting failures there
|
||||
would reintroduce the asymmetric flap.)
|
||||
- `doRetransmissions`: on the last-retransmission branch (`numRetransmissions == 1`, the
|
||||
point a directed delivery has gone un-ACKed for both originator and intermediate) →
|
||||
`noteRouteFailure(to)`, then the existing NodeDB `next_hop` reset + flood. We deliberately
|
||||
do **not** `clearRouteHealth` here: keeping the record is what lets the failure count
|
||||
accumulate across DMs so a flapping reverse-path-relearned dead hop eventually ages out.
|
||||
- `ReliableRouter::sniffReceived` ACK path → `noteRouteSuccess(getFrom(p), millis())`
|
||||
(an end-to-end ACK addressed to us is genuine forward-delivery proof; clears failures and
|
||||
refreshes freshness). `noteRouteSuccess`/`noteRouteFailure` are no-ops when no record
|
||||
exists, so flood-only destinations never pollute the table.
|
||||
|
||||
**Reconciliation (no double-handling):** `doRetransmissions` owns _in-flight_ failure of
|
||||
the current DM (reset NodeDB `next_hop` + flood, and bump the cross-DM failure counter);
|
||||
`getNextHop` owns _between-DM_ staleness (TTL or failure-threshold → flood + clear). The
|
||||
only place that erases a health record is the `getNextHop` decay path; the retransmission
|
||||
path leaves it intact so the counter survives a reverse-path re-learn.
|
||||
|
||||
### M4 — Earlier flood for unverified routes (gated, off by default)
|
||||
|
||||
Compile-gated so healthy sparse meshes are untouched. **Default is off** — the define
|
||||
lives in `NextHopRouter.h` and must be flipped to measure:
|
||||
`#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 1`.
|
||||
|
||||
In `doRetransmissions`, the directed-retry `else` branch: if the route is **not verified**
|
||||
(`!findRouteHealth(to) || consecutiveFailures > 0 || isRouteStale`), reset `next_hop` and
|
||||
flood on this attempt instead of spending another directed try. A **verified** route
|
||||
(record present, `consecutiveFailures == 0`, within TTL — i.e. recently ACKed) takes the
|
||||
unchanged directed-retry path, so the sparse-mesh happy path is untouched. Trade-off:
|
||||
airtime ↔ latency; the gate ensures we never pay the flood cost on a proven route, only on
|
||||
one we already distrust. Off by default precisely so it can be A/B-measured on the
|
||||
simulator before broad enable.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/mesh/MeshTypes.h` | `NEXTHOP_NEIGHBOR_FRESH_SECS`, `ROUTE_TTL_MSEC`, `ROUTE_FAILURE_THRESHOLD`, `NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED` |
|
||||
| `src/mesh/NodeDB.h` / `src/mesh/NodeDB.cpp` | `LastByteResolution`, `ResolvedNode`, `resolveLastByte`, `resolveUniqueLastByte` |
|
||||
| `src/mesh/NextHopRouter.h` | `RouteHealth` + array + helpers; `#ifdef PIO_UNIT_TESTING public:` for helpers and `getNextHop` |
|
||||
| `src/mesh/NextHopRouter.cpp` | `getNextHop` (M2 gate + M3 decay); `sniffReceived` (learn gate + health seed + success); `doRetransmissions` (failure counting + M4); comment site 1 |
|
||||
| `src/mesh/Router.cpp` | `shouldDecrementHopLimit` → resolver + favorite/router re-check |
|
||||
| `src/mesh/ReliableRouter.cpp` | ACK path → `noteRouteSuccess` |
|
||||
| `test/test_nexthop_routing/test_main.cpp` | **new** unit suite (auto-built under `[env:native]`) |
|
||||
|
||||
**Reuse, don't reinvent:** `getLastByteOfNodeNum`, `sinceLastSeen`, the bitfield helpers,
|
||||
`getMeshNodeByIndex`/`getNumMeshNodes`, PacketHistory's reuse-oldest eviction shape, and
|
||||
`MockNodeDB::addTestNode` (from `test/test_hop_scaling/test_main.cpp`).
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **`0x00`↔`0xFF` projection:** the resolver compares via `getLastByteOfNodeNum` on both
|
||||
sides, so a `…00` node and a `…FF` node correctly collide on `0xFF` → `Ambiguous`. Test
|
||||
explicitly.
|
||||
- **MQTT packets:** `relay_node`/`next_hop` are forced invalid when `hop_start == 0`
|
||||
(`src/mesh/RadioLibInterface.cpp:603-605`) → byte 0 → resolver `None` → don't learn
|
||||
(correct).
|
||||
- **`has_hops_away == false`** nodes are excluded from the strict gate (never fabricate a
|
||||
Unique neighbor for M2); admitted to the lenient gate only via favorite/router role.
|
||||
Safe; self-corrects once `hops_away` is learned.
|
||||
- **Self / broadcast:** the resolver skips `getNodeNum()` and `NODENUM_BROADCAST`;
|
||||
`getNextHop` already early-returns for broadcast.
|
||||
- **Perf:** M2 adds one O(N) resolver scan per directed send/relay (early-exit on the 2nd
|
||||
match), cheaper than the crypto already on that path; site-4 is a wash. If ever hot, a
|
||||
future 256-entry last-byte index is the optimization (not now — RAM).
|
||||
|
||||
---
|
||||
|
||||
## Verification (all tiers)
|
||||
|
||||
### 1. Native unit tests — new `test/test_nexthop_routing/test_main.cpp`
|
||||
|
||||
`pio test -e native -f test_nexthop_routing`; on macOS `./bin/test-native-docker.sh -f test_nexthop_routing`.
|
||||
Design the RouteHealth helpers to take `now` as a parameter so the 30-min TTL logic is
|
||||
testable without a clock mock.
|
||||
|
||||
- **Resolver:** None / Unique / **Ambiguous (birthday collision)** / strict-excludes-stale /
|
||||
strict-excludes-far / lenient-includes-favorite-router / lenient-collision / skips-self /
|
||||
skips-ignored / **`0x00`↔`0xFF` collision** / early-exit.
|
||||
- **`getNextHop`:** unique→byte, **ambiguous→nullopt**, stale-neighbor→nullopt,
|
||||
split-horizon (relay==next_hop)→nullopt, broadcast→nullopt.
|
||||
- **RouteHealth:** TTL boundary, **rollover** (learn near `0xFFFFFFFF`, check after wrap),
|
||||
failure threshold, success-resets, **re-learn-same-hop keeps fails (anti-flap)**,
|
||||
re-learn-new-hop resets, LRU eviction bound, clear.
|
||||
- **Site-4:** preserve on unique favorite router; **decrement on two colliding favorites**;
|
||||
decrement when the resolved node is not a favorite.
|
||||
- **Sparse-mesh regression:** all-distinct last bytes → every resolve Unique, `getNextHop`
|
||||
returns the stored byte unchanged (proves no happy-path change).
|
||||
- Re-run `test_packet_history` and `test_hop_scaling` for no regression.
|
||||
|
||||
### 2. portduino SimRadio simulator
|
||||
|
||||
`pio run -e native && ./bin/test-simulator.sh`. Best vehicle for the **intermediate-node**
|
||||
path the 2-device bench can't reach. Line topology A — B — C: establish A→C (B learns a
|
||||
directed route), stop B relaying that dest, confirm A re-discovers via flood within
|
||||
`ROUTE_FAILURE_THRESHOLD` and that B's `noteRouteFailure`/`clearRouteHealth` fires (visible
|
||||
via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this to A/B M4
|
||||
(attempts-to-delivery, total airtime).
|
||||
|
||||
### 3. Hardware via meshtastic MCP (auto-detect; 3+ devices for a real hop)
|
||||
|
||||
- `mcp-server/tests/mesh/test_nexthop_multihop_recovery.py` — **the multi-hop validator
|
||||
for this work** (added on this branch). Self-discovers an A — relay — C line, asserts a
|
||||
directed DM is delivered across the relay (next_hop + M1/M2/M3 engaged), and asserts
|
||||
delivery recovers after the relay is power-cycled (M3). Skips unless the bench is a true
|
||||
multi-hop line (≥3 roles via `--hub-profile`, endpoints out of direct RF range).
|
||||
- `mcp-server/tests/mesh/test_direct_with_ack.py` — happy-path regression: a fresh/unique
|
||||
route still delivers a want_ack DM on the first/second try (M4's gate must keep this
|
||||
green).
|
||||
- `mcp-server/tests/mesh/test_peer_offline_recovery.py` — 2-device recovery validator: peer
|
||||
off mid-conversation then back. Must stay green and ideally recover in fewer attempts.
|
||||
|
||||
### 4. Build / format sanity
|
||||
|
||||
native-macos **and** Docker both ways; trunk clang-format@16.0.3; a release `pio run` to
|
||||
confirm the `#ifdef PIO_UNIT_TESTING` visibility widening does **not** leak into
|
||||
production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B table.
|
||||
|
||||
---
|
||||
|
||||
## Verification status (as built on `nexthop-redux`)
|
||||
|
||||
| Tier | What ran | Result |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------- | ------------------- |
|
||||
| Unit (native-macos) | `test_nexthop_routing` (31 cases) | ✅ 31/31 |
|
||||
| Unit (Docker / Linux, CI parity) | `test_nexthop_routing` | ✅ 31/31 |
|
||||
| Regression | `test_packet_history`, `test_hop_scaling`, `test_mqtt`, `test_traffic_management` | ✅ 105/105 |
|
||||
| Build | `pio run -e native-macos` (M4 off) and with `-DNEXTHOP_EARLY_FLOOD_ON_UNVERIFIED=1` | ✅ both link |
|
||||
| Format | trunk `clang-format@16.0.3` | ✅ no issues |
|
||||
| Simulator (CI `simulator-tests`) | `meshtasticd -s` + `meshtastic.test.testSimulator()` on native-macos | ✅ exit 0, no crash |
|
||||
|
||||
**Pending (environment-blocked, not yet run):**
|
||||
|
||||
- **Multi-hop A–B–C recovery sim** — the `simulator/` broker hub is **not git-tracked**
|
||||
(only stale local `.pyc`), and two `meshtasticd -s` instances can't hear each other
|
||||
without it. The intermediate-node failure-count path and the M4 A/B therefore have unit
|
||||
coverage of their logic but no end-to-end multi-node run yet.
|
||||
- **Hardware / multi-hop tier** — a committable bench test now exists:
|
||||
`mcp-server/tests/mesh/test_nexthop_multihop_recovery.py`. It self-discovers a real
|
||||
multi-hop pair (A — relay — C), asserts a directed DM is delivered across the relay, and
|
||||
asserts delivery recovers after the relay is power-cycled (the M3 path). It
|
||||
`pytest.skip`s cleanly unless the bench is a true line with endpoints out of direct RF
|
||||
range (≥3 roles via `--hub-profile`), so it's safe to commit and only asserts when the
|
||||
NextHop path is genuinely exercised. Collected + verified to skip without hardware;
|
||||
not yet run on a bench. `test_direct_with_ack.py` / `test_peer_offline_recovery.py`
|
||||
remain the 2-device happy-path/recovery regressions.
|
||||
|
||||
---
|
||||
|
||||
## Risks & limitations
|
||||
|
||||
- **Site-1 impostor rebroadcast** is unfixable without a wider field — documented; M1/M2
|
||||
only shrink its frequency.
|
||||
- **Dense meshes flood DMs more often** — intended (a flooded DM arrives; a mis-unicast one
|
||||
black-holes). Call out in the PR so reviewers expect a slightly higher DM flood rate on
|
||||
very dense meshes.
|
||||
- **M4 airtime** if the gate is too loose → default conservative + compile-gated +
|
||||
simulator A/B before broad enable.
|
||||
- **RAM** ~384 B (32 slots); 16 slots (~192 B) with graceful LRU degradation if tight.
|
||||
- **Asymmetric flap** not fully closed (a _new_ bad hop resets the counter); the TTL
|
||||
backstop bounds it. Per-hop failure history is future work (more RAM).
|
||||
|
||||
---
|
||||
|
||||
## How to continue this work (commit sequencing)
|
||||
|
||||
Each step is independently testable; land them as separate commits.
|
||||
|
||||
1. **M1 resolver + unit tests** — `NodeDB` only; no behavior change until wired. Lands the
|
||||
`resolveLastByte`/`resolveUniqueLastByte` primitive and its full unit-test matrix.
|
||||
2. **M2 + wiring + tests** — `getNextHop` strict gate, learning gate, favorite-router
|
||||
preservation rewrite. Adds the `getNextHop` and site-4 tests.
|
||||
3. **M3 health table + decay + tests** — RAM `RouteHealth` table, decay-on-read, failure/
|
||||
success accounting, reconciliation with the existing last-retry reset. Adds the
|
||||
route-health unit tests and the simulator recovery check.
|
||||
4. **M4 gated tuning** — early-flood-on-unverified behind the compile flag; simulator A/B
|
||||
and hardware regression.
|
||||
|
||||
Reference plan (with the same content) was developed at
|
||||
`~/.claude/plans/nexthop-routing-for-direct-lexical-shell.md` on the author's machine; this
|
||||
in-repo doc is the canonical handoff copy.
|
||||
@@ -70,6 +70,17 @@ def esp32_create_combined_bin(source, target, env):
|
||||
|
||||
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", esp32_create_combined_bin)
|
||||
|
||||
# Enable Newlib Nano formatting to save space
|
||||
# ...but allow printf float support (compromise)
|
||||
env.Append(LINKFLAGS=["--specs=nano.specs", "-u", "_printf_float"])
|
||||
esp32_kind = env.GetProjectOption("custom_esp32_kind")
|
||||
if esp32_kind == "esp32":
|
||||
# Free up some IRAM by removing auxiliary SPI flash chip drivers.
|
||||
# Wrapped stub symbols are defined in src/platform/esp32/iram-quirk.c.
|
||||
env.Append(
|
||||
LINKFLAGS=[
|
||||
"-Wl,--wrap=esp_flash_chip_gd",
|
||||
"-Wl,--wrap=esp_flash_chip_issi",
|
||||
"-Wl,--wrap=esp_flash_chip_winbond",
|
||||
]
|
||||
)
|
||||
else:
|
||||
# For newer ESP32 targets, using newlib nano works better.
|
||||
env.Append(LINKFLAGS=["--specs=nano.specs", "-u", "_printf_float"])
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# trunk-ignore-all(ruff/F821)
|
||||
# trunk-ignore-all(flake8/F821): For SConstruct imports
|
||||
|
||||
# force linker response file instead of command line arguments
|
||||
|
||||
Import("env")
|
||||
|
||||
|
||||
def wrap_with_tempfile(command_key):
|
||||
command = env.get(command_key)
|
||||
if not command or not isinstance(command, str):
|
||||
return
|
||||
if "TEMPFILE(" in command:
|
||||
return
|
||||
env.Replace(**{command_key: "${TEMPFILE('%s')}" % command})
|
||||
|
||||
|
||||
# Force SCons to spill long commands into response files on this target.
|
||||
env.Replace(MAXLINELENGTH=8192)
|
||||
|
||||
for key in ("LINKCOM", "CXXLINKCOM", "SHLINKCOM", "SHCXXLINKCOM"):
|
||||
wrap_with_tempfile(key)
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# trunk-ignore-all(ruff/F821)
|
||||
# trunk-ignore-all(flake8/F821)
|
||||
#
|
||||
# Whole-image LTO for nrf52840 (~-60KB; ~-23KB beyond src-only LTO), EXCEPT the objects
|
||||
# that own interrupt/exception handlers.
|
||||
#
|
||||
# Every ISR is referenced only from the assembly vector table (gcc_startup_nrf52840.S),
|
||||
# which LTO cannot see -> whole-program LTO judges the handlers dead, removes them, and
|
||||
# the weak `b .` Default_Handler stubs prevail -> the IRQ lands in an infinite loop and the
|
||||
# chip hangs (or the peripheral silently stalls). Compiling the handler-bearing objects
|
||||
# WITHOUT LTO lets ordinary linking keep the strong handlers; everything else stays LTO'd:
|
||||
# - framework core (/FrameworkArduino/, /cores/nRF5/): every nrfx ISR + the FreeRTOS
|
||||
# SVC/PendSV port.
|
||||
# - TinyUSB nrf port (Adafruit_TinyUSB_nrf.cpp): USBD_IRQHandler (USB data path).
|
||||
# - library .cpp files that own a vector ISR (would otherwise be silently dropped):
|
||||
# bluefruit.cpp -> SD_EVT/SWI2_EGU2 (SoftDevice BLE-event delivery -- advertising
|
||||
# hangs without it)
|
||||
# Wire_nRF52.cpp -> SPIM0/TWIM0 + SPIM1/TWIM1 (interrupt-driven I2C/SPI)
|
||||
# PDM.cpp -> PDM_IRQHandler (PDM microphone)
|
||||
# RotaryEncoder.cpp -> QDEC_IRQHandler (hardware quadrature/rotary encoder)
|
||||
#
|
||||
# A post-link guard (bottom of this file) fails the build if a critical handler was dropped
|
||||
# anyway -- so a future deps bump or a new ISR-owning library becomes a red build, not a field
|
||||
# hang. To hunt a dropped ISR by hand: nm the .elf for `_IRQHandler$` symbols marked `W`, then
|
||||
# grep the libs/framework for who defines them.
|
||||
#
|
||||
# HW-validated: RAK4631 (SX1262) + muzi-base (LR1121).
|
||||
import glob
|
||||
import os
|
||||
|
||||
Import("env")
|
||||
|
||||
env.Append(LINKFLAGS=["-flto", "-flto-partition=1to1"])
|
||||
|
||||
# The -fno-lto re-compiles below run with the global env, which lacks the framework's
|
||||
# bundled-library include dirs -- and those libs cross-include each other (Wire pulls in
|
||||
# Adafruit_TinyUSB.h, which pulls in SPI.h, ...). Add every bundled-lib dir (+ its src/) so
|
||||
# the re-compiles resolve without chasing headers one at a time.
|
||||
_fw = env.PioPlatform().get_package_dir("framework-arduinoadafruitnrf52") or ""
|
||||
_extra_inc = []
|
||||
for _d in sorted(glob.glob(os.path.join(_fw, "libraries", "*"))):
|
||||
if os.path.isdir(_d):
|
||||
_extra_inc.append(_d)
|
||||
if os.path.isdir(os.path.join(_d, "src")):
|
||||
_extra_inc.append(os.path.join(_d, "src"))
|
||||
|
||||
FRAMEWORK = ("/FrameworkArduino/", "/cores/nRF5/")
|
||||
USB_ISR = "Adafruit_TinyUSB_nrf" # USBD_IRQHandler
|
||||
# Library .cpp files that define vector-table ISRs (the rest of their lib stays LTO'd):
|
||||
LIB_ISR = ("/bluefruit.cpp", "/Wire_nRF52.cpp", "/PDM.cpp", "/RotaryEncoder.cpp")
|
||||
|
||||
|
||||
def _no_lto(node):
|
||||
try:
|
||||
path = node.get_abspath()
|
||||
except Exception:
|
||||
path = str(node)
|
||||
path = path.replace(
|
||||
"\\", "/"
|
||||
) # normalize Windows backslashes so matches work cross-platform
|
||||
if (
|
||||
USB_ISR in path
|
||||
or any(s in path for s in FRAMEWORK)
|
||||
or any(s in path for s in LIB_ISR)
|
||||
):
|
||||
return env.Object(
|
||||
node,
|
||||
CCFLAGS=env["CCFLAGS"] + ["-fno-lto"],
|
||||
CPPPATH=env["CPPPATH"] + _extra_inc,
|
||||
)
|
||||
return node
|
||||
|
||||
|
||||
env.AddBuildMiddleware(_no_lto)
|
||||
|
||||
|
||||
# --- post-link guard: catch a dropped ISR handler at build time (CI footgun protection) ----
|
||||
# After every link, fail the build if one of these critical vector-table handlers resolved to
|
||||
# the weak `b .` Default_Handler stub -- i.e. LTO (or a deps bump, or a new ISR-owning library
|
||||
# that nobody added to LIB_ISR) silently dropped it. A dropped handler hangs the chip the
|
||||
# instant that IRQ fires; this turns a field hang into a red build. CI builds every nrf52840
|
||||
# target, so this runs on every PR automatically. If a board deliberately stops using one of
|
||||
# these, edit the tuples on purpose.
|
||||
_REQUIRED_STRONG = (
|
||||
"SWI2_EGU2_IRQHandler", # SoftDevice BLE event (SD_EVT) -- advertising & connections
|
||||
"GPIOTE_IRQHandler", # GPIO interrupts: radio DIO + buttons
|
||||
"RTC1_IRQHandler", # FreeRTOS scheduler tick
|
||||
)
|
||||
# Owned by the TinyUSB stack, so only required when the board builds with USB at all.
|
||||
# Boards without native USB wiring (e.g. wio-sdk-wm1110's CH340 UART) strip TinyUSB via
|
||||
# disable_adafruit_usb.py / unflagging USE_TINYUSB, leaving these legitimately weak.
|
||||
_REQUIRED_STRONG_USB = (
|
||||
"USBD_IRQHandler", # USB CDC (serial console + 1200bps DFU trigger)
|
||||
"POWER_CLOCK_IRQHandler", # USB power events (VBUS detect/ready) via TinyUSB hal
|
||||
)
|
||||
|
||||
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
|
||||
_NM = os.path.join(_tc, "bin", "arm-none-eabi-nm")
|
||||
if not os.path.isfile(_NM):
|
||||
_NM = "arm-none-eabi-nm" # fall back to PATH
|
||||
|
||||
|
||||
def _assert_isr_handlers_survived(source, target, env):
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
# Resolve the ELF at build time; target[0] is the buildprog alias, not the file.
|
||||
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
|
||||
out = subprocess.check_output([_NM, elf], universal_newlines=True)
|
||||
except Exception as exc: # tooling hiccup: warn loudly, don't wedge the build
|
||||
print("nrf52_lto: WARNING - ISR-handler guard skipped (nm failed: %s)" % exc)
|
||||
return
|
||||
# nm line: "<addr> <type> <symbol>". type 'T'/'t' = strong (good); 'W'/'w' = weak stub.
|
||||
kind = {}
|
||||
for line in out.split("\n"):
|
||||
f = line.split()
|
||||
if len(f) >= 3 and f[-1].endswith("_IRQHandler"):
|
||||
kind[f[-1]] = f[-2]
|
||||
required = list(_REQUIRED_STRONG)
|
||||
defines = [
|
||||
str(d[0] if isinstance(d, tuple) else d) for d in env.get("CPPDEFINES", [])
|
||||
]
|
||||
if "USE_TINYUSB" in defines:
|
||||
required += _REQUIRED_STRONG_USB
|
||||
dropped = [h for h in required if kind.get(h, "W").upper() != "T"]
|
||||
if dropped:
|
||||
sys.stderr.write(
|
||||
"\n*** nrf52 LTO guard: interrupt handler(s) DROPPED: %s ***\n"
|
||||
"Each resolved to the weak Default_Handler stub, so the chip hangs when that IRQ\n"
|
||||
"fires. Compile the .cpp that defines the handler with -fno-lto by adding it to\n"
|
||||
"LIB_ISR in extra_scripts/nrf52_lto.py. Find the owner of FOO_IRQHandler with:\n"
|
||||
" grep -rl FOO_IRQHandler <framework-arduinoadafruitnrf52>/{libraries,cores}\n\n"
|
||||
% ", ".join(dropped)
|
||||
)
|
||||
from SCons.Script import Exit
|
||||
|
||||
Exit(1) # canonical SCons build-abort -> red build
|
||||
print(
|
||||
"nrf52_lto: ISR-handler guard OK -- %d critical handlers strong" % len(required)
|
||||
)
|
||||
|
||||
|
||||
# Attach to the phony "buildprog" alias, NOT the .elf file node: SCons can skip a post-action
|
||||
# on a file target during an incremental relink (observed), but the buildprog alias runs every
|
||||
# build -- so the guard fires on local incremental rebuilds and clean CI builds alike.
|
||||
env.AddPostAction("buildprog", _assert_isr_handlers_survived)
|
||||
@@ -1,70 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# trunk-ignore-all(ruff/F821)
|
||||
# trunk-ignore-all(flake8/F821): For SConstruct imports
|
||||
#
|
||||
# Post-link guard for the warm-node-store raw-flash region on nRF52840.
|
||||
#
|
||||
# The 3 app-region pages below LittleFS (0xEA000-0xED000, reclaimed by whole-image
|
||||
# LTO) are reserved for the WarmNodeStore record-ring (see WarmNodeStore.h). Our
|
||||
# linker scripts (nrf52840_s140_v6.ld and nrf52840_s140_v7.ld) cap the image at
|
||||
# 0xEA000, but boards on the framework-default script (FLASH ending at 0xED000) could
|
||||
# silently place code in those pages — the first warm-store save would then brick the
|
||||
# device. This turns that into a build failure.
|
||||
#
|
||||
# Image flash end = __etext + sizeof(.data) (loaded at LMA __etext); symbols from
|
||||
# the framework's nrf52_common.ld.
|
||||
import os
|
||||
|
||||
Import("env")
|
||||
|
||||
WARM_REGION_BASE = 0xEA000 # keep in sync with WARM_FLASH_REGION_BASE in WarmNodeStore.h (3 x 4 KB record-ring)
|
||||
|
||||
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
|
||||
_NM = os.path.join(_tc, "bin", "arm-none-eabi-nm")
|
||||
if not os.path.isfile(_NM):
|
||||
_NM = "arm-none-eabi-nm" # fall back to PATH
|
||||
|
||||
|
||||
def _assert_warm_region_clear(source, target, env):
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
|
||||
out = subprocess.check_output([_NM, elf], universal_newlines=True)
|
||||
except Exception as exc: # tooling hiccup: warn loudly, don't wedge the build
|
||||
print("nrf52_warm_region: WARNING - guard skipped (nm failed: %s)" % exc)
|
||||
return
|
||||
|
||||
syms = {}
|
||||
for line in out.split("\n"):
|
||||
f = line.split()
|
||||
if len(f) >= 3 and f[-1] in ("__etext", "__data_start__", "__data_end__"):
|
||||
syms[f[-1]] = int(f[0], 16)
|
||||
if len(syms) != 3:
|
||||
print("nrf52_warm_region: WARNING - guard skipped (linker symbols not found)")
|
||||
return
|
||||
|
||||
flash_end = syms["__etext"] + (syms["__data_end__"] - syms["__data_start__"])
|
||||
if flash_end > WARM_REGION_BASE:
|
||||
sys.stderr.write(
|
||||
"\n*** nrf52 warm-region guard: image ends at 0x%X, past the reserved "
|
||||
"warm-store region at 0x%X ***\n"
|
||||
"The 12 KB region at 0xEA000 holds the WarmNodeStore record-ring; a warm-store\n"
|
||||
"save would overwrite this firmware's tail. Shrink the image, or shrink/move\n"
|
||||
"the region (WARM_FLASH_REGION_BASE in src/mesh/WarmNodeStore.h, the FLASH\n"
|
||||
"LENGTH in src/platform/nrf52/nrf52840_s140_v6.ld and _v7.ld, and this guard).\n\n"
|
||||
% (flash_end, WARM_REGION_BASE)
|
||||
)
|
||||
from SCons.Script import Exit
|
||||
|
||||
Exit(1)
|
||||
print(
|
||||
"nrf52_warm_region: guard OK -- image ends at 0x%X, %d KB clear of the warm region"
|
||||
% (flash_end, (WARM_REGION_BASE - flash_end) // 1024)
|
||||
)
|
||||
|
||||
|
||||
# Attach to the phony "buildprog" alias (not the .elf node) so the guard runs
|
||||
# on incremental relinks too -- same reasoning as nrf52_lto.py's guard.
|
||||
env.AddPostAction("buildprog", _assert_warm_region_clear)
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# trunk-ignore-all(ruff/F821)
|
||||
# trunk-ignore-all(flake8/F821): For SConstruct imports
|
||||
#
|
||||
# post:extra_scripts/nrf54l15_linker.py
|
||||
#
|
||||
# Fix for Zephyr two-pass link on nRF54L15:
|
||||
# platformio-build.py registers env.Depends("$PROG_PATH", final_ld_script) but
|
||||
# the SCons dependency chain is broken (final_ld_script Command never runs).
|
||||
# This script adds a PreAction on the final firmware binary that runs the gcc
|
||||
# preprocessing command directly (extracted from build.ninja) to generate
|
||||
# zephyr/linker.cmd before the link step.
|
||||
#
|
||||
# PlatformIO bundles an old Ninja that can't handle multi-output depslog rules,
|
||||
# so we parse the COMMAND line from build.ninja and run just the gcc -E part,
|
||||
# skipping the cmake_transform_depfile step (only needed for Ninja deps tracking).
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
Import("env")
|
||||
|
||||
if env.get("PIOENV") != "nrf54l15dk":
|
||||
pass # Only for the nrf54l15dk environment
|
||||
else:
|
||||
|
||||
def _extract_gcc_command(ninja_build):
|
||||
"""Parse build.ninja to find the gcc -E command that generates linker.cmd.
|
||||
|
||||
The rule format depends on the host:
|
||||
Windows (CMake's RunCMake wraps every command):
|
||||
COMMAND = cmd.exe /C "cd /D DIR && arm-none-eabi-gcc.exe ... -o linker.cmd && cmake.exe -E cmake_transform_depfile ..."
|
||||
POSIX (Linux/macOS — no wrapper):
|
||||
COMMAND = cd DIR && arm-none-eabi-gcc ... -o linker.cmd && cmake -E cmake_transform_depfile ...
|
||||
|
||||
Returns (gcc_cmd_string, cwd_path) or raises RuntimeError.
|
||||
"""
|
||||
in_rule = False
|
||||
with open(ninja_build, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
# Detect start of the linker.cmd custom command rule
|
||||
if not in_rule:
|
||||
if "build zephyr/linker.cmd" in line and "CUSTOM_COMMAND" in line:
|
||||
in_rule = True
|
||||
continue
|
||||
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("COMMAND = "):
|
||||
continue
|
||||
|
||||
command_val = stripped[len("COMMAND = ") :]
|
||||
|
||||
# On Windows the value is wrapped in `cmd.exe /C "..."` — strip
|
||||
# the wrapper. On POSIX hosts the inner sequence is the value
|
||||
# itself (no quoting layer).
|
||||
m = re.search(r'/C\s+"(.*)"\s*$', command_val)
|
||||
inner = m.group(1) if m else command_val
|
||||
parts = inner.split(" && ")
|
||||
|
||||
cwd = None
|
||||
gcc_cmd = None
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if part.startswith("cd /D "): # Windows form
|
||||
cwd = part[len("cd /D ") :]
|
||||
elif part.startswith("cd "): # POSIX form
|
||||
cwd = part[len("cd ") :]
|
||||
elif "arm-none-eabi-gcc" in part:
|
||||
gcc_cmd = part
|
||||
|
||||
if not gcc_cmd:
|
||||
raise RuntimeError(
|
||||
"nRF54L15 linker fix: arm-none-eabi-gcc command not found in:\n%s"
|
||||
% inner[:400]
|
||||
)
|
||||
|
||||
return gcc_cmd, cwd
|
||||
|
||||
raise RuntimeError(
|
||||
"nRF54L15 linker fix: 'build zephyr/linker.cmd' rule not found in build.ninja"
|
||||
)
|
||||
|
||||
def _generate_linker_cmd(target, source, env):
|
||||
"""Generate zephyr/linker.cmd via direct gcc invocation before the final link."""
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
zephyr_dir = os.path.join(build_dir, "zephyr")
|
||||
linker_cmd = os.path.join(zephyr_dir, "linker.cmd")
|
||||
|
||||
if os.path.exists(linker_cmd):
|
||||
return # Already present — nothing to do
|
||||
|
||||
ninja_build = os.path.join(build_dir, "build.ninja")
|
||||
if not os.path.exists(ninja_build):
|
||||
raise RuntimeError(
|
||||
"nRF54L15 linker fix: build.ninja not found at %s\n"
|
||||
"Run a full build first so CMake generates the Ninja files."
|
||||
% ninja_build
|
||||
)
|
||||
|
||||
gcc_cmd, cwd = _extract_gcc_command(ninja_build)
|
||||
run_cwd = cwd if cwd else zephyr_dir
|
||||
|
||||
print(
|
||||
"==> nRF54L15: Generating zephyr/linker.cmd (LINKER_ZEPHYR_FINAL) via GCC"
|
||||
)
|
||||
# gcc_cmd comes verbatim from our own build.ninja (never user input) and
|
||||
# contains Windows-style paths with spaces that cannot be safely argv-split
|
||||
# with shlex, so we run it via the platform shell. nosec/nosemgrep below
|
||||
# acknowledge this deliberate, scoped use of shell=True.
|
||||
result = subprocess.run( # nosec B602
|
||||
gcc_cmd,
|
||||
shell=True, # nosemgrep: python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
|
||||
cwd=run_cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("GCC stdout:", result.stdout[:2000])
|
||||
print("GCC stderr:", result.stderr[:2000])
|
||||
raise RuntimeError(
|
||||
"nRF54L15 linker fix: GCC failed to generate linker.cmd (rc=%d)"
|
||||
% result.returncode
|
||||
)
|
||||
if not os.path.exists(linker_cmd):
|
||||
raise RuntimeError(
|
||||
"nRF54L15 linker fix: GCC returned 0 but linker.cmd was not created at %s"
|
||||
% linker_cmd
|
||||
)
|
||||
print("==> linker.cmd generated successfully")
|
||||
|
||||
# Use PIOMAINPROG (set by ZephyrBuildProgram) to get the exact SCons node
|
||||
prog = env.get("PIOMAINPROG")
|
||||
if prog:
|
||||
env.AddPreAction(prog, _generate_linker_cmd)
|
||||
else:
|
||||
print(
|
||||
"[nrf54l15_linker] WARNING: PIOMAINPROG not set, falling back to $PROG_PATH"
|
||||
)
|
||||
env.AddPreAction(env.subst("$PROG_PATH"), _generate_linker_cmd)
|
||||
@@ -191,12 +191,10 @@ echo
|
||||
# PASS/FAIL — every hardware test would SKIP with "role not present". We
|
||||
# narrow to tests/unit explicitly so the summary reads as "no hardware,
|
||||
# unit suite only" instead of "big skip count looks suspicious".
|
||||
# Keep terminal output condensed (`-q -r fE`) so skip-heavy runs do not print
|
||||
# each skipped test in full; skip counts still appear in pytest's summary.
|
||||
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 -q -r fE --report-log=tests/reportlog.jsonl
|
||||
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
|
||||
@@ -212,13 +210,11 @@ fi
|
||||
# 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.
|
||||
# Defaults also use condensed reporting (`-q -r fE`) to avoid listing every
|
||||
# skipped test verbatim while still surfacing failures/errors and summary data.
|
||||
if [[ $# -eq 0 ]]; then
|
||||
set -- tests/ \
|
||||
--html=tests/report.html --self-contained-html \
|
||||
--junitxml=tests/junit.xml \
|
||||
-q -r fE --tb=short
|
||||
-v --tb=short
|
||||
fi
|
||||
|
||||
# UI tier requires opencv-python-headless (and ideally easyocr). If it's
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
"""Fake NodeDB fixture push — Portduino file copy + hardware XModem upload.
|
||||
|
||||
The fixture pipeline is two-stage:
|
||||
1. `bin/gen-fake-nodedb-seed.py` produces a deterministic JSONL describing N
|
||||
fake-but-realistic peers. Committed under `test/fixtures/nodedb/`.
|
||||
2. `bin/seed-json-to-proto.py` compiles JSONL → binary v25 NodeDatabase
|
||||
protobuf with fresh wall-clock timestamps.
|
||||
|
||||
This module exposes `push_fake_nodedb(...)`, the MCP tool that:
|
||||
- target="portduino": compiles the JSONL into the device's prefs dir on
|
||||
the local filesystem (`~/.portduino/<config>/prefs/nodes.proto`).
|
||||
- target="hardware": compiles to a temp file, then streams it over the
|
||||
XModem protocol (via the meshtastic SerialInterface/BLEInterface +
|
||||
`meshtastic.xmodempacket` pubsub topic) to `/prefs/nodes.proto` on the
|
||||
device. Triggers a reboot so the firmware loads the new state on next
|
||||
boot.
|
||||
|
||||
XModem wire details (mirrors firmware impl at src/xmodem.cpp:115-260):
|
||||
* 128-byte chunks; final chunk padded to 128 B with 0x1A (SUB) bytes.
|
||||
* CRC16-CCITT (poly 0x1021, init 0x0000).
|
||||
* SOH/seq=0 carries the destination filename in `buffer.bytes`. ACK if
|
||||
`FSCom.open(filename, FILE_O_WRITE)` succeeds; NAK otherwise.
|
||||
* SOH/seq≥1 carries a 128-byte chunk. ACK = advance; NAK = retransmit.
|
||||
* EOT after the last chunk flushes + closes the file on-device.
|
||||
|
||||
Hardware push requires `confirm=True` (mirrors factory_reset / erase_and_flash
|
||||
in the .github/copilot-instructions.md "never do these without asking" list).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import pathlib
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
|
||||
from .connection import connect, is_tcp_port
|
||||
|
||||
# Resolve repo root so the tool works regardless of mcp-server cwd.
|
||||
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
_SEED_DIR = _REPO_ROOT / "test" / "fixtures" / "nodedb"
|
||||
_COMPILE_SCRIPT = _REPO_ROOT / "bin" / "seed-json-to-proto.py"
|
||||
|
||||
_DEFAULT_NODES_FILENAME = "/prefs/nodes.proto"
|
||||
_XMODEM_CHUNK = 128
|
||||
_XMODEM_SUB = 0x1A
|
||||
_ACK_TIMEOUT_INIT_S = 5.0
|
||||
_ACK_TIMEOUT_CHUNK_S = 2.0
|
||||
_MAX_CHUNK_RETRIES = 5
|
||||
|
||||
_VALID_SIZES = (250, 500, 1000, 2000)
|
||||
|
||||
|
||||
class FixtureError(RuntimeError):
|
||||
"""Raised for any fixture-push failure (compile, transport, ack timeout, …)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRC16-CCITT (poly 0x1021, init 0x0000). Matches the firmware's `crc16_ccitt`.
|
||||
# Hand-rolled to avoid the optional `crcmod` dep.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _crc16_ccitt(data: bytes, *, init: int = 0x0000) -> int:
|
||||
crc = init
|
||||
for b in data:
|
||||
crc ^= b << 8
|
||||
for _ in range(8):
|
||||
if crc & 0x8000:
|
||||
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
|
||||
else:
|
||||
crc = (crc << 1) & 0xFFFF
|
||||
return crc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compile step — shells out to bin/seed-json-to-proto.py so the MCP module
|
||||
# doesn't have to duplicate the proto-encoding logic.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _compile_proto(jsonl_path: pathlib.Path, out_path: pathlib.Path) -> None:
|
||||
if not _COMPILE_SCRIPT.is_file():
|
||||
raise FixtureError(f"compile script missing at {_COMPILE_SCRIPT}")
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(_COMPILE_SCRIPT),
|
||||
"--in",
|
||||
str(jsonl_path),
|
||||
"--out",
|
||||
str(out_path),
|
||||
]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise FixtureError(
|
||||
f"seed-json-to-proto.py failed (exit {exc.returncode}):\n"
|
||||
f" stdout: {exc.stdout}\n stderr: {exc.stderr}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _resolve_seed_jsonl(size: int, custom: str | None) -> pathlib.Path:
|
||||
if custom is not None:
|
||||
p = pathlib.Path(custom).expanduser().resolve()
|
||||
if not p.is_file():
|
||||
raise FixtureError(f"custom_seed_jsonl not found: {p}")
|
||||
return p
|
||||
p = _SEED_DIR / f"seed_v25_{size:04d}.jsonl"
|
||||
if not p.is_file():
|
||||
raise FixtureError(
|
||||
f"missing committed seed at {p}. "
|
||||
f"Run `./bin/regen-fake-nodedbs.sh` to generate it."
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portduino push — file copy into ~/.portduino/<config>/prefs/
|
||||
# ---------------------------------------------------------------------------
|
||||
def _portduino_prefs_dir(config_name: str) -> pathlib.Path:
|
||||
home = pathlib.Path.home()
|
||||
return home / ".portduino" / config_name / "prefs"
|
||||
|
||||
|
||||
def _push_portduino(
|
||||
size: int,
|
||||
jsonl: pathlib.Path,
|
||||
portduino_config: str,
|
||||
backup_existing: bool,
|
||||
) -> dict[str, Any]:
|
||||
prefs = _portduino_prefs_dir(portduino_config)
|
||||
prefs.mkdir(parents=True, exist_ok=True)
|
||||
target = prefs / "nodes.proto"
|
||||
backed_up_to: str | None = None
|
||||
if backup_existing and target.is_file():
|
||||
ts = int(time.time())
|
||||
backup = prefs / f"nodes.proto.bak.{ts}"
|
||||
shutil.move(str(target), str(backup))
|
||||
backed_up_to = str(backup)
|
||||
_compile_proto(jsonl, target)
|
||||
raw = target.read_bytes()
|
||||
return {
|
||||
"transport": "portduino",
|
||||
"path": str(target),
|
||||
"bytes": len(raw),
|
||||
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"jsonl_source": str(jsonl),
|
||||
"backed_up_to": backed_up_to,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardware push — XModem over BLE/serial via the meshtastic Python interface.
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclasses.dataclass
|
||||
class _AckEvent:
|
||||
control: int
|
||||
seq: int
|
||||
|
||||
|
||||
def _wait_for_response(q: "queue.Queue[_AckEvent]", timeout_s: float) -> _AckEvent:
|
||||
try:
|
||||
return q.get(timeout=timeout_s)
|
||||
except queue.Empty as exc:
|
||||
raise FixtureError(
|
||||
f"XModem response timeout after {timeout_s:.1f}s — device not responding"
|
||||
) from exc
|
||||
|
||||
|
||||
def _push_hardware(
|
||||
size: int,
|
||||
jsonl: pathlib.Path,
|
||||
port: str | None,
|
||||
reboot_after: bool,
|
||||
) -> dict[str, Any]:
|
||||
# Lazy imports so the module loads even when the meshtastic deps aren't
|
||||
# available (e.g. CI in a Python env without the package installed).
|
||||
try:
|
||||
from meshtastic.protobuf import mesh_pb2, xmodem_pb2
|
||||
from pubsub import pub
|
||||
except ImportError as exc: # pragma: no cover — dep missing
|
||||
raise FixtureError(
|
||||
f"hardware push requires the meshtastic + pypubsub packages: {exc}"
|
||||
) from exc
|
||||
|
||||
if is_tcp_port(port):
|
||||
raise FixtureError(
|
||||
"hardware push over TCP/portduino is not supported — use "
|
||||
"target='portduino' to drop the fixture directly into the prefs dir."
|
||||
)
|
||||
|
||||
# Compile the fixture to a temp file with fresh timestamps.
|
||||
with tempfile.NamedTemporaryFile(suffix=".proto", delete=False) as tf:
|
||||
proto_path = pathlib.Path(tf.name)
|
||||
try:
|
||||
_compile_proto(jsonl, proto_path)
|
||||
payload = proto_path.read_bytes()
|
||||
finally:
|
||||
proto_path.unlink(missing_ok=True)
|
||||
|
||||
sha256 = hashlib.sha256(payload).hexdigest()
|
||||
total_bytes = len(payload)
|
||||
|
||||
# Subscribe to XModem responses BEFORE we open the interface, so we don't
|
||||
# race the first ACK that arrives during the SOH/seq=0 handshake.
|
||||
#
|
||||
# NB: the signature MUST declare every kwarg pypubsub will see for this
|
||||
# topic, or pubsub locks the topic spec to a smaller set (whichever
|
||||
# subscribe arrives first) and then *rejects* the meshtastic library's
|
||||
# publish call with `SenderUnknownMsgDataError: unknown ... interface`.
|
||||
# The meshtastic lib publishes both `packet=` and `interface=`
|
||||
# (mesh_interface.py:1389-1395), so both must appear here.
|
||||
response_q: "queue.Queue[_AckEvent]" = queue.Queue()
|
||||
|
||||
def _on_xmodem(packet: Any = None, interface: Any = None, **_kw: Any) -> None:
|
||||
if packet is None:
|
||||
return
|
||||
response_q.put(_AckEvent(control=int(packet.control), seq=int(packet.seq)))
|
||||
|
||||
pub.subscribe(_on_xmodem, "meshtastic.xmodempacket")
|
||||
|
||||
chunks_sent = 0
|
||||
retried = 0
|
||||
rebooted = False
|
||||
|
||||
XMC = xmodem_pb2.XModem.Control
|
||||
try:
|
||||
with connect(port=port) as iface:
|
||||
# 1) Send the filename (SOH, seq=0).
|
||||
init_pkt = xmodem_pb2.XModem(
|
||||
control=XMC.Value("SOH"),
|
||||
seq=0,
|
||||
buffer=_DEFAULT_NODES_FILENAME.encode("utf-8"),
|
||||
)
|
||||
iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=init_pkt))
|
||||
ack = _wait_for_response(response_q, _ACK_TIMEOUT_INIT_S)
|
||||
if ack.control != XMC.Value("ACK"):
|
||||
raise FixtureError(
|
||||
f"device refused filename {_DEFAULT_NODES_FILENAME!r} "
|
||||
f"(got control={ack.control}, expected ACK). "
|
||||
f"Filesystem full or permissions issue?"
|
||||
)
|
||||
|
||||
# 2) Stream the payload in 128 B chunks.
|
||||
for offset in range(0, total_bytes, _XMODEM_CHUNK):
|
||||
chunk = payload[offset : offset + _XMODEM_CHUNK]
|
||||
if len(chunk) < _XMODEM_CHUNK:
|
||||
# Pad final chunk to 128 B with SUB. The trailing 0x1A bytes
|
||||
# become part of the file on-device, but nanopb ignores
|
||||
# bytes past the end of the top-level message.
|
||||
chunk = chunk + bytes([_XMODEM_SUB] * (_XMODEM_CHUNK - len(chunk)))
|
||||
seq = ((offset // _XMODEM_CHUNK) + 1) % 256
|
||||
# Retry loop on NAK / timeout.
|
||||
attempts = 0
|
||||
while True:
|
||||
pkt = xmodem_pb2.XModem(
|
||||
control=XMC.Value("SOH"),
|
||||
seq=seq,
|
||||
buffer=chunk,
|
||||
crc16=_crc16_ccitt(chunk),
|
||||
)
|
||||
iface._sendToRadio(mesh_pb2.ToRadio(xmodemPacket=pkt))
|
||||
ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S)
|
||||
if ack.control == XMC.Value("ACK"):
|
||||
chunks_sent += 1
|
||||
break
|
||||
if ack.control == XMC.Value("NAK"):
|
||||
attempts += 1
|
||||
retried += 1
|
||||
if attempts >= _MAX_CHUNK_RETRIES:
|
||||
# Abort: send CAN so the firmware removes the half-
|
||||
# written file via FSCom.remove(filename).
|
||||
iface._sendToRadio(
|
||||
mesh_pb2.ToRadio(
|
||||
xmodemPacket=xmodem_pb2.XModem(
|
||||
control=XMC.Value("CAN")
|
||||
)
|
||||
)
|
||||
)
|
||||
raise FixtureError(
|
||||
f"chunk seq={seq} NAK'd {attempts} times; "
|
||||
f"aborted transfer (file removed on-device)."
|
||||
)
|
||||
continue # retry the same chunk
|
||||
raise FixtureError(
|
||||
f"unexpected XModem control={ack.control} on seq={seq}"
|
||||
)
|
||||
|
||||
# 3) Tell the device we're done.
|
||||
iface._sendToRadio(
|
||||
mesh_pb2.ToRadio(
|
||||
xmodemPacket=xmodem_pb2.XModem(control=XMC.Value("EOT"))
|
||||
)
|
||||
)
|
||||
ack = _wait_for_response(response_q, _ACK_TIMEOUT_CHUNK_S)
|
||||
if ack.control != XMC.Value("ACK"):
|
||||
raise FixtureError(f"EOT not ACKed (got control={ack.control})")
|
||||
|
||||
# 4) Reboot so loadFromDisk picks up the new file.
|
||||
if reboot_after:
|
||||
iface.localNode.reboot(secs=1)
|
||||
rebooted = True
|
||||
finally:
|
||||
try:
|
||||
pub.unsubscribe(_on_xmodem, "meshtastic.xmodempacket")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"transport": "hardware",
|
||||
"port": port,
|
||||
"filename_on_device": _DEFAULT_NODES_FILENAME,
|
||||
"bytes": total_bytes,
|
||||
"chunks_sent": chunks_sent,
|
||||
"retried": retried,
|
||||
"sha256": sha256,
|
||||
"jsonl_source": str(jsonl),
|
||||
"rebooted": rebooted,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point — registered as an MCP tool in server.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
def push_fake_nodedb(
|
||||
size: int,
|
||||
target: Literal["portduino", "hardware"] = "portduino",
|
||||
*,
|
||||
port: str | None = None,
|
||||
portduino_config: str = "default",
|
||||
backup_existing: bool = True,
|
||||
confirm: bool = False,
|
||||
reboot_after: bool = True,
|
||||
custom_seed_jsonl: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compile a fresh-timestamp NodeDatabase fixture and push it to a device.
|
||||
|
||||
Args:
|
||||
size: 250, 500, 1000, or 2000 — selects which committed seed JSONL to use.
|
||||
target: "portduino" (file copy to ~/.portduino/<config>/prefs/) or
|
||||
"hardware" (XModem upload to /prefs/nodes.proto + reboot).
|
||||
port: required for target="hardware". Serial path (e.g. /dev/cu.usbmodemXXXX)
|
||||
or BLE identifier. TCP endpoints are rejected — use target="portduino"
|
||||
instead.
|
||||
portduino_config: which Portduino instance dir under ~/.portduino/. Default "default".
|
||||
backup_existing: portduino only. Move nodes.proto -> nodes.proto.bak.<ts>
|
||||
if present, so you can roll back.
|
||||
confirm: required True for target="hardware" (writes flash + reboots).
|
||||
reboot_after: hardware only. If True, send a 1-second reboot after the
|
||||
final ACK so loadFromDisk picks up the new file at next boot.
|
||||
custom_seed_jsonl: override the committed JSONL. Use to push a hand-edited
|
||||
test scenario.
|
||||
|
||||
Returns:
|
||||
dict with transport, bytes, sha256, etc. — depends on target.
|
||||
|
||||
"""
|
||||
if size not in _VALID_SIZES:
|
||||
raise FixtureError(
|
||||
f"size must be one of {_VALID_SIZES}; got {size!r}. "
|
||||
f"Add a new committed seed if you need a different cardinality."
|
||||
)
|
||||
|
||||
jsonl = _resolve_seed_jsonl(size, custom_seed_jsonl)
|
||||
|
||||
if target == "portduino":
|
||||
return _push_portduino(size, jsonl, portduino_config, backup_existing)
|
||||
|
||||
if target == "hardware":
|
||||
if not confirm:
|
||||
raise FixtureError(
|
||||
"hardware push writes flash and triggers a reboot — pass confirm=True."
|
||||
)
|
||||
if not port:
|
||||
raise FixtureError(
|
||||
"target='hardware' requires a port (e.g. /dev/cu.usbmodemXXXX)."
|
||||
)
|
||||
return _push_hardware(size, jsonl, port, reboot_after)
|
||||
|
||||
raise FixtureError(f"unknown target {target!r}; expected 'portduino' or 'hardware'")
|
||||
@@ -15,7 +15,6 @@ from . import (
|
||||
admin,
|
||||
boards,
|
||||
devices,
|
||||
fixtures,
|
||||
flash,
|
||||
hw_tools,
|
||||
info,
|
||||
@@ -964,45 +963,3 @@ def recorder_export(
|
||||
dest_dir=dest_dir,
|
||||
streams=streams,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Fixture / test-data push --------------------------------------
|
||||
|
||||
|
||||
@app.tool()
|
||||
def push_fake_nodedb(
|
||||
size: int,
|
||||
target: str = "portduino",
|
||||
port: str | None = None,
|
||||
portduino_config: str = "default",
|
||||
backup_existing: bool = True,
|
||||
confirm: bool = False,
|
||||
reboot_after: bool = True,
|
||||
custom_seed_jsonl: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Push a fake-NodeDB v25 fixture (250/500/1000/2000 nodes) onto a device.
|
||||
|
||||
Two transports:
|
||||
target="portduino" — file copy to ~/.portduino/<portduino_config>/prefs/nodes.proto.
|
||||
Fast, no device connection needed.
|
||||
target="hardware" — XModem upload over serial/BLE to /prefs/nodes.proto.
|
||||
Requires `port` + `confirm=True`. Triggers a reboot
|
||||
so loadFromDisk picks up the new file at next boot.
|
||||
|
||||
Compiles a fresh-timestamp proto from the committed JSONL seed under
|
||||
test/fixtures/nodedb/seed_v25_<N>.jsonl each invocation, so the loaded
|
||||
NodeDB always looks "recent" to the connecting phone. Structural data
|
||||
(names, IDs, positions, telemetries) is deterministic per the seed.
|
||||
|
||||
Override the JSONL via `custom_seed_jsonl` to push a hand-edited scenario.
|
||||
"""
|
||||
return fixtures.push_fake_nodedb(
|
||||
size=size,
|
||||
target=target, # type: ignore[arg-type]
|
||||
port=port,
|
||||
portduino_config=portduino_config,
|
||||
backup_existing=backup_existing,
|
||||
confirm=confirm,
|
||||
reboot_after=reboot_after,
|
||||
custom_seed_jsonl=custom_seed_jsonl,
|
||||
)
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
"""Multi-hop NextHop directed-message delivery + relay-recovery (bench test).
|
||||
|
||||
This is the hardware/tier-3 validator for the NextHop DM reliability work
|
||||
(see `docs/nexthop-routing-reliability.md`). The unit suite
|
||||
`test/test_nexthop_routing` covers the routing *logic* exhaustively; this test
|
||||
covers the *end-to-end* multi-hop behavior that only a real (or RF-separated)
|
||||
mesh exercises:
|
||||
|
||||
* a directed DM that must traverse a relay is delivered (next_hop routing +
|
||||
the M1/M2 ambiguity gate + M3 route learning all engage), and
|
||||
* when the established relay drops and returns, delivery recovers rather than
|
||||
black-holing (the M3 stale-route decay / re-learn path).
|
||||
|
||||
TOPOLOGY REQUIREMENT — why this usually SKIPS:
|
||||
A NextHop relay only happens when the two endpoints are NOT direct neighbors.
|
||||
Three co-located radios all hear each other, so A→C is a single direct hop and
|
||||
next_hop never engages. To run this test the bench must be a *line* — A — B — C
|
||||
— with the endpoints out of each other's direct RF range (physical distance or
|
||||
attenuators). The `multihop_topology` fixture detects this automatically: it
|
||||
warms the mesh, looks for a pair that is ≥1 hop apart, confirms the relay via
|
||||
traceroute, and `pytest.skip`s cleanly when the bench is all-direct. So this
|
||||
file is safe to commit and run anywhere — it only *asserts* when the topology
|
||||
genuinely requires a relay.
|
||||
|
||||
REQUIREMENTS:
|
||||
* ≥3 baked devices. The default hub profile is 2 roles (nrf52, esp32s3); add a
|
||||
third via `--hub-profile=path/to/hub.yaml` (see conftest `hub_profile`).
|
||||
* The relay-recovery test additionally needs uhubctl + a power-controllable
|
||||
relay port (same gate the other power tests use).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.connection import connect
|
||||
from tests import _power
|
||||
from tests._port_discovery import resolve_port_by_role
|
||||
|
||||
from ._receive import ReceiveCollector, nudge_nodeinfo, nudge_nodeinfo_port
|
||||
|
||||
|
||||
def _hops_away(rec: dict[str, Any]) -> int | None:
|
||||
"""Read a node's hop distance from a `nodesByNum` entry, tolerating either
|
||||
the camelCase (`hopsAway`) or snake_case (`hops_away`) spelling depending on
|
||||
the meshtastic-python version."""
|
||||
for key in ("hopsAway", "hops_away"):
|
||||
val = rec.get(key)
|
||||
if isinstance(val, int):
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _warm_mesh(ports: list[str], rounds: int = 2, settle: float = 6.0) -> None:
|
||||
"""Flood a fresh NodeInfo from every node so the whole mesh (including
|
||||
multi-hop pairs, reached via relayed broadcasts) populates pubkeys and hop
|
||||
distances. Best-effort — a single node failing to nudge shouldn't abort."""
|
||||
for _ in range(rounds):
|
||||
for port in ports:
|
||||
try:
|
||||
nudge_nodeinfo_port(port)
|
||||
except Exception: # noqa: BLE001 — warmup is best-effort
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
time.sleep(settle)
|
||||
|
||||
|
||||
def _wait_for_pubkey(
|
||||
tx_iface: Any, rx_num: int, rx_port: str, deadline_s: float = 90.0
|
||||
) -> bool:
|
||||
"""Block until `tx_iface` holds `rx_num`'s public key (directed PKI sends
|
||||
NAK without it). Re-nudges both sides periodically; multi-hop warmup is
|
||||
slower than the 2-device case because NodeInfo must be relayed, hence the
|
||||
longer default deadline."""
|
||||
deadline = time.monotonic() + deadline_s
|
||||
last_nudge = time.monotonic()
|
||||
while time.monotonic() < deadline:
|
||||
rec = (tx_iface.nodesByNum or {}).get(rx_num, {})
|
||||
if rec.get("user", {}).get("publicKey"):
|
||||
return True
|
||||
if time.monotonic() - last_nudge > 20.0:
|
||||
nudge_nodeinfo_port(rx_port)
|
||||
nudge_nodeinfo(tx_iface)
|
||||
last_nudge = time.monotonic()
|
||||
time.sleep(1.0)
|
||||
return False
|
||||
|
||||
|
||||
def _traceroute_route(tx_port: str, rx_num: int, rx_port: str) -> list[int] | None:
|
||||
"""Run a traceroute TX→RX and return the forward `route` (list of relay node
|
||||
numbers), or None if it couldn't be obtained. Mirrors test_traceroute's
|
||||
request/PKI/retry pattern."""
|
||||
from meshtastic.mesh_interface import MeshInterface
|
||||
|
||||
with ReceiveCollector(tx_port, topic="meshtastic.receive.traceroute") as tx:
|
||||
nudge_nodeinfo_port(rx_port)
|
||||
tx.broadcast_nodeinfo_ping()
|
||||
if not _wait_for_pubkey(tx._iface, rx_num, rx_port, 60.0):
|
||||
return None
|
||||
for _attempt in range(2):
|
||||
try:
|
||||
tx._iface.sendTraceRoute(dest=rx_num, hopLimit=5)
|
||||
break
|
||||
except MeshInterface.MeshInterfaceError:
|
||||
time.sleep(5.0)
|
||||
else:
|
||||
return None
|
||||
pkt = tx.wait_for(lambda p: p.get("from") == rx_num, timeout=8.0)
|
||||
if pkt is None:
|
||||
return None
|
||||
tr = (pkt.get("decoded", {}) or {}).get("traceroute") or {}
|
||||
return [int(n) for n in (tr.get("route") or [])]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Discover a real multi-hop pier (tx → relay → rx) on the bench, or skip.
|
||||
|
||||
Returns {tx_role, tx_port, rx_role, rx_port, rx_num, relay_role, relay_num}.
|
||||
"""
|
||||
roles = sorted(baked_mesh)
|
||||
if len(roles) < 3:
|
||||
pytest.skip(
|
||||
"multi-hop NextHop test needs ≥3 baked devices arranged as a line "
|
||||
"(endpoints out of direct RF range). Add a third role via "
|
||||
f"--hub-profile. Detected roles: {roles}"
|
||||
)
|
||||
|
||||
by_role = {r: (baked_mesh[r]["port"], baked_mesh[r]["my_node_num"]) for r in roles}
|
||||
if any(num is None for _, num in by_role.values()):
|
||||
pytest.skip("a baked device is missing my_node_num; can't map the topology")
|
||||
|
||||
_warm_mesh([port for port, _ in by_role.values()])
|
||||
|
||||
# Find an ordered pair that is ≥1 hop apart, using each node's own nodeDB
|
||||
# (cheap — no traceroute yet). On an all-direct bench nothing qualifies.
|
||||
multihop_pair: tuple[str, str] | None = None
|
||||
for a_role in roles:
|
||||
a_port, _ = by_role[a_role]
|
||||
try:
|
||||
with connect(port=a_port) as a_iface:
|
||||
nodes = a_iface.nodesByNum or {}
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for c_role in roles:
|
||||
if c_role == a_role:
|
||||
continue
|
||||
_, c_num = by_role[c_role]
|
||||
hops = _hops_away(nodes.get(c_num, {}))
|
||||
if hops is not None and hops >= 1:
|
||||
multihop_pair = (a_role, c_role)
|
||||
break
|
||||
if multihop_pair:
|
||||
break
|
||||
|
||||
if not multihop_pair:
|
||||
pytest.skip(
|
||||
"no multi-hop pair found — every device appears to be a direct "
|
||||
"neighbor. Arrange the bench as a line (A — B — C) with the "
|
||||
"endpoints out of direct RF range (distance or attenuators) so a "
|
||||
"relay is actually required, then re-run."
|
||||
)
|
||||
|
||||
a_role, c_role = multihop_pair
|
||||
a_port, _ = by_role[a_role]
|
||||
c_port, c_num = by_role[c_role]
|
||||
|
||||
route = _traceroute_route(a_port, c_num, c_port)
|
||||
if not route:
|
||||
pytest.skip(
|
||||
f"{a_role}→{c_role} looked multi-hop but traceroute returned no "
|
||||
"intermediate relay; can't identify the relay node to drive the "
|
||||
"recovery test"
|
||||
)
|
||||
|
||||
relay_num = route[0]
|
||||
relay_role = next((r for r in roles if by_role[r][1] == relay_num), None)
|
||||
return {
|
||||
"tx_role": a_role,
|
||||
"tx_port": a_port,
|
||||
"rx_role": c_role,
|
||||
"rx_port": c_port,
|
||||
"rx_num": c_num,
|
||||
"relay_role": relay_role,
|
||||
"relay_num": relay_num,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.timeout(300)
|
||||
def test_multihop_dm_delivers(multihop_topology: dict[str, Any]) -> None:
|
||||
"""A directed wantAck DM that must traverse the relay is delivered.
|
||||
|
||||
Exercises the NextHop routing path end-to-end: TX picks a next hop toward
|
||||
RX (M2 gate), the relay resolves the next_hop byte and forwards (M1), and
|
||||
the route is learned from the returning ACK (M3). Retries absorb transient
|
||||
LoRa loss; the assertion is on eventual delivery.
|
||||
"""
|
||||
tx_port = multihop_topology["tx_port"]
|
||||
rx_port = multihop_topology["rx_port"]
|
||||
rx_num = multihop_topology["rx_num"]
|
||||
tx_role = multihop_topology["tx_role"]
|
||||
rx_role = multihop_topology["rx_role"]
|
||||
relay_role = multihop_topology["relay_role"]
|
||||
|
||||
unique = f"nexthop-mh-{tx_role}-to-{rx_role}-{int(time.time())}"
|
||||
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
|
||||
pytest.skip(
|
||||
f"{tx_role} never learned {rx_role}'s pubkey over the relay; "
|
||||
"multi-hop PKI warmup didn't complete"
|
||||
)
|
||||
got = None
|
||||
for _attempt in range(3):
|
||||
pkt = tx_iface.sendText(unique, destinationId=rx_num, wantAck=True)
|
||||
assert pkt is not None
|
||||
got = rx.wait_for(
|
||||
lambda p: p.get("decoded", {}).get("text") == unique,
|
||||
timeout=45,
|
||||
)
|
||||
if got is not None:
|
||||
break
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
nudge_nodeinfo(tx_iface)
|
||||
time.sleep(5.0)
|
||||
|
||||
assert got is not None, (
|
||||
f"multi-hop directed DM {tx_role}→{rx_role} via relay "
|
||||
f"{relay_role!r} never landed — NextHop multi-hop delivery is broken"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(600)
|
||||
def test_multihop_relay_recovery(
|
||||
multihop_topology: dict[str, Any],
|
||||
power_cycle, # noqa: ARG001 — forces the uhubctl-availability skip
|
||||
) -> None:
|
||||
"""Delivery recovers after the established relay drops and returns.
|
||||
|
||||
Establishes a baseline DM (route via relay learned), powers the relay OFF
|
||||
(confirming TX survives sending across a downed relay), then powers it back
|
||||
ON and asserts directed delivery resumes — the M3 stale-route decay /
|
||||
re-learn path. With a strict A — B — C line there is no path while B is down,
|
||||
so we only assert TX doesn't crash during the outage; the delivery assertion
|
||||
is after B returns.
|
||||
"""
|
||||
relay_role = multihop_topology["relay_role"]
|
||||
if not relay_role:
|
||||
pytest.skip(
|
||||
"relay node isn't one of the baked hub roles, so it can't be "
|
||||
"power-cycled; recovery test needs a controllable relay"
|
||||
)
|
||||
|
||||
tx_port = multihop_topology["tx_port"]
|
||||
rx_port = multihop_topology["rx_port"]
|
||||
rx_num = multihop_topology["rx_num"]
|
||||
tx_role = multihop_topology["tx_role"]
|
||||
rx_role = multihop_topology["rx_role"]
|
||||
|
||||
base = f"mh-recover-base-{int(time.time())}"
|
||||
post = f"mh-recover-post-{int(time.time())}"
|
||||
|
||||
# Baseline: confirm delivery works (so the route via the relay is learned)
|
||||
# before we perturb anything — otherwise a later failure is ambiguous.
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
|
||||
pytest.skip("multi-hop PKI warmup failed; can't run recovery test")
|
||||
tx_iface.sendText(base, destinationId=rx_num, wantAck=True)
|
||||
assert (
|
||||
rx.wait_for(
|
||||
lambda p: p.get("decoded", {}).get("text") == base, timeout=45
|
||||
)
|
||||
is not None
|
||||
), "baseline multi-hop delivery failed — skipping recovery to avoid a false result"
|
||||
|
||||
# Power the relay OFF.
|
||||
try:
|
||||
_power.power_off(relay_role)
|
||||
_power.wait_for_absence(relay_role, timeout_s=15.0)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
try:
|
||||
_power.power_on(relay_role)
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
pytest.skip(f"can't power-control relay {relay_role!r}: {exc}")
|
||||
|
||||
# With the only relay down there's no path; we just confirm TX accepts the
|
||||
# send and survives its internal retries (it must not crash / wedge).
|
||||
try:
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
pkt = tx_iface.sendText(
|
||||
f"mh-while-down-{int(time.time())}",
|
||||
destinationId=rx_num,
|
||||
wantAck=True,
|
||||
)
|
||||
assert pkt is not None
|
||||
time.sleep(8.0) # let retransmissions + route decay run
|
||||
except Exception as exc: # noqa: BLE001 — restore bench state before failing
|
||||
_power.power_on(relay_role)
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
raise AssertionError(
|
||||
f"TX crashed sending across a downed relay: {exc}"
|
||||
) from exc
|
||||
|
||||
# Power the relay back ON and let it re-enumerate + boot.
|
||||
_power.power_on(relay_role)
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
except Exception: # noqa: BLE001 — relay port isn't one we connect to directly
|
||||
pass
|
||||
time.sleep(8.0)
|
||||
_warm_mesh([tx_port, rx_port], rounds=1) # re-flood so the relay re-learns
|
||||
|
||||
# Delivery should resume once the relay is back (M3 re-learn / decay path).
|
||||
got = None
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
_wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0)
|
||||
for _attempt in range(4):
|
||||
pkt = tx_iface.sendText(post, destinationId=rx_num, wantAck=True)
|
||||
assert pkt is not None
|
||||
got = rx.wait_for(
|
||||
lambda p: p.get("decoded", {}).get("text") == post,
|
||||
timeout=45,
|
||||
)
|
||||
if got is not None:
|
||||
break
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
nudge_nodeinfo(tx_iface)
|
||||
time.sleep(6.0)
|
||||
|
||||
assert got is not None, (
|
||||
f"after relay {relay_role!r} returned, multi-hop DM {tx_role}→{rx_role} "
|
||||
"never resumed — stale-route recovery (M3) may be broken"
|
||||
)
|
||||
@@ -44,7 +44,7 @@ _ESP32_ARCHES = {
|
||||
"esp32-c6",
|
||||
"esp32c6",
|
||||
}
|
||||
_NRF52_ARCHES = {"nrf52", "nrf52840"}
|
||||
_NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"}
|
||||
|
||||
|
||||
def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None:
|
||||
|
||||
@@ -1,364 +0,0 @@
|
||||
"""Tests for the fake-NodeDB fixture pipeline (bin/gen-fake-nodedb-seed.py
|
||||
+ bin/seed-json-to-proto.py + mcp-server fixtures.push_fake_nodedb).
|
||||
|
||||
Lives under tests/unit/ because none of these touch real hardware — they
|
||||
shell out to the bin/ scripts and decode the resulting protobufs in-process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
SEED_GEN = REPO_ROOT / "bin" / "gen-fake-nodedb-seed.py"
|
||||
COMPILE = REPO_ROOT / "bin" / "seed-json-to-proto.py"
|
||||
FIXTURES_DIR = REPO_ROOT / "test" / "fixtures" / "nodedb"
|
||||
|
||||
# Ensure the locally-generated Python protobuf bindings are importable.
|
||||
# These live under `meshtastic_v25` (not `meshtastic`) so they don't shadow
|
||||
# the PyPI `meshtastic` package that the rest of the mcp-server depends on.
|
||||
_BINDINGS_DIR = REPO_ROOT / "bin" / "_generated"
|
||||
if _BINDINGS_DIR.is_dir() and str(_BINDINGS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_BINDINGS_DIR))
|
||||
|
||||
try:
|
||||
from meshtastic_v25.deviceonly_pb2 import (
|
||||
NodeDatabase, # type: ignore[import-not-found]
|
||||
)
|
||||
except ImportError:
|
||||
NodeDatabase = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _require_v25_bindings() -> None:
|
||||
if NodeDatabase is None:
|
||||
pytest.skip(
|
||||
"v25 Python protobuf bindings missing; run `./bin/regen-py-protos.sh`."
|
||||
)
|
||||
if "positions" not in NodeDatabase.DESCRIPTOR.fields_by_name:
|
||||
pytest.skip(
|
||||
"Loaded NodeDatabase predates v25 — run `./bin/regen-py-protos.sh`."
|
||||
)
|
||||
|
||||
|
||||
def _run(cmd: list[str]) -> None:
|
||||
subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed generator: deterministic for given --seed (no wall-clock dependence).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_seed_generator_is_deterministic(tmp_path: pathlib.Path) -> None:
|
||||
a = tmp_path / "a.jsonl"
|
||||
b = tmp_path / "b.jsonl"
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SEED_GEN),
|
||||
"--count",
|
||||
"100",
|
||||
"--seed",
|
||||
"42",
|
||||
"--out",
|
||||
str(a),
|
||||
]
|
||||
)
|
||||
# Sleep so any sneaky wall-clock leak in the generator would surface as
|
||||
# a byte diff between the two runs.
|
||||
time.sleep(0.8)
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SEED_GEN),
|
||||
"--count",
|
||||
"100",
|
||||
"--seed",
|
||||
"42",
|
||||
"--out",
|
||||
str(b),
|
||||
]
|
||||
)
|
||||
assert a.read_bytes() == b.read_bytes()
|
||||
|
||||
|
||||
def test_seed_generator_meta_line(tmp_path: pathlib.Path) -> None:
|
||||
out = tmp_path / "seed.jsonl"
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SEED_GEN),
|
||||
"--count",
|
||||
"50",
|
||||
"--seed",
|
||||
"1",
|
||||
"--out",
|
||||
str(out),
|
||||
]
|
||||
)
|
||||
lines = out.read_text(encoding="utf-8").splitlines()
|
||||
assert len(lines) == 51 # 1 meta + 50 nodes
|
||||
meta = json.loads(lines[0])
|
||||
assert "_meta" in meta
|
||||
assert meta["_meta"]["version"] == 25
|
||||
assert meta["_meta"]["count"] == 50
|
||||
assert meta["_meta"]["seed"] == 1
|
||||
|
||||
|
||||
def test_seed_only_uses_active_hardware_and_roles(tmp_path: pathlib.Path) -> None:
|
||||
"""Confirm no deprecated roles + no off-list HW models leak through."""
|
||||
out = tmp_path / "seed.jsonl"
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SEED_GEN),
|
||||
"--count",
|
||||
"500",
|
||||
"--seed",
|
||||
"7",
|
||||
"--out",
|
||||
str(out),
|
||||
]
|
||||
)
|
||||
forbidden_roles = {"ROUTER_CLIENT", "REPEATER"}
|
||||
forbidden_hw = {
|
||||
"TLORA_V1",
|
||||
"TLORA_V2",
|
||||
"TLORA_V1_1P3",
|
||||
"TLORA_V2_1_1P6",
|
||||
"TLORA_V2_1_1P8",
|
||||
"HELTEC_V1",
|
||||
"HELTEC_V2_0",
|
||||
"HELTEC_V2_1",
|
||||
"TBEAM",
|
||||
"TBEAM_V0P7",
|
||||
"NANO_G1",
|
||||
"NANO_G1_EXPLORER",
|
||||
"NANO_G2_ULTRA",
|
||||
"STATION_G1",
|
||||
"STATION_G2",
|
||||
"PORTDUINO",
|
||||
"ANDROID_SIM",
|
||||
"DIY_V1",
|
||||
"LORA_RELAY_V1",
|
||||
"NRF52840_PCA10059",
|
||||
"NRF52_UNKNOWN",
|
||||
"DR_DEV",
|
||||
"GENIEBLOCKS",
|
||||
"M5STACK",
|
||||
"RP2040_LORA",
|
||||
"PPR",
|
||||
}
|
||||
for raw in out.read_text(encoding="utf-8").splitlines()[1:]:
|
||||
node = json.loads(raw)
|
||||
assert node["role"] not in forbidden_roles, f"deprecated role: {node['role']}"
|
||||
assert (
|
||||
node["hw_model"] not in forbidden_hw
|
||||
), f"non-tier-1 HW: {node['hw_model']}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compile step + committed seeds.
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("size", [250, 500, 1000, 2000])
|
||||
def test_committed_seed_compiles_and_decodes(size: int, tmp_path: pathlib.Path) -> None:
|
||||
_require_v25_bindings()
|
||||
proto = tmp_path / "out.proto"
|
||||
jsonl = FIXTURES_DIR / f"seed_v25_{size:04d}.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip(f"{jsonl} not present — run ./bin/regen-fake-nodedbs.sh")
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(proto)])
|
||||
|
||||
db = NodeDatabase()
|
||||
db.ParseFromString(proto.read_bytes())
|
||||
assert db.version == 25
|
||||
assert len(db.nodes) == size
|
||||
nums = {n.num for n in db.nodes}
|
||||
assert len(nums) == size, "node numbers must be unique"
|
||||
assert all(n.long_name and n.short_name for n in db.nodes)
|
||||
assert all(len(n.long_name) <= 24 for n in db.nodes) # max_size:25 - NUL
|
||||
|
||||
# Coverage sanity (±10pp tolerance for binomial fluctuation).
|
||||
def in_range(actual: int, expected_ratio: float, tol_pp: float = 0.10) -> bool:
|
||||
lo = max(0, int((expected_ratio - tol_pp) * size))
|
||||
hi = min(size, int((expected_ratio + tol_pp) * size))
|
||||
return lo <= actual <= hi
|
||||
|
||||
assert in_range(len(db.positions), 0.85)
|
||||
assert in_range(len(db.telemetry), 0.70)
|
||||
assert in_range(len(db.environment), 0.25)
|
||||
assert in_range(len(db.status), 0.40)
|
||||
|
||||
|
||||
def test_compile_freshens_timestamps(tmp_path: pathlib.Path) -> None:
|
||||
"""Same JSONL compiled twice → identical structure, different timestamps."""
|
||||
_require_v25_bindings()
|
||||
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip("250-node seed not present — run ./bin/regen-fake-nodedbs.sh")
|
||||
a = tmp_path / "a.proto"
|
||||
b = tmp_path / "b.proto"
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(a)])
|
||||
time.sleep(1.2)
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(b)])
|
||||
|
||||
da = NodeDatabase()
|
||||
db_ = NodeDatabase()
|
||||
da.ParseFromString(a.read_bytes())
|
||||
db_.ParseFromString(b.read_bytes())
|
||||
|
||||
# Zero out timestamp fields and confirm everything else is byte-identical.
|
||||
for d in (da, db_):
|
||||
for n in d.nodes:
|
||||
n.last_heard = 0
|
||||
for p in d.positions:
|
||||
p.position.time = 0
|
||||
assert da.SerializeToString() == db_.SerializeToString()
|
||||
|
||||
# Re-load fresh copies to confirm timestamps actually moved.
|
||||
aa = NodeDatabase()
|
||||
bb = NodeDatabase()
|
||||
aa.ParseFromString(a.read_bytes())
|
||||
bb.ParseFromString(b.read_bytes())
|
||||
aa_max = max(n.last_heard for n in aa.nodes if n.last_heard)
|
||||
bb_max = max(n.last_heard for n in bb.nodes if n.last_heard)
|
||||
assert bb_max >= aa_max
|
||||
assert bb_max - aa_max < 5 # within a few seconds
|
||||
|
||||
|
||||
def test_compile_pinned_now_epoch_is_byte_identical(tmp_path: pathlib.Path) -> None:
|
||||
"""With --now-epoch pinned, two compiles produce identical bytes."""
|
||||
_require_v25_bindings()
|
||||
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip("250-node seed not present")
|
||||
a = tmp_path / "a.proto"
|
||||
b = tmp_path / "b.proto"
|
||||
for o in (a, b):
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
str(COMPILE),
|
||||
"--in",
|
||||
str(jsonl),
|
||||
"--now-epoch",
|
||||
"1700000000",
|
||||
"--out",
|
||||
str(o),
|
||||
]
|
||||
)
|
||||
assert a.read_bytes() == b.read_bytes()
|
||||
|
||||
|
||||
def test_compile_timestamps_are_recent(tmp_path: pathlib.Path) -> None:
|
||||
_require_v25_bindings()
|
||||
jsonl = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||
if not jsonl.is_file():
|
||||
pytest.skip("250-node seed not present")
|
||||
out = tmp_path / "out.proto"
|
||||
_run([sys.executable, str(COMPILE), "--in", str(jsonl), "--out", str(out)])
|
||||
db = NodeDatabase()
|
||||
db.ParseFromString(out.read_bytes())
|
||||
now = int(time.time())
|
||||
# No timestamp older than 7 days, none in the future.
|
||||
for n in db.nodes:
|
||||
if n.last_heard:
|
||||
assert now - 7 * 86400 <= n.last_heard <= now
|
||||
# At least half should be within the last hour
|
||||
# (matches expovariate(mean=3600s)).
|
||||
recent = sum(1 for n in db.nodes if n.last_heard and n.last_heard >= now - 3600)
|
||||
assert recent >= 0.4 * len(db.nodes)
|
||||
|
||||
|
||||
def test_compile_hand_edit_round_trip(tmp_path: pathlib.Path) -> None:
|
||||
"""Edit one JSONL line, recompile, confirm edit appears in the proto."""
|
||||
_require_v25_bindings()
|
||||
src = FIXTURES_DIR / "seed_v25_0250.jsonl"
|
||||
if not src.is_file():
|
||||
pytest.skip("250-node seed not present")
|
||||
dst = tmp_path / "edited.jsonl"
|
||||
lines = src.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
# Find a node that already has telemetry so the index relationship is
|
||||
# easy to assert on the other side.
|
||||
edit_idx = None
|
||||
for i, raw in enumerate(lines[1:], start=1):
|
||||
node = json.loads(raw)
|
||||
if node.get("telemetry") is not None:
|
||||
edit_idx = i
|
||||
break
|
||||
assert edit_idx is not None, "expected at least one node with telemetry"
|
||||
|
||||
node = json.loads(lines[edit_idx])
|
||||
target_num = int(node["num"], 16)
|
||||
node["long_name"] = "Hand Edited Node"
|
||||
node["telemetry"] = {
|
||||
"battery_level": 42,
|
||||
"voltage": 3.71,
|
||||
"channel_utilization": 0.0,
|
||||
"air_util_tx": 0.0,
|
||||
"uptime_seconds": 1,
|
||||
}
|
||||
lines[edit_idx] = json.dumps(node, ensure_ascii=False, sort_keys=True)
|
||||
dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
out = tmp_path / "out.proto"
|
||||
_run([sys.executable, str(COMPILE), "--in", str(dst), "--out", str(out)])
|
||||
db = NodeDatabase()
|
||||
db.ParseFromString(out.read_bytes())
|
||||
edited = next((n for n in db.nodes if n.num == target_num), None)
|
||||
assert edited is not None
|
||||
assert edited.long_name == "Hand Edited Node"
|
||||
tel = next((t for t in db.telemetry if t.num == target_num), None)
|
||||
assert tel is not None
|
||||
assert tel.device_metrics.battery_level == 42
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Misc smoke checks on the module surface.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_crc16_ccitt_matches_known_vectors() -> None:
|
||||
"""Sanity-check the hand-rolled CRC16-CCITT matches well-known vectors.
|
||||
|
||||
Test vectors from the XModem-CRC spec (init=0, poly=0x1021):
|
||||
crc16("123456789") = 0x31C3
|
||||
crc16("") = 0x0000
|
||||
"""
|
||||
from meshtastic_mcp.fixtures import _crc16_ccitt
|
||||
|
||||
assert _crc16_ccitt(b"") == 0x0000
|
||||
assert _crc16_ccitt(b"123456789") == 0x31C3
|
||||
|
||||
|
||||
def test_push_fake_nodedb_rejects_invalid_size() -> None:
|
||||
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||
|
||||
with pytest.raises(FixtureError, match="size must be one of"):
|
||||
push_fake_nodedb(size=999, target="portduino") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_push_fake_nodedb_hardware_requires_confirm() -> None:
|
||||
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||
|
||||
with pytest.raises(FixtureError, match="confirm=True"):
|
||||
push_fake_nodedb(size=250, target="hardware", port="/dev/cu.fake")
|
||||
|
||||
|
||||
def test_push_fake_nodedb_hardware_requires_port() -> None:
|
||||
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||
|
||||
with pytest.raises(FixtureError, match="requires a port"):
|
||||
push_fake_nodedb(size=250, target="hardware", confirm=True)
|
||||
|
||||
|
||||
def test_push_fake_nodedb_hardware_rejects_tcp_port() -> None:
|
||||
from meshtastic_mcp.fixtures import FixtureError, push_fake_nodedb
|
||||
|
||||
with pytest.raises(FixtureError, match="not supported"):
|
||||
push_fake_nodedb(
|
||||
size=250, target="hardware", confirm=True, port="tcp://localhost:4403"
|
||||
)
|
||||
@@ -34,7 +34,6 @@ BuildRequires: python3dist(grpcio-tools)
|
||||
BuildRequires: git-core
|
||||
BuildRequires: gcc-c++
|
||||
BuildRequires: pkgconfig(yaml-cpp)
|
||||
BuildRequires: pkgconfig(jsoncpp)
|
||||
BuildRequires: pkgconfig(libgpiod)
|
||||
BuildRequires: pkgconfig(bluez)
|
||||
BuildRequires: pkgconfig(libusb-1.0)
|
||||
|
||||
+25
-23
@@ -2,7 +2,7 @@
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[platformio]
|
||||
default_envs = heltec-v3
|
||||
default_envs = tbeam
|
||||
|
||||
extra_configs =
|
||||
variants/*/*.ini
|
||||
@@ -13,11 +13,10 @@ extra_configs =
|
||||
description = Meshtastic
|
||||
|
||||
[env]
|
||||
; test_build_src = true ; disabled for faster incremental builds
|
||||
test_build_src = true
|
||||
extra_scripts =
|
||||
pre:bin/platformio-pre.py
|
||||
bin/platformio-custom.py
|
||||
post:extra_scripts/nrf54l15_linker.py
|
||||
; note: we add src to our include search path so that lmic_project_config can override
|
||||
; note: TINYGPS_OPTION_NO_CUSTOM_FIELDS is VERY important. We don't use custom fields and somewhere in that pile
|
||||
; of code is a heap corruption bug!
|
||||
@@ -50,7 +49,6 @@ build_flags = -Wno-missing-field-initializers
|
||||
-DRADIOLIB_EXCLUDE_PAGER=1
|
||||
-DRADIOLIB_EXCLUDE_FSK4=1
|
||||
-DRADIOLIB_EXCLUDE_APRS=1
|
||||
-DRADIOLIB_EXCLUDE_ADSB=1
|
||||
-DRADIOLIB_EXCLUDE_LORAWAN=1
|
||||
-DMESHTASTIC_EXCLUDE_DROPZONE=1
|
||||
-DMESHTASTIC_EXCLUDE_REPLYBOT=1
|
||||
@@ -59,10 +57,10 @@ build_flags = -Wno-missing-field-initializers
|
||||
-DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware
|
||||
-DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1
|
||||
-DMESHTASTIC_EXCLUDE_POWERMON=1
|
||||
-DMESHTASTIC_EXCLUDE_STATUS=1
|
||||
-D MAX_THREADS=40 ; As we've split modules, we have more threads to manage
|
||||
#-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now
|
||||
#-D OLED_PL=1
|
||||
#-D OLED_ZH=1 ; uncomment for compile-time Chinese font (runtime switching works without this on TFT)
|
||||
#-D DEBUG_HEAP=1 ; uncomment to add free heap space / memory leak debugging logs
|
||||
#-D DEBUG_LOOP_TIMING=1 ; uncomment to add main loop timing logs
|
||||
|
||||
@@ -70,7 +68,7 @@ monitor_speed = 115200
|
||||
monitor_filters = direct
|
||||
lib_deps =
|
||||
# renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master
|
||||
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/6bfd1f135e1ebe37afd6050bb4b9964cea3fcfda.zip
|
||||
https://github.com/meshtastic/esp8266-oled-ssd1306/archive/2e26010040e028baee72e2093402fa7b3c59e430.zip
|
||||
# renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master
|
||||
https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip
|
||||
# renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master
|
||||
@@ -104,7 +102,7 @@ build_unflags =
|
||||
-std=gnu++11
|
||||
build_flags = ${env.build_flags} -Os
|
||||
-std=gnu++17
|
||||
build_src_filter = ${env.build_src_filter} -<platform/> +<platform/extra_variants/> -<graphics/niche/>
|
||||
build_src_filter = ${env.build_src_filter} -<platform/portduino/> -<graphics/niche/>
|
||||
|
||||
; Common libs for communicating over TCP/IP networks such as MQTT
|
||||
[networking_base]
|
||||
@@ -123,12 +121,12 @@ lib_deps =
|
||||
[radiolib_base]
|
||||
lib_deps =
|
||||
# renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib
|
||||
https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip
|
||||
https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip
|
||||
|
||||
[device-ui_base]
|
||||
lib_deps =
|
||||
# renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master
|
||||
https://github.com/meshtastic/device-ui/archive/4bf593a82100b911ff816dddf7158ffdee2114cd.zip
|
||||
https://github.com/meshtastic/device-ui/archive/502ba30dbccc6a323f1d4f7ca961131c27f60672.zip
|
||||
|
||||
; Common libs for environmental measurements in telemetry module
|
||||
[environmental_base]
|
||||
@@ -142,7 +140,7 @@ lib_deps =
|
||||
# renovate: datasource=github-tags depName=NeoPixel packageName=adafruit/Adafruit_NeoPixel
|
||||
https://github.com/adafruit/Adafruit_NeoPixel/archive/1.15.5.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SSD1306 packageName=adafruit/Adafruit_SSD1306
|
||||
https://github.com/adafruit/Adafruit_SSD1306/archive/refs/tags/2.5.16.zip
|
||||
https://github.com/adafruit/Adafruit_SSD1306/archive/2.5.17.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit BMP280 packageName=adafruit/Adafruit_BMP280_Library
|
||||
https://github.com/adafruit/Adafruit_BMP280_Library/archive/refs/tags/3.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit BMP085 packageName=adafruit/Adafruit-BMP085-Library
|
||||
@@ -173,8 +171,8 @@ lib_deps =
|
||||
https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614
|
||||
https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT
|
||||
https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip
|
||||
# renovate: datasource=git-refs depName=INA3221 packageName=https://github.com/sgtwilko/INA3221 gitBranch=FixOverflow
|
||||
https://github.com/sgtwilko/INA3221/archive/bb03d7e9bfcc74fc798838a54f4f99738f29fc6a.zip
|
||||
# renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass
|
||||
https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip
|
||||
# renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU
|
||||
@@ -196,21 +194,11 @@ lib_deps =
|
||||
# renovate: datasource=github-tags depName=DFRobot_BMM150 packageName=dfrobot/DFRobot_BMM150
|
||||
https://github.com/DFRobot/DFRobot_BMM150/archive/refs/tags/V1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=SparkFun MMC5983MA Magnetometer packageName=sparkfun/SparkFun_MMC5983MA_Magnetometer_Arduino_Library
|
||||
https://github.com/sparkfun/SparkFun_MMC5983MA_Magnetometer_Arduino_Library/archive/refs/tags/v1.1.4.zip
|
||||
https://github.com/sparkfun/SparkFun_MMC5983MA_Magnetometer_Arduino_Library/archive/v1.1.5.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit_TSL2561 packageName=adafruit/Adafruit_TSL2561
|
||||
https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip
|
||||
# renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE
|
||||
https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
|
||||
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x
|
||||
https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
|
||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
|
||||
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
|
||||
|
||||
; Common environmental sensor libraries (not included in native / portduino)
|
||||
[environmental_extra_common]
|
||||
@@ -219,16 +207,30 @@ lib_deps =
|
||||
https://github.com/adafruit/Adafruit_BMP3XX/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit MAX1704X packageName=adafruit/Adafruit_MAX1704X
|
||||
https://github.com/adafruit/Adafruit_MAX1704X/archive/refs/tags/1.0.3.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHTC3 packageName=adafruit/Adafruit_SHTC3
|
||||
https://github.com/adafruit/Adafruit_SHTC3/archive/refs/tags/1.0.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit LPS2X packageName=adafruit/Adafruit_LPS2X
|
||||
https://github.com/adafruit/Adafruit_LPS2X/archive/refs/tags/2.0.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHT31 packageName=adafruit/Adafruit_SHT31
|
||||
https://github.com/adafruit/Adafruit_SHT31/archive/refs/tags/2.2.2.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit VEML7700 packageName=adafruit/Adafruit_VEML7700
|
||||
https://github.com/adafruit/Adafruit_VEML7700/archive/refs/tags/2.1.6.zip
|
||||
# renovate: datasource=github-tags depName=Adafruit SHT4x packageName=adafruit/Adafruit_SHT4X
|
||||
https://github.com/adafruit/Adafruit_SHT4X/archive/refs/tags/1.0.5.zip
|
||||
# renovate: datasource=github-tags depName=SparkFun Qwiic Scale NAU7802 packageName=sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library
|
||||
https://github.com/sparkfun/SparkFun_Qwiic_Scale_NAU7802_Arduino_Library/archive/refs/tags/v1.0.6.zip
|
||||
# renovate: datasource=custom.pio depName=ClosedCube OPT3001 packageName=closedcube/library/ClosedCube OPT3001
|
||||
closedcube/ClosedCube OPT3001@1.1.2
|
||||
# renovate: datasource=git-refs depName=meshtastic-DFRobot_LarkWeatherStation packageName=https://github.com/meshtastic/DFRobot_LarkWeatherStation gitBranch=master
|
||||
https://github.com/meshtastic/DFRobot_LarkWeatherStation/archive/4de3a9cadef0f6a5220a8a906cf9775b02b0040d.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
|
||||
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x
|
||||
https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
|
||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
|
||||
|
||||
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
|
||||
[environmental_extra]
|
||||
|
||||
+1
-1
Submodule protobufs updated: 03314e6395...59cb394dcf
@@ -152,16 +152,15 @@ extern "C" void logLegacy(const char *level, const char *fmt, ...);
|
||||
// Default Bluetooth PIN
|
||||
#define defaultBLEPin 123456
|
||||
|
||||
#if HAS_ETHERNET && defined(USE_ARDUINO_ETHERNET)
|
||||
#include <Ethernet.h> // arduino-libraries/Ethernet — supports W5500 auto-detect
|
||||
#elif HAS_ETHERNET && defined(USE_CH390D)
|
||||
#if HAS_ETHERNET && defined(USE_CH390D)
|
||||
#include <ESP32_CH390.h>
|
||||
#elif HAS_ETHERNET && !defined(USE_WS5500)
|
||||
#include <RAK13800_W5100S.h>
|
||||
#endif // HAS_ETHERNET
|
||||
|
||||
#if HAS_ETHERNET && defined(ARCH_ESP32)
|
||||
#include <ETH.h>
|
||||
#if HAS_ETHERNET && defined(USE_WS5500)
|
||||
#include <ETHClass2.h>
|
||||
#define ETH ETH2
|
||||
#endif // HAS_ETHERNET
|
||||
|
||||
#if HAS_WIFI
|
||||
|
||||
+10
-30
@@ -1,62 +1,42 @@
|
||||
#include "DisplayFormatters.h"
|
||||
#include "MeshRadio.h"
|
||||
|
||||
const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset preset, bool useShortName,
|
||||
bool usePreset)
|
||||
{
|
||||
|
||||
// If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels
|
||||
// rely on this being a stable literal for channel-name hashing and default-channel detection.
|
||||
// If use_preset is false, always return "Custom"
|
||||
if (!usePreset) {
|
||||
return "Custom";
|
||||
}
|
||||
|
||||
switch (preset) {
|
||||
case PRESET(SHORT_TURBO):
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO:
|
||||
return useShortName ? "ShortT" : "ShortTurbo";
|
||||
break;
|
||||
case PRESET(SHORT_SLOW):
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW:
|
||||
return useShortName ? "ShortS" : "ShortSlow";
|
||||
break;
|
||||
case PRESET(SHORT_FAST):
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST:
|
||||
return useShortName ? "ShortF" : "ShortFast";
|
||||
break;
|
||||
case PRESET(MEDIUM_SLOW):
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW:
|
||||
return useShortName ? "MedS" : "MediumSlow";
|
||||
break;
|
||||
case PRESET(MEDIUM_FAST):
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST:
|
||||
return useShortName ? "MedF" : "MediumFast";
|
||||
break;
|
||||
case PRESET(LONG_SLOW):
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW:
|
||||
return useShortName ? "LongS" : "LongSlow";
|
||||
break;
|
||||
case PRESET(LONG_FAST):
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST:
|
||||
return useShortName ? "LongF" : "LongFast";
|
||||
break;
|
||||
case PRESET(LONG_TURBO):
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO:
|
||||
return useShortName ? "LongT" : "LongTurbo";
|
||||
break;
|
||||
case PRESET(LONG_MODERATE):
|
||||
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE:
|
||||
return useShortName ? "LongM" : "LongMod";
|
||||
break;
|
||||
case PRESET(LITE_FAST):
|
||||
return useShortName ? "LiteF" : "LiteFast";
|
||||
break;
|
||||
case PRESET(LITE_SLOW):
|
||||
return useShortName ? "LiteS" : "LiteSlow";
|
||||
break;
|
||||
case PRESET(NARROW_FAST):
|
||||
return useShortName ? "NarF" : "NarrowFast";
|
||||
break;
|
||||
case PRESET(NARROW_SLOW):
|
||||
return useShortName ? "NarS" : "NarrowSlow";
|
||||
break;
|
||||
case PRESET(TINY_FAST):
|
||||
return useShortName ? "TinyF" : "TinyFast";
|
||||
break;
|
||||
case PRESET(TINY_SLOW):
|
||||
return useShortName ? "TinyS" : "TinySlow";
|
||||
break;
|
||||
default:
|
||||
return useShortName ? "Custom" : "Invalid";
|
||||
break;
|
||||
|
||||
+114
-171
@@ -79,136 +79,28 @@ bool copyFile(const char *from, const char *to)
|
||||
bool renameFile(const char *pathFrom, const char *pathTo)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// take SPI Lock
|
||||
spiLock->lock();
|
||||
// rename was fixed for ESP32 IDF LittleFS in April
|
||||
bool result = FSCom.rename(pathFrom, pathTo);
|
||||
spiLock->unlock();
|
||||
return result;
|
||||
#else
|
||||
return false;
|
||||
// copyFile does its own locking.
|
||||
if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Platform-agnostic filesystem format / wipe.
|
||||
*
|
||||
* On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the
|
||||
* native FSCom.format() which erases and reinitialises the LittleFS
|
||||
* partition.
|
||||
*
|
||||
* On Portduino the fs::FS backend has no format() method. We instead
|
||||
* delete /prefs (the only meshtastic data directory written at runtime)
|
||||
* and return. rmDir("/prefs") is already called unconditionally by
|
||||
* factoryReset() so this is a proven primitive on Portduino.
|
||||
* FSBegin() is a no-op (#define FSBegin() true) on Portduino.
|
||||
*
|
||||
* @return true on success, false on failure or if no filesystem is configured.
|
||||
*/
|
||||
bool fsFormat()
|
||||
{
|
||||
#ifdef FSCom
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
rmDir("/prefs");
|
||||
return FSBegin();
|
||||
#else
|
||||
return FSCom.format();
|
||||
#endif
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef FSCom
|
||||
namespace
|
||||
{
|
||||
bool pathEndsWithDot(const char *path)
|
||||
{
|
||||
if (!path)
|
||||
return false;
|
||||
|
||||
size_t length = strlen(path);
|
||||
return length > 0 && path[length - 1] == '.';
|
||||
}
|
||||
|
||||
bool copyFilePath(char *dest, size_t destSize, const char *path, bool *wasLimited)
|
||||
{
|
||||
if (!path || destSize == 0) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strlcpy(dest, path, destSize) >= destSize) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vector<meshtastic_FileInfo> &filenames,
|
||||
bool *wasLimited)
|
||||
{
|
||||
if (!dirname)
|
||||
return;
|
||||
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
if (!root)
|
||||
return;
|
||||
if (!root.isDirectory()) {
|
||||
root.close();
|
||||
return;
|
||||
}
|
||||
|
||||
File file = root.openNextFile();
|
||||
// file.name()[0] check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
|
||||
while (file && file.name()[0]) {
|
||||
if (filenames.size() >= maxCount) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
file.close();
|
||||
break;
|
||||
}
|
||||
const char *fileName = file.name();
|
||||
if (file.isDirectory() && !pathEndsWithDot(fileName)) {
|
||||
char pathBuffer[sizeof(((meshtastic_FileInfo *)nullptr)->file_name)] = {};
|
||||
#ifdef ARCH_ESP32
|
||||
const char *subDirPath = file.path();
|
||||
#else
|
||||
const char *subDirPath = fileName;
|
||||
#endif
|
||||
bool hasSubDirPath = copyFilePath(pathBuffer, sizeof(pathBuffer), subDirPath, wasLimited);
|
||||
file.close();
|
||||
|
||||
if (levels && hasSubDirPath) {
|
||||
collectFiles(pathBuffer, levels - 1, maxCount, filenames, wasLimited);
|
||||
} else if (wasLimited) {
|
||||
*wasLimited = true;
|
||||
}
|
||||
} else {
|
||||
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
||||
#ifdef ARCH_ESP32
|
||||
bool hasFilePath = copyFilePath(fileInfo.file_name, sizeof(fileInfo.file_name), file.path(), wasLimited);
|
||||
#else
|
||||
bool hasFilePath = copyFilePath(fileInfo.file_name, sizeof(fileInfo.file_name), file.name(), wasLimited);
|
||||
#endif
|
||||
if (hasFilePath && !pathEndsWithDot(fileInfo.file_name)) {
|
||||
filenames.push_back(fileInfo);
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
file = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
}
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
/**
|
||||
* @brief Get the list of files in a directory.
|
||||
*
|
||||
@@ -217,40 +109,45 @@ void collectFiles(const char *dirname, uint8_t levels, size_t maxCount, std::vec
|
||||
*
|
||||
* @param dirname The name of the directory.
|
||||
* @param levels The number of levels of subdirectories to list.
|
||||
* @param maxCount The maximum number of files to collect before truncating the walk.
|
||||
* @param wasLimited Optional out-param, set to true if the listing was truncated (by maxCount or low memory).
|
||||
* @return A vector of meshtastic_FileInfo for each file in the directory.
|
||||
* @return A vector of strings containing the full path of each file in the directory.
|
||||
*/
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount, bool *wasLimited)
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels)
|
||||
{
|
||||
std::vector<meshtastic_FileInfo> filenames = {};
|
||||
if (wasLimited)
|
||||
*wasLimited = false;
|
||||
#ifdef FSCom
|
||||
#if defined(__cpp_exceptions) || defined(__EXCEPTIONS)
|
||||
size_t reservedCount = maxCount;
|
||||
while (reservedCount > 0) {
|
||||
try {
|
||||
filenames.reserve(reservedCount);
|
||||
break;
|
||||
} catch (const std::bad_alloc &) {
|
||||
reservedCount /= 2;
|
||||
} catch (const std::length_error &) {
|
||||
reservedCount /= 2;
|
||||
}
|
||||
}
|
||||
if (reservedCount == 0) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
if (!root)
|
||||
return filenames;
|
||||
}
|
||||
if (reservedCount < maxCount) {
|
||||
if (wasLimited)
|
||||
*wasLimited = true;
|
||||
maxCount = reservedCount;
|
||||
}
|
||||
if (!root.isDirectory())
|
||||
return filenames;
|
||||
|
||||
File file = root.openNextFile();
|
||||
while (file) {
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
#ifdef ARCH_ESP32
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.path(), levels - 1);
|
||||
#else
|
||||
std::vector<meshtastic_FileInfo> subDirFilenames = getFiles(file.name(), levels - 1);
|
||||
#endif
|
||||
collectFiles(dirname, levels, maxCount, filenames, wasLimited);
|
||||
filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end());
|
||||
file.close();
|
||||
}
|
||||
} else {
|
||||
meshtastic_FileInfo fileInfo = {"", static_cast<uint32_t>(file.size())};
|
||||
#ifdef ARCH_ESP32
|
||||
strcpy(fileInfo.file_name, file.path());
|
||||
#else
|
||||
strcpy(fileInfo.file_name, file.name());
|
||||
#endif
|
||||
if (!String(fileInfo.file_name).endsWith(".")) {
|
||||
filenames.push_back(fileInfo);
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
file = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
#endif
|
||||
return filenames;
|
||||
}
|
||||
@@ -266,59 +163,98 @@ std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, s
|
||||
void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
{
|
||||
#ifdef FSCom
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
char buffer[255];
|
||||
#endif
|
||||
File root = FSCom.open(dirname, FILE_O_READ);
|
||||
if (!root || !root.isDirectory())
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
if (!root.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
File file = root.openNextFile();
|
||||
while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395
|
||||
#ifdef ARCH_ESP32
|
||||
const char *filepath = file.path();
|
||||
#else
|
||||
const char *filepath = file.name();
|
||||
#endif
|
||||
while (
|
||||
file &&
|
||||
file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395)
|
||||
if (file.isDirectory() && !String(file.name()).endsWith(".")) {
|
||||
if (levels) {
|
||||
listDir(filepath, levels - 1, del);
|
||||
#ifdef ARCH_ESP32
|
||||
listDir(file.path(), levels - 1, del);
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
LOG_DEBUG("Remove %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
file.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
listDir(file.name(), levels - 1, del);
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", file.name());
|
||||
strncpy(buffer, file.name(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
file.close();
|
||||
}
|
||||
#else
|
||||
LOG_DEBUG(" %s (directory)", file.name());
|
||||
listDir(file.name(), levels - 1, del);
|
||||
file.close();
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
#ifdef ARCH_ESP32
|
||||
if (del) {
|
||||
LOG_DEBUG("Delete %s", filepath);
|
||||
strncpy(buffer, filepath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
LOG_DEBUG("Delete %s", file.path());
|
||||
strncpy(buffer, file.path(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", filepath, file.size());
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size());
|
||||
file.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
if (del) {
|
||||
LOG_DEBUG("Delete %s", file.name());
|
||||
strncpy(buffer, file.name(), sizeof(buffer));
|
||||
file.close();
|
||||
FSCom.remove(buffer);
|
||||
} else {
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||
file.close();
|
||||
}
|
||||
#else
|
||||
LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size());
|
||||
file.close();
|
||||
#endif
|
||||
}
|
||||
file = root.openNextFile();
|
||||
}
|
||||
#ifdef ARCH_ESP32
|
||||
const char *rootpath = root.path();
|
||||
#else
|
||||
const char *rootpath = root.name();
|
||||
#endif
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", rootpath);
|
||||
strncpy(buffer, rootpath, sizeof(buffer) - 1);
|
||||
buffer[sizeof(buffer) - 1] = '\0';
|
||||
LOG_DEBUG("Remove %s", root.path());
|
||||
strncpy(buffer, root.path(), sizeof(buffer));
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
if (del) {
|
||||
LOG_DEBUG("Remove %s", root.name());
|
||||
strncpy(buffer, root.name(), sizeof(buffer));
|
||||
root.close();
|
||||
FSCom.rmdir(buffer);
|
||||
} else {
|
||||
root.close();
|
||||
}
|
||||
#else
|
||||
root.close();
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -332,7 +268,14 @@ void listDir(const char *dirname, uint8_t levels, bool del)
|
||||
void rmDir(const char *dirname)
|
||||
{
|
||||
#ifdef FSCom
|
||||
|
||||
#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO))
|
||||
listDir(dirname, 10, true);
|
||||
#elif defined(ARCH_NRF52)
|
||||
// nRF52 implementation of LittleFS has a recursive delete function
|
||||
FSCom.rmdir_r(dirname);
|
||||
#endif
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -364,7 +307,7 @@ void fsInit()
|
||||
*/
|
||||
void setupSDCard()
|
||||
{
|
||||
#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) && !defined(HAS_SD_MMC)
|
||||
#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI)
|
||||
concurrency::LockGuard g(spiLock);
|
||||
SDHandler.begin(SPI_SCK, SPI_MISO, SPI_MOSI);
|
||||
if (!SD.begin(SDCARD_CS, SDHandler, SD_SPI_FREQUENCY)) {
|
||||
|
||||
+3
-12
@@ -14,8 +14,8 @@
|
||||
#define FILE_O_READ "r"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_STM32)
|
||||
// STM32
|
||||
#if defined(ARCH_STM32WL)
|
||||
// STM32WL
|
||||
#include "LittleFS.h"
|
||||
#define FSCom InternalFS
|
||||
#define FSBegin() FSCom.begin()
|
||||
@@ -48,20 +48,11 @@ using namespace STM32_LittleFS_Namespace;
|
||||
using namespace Adafruit_LittleFS_Namespace;
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_NRF54L15)
|
||||
// nRF54L15 — Zephyr LittleFS on 36 KB storage_partition (internal RRAM)
|
||||
#include "InternalFileSystem.h"
|
||||
#define FSCom InternalFS
|
||||
#define FSBegin() FSCom.begin()
|
||||
using namespace Adafruit_LittleFS_Namespace;
|
||||
#endif
|
||||
|
||||
void fsInit();
|
||||
void fsListFiles();
|
||||
bool copyFile(const char *from, const char *to);
|
||||
bool renameFile(const char *pathFrom, const char *pathTo);
|
||||
bool fsFormat();
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels, size_t maxCount = 64, bool *wasLimited = nullptr);
|
||||
std::vector<meshtastic_FileInfo> getFiles(const char *dirname, uint8_t levels);
|
||||
void listDir(const char *dirname, uint8_t levels, bool del = false);
|
||||
void rmDir(const char *dirname);
|
||||
void setupSDCard();
|
||||
+6
-3
@@ -53,7 +53,8 @@ class GPSStatus : public Status
|
||||
int32_t getLatitude() const
|
||||
{
|
||||
if (config.position.fixed_position) {
|
||||
return localPosition.latitude_i;
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
return node->position.latitude_i;
|
||||
} else {
|
||||
return p.latitude_i;
|
||||
}
|
||||
@@ -62,7 +63,8 @@ class GPSStatus : public Status
|
||||
int32_t getLongitude() const
|
||||
{
|
||||
if (config.position.fixed_position) {
|
||||
return localPosition.longitude_i;
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
return node->position.longitude_i;
|
||||
} else {
|
||||
return p.longitude_i;
|
||||
}
|
||||
@@ -71,7 +73,8 @@ class GPSStatus : public Status
|
||||
int32_t getAltitude() const
|
||||
{
|
||||
if (config.position.fixed_position) {
|
||||
return localPosition.altitude;
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
||||
return node->position.altitude;
|
||||
} else {
|
||||
return p.altitude;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user