Fix Cascade power saving, USB Companion, and mOTA flows

This commit is contained in:
mikecarper
2026-08-18 14:02:10 -07:00
parent 21bfc97712
commit 7a4da3ccfb
59 changed files with 3946 additions and 2437 deletions
+22
View File
@@ -8,8 +8,30 @@ existing behavior * **Internal** = refactor / under-the-hood * **Docs** = docume
**Build** / **CI** = build system & automation. **[UP] Upstream sync** marks a merge of the
upstream MeshCore `dev` branch, which generally pulls in a new MeshCore software version.
### August 2026
- **Improvement** - Added a WebConfig CLI terminal, tightened command failure/reboot handling, stopped secret reads, and enforced the setup password <sub>2026-08-08 * `d7109c18`, `c831e599`, `8abe26ba`</sub>
- **New** * `mqtt` - Added IdahoMesh, GoMesh, okimesh, and atvirastinklas presets; expanded the built-in preset table to 31 entries <sub>2026-08-08 * `da37b6eb`, `3b11540e`, `73faa30c`, `dbee39b5`</sub>
- **New** - Enabled online OTA for Station G3 observer builds <sub>2026-08-08 * `5000391c`</sub>
- **New** - Configurable repeater telemetry history plus the browser decoder <sub>2026-08-05 * `3ba0fb18`, `64f99420`</sub>
- **Improvement** * `mqtt-neighbors` - Enabled neighbor publication on opted-in non-PSRAM observers and fixed unusable heard ages <sub>2026-08-03 * `a3a0a94d`, `5828a3c5`</sub>
- **Fix** - Restored nRF52 builds after observer-only dependencies leaked into the common build path <sub>2026-08-03 * `45379ad7`</sub>
- [UP] **Upstream sync** - Promoted the observer channel onto MeshCore v1.17.0-era upstream changes <sub>2026-08-09 * `b744b42a`</sub>
### July 2026
- **New** * `webconfig` - Added the ESP32 WebConfig portal, request/result correlation, batch-state tests, and administrator-password management <sub>2026-07-21 * `a7c1cc63`, `dc44311e`, `cece565e`, `675bc6b5`</sub>
- **Fix** * `mqtt` - Added cooperative MQTT shutdown, a clean-stop OTA barrier, and a hardware-derived slot-scaled stop timeout <sub>2026-07-19 * `2e1a1410`, `7767760f`</sub>
- **New** * `mqtt` - Added periodic neighbor publication, host-tested JSON construction, preferences, and WebConfig controls <sub>2026-07-19 * `de320bc4`, `e36aee04`, `8d7a47ab`, `d6f8a871`</sub>
- **Fix** * `mqtt` - Made preference migration power-loss recoverable, restored PSRAM buffers after restart, and added representative CI firmware guardrails <sub>2026-07-18 * `1be09b9b`, `b5deaf93`, `7b60ee70`</sub>
- **New** * `mqtt` - Added per-slot packet allowlists with named payload types and downgrade-safe preference handling <sub>2026-07-28 * `e00b29b4`, `52bd2719`, `ecbb5005`</sub>
- **New** - Added standalone WebConfig CLI controls and shared Wi-Fi OTA-seeder policy for Full ESP32 roles <sub>2026-07-29 * `a6ace607`, `e6abf3ea`</sub>
- **Fix** * `mqtt` - Preserved observer capture and remote administration when duty-cycle throttling fills the packet pool <sub>2026-07-09 * `1c9c6292`, `847be34e`</sub>
- [UP] **Upstream sync** - Synced the observer development channel with upstream dev through 2026-07-30 <sub>2026-07-30 * `612c5213`</sub>
### June 2026
- **Fix** * `kiss_modem` - Prevented USB TX backpressure from stalling the modem and added its separate native CI suite <sub>2026-06-23 * `fb2c61f8`, `39ff5b87`</sub>
- **New** - AlertReporter integration in MyMesh for Room Server <sub>2026-06-17 * `985fda13`</sub>
- **Improvement** - Cumulative packet statistics (`packets_sent` / `packets_received`) added to the status message <sub>2026-06-16 * `8bf590b1`</sub>
- **Improvement** - Packet path now published as an array of lowercase hex hop tokens <sub>2026-06-16 * `e80a5ded`</sub>
+67 -175
View File
@@ -1,194 +1,86 @@
# MeshCore Memory Monitoring Guide
# MeshCore memory monitoring
## Quick Start
MeshCore exposes current allocator information through the CLI. On an ESP32
build, run `memory` over a supported local or administrator CLI transport:
```text
memory
-> Free: 102796, Min: 83544, Max: 75764, Queue: 0, IntFree: 68420, IntMax: 53248, PSRAM: 3918400/4194304
```
For a USB serial console, identify the device port and open it at the baud rate
configured by the build (normally 115200):
### 1. Find Your Device Port
```bash
# Linux/macOS
ls /dev/tty* | grep -E "(USB|ACM)"
# Common ports:
# /dev/ttyUSB0 - Linux USB serial
# /dev/ttyACM0 - Linux USB CDC
# /dev/cu.usbserial-* - macOS USB serial
# /dev/cu.usbmodem* - macOS USB CDC
ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null
screen /dev/ttyACM0 115200
```
### 2. Run Monitoring
```bash
# Monitor for 4 hours (default)
python3 monitor_memory.py /dev/ttyUSB0
Then enter `memory` periodically. Exit GNU Screen with `Ctrl-A`, then `K`.
# Monitor for 24 hours
python3 monitor_memory.py /dev/ttyUSB0 24
The repository does not ship a `monitor_memory.py` collector. For a long soak,
use a serial terminal with timestamped logging, or have the test harness send
`memory` at a conservative interval and retain each complete reply.
# Monitor for 2 hours with 60-second intervals
python3 monitor_memory.py /dev/ttyUSB0 2 --interval 60
```
## ESP32 fields
## What It Monitors
| Field | Meaning |
|---|---|
| `Free` | Bytes currently free in the ESP heap. |
| `Min` | Lowest free-heap value observed since boot. This is a low-water mark and does not rise when memory is released. |
| `Max` | Largest single allocation currently available from the ESP heap. |
| `Queue` | Current MeshCore transmit-queue length. It is not specifically an MQTT queue. |
| `IntFree` | Bytes currently free in internal-capability RAM. |
| `IntMax` | Largest single allocation currently available in internal-capability RAM. |
| `PSRAM` | Free/total external PSRAM bytes. A board or build without PSRAM normally reports `0/0`. |
### Memory Metrics
- **Free Heap**: Available memory in bytes
- **Min Heap**: Minimum free heap since boot
- **Max Alloc**: Largest allocatable block
- **Queue Size**: Number of queued MQTT packets
Non-ESP builds that expose the common CLI may return the shorter
`Heap: free=<bytes>, used=<bytes>` form. The public build matrix only promises
the detailed `memory` command on ESP32; see the
[CLI availability matrix](docs/cli_command_availability.md#memory).
### Calculated Metrics
- **Heap Usage %**: Percentage of total memory used
- **Fragmentation %**: How fragmented the heap is
## Interpreting a soak
### Automatic Alerts
- **LOW_MEMORY**: Free heap < 50KB
- **HIGH_FRAGMENTATION**: Fragmentation > 50%
- **QUEUE_BUILDUP**: Queue size > 20 packets
- **POSSIBLE_LEAK**: Memory decreasing over time
Establish a separate idle and workload baseline for each board and firmware
profile. Fixed thresholds such as “50 KB is always low” are misleading because
heap size, PSRAM, enabled features, and allocation capabilities differ by build.
Look for trends instead:
## Output Files
- `Free` repeatedly returns to roughly the same baseline after transient work.
- `Min` can fall during a new peak workload; continued new lows under an
identical repeating workload deserve investigation.
- A shrinking `Max` while `Free` remains stable can indicate fragmentation or a
changed allocation pattern.
- A `Queue` that grows and does not drain points to radio backpressure or stalled
processing, not necessarily a leak.
- On PSRAM builds, inspect internal RAM separately. Plenty of PSRAM cannot satisfy
allocations that require internal-capability memory.
### Console Output
```
[ 30.0m] Free: 102796, Min: 83544, Max: 75764, Queue: 0, Usage: 68.6%, Frag: 26.3%
[ 60.0m] Free: 101234, Min: 82345, Max: 74321, Queue: 2, Usage: 69.1%, Frag: 26.5%
[WARN] WARNING: HIGH_FRAGMENTATION
```
Sample immediately after boot, after network and MQTT startup, during the
intended peak workload, and again after that workload becomes idle. Preserve
the firmware version, build environment, uptime, and workload alongside the
samples so results are comparable.
### CSV Log File
```csv
Timestamp,Elapsed_Minutes,Free_Heap,Min_Heap,Max_Alloc,Queue_Size,Heap_Usage_Percent,Fragmentation_Percent
2024-01-15T10:30:00,0.0,102796,83544,75764,0,68.6,26.3
2024-01-15T11:00:00,30.0,101234,82345,74321,2,69.1,26.5
```
## Related diagnostics
## Understanding Results
- `stats-core` reports battery, uptime, queue length, and core debug flags over a
local serial session.
- `stats-radio` and `stats-packets` help distinguish memory pressure from radio
or queue congestion.
- On MQTT observer builds, `get mqtt.stats` reports bridge publish health and a
heap snapshot; `get mqtt.status` reports bridge state and schedules.
### Healthy System
- **Free Heap**: 150KB+ (stable)
- **Min Heap**: 120KB+ (stable)
- **Max Alloc**: 100KB+ (stable)
- **Fragmentation**: < 30%
- **Queue**: 0-10 packets
### Warning Signs
- **Free Heap**: < 100KB or decreasing
- **Min Heap**: < 80KB or decreasing
- **Fragmentation**: > 50%
- **Queue**: > 20 packets consistently
### Memory Leak Indicators
- **Consistent decrease** in Free Heap over time
- **Min Heap dropping** below previous minimums
- **Max Alloc shrinking** (fragmentation increasing)
- **POSSIBLE_LEAK** alert triggered
## Long-Term Monitoring
### 24-Hour Test
```bash
python3 monitor_memory.py /dev/ttyUSB0 24
```
- Tests for memory leaks over extended period
- Monitors system stability under normal load
- Identifies gradual memory degradation
### 48-Hour Stress Test
```bash
python3 monitor_memory.py /dev/ttyUSB0 48 --interval 60
```
- Extended monitoring for critical deployments
- 60-second intervals reduce log file size
- Tests system under continuous operation
See the [CLI command reference](docs/cli_commands.md#statistics) and
[MQTT command availability](docs/cli_command_availability.md#mqtt-stats).
## Troubleshooting
### Device Not Responding
1. Check port is correct: `ls /dev/tty*`
2. Ensure device is connected and powered
3. Try different baud rate if needed
4. Check device is in correct mode
### No Data in CSV
1. Verify device responds to `memory` command manually
2. Check serial connection is stable
3. Ensure device has MQTT bridge enabled
### High Memory Usage
1. Check if it's stable or increasing
2. Look for memory leak patterns
3. Monitor queue size for packet buildup
4. Consider reducing debug logging
## Analysis Tools
### Plot Memory Usage
```python
import pandas as pd
import matplotlib.pyplot as plt
# Load CSV data
df = pd.read_csv('memory_monitor_20240115_103000.csv')
# Plot memory over time
plt.figure(figsize=(12, 8))
plt.subplot(2, 2, 1)
plt.plot(df['Elapsed_Minutes'], df['Free_Heap'])
plt.title('Free Heap Over Time')
plt.ylabel('Bytes')
plt.subplot(2, 2, 2)
plt.plot(df['Elapsed_Minutes'], df['Heap_Usage_Percent'])
plt.title('Heap Usage Percentage')
plt.ylabel('%')
plt.subplot(2, 2, 3)
plt.plot(df['Elapsed_Minutes'], df['Fragmentation_Percent'])
plt.title('Heap Fragmentation')
plt.ylabel('%')
plt.subplot(2, 2, 4)
plt.plot(df['Elapsed_Minutes'], df['Queue_Size'])
plt.title('Queue Size')
plt.ylabel('Packets')
plt.tight_layout()
plt.savefig('memory_analysis.png')
plt.show()
```
### Check for Trends
```python
# Calculate memory trend
df['Free_Heap_Trend'] = df['Free_Heap'].rolling(window=10).mean()
df['Trend_Slope'] = df['Free_Heap_Trend'].diff()
# Identify decreasing trends
decreasing = df[df['Trend_Slope'] < -1000]
if not decreasing.empty:
print("Memory decreasing trend detected!")
print(decreasing[['Elapsed_Minutes', 'Free_Heap', 'Trend_Slope']])
```
## Best Practices
1. **Start with 4-hour baseline** to establish normal patterns
2. **Monitor during peak usage** times for worst-case scenarios
3. **Run 24-hour tests** before production deployment
4. **Check logs regularly** for warning signs
5. **Keep historical data** for trend analysis
6. **Test after code changes** to verify fixes
## Emergency Procedures
### If Memory Leak Detected
1. **Stop monitoring** (Ctrl+C)
2. **Check recent code changes**
3. **Look for unfreed allocations**
4. **Test with reduced functionality**
5. **Deploy memory leak fix**
### If System Crashes
1. **Check last known good memory values**
2. **Identify crash threshold**
3. **Add more frequent monitoring**
4. **Implement memory safeguards**
5. **Consider hardware upgrade**
- If the device does not answer, verify the port, build-specific baud rate, and
that the selected firmware exposes a CLI on that transport.
- If `memory` returns `Unknown command`, check the build matrix. Do not infer a
memory failure from an unavailable command.
- If output stops during a soak, retain the last complete sample and capture the
device log and reset reason. The last free-heap value alone is not a crash
diagnosis.
- If only `Queue` rises, inspect radio and packet statistics before treating the
symptom as allocator exhaustion.
+12 -12
View File
@@ -37,7 +37,7 @@ portal runs until `stop webconfig` or a reboot. To force the captive setup AP,
first stop the MQTT bridge, then start the portal in AP mode:
```text
set bridge off
set bridge.enabled off
start webconfig ap
```
@@ -47,12 +47,12 @@ if you did not reboot:
```text
stop webconfig
set bridge on
set bridge.enabled on
```
The classic 4 MB ESP32
`LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt` and
`LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt` targets omit the browser
`LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_` and
`LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt_` targets omit the browser
portal because the async web-server code does not fit while retaining two app
slots for LoRa OTA. Configure those two builds with the CLI below.
@@ -268,8 +268,8 @@ pio run -e Station_G3_ESP32_repeater_observer_mqtt
pio run -e Station_G3_ESP32_room_server_observer_mqtt
# LilyGo T-LoRa V2.1-1.6 (TTGO LoRa32 V1.0)
pio run -e LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt
pio run -e LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt
pio run -e LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_
pio run -e LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt_
# Elecrow ThinkNode M7
pio run -e ThinkNode_M7_repeater_observer_mqtt
@@ -294,8 +294,8 @@ Some MQTT observer builds use a non-default partition table to accommodate the l
|-------------|----------------|------------|---------------|-------|
| `LilyGo_T3S3_sx1262_repeater_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | Changed from default (1.25 MB) |
| `LilyGo_T3S3_sx1262_room_server_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | Changed from default (1.25 MB) |
| `LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt` | `dual_ota_1984k.csv` | 4 MB | 1.9375 MB | 64 KB SPIFFS; no coredump partition. **One active WSS broker** recommended (no PSRAM; dual TLS usually fails on the second slot). |
| `LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt` | `min_spiffs.csv` | 4 MB | 1.875 MB | TTGO LoRa32 V1.0; observer omits `sensor_base`; one active WSS broker recommended. |
| `LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_` | `dual_ota_1984k.csv` | 4 MB | 1.9375 MB | 64 KB SPIFFS; no coredump partition. **One active WSS broker** recommended (no PSRAM; dual TLS usually fails on the second slot). |
| `LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt_` | `min_spiffs.csv` | 4 MB | 1.875 MB | TTGO LoRa32 V1.0; observer omits `sensor_base`; one active WSS broker recommended. |
| `Station_G2_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board |
| `Station_G2_room_server_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board |
| `Station_G3_ESP32_repeater_observer_mqtt` | `default_16MB.csv` | 16 MB | 6.25 MB | 16 MB flash board |
@@ -308,9 +308,9 @@ same partition table as the board's other firmwares.
Flashing a **full merged image** (`*-merged.bin` at offset `0x0`) writes a new bootloader **and** partition table. If that layout **differs** from what is already on the device, **NVS is typically wiped or invalidated** - expect to lose stored configuration (admin preferences, WiFi, MQTT slots, name, etc.) and reconfigure from scratch.
- **`LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt`:** This uses the custom `dual_ota_1984k.csv` layout. Install its merged image when coming from a standard TLora build, the room-server observer, or any older `huge_app.csv` build; expect to reconfigure after that partition change.
- **`LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt`:** This retains the normal `min_spiffs.csv` layout. Moving from another `min_spiffs` TLora build does not itself require a partition change, but coming from the repeater observer's custom layout, `huge_app.csv`, or a non-MeshCore layout does.
- **`Station_G2_*_observer_mqtt`** and **`LilyGo_TBeam_1W_*_observer_mqtt`**: These use `default_16MB.csv` to accomodate the larger size of the MQTT observer firmware. Installing MQTT observer firmware on these devices requires a **merged** flash the first time. The same applies if you move **from** firmware that was built with a **different** partition table-the first merged flash that installs this layout will **wipe** stored settings.
- **`LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_`:** This uses the custom `dual_ota_1984k.csv` layout. Install its merged image when coming from a standard TLora build, the room-server observer, or any older `huge_app.csv` build; expect to reconfigure after that partition change.
- **`LilyGo_TLora_V2_1_1_6_room_server_observer_mqtt_`:** This retains the normal `min_spiffs.csv` layout. Moving from another `min_spiffs` TLora build does not itself require a partition change, but coming from the repeater observer's custom layout, `huge_app.csv`, or a non-MeshCore layout does.
- **`Station_G2_*_observer_mqtt`** and **`LilyGo_TBeam_1W_*_observer_mqtt`**: These use `default_16MB.csv` to accommodate the larger size of the MQTT observer firmware. Installing MQTT observer firmware on these devices requires a **merged** flash the first time. The same applies if you move **from** firmware built with a **different** partition table: the first merged flash that installs this layout will **wipe** stored settings.
**How to flash the merged firmware:**
@@ -685,7 +685,7 @@ be provisioned and managed without the serial CLI. It is started from the CLI
to log in. If WiFi is **not** configured (`wifi.ssid` empty), it raises the
setup AP instead (same as first boot).
- `start webconfig ap` -- force the **setup AP** even when WiFi is configured.
The MQTT bridge must be stopped first (`set bridge off`); the AP owns the
The MQTT bridge must be stopped first (`set bridge.enabled off`); the AP owns the
radio. Used for re-provisioning in the field.
- `stop webconfig` -- stop the portal and free its resources. LAN mode runs until
this is issued; the setup AP also auto-stops after an idle timeout (default 10
+5 -4
View File
@@ -57,11 +57,12 @@ scheduled time are expired at dequeue, so under throttle the queue holds only fr
traffic and admin responses reach the trickle of TX budget. Non-observer builds keep
the upstream pool behavior.
### Neighbors publication path (PSRAM only)
### Neighbors publication path (feature-gated)
Periodic neighbors publishing is gated on `WITH_MQTT_NEIGHBORS`
(`defined(BOARD_HAS_PSRAM) && defined(MAX_NEIGHBOURS) && MAX_NEIGHBOURS > 0`,
defined in `MQTTBridge.h`). It spans two subsystems and two cores:
Periodic neighbors publishing is gated on `WITH_MQTT_NEIGHBORS`, defined in
`MQTTBridge.h` when `MAX_NEIGHBOURS > 0` and either `BOARD_HAS_PSRAM` or the
variant opt-in `MQTT_NEIGHBORS_WITHOUT_PSRAM` is present. It spans two
subsystems and two cores:
- **Mesh side (Core 1), `MyMesh`**: the `loop()` runs a two-stage refresh driven by
`mqtt_neighbors_interval`. Stage 1 sends a zero-hop `sendNodeDiscoverReq()` and waits
+32 -26
View File
@@ -1,5 +1,12 @@
# MQTT Bridge Cross-Core Ownership Model
> **Historical design record, status reconciled 2026-08-18.** The ownership
> analysis was written during Phases 4-5. File/line references describe that
> snapshot and must be re-located before editing current code. Cooperative
> shutdown and the OTA barrier are implemented and hardware-characterized;
> immutable status publication and replacement of the remaining volatile
> NTP/reconfigure handshakes are still deferred.
This document is the Phase 4 deliverable from `STABILITY_TESTABILITY_HANDOFF.md`:
it records **one owner for each mutable runtime domain**, maps every place a
non-owner reads owned state across cores today, and states the target primitive
@@ -11,8 +18,9 @@ landed (Phase 4). **Phase 5 (branch `phase5/cooperative-mqtt-shutdown`) has now
implemented the cooperative shutdown and the OTA barrier -- hazard Section 4 below.**
Still **deferred** (carried to Phase 5b / Phase 6): publishing a plain-data
snapshot and repointing the Section 1/Section 2 consumers, and replacing the Section 3 `volatile`
handshakes with a command channel. This document is the plan; Section 1-Section 3 still
describe current behavior, while Section 4 is now resolved (see its note). Line
handshakes with a command channel. This document is a design record; Sections
1-3 describe hazards that still exist conceptually, while Section 4 is resolved.
Line
references are against the tree at the time of writing and should be re-verified
before editing.
@@ -44,7 +52,7 @@ The rule that follows: **loop/WebConfig/CLI/AlertReporter code must not directly
inspect mutable MQTT slot objects or client counters.** The MQTT task must
publish a plain-data snapshot they can read instead.
## Current cross-core hazards (to resolve in Phase 5)
## Remaining cross-core hazards after the minimal Phase 5 change
All of the following run on **Core 1** and read state mutated by **Core 0**
without a lock or a published snapshot.
@@ -122,12 +130,14 @@ had no double-call guard, and lifecycle state was a single `_initialized` bool.
bare bool.
- The OTA barrier gates `simple_repeater`'s deferred flash on a clean stop.
Not hardware-validated yet (Phase 7); `MQTT_STOP_TIMEOUT_MS` is a Phase-0
placeholder. Note the residual Section 1/Section 2 instance-pointer reads remain deferred, so
consumers can still (as before) touch a torn-down bridge -- that is unchanged by
Phase 5 and tracked above.
The shutdown path and timeout were subsequently hardware-characterized. The
flat 8-second placeholder was replaced by a per-stop budget of 5 seconds plus
8 seconds per enabled slot. A two-WSS-slot stop that previously timed out
acknowledged cleanly in about 11.7 seconds within its 21-second budget. The
residual Section 1/Section 2 instance-pointer reads remain deferred, so those
consumers can still touch live or torn-down bridge state as tracked above.
## Target primitives (Phase 5)
## Remaining target primitives
- **Task notifications or a command queue** for one-way lifecycle / reconfigure
/ NTP requests -- replacing every `volatile` flag in Section 3.
@@ -142,10 +152,10 @@ Phase 5 and tracked above.
## Lifecycle contract (the test seam)
`src/helpers/MQTTLifecycle.h` encodes the cooperative lifecycle Phase 5 must
implement, as a pure state machine plus a narrow injected `Ops` seam
`src/helpers/MQTTLifecycle.h` encodes the cooperative lifecycle implemented by
Phase 5 as a pure state machine plus a narrow injected `Ops` seam
(clock / task control / resource owner / OTA barrier). The invariants proven by
`test/test_mqtt_lifecycle/` -- and that Phase 5's production wiring must preserve:
`test/test_mqtt_lifecycle/` and preserved by the production wiring are:
- `Stopped -> Starting -> Running -> StopRequested -> Stopping -> Stopped`.
- Idempotent start and stop; safe restart only from `Stopped` (`mayRestart`).
@@ -161,25 +171,21 @@ implement, as a pure state machine plus a narrow injected `Ops` seam
acknowledgment; a timed-out stop leaves flashing blocked so OTA aborts rather
than writing under uncertain ownership.
### Phase 0 / hardware-pending items
### Hardware characterization result
Per the "derive from code + flag" discipline, these are **not** encoded as
constants and must be characterized on hardware before Phase 5 ships:
WSS teardown was measured as roughly 5-6 seconds per slot and is sequential.
Production now sets the coordinator timeout to `5 s + 8 s * enabled_slots`.
Representative non-PSRAM and PSRAM start/stop matrices found no downward heap
or largest-block trend; multi-day soak, task-stack high-water, and exhaustive
callback ordering remain outside that run.
- The concrete stop timeout (injected as `Coordinator`'s `stop_timeout_ms`;
must be measured against mbedTLS teardown over `wss` with a down broker).
- Exact callback timing/ordering under a real TLS disconnect.
- Heap / largest-block / task-stack high-water and task/client counts across
start/stop/restart (Phase 7 gates).
## Deferred to Phase 5 (not done here)
## Implementation status after Phase 5
- Publishing the plain-data snapshot and repointing the Section 1/Section 2 consumers at it.
- Replacing the Section 3 `volatile` handshakes with a command queue / task
notifications.
- The cooperative shutdown state machine in `MQTTBridge` (`end()` rewrite, the
`begin()` double-call guard) and the OTA teardown barrier.
- **Completed:** the cooperative shutdown state machine in `MQTTBridge`, the
`begin()` double-call guard, slot-scaled timeout, and OTA teardown barrier.
`MQTTBridge.cpp` was intentionally left untouched in Phase 4 to keep the
merge-sensitive file (~3.7k lines) free of churn until the Phase 5 change lands
as a single reviewable unit.
Historical note: `MQTTBridge.cpp` was intentionally untouched during Phase 4;
Phase 5 later made the planned lifecycle changes as one reviewable unit.
+20 -3
View File
@@ -111,11 +111,28 @@ The agent starts automatically once WiFi connects and `snmp_enabled = 1` in pref
## Build Configuration
SNMP is enabled at compile time with the `WITH_SNMP=1` build flag and included in observer firmware targets:
SNMP is enabled at compile time with the `WITH_SNMP=1` build flag. It is
currently included in these observer firmware targets:
- `heltec_v3_repeater_observer_mqtt`
- `Heltec_v3_repeater_observer_mqtt`
- `Heltec_v3_room_server_observer_mqtt`
- `Heltec_WSL3_repeater_observer_mqtt`
- `Heltec_WSL3_room_server_observer_mqtt`
- `heltec_v4_repeater_observer_mqtt`
- `heltec_v4_room_server_observer_mqtt`
- `heltec_v4_expansionkit_repeater_observer_mqtt`
- `heltec_v4_expansionkit_room_server_observer_mqtt`
- `Station_G2_repeater_observer_mqtt`
- `Station_G2_room_server_observer_mqtt`
- `Station_G3_ESP32_repeater_observer_mqtt`
- `Station_G3_ESP32_room_server_observer_mqtt`
- `RAK_3112_repeater_observer_mqtt`
- `RAK_3112_room_server_observer_mqtt`
- `ThinkNode_M7_repeater_observer_mqtt`
- `ThinkNode_M7_room_server_observer_mqtt`
The build flag in each variant's `platformio.ini` is authoritative; update this
list whenever SNMP is added to or removed from an environment.
To add SNMP to another observer variant, add the following to its `platformio.ini`:
@@ -146,4 +163,4 @@ lib_deps =
## Private Enterprise Number
The OIDs currently use a temporary unregistered enterprise number (`99999`). A proper Private Enterprise Number (PEN) can be registered with IANA at no cost at https://pen.iana.org/pen/PenApplication.page.
The OIDs currently use a temporary unregistered enterprise number (`99999`). A proper Private Enterprise Number (PEN) can be registered with IANA at no cost at https://www.iana.org/assignments/enterprise-numbers/assignment/apply/.
+9 -8
View File
@@ -18,7 +18,7 @@ MeshCore provides the ability to create wireless mesh networks, similar to Mesht
* Multi-Hop Packet Routing
* Devices can forward messages across multiple nodes, extending range beyond a single radio's reach.
* Supports up to a configurable number of hops to balance network efficiency and prevent excessive traffic.
* Nodes use fixed roles where "Companion" nodes are not repeating messages at all to prevent adverse routing paths from being used.
* Companion nodes do not repeat by default. Supported Companion builds can opt into bounded client repeating on permitted frequencies, while dedicated repeaters remain the normal way to extend coverage.
* Supports LoRa Radios - Works with Heltec, RAK Wireless, and other LoRa-based hardware.
* Decentralized & Resilient - No central server or internet required; the network is self-healing.
* Low Power Consumption - Ideal for battery-powered or solar-powered devices.
@@ -73,8 +73,8 @@ The companion firmware can be connected to via BLE, USB or Wi-Fi depending on th
- Web: https://app.meshcore.nz
- Android: https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android
- iOS: https://apps.apple.com/us/app/meshcore/id6742354151?platform=iphone
- NodeJS: https://github.com/liamcottle/meshcore.js
- Python: https://github.com/fdlamotte/meshcore-cli
- NodeJS: https://github.com/meshcore-dev/meshcore.js
- Python: https://github.com/meshcore-dev/meshcore-cli
**Repeater and Room Server Firmware**
@@ -100,7 +100,7 @@ For minor changes just submit your PR and we'll try to review it, but for anythi
Here are some general principles you should try to adhere to:
* Keep it simple. Please, don't think like a high-level lang programmer. Think embedded, and keep code concise, without any unnecessary layers.
* No dynamic memory allocation, except during setup/begin functions.
* Use the same brace and indenting style that's in the core source modules. (A .clang-format is probably going to be added soon, but please do NOT retroactively re-format existing code. This just creates unnecessary diffs that make finding problems harder)
* Follow the repository's `.clang-format` and the surrounding source style. Do not retroactively reformat unrelated code; that creates noisy diffs and makes functional changes harder to review.
Help us prioritize! Please react with thumbs-up to issues/PRs you care about most. We look at reaction counts when planning work.
@@ -120,15 +120,16 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] Standardise Bridge mode for repeaters
- [ ] Repeater/Bridge: Standardise the Transport Codes for zoning/filtering
- [X] Core + Repeater: enhanced zero-hop neighbour discovery
- [ ] Core: round-trip manual path support
- [ ] Companion + Apps: support for multiple sub-meshes (and 'off-grid' client repeat mode)
- [X] Core + Full Companion: round-trip trace and manual path support
- [X] Companion: opt-in off-grid client repeat mode
- [ ] Companion + Apps: support for multiple sub-meshes
- [ ] Core + Apps: support for LZW message compression
- [ ] Core: dynamic CR (Coding Rate) for weak vs strong hops
- [X] Core: adaptive CR (Coding Rate) for direct retries using recently heard SNR
- [ ] Core: new framework for hosting multiple virtual nodes on one physical device
- [ ] V2 protocol spec: discussion and consensus around V2 packet protocol, including path hashes, new encryption specs, etc
## [TELEPHONE] Get Support
- Report bugs and request features on the [GitHub Issues](https://github.com/ripplebiz/MeshCore/issues) page.
- Report bugs and request features on the [GitHub Issues](https://github.com/meshcore-dev/MeshCore/issues) page.
- Find additional guides and components on [my site](https://buymeacoffee.com/ripplebiz).
- Join [MeshCore Discord](https://meshcore.gg) to chat with the developers and get help from the community.
+23 -22
View File
@@ -1,5 +1,10 @@
# Restoring accidentally-reverted upstream features
> **Historical recovery record, reconciled 2026-08-18.** Phase 1 and Phase 2
> described below have been implemented in the current tree. This file explains
> why the restoration occurred; use the CLI and build documentation for current
> behavior rather than treating the old “remaining” text as an active plan.
## Background
On 2026-03-20, commit `22eb9b87` - *Revert "Merge remote-tracking branch 'origin/dev' into mqtt-bridge-implementation"* - reverted an entire upstream merge to escape a bad merge state, deleting 860 lines across 66 files. That was not intentional feature removal; it wholesale dropped a batch of upstream progress. When upstream was later re-merged, some collateral came back (MicroNMEA `claim()/release()`, the GAT562 board) but several upstream features were never reconciled and remained missing.
@@ -51,8 +56,10 @@ on a Heltec V4.2 (busy live mesh + off-frequency bench):**
`stats-radio-diag` `err_flags`; the watchdog arms only after first radio activity
(`last_active > 0`), so a radio wedged from boot is deliberately not covered.
Remaining before undraft: passive soak at fleet-default `airtime_factor` (duty
convergence, flat heap, adverts still advertised).
At the time of that review, the remaining undraft recommendation was a passive
soak at the fleet-default `airtime_factor` (duty convergence, flat heap, and
continued adverts). That sentence is retained as historical validation scope,
not as the current branch's merge state.
### Known interaction: throttling starves MQTT capture (mitigated)
@@ -69,26 +76,20 @@ observer's purpose. Mitigated on observer builds by `RxReservePacketManager`
reserve (own responses/ACKs stay queueable) plus 30 s expiry of stale queued
outbound. See MQTT_INTERNALS.md "Capture vs. duty-cycle throttling".
## Phase 2 - CAD and FEM RX gain (NOT done; needs care + device testing)
## Phase 2 - CAD and FEM RX gain (completed)
Still missing at HEAD, also dropped by `22eb9b87`, still present upstream:
The current tree contains both restored capabilities:
- **`cad_enabled`** - hardware Channel Activity Detection (listen-before-talk) before TX.
The `Dispatcher`/`Radio` interface (`setCADEnabled`/`getCADEnabled`) is restored by
Phase 1, so CAD currently stays **off by default** (unchanged behavior). To make it
configurable again, restore:
- `NodePrefs.cad_enabled` field, its CLI get/set, and its persistence in
`CommonCLI.cpp` (`loadPrefsInt`/`savePrefs`). **Offset care:** this branch's
`/com_prefs` layout is carefully managed - add `cad_enabled` following the same
append-and-size-guard pattern used for `rx_boosted_gain`/`flood_max_*`, and add a
host-side round-trip test (see `scratchpad/migtest`).
- The `MyMesh::getCADEnabled()` override returning `_prefs.cad_enabled`.
- `RadioLibWrappers::setCADEnabled()` override so the hardware CAD is actually driven
(the fork's wrapper currently doesn't override it).
- **`radio_fem_rxgain`** - LoRa front-end-module RX gain. Restore `NodePrefs.radio_fem_rxgain`
+ CLI + persistence (same offset care), plus the per-board FEM wiring reverted across
~20 `variants/*/target.cpp` and the `heltec_tracker_v2/LoRaFEMControl.{cpp,h}` files.
This is board-specific and only affects FEM-equipped hardware.
- **`cad_enabled`** is present in common preferences, load/save paths, and the
`get/set cad` CLI. Repeater, room-server, sensor, and Companion integrations
feed it to `RadioLibWrapper::setCADEnabled()`. Target-default builds default
CAD off; the Cascade profile supplies `DEFAULT_CAD_ENABLED=1`.
- **`radio_fem_rxgain`** is persisted and exposed as
`get/set radio.fem.rxgain`. Supported boards implement
`setLoRaFemLnaEnabled()`; unsupported boards reject the operation rather than
claiming a state change. Companion protocol v13 also exposes FEM RX-gain get
and set commands.
Phase 2 is lower urgency than duty-cycle enforcement (CAD/FEM are capability gaps, not a
compliance regression) and is best done as its own change with per-board hardware testing.
See [CLI commands](docs/cli_commands.md) and the
[command availability matrix](docs/cli_command_availability.md) for current
syntax and build/hardware limits.
+5 -2
View File
@@ -7,8 +7,11 @@ fixes to older versions.
| Version | Supported |
|---------|-----------|
| 1.15+ | [OK] |
| <1.15 | [X] |
| Latest published release | [OK] |
| Older releases | [X] |
The table is intentionally release-relative rather than tied to a minor version:
when a new release is published, the previously latest release becomes unsupported.
## Reporting a Vulnerability
+51 -40
View File
@@ -1,12 +1,18 @@
# Stability, Testability, and Upstream-Merge Handoff
> **Archived roadmap snapshot.** This document records work and hardware results
> through 2026-08-03; it is no longer the live plan of record. Internal status
> sections are retained to explain the sequence in which work landed. For the
> current tree, use [test/README.md](test/README.md),
> [MQTT_OWNERSHIP.md](MQTT_OWNERSHIP.md), and the implementation itself. The
> status corrections below were reconciled on 2026-08-18.
## Purpose
This document is the plan of record for refining the fork-owned WebConfig and
MQTT observer code after the initial policy extraction and host-test work. The
goal is to improve runtime stability, long-uptime confidence, and serviceability
without broad rewrites of upstream-heavy files or creating unnecessary merge
conflicts.
This document was the plan of record for refining the fork-owned WebConfig and
MQTT observer code after the initial policy extraction and host-test work. It is
kept as a dated engineering record, not as an instruction to resume every item
whose original phase text says “pending.”
The order is deliberate: cheap CI and persistence guardrails land first, then
tests and ownership boundaries needed to make lifecycle work safe, and only
@@ -15,11 +21,10 @@ result; it is not the first line of defense for the riskiest change.
## Roadmap Status
The guardrail phases have already landed on this branch; the remaining work is
the lifecycle refactor and its safety net. Phases are intentionally not
renumbered so cross-references and the completed acceptance criteria stay stable;
each phase below carries an explicit status line, and this table is the quick
index.
At the final recorded state, the guardrails and minimal lifecycle refactor had
landed, while the table still tracked validation and explicitly deferred work.
Phases are intentionally not renumbered so historical cross-references and
acceptance criteria stay stable.
| Phase | Scope | Status |
|-------|-------|--------|
@@ -27,18 +32,18 @@ index.
| 2 | PSRAM restart resource symmetry | Done |
| 3 | MQTT preference migration fixtures | Done (filesystem adapter still lives in `CommonCLI`) |
| 0 | Pre-change lifecycle characterization | Hardware run 2026-07-19 (see "Hardware Characterization Results"): teardown timing measured on V3+V4. Finding: flat `MQTT_STOP_TIMEOUT_MS=8000` too small -> **FIXED** with slot-scaled timeout (`5s + 8sxslots`), hardware-verified (2-slot stop now clean) |
| 4 | Ownership and teardown test seams | Seams + ownership doc + teardown tests done; production rewiring deferred to Phase 5 |
| 5 | Cooperative MQTT shutdown | Minimal cooperative `end()` + `begin()` guard + OTA barrier implemented on branch `phase5/cooperative-mqtt-shutdown` (native green, firmware smoke build green); NOT hardware-validated. Volatile-handshake replacement + snapshot-consumer repointing deferred |
| -- | OTA teardown barrier | Implemented -- flash gated on a clean MQTT stop in `simple_repeater`; not hardware-validated |
| 4 | Ownership and teardown test seams | Seams + ownership doc + teardown tests done. Minimal production lifecycle wiring landed in Phase 5; snapshots and remaining handshake replacement stayed deferred |
| 5 | Cooperative MQTT shutdown | Minimal cooperative `end()` + `begin()` guard + OTA barrier implemented and hardware-characterized; slot-scaled stop timeout verified with a two-WSS-slot clean stop. Volatile-handshake replacement + snapshot-consumer repointing remain deferred |
| -- | OTA teardown barrier | Implemented; clean/dirty latch inputs hardware-verified. The live deferred `ota update` flash action was not exercised end-to-end in the recorded bench run |
| 6 | Request/queue/connection/publication integration tests | Partial: WiFi-backoff + publish-outcome + enum-alignment gaps extracted and host-tested; WebConfig batch/reboot/stop spec (`WebConfigBatch.h`) **now wired into `WebConfigServer.cpp`** (2026-07-19) so the host tests cover production; queue-orchestration coverage still open, and the wired path is not yet exercised over real HTTP |
| 7 | Uptime, memory, and fault-injection gates | Representative HW matrix run 2026-07-19: V3 non-PSRAM + V4 PSRAM done (no leak/crash; forced-path OTA-withhold + ~15-27 s loop stall observed). Multi-day soak + stack-HWM build pending |
| -- | Non-PSRAM neighbors publication | Enabled on the ESP32-S3 non-PSRAM observer envs via `MQTT_NEIGHBORS_WITHOUT_PSRAM` (`feat/non-psram-neighbors`). Bench-verified 2026-08-03 at the 2-wss-slot non-PSRAM maximum (see "Hardware Characterization -- Non-PSRAM Neighbors"). Found and fixed a pre-existing PSRAM bug on the dev/beta channel: the JSON pool budget starved at ~40+ neighbours and dropped the whole publish (`34037f20`). Production is on ArduinoJson v6 and unaffected. Truncation above 20 neighbours is still unverified on hardware. |
| -- | Upstream merge (latest) | `upstream/dev` `9d902e63` merged 2026-08-03 as `126a2564`: 13 commits, 6 files, zero conflicts. Carries an LR1110 RX-timeout fix affecting the ThinkNode M7 observer envs and switches all nRF52 boards to CC310 hardware Ed25519. See "Upstream Merge Record -- 2026-08-03". It exposed that nRF52/RP2040 had been unbuildable since 2026-04-10; nRF52 was fixed in `45379ad7`, while RP2040 remained open. |
| -- | Upstream merge (recorded 2026-08-03) | `upstream/dev` `9d902e63` merged 2026-08-03 as `126a2564`: 13 commits, 6 files, zero conflicts. Carries an LR1110 RX-timeout fix affecting the ThinkNode M7 observer envs and switches all nRF52 boards to CC310 hardware Ed25519. See "Upstream Merge Record -- 2026-08-03". It exposed that nRF52/RP2040 had been unbuildable since 2026-04-10; nRF52 was fixed in `45379ad7`, while RP2040 remained open. |
| -- | Upstream merge | `upstream/dev` merged 2026-07-19 on `observer-firmware-dev` (191 commits, 14 conflicted files). See "Upstream Merge Record". Not yet promoted to `webconfig` |
| -- | Dev release channel | `observer-firmware-dev` publishes the dev/beta firmware channel (see "Release Channels"). Manual dispatch; separate from production |
Phases 0, 4, and 5 (with the OTA teardown barrier) are landed and
hardware-validated. **Remaining work, in execution order:**
Phases 0, 4, and 5 (with the OTA teardown barrier) were landed and
hardware-validated. **The remaining-work list recorded at that snapshot was:**
1. Exercise the wired WebConfig batch machine over real HTTP (Phase 6) -- the
only untested part of a change that is already in the branch.
@@ -56,9 +61,9 @@ Do not reopen a "Done" phase without a deliberate reason (see
## Hardware Characterization Results (Phase 0 & Phase 7) -- 2026-07-19
Hardware run of the outstanding Phase 0 (pre-change/cooperative teardown timing)
and Phase 7 (uptime/memory/fault-injection) items against two live observer
nodes. **Not yet committed as a plan change -- this section records measured
results and a release-gating recommendation for review.**
and representative Phase 7 (uptime/memory/fault-injection) items against two
live observer nodes. This section records the measured results; it does not
claim the unrun multi-day soak or stack-HWM work was completed.
### Setup
@@ -281,7 +286,7 @@ covers on dev.
- Single bench run; no multi-day soak, and no stack high-water-mark build. The
4752->304 byte stack-frame reduction was verified by disassembly, not at runtime.
## Current Baseline
## Baseline recorded by this handoff
The current branch has:
@@ -290,7 +295,8 @@ The current branch has:
construction, WebConfig keys, the WebConfig batch state machine, the MQTT
lifecycle/teardown seam, the `/mqtt_prefs` codec, the atomic prefs store, the
runtime-buffer lifecycle, and upstream `Utils::toHex` and mesh-table behavior.
15 suites as of the 2026-07-19 upstream merge.
15 suites as of the 2026-07-19 upstream merge. The 2026-08-18 tree has 49
suite directories; use [test/README.md](test/README.md) for the current list.
- ArduinoJson pinned to 7.4.3 across the native and all firmware environments,
enforced in CI by `scripts/check_arduinojson_pin.py`.
- PR CI (`.github/workflows/`) that runs the native suite and compiles both
@@ -390,8 +396,10 @@ persistence change across a fleet of thousands of devices is not.
### Phase 0: Record the pre-change lifecycle characterization
**Status: Not started -- the next actionable step, and prerequisite for Phases 4
and 5.**
**Final recorded status: representative hardware characterization completed
2026-07-19.** It found the flat 8-second stop timeout was too short and led to
the verified slot-scaled timeout described earlier. Task-stack HWM and a
multi-day soak remained open.
Capture current behavior before changing shutdown mechanics. This provides a
reference for the lifecycle fakes and lets the later state-machine refactor
@@ -520,17 +528,16 @@ Acceptance criteria:
### Phase 4: Establish ownership and teardown test seams
**Status: Seams, ownership doc, and teardown tests landed; production rewiring
deferred to Phase 5 by explicit decision (scope: "seams + tests only").** The
**Final recorded status: seams, ownership doc, and teardown tests landed;
cooperative production shutdown and the OTA barrier subsequently landed in
Phase 5.** The
fork-owned pure lifecycle state machine and narrow dependency seam
(`src/helpers/MQTTLifecycle.h`), the teardown-focused test matrix
(`test/test_mqtt_lifecycle/`), and the ownership model (`MQTT_OWNERSHIP.md`) are
in place. `MQTTBridge.cpp` was intentionally left untouched to keep the
merge-sensitive file free of churn until the Phase 5 change lands as one
reviewable unit. The invasive production work -- publishing the plain-data
snapshot and repointing consumers at it, replacing the `volatile` handshakes
with a command queue / task notifications, and the cooperative-shutdown `end()`
rewrite with a `begin()` double-call guard -- is carried into Phase 5.
in place. `MQTTBridge.cpp` was intentionally left untouched during Phase 4.
Publishing the plain-data snapshot and replacing the remaining `volatile`
NTP/reconfigure handshakes were not included in the later minimal Phase 5
change and remain deferred.
Verified premise (with Phase 4 refinements): the loop task, WebConfig, CLI, and
`AlertReporter` currently read the MQTT task's live, mutable slot objects and
@@ -603,8 +610,10 @@ Required teardown-focused tests:
### OTA Teardown Barrier: release-critical scenario
**Status: Not started. This is the fix for a known shipping crash, not a
hypothetical hardening target.** With a broker down over `wss`, the abrupt
**Final recorded status: implemented.** Clean and timed-out stop inputs were
hardware-verified; the live deferred `ota update` flash action itself was not
driven end-to-end in the recorded bench run. The original failure premise was:
with a broker down over `wss`, the abrupt
`vTaskDelete` in `end()` can kill the MQTT task inside mbedTLS; `destroySlotClients()`
then frees client buffers on a possibly-corrupted heap and OTA begins flashing
with no barrier -- the observed teardown heap panic. There is no coordination
@@ -639,11 +648,11 @@ to bridge teardown, OTA sequencing, MQTT client lifetime, or task ownership.
### Phase 5: Implement cooperative MQTT shutdown
**Status: Minimal cooperative shutdown implemented on branch
`phase5/cooperative-mqtt-shutdown` (scope: "the smallest change that fixes the
OTA teardown panic as one reviewable unit"). Native suite green; the non-PSRAM
observer firmware smoke build compiles. NOT yet hardware-validated -- that is the
Phase 7 gate -- and the stop timeout is a Phase-0 placeholder (see below).**
**Final recorded status: minimal cooperative shutdown implemented and
hardware-characterized.** Native tests and representative firmware builds were
green. The original flat timeout was replaced with the slot-scaled timeout and
a two-WSS-slot clean stop was verified on hardware. Immutable status snapshots
and replacement of the remaining volatile handshakes were outside this phase.
What landed (wiring the Phase 4 `MQTTLifecycle` state machine into the bridge):
@@ -782,8 +791,10 @@ prerequisite for the stability work.
### Phase 7: Establish uptime, memory, and fault-injection gates
**Status: Not started.** The final validation gate; runs after the lifecycle and
OTA-barrier work is in place.
**Final recorded status: partial.** Representative V3 non-PSRAM and V4 PSRAM
fault-injection/start-stop matrices were run without a leak or crash. The
multi-day soak, stack-HWM build, and the complete network/OTA matrix below were
not run.
Use hardware soak tests to validate the already-tested design, not to discover
basic lifecycle errors for the first time.
+21 -18
View File
@@ -1,5 +1,13 @@
# WebConfig Branch Review
> **Historical review (2026-07-18).** This file preserves the findings against
> the commits named below; its line numbers and present-tense statements are not
> a current audit of the 2026-08-18 tree. Consult [docs/WiFi.md](docs/WiFi.md),
> [MQTT_IMPLEMENTATION.md](MQTT_IMPLEMENTATION.md), and
> [test/README.md](test/README.md) for current operation and test coverage.
> Findings are left in their original form unless a resolution is explicitly
> recorded.
## Scope
This review covers the fork-owned WebConfig and Heltec Tracker additions on the `webconfig` branch, principally commits `d7a7e1b6`, `dfee21a0`, and `639c07a4`, plus the fork-owned MQTT/CLI paths they invoke. Issues inherited unchanged from `meshcore-dev/MeshCore` are intentionally excluded.
@@ -10,7 +18,7 @@ No implementation changes are included in this document.
The portal builds successfully and has a sound high-level design: HTTP handlers avoid directly running CLI/radio operations, configuration writes are marshalled to the loop task, secrets are represented by placeholders, and the UI is self-contained for offline provisioning.
Before deployment, the most important work is:
At review time, the most important work was:
1. Secure setup/forced-AP reachability.
2. Correlate each save with its own result.
@@ -389,7 +397,7 @@ The v1.1 environment reuses the V2 board implementation, whose manufacturer name
Return `Heltec Tracker V1.1` when `HELTEC_TRACKER_V1_1` is defined, and retain the V2 value otherwise. Add a build-time or host-side assertion for both target identities.
### 20. WebConfig operation and security behavior are undocumented
### 20. WebConfig operation and security behavior are undocumented -- resolved
**Severity:** Documentation gap
@@ -398,22 +406,13 @@ Return `Heltec Tracker V1.1` when `HELTEC_TRACKER_V1_1` is defined, and retain t
- Commands added in `src/helpers/CommonCLI_Observer.cpp:982-994`
- `MQTT_IMPLEMENTATION.md`
**Problem:**
**Resolution:**
The build targets are documented, but operators cannot discover `start webconfig`, `start webconfig ap`, `stop webconfig`, first-boot AP behavior, authentication, timeout, or the security implications of setup mode.
**Suggested fix:**
Add an operator section to `MQTT_IMPLEMENTATION.md` covering:
- first-boot setup behavior;
- AP name and setup credential;
- LAN versus AP modes;
- exact CLI commands;
- authentication requirements;
- idle and absolute timeout behavior;
- how Wi-Fi changes are applied;
- how to recover through serial if provisioning fails.
Current operator documentation covers first-boot setup, LAN and forced-AP
modes, `start webconfig`, `start webconfig ap`, `stop webconfig`, authentication,
timeouts, credential handling, and recovery. See
[WiFi and MQTT by Firmware Type](docs/WiFi.md#mqtt-observer-setup) and
[MQTT Implementation](MQTT_IMPLEMENTATION.md#browser-setup-recommended).
### 21. Repeater and room-server integration is duplicated
@@ -467,7 +466,11 @@ Remove it from production environments or create explicit debug variants. Confir
## Test Recommendations
No WebConfig-specific automated tests were found. Add focused tests for:
No WebConfig-specific automated tests were present at review time. The current
tree has host coverage for WebConfig keys and the wired batch state machine in
`test_webconfig_keys` and `test_webconfig_batch`; see [test/README.md](test/README.md).
The following list is retained as the original recommendation, including
integration and hardware scenarios not implied by those host suites:
1. Save request/result correlation, including stale and concurrent batches.
2. Partial command failures and reboot gating.
@@ -129,17 +129,17 @@ hits the memory, the `sync` function can simply return 0.
## Reference material
[DESIGN.md](DESIGN.md) - DESIGN.md contains a fully detailed dive into how
[DESIGN.md](https://github.com/littlefs-project/littlefs/blob/v1.7.0/DESIGN.md) - DESIGN.md contains a fully detailed dive into how
littlefs actually works. I would encourage you to read it since the
solutions and tradeoffs at work here are quite interesting.
[SPEC.md](SPEC.md) - SPEC.md contains the on-disk specification of littlefs
[SPEC.md](https://github.com/littlefs-project/littlefs/blob/v1.7.0/SPEC.md) - SPEC.md contains the on-disk specification of littlefs
with all the nitty-gritty details. Can be useful for developing tooling.
## Testing
The littlefs comes with a test suite designed to run on a PC using the
[emulated block device](emubd/lfs_emubd.h) found in the emubd directory.
[emulated block device](https://github.com/littlefs-project/littlefs/blob/v1.7.0/emubd/lfs_emubd.h) found in the upstream emubd directory.
The tests assume a Linux environment and can be started with make:
``` bash
@@ -157,21 +157,19 @@ Individual files contain the following tag instead of the full license text.
SPDX-License-Identifier: BSD-3-Clause
This enables machine processing of license information based on the SPDX
License Identifiers that are here available: http://spdx.org/licenses/
License Identifiers available at https://spdx.org/licenses/
## Related projects
[Mbed OS](https://github.com/ARMmbed/mbed-os/tree/master/features/filesystem/littlefs) -
The easiest way to get started with littlefs is to jump into [Mbed](https://os.mbed.com/),
which already has block device drivers for most forms of embedded storage. The
littlefs is available in Mbed OS as the [LittleFileSystem](https://os.mbed.com/docs/latest/reference/littlefilesystem.html)
class.
[Mbed OS](https://github.com/ARMmbed/mbed-os/tree/master/storage/filesystem/littlefs) -
The archived Mbed OS tree includes a `LittleFileSystem` wrapper and block-device
drivers for several forms of embedded storage.
[littlefs-fuse](https://github.com/geky/littlefs-fuse) - A [FUSE](https://github.com/libfuse/libfuse)
[littlefs-fuse](https://github.com/littlefs-project/littlefs-fuse) - A [FUSE](https://github.com/libfuse/libfuse)
wrapper for littlefs. The project allows you to mount littlefs directly on a
Linux machine. Can be useful for debugging littlefs if you have an SD card
handy.
[littlefs-js](https://github.com/geky/littlefs-js) - A javascript wrapper for
[littlefs-js](https://github.com/littlefs-project/littlefs-js) - A JavaScript wrapper for
littlefs. I'm not sure why you would want this, but it is handy for demos.
You can see it in action [here](http://littlefs.geky.net/demo.html).
+1 -1
View File
@@ -2419,7 +2419,7 @@ apply_radio_overrides() {
apply_firmware_profile_overrides() {
case "${FIRMWARE_PROFILE_OVERRIDE,,}" in
cascade)
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCASCADE_PROFILE=1 -DDEFAULT_PATH_HASH_MODE=2 -DDEFAULT_LOOP_DETECT=1 -DDEFAULT_CAD_ENABLED=1 -DDEFAULT_RX_DELAY_BASE=2.0f -DDEFAULT_AGC_RESET_INTERVAL_SECONDS=8 -DDEFAULT_ADVERT_INTERVAL_MINUTES=0 -DDEFAULT_FLOOD_ADVERT_INTERVAL_HOURS=83 -DDEFAULT_MULTI_ACKS=1 -DDEFAULT_MANUAL_ADD_CONTACTS=1 -DDEFAULT_AUTOADD_CONFIG=0"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCASCADE_PROFILE=1 -DDEFAULT_PATH_HASH_MODE=2 -DDEFAULT_LOOP_DETECT=1 -DDEFAULT_CAD_ENABLED=1 -DDEFAULT_RX_DELAY_BASE=2.0f -DDEFAULT_AGC_RESET_INTERVAL_SECONDS=8 -DDEFAULT_ADVERT_INTERVAL_MINUTES=0 -DDEFAULT_FLOOD_ADVERT_INTERVAL_HOURS=83 -DDEFAULT_MULTI_ACKS=1 -DDEFAULT_MANUAL_ADD_CONTACTS=1 -DDEFAULT_AUTOADD_CONFIG=0 -DDEFAULT_POWERSAVING_ENABLED=1 -DDEFAULT_RXPS_ENABLED=1 -DDEFAULT_RXPS_LEVEL=8 -DDEFAULT_RXPS_PREAMBLE=16 -DRXPS_FIXED_ENABLED=1 -DRXPS_FIXED_LEVEL=8 -DRXPS_FIXED_PREAMBLE=16"
;;
esac
}
+13 -7
View File
@@ -93,7 +93,7 @@ best effort rather than durable storage. Each enabled slot publishes
independently, so one failed broker does not intentionally stop the other
slots.
See [MQTT_IMPLEMENTATION.md](../MQTT_IMPLEMENTATION.md) for the complete preset
See [MQTT_IMPLEMENTATION.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md) for the complete preset
list, custom broker configuration, topic formats, authentication, diagnostics,
and memory limits.
@@ -203,12 +203,18 @@ WiFi companions do not have the repeater/room-server admin CLI password model,
so their LAN WebConfig page is intentionally unauthenticated. Use them only on
a trusted LAN.
On radio chips that support receive duty cycling, the WebConfig **Advanced**
card also exposes RX power saving. Its master switch selects continuous receive
when off or RX/sleep duty cycling when on. Levels 1-10, automatic or explicit
16/32-symbol preambles, and manual receive/sleep windows are persisted across
reboots. This radio setting is separate from whole-device sleep; the WiFi
companion remains awake so its TCP service and configuration page stay reachable.
The WebConfig **Advanced** card exposes device power saving on WiFi Companion,
repeater, and room-server builds. It also exposes RX power saving on radio chips
that support receive duty cycling. RXPS can select continuous receive, levels
1-10, automatic or explicit 16/32-symbol preambles, or manual receive/sleep
windows. Both settings are persisted across reboots.
The two settings are independent. A WiFi Companion keeps its transports
available while device power saving reduces CPU and GPS idle power. An
infrastructure node can sleep when device power saving is enabled, so its WiFi
services may be temporarily unavailable. RXPS only duty-cycles the LoRa
receiver. Fresh Cascade-profile builds default to device power saving on and
RXPS on at level 8 with a 16-symbol preamble.
When `ENABLE_OTA` is included, a WiFi companion also listens on:
+6 -4
View File
@@ -75,8 +75,10 @@ matching FULL ESP32 build when those commands are required.
Some observer commands have their own hardware limit:
- MQTT neighbor-table publishing requires PSRAM. `discover.scopes` also
requires PSRAM and the FULL MQTT parser; the portable MQTT profile omits it.
- MQTT neighbor-table publishing and `discover.scopes` require the compiled
`WITH_MQTT_NEIGHBORS` feature. PSRAM boards enable it automatically; selected
non-PSRAM variants opt in with `MQTT_NEIGHBORS_WITHOUT_PSRAM`. The commands
can therefore be present in either portable or FULL MQTT profiles.
- `discover.neighbors` does **not** require MQTT or PSRAM.
- full NTP connectivity diagnostics are omitted from the portable profile.
@@ -113,8 +115,8 @@ does not exist on that target:
room-server builds support the corresponding WiFi setters and status
commands.
- MQTT commands require an MQTT observer target.
- `discover.scopes` requires a FULL MQTT build, MQTT neighbor support, and
PSRAM.
- `discover.scopes` requires an MQTT observer with compiled neighbor support;
it does not independently require PSRAM or the FULL parser.
- GPS and external-sensor commands require their drivers and pins.
- Ethernet and bridge commands require the corresponding transport.
- LoRa OTA commands require an artifact with OTA enabled.
+36 -37
View File
@@ -32,7 +32,6 @@ Cell values mean:
- **Feature** - available only when the target compiles the feature or hardware
named in Scope.
- **Serial** - available only from the local serial console.
- **PSRAM** - available only on an MQTT target with PSRAM neighbor support.
- **Manifest** - available only when the MQTT target defines
`OTA_MANIFEST_BASE`.
- **Limited** - the family exists, but the limitation in Scope applies.
@@ -63,12 +62,12 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Neighbors | [`neighbors`](cli_commands.md#list-nearby-neighbors) | Role with a neighbor table | Yes | Yes | Yes |
| Neighbors | [`neighbor.remove <pubkey_prefix>`](cli_commands.md#remove-a-neighbor) | Role with a mutable neighbor table | Yes | Yes | Yes |
| Neighbors | [`discover.neighbors`](cli_commands.md#discover-zero-hop-neighbors) | Repeater; some MQTT room servers | Yes | Yes | Yes |
| Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-psram-only) | MQTT observer with PSRAM | No | No | No |
| Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No |
| Statistics | [`clear stats`](cli_commands.md#clear-stats) | All full-parser text CLI roles | Yes | Yes | Yes |
| Statistics | [`stats-core`](cli_commands.md#system-stats---battery-uptime-queue-length-and-debug-flags) | Local serial | Serial | Serial | Serial |
| Statistics | [`stats-radio`](cli_commands.md#radio-stats---noise-floor-last-rssisnr-airtime-receive-errors) | Local serial | Serial | Serial | Serial |
| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local serial | Serial | Serial | Serial |
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local serial | Serial | Serial | Serial |
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial |
| Statistics | [`stats-packets`](cli_commands.md#packet-stats---packet-counters-received-sent) | Local serial | Serial | Serial | Serial |
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local serial | Serial | Serial | Serial |
| Statistics | [`get telemetry.temp/volt`; optional GPS history; `get/set telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS commands require a GPS provider; remote access requires administrator | Yes | Yes | Yes |
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data; other roles can return empty data | Yes | Yes | Yes |
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial |
@@ -160,7 +159,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Ethernet | [`eth.status`](cli_commands.md#view-ethernet-connection-status) | Ethernet target | Feature | Feature | No |
| Browser OTA | [`start ota [ap]`; `stop ota`](cli_commands.md#start-or-stop-an-over-the-air-ota-firmware-update) | ESP32 browser uploader | No | No | No |
| WebConfig | [`start webconfig [ap]`; `stop webconfig`; `get/set webui`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | ESP32 WebConfig | No | No | No |
| WiFi | [`get/set wifi.ssid`; `set wifi.pwd`; `get wifi.status`; `get/set wifi.powersave`](../MQTT_IMPLEMENTATION.md#wifi-commands) | ESP32 WiFi | No | No | No |
| WiFi | [`get/set wifi.ssid`; `set wifi.pwd`; `get wifi.status`; `get/set wifi.powersave`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#wifi-commands) | ESP32 WiFi | No | No | No |
| WiFi | [`get/set wifi.cli`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | ESP32 WebConfig | No | No | No |
| LoRa OTA | [`ota help`; `ota ?`; `ota h`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota`; `ota status`; `ota st`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
@@ -168,7 +167,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| LoRa OTA | [`ota ls`; `ota neighbors`; `ota nbrs`; `ota updates`; `ota n`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build; nRF52 installs in-place deltas | No | No | Yes |
| LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible bootloader and completed update | No | No | Yes |
| LoRa OTA | [`ota rescue install <base_hash16>`](ota_protocol.md#12-apply--bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature |
| LoRa OTA | [`ota rescue install <base_hash16>`](ota_protocol.md#12-apply-bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature |
| LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes |
@@ -176,24 +175,24 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| LoRa OTA | [`ota config`; `ota cfg`; `ota set`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota key`; `ota keys`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes |
| LoRa OTA | [`ota dev ...`](ota_protocol.md#11-cli-surface-otaclicpp) | Developer diagnostics | No | No | Yes |
| MQTT | [`get/set mqttN.preset`](../MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No |
| MQTT | [`get/set mqttN.server`; `get/set mqttN.port`; `get/set mqttN.username`; `get/set mqttN.password`](../MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No |
| MQTT | [`get/set mqttN.token`; `get/set mqttN.topic`; `get/set mqttN.audience`](../MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No |
| MQTT | [`get/set mqttN.preset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No |
| MQTT | [`get/set mqttN.server`; `get/set mqttN.port`; `get/set mqttN.username`; `get/set mqttN.password`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No |
| MQTT | [`get/set mqttN.token`; `get/set mqttN.topic`; `get/set mqttN.audience`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No |
| MQTT | [`get mqttN.diag`](#mqtt-slot-diagnostics) | MQTT observer | No | No | No |
| MQTT | [`get/set mqtt.origin`; `get/set mqtt.iata`; `get mqtt.presets`](../MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No |
| MQTT | [`get/set mqtt.origin`; `get/set mqtt.iata`; `get mqtt.presets`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No |
| MQTT | [`get mqtt.stats`](#mqtt-stats) | MQTT observer | No | No | No |
| MQTT | [`get/set mqtt.status`; `get/set mqtt.packets`; `get/set mqtt.raw`; `get/set mqtt.interval`](../MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No |
| MQTT | [`get/set mqtt.status`; `get/set mqtt.packets`; `get/set mqtt.raw`; `get/set mqtt.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No |
| MQTT | [`get/set mqtt.rx`](cli_commands.md#view-or-change-mqtt-rx-packet-uplinking); [`get/set mqtt.tx`](cli_commands.md#view-or-change-mqtt-tx-packet-uplinking) | MQTT observer | No | No | No |
| MQTT | [`get/set mqtt.neighbors`](cli_commands.md#view-or-change-periodic-neighbors-publishing-mqtt-observer-psram-only); [`get/set mqtt.neighbors.interval`](cli_commands.md#view-or-change-the-neighbors-publish-interval-mqtt-observer-psram-only) | MQTT observer with PSRAM | No | No | No |
| MQTT | [`get/set mqtt.neighbors`](cli_commands.md#view-or-change-periodic-neighbors-publishing-mqtt-observer-neighbors-feature); [`get/set mqtt.neighbors.interval`](cli_commands.md#view-or-change-the-neighbors-publish-interval-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No |
| MQTT | [`get/set mqtt.ntp`](cli_commands.md#view-or-change-the-ntp-server-mqtt-observer-only) | MQTT observer | No | No | No |
| MQTT | [`get mqtt.ntp.diag`](cli_commands.md#diagnose-ntp-server-connectivity-mqtt-observer-only) | Full MQTT observer | No | No | No |
| MQTT | [`get/set timezone`; `get/set timezone.offset`](../MQTT_IMPLEMENTATION.md#timezone-commands) | MQTT observer | No | No | No |
| MQTT | [`get/set mqtt.analyzer.us`; `get/set mqtt.analyzer.eu`](../MQTT_IMPLEMENTATION.md#migration-from-old-configuration) | Legacy MQTT aliases | No | No | No |
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](../MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` is local serial only | No | No | No |
| MQTT | [`get/set timezone`; `get/set timezone.offset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#timezone-commands) | MQTT observer | No | No | No |
| MQTT | [`get/set mqtt.analyzer.us`; `get/set mqtt.analyzer.eu`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#migration-from-old-configuration) | Legacy MQTT aliases | No | No | No |
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` is local serial only | No | No | No |
| MQTT | [`get mqtt.config.valid`](#mqtt-config-valid) | MQTT observer | No | No | No |
| SNMP | [`get/set snmp`; `get/set snmp.community`](../MQTT_SNMP.md#cli-commands) | MQTT target compiled with SNMP | No | No | No |
| Alerts | [`get/set alert`; `get/set alert.psk`; `get/set alert.hashtag`; `get/set alert.region`; `get/set alert.wifi`; `get/set alert.mqtt`; `get/set alert.interval`](../ALERTS.md#cli) | MQTT observer | No | No | No |
| Alerts | [`alert test [message]`](../ALERTS.md#cli) | MQTT observer with configured alert channel | No | No | No |
| SNMP | [`get/set snmp`; `get/set snmp.community`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_SNMP.md#cli-commands) | MQTT target compiled with SNMP | No | No | No |
| Alerts | [`get/set alert`; `get/set alert.psk`; `get/set alert.hashtag`; `get/set alert.region`; `get/set alert.wifi`; `get/set alert.mqtt`; `get/set alert.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer | No | No | No |
| Alerts | [`alert test [message]`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer with configured alert channel | No | No | No |
| TLS | [`tls.bundletest <host>`](#tls-bundle-test) | MQTT target with embedded certificate bundle | No | No | No |
| Manifest OTA | [`ota check`](#manifest-ota) | MQTT target with `OTA_MANIFEST_BASE` | No | No | No |
| Manifest OTA | [`ota update`](#manifest-ota) | MQTT target with `OTA_MANIFEST_BASE` | No | No | No |
@@ -220,12 +219,12 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Neighbors | [`neighbors`](cli_commands.md#list-nearby-neighbors) | Role with a neighbor table | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Neighbors | [`neighbor.remove <pubkey_prefix>`](cli_commands.md#remove-a-neighbor) | Role with a mutable neighbor table | Yes | Yes | Yes | No | No | Yes | Yes |
| Neighbors | [`discover.neighbors`](cli_commands.md#discover-zero-hop-neighbors) | Repeater; some MQTT room servers | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-psram-only) | MQTT observer with PSRAM | No | No | No | No | No | PSRAM | No |
| Neighbors | [`discover.scopes`](cli_commands.md#discover-neighbor-scopes-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No | Feature | No | Feature | No |
| Statistics | [`clear stats`](cli_commands.md#clear-stats) | Full parser | Yes | Yes | Yes | No | No | Yes | Yes |
| Statistics | [`stats-core`](cli_commands.md#system-stats---battery-uptime-queue-length-and-debug-flags) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial |
| Statistics | [`stats-radio`](cli_commands.md#radio-stats---noise-floor-last-rssisnr-airtime-receive-errors) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial |
| Statistics | [`stats-core`](cli_commands.md#stats-core) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial |
| Statistics | [`stats-radio`](cli_commands.md#stats-radio) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial |
| Statistics | [`stats-radio-diag`](#stats-radio-diag) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial |
| Statistics | [`stats-packets`](cli_commands.md#packet-stats---packet-counters-received-sent) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial |
| Statistics | [`stats-packets`](cli_commands.md#stats-packets) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial |
| Statistics | [`get telemetry.temp/volt`; optional GPS history; `get/set telemetry.tx`](cli_commands.md#read-repeater-telemetry-history) | Non-STM32 repeater; GPS commands require a GPS provider; remote access requires administrator | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data | Yes | Yes | Yes | No | No | Yes | Yes |
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local serial | Serial | Serial | Serial | No | No | Serial | Serial |
@@ -317,7 +316,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| Ethernet | [`eth.status`](cli_commands.md#view-ethernet-connection-status) | Ethernet target | Feature | Feature | No | No | No | No | Feature |
| Browser OTA | [`start ota [ap]`; `stop ota`](cli_commands.md#start-or-stop-an-over-the-air-ota-firmware-update) | Compiled browser uploader | No | No | Yes | Yes | No | Feature | Feature |
| WebConfig | [`start webconfig [ap]`; `stop webconfig`; `get/set webui`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | Compiled WebConfig | No | No | No | No | No | Feature | Feature |
| WiFi | [`get/set wifi.ssid`; `set wifi.pwd`; `get wifi.status`; `get/set wifi.powersave`](../MQTT_IMPLEMENTATION.md#wifi-commands) | MQTT WiFi, or standalone FULL WebConfig; standalone has no `get wifi.pwd` | No | No | No | Yes | No | Yes | Feature |
| WiFi | [`get/set wifi.ssid`; `set wifi.pwd`; `get wifi.status`; `get/set wifi.powersave`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#wifi-commands) | MQTT WiFi, or standalone FULL WebConfig; standalone has no `get wifi.pwd` | No | No | No | Yes | No | Yes | Feature |
| WiFi | [`get/set wifi.cli`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | Compiled WebConfig | No | No | No | No | No | Feature | Feature |
| LoRa OTA | [`ota help`; `ota ?`; `ota h`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes |
| LoRa OTA | [`ota`; `ota status`; `ota st`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes |
@@ -325,7 +324,7 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| LoRa OTA | [`ota ls`; `ota neighbors`; `ota nbrs`; `ota updates`; `ota n`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes |
| LoRa OTA | [`ota get`; `ota pull`; `ota download`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes |
| LoRa OTA | [`ota install`; `ota apply`; `ota applydelta`](ota_protocol.md#11-cli-surface-otaclicpp) | Compatible completed update | No | No | Yes | No | No | Yes | Yes |
| LoRa OTA | [`ota rescue install <base_hash16>`](ota_protocol.md#12-apply--bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature | No | No | No | No |
| LoRa OTA | [`ota rescue install <base_hash16>`](ota_protocol.md#12-apply-bootloader-contract) | Internal-flash nRF52 LoRa OTA build with failed app-side EndF validation | No | No | Feature | No | No | No | No |
| LoRa OTA | [`ota cancel`; `ota drop`; `ota stop`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes |
| LoRa OTA | [`ota announce`; `ota adv`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes |
| LoRa OTA | [`ota self`; `ota id`](ota_protocol.md#11-cli-surface-otaclicpp) | Firmware with EndF trailer | No | No | Yes | No | No | Yes | Yes |
@@ -333,24 +332,24 @@ fix, no WiFi connection, an inactive bridge, or an nRF52 bootloader without
| LoRa OTA | [`ota config`; `ota cfg`; `ota set`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes |
| LoRa OTA | [`ota key`; `ota keys`](ota_protocol.md#11-cli-surface-otaclicpp) | LoRa OTA build | No | No | Yes | No | No | Yes | Yes |
| LoRa OTA | [`ota dev ...`](ota_protocol.md#11-cli-surface-otaclicpp) | Developer diagnostics | No | No | Yes | No | No | Yes | Yes |
| MQTT | [`get/set mqttN.preset`](../MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqttN.server`; `get/set mqttN.port`; `get/set mqttN.username`; `get/set mqttN.password`](../MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqttN.token`; `get/set mqttN.topic`; `get/set mqttN.audience`](../MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqttN.preset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqttN.server`; `get/set mqttN.port`; `get/set mqttN.username`; `get/set mqttN.password`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqttN.token`; `get/set mqttN.topic`; `get/set mqttN.audience`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-slot-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get mqttN.diag`](#mqtt-slot-diagnostics) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqtt.origin`; `get/set mqtt.iata`; `get mqtt.presets`](../MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqtt.origin`; `get/set mqtt.iata`; `get mqtt.presets`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get mqtt.stats`](#mqtt-stats) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqtt.status`; `get/set mqtt.packets`; `get/set mqtt.raw`; `get/set mqtt.interval`](../MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqtt.status`; `get/set mqtt.packets`; `get/set mqtt.raw`; `get/set mqtt.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqtt.rx`](cli_commands.md#view-or-change-mqtt-rx-packet-uplinking); [`get/set mqtt.tx`](cli_commands.md#view-or-change-mqtt-tx-packet-uplinking) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqtt.neighbors`](cli_commands.md#view-or-change-periodic-neighbors-publishing-mqtt-observer-psram-only); [`get/set mqtt.neighbors.interval`](cli_commands.md#view-or-change-the-neighbors-publish-interval-mqtt-observer-psram-only) | MQTT observer with PSRAM | No | No | No | PSRAM | No | PSRAM | No |
| MQTT | [`get/set mqtt.neighbors`](cli_commands.md#view-or-change-periodic-neighbors-publishing-mqtt-observer-neighbors-feature); [`get/set mqtt.neighbors.interval`](cli_commands.md#view-or-change-the-neighbors-publish-interval-mqtt-observer-neighbors-feature) | MQTT observer with compiled neighbor support | No | No | No | Feature | No | Feature | No |
| MQTT | [`get/set mqtt.ntp`](cli_commands.md#view-or-change-the-ntp-server-mqtt-observer-only) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get mqtt.ntp.diag`](cli_commands.md#diagnose-ntp-server-connectivity-mqtt-observer-only) | Full MQTT observer; intentionally cut from portable | No | No | No | No | No | Yes | No |
| MQTT | [`get/set timezone`; `get/set timezone.offset`](../MQTT_IMPLEMENTATION.md#timezone-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqtt.analyzer.us`; `get/set mqtt.analyzer.eu`](../MQTT_IMPLEMENTATION.md#migration-from-old-configuration) | Legacy MQTT aliases | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](../MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` is local serial only | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set timezone`; `get/set timezone.offset`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#timezone-commands) | MQTT observer | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqtt.analyzer.us`; `get/set mqtt.analyzer.eu`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#migration-from-old-configuration) | Legacy MQTT aliases | No | No | No | Yes | No | Yes | No |
| MQTT | [`get/set mqtt.owner`; `get/set mqtt.email`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_IMPLEMENTATION.md#mqtt-shared-commands) | MQTT observer; `get` is local serial only | No | No | No | Yes | No | Yes | No |
| MQTT | [`get mqtt.config.valid`](#mqtt-config-valid) | MQTT observer | No | No | No | Yes | No | Yes | No |
| SNMP | [`get/set snmp`; `get/set snmp.community`](../MQTT_SNMP.md#cli-commands) | MQTT target compiled with SNMP | No | No | No | No | No | Feature | No |
| Alerts | [`get/set alert`; `get/set alert.psk`; `get/set alert.hashtag`; `get/set alert.region`; `get/set alert.wifi`; `get/set alert.mqtt`; `get/set alert.interval`](../ALERTS.md#cli) | MQTT observer | No | No | No | Yes | No | Yes | No |
| Alerts | [`alert test [message]`](../ALERTS.md#cli) | MQTT observer with configured alert channel | No | No | No | Yes | No | Yes | No |
| SNMP | [`get/set snmp`; `get/set snmp.community`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/MQTT_SNMP.md#cli-commands) | MQTT target compiled with SNMP | No | No | No | No | No | Feature | No |
| Alerts | [`get/set alert`; `get/set alert.psk`; `get/set alert.hashtag`; `get/set alert.region`; `get/set alert.wifi`; `get/set alert.mqtt`; `get/set alert.interval`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer | No | No | No | Yes | No | Yes | No |
| Alerts | [`alert test [message]`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/ALERTS.md#cli) | MQTT observer with configured alert channel | No | No | No | Yes | No | Yes | No |
| TLS | [`tls.bundletest <host>`](#tls-bundle-test) | MQTT target with embedded certificate bundle | No | No | No | Feature | No | Feature | No |
| Manifest OTA | [`ota check`](#manifest-ota) | MQTT target with `OTA_MANIFEST_BASE` | No | No | No | Manifest | No | Manifest | No |
| Manifest OTA | [`ota update`](#manifest-ota) | MQTT target with `OTA_MANIFEST_BASE` | No | No | No | Manifest | No | Manifest | No |
+14 -6
View File
@@ -260,7 +260,7 @@ require MQTT or PSRAM.
---
### Discover neighbor scopes (MQTT observer, PSRAM only)
### Discover neighbor scopes (MQTT observer, neighbors feature)
Refreshes the zero-hop neighbor table, then queries each neighbor for its region
scopes and publishes the assembled table to the MQTT `neighbors` topic once.
@@ -282,6 +282,7 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
---
<a id="stats-core"></a>
### System Stats - Battery, Uptime, Queue Length and Debug Flags
**Usage:**
- `stats-core`
@@ -290,6 +291,7 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
---
<a id="stats-radio"></a>
### Radio Stats - Noise floor, Last RSSI/SNR, Airtime, Receive errors
**Usage:** `stats-radio`
@@ -297,6 +299,7 @@ Elsewhere it replies `Err - neighbors not enabled in this build`. If a
---
<a id="stats-packets"></a>
### Packet stats - Packet counters: Received, Sent
**Usage:** `stats-packets`
@@ -645,6 +648,7 @@ Station G2/G3 targets default to `off`.
- Its default `off` state keeps the host-controlled SX1262 receive path enabled during RX duty-cycle mode. Setting it to `on` reproduces the old missing-RF_RX behavior and can significantly reduce receive sensitivity, making remote commands harder to receive.
- `radio.rxps.rfrx_disabled` is supported only on SX1262 targets with a host-controlled RX enable pin.
- `on` and `conservative` select level `1` with a 16-symbol preamble; `balanced` selects level `5` with a 16-symbol preamble.
- Fresh Cascade-profile builds start with RXPS on at level `8` and a 16-symbol preamble. Saved operator settings still take precedence after an upgrade.
- Level-based settings automatically recalculate their timings when the spreading factor or bandwidth changes. Custom `<rx_us> <sleep_us>` timings remain fixed.
- The selected mode is applied immediately, persisted, and restored after reboot.
@@ -1081,16 +1085,18 @@ get clock.sync.status
- `powersaving`
- `powersaving on`
- `powersaving off`
- `set powersaving on`
- `set powersaving off`
**Parameters:**
- `on`: enable power saving
- `off`: disable power saving
**Default:** `off` for infrastructure roles; `on` for Companion firmware
**Default:** `on` for fresh Cascade-profile builds and Companion firmware; `off` for other infrastructure profiles
**Note:** Infrastructure firmware enters sleep between radio transmissions. It refuses to enable power saving from the local serial console or while an active USB serial data connection is detected; USB power alone does not block power saving.
Companion firmware defaults this setting to `on`. Full Companion accepts the command from its local USB terminal and exposes the same setting in WebConfig. On ESP32, it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. USB, BLE, and WiFi remain available. `powersaving off` restores the board's normal CPU clock and disables the GPS duty cycle. This device setting is separate from LoRa RXPS (`radio.rxps`) and WiFi modem power save (`wifi.powersave`).
Companion firmware defaults this setting to `on`. Full Companion accepts the command from its local USB terminal and exposes the same setting in WebConfig. On ESP32, it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. USB, BLE, and WiFi remain available. `powersaving off` restores the board's normal CPU clock and disables the GPS duty cycle. This device setting is separate from LoRa RXPS (`radio.rxps`) and WiFi modem power save (`wifi.powersave`). Infrastructure WebConfig uses the `set powersaving` form; enabling it can put the node to sleep and make WiFi temporarily unavailable.
---
@@ -3035,7 +3041,7 @@ sleep, this command schedules a sync and wakes it; after `gps off`, it reports
---
#### View or change periodic neighbors publishing (MQTT observer, PSRAM only)
#### View or change periodic neighbors publishing (MQTT observer, neighbors feature)
**Usage:**
- `get mqtt.neighbors`
- `set mqtt.neighbors <on|off>`
@@ -3058,7 +3064,7 @@ sleep, this command schedules a sync and wakes it; after `gps off`, it reports
---
#### View or change the neighbors publish interval (MQTT observer, PSRAM only)
#### View or change the neighbors publish interval (MQTT observer, neighbors feature)
**Usage:**
- `get mqtt.neighbors.interval`
- `set mqtt.neighbors.interval <hours>`
@@ -3068,7 +3074,9 @@ sleep, this command schedules a sync and wakes it; after `gps off`, it reports
**Default:** `24` (hours)
> **Note:** Out-of-range values are rejected (not clamped). Requires a PSRAM board.
> **Note:** Out-of-range values are rejected (not clamped). Requires a build
> with `WITH_MQTT_NEIGHBORS`; PSRAM boards enable it automatically and selected
> non-PSRAM variants opt in with `MQTT_NEIGHBORS_WITHOUT_PSRAM`.
---
+164 -55
View File
@@ -1,13 +1,23 @@
# Companion Protocol
- **Last Updated**: 2026-03-08
- **Protocol Version**: Companion Firmware v1.12.0+
- **Last Updated**: 2026-08-18
- **Protocol Version**: 13 (`FIRMWARE_VER_CODE`)
> NOTE: This document is still in development. Some information may be inaccurate.
> The command and response catalogs track
> `examples/companion_radio/MyMesh.cpp`. Applications should negotiate the
> protocol and validate lengths because older firmware exposes a subset.
This document provides a comprehensive guide for communicating with MeshCore devices over Bluetooth Low Energy (BLE).
This document is a practical guide to MeshCore's binary companion protocol.
The same protocol frames can be carried by the enabled BLE, USB serial, Wi-Fi,
or Ethernet companion interface; connection details differ by build.
It is platform-agnostic and can be used for Android, iOS, Python, JavaScript, or any other platform that supports BLE.
On builds exposing more than one transport, delivery-required replies follow
the interface which supplied the command. The multi-frame contact-list response
holds that route until `END_OF_CONTACTS`; best-effort asynchronous observations
may still be broadcast to enabled clients. Treat the device as one Companion
session rather than as independent per-transport sessions.
The examples focus on BLE, but the packet formats are transport-independent.
## Official Libraries
@@ -160,6 +170,59 @@ The first byte indicates the packet type (see [Response Parsing](#response-parsi
## Commands
The first byte selects the command. This is the current protocol-v13 command
catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused.
| Byte | Firmware name | Purpose |
|---|---|---|
| `0x01` | `CMD_APP_START` | Start an app session and request self information. |
| `0x02` | `CMD_SEND_TXT_MSG` | Send text to a contact. |
| `0x03` | `CMD_SEND_CHANNEL_TXT_MSG` | Send channel text. |
| `0x04` | `CMD_GET_CONTACTS` | Enumerate contacts, optionally modified since a timestamp. |
| `0x05` / `0x06` | `CMD_GET_DEVICE_TIME` / `CMD_SET_DEVICE_TIME` | Read or set the device clock. |
| `0x07` / `0x08` | `CMD_SEND_SELF_ADVERT` / `CMD_SET_ADVERT_NAME` | Advertise self or change the advertised name. |
| `0x09` | `CMD_ADD_UPDATE_CONTACT` | Add or update a contact. |
| `0x0A` | `CMD_SYNC_NEXT_MESSAGE` | Dequeue the next pending message. |
| `0x0B` / `0x0C` | `CMD_SET_RADIO_PARAMS` / `CMD_SET_RADIO_TX_POWER` | Set radio parameters or transmit power. |
| `0x0D` | `CMD_RESET_PATH` | Reset a contact's learned path. |
| `0x0E` | `CMD_SET_ADVERT_LATLON` | Set advertised coordinates. |
| `0x0F` | `CMD_REMOVE_CONTACT` | Remove a contact. |
| `0x10` / `0x11` / `0x12` | `CMD_SHARE_CONTACT` / `CMD_EXPORT_CONTACT` / `CMD_IMPORT_CONTACT` | Share, export, or import contact data. |
| `0x13` | `CMD_REBOOT` | Reboot after the required confirmation body. |
| `0x14` | `CMD_GET_BATT_AND_STORAGE` | Read battery and storage usage. |
| `0x15` | `CMD_SET_TUNING_PARAMS` | Set tuning parameters. |
| `0x16` | `CMD_DEVICE_QUERY` | Negotiate protocol support and read device information. |
| `0x17` / `0x18` | `CMD_EXPORT_PRIVATE_KEY` / `CMD_IMPORT_PRIVATE_KEY` | Export or import identity key material when enabled. |
| `0x19` | `CMD_SEND_RAW_DATA` | Send an application raw-data packet. |
| `0x1A`-`0x1D` | `CMD_SEND_LOGIN` through `CMD_LOGOUT` | Manage a server connection. |
| `0x1E` | `CMD_GET_CONTACT_BY_KEY` | Look up a contact by public-key prefix. |
| `0x1F` / `0x20` | `CMD_GET_CHANNEL` / `CMD_SET_CHANNEL` | Read or write a channel slot. |
| `0x21`-`0x23` | `CMD_SIGN_START` through `CMD_SIGN_FINISH` | Stream data for identity signing. |
| `0x24` | `CMD_SEND_TRACE_PATH` | Trace a direct route. |
| `0x25` | `CMD_SET_DEVICE_PIN` | Set or clear the device PIN. |
| `0x26` | `CMD_SET_OTHER_PARAMS` | Set telemetry, location, ACK, and related preferences. |
| `0x27` | `CMD_SEND_TELEMETRY_REQ` | Send the legacy telemetry request. |
| `0x28` / `0x29` | `CMD_GET_CUSTOM_VARS` / `CMD_SET_CUSTOM_VAR` | Read or set custom variables. |
| `0x2A` | `CMD_GET_ADVERT_PATH` | Read a cached advertisement path. |
| `0x2B` | `CMD_GET_TUNING_PARAMS` | Read tuning parameters. |
| `0x32` | `CMD_SEND_BINARY_REQ` | Send an application binary request. |
| `0x33` | `CMD_FACTORY_RESET` | Factory-reset after the required confirmation body. |
| `0x34` | `CMD_SEND_PATH_DISCOVERY_REQ` | Request path discovery. |
| `0x36` | `CMD_SET_FLOOD_SCOPE_KEY` | Select scoped or unscoped flood behavior. |
| `0x37` | `CMD_SEND_CONTROL_DATA` | Send zero-hop control data. |
| `0x38` | `CMD_GET_STATS` | Read core, radio, or packet statistics. |
| `0x39` | `CMD_SEND_ANON_REQ` | Send an anonymous request. |
| `0x3A` / `0x3B` | `CMD_SET_AUTOADD_CONFIG` / `CMD_GET_AUTOADD_CONFIG` | Write or read automatic-contact policy. |
| `0x3C` | `CMD_GET_ALLOWED_REPEAT_FREQ` | Read allowed client-repeat frequency ranges. |
| `0x3D` | `CMD_SET_PATH_HASH_MODE` | Set path-hash width mode. |
| `0x3E` | `CMD_SEND_CHANNEL_DATA` | Send a channel binary datagram. |
| `0x3F` / `0x40` | `CMD_SET_DEFAULT_FLOOD_SCOPE` / `CMD_GET_DEFAULT_FLOOD_SCOPE` | Write or read the default flood scope. |
| `0x41` | `CMD_SEND_RAW_PACKET` | Queue a fully encoded raw mesh packet. |
| `0x42` / `0x43` | `CMD_GET_RADIO_FEM_RXGAIN` / `CMD_SET_RADIO_FEM_RXGAIN` | Read or set FEM receive gain. |
The sections below detail the most common frames. Refer to the source named
above for command bodies that are not expanded here.
### 1. App Start
**Purpose**: Initialize communication with the device. Must be sent first after connection.
@@ -187,12 +250,12 @@ Bytes 8+: Application name (UTF-8, optional)
**Command Format**:
```
Byte 0: 0x16
Byte 1: 0x03
Byte 1: Highest companion protocol version understood by the app
```
**Example** (hex):
```
16 03
16 0D
```
**Response**: `PACKET_DEVICE_INFO` (0x0D) with device information
@@ -206,7 +269,7 @@ Byte 1: 0x03
**Command Format**:
```
Byte 0: 0x1F
Byte 1: Channel Index (0-7)
Byte 1: Channel index (0 through max_channels - 1)
```
**Example** (get channel 1):
@@ -225,16 +288,17 @@ Byte 1: Channel Index (0-7)
**Command Format**:
```
Byte 0: 0x20
Byte 1: Channel Index (0-7)
Byte 1: Channel index (0 through max_channels - 1)
Bytes 2-33: Channel Name (32 bytes, UTF-8, null-padded)
Bytes 34-49: Secret (16 bytes)
```
**Total Length**: 50 bytes
**Channel Index**:
- Index 0: Reserved for public channels (no secret)
- Indices 1-7: Available for private channels
**Channel index**:
- Slot count is build-specific. Read `max_channels` from byte 3 of
`PACKET_DEVICE_INFO`; current profiles commonly expose 1, 8, or 40 slots.
- No slot number has an intrinsic public/private meaning.
**Channel Name**:
- UTF-8 encoded
@@ -242,10 +306,12 @@ Bytes 34-49: Secret (16 bytes)
- Padded with null bytes (0x00) if shorter
**Secret Field** (16 bytes):
- For **private channels**: 16-byte secret
- For **public channels**: All zeros (0x00)
- Supply the exact 16-byte channel key. A private channel normally uses a
cryptographically random key; known public and hashtag channels use their
defined or derived key.
- An all-zero key is not the public-channel key.
**Example** (create channel "YourChannelName" at index 1 with secret):
**Example** (create channel "SMS" at index 1 with secret):
```
20 01 53 4D 53 00 00 ... (name padded to 32 bytes)
[16 bytes of secret]
@@ -265,7 +331,7 @@ Bytes 34-49: Secret (16 bytes)
```
Byte 0: 0x03
Byte 1: 0x00
Byte 2: Channel Index (0-7)
Byte 2: Channel index (0 through max_channels - 1)
Bytes 3-6: Timestamp (32-bit little-endian Unix timestamp, seconds)
Bytes 7+: Message Text (UTF-8, variable length)
```
@@ -288,13 +354,19 @@ Bytes 7+: Message Text (UTF-8, variable length)
**Command Format**:
```
Byte 0: 0x3E
Byte 1: Channel Index (0-7)
Byte 2: Path Length (0xFF = flood, otherwise actual path length)
Bytes 3 .. 2+path_len: Path (omitted when path_len == 0xFF)
Byte 1: Channel index (0 through max_channels - 1)
Byte 2: Encoded path descriptor (0xFF = flood)
Bytes 3+: Encoded path bytes (omitted for 0xFF)
Next 2 bytes (little-endian): Data Type (`data_type`, uint16)
Remaining bytes: Binary payload (variable length)
```
For a direct send, the descriptor's low six bits are the hash count and its
high two bits are the hash size minus one. Current mesh packets accept one-,
two-, or three-byte hashes; the four-byte code is reserved. The following path
therefore occupies `hash_count * hash_size` bytes; the descriptor itself is not
a raw byte count.
**Example** (flood, `DATA_TYPE_DEV`, payload `A1 B2 C3`, channel 1):
```
3E 01 FF FF FF A1 B2 C3
@@ -303,10 +375,10 @@ Remaining bytes: Binary payload (variable length)
**Data Type / Transport Mapping**:
- `0x0000` (`DATA_TYPE_RESERVED`) is invalid and rejected with `PACKET_ERROR`.
- `0xFFFF` (`DATA_TYPE_DEV`) is the developer namespace for experimenting and developing apps.
- Values `0x0001`-`0xFFFE` are available for registered application/community namespaces. See the [Registered data_type values](#registered-data_type-values) table below.
- Registered application/community namespaces occupy `0x0100`-`0xFEFF`; the remaining nonzero ranges are reserved for internal or development use. See the [Registered data_type values](#registered-data_type-values) table below.
**Limits**:
- Maximum payload length is `MAX_CHANNEL_DATA_LENGTH = MAX_FRAME_SIZE - 9 = 163` bytes.
- Maximum payload length is `MAX_CHANNEL_DATA_LENGTH = MAX_FRAME_SIZE - 9 = 167` bytes.
- Larger payloads are rejected with `PACKET_ERROR` (`ERR_CODE_ILLEGAL_ARG`).
**Response**: `PACKET_OK` (0x00) on success, or `PACKET_ERROR` (0x01) with one of:
@@ -341,7 +413,7 @@ Inbound group datagrams (radio-level `PAYLOAD_TYPE_GRP_DATA`, 0x06) are forwarde
Byte 0: 0x1B (packet type)
Byte 1: SNR (signed int8, scaled x4 - divide by 4.0 to recover dB)
Bytes 2-3: Reserved (clients MUST ignore)
Byte 4: Channel Index (0-7)
Byte 4: Channel index (0 through max_channels - 1)
Byte 5: Path Length (actual path length when flooded, otherwise 0xFF for direct)
Bytes 6-7: Data Type (uint16 little-endian)
Byte 8: Data Length
@@ -452,7 +524,8 @@ Byte 0: 0x14
### Channel Lifecycle
1. **Set Channel**:
- Fetch all channel slots, and find one with empty name and all-zero secret
- Read `max_channels` from device info, fetch those slots, and choose an
unused slot (normally an empty name and zeroed key)
- Generate or provide a 16-byte secret
- Send `CMD_SET_CHANNEL` with name and a 16-byte secret
2. **Get Channel**:
@@ -550,7 +623,7 @@ def parse_contact_message(data):
**Standard Format** (`PACKET_CHANNEL_MSG_RECV`, 0x08):
```
Byte 0: 0x08 (packet type)
Byte 1: Channel Index (0-7)
Byte 1: Channel index (0 through max_channels - 1)
Byte 2: Path Length
Byte 3: Text Type
Bytes 4-7: Timestamp (32-bit little-endian)
@@ -562,7 +635,7 @@ Bytes 8+: Message Text (UTF-8)
Byte 0: 0x11 (packet type)
Byte 1: SNR (signed byte, multiplied by 4)
Bytes 2-3: Reserved
Byte 4: Channel Index (0-7)
Byte 4: Channel index (0 through max_channels - 1)
Byte 5: Path Length
Byte 6: Text Type
Bytes 7-10: Timestamp (32-bit little-endian)
@@ -600,8 +673,11 @@ def parse_channel_message(data):
Use the `SEND_CHANNEL_MESSAGE` command (see [Commands](#commands)).
**Important**:
- Messages are limited to 133 characters per MeshCore specification
- Long messages should be split into chunks
- The shared text envelope permits up to 160 UTF-8 bytes. For channel text,
firmware prepends `<sender name>: ` inside that envelope, so the available
message body is `160 - prefix_bytes` and varies with the configured name.
- Count encoded UTF-8 bytes, not Unicode characters. Split a longer message at
valid UTF-8 boundaries.
- Include a chunk indicator (e.g., "[1/3] message text")
---
@@ -617,31 +693,57 @@ This document uses a spec-level naming convention (`PACKET_*`) for bytes the fir
Byte values are authoritative; names are aliases. When reading firmware source, `RESP_CODE_X` / `PUSH_CODE_X` correspond to this doc's `PACKET_X` of the same numeric value.
### Packet Types
### Response types
| Value | Name | Description |
|-------|----------------------------|-------------------------------|
| 0x00 | PACKET_OK | Command succeeded |
| 0x01 | PACKET_ERROR | Command failed |
| 0x02 | PACKET_CONTACT_START | Start of contact list |
| 0x03 | PACKET_CONTACT | Contact information |
| 0x04 | PACKET_CONTACT_END | End of contact list |
| 0x05 | PACKET_SELF_INFO | Device self-information |
| 0x06 | PACKET_MSG_SENT | Message sent confirmation |
| 0x07 | PACKET_CONTACT_MSG_RECV | Contact message (standard) |
| 0x08 | PACKET_CHANNEL_MSG_RECV | Channel message (standard) |
| 0x09 | PACKET_CURRENT_TIME | Current time response |
| 0x0A | PACKET_NO_MORE_MSGS | No more messages available |
| 0x0C | PACKET_BATTERY | Battery level |
| 0x0D | PACKET_DEVICE_INFO | Device information |
| 0x10 | PACKET_CONTACT_MSG_RECV_V3 | Contact message (V3 with SNR) |
| 0x11 | PACKET_CHANNEL_MSG_RECV_V3 | Channel message (V3 with SNR) |
| 0x12 | PACKET_CHANNEL_INFO | Channel information |
| 0x1B | PACKET_CHANNEL_DATA_RECV | Channel data datagram |
| 0x80 | PACKET_ADVERTISEMENT | Advertisement packet |
| 0x82 | PACKET_ACK | Acknowledgment |
| 0x83 | PACKET_MESSAGES_WAITING | Messages waiting notification |
| 0x88 | PACKET_LOG_DATA | RF log data (can be ignored) |
| Value | Firmware name | Description |
|---|---|---|
| `0x00` | `RESP_CODE_OK` | Command succeeded. |
| `0x01` | `RESP_CODE_ERR` | Command failed; byte 1 is the error code. |
| `0x02` | `RESP_CODE_CONTACTS_START` | Contact enumeration started. |
| `0x03` | `RESP_CODE_CONTACT` | One contact record. |
| `0x04` | `RESP_CODE_END_OF_CONTACTS` | Contact enumeration ended. |
| `0x05` | `RESP_CODE_SELF_INFO` | Device self-information. |
| `0x06` | `RESP_CODE_SENT` | Send accepted, with route/tag/timeout data. |
| `0x07` / `0x08` | `RESP_CODE_CONTACT_MSG_RECV` / `RESP_CODE_CHANNEL_MSG_RECV` | Queued legacy-format message. |
| `0x09` | `RESP_CODE_CURR_TIME` | Current device time. |
| `0x0A` | `RESP_CODE_NO_MORE_MESSAGES` | Offline queue is empty. |
| `0x0B` | `RESP_CODE_EXPORT_CONTACT` | Exported contact bytes. |
| `0x0C` | `RESP_CODE_BATT_AND_STORAGE` | Battery and storage values. |
| `0x0D` | `RESP_CODE_DEVICE_INFO` | Protocol and build information. |
| `0x0E` | `RESP_CODE_PRIVATE_KEY` | Exported identity key, when enabled. |
| `0x0F` | `RESP_CODE_DISABLED` | Requested sensitive feature is disabled. |
| `0x10` / `0x11` | `RESP_CODE_CONTACT_MSG_RECV_V3` / `RESP_CODE_CHANNEL_MSG_RECV_V3` | Queued message with SNR fields. |
| `0x12` | `RESP_CODE_CHANNEL_INFO` | Channel slot information. |
| `0x13` / `0x14` | `RESP_CODE_SIGN_START` / `RESP_CODE_SIGNATURE` | Signing capacity or completed signature. |
| `0x15` | `RESP_CODE_CUSTOM_VARS` | Custom-variable data. |
| `0x16` | `RESP_CODE_ADVERT_PATH` | Cached advertisement path. |
| `0x17` | `RESP_CODE_TUNING_PARAMS` | Tuning parameters. |
| `0x18` | `RESP_CODE_STATS` | Requested statistics subtype. |
| `0x19` | `RESP_CODE_AUTOADD_CONFIG` | Automatic-contact policy. |
| `0x1A` | `RESP_ALLOWED_REPEAT_FREQ` | Allowed repeat-frequency ranges. |
| `0x1B` | `RESP_CODE_CHANNEL_DATA_RECV` | Queued channel datagram. |
| `0x1C` | `RESP_CODE_DEFAULT_FLOOD_SCOPE` | Default flood-scope data. |
### Asynchronous push types
| Value | Firmware name | Description |
|---|---|---|
| `0x80` | `PUSH_CODE_ADVERT` | Advertisement received. |
| `0x81` | `PUSH_CODE_PATH_UPDATED` | A contact path changed. |
| `0x82` | `PUSH_CODE_SEND_CONFIRMED` | A sent message was acknowledged. |
| `0x83` | `PUSH_CODE_MSG_WAITING` | One or more offline frames are waiting. |
| `0x84` | `PUSH_CODE_RAW_DATA` | Raw application data received. |
| `0x85` / `0x86` | `PUSH_CODE_LOGIN_SUCCESS` / `PUSH_CODE_LOGIN_FAIL` | Server login result. |
| `0x87` | `PUSH_CODE_STATUS_RESPONSE` | Server status response. |
| `0x88` | `PUSH_CODE_LOG_RX_DATA` | Radio receive log data. |
| `0x89` | `PUSH_CODE_TRACE_DATA` | Completed trace data. |
| `0x8A` | `PUSH_CODE_NEW_ADVERT` | Newly stored contact advertisement. |
| `0x8B` | `PUSH_CODE_TELEMETRY_RESPONSE` | Telemetry response. |
| `0x8C` | `PUSH_CODE_BINARY_RESPONSE` | Binary request response. |
| `0x8D` | `PUSH_CODE_PATH_DISCOVERY_RESPONSE` | Path-discovery response. |
| `0x8E` | `PUSH_CODE_CONTROL_DATA` | Control/discovery data. |
| `0x8F` | `PUSH_CODE_CONTACT_DELETED` | Oldest contact was deleted while making room. |
| `0x90` | `PUSH_CODE_CONTACTS_FULL` | Contact storage is full. |
### Parsing Responses
@@ -700,6 +802,11 @@ def parse_device_info(data):
info['fw_build'] = data[8:20].decode('utf-8').rstrip('\x00').strip()
info['model'] = data[20:60].decode('utf-8').rstrip('\x00').strip()
info['ver'] = data[60:80].decode('utf-8').rstrip('\x00').strip()
if fw_ver >= 9 and len(data) >= 81:
info['client_repeat'] = data[80] != 0
if fw_ver >= 10 and len(data) >= 82:
info['path_hash_mode'] = data[81]
return info
```
@@ -801,10 +908,11 @@ Bytes 2-5: Tag / Expected ACK (4 bytes, little-endian)
Bytes 6-9: Suggested Timeout (32-bit little-endian, milliseconds)
```
**PACKET_ACK** (0x82):
**PACKET_SEND_CONFIRMED** (0x82):
```
Byte 0: 0x82
Bytes 1-6: ACK Code (6 bytes, hex)
Bytes 1-4: ACK code (32-bit little-endian)
Bytes 5-8: Round-trip time (32-bit little-endian, milliseconds)
```
### Error Codes
@@ -835,7 +943,8 @@ BLE implementations enqueue and deliver one protocol frame per BLE write/notific
1. **Command-Response Pattern**:
- Send command via RX characteristic
- Wait for response via TX characteristic (notification)
- Match response to command using sequence numbers or command type
- Match the response by the expected response type; frames do not carry a
general command sequence number
- Handle timeout (typically 5 seconds)
- Use command queue to prevent concurrent commands
@@ -902,7 +1011,7 @@ secret_hex = secret_16_bytes.hex()
# 2. Build SET_CHANNEL command
channel_name = "YourChannelName"
channel_index = 1 # Use 1-7 for private channels
channel_index = choose_unused_slot(max_channels)
command = build_set_channel(channel_index, channel_name, secret_16_bytes)
# 3. Send command
+33 -4
View File
@@ -74,6 +74,21 @@ powersaving on
powersaving off
```
On radios with RX duty-cycle support, WebConfig and the USB terminal also
expose the persisted RXPS setting:
```text
get radio.rxps
set radio.rxps off
set radio.rxps on
set radio.rxps level 8 preamble 16
set radio.rxps 65625 60000
```
Fresh Cascade-profile Full Companion builds start with RXPS on at level 8 and
a 16-symbol preamble. Changing it takes effect immediately and remains selected
after reboot.
Companion firmware defaults device power saving to on. Version 1.17.1.2 also
turns it on once when upgrading an older Companion preference file, including
one written by the short-lived default-off regression. After that one-time
@@ -83,7 +98,11 @@ On ESP32, enabling it lowers the CPU clock to 80 MHz, enables idle yielding,
and enables the configured GPS duty cycle. Disabling it restores the normal CPU
clock and keeps GPS awake. Full Companion transports remain available in both
states; WiFi modem sleep stays enabled when BLE is present because coexistence
requires it. The selected state is retained after reboot.
requires it. While a native-USB host is enumerated, the platform sleep attempt
is held off so USB CDC remains responsive; detaching the host releases that
guard. CPU, radio-modem, and GPS power-saving settings remain active, and USB
power from a charger alone does not create a Companion session. The selected
state is retained after reboot.
On the LilyGo T-Beam 1W Full Companion, press the physical `BOOT` button once
to turn the ESP32 WiFi radio and all WiFi services off or on. The screen confirms
@@ -123,9 +142,19 @@ itself.
| ESP32 | TCP 5002 | Local `ota`, `tempradio`, and `normalradio` console |
| nRF52 | USB mOTA mode | Host `.mota` folder from `motatool serve --serial` |
Binary Companion replies are broadcast through the multi-interface manager,
so use one active Companion application at a time. On nRF52, BLE remains
available while USB is in terminal or mOTA mode.
Delivery-required replies are returned only to the interface which supplied the
latest command. A contact-list stream keeps that route locked from
`CONTACTS_START` through `END_OF_CONTACTS`; commands waiting on another
interface are read after the stream finishes. Best-effort asynchronous
observations such as adverts remain broadcast so passive clients can refresh
their views. Companion session state is device-wide, so use one active
Companion application at a time. On nRF52, BLE remains available while USB is
in terminal or mOTA mode.
USB Binary output is queued as complete length-prefixed frames. Temporary CDC
or UART backpressure pauses the contact stream; a frame may drain through a
smaller hardware FIFO in ordered chunks, but its remainder is retained and no
later frame can interleave with it or cause it to be discarded.
When a BLE client requests pairing, a display-equipped build wakes the screen,
switches to the first home page, and keeps the active six-digit PIN visible
+105 -84
View File
@@ -31,7 +31,7 @@ A list of frequently-asked questions and answers for MeshCore
- [3.9.2. Q: **What determines a packet's path hash size?**](#392-q-what-determines-a-packets-path-hash-size)
- [3.9.3. Q: **How do I change my companion's path hash size?**](#393-q-how-do-i-change-my-companions-path-hash-size)
- [3.9.4. Q: **What does the CLI command `path.hash.mode` do on a repeater?**](#394-q-what-does-the-cli-command-pathhashmode-do-on-a-repeater)
- [3.9.5. Q: **Why use 2- or 3-byte path hash for adverts?**](#395-q-why-use-2--or-3-byte-path-hash-for-adverts)
- [3.9.5. Q: **Why use 2- or 3-byte path hash for adverts?**](#path-hash-size-adverts)
- [3.9.6. Q: **When can we move away from 1-byte path hash for channel and direct messages?**](#396-q-when-can-we-move-away-from-1-byte-path-hash-for-channel-and-direct-messages)
- [4. T-Deck Related](#4-t-deck-related)
- [4.1. Q: Is there a user guide for T-Deck, T-Pager, T-Watch, or T-Display Pro?](#41-q-is-there-a-user-guide-for-t-deck-t-pager-t-watch-or-t-display-pro)
@@ -94,7 +94,7 @@ MeshCore is free and open source:
- MeshCore is the routing and firmware etc., available on GitHub under MIT license
- There are clients made by the community, such as the web clients, these are free to use, and some are open source too
- The cross-platform mobile app developed by [Liam Cottle](https://liamcottle.net) for Android/iOS/PC etc. is free to download and use
- The cross-platform mobile app developed by [Liam Cottle](https://liamcottle.com/) for Android/iOS/PC etc. is free to download and use
- The T-Deck firmware is developed by Scott at Ripple Radios, the creator of MeshCore, is also free to flash on your devices and use
@@ -124,18 +124,19 @@ For an up-to-date list of supported devices, please go to <https://flasher.meshc
To use MeshCore without using a phone as the client interface, you can run MeshCore on a LilyGo T-Deck, T-Deck Plus, T-Pager, T-Watch, or T-Display Pro. MeshCore Ultra firmware running on these devices is a complete off-grid secure communication solution.
#### 1.2.2. Firmware
MeshCore has four firmware types that are not available on other LoRa systems. MeshCore has the following:
MeshCore firmware is organized by role and, for companions, by host transport.
The main roles are Companion, Repeater, Room Server, and Sensor. The tree also
contains specialized terminal-chat and KISS-modem builds. Availability depends
on the board; use the flasher or that board's PlatformIO environments as the
current source of truth.
#### 1.2.3. Companion Radio Firmware
Companion radios are for connecting to the Android app or web app as a messenger client. There are two different companion radio firmware versions:
1. **BLE Companion**
BLE Companion firmware runs on a supported LoRa device and connects to a smart device running the Android or iOS MeshCore client over BLE
<https://meshcore.io>
2. **USB Serial Companion**
USB Serial Companion firmware runs on a supported LoRa device and connects to a smart device or a computer over USB Serial running the MeshCore web client
<https://app.meshcore.nz>
Companion radios connect MeshCore client software to LoRa. Depending on the
board and build profile, the companion protocol can be exposed over BLE, USB
serial, Wi-Fi, or Ethernet. Some boards also provide a Full Companion profile
with additional local administration features. Check the exact build name and
transport before flashing. See <https://meshcore.io> and the web client at
<https://app.meshcore.nz>.
#### 1.2.4. Repeater
Repeaters are used to extend the range of a MeshCore network. Repeater firmware runs on the same devices that run client firmware. A repeater's job is to forward MeshCore packets to the destination device. It does **not** forward or retransmit every packet it receives, unlike other LoRa mesh systems.
@@ -185,13 +186,15 @@ The T-Deck firmware is free to download and most features are available without
### 2.3. Q: What frequencies are supported by MeshCore?
**A:** It supports the 868MHz range in the UK/EU and the 915MHz range in New Zealand, Australia, and the USA. Countries and regions in these two frequency ranges are also supported.
**A:** Supported frequencies depend on the radio hardware and the rules for the
country in which it operates. Common MeshCore hardware covers portions of the
433, 868, and 915 MHz ISM bands.
Use the smartphone client or the repeater setup feature on the web flasher to set your radios' RF settings by choosing the preset for your regions.
Recently, as of October 2025, many regions have moved to the "narrow" setting, aka using BW62.5 and a lower SF number (instead of the original SF11). For example, USA/Canada (Recommended) preset is 910.525MHz, SF7, BW62.5, CR5.
After extensive testing, many regions have switched or about to switch over to BW62.5 and SF7, 8, or 9. Narrower bandwidth setting and lower SF setting allow MeshCore's radio signals to fit between interference in the ISM band, provide for a lower noise floor, better SNR, and faster transmissions.
Use the current regional preset offered by the client or flasher, confirm it
with the local MeshCore community, and comply with local frequency, bandwidth,
duty-cycle, and power limits. Do not copy an old frequency from this FAQ.
Narrow presets commonly use BW62.5 with a lower spreading factor; their exact
frequency and SF are region-specific and can change as deployments coordinate.
If you have consensus from your community in your region to update your region's preset recommendation, please post your update request on the [#meshcore-app](https://discord.com/channels/1343693475589263471/1391681655911088241) channel on the [MeshCore Discord server ](https://meshcore.gg) to let Liam Cottle know.
@@ -284,7 +287,10 @@ Best practice is when you set up a new repeater, choose a public key that is not
The `<number>` unit is in seconds and is incremented by 4. `set agc.reset.interval 4` works well to cure deafness.
This is a very low-cost operation. AGC reset is done by simply setting `state = STATE_IDLE;` in function `RadioLibWrapper::resetAGC()` in `RadioLibWrappers.cpp`
The reset is skipped while a packet is pending, being received, or being
transmitted. When safe, `RadioLibWrapper::resetAGC()` warm-sleeps the radio,
returns the wrapper to idle so receive mode is re-armed, reapplies the cached RX
boost setting, and starts a fresh noise-floor calibration.
### 3.8. Q: How do I make my repeater an observer on the mesh?
@@ -328,6 +334,7 @@ Usage: `set path.hash.mode {0|1|2}`:
It is safe to set your 1.14+ repeaters to mode 1 or 2.
<a id="path-hash-size-adverts"></a>
### 3.9.5. Q: **Why use 2- or 3-byte path hash for adverts?**
A longer path hash helps tools like the LetsMesh.net Analyzer and MeshMapper disambiguate repeaters more reliably. With only 1 byte, the chance of different repeaters having the same first byte in their public key is high, making it harder to tell them apart in mesh network analysis. Since this only affects adverts, there's no downside. 2- and 3-byte adverts don't travel as far as 1-byte adverts, but it is not important for MeshCore nodes to hear a repeater's advert that is 21 or 32 hops away.
@@ -409,16 +416,26 @@ Unlock page: <https://buymeacoffee.com/ripplebiz/e/249834>
See here for packet-type:
<https://github.com/meshcore-dev/MeshCore/blob/main/src/Packet.h#L19>
```
#define PAYLOAD_TYPE_REQ 0x00 // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
#define PAYLOAD_TYPE_RESPONSE 0x01 // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
#define PAYLOAD_TYPE_TXT_MSG 0x02 // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text)
#define PAYLOAD_TYPE_ACK 0x03 // a simple ack #define PAYLOAD_TYPE_ADVERT 0x04 // a node advertising its Identity
#define PAYLOAD_TYPE_GRP_TXT 0x05 // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg")
#define PAYLOAD_TYPE_GRP_DATA 0x06 // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: data_type, data_len, blob)
#define PAYLOAD_TYPE_ANON_REQ 0x07 // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)
#define PAYLOAD_TYPE_PATH 0x08 // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)
```
| Value | Payload |
|---|---|
| `0x00` | Request |
| `0x01` | Response |
| `0x02` | Plain text message |
| `0x03` | Acknowledgment |
| `0x04` | Node advertisement |
| `0x05` | Group text |
| `0x06` | Group datagram |
| `0x07` | Anonymous request |
| `0x08` | Returned path |
| `0x09` | Trace |
| `0x0A` | Multipart |
| `0x0B` | Control/discovery data |
| `0x0C` | OTA-over-LoRa data |
| `0x0D`-`0x0E` | Reserved |
| `0x0F` | Raw custom data |
See [Packet Format](packet_format.md) and [Payload Format](payloads.md) for the
maintained descriptions.
[Source](https://discord.com/channels/1343693475589263471/1343693475589263474/1350611321040932966)
@@ -474,8 +491,11 @@ So, it's a balancing act between speed of the transmission and resistance to noi
The Things Network is mainly focused on LoRaWAN, but the LoRa low-level stuff still checks out for any LoRa project
### 5.2. Q: Do MeshCore clients repeat?
**A:** No, MeshCore clients do not repeat. This is the core of MeshCore's messaging-first design. This is to avoid devices flooding the airwaves and create endless collisions, so messages sent aren't received.
In MeshCore, only repeaters and room servers with `set repeat on` repeat.
**A:** Companion clients do not repeat by default. Supported builds can opt into
bounded client repeating on permitted frequencies, and the emergency-channel
path has a bounded delayed relay behavior. Dedicated repeaters remain the
normal and recommended way to extend network coverage. Room servers can repeat
when explicitly configured, but separating the roles is generally preferable.
### 5.3. Q: What happens when a node learns a route via a mobile repeater, and that repeater is gone?
**A:** If you used to reach a node through a repeater and the repeater is no longer reachable, the client will send the message using the existing (but now broken) known path, the message will fail after 3 retries, and the app will reset the path and send the message as flood on the last retry by default. This can be turned off in settings. If the destination is reachable directly or through another repeater, the new path will be used going forward. Or you can set the path manually if you know a specific repeater to use to reach that destination.
@@ -525,7 +545,7 @@ Support Rastislav Vysoky (recrof)'s flasher website and the map website developm
Build instructions for MeshCore:
For Windows, first install WSL and Python+pip via: <https://plainenglish.io/blog/setting-up-python-on-windows-subsystem-for-linux-wsl-26510f1b2d80>
For Windows, first install WSL and Python+pip via: <https://plainenglish.io/python/setting-up-python-on-windows-subsystem-for-linux-wsl-26510f1b2d80>
(Linux, Windows+WSL) In the terminal/shell:
```
@@ -540,34 +560,32 @@ Then it should be the same for all platforms:
python3 -m venv meshcore
cd meshcore && source bin/activate
pip install -U platformio
git clone https://github.com/ripplebiz/MeshCore.git
git clone https://github.com/meshcore-dev/MeshCore.git
cd MeshCore
```
open platformio.ini and in `[arduino_base]` edit the `LORA_FREQ=867.5`
save, then run:
Choose an exact environment listed by `pio project config` or in the target
board's `variants/*/platformio.ini`. For example:
```
pio run -e RAK_4631_Repeater
pio run -e RAK_4631_repeater
```
then you'll find `firmware.zip` in `.pio/build/RAK_4631_Repeater`
The output is under `.pio/build/RAK_4631_repeater/`. Set the radio parameters to
your current regional preset after flashing; if changing build defaults, keep
that local change out of commits unless it is intended for every user.
### 5.10. Q: Are there other MeshCore related open source projects?
**A:** [Liam Cottle](https://liamcottle.net)'s MeshCore web client and MeshCore JavaScript library are open source under MIT license.
**A:** [Liam Cottle](https://liamcottle.com/)'s MeshCore web client and MeshCore JavaScript library are open source under MIT license.
Web client: <https://github.com/liamcottle/meshcore-web>
Javascript: <https://github.com/liamcottle/meshcore.js>
JavaScript: <https://github.com/meshcore-dev/meshcore.js>
### 5.11. Q: Does MeshCore support ATAK?
**A:** ATAK is not currently on MeshCore's roadmap.
MeshCore would not be best suited to ATAK because MeshCore:
- clients do not repeat and therefore you would need a network of repeaters in place
- will not have a stable path where all clients are constantly moving between repeaters
MeshCore clients would need to reset path constantly and flood traffic across the network which could lead to lots of collisions with something as chatty as ATAK.
This could change in the future if MeshCore develops a client firmware that repeats.
**A:** This repository does not ship or document an official ATAK integration.
An external integration must account for LoRa airtime, moving endpoints, stale
direct paths, and the collision cost of frequent flood fallback. Optional,
bounded Companion repeating does not turn every mobile client into a dedicated
repeater and does not remove those capacity constraints. Check current
community projects before designing a deployment.
[Source](https://discord.com/channels/826570251612323860/1330643963501351004/1354780032140054659)
@@ -590,47 +608,42 @@ Below are the instructions to flash firmware onto a supported LoRa device using
For ESP-based devices (e.g. Heltec V3) you need:
1. Download the firmware file from <https://flasher.meshcore.io>.
- Go to the website in a browser and find the section that has the firmware you need.
- Click the Download button, right-click on the file you need, for example:
- `Heltec_V3_companion_radio_ble-v1.7.1-165fb33.bin`
- Non-merged bin keeps the existing Bluetooth pairing database.
- `Heltec_v3_companion_radio_usb-v1.7.1-165fb33-merged.bin`
- Merged bin overwrites everything including the bootloader and existing Bluetooth pairing database, but keeps configurations.
- Right-click on the file name and copy the link. Here is an example: `https://flasher.meshcore.io/releases/download/companion-v1.7.1/Heltec_v3_companion_radio_ble-v1.7.1-165fb33.bin`
- Run:
- `wget https://flasher.meshcore.io/releases/download/companion-v1.7.1/Heltec_v3_companion_radio_ble-v1.7.1-165fb33.bin` to download the firmware file for your device type or the version you need: USB, BLE, Repeater, Room Server, merged bin or non-merged bin.
- If the above wget command only downloads a very small file (10K bytes instead of more than 100K byte), use this command instead:
- `wget --user-agent="Mozilla/5.0" --content-disposition "https://flasher.meshcore.io/releases/download/companion-v1.7.1/Heltec_v3_companion_radio_usb-v1.7.1-165fb33.bin"`
- Select the exact board and role. Artifact names and release URLs include a
changing version and commit, so copy the current download URL instead of
using an example URL from a guide.
- A non-merged application image is for an already compatible bootloader
and partition table. A merged image also contains low-level flash data.
Back up settings before either operation; do not assume a merged flash
preserves configuration, pairing data, or a previous partition layout.
- To download a copied URL from the shell, use
`curl -L '<copied-url>' -o <firmware>.bin`.
2. Confirm the `ttyXXXX` device path on your Raspberry Pi.
- Go to the `/dev` directory and run the `ls` command to find your device path.
- It is usually `/dev/ttyUSB0` for ESP devices.
3. Install esptool from the shell.
- `pip install esptool --break-system-packages`
- Run `ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null` before and after connecting
the board. ESP devices can appear under either name.
3. Install esptool in a virtual environment.
- `python3 -m venv .venv && . .venv/bin/activate`
- `python -m pip install --upgrade esptool`
4. Flash the firmware.
- For non-merged bin:
- `esptool.py -p /dev/ttyUSB0 --chip esp32-s3 write_flash 0x10000 <non-merged_firmware>.bin`
- `python -m esptool --port /dev/ttyUSB0 --chip auto write-flash 0x10000 <non-merged-firmware>.bin`
- For merged bin:
- `esptool.py -p /dev/ttyUSB0 --chip esp32-s3 write_flash 0x00000 <merged_firmware>.bin`
- `python -m esptool --port /dev/ttyUSB0 --chip auto write-flash 0x00000 <merged-firmware>.bin`
**Instructions for nRF devices:**
For nRF devices (e.g. RAK, Heltec T114) you need the following:
1. Download the firmware file from <https://flasher.meshcore.io>.
- Go to the website in a browser and find the section that has the firmware you need.
- You need the ZIP version for the adafruit flash tool below.
- Click the Download button, right-click on the ZIP file, for example:
- `RAK_4631_companion_radio_ble-v1.7.1-165fb33.zip`
- Right-click on the file name and copy the link. Here is an example: `https://flasher.meshcore.io/releases/download/companion-v1.7.1/RAK_4631_companion_radio_ble-v1.7.1-165fb33.zip`
- Run:
- `wget https://flasher.meshcore.io/releases/download/companion-v1.7.1/RAK_4631_companion_radio_ble-v1.7.1-165fb33.zip` to download the firmware file for your device type or the version you need: USB, BLE, Repeater, Room Server, ZIP file only.
- Select the exact board and role, and download its current ZIP package.
Do not rename a package from another board or reuse a static versioned URL.
2. Confirm the `ttyXXXX` device path on your Raspberry Pi.
- Go to the `/dev` directory and run the `ls` command to find your device path.
- It is usually `/dev/ttyACM0` for nRF devices.
3. Install adafruit-nrfutil.
- `pip install adafruit-nrfutil --break-system-packages`
- Run `ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null`; nRF bootloaders commonly
appear as `/dev/ttyACM0`, but the number can change.
3. In a virtual environment, install adafruit-nrfutil:
- `python3 -m venv .venv && . .venv/bin/activate`
- `python -m pip install --upgrade adafruit-nrfutil`
4. Flash the nRF device.
- `adafruit-nrfutil --verbose dfu serial --package RAK_4631_companion_radio_usb-v1.7.1-165fb33.zip -p /dev/ttyACM0 -b 115200 --singlebank --touch 1200`
- `adafruit-nrfutil --verbose dfu serial --package <firmware-package>.zip -p /dev/ttyACM0 -b 115200 --singlebank --touch 1200`
To manage a repeater or room server connected to a Pi over USB serial using shell commands, you need to install `picocom`. To install `picocom`, run the following command:
@@ -663,7 +676,7 @@ Both the Windows and Mac versions of the client app are fully unlocked and are f
- The Comms Channel on YouTube: <https://www.youtube.com/watch?v=guDoKGs02Us>
- MeshCore Advantages by MCarper: <https://github.com/mikecarper/meshfirmware/blob/main/MeshCoreAdvantages.md>
- MeshCore vs Meshtastic by austinmesh.org: <https://www.austinmesh.org/learn/meshcore-vs-meshtastic>
- MeshCore vs Meshtastic by austinmesh.org: <https://www.austinmesh.org/about/meshcore-vs-meshtastic/>
---
@@ -744,7 +757,9 @@ Allow the browser user on it:
12. If it fails, try toggling Bluetooth on your phone. If that doesn't work, try rebooting your phone. If you keep getting failures at the "Enabling Bootloader" step, try forgetting the nRF board in your iOS or Android device's Bluetooth settings and re-pair it through the DFU app.
13. Wait for the update to complete. It can take a few minutes.
14. It is strongly recommended that you install and use the OTAFIX bootloader at <https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX>.
15. To update a companion node over OTA, it must be running companion firmware v1.15 or greater.
15. A companion must be running a build that exposes `start ota`; consult the
current flasher release notes rather than relying on a version threshold in
this FAQ.
16. Please see the MeshCore Blog for additional information on OTA firmware flashing:
- <https://blog.meshcore.io/2026/04/06/otafix-bootloader>
- <https://blog.meshcore.io/2026/04/02/nrf-ota-update>
@@ -760,7 +775,9 @@ After this bootloader is flashed onto the device, you can trigger an over-the-ai
### 7.2. Q: How to update ESP32-based devices over the air?
**A:** For ESP32-based devices (e.g. Heltec V3):
1. On <https://flasher.meshcore.io>, download the **non-merged** version of the firmware for your ESP32 device (e.g. `Heltec_v3_repeater-v1.6.2-4449fd3.bin`, no `"merged"` in the file name).
1. On <https://flasher.meshcore.io>, download the current **non-merged**
application image for the exact ESP32 board and role (no `merged` in the
filename).
2. From the MeshCore app, log in remotely to the repeater you want to update with admin privileges.
3. Go to the Command Line tab, type `start ota` and hit enter.
4. You should see `OK` to confirm the repeater device is now in OTA mode.
@@ -779,14 +796,18 @@ Refer to <https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX> for the la
Currently, the following boards are supported:
- Elecrow ThinkNode M1, M3, and M6
- Heltec Automation Mesh Node T114 / HT-nRF5262
- LilyGO T-Echo
- Minewsemi MX25LE01
- Nologo ProMicro NRF52840 (aka SuperMini NRF52840)
- RAK4631
- RAK WisMesh Tag
- Seeed Studio SenseCAP Card Tracker T1000-E
- Seeed Studio SenseCAP Solar Node P1
- Seeed Studio Wio Tracker L1
- Seeed Studio XIAO nRF52840 BLE
- Seeed Studio XIAO nRF52840 BLE SENSE
- RAK 4631
- RAK WisMesh Tag (new 28/11/2025)
### 7.4. Q: Are the MeshCore logo and font available?
**A:** Yes, it is on the MeshCore GitHub repo here: <https://github.com/meshcore-dev/MeshCore/tree/main/logo>
@@ -841,7 +862,7 @@ For companion radios, you can set these radios' transmit power in the smartphone
| Device / Model | Region / Description | In-App Setting (dBm) | Target Radio Output | Notes |
|:-----------------------------------------------------------------------------------|:------------------------------------|:---------------------|:-----------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------|
| **Station G2** <br> [Reference](https://wiki.uniteng.com/en/meshtastic/station-g2) | US915 Max Output | 19 dBm | 36.5 dBm (4.46W) | |
| **Station G2** <br> [Reference](https://wiki.bqvoy.com/en/meshtastic/station-g2) | US915 Max Output | 19 dBm | 36.5 dBm (4.46W) | |
| | US915 Max at 1dB compression point | 16 dBm | 35 dBm (3.16W) | 1dB compression point |
| | EU868 Max at 1dB compression point | 15 dBm | 34.5 dBm (2.82W) | 1dB compression point |
| | US915 1W Output | 10 dBm | 1W | Refer to your local government's requirements |
+12 -6
View File
@@ -5,8 +5,8 @@ physical failure of the withdrawn 26-step migration and the corrected
v1.17.01 test candidate. Its runner blocks the withdrawn chain and requires an
explicit lab-only gate for the corrected chain until physical testing passes.
[`tools/lora_ota/lora_ota.sh`](../tools/lora_ota/lora_ota.sh) and
[`tools/lora_ota/lora_ota.ps1`](../tools/lora_ota/lora_ota.ps1) automate a
[`tools/lora_ota/lora_ota.sh`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/lora_ota/lora_ota.sh) and
[`tools/lora_ota/lora_ota.ps1`](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/lora_ota/lora_ota.ps1) automate a
MeshCore LoRa firmware update from a release `.zip` or ready `.mota`. They
identify the destination, validate the hardware and running firmware, prepare
the right container, move the participating nodes to a temporary radio
@@ -168,13 +168,18 @@ on the normal channel. The script runs these authenticated checks itself:
```text
ota status
get bootloader.ver
ota self
ota stats
```
For nRF52, `ota self` must report `bootloader: apply OK` or
`bootloader: SD apply OK`. The script also checks the reported bootloader ABI
and codec mask against the selected package.
The script uses `get bootloader.ver` to distinguish ESP32 from nRF52 and, for
nRF52, report the installed bootloader version. It then requires `ota self` to
report `bootloader: apply OK` or `bootloader: SD apply OK` and checks the
reported bootloader ABI and codec mask against the selected package. If the
version command is unavailable on older firmware, the script warns and falls
back to the legacy `ota self` platform marker. If an nRF52 bootloader lacks
the required capabilities, install the exact-board OTAFIX bootloader first.
The default TempRadio tuple is:
@@ -401,7 +406,8 @@ the destination.
1. Validate the input paths and host tools, then prove the source is either an
OTA-enabled raw CLI or a source-only full Companion control interface.
2. Authenticate to the target and query its target ID, hardware, running body
hash, version, and nRF52 bootloader capabilities.
hash, firmware version, bootloader version, and nRF52 bootloader
capabilities.
3. Select or build one compatible mOTA and verify all block hashes, Merkle
root, full-image hash where applicable, identity fields, signature, codec,
base, and the firmware's 1024-byte maximum block size.
+3 -3
View File
@@ -216,6 +216,6 @@ DEBUG: PWRMGT: LPCOMP wake configured (AIN7, ref=3/8 VDD)
## References
- [nRF52840 Product Specification - POWER](https://infocenter.nordicsemi.com/topic/ps_nrf52840/power.html)
- [nRF52840 Product Specification - LPCOMP](https://infocenter.nordicsemi.com/topic/ps_nrf52840/lpcomp.html)
- [SoftDevice S140 API - Power Management](https://infocenter.nordicsemi.com/topic/sdk_nrf5_v17.1.0/group__nrf__sdm__api.html)
- [nRF52840 Product Specification - POWER](https://docs.nordicsemi.com/bundle/ps_nrf52840/page/power.html)
- [nRF52840 Product Specification - LPCOMP](https://docs.nordicsemi.com/bundle/ps_nrf52840/page/lpcomp.html)
- [SoftDevice S140 API - Power Management](https://docs.nordicsemi.com/bundle/sdk_nrf5_v17.1.0/page/group__nrf__sdm__api.html)
+1 -1
View File
@@ -17,7 +17,7 @@ Once you have a working app/project, you need to be able to demonstrate it exist
| 0000 - 00FF | -reserved for internal use- | |
| 0100 | MeshCore Open | zsylvester@monitormx.com - https://github.com/zjs81/meshcore-open |
| 0110 - 011F | Ripple | ripple_biz@protonmail.com - https://buymeacoffee.com/ripplebiz |
| 0120 | MCO Advanced | most.original.address@gmail.com - https://hdden.ru/MCOa/ |
| 0120 | MCO Advanced | most.original.address@gmail.com - https://github.com/HDDen/meshcore-open/tree/rename-mco-advanced |
| FF00 - FFFF | -reserved for testing/dev- | |
(add rows, inside the range 0100 - FEFF for custom apps)
+16 -8
View File
@@ -164,10 +164,12 @@ similar board's bootloader.
Before preparing or downloading a LoRa update, run this on the destination:
```text
get bootloader.ver
ota self
```
Continue only if the reply includes:
The first command identifies the installed nRF52 bootloader. Continue only if
the `ota self` reply includes:
```text
bootloader: apply OK
@@ -272,14 +274,19 @@ ota status
ota ls
```
Discovery is asynchronous. Wait a few seconds and run `ota ls` again if the list is initially empty. Select
the entry marked `[yours]`: it should say `full` for the ESP32 path or `delta` for the nRF52 path. If it is
entry 1, run:
Discovery is asynchronous. `ota ls` says `refreshing`; wait a few seconds and run it again even if it first
shows an older row. Select `[same target]`: it should say `full` for the ESP32 path or `delta` for the nRF52
path. Do not select `[unsupported]` (for example, a source's self-served full image on a single-slot nRF52).
Use the row's stable eight-hex manifest ID rather than its changing list position:
```text
ota pull 1 flash
ota pull 838B8169 flash
```
If an internal-flash nRF52 reports `no EndF`, only a row marked `[rescue]` is eligible. Current rescue-capable
firmware requires `ota pull <mid8> flash rescue`, followed after completion by
`ota rescue install <base_hash16>`. Older running firmware without those commands must be recovered over USB.
Monitor the transfer:
```text
@@ -322,9 +329,10 @@ ota status
- **The CLI says LoRa OTA is not included:** that firmware does not contain the LoRa OTA feature.
If it is the source or destination, install a supported `-ota-` build over WiFi or USB first. An intermediate
repeater does not need the OTA CLI and can relay opaquely while its matching `tempradio` window is active.
- **The update is marked `[other hw]`:** it is for a different board or firmware role. Do not install it.
- **An nRF52 node does not list a full update:** this is intentional for internal-flash targets. The
MeshTower V2 microSD target accepts full images with its matching SD-aware bootloader.
- **The update shows another environment or a raw `[hw XXXXXXXX]`:** it is for a different board or firmware
role. Do not install it.
- **An internal-flash nRF52 marks a full update `[unsupported]`:** it can install only an in-place delta.
The MeshTower V2 microSD target accepts full images with its matching SD-aware bootloader.
- **nRF52 reports no bootloader apply support:** install the exact-board in-place-delta OTAFIX bootloader
before trying LoRa OTA.
- **nRF52 reports a base mismatch:** the file passed to `--base` is not the exact application running on
+6 -5
View File
@@ -264,15 +264,16 @@ then run:
```text
ota ls
ota ls 2
ota get 1 flash
ota get <mid8> flash
ota status
```
Use `ota ls 2`, `ota ls 3`, and so on when the source advertises more than the
two rows that fit in one remote CLI reply. The update numbers are global across
pages, so select the displayed number for the desired target instead of assuming
it is always `1`. A receiver retains the complete protocol catalog and verifies
every transferred block. It still applies its normal target, hardware, codec,
two rows that fit in one remote CLI reply. Each row includes a stable
eight-hex-digit manifest ID. Use that ID instead of a list number, because
asynchronous catalog refreshes can reorder rows between the list and pull
commands. A receiver retains the complete protocol catalog and verifies every
transferred block. It still applies its normal target, hardware, codec,
signature, and installation checks. The SD
repeater may advertise images for many hardware families; it never installs
those archive files merely because it serves them.
+22 -9
View File
@@ -2,8 +2,10 @@
This is the **single source of truth** for MeshCore's over-the-air firmware update system ("mOTA"). It is
written for developers who want to implement an interoperable peer (server, fetcher, relay, or host tool)
in another codebase or project. Everything below is implemented and hardware-verified in this repository;
where a section names a source file, that file is the authoritative reference for byte-level details.
in another codebase or project. Everything below is implemented in this repository and covered by host,
simulation, build, or hardware tests as noted in the relevant section. Hardware qualification is target- and
chain-specific; do not infer it from implementation alone. Where a section names a source file, that file is
the authoritative reference for byte-level details.
> **Just want to update your node?** See the plain-language [OTA user guide](ota_user_guide.md) - this
> document is the technical/wire specification.
@@ -486,7 +488,7 @@ OTA_LEAVES: manifest_id[4] frag_idx(1) frag_total(1) bytes[] # up
device's firmware into a `motatool serve` folder is slow (a full image is hundreds of blocks). Because
builds here are non-deterministic, you cannot reproduce the exact target on the host - but a *similar*
build (e.g. a fresh recompile) is ~99% identical. So `motatool serve --seed <similar.mota>` stages that
build's payload into the destination `.part`, and `ota pull <#> folder validate` makes the fetcher (1) bulk-
build's payload into the destination `.part`, and `ota pull <mid8> folder validate` makes the fetcher (1) bulk-
fetch the target's `leaves[]` via `OTA_GET_LEAVES`/`OTA_LEAVES` (bitmap-fragmented with a `want_mask`, same
anti-burst rule as `OTA_MANIFEST`), (2) recompute the merkle root from them and check it equals the
manifest root (authenticate), then (3) keep every seeded block whose leaf matches and pull full `OTA_DATA`
@@ -584,10 +586,12 @@ transmission per hop.
board/role a target is, a node (and `motatool`) reverse-looks-it-up in `src/helpers/ota/OtaTargets.h` -
a generated `target_id -> env-name` table covering every `ENABLE_OTA` env (`tools/mota/gen_targets.py`,
resolved from `pio project config`). So `ota ls` can render `[Heltec_v3_repeater]` for a neighbour's
beacon without the string being transmitted. Unknown ids show as `other hw` / `N/A`.
beacon without the string being transmitted. Unknown IDs show as raw `hw XXXXXXXX` / `N/A` values.
- **`fw_version`:** packed comparable uint32 (`MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre`); also self-described
in EndF. `ota ls` decodes it for display and flags each update `[yours]` / `[other hw]` / `[?]` by
comparing the advertised `target_id` to the node's own.
in EndF. `ota ls` prints the stable eight-hex manifest ID and uses `[same target]`, `[unsupported]`, or
`[rescue]` after combining target equality with the local codec, bootloader, and EndF preflight. A known
different target is rendered by environment name; an unknown/unset target remains raw or `?`. Target
equality is routing information, not by itself an assertion that an image is safe to install.
- **`hw_id`:** 32-byte NUL-padded ASCII hardware tag inside the signed head. The applier refuses a `.mota`
whose `hw_id` differs from the device's own tag (empty on either side = permissive). Brick-safety
independent of signature.
@@ -612,7 +616,7 @@ verify everything). The serve side (`OtaManager`) keeps a lightweight registry o
resident "views": `view0` (its own firmware) and one on-demand view loaded from a source when a request
targets an external mota. Every fetch message carries `manifest_id`, so dispatch is a registry lookup.
The same host-folder link is also a **pull destination** (the reverse direction): `ota pull <#> folder`
The same host-folder link is also a **pull destination** (the reverse direction): `ota pull <mid8> folder`
fetches a `.mota` off the mesh and streams it onto the host as `<mid>.mota` via the seeder STORAGE ops
(`OP_STAT/BEGIN/WRITE/SREAD/FIN`, see `MotaSeederProto.h`), using a `FolderMotaStore` as the fetch's
`OtaStore` instead of RAM/flash. This captures an exact copy of a device's firmware - e.g. to build a delta
@@ -682,6 +686,11 @@ relays a host folder to a
Heltec V3 over one USB cable, and a host feeds a Heltec V3 over WiFi (`:5001`) while the companion serves a
phone on `:5000` - every block merkle-checked.
The attach reply and bare `ota folder` report `host=advertised/offered`. The registry is RAM-bounded
(`OTA_MAX_SERVE`, with the node's own firmware consuming one slot), so a host may correctly index more valid
files than this particular firmware can advertise. Omitted entries are now reported instead of silently
disappearing. Operators should split a large chain or use a higher-capacity/SD seeder when the two counts differ.
**Transport-agnostic by design.** The request/response *semantics* (`COUNT` / `DESCRIBE(idx)` /
`READ(idx, off, len)` over a folder catalog) are independent of the link. The 2-byte magic + XOR checksum +
resync framing above exists for the shared USB-UART (an unframed byte stream); it is harmless over a
@@ -708,7 +717,8 @@ the recommended user-facing forms. Output is plain-language (a user-facing guide
ota help | ? | h list the commands
ota status | st (or bare `ota`) plain-language: running fw, the one fetch session (state/%/id), serving, keys
ota ls | neighbors | nbrs | updates | n [page] paged updates (queries sources; rows arrive async via OTA_HAVE)
ota get | pull | download <#|mid8> fetch a chosen mOTA (manual; works regardless of autofetch)
ota get | pull | download <mid8|#index> flash [rescue] | folder [validate]
fetch by stable mid8 (preferred) or current page index
ota install | apply | applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader
ota rescue install <base_hash16> internal-flash nRF52 only: recover from failed app-side EndF validation
ota cancel | drop | stop drop the current fetch session (frees the slot)
@@ -722,6 +732,7 @@ ota dev ... bring-up helpers (stage/recv/serve/verify)
---
<a id="12-apply-bootloader-contract"></a>
## 12. Apply & bootloader contract
- **ESP32 (A/B):** applied in-firmware via the detools decoder into the inactive OTA slot
@@ -745,7 +756,9 @@ ota dev ... bring-up helpers (stage/recv/serve/verify)
it still requires USB recovery if that app does not already contain this command.
A chain intended to cross historical firmware must introduce this command in its first bridge and retain
it in every later bridge. Manual pulls still use the build-provided target ID when app-side EndF parsing
fails, so a rescue-capable bridge can fetch its exact successor before invoking the guarded command.
fails, so a rescue-capable bridge can fetch its exact successor before invoking the guarded command. Such
a node must acknowledge the condition up front with `ota pull <mid8> flash rescue`; an ordinary flash pull
refuses before altering staged data. Firmware that predates both rescue commands still requires USB recovery.
- **MeshTower V2 SD nRF52:** the application stores a contiguous `/meshcore-ota.mota` on microSD and
publishes its raw sector range in a checksummed handoff record outside the MBR partition. The matching
bootloader reads the card without mounting FAT, supports either a full image or an in-place delta,
+43 -26
View File
@@ -4,12 +4,13 @@ This guide is for **node operators**: how to update your MeshCore device's firmw
plain language. No cables, no programmer - your node can download a new firmware from a neighbour and
install it. (For the technical wire format, see [the OTA protocol spec](ota_protocol.md).)
LoRa OTA download and installation are present only in supported Keymind artifacts whose filename contains
`-ota-`. Use an `-ota-` build on the source and receiver. Intermediate repeaters do not need OTA-enabled
firmware: current repeater builds transport OTA floods opaquely, subject to their normal forwarding filters,
duplicate checks, and flood limits. OTA radio traffic is accepted, generated, and relayed only while
`tempradio` is actually running on that node. Every source, receiver, and intermediate repeater must therefore
have an overlapping temporary-radio window.
LoRa OTA download and installation are present only in supported Keymind destination artifacts whose filename
contains `-ota-`; the receiver must already be running one of those install-capable builds. A source can be an
OTA-enabled infrastructure node or a source-only Full Companion backed by `motatool`. Intermediate repeaters
do not need OTA-enabled firmware: current repeater builds transport OTA floods opaquely, subject to their normal
forwarding filters, duplicate checks, and flood limits. OTA radio traffic is accepted, generated, and relayed
only while `tempradio` is actually running on that node. Every source, receiver, and intermediate repeater must
therefore have an overlapping temporary-radio window.
The recommended temporary OTA settings use 250 kHz bandwidth, SF5, CR5, and a 120-minute window. For a
North American node currently configured for 909.950 MHz, run this on every participating node:
@@ -96,38 +97,46 @@ ota ls 2 # page 2 when more than two updates are available
```
Your node asks around and lists the firmware updates other nodes nearby are offering, in plain words -
each with a **number**, its version, whether it's a full image or a small delta, how many nodes have it,
and how recently it was seen. For example:
each with a temporary **number**, a stable eight-hex **manifest ID**, its version, whether it's a full image
or a small delta, how many nodes have it, and how recently it was seen. For example:
```
Updates 1/1 (2 src) - `ota get <#>`:
1) v1.2.3 delta [yours] 3n 5s
2) v1.2.0 full [other hw] 1n 12s [downloading]
Updates 1/1 (2 src; refreshing):
1) 838B8169 v1.2.3 delta [same target] 3n 5s
2) BF0AB0C4 v1.2.0 full [unsupported] 1n 12s
```
Each row shows the version, full-vs-delta, **whether it fits your node**, how many nodes have it, and how
long ago it was seen. The fit marker:
- **[yours]** - built for your exact hardware **and** role; safe to install.
- **[other hw]** - a different board or role (e.g. a companion image, or another board). Don't install it.
- **[same target]** - the advertised target ID matches this hardware-and-role build. Download and apply
still enforce codec, bootloader, signed hardware tag, base hash, and integrity checks.
- **[unsupported]** - the target may match, but this build or its bootloader cannot apply that codec. A common
example is the source node's self-served **full** image on a single-slot nRF52, which needs an in-place delta.
- **[rescue]** - an installable in-place nRF52 delta for the same target, but this running firmware has no
valid app-side EndF. It requires the explicit rescue download and install flow below.
- **[name]** - a different known board or role (for example `[ProMicro_companion_radio_usb]`). Don't install it.
- **[?]** - can't tell (a build with no target id set, e.g. a bare IDE build rather than a release build).
Run it again after a few seconds - discovery happens in the background, so the list fills in. Nothing is
downloaded yet; this is just looking around. Two updates fit in each remote CLI reply; use `ota ls 2`,
`ota ls 3`, and so on for later pages. The displayed update numbers remain global across pages.
downloaded yet; this is just looking around. `refreshing` means the command has just sent asynchronous
catalog queries, so run it again even when an older row is already visible. Two updates fit in each remote
CLI reply; use `ota ls 2`, `ota ls 3`, and so on for later pages. Catalog rows can change while replies arrive,
so use the displayed manifest ID for scripts and important operations rather than a numeric position.
(`ota neighbors` / `ota updates` also work.)
### 3. Download an update
Pick one from the list by its **number**, and say **where** to put it:
Pick one from the list by its stable **manifest ID** (a number also works for interactive use), and say
**where** to put it:
```
ota pull 1 flash # stage it in this node's flash, to install here
ota pull 1 folder # capture it onto a connected motatool folder as <id>.mota (don't install here)
ota pull 1 folder validate # same capture, warm-started from a motatool --seed build (much faster; below)
ota pull 838B8169 flash # stage it in this node's flash, to install here
ota pull 838B8169 folder # capture it onto a connected motatool folder as <id>.mota
ota pull 838B8169 folder validate # warm-start capture from a motatool --seed build (much faster; below)
```
The destination is required - `ota pull 1` on its own just shows the choices. **`flash`** is always
The destination is required - `ota pull 838B8169` on its own just shows the choices. **`flash`** is always
available (stage here, then `ota install`). **`folder`** appears only while a `motatool serve` link is
attached (it shows the link, e.g. `folder: tcp 192.168.4.5`); it streams the firmware straight onto the
host folder - nothing is staged on this node. That's how you grab an **exact copy of another device's
@@ -185,17 +194,20 @@ After it reboots, run `ota status` to confirm the new version.
- A download that stalls or gets interrupted just **resumes** later, or you can `ota cancel` and try again.
- An internal-flash **nRF52** that still runs but reports `no EndF` can use the pre-provisioned rescue path
if its physical EndF is intact and only app-side validation is failing. Fetch the exact in-place delta,
obtain its 16-hex-digit `base_hash` from the package metadata, then run:
if its physical EndF is intact and only app-side validation is failing. Fetch the exact `[rescue]`
in-place delta with an explicit acknowledgement, obtain its 16-hex-digit `base_hash` from the package
metadata, then run:
```text
ota pull <mid8> flash rescue
# wait for ota status to say ready to install
ota rescue install <base_hash16>
```
This is not a force option. It refuses a normally valid EndF, a different package hash, hardware or
target mismatch, corrupt payload, and invalid/untrusted signatures. The bootloader independently hashes
the running app and rejects a wrong base before writing the app. If the physical EndF is absent or this
command was not already in the running firmware, recover over USB.
the running app and rejects a wrong base before writing the app. If the physical EndF is absent or the
rescue commands were not already in the running firmware, recover over USB.
Release chains should put this command in their first bridge and keep it in every bridge after that.
- If an **install** fails, the node won't boot a broken image - it lands in **recovery mode**:
- **nRF52:** it appears as a USB drive; drag a known-good firmware `.uf2` for that exact board onto it
@@ -279,6 +291,11 @@ remote area.
`ota get` them like any other. (A WiFi node prints its IP + seeder port to the serial log on connect.
Details: <https://github.com/vk496/motatool>.)
Check the device's attach reply or run `ota folder`: `host=X/Y` means the firmware is advertising `X` of
the `Y` valid entries reported by the host. Serve registries are deliberately RAM-bounded on smaller builds,
and the node's own firmware also consumes a slot. If `X < Y`, split the chain across seeders/folders or use
a higher-capacity seeder; `motatool` saying that every file is valid does not mean every file fit on-device.
To stop, just stop the daemon - over WiFi the node auto-detaches when the connection closes; over USB you
can also run `ota folder off` on the node. `ota folder` on its own lists what your node is offering.
On a FULL repeater or room server, run `start webconfig` first if WiFi is not
@@ -304,7 +321,7 @@ that only contains what changed). You get them by:
- **Downloading a build.** This fork publishes a rolling **`dev-latest`** release on GitHub with the
current firmware for many boards, each accompanied by a `.full.mota` and a tiny `.delta.mota`. Grab the
one for your board to test.
- **Building your own** with the `mota` packaging tool - see [tools/mota/README.md](../tools/mota/README.md)
- **Building your own** with the `mota` packaging tool - see [tools/mota/README.md](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/mota/README.md)
(this is for people distributing updates, not everyday operators).
---
@@ -317,7 +334,7 @@ that only contains what changed). You get them by:
| See my firmware + any download | `ota status` (or just `ota`) |
| Admin: ids/hashes + serving + policy | `ota stats` (admin-only remotely) |
| Find updates nearby | `ota ls` |
| Download update #1 for installation | `ota get 1 flash` |
| Download a listed update for installation | `ota get <mid8> flash` |
| Cancel a download | `ota cancel` |
| Install a finished download | `ota install` |
| Recover app-side `no EndF` on internal nRF52 | `ota rescue install <base_hash16>` |
+4 -3
View File
@@ -9,7 +9,8 @@ This document describes the MeshCore packet format.
## Version 1 Packet Format
This is the protocol level packet structure used in MeshCore firmware v1.12.0
This is the current version-1 MeshCore wire packet structure. Older firmware may
support only a subset of the path encodings and payload types described here.
```
[header][transport_codes(optional)][path_length][path][payload]
@@ -35,7 +36,7 @@ This is the protocol level packet structure used in MeshCore firmware v1.12.0
- `0x09`/`0b1001` - `PAYLOAD_TYPE_TRACE` - Trace a path, collecting SNR for each hop
- `0x0A`/`0b1010` - `PAYLOAD_TYPE_MULTIPART` - Packet is part of a sequence of packets
- `0x0B`/`0b1011` - `PAYLOAD_TYPE_CONTROL` - Control packet data (unencrypted)
- `0x0C`/`0b1100` - reserved
- `0x0C`/`0b1100` - `PAYLOAD_TYPE_OTA` - OTA-over-LoRa firmware distribution
- `0x0D`/`0b1101` - reserved
- `0x0E`/`0b1110` - reserved
- `0x0F`/`0b1111` - `PAYLOAD_TYPE_RAW_CUSTOM` - Custom packet (raw bytes, custom encryption)
@@ -137,7 +138,7 @@ Examples:
| `0x09` | `PAYLOAD_TYPE_TRACE` | Trace a path, collecting SNR for each hop |
| `0x0A` | `PAYLOAD_TYPE_MULTIPART` | Packet is part of a sequence of packets |
| `0x0B` | `PAYLOAD_TYPE_CONTROL` | Control packet data (unencrypted) |
| `0x0C` | reserved | reserved |
| `0x0C` | `PAYLOAD_TYPE_OTA` | OTA-over-LoRa firmware distribution |
| `0x0D` | reserved | reserved |
| `0x0E` | reserved | reserved |
| `0x0F` | `PAYLOAD_TYPE_RAW_CUSTOM` | Custom packet (raw bytes, custom encryption) |
+51 -16
View File
@@ -2,20 +2,16 @@
Inside each [MeshCore Packet](./packet_format.md) is a payload, identified by the payload type in the packet header. The types of payloads are:
* Node advertisement.
* Acknowledgment.
* Returned path.
* Request (destination/source hashes + MAC).
* Response to REQ or ANON_REQ.
* Plain text message.
* Anonymous request.
* Group text message (unverified).
* Group datagram (unverified).
* Multi-part packet
* Control data packet
* Custom packet (raw bytes, custom encryption).
* Request, response, and plain text (`0x00`-`0x02`).
* Acknowledgment and node advertisement (`0x03`-`0x04`).
* Group text and group datagram (`0x05`-`0x06`).
* Anonymous request and returned path (`0x07`-`0x08`).
* Trace, multipart, control, and OTA (`0x09`-`0x0C`).
* Custom raw data (`0x0F`).
This document defines the structure of each of these payload types.
This document describes the shared payload envelopes implemented by the core.
Application-specific request, response, control, and custom bodies can add their
own formats.
NOTE: all 16 and 32-bit integer fields are Little Endian.
@@ -91,9 +87,9 @@ Returned path messages provide a description of the route a packet took from the
| Field | Size (bytes) | Description |
|-------------|--------------|----------------------------------------------------------------------------------------------------------------------|
| path length | 1 | length of next field |
| path | see above | a list of node hashes (one byte each) |
| extra type | 1 | extra, bundled payload type, eg., acknowledgement or response. Same values as in [Packet Format](./packet_format.md) |
| path descriptor | 1 | low 6 bits are the hash count; high 2 bits encode hash size minus one |
| path | count × size | encoded node-hash prefixes, each 1-3 bytes; the four-byte code is reserved |
| extra type | 1 | low nibble is the bundled payload type, such as acknowledgment or response; high nibble is reserved |
| extra | rest of data | extra, bundled payload content, follows same format as main content defined by this document |
### Request
@@ -297,6 +293,45 @@ The data contained in the ciphertext uses the format below:
| pubkey | 8 or 32 | node's ID (or prefix) |
## Trace
Trace packets use direct routing. Their normal direct-path header is empty;
instead, the intended route follows a fixed nine-byte trace header in the
payload. Each forwarding node appends its received SNR multiplied by four to
the packet's route-accumulator field.
| Field | Size (bytes) | Description |
|-------------|--------------|-------------|
| tag | 4 | Sender-selected trace identifier. |
| auth code | 4 | Application-defined authentication/correlation value. |
| flags | 1 | Low two bits encode the route hash size. Current senders use the legacy `1 << value` interpretation (1, 2, 4, or 8 bytes); receivers also accept the packed 1-4-byte interpretation where it is unambiguous. |
| route | rest | Concatenated node-hash prefixes for the requested direct route. |
At the destination, the application receives the tag, auth code, flags,
accumulated SNR bytes, and original route bytes. Trace therefore differs from a
normal direct packet whose path is carried entirely in the packet route field.
## Multipart
The first payload byte identifies the inner payload and how many packets remain:
| Field | Size (bytes) | Description |
|---|---|---|
| remaining + type | 1 | Upper nibble is the remaining-packet count; lower nibble is the inner payload type. |
| inner data | rest | Data for the inner payload type. |
The core currently creates and consumes multipart acknowledgments. For that
form, the low nibble is `0x03` and the next four bytes are the acknowledgment
CRC. Other multipart inner types are reserved for application or future use.
## OTA
An OTA payload contains one OTA protocol message and is normally sent by flood.
Its message types, integrity fields, and transfer state are defined in the
[OTA-over-LoRa protocol](ota_protocol.md). Builds without OTA support can still
relay an opaque OTA packet when their routing policy permits it.
## Custom packet
Custom packets have no defined format.
+4 -1
View File
@@ -126,7 +126,10 @@ sequence has exhaustive offline validation but not yet a complete physical run.
Restore the test RAK locally with the ZIP's
`recovery/test-start/RAK3401-test-start-v1.16.7-c1caa5ad.uf2`. Before starting,
require start version `1.16.7.0`, body hash `71F4026CBE4B8B74`, target
`2FA509C1`, hardware `RAK_3401`, and OTAFIX mOTA ABI 2 with codec 2.
`2FA509C1`, hardware `RAK_3401`, and OTAFIX mOTA ABI 2 with codec 2. The live
runner queries `get bootloader.ver`, reports the installed version, and then
uses `ota self` to verify those apply capabilities before changing any radio
or watchdog setting.
For the tested direct topology:
+38 -9
View File
@@ -290,6 +290,12 @@ void MyMesh::writeContactRespFrame(uint8_t code, const ContactInfo &contact) {
_serial->writeFrame(out_frame, i);
}
void MyMesh::stopContactsIterator() {
if (!_iter_started) return;
_iter_started = false;
if (_serial != NULL) _serial->unlockReplyRoute();
}
void MyMesh::updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len) {
int i = 0;
uint8_t code = frame[i++]; // eg. CMD_ADD_UPDATE_CONTACT
@@ -2368,6 +2374,10 @@ void MyMesh::startInterface(BaseSerialInterface &serial) {
serial.enable();
}
void MyMesh::cancelSerialResponseStream() {
stopContactsIterator();
}
void MyMesh::handleCmdFrame(size_t len) {
if (len == 0) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
@@ -2401,7 +2411,7 @@ void MyMesh::handleCmdFrame(size_t len) {
cmd_frame[len] = 0; // make app_name null terminated
MESH_DEBUG_PRINTLN("App %s connected", app_name);
_iter_started = false; // stop any left-over ContactsIterator
stopContactsIterator(); // stop any left-over ContactsIterator
cancelPendingRadioParamApply();
int i = 0;
out_frame[i++] = RESP_CODE_SELF_INFO;
@@ -2613,16 +2623,18 @@ void MyMesh::handleCmdFrame(size_t len) {
_iter_filter_since = 0;
}
// CONTACTS_START, every CONTACT, and END_OF_CONTACTS are one response
// transaction. Keep them on the transport which requested the list.
_serial->lockReplyRoute();
_iter = startContactsIterator();
_iter_started = true;
_most_recent_lastmod = 0;
uint8_t reply[5];
reply[0] = RESP_CODE_CONTACTS_START;
uint32_t count = getNumContacts(); // total, NOT filtered count
memcpy(&reply[1], &count, 4);
_serial->writeFrame(reply, 5);
// start iterator
_iter = startContactsIterator();
_iter_started = true;
_most_recent_lastmod = 0;
}
} else if (cmd_frame[0] == CMD_SET_ADVERT_NAME && len >= 2) {
int nlen = len - 1;
@@ -2970,7 +2982,7 @@ void MyMesh::handleCmdFrame(size_t len) {
self_id = identity;
writeOKFrame();
// re-load contacts, to invalidate ecdh shared_secrets
_iter_started = false;
stopContactsIterator();
resetContacts();
_store->loadContacts(this);
updateGpsTelemetryPolicy();
@@ -4482,6 +4494,17 @@ void MyMesh::handleTerminalCommand(char* command) {
char reply[160];
applyAndSavePowerSaving(command + 12, reply);
Serial.printf(" %s\r\n", reply);
} else if (strcmp(command, "get radio.rxps") == 0) {
if (!radio_driver.supportsRxPowerSaving()) {
Serial.print(" ERROR: RX power saving is unsupported on this radio\r\n");
} else {
Serial.printf(" radio.rxps %s,level=%u,preamble=%u,rx=%lu,sleep=%lu\r\n",
_prefs.rx_powersaving_enabled ? "on" : "off",
(unsigned)_prefs.rx_ps_level,
(unsigned)_prefs.rx_ps_preamble,
(unsigned long)_prefs.rx_ps_rx_us,
(unsigned long)_prefs.rx_ps_sleep_us);
}
} else if (strcmp(command, "get radio.fem.rxgain") == 0) {
if (!board.canControlLoRaFemLna()) {
Serial.print(" ERROR: FEM RX gain control is unsupported on this board\r\n");
@@ -4502,6 +4525,10 @@ void MyMesh::handleTerminalCommand(char* command) {
char reply[160];
applyAndSavePowerSaving(config + 12, reply);
Serial.printf(" %s\r\n", reply);
} else if (strncmp(config, "radio.rxps ", 11) == 0) {
char reply[160];
applyAndSaveRxPowerSaving(config + 11, reply);
Serial.printf(" %s\r\n", reply);
} else if (strncmp(config, "af ", 3) == 0) {
_prefs.airtime_factor = constrain((float)atof(config + 3), 0.0f, 9.0f);
savePrefs();
@@ -4564,6 +4591,8 @@ void MyMesh::handleTerminalCommand(char* command) {
Serial.print("Commands:\r\n");
Serial.print(" set {name|lat|lon|freq|tx|af} {value}\r\n");
Serial.print(" powersaving [on|off]\r\n");
Serial.print(" get radio.rxps\r\n");
Serial.print(" set radio.rxps <off|on|level 1-10 [preamble 16|32]|rx_us sleep_us>\r\n");
Serial.print(" get radio.fem.rxgain\r\n");
Serial.print(" set radio.fem.rxgain <on|off>\r\n");
Serial.print(" get radio.fem.txgain\r\n");
@@ -4780,7 +4809,7 @@ void MyMesh::checkCLIRescueCmd() {
void MyMesh::checkSerialInterface() {
size_t len = _serial->checkRecvFrame(cmd_frame);
if (!_serial->isConnected()) {
_iter_started = false;
stopContactsIterator();
cancelPendingRadioParamApply();
return;
}
@@ -4803,7 +4832,7 @@ void MyMesh::checkSerialInterface() {
memcpy(&out_frame[1], &_most_recent_lastmod,
4); // include the most recent lastmod, so app can update their 'since'
_serial->writeFrame(out_frame, 5);
_iter_started = false;
stopContactsIterator();
}
//} else if (!_serial->isWriteBusy()) {
// checkConnections(); // TODO - deprecate the 'Connections' stuff
+2
View File
@@ -116,6 +116,7 @@ public:
void activateRadio();
bool isRadioReady() const { return _radio_available; }
void startInterface(BaseSerialInterface &serial);
void cancelSerialResponseStream();
const char *getNodeName();
CompanionNodePrefs *getNodePrefs();
@@ -268,6 +269,7 @@ private:
void writeErrFrame(uint8_t err_code);
void writeDisabledFrame();
void writeContactRespFrame(uint8_t code, const ContactInfo &contact);
void stopContactsIterator();
void updateContactFromFrame(ContactInfo &contact, uint32_t& last_mod, const uint8_t *frame, int len);
void addToOfflineQueue(const uint8_t frame[], int len);
int getFromOfflineQueue(uint8_t frame[]);
+7 -3
View File
@@ -279,6 +279,7 @@ static bool isUsbTerminalDataConnected() {
}
static void enterUsbTerminalMode() {
the_mesh.cancelSerialResponseStream();
usb_serial_interface.setPassthroughMode(true);
clearUsbTerminalLine();
usb_terminal_discard_line = false;
@@ -318,6 +319,7 @@ static void leaveUsbMotaMode(bool acknowledge) {
}
static void enterUsbMotaMode() {
the_mesh.cancelSerialResponseStream();
usb_serial_interface.setPassthroughMode(true);
usb_mota_mode = true;
usb_mota_line_len = 0;
@@ -962,7 +964,8 @@ void setup() {
// activity: a real client has to send us frames.
usb_serial_interface.setConnectedCheck([]() {
uint32_t last = usb_serial_interface.getLastFrameMillis();
return (bool)Serial && last != 0 && (millis() - last) < USB_CLIENT_IDLE_TIMEOUT;
return (bool)Serial && usb_serial_interface.hasReceivedFrame()
&& (millis() - last) < USB_CLIENT_IDLE_TIMEOUT;
});
#elif (defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT) \
|| defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM)
@@ -1058,10 +1061,11 @@ void loop() {
#endif
// USB power alone (for example, a wall charger) must not disable power
// saving. Stay awake only while a computer has an active USB data session.
// saving. Stay awake only while an enumerated USB host is attached.
bool can_sleep = the_mesh.getNodePrefs()->powersaving_enabled
&& !the_mesh.hasPendingWork();
#if defined(NRF52_PLATFORM)
#if defined(NRF52_PLATFORM) \
|| (defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE))
can_sleep = can_sleep && !board.isUsbHostConnected();
#endif
if (can_sleep) {
+10 -1
View File
@@ -3079,13 +3079,20 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.battery_alert_enabled = 0;
_prefs.battery_alert_low_percent = BATTERY_ALERT_LOW_PERCENT_DEFAULT;
_prefs.battery_alert_critical_percent = BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT;
_prefs.powersaving_enabled = DEFAULT_POWERSAVING_ENABLED ? 1 : 0;
#ifdef WITH_MQTT_BRIDGE
_prefs.agc_reset_interval = 7; // 28 seconds (secs/4) - prevents AGC drift on long-running observers
#endif
// Observer defaults (radio_watchdog, alert.*, snmp.*) moved to applyMQTTDefaults()
// in MQTTDefaults.h - they live in /mqtt_prefs now, not NodePrefs.
_prefs.rx_powersaving_enabled = DEFAULT_RXPS_ENABLED ? 1 : 0;
_prefs.rx_ps_level = DEFAULT_RXPS_LEVEL;
_prefs.rx_ps_preamble = DEFAULT_RXPS_PREAMBLE;
_prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
_prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
&_prefs.rx_ps_sleep_us);
// bridge defaults
_prefs.bridge_enabled = 1; // enabled
@@ -9574,6 +9581,7 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
s.rx_ps_preamble = _prefs.rx_ps_preamble;
s.rx_ps_rx_us = _prefs.rx_ps_rx_us;
s.rx_ps_sleep_us = _prefs.rx_ps_sleep_us;
s.power_saving = _prefs.powersaving_enabled;
s.repeat = !_prefs.disable_fwd;
s.advert_interval = _prefs.advert_interval * 2;
s.flood_advert_interval = _prefs.flood_advert_interval;
@@ -9585,7 +9593,8 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
| WebConfigServer::CAP_DELAYS | WebConfigServer::CAP_CAD
| WebConfigServer::CAP_RX_GAIN | WebConfigServer::CAP_REPEAT
| WebConfigServer::CAP_ADVERT | WebConfigServer::CAP_FLOOD
| WebConfigServer::CAP_LOOP | WebConfigServer::CAP_WIFI_POWER_SAVE;
| WebConfigServer::CAP_LOOP | WebConfigServer::CAP_WIFI_POWER_SAVE
| WebConfigServer::CAP_POWER_SAVING;
if (board.canControlLoRaFemLna()) {
s.capabilities |= WebConfigServer::CAP_FEM_RX_GAIN;
}
+10 -1
View File
@@ -1182,8 +1182,15 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.interference_threshold = 0; // disabled
_prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards)
_prefs.cad_enabled = DEFAULT_CAD_ENABLED; // Cascade defaults CAD on; target default remains off
_prefs.powersaving_enabled = DEFAULT_POWERSAVING_ENABLED ? 1 : 0;
_prefs.rx_powersaving_enabled = DEFAULT_RXPS_ENABLED ? 1 : 0;
_prefs.rx_ps_level = DEFAULT_RXPS_LEVEL;
_prefs.rx_ps_preamble = DEFAULT_RXPS_PREAMBLE;
_prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
_prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
&_prefs.rx_ps_sleep_us);
#ifdef ROOM_PASSWORD
StrHelper::strncpy(_prefs.guest_password, ROOM_PASSWORD, sizeof(_prefs.guest_password));
#endif
@@ -1568,6 +1575,7 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
s.rx_ps_preamble = _prefs.rx_ps_preamble;
s.rx_ps_rx_us = _prefs.rx_ps_rx_us;
s.rx_ps_sleep_us = _prefs.rx_ps_sleep_us;
s.power_saving = _prefs.powersaving_enabled;
s.repeat = !_prefs.disable_fwd;
s.advert_interval = _prefs.advert_interval * 2;
s.flood_advert_interval = _prefs.flood_advert_interval;
@@ -1579,7 +1587,8 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) {
| WebConfigServer::CAP_DELAYS | WebConfigServer::CAP_CAD
| WebConfigServer::CAP_RX_GAIN | WebConfigServer::CAP_REPEAT
| WebConfigServer::CAP_ADVERT | WebConfigServer::CAP_FLOOD
| WebConfigServer::CAP_LOOP | WebConfigServer::CAP_WIFI_POWER_SAVE;
| WebConfigServer::CAP_LOOP | WebConfigServer::CAP_WIFI_POWER_SAVE
| WebConfigServer::CAP_POWER_SAVING;
if (board.canControlLoRaFemLna()) {
s.capabilities |= WebConfigServer::CAP_FEM_RX_GAIN;
}
+7
View File
@@ -937,8 +937,15 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise
_prefs.interference_threshold = 0; // disabled
_prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards)
_prefs.cad_enabled = DEFAULT_CAD_ENABLED; // Cascade defaults CAD on; target default remains off
_prefs.powersaving_enabled = DEFAULT_POWERSAVING_ENABLED ? 1 : 0;
_prefs.rx_powersaving_enabled = DEFAULT_RXPS_ENABLED ? 1 : 0;
_prefs.rx_ps_level = DEFAULT_RXPS_LEVEL;
_prefs.rx_ps_preamble = DEFAULT_RXPS_PREAMBLE;
_prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
_prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
&_prefs.rx_ps_sleep_us);
// GPS defaults
_prefs.gps_enabled = 0;
+13 -6
View File
@@ -1,6 +1,6 @@
# PsychicMqttClient
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/license/MIT)
[![Continuous Integration](https://github.com/theelims/PsychicMqttClient/actions/workflows/ci.yml/badge.svg)](https://github.com/theelims/PsychicMqttClient/actions/workflows/ci.yml)
[![PlatformIO Registry](https://badges.registry.platformio.org/packages/elims/library/PsychicMqttClient.svg)](https://registry.platformio.org/libraries/elims/PsychicMqttClient)
@@ -121,15 +121,15 @@ That's it. Your MQTT connection is encrypted now.
### X509 CA Root Certificate Bundles
If you require universal connectivity to more then one server with different root certificate authorities you can use the python script in the `/scripts` folder. It will either download a standard set of the most popular root CA's or use a set of certificates in \*.PEM or \*.DEM file format located in the folder `/ssl_certs`. For the download either the Mozilla collection at [https://curl.se/ca/cacert.pem](https://curl.se/ca/cacert.pem) is used or a collection curated by [Adafruit](https://github.com/adafruit/certificates/) specifically adjusted for the constraints of embedded systems.
If you require connectivity to more than one server with different root certificate authorities, you can use the Python script in the `/scripts` folder. It either downloads a standard set of popular root CAs or uses a set of certificates in \*.PEM or \*.DER format from `/ssl_certs`. The download source can be either the Mozilla collection at [https://curl.se/ca/cacert.pem](https://curl.se/ca/cacert.pem) or a collection curated by [Adafruit](https://github.com/adafruit/certificates/) specifically for the constraints of embedded systems.
Copy the script from the library folder into your platformio project folder `./scripts` so that it can be found. In the `platformio.ini` add the following lines
```ini
extra_scripts = pre:scripts/generate_cert_bundle.py
; Source for SSL Cert Store can bei either downloaded from Mozilla with 'mozilla' ('https://curl.se/ca/cacert.pem')
; or from a curated Adafruit repository with 'adafruit' (https://raw.githubusercontent.com/adafruit/certificates/main/data/roots.pem)
; or complied from a 'folder' full of *.pem / *.dem files stored in the ./ssl_certs folder
; or from a curated Adafruit repository with 'adafruit' (https://raw.githubusercontent.com/adafruit/certificates/main/data/roots-filtered.pem)
; or compiled from a folder of *.pem / *.der files stored in ./ssl_certs
board_ssl_cert_source = adafruit
board_build.embed_files = src/certs/x509_crt_bundle.bin
```
@@ -166,8 +166,15 @@ mqttClient.attachArduinoCACertBundle();
Otherwise the bundle will be overwritten by the MQTT client with unwanted side effects.
> [!IMPORTANT]
> Currently there is a bug in mbedtls which prevents the proper certificate validation for all certificates signed by `Let's encrypt`. For this reason working directly with the ISRG Root X1 CA certificate or the bundle downloaded from Mozilla might result in SSL failing. You need to include the DST Root CA X3 certificate as well. Currently this is only done by the Adafruit repository. You can [read](https://github.com/adafruit/certificates/pull/1) here and [here](https://github.com/espressif/arduino-esp32/issues/8626) about the details.
> [!NOTE]
> Older Let's Encrypt servers could send a cross-signed chain that exposed an
> mbedTLS validation problem and required the expired DST Root CA X3 workaround.
> Let's Encrypt stopped serving that chain in 2024. Current deployments should
> trust ISRG Root X1 through a maintained Mozilla or Adafruit bundle; retain the
> old workaround only when connecting to a deliberately preserved legacy chain.
> See the [Adafruit certificate change](https://github.com/adafruit/certificates/pull/1)
> and the [archived ESP32 report](https://github.com/espressif/arduino-esp32/issues/8626)
> for the historical details.
## Advanced Usage
+2 -2
View File
@@ -11,8 +11,8 @@ monitor_filters = esp32_exception_decoder, log2file
extra_scripts = pre:scripts/generate_cert_bundle.py
; Source for SSL Cert Store can bei either downloaded from Mozilla with 'mozilla' ('https://curl.se/ca/cacert.pem')
; or from a curated Adafruit repository with 'adafruit' (https://raw.githubusercontent.com/adafruit/certificates/main/data/roots.pem)
; or complied from a 'folder' full of *.pem / *.dem files stored in the ./ssl_certs folder
; or from a curated Adafruit repository with 'adafruit' (https://raw.githubusercontent.com/adafruit/certificates/main/data/roots-filtered.pem)
; or compiled from a folder of *.pem / *.der files stored in ./ssl_certs
board_ssl_cert_source = adafruit
board_build.embed_files = src/certs/x509_crt_bundle.bin
+3 -3
View File
@@ -102,9 +102,9 @@ def default_config(setup_mode):
"radio": {
"freq": 910.525, "bw": 62.5, "sf": 7, "cr": 5, "tx": 22, "af": 1.0,
"rxdelay": 0.0, "txdelay": 0.5, "cad": False, "rxgain": True,
"rxps_enabled": True, "rxps_level": 5, "rxps_preamble": 16,
"rxps_rx_us": 20936, "rxps_sleep_us": 13425,
"powersaving": False,
"rxps_enabled": True, "rxps_level": 8, "rxps_preamble": 16,
"rxps_rx_us": 18205, "rxps_sleep_us": 20423,
"powersaving": True,
"repeat": True, "flood_max": 64, "flood_max_advert": 8,
"flood_max_unscoped": 8, "loop_detect": "moderate",
"name": "MockNode", "lat": 39.7392, "lon": -104.9903,
+133 -18
View File
@@ -1,4 +1,5 @@
#include "ArduinoSerialInterface.h"
#include "CompanionFrameQueue.h"
#define RECV_STATE_IDLE 0
#define RECV_STATE_HDR_FOUND 1
@@ -11,6 +12,103 @@ void ArduinoSerialInterface::resetReceiveState() {
_secondaryControlSequencePos = 0;
_frame_len = 0;
rx_len = 0;
_last_rx_byte_ms = 0;
}
void ArduinoSerialInterface::serviceReceiveTimeout() {
if (_state != RECV_STATE_IDLE && _serial != nullptr
&& _serial->available() == 0
&& (uint32_t)(millis() - _last_rx_byte_ms) >= RX_FRAME_TIMEOUT_MS) {
// A truncated length-prefixed frame must not hold the MCU awake forever or
// turn bytes from a later session into the missing tail of the old frame.
resetReceiveState();
}
}
void ArduinoSerialInterface::resetTransmitState() {
_tx_queue_len = 0;
_tx_offset = 0;
}
bool ArduinoSerialInterface::enqueueFrame(const uint8_t src[], size_t len) {
if (src == nullptr || len == 0 || len > MAX_FRAME_SIZE) return false;
// MSG_WAITING is level-triggered. Retaining more than one copy only takes
// space away from command replies while a USB host is backpressuring us.
if (src[0] == 0x83) {
for (uint8_t i = 0; i < _tx_queue_len; ++i) {
if (_tx_queue[i].len > 3 && _tx_queue[i].buf[3] == 0x83) return true;
}
}
const bool delivery_required = mesh::companionFrameRequiresDelivery(src, len);
if (!delivery_required && _tx_queue_len >= TX_QUEUE_SIZE - 1) {
return false; // reserve one slot for a response or required push
}
if (_tx_queue_len == TX_QUEUE_SIZE) {
if (!delivery_required) return false;
// A required frame may replace queued best-effort traffic. Never replace
// the head after any of it has reached the host: doing so would splice two
// frames together on the byte stream.
const uint8_t first_evictable = _tx_offset == 0 ? 0 : 1;
int evict = -1;
for (int i = TX_QUEUE_SIZE - 1; i >= first_evictable; --i) {
const TxFrame& queued = _tx_queue[i];
if (!mesh::companionFrameRequiresDelivery(&queued.buf[3], queued.len - 3)) {
evict = i;
break;
}
}
if (evict < 0) return false;
for (uint8_t i = (uint8_t)evict; i + 1 < _tx_queue_len; ++i) {
_tx_queue[i] = _tx_queue[i + 1];
}
--_tx_queue_len;
}
TxFrame& frame = _tx_queue[_tx_queue_len++];
frame.len = (uint16_t)(len + 3);
frame.buf[0] = '>';
frame.buf[1] = (uint8_t)(len & 0xFF);
frame.buf[2] = (uint8_t)(len >> 8);
memcpy(&frame.buf[3], src, len);
return true;
}
void ArduinoSerialInterface::serviceTransmit() {
if (!_flow_ctl || _passthroughMode || _serial == nullptr) return;
if (!isConnected()) {
// A disconnected USB endpoint cannot make progress. Its host also loses
// any partial endpoint data on disconnect, so begin the next session clean.
resetTransmitState();
return;
}
while (_tx_queue_len > 0) {
TxFrame& frame = _tx_queue[0];
const size_t remaining = frame.len - _tx_offset;
int available = _serial->availableForWrite();
if (available <= 0) return;
// A UART or CDC FIFO can be smaller than MAX_FRAME_SIZE. Drain the frame in
// bounded chunks, retaining the offset and never allowing another frame to
// interleave until this header and body are complete.
const size_t attempt = (size_t)available < remaining
? (size_t)available : remaining;
size_t written = _serial->write(&frame.buf[_tx_offset], attempt);
if (written > attempt) written = attempt;
_tx_offset += (uint16_t)written;
if (_tx_offset < frame.len) return;
for (uint8_t i = 0; i + 1 < _tx_queue_len; ++i) {
_tx_queue[i] = _tx_queue[i + 1];
}
--_tx_queue_len;
_tx_offset = 0;
if (written < attempt) return;
}
}
bool ArduinoSerialInterface::checkControlSequence(uint8_t c,
@@ -39,6 +137,7 @@ void ArduinoSerialInterface::setPassthroughMode(bool enabled) {
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
resetReceiveState();
resetTransmitState();
}
bool ArduinoSerialInterface::takeControlSequence() {
@@ -57,37 +156,52 @@ void ArduinoSerialInterface::enable() {
_isEnabled = true;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
_has_received_frame = false;
resetReceiveState();
resetTransmitState();
}
void ArduinoSerialInterface::disable() {
_isEnabled = false;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
_has_received_frame = false;
resetReceiveState();
resetTransmitState();
}
bool ArduinoSerialInterface::isConnected() const {
if (_serial == nullptr) return false;
if (_conn_check) return _conn_check();
return true; // no way of knowing, so assume yes
}
void ArduinoSerialInterface::loop() {
serviceReceiveTimeout();
serviceTransmit();
}
bool ArduinoSerialInterface::isReadBusy() const {
return false;
return _state != RECV_STATE_IDLE;
}
bool ArduinoSerialInterface::isWriteBusy() const {
if (_passthroughMode) return false;
if (_passthroughMode || _serial == nullptr) return false;
if (_flow_ctl && isConnected()) {
return const_cast<Stream*>(_serial)->availableForWrite() < (int)(MAX_FRAME_SIZE + 3);
if (_tx_queue_len > 0) return true;
return const_cast<Stream*>(_serial)->availableForWrite() <= 0;
}
// while nobody drains the port the TX buffer stays full, so never report
// busy in that case: it would stall the paced streams on all interfaces
return false;
}
bool ArduinoSerialInterface::hasPendingIO() const {
return isReadBusy() || _tx_queue_len > 0;
}
size_t ArduinoSerialInterface::writeFrame(const uint8_t src[], size_t len) {
if (len > MAX_FRAME_SIZE) {
// frame is too big!
if (src == nullptr || len == 0 || len > MAX_FRAME_SIZE || _serial == nullptr) {
// invalid frame or an interface which has not begun yet
return 0;
}
if (_passthroughMode) return len;
@@ -95,29 +209,29 @@ size_t ArduinoSerialInterface::writeFrame(const uint8_t src[], size_t len) {
if (!isConnected()) {
return len; // nobody is listening, drop instead of filling the TX buffer
}
if (_serial->availableForWrite() < (int)(len + 3)) {
// a short write would tear the length prefixed framing, and as there is
// neither a checksum nor a resync marker the receiver would stay out of
// sync forever - so drop the whole frame instead
return 0;
}
if (!enqueueFrame(src, len)) return 0;
serviceTransmit();
return len;
}
uint8_t hdr[3];
hdr[0] = '>';
hdr[1] = (len & 0xFF); // LSB
hdr[2] = (len >> 8); // MSB
_serial->write(hdr, 3);
return _serial->write(src, len);
uint8_t frame[MAX_FRAME_SIZE + 3];
frame[0] = '>';
frame[1] = (uint8_t)(len & 0xFF); // LSB
frame[2] = (uint8_t)(len >> 8); // MSB
memcpy(&frame[3], src, len);
return _serial->write(frame, len + 3) == len + 3 ? len : 0;
}
size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) {
if (_serial == nullptr || dest == nullptr) return 0;
serviceReceiveTimeout();
serviceTransmit();
if (_passthroughMode) return 0;
while (_serial->available()) {
int c = _serial->read();
if (c < 0) break;
_last_rx_byte_ms = millis();
switch (_state) {
case RECV_STATE_IDLE:
@@ -152,6 +266,7 @@ size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) {
memcpy(dest, rx_buf, _frame_len);
_state = RECV_STATE_IDLE; // reset state, for next frame
_last_frame_ms = millis(); // a real client is talking to us
_has_received_frame = true;
return _frame_len;
}
}
+33 -5
View File
@@ -9,6 +9,14 @@ public:
typedef bool (*ConnectedCheck)();
private:
static constexpr uint8_t TX_QUEUE_SIZE = 4;
static constexpr uint32_t RX_FRAME_TIMEOUT_MS = 1000;
struct TxFrame {
uint16_t len;
uint8_t buf[MAX_FRAME_SIZE + 3];
};
bool _isEnabled;
bool _passthroughMode;
bool _controlSequenceReceived;
@@ -20,15 +28,24 @@ private:
uint16_t _frame_len;
uint16_t rx_len;
uint32_t _last_frame_ms;
uint32_t _last_rx_byte_ms;
bool _has_received_frame;
Stream* _serial;
const char* _controlSequence;
const char* _secondaryControlSequence;
ConnectedCheck _conn_check;
uint8_t rx_buf[MAX_FRAME_SIZE];
TxFrame _tx_queue[TX_QUEUE_SIZE];
uint8_t _tx_queue_len;
uint16_t _tx_offset;
bool checkControlSequence(uint8_t c, const char* sequence,
size_t& position, bool& received);
void resetReceiveState();
void serviceReceiveTimeout();
void resetTransmitState();
bool enqueueFrame(const uint8_t src[], size_t len);
void serviceTransmit();
public:
ArduinoSerialInterface()
@@ -36,8 +53,10 @@ public:
_controlSequenceReceived(false), _secondaryControlSequenceReceived(false),
_flow_ctl(false), _state(0), _controlSequencePos(0),
_secondaryControlSequencePos(0), _frame_len(0), rx_len(0),
_last_frame_ms(0), _serial(nullptr), _controlSequence(nullptr),
_secondaryControlSequence(nullptr), _conn_check(nullptr) {}
_last_frame_ms(0), _last_rx_byte_ms(0), _has_received_frame(false),
_serial(nullptr), _controlSequence(nullptr),
_secondaryControlSequence(nullptr), _conn_check(nullptr),
_tx_queue_len(0), _tx_offset(0) {}
void begin(Stream& serial, const char* controlSequence = nullptr,
const char* secondaryControlSequence = nullptr) {
@@ -48,7 +67,9 @@ public:
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
_last_frame_ms = 0;
_has_received_frame = false;
resetReceiveState();
resetTransmitState();
#ifdef RAK_4631
pinMode(WB_IO2, OUTPUT);
#endif
@@ -71,12 +92,17 @@ public:
// millis() of the last completely received frame, 0 if none since boot.
// Useful as an activity-based connection check where no DTR state exists.
uint32_t getLastFrameMillis() const { return _last_frame_ms; }
bool hasReceivedFrame() const { return _has_received_frame; }
// Optional: only hand a frame to the stream when it fits into the TX buffer
// as a whole, and report busy while it does not, so bulk streams get paced.
// Optional: queue complete frames and drain one at a time according to the
// stream's TX capacity, so short writes cannot discard or interleave bytes.
// Report busy while a frame remains queued so bulk streams get paced.
// Only enable this for streams which really implement availableForWrite()
// (USB-CDC does, the Print default returns 0).
void enableFlowControl(bool enable) { _flow_ctl = enable; }
void enableFlowControl(bool enable) {
if (!enable) resetTransmitState();
_flow_ctl = enable;
}
// BaseSerialInterface methods
void enable() override;
@@ -84,9 +110,11 @@ public:
bool isEnabled() const override { return _isEnabled; }
bool isConnected() const override;
void loop() override;
bool isReadBusy() const override;
bool isWriteBusy() const override;
bool hasPendingIO() const override;
size_t writeFrame(const uint8_t src[], size_t len) override;
size_t checkRecvFrame(uint8_t dest[]) override;
};
+5
View File
@@ -25,6 +25,11 @@ public:
// Returns true once for each pending Bluetooth pairing prompt. Non-BLE
// transports keep the default implementation so UI code can poll safely.
virtual bool takePairingRequest() { return false; }
// Multi-transport implementations can pin a sequence of response frames to
// the interface which supplied the current command. Single transports have
// nothing to route, so their default implementations are no-ops.
virtual void lockReplyRoute() {}
virtual void unlockReplyRoute() {}
virtual size_t writeFrame(const uint8_t src[], size_t len) = 0;
virtual size_t checkRecvFrame(uint8_t dest[]) = 0;
};
+7
View File
@@ -3689,6 +3689,13 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "OK - %s,%lu,%lu", enable ? "on" : "off",
(unsigned long)rx_us, (unsigned long)sleep_us);
}
} else if (strcmp(config, "powersaving on") == 0
|| strcmp(config, "powersaving off") == 0) {
const bool enabled = strcmp(&config[12], "on") == 0;
_prefs->powersaving_enabled = enabled ? 1 : 0;
_sensors->setPowerSavingEnabled(enabled);
savePrefs();
sprintf(reply, "OK - powersaving %s", enabled ? "on" : "off");
} else if (memcmp(config, "radio ", 6) == 0) {
strcpy(tmp, &config[6]);
const char *parts[4];
+3
View File
@@ -13,6 +13,9 @@
#ifndef DEFAULT_CAD_ENABLED
#define DEFAULT_CAD_ENABLED 0
#endif
#ifndef DEFAULT_POWERSAVING_ENABLED
#define DEFAULT_POWERSAVING_ENABLED 0
#endif
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
#include <helpers/UserGpio.h>
+88 -6
View File
@@ -1,6 +1,7 @@
#pragma once
#include "BaseSerialInterface.h"
#include "CompanionFrameQueue.h"
#ifndef MAX_INTERFACES
// ble, usb, wifi, ethernet
@@ -26,6 +27,31 @@ private:
bool _enabled = false;
RegisteredInterface _interfaces[MAX_INTERFACES] = {};
BaseSerialInterface* _lastRxInterface = nullptr;
BaseSerialInterface* _lockedReplyInterface = nullptr;
bool isAvailableReplyTarget(BaseSerialInterface* target) const {
if (target == nullptr) return false;
for (auto iface : _interfaces) {
if (iface.instance == target) {
return target->isEnabled() && target->isConnected();
}
}
return false;
}
void clearReplyRouteFor(BaseSerialInterface* target) {
// Keep a locked pointer until the response producer observes that its
// target is unavailable and aborts the transaction. Clearing it here would
// make the remaining frames fall back to broadcast on another interface.
if (_lockedReplyInterface == target) return;
if (_lastRxInterface == target) _lastRxInterface = nullptr;
}
BaseSerialInterface* replyTarget() const {
return _lockedReplyInterface != nullptr
? _lockedReplyInterface : _lastRxInterface;
}
public:
bool addInterface(InterfaceType type, BaseSerialInterface* iface) {
@@ -56,6 +82,7 @@ public:
// find and remove interface
for(int i = 0; i < MAX_INTERFACES; i++){
if(_interfaces[i].instance == iface){
clearReplyRouteFor(iface);
_interfaces[i] = {};
return true;
}
@@ -77,6 +104,7 @@ public:
for(auto iface : _interfaces){
if(iface.instance && iface.type == InterfaceType::Bluetooth){
iface.instance->disable();
clearReplyRouteFor(iface.instance);
}
}
}
@@ -105,6 +133,8 @@ public:
// enable all interfaces
void enable() override {
_enabled = true;
_lastRxInterface = nullptr;
_lockedReplyInterface = nullptr;
for(auto iface : _interfaces){
if(iface.instance){
iface.instance->enable();
@@ -115,6 +145,8 @@ public:
// disable all interfaces
void disable() override {
_enabled = false;
_lastRxInterface = nullptr;
_lockedReplyInterface = nullptr;
for(auto iface : _interfaces){
if(iface.instance){
iface.instance->disable();
@@ -132,9 +164,17 @@ public:
return false;
}
// check if any interface is connected
// A locked multi-frame reply belongs to one client. Treat losing that
// client as a disconnect even if another transport remains connected, so
// the producer aborts instead of leaking the remainder to somebody else.
if (_lockedReplyInterface != nullptr) {
return isAvailableReplyTarget(_lockedReplyInterface);
}
// check if any enabled interface is connected
for(auto iface : _interfaces){
if(iface.instance && iface.instance->isConnected()) {
if(iface.instance && iface.instance->isEnabled()
&& iface.instance->isConnected()) {
return true;
}
}
@@ -158,7 +198,12 @@ public:
return false;
}
// check if any interface is busy
// Pace a response stream against its destination. A slow inactive BLE or
// WiFi client must not stall a contact sync running over USB (or vice versa).
BaseSerialInterface* target = replyTarget();
if (isAvailableReplyTarget(target)) return target->isWriteBusy();
// With no requester yet, preserve the aggregate behavior used for pushes.
for(auto iface : _interfaces){
if(iface.instance && iface.instance->isEnabled() && iface.instance->isWriteBusy()){
return true;
@@ -202,13 +247,35 @@ public:
return false;
}
void lockReplyRoute() override {
if (isAvailableReplyTarget(_lastRxInterface)) {
_lockedReplyInterface = _lastRxInterface;
}
}
void unlockReplyRoute() override {
_lockedReplyInterface = nullptr;
}
size_t writeFrame(const uint8_t src[], size_t len) override {
// don't write when disabled or nothing provided
if(!_enabled || len == 0){
if(!_enabled || src == nullptr || len == 0){
return 0;
}
// write frame to all enabled interfaces
// Responses and delivery-required completion pushes belong to the client
// which supplied the latest command. Best-effort asynchronous observations
// remain broadcast so passive connected apps can keep their view fresh.
if (mesh::companionFrameRequiresDelivery(src, len)) {
BaseSerialInterface* target = replyTarget();
if (target != nullptr) {
if (!isAvailableReplyTarget(target)) return 0;
return target->writeFrame(src, len);
}
}
// Before any command establishes a reply route, or for best-effort pushes,
// write the frame to all enabled interfaces.
bool allSuccessful = true;
for(auto iface : _interfaces){
if(iface.instance && iface.instance->isEnabled()){
@@ -224,15 +291,30 @@ public:
size_t checkRecvFrame(uint8_t dest[]) override {
// don't read when disabled
if(!_enabled){
if(!_enabled || dest == nullptr){
return 0;
}
// Keep a multi-frame response transaction on its originating transport.
// Other interfaces retain their input until the producer unlocks the route.
if (_lockedReplyInterface != nullptr) {
if (!isAvailableReplyTarget(_lockedReplyInterface)) return 0;
size_t frameSize = _lockedReplyInterface->checkRecvFrame(dest);
if (frameSize > 0) _lastRxInterface = _lockedReplyInterface;
return frameSize;
}
if (_lastRxInterface != nullptr
&& !isAvailableReplyTarget(_lastRxInterface)) {
_lastRxInterface = nullptr;
}
// try to read a frame from any enabled interface
for(auto iface : _interfaces){
if(iface.instance && iface.instance->isEnabled()){
size_t frameSize = iface.instance->checkRecvFrame(dest);
if(frameSize > 0){
_lastRxInterface = iface.instance;
return frameSize;
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,7 +4,7 @@
#include "OtaStore.h"
// An OtaStore that captures an in-transit `.mota` onto a HOST folder over the mota-seeder link (the WRITE
// half of MotaSeederProto: OP_STAT/BEGIN/WRITE/SREAD/FIN). This is the destination for `ota pull <#> folder`:
// half of MotaSeederProto: OP_STAT/BEGIN/WRITE/SREAD/FIN). This is the destination for `ota pull <id> folder`:
// blocks stream straight to the host `<mid>.mota` - the device holds NO RAM/flash staging for it.
//
// begin(total) -> OP_BEGIN (host creates a 0xFF-filled <midhex>.mota.part)
+204 -56
View File
@@ -1,6 +1,6 @@
#include "OtaCli.h"
#include "OtaContext.h"
#include "FolderMotaStore.h" // `ota pull <#> folder` destination (set_mid on the connected folder store)
#include "FolderMotaStore.h" // `ota pull <id> folder` destination (set_mid on the connected folder store)
#include "OtaVerify.h"
#include "OtaSelf.h"
#include "OtaTargets.h" // ota_target_env_name(): human-readable name for a target_id (no string on the wire)
@@ -83,6 +83,21 @@ static const char* state_short(OtaManager::FetchState s) {
}
}
static const char* fetch_error_word(OtaManager::FetchError error) {
switch (error) {
case OtaManager::FETCH_ERROR_MANIFEST: return "invalid manifest";
case OtaManager::FETCH_ERROR_HASH_ALGO: return "unsupported hash";
case OtaManager::FETCH_ERROR_CODEC: return "unsupported codec";
case OtaManager::FETCH_ERROR_GEOMETRY: return "invalid geometry";
case OtaManager::FETCH_ERROR_TOO_LARGE: return "image too large";
case OtaManager::FETCH_ERROR_STORAGE: return "storage error";
case OtaManager::FETCH_ERROR_INTEGRITY: return "integrity check";
case OtaManager::FETCH_ERROR_MANIFEST_TIMEOUT: return "manifest timeout";
case OtaManager::FETCH_ERROR_LEAVES_TIMEOUT: return "leaves timeout";
default: return "none";
}
}
// Render the packed fw_version as "v1.2.3" (or "v1.2.3.4" when a prerelease byte is set).
static void ver_str(char* out, size_t cap, uint32_t v) {
FwVersion fw = FwVersion::unpack(v);
@@ -129,16 +144,16 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
if (is_cmd(a, "help|?|h", &rest)) {
#if defined(OTA_SEEDER_ONLY)
snprintf(reply, 160,
"OTA seeder: status | stats | ls=find images | get <#> folder=capture | cancel | "
"OTA seeder: status | stats | ls=find images | get <id> folder=capture | cancel | "
"announce | folder | config. LoRa install is disabled.");
#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE)
strcpy(reply,
"OTA: status | stats | ls | get | install | rescue install <hash16> | cancel | announce | "
"self | folder | config | key");
"OTA: status | stats | ls | get <id> flash [rescue] | install | rescue install <hash16> | "
"cancel | announce | self | folder | config | key");
#else
snprintf(reply, 160,
"OTA: status | stats=admin ids/hashes | ls=find updates | get <#>=download | install | cancel | "
"announce | self | folder | cache | config | key. Use `ota ls [page]`.");
"OTA: status | stats | ls | get <id> flash | install | cancel | announce | self | folder | "
"cache | config | key. `ota ls [page]`; folder [validate].");
#endif
// ---- inventory dashboard: running fw (self), the one fetch session, serving state ----
@@ -162,7 +177,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal();
unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0;
unsigned age = c.session_started_ms ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0;
snprintf(dl, sizeof dl, "download: %s %u/%u (%u%%) id=%s %us", state_word(fs), have, tot, pct, midhx, age);
if (fs == OtaManager::FAILED)
snprintf(dl, sizeof dl, "download: failed (%s) %u/%u id=%s",
fetch_error_word(c.manager.fetchError()), have, tot, midhx);
else
snprintf(dl, sizeof dl, "download: %s %u/%u (%u%%) id=%s %us",
state_word(fs), have, tot, pct, midhx, age);
}
const char* hw = (c.hw_id[0]) ? c.hw_id : "?";
const char* tenv = ota_target_env_name(c.manager.target()); // env name, or "?" if not in the table
@@ -211,7 +231,12 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal();
unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0;
unsigned age = c.session_started_ms ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0;
snprintf(fbuf, sizeof fbuf, "fetch %s %u/%u %u%% id=%s %us", state_short(fs), have, tot, pct, fmid, age);
if (fs == OtaManager::FAILED)
snprintf(fbuf, sizeof fbuf, "fetch failed:%s %u/%u id=%s",
fetch_error_word(c.manager.fetchError()), have, tot, fmid);
else
snprintf(fbuf, sizeof fbuf, "fetch %s %u/%u %u%% id=%s %us",
state_short(fs), have, tot, pct, fmid, age);
}
uint8_t af = c.manager.autofetch();
snprintf(reply, 160, "OTA | fw %s id=%s body=%s %ub %uK | serv %u dg=%s | %s | af=%s hops=%u",
@@ -236,11 +261,18 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
snprintf(reply, CAP, "ERR update page %u out of range (1-%u)", (unsigned)page, (unsigned)pages);
return true;
}
int n = snprintf(reply, CAP, "Updates %u/%u (%u src) - `ota get <#>`:",
int n = snprintf(reply, CAP, "Updates %u/%u (%u src; refreshing):",
(unsigned)page, (unsigned)pages, (unsigned)c.manager.sourceCount());
OtaManager::FetchState fs = c.manager.fetchState();
const uint8_t* cur = (fs != OtaManager::IDLE) ? c.manager.fetchManifestId() : nullptr;
uint32_t myt = c.manager.target(); // effective target (EndF identity if present, else build flag)
#if defined(NRF52_PLATFORM)
const OtaBlCaps& list_bl = c.bootloaderCaps();
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE)
SelfFwInfo list_self;
bool list_has_endf = ota_self_firmware(list_self) && list_self.valid;
#endif
uint32_t now = millis(); int shown = 0;
uint16_t first = (uint16_t)(page - 1) * PAGE_SIZE;
uint16_t last = first + PAGE_SIZE; if (last > count) last = count;
@@ -258,18 +290,32 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
}
uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999;
char ver[20]; ver_str(ver, sizeof ver, h->fw_version);
// What is this update for? "yours" if same target (hw+role) as us; else the target's env name when we
// know it (named locally from its 4-byte target_id - no string travels on the wire); else the raw
// target_id hex (an env this build's OtaTargets.h table doesn't know) or '?' for an unset target.
// Target equality alone is not an install-safety claim. Surface local codec/bootloader limitations,
// and flag the explicit rescue path when this internal-flash nRF52 has no valid running EndF.
char hwbuf[16];
const char* fit;
const char* env = ota_target_env_name(h->target_id);
if (myt && h->target_id == myt) fit = "yours";
else if (env) fit = env;
else if (h->target_id == 0) fit = "?";
if (myt && h->target_id == myt) {
bool installable = c.manager.codecOk(h->codec);
#if defined(NRF52_PLATFORM)
installable = installable && list_bl.present && list_bl.apply_abi >= MOTA_FORMAT_VER
&& h->codec < 16 && (list_bl.codec_mask & (1u << h->codec));
#if defined(OTA_SD_STORE)
installable = installable && (list_bl.storage_flags & OTA_BL_STORAGE_SD);
#endif
#endif
if (!installable) fit = "unsupported";
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE)
else if (!list_has_endf) fit = "rescue";
#endif
else fit = "same target";
} else if (env) fit = env;
else if (h->target_id == 0) fit = "?";
else { snprintf(hwbuf, sizeof hwbuf, "hw %08X", (unsigned)h->target_id); fit = hwbuf; }
n += snprintf(reply + n, CAP - n, "\n %u) %s %s [%s] %un %us%s", (unsigned)(i + 1), ver,
codec_kind(h->codec), fit, (unsigned)h->n_seeders, (unsigned)age, tag);
char midhx[9]; mesh::Utils::toHex(midhx, h->mid, 4);
n += snprintf(reply + n, CAP - n, "\n %u) %s %s %s [%s] %un %us%s",
(unsigned)(i + 1), midhx, ver, codec_kind(h->codec), fit,
(unsigned)h->n_seeders, (unsigned)age, tag);
shown++;
}
if (shown == 0) strcpy(reply, "No updates seen yet - re-run `ota ls` in a few seconds (just asked around).");
@@ -283,19 +329,29 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
while (p[i] && p[i] != ' ' && i < (int)sizeof(selstr) - 1) { selstr[i] = p[i]; i++; }
selstr[i] = 0;
const char* dst = p + i; while (*dst == ' ') dst++;
if (selstr[0] == 0) { strcpy(reply, "usage: ota pull <#> <flash|folder> (see `ota ls`)"); return true; }
if (selstr[0] == 0) { strcpy(reply, "usage: ota pull <id> flash [rescue] | folder [validate] (see `ota ls`)"); return true; }
// resolve the catalogue row (index or explicit manifest_id)
const OtaManager::CatRow* sel = nullptr; uint8_t mid[4];
bool isnum = (selstr[0] == '#');
if (!isnum) { isnum = true; for (const char* x = selstr; *x; x++) if (*x < '0' || *x > '9') { isnum = false; break; } }
if (isnum) {
int idx = atoi(selstr[0] == '#' ? selstr + 1 : selstr);
if (idx >= 1 && idx <= c.manager.catalogCount()) sel = c.manager.catalogRow((uint8_t)(idx - 1));
} else if (mesh::Utils::fromHex(mid, 4, selstr)) {
// An eight-digit all-numeric manifest ID is still an ID, not a huge list index. Bare short decimal
// values retain the legacy index form; `#N` is the unambiguous explicit index spelling.
size_t selector_len = strlen(selstr);
bool explicit_index = selstr[0] == '#';
const char* index_text = explicit_index ? selstr + 1 : selstr;
bool decimal = *index_text != 0;
uint16_t index = 0;
for (const char* x = index_text; *x && decimal; x++) {
if (*x < '0' || *x > '9') decimal = false;
else if (index > 255 / 10 || (index == 255 / 10 && (uint8_t)(*x - '0') > 255 % 10)) decimal = false;
else index = (uint16_t)(index * 10 + (uint8_t)(*x - '0'));
}
bool use_index = explicit_index || (selector_len != 8 && decimal);
if (use_index && decimal) {
if (index >= 1 && index <= c.manager.catalogCount()) sel = c.manager.catalogRow((uint8_t)(index - 1));
} else if (!explicit_index && selector_len == 8 && mesh::Utils::fromHex(mid, 4, selstr)) {
for (uint8_t k = 0; k < c.manager.catalogCount(); k++)
if (memcmp(c.manager.catalogRow(k)->mid, mid, 4) == 0) { sel = c.manager.catalogRow(k); break; }
}
if (!sel) { strcpy(reply, "ERR no such update (see the numbers in `ota ls`)"); return true; }
if (!sel) { strcpy(reply, "ERR no such update (copy its eight-digit ID from `ota ls`)"); return true; }
// destination is MANDATORY: with none given, show the choices (flash always; folder iff a link is up).
if (*dst == 0) {
#if defined(OTA_SEEDER_ONLY)
@@ -315,40 +371,112 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
return true;
}
if (c.apply_pending) { strcpy(reply, "ERR busy applying"); return true; }
// optional 3rd token: `folder validate` -> leaf-diff warm-start against a seed motatool staged (capture only)
bool validate = false;
{ const char* q = dst; while (*q && *q != ' ') q++; while (*q == ' ') q++;
if (strncmp(q, "validate", 8) == 0) validate = true; }
uint8_t selmid[4]; uint32_t seltgt = sel->target_id; memcpy(selmid, sel->mid, 4); // sel may move on reset
OtaStore* store; const char* dname;
if (strncmp(dst, "flash", 5) == 0) {
// Parse exact destination/options. Prefix matches used to accept typos such as "flashgarbage" and an
// ignored third token, which is especially unsafe for the explicit no-EndF rescue acknowledgement.
char destination[8] = {0}, option[10] = {0}, extra[2] = {0};
int parts = sscanf(dst, "%7s %9s %1s", destination, option, extra);
if (parts < 1 || parts > 2) {
strcpy(reply, "ERR usage: ota pull <id> flash [rescue] | folder [validate]");
return true;
}
bool to_flash = strcmp(destination, "flash") == 0;
bool to_folder = strcmp(destination, "folder") == 0;
bool validate = to_folder && parts == 2 && strcmp(option, "validate") == 0;
bool rescue = to_flash && parts == 2 && strcmp(option, "rescue") == 0;
if ((!to_flash && !to_folder) || (parts == 2 && !validate && !rescue)) {
strcpy(reply, "ERR usage: ota pull <id> flash [rescue] | folder [validate]");
return true;
}
uint8_t selmid[4]; uint32_t seltgt = sel->target_id; uint8_t selcodec = sel->codec;
memcpy(selmid, sel->mid, 4); // sel may move when catalog traffic arrives
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
c.stopSdCacheFetch(); // explicit operator work owns the receive slot
#endif
OtaManager::FetchState current = c.manager.fetchState();
if (current != OtaManager::IDLE && current != OtaManager::FAILED) {
snprintf(reply, 160, "ERR OTA slot is %s; use `ota cancel` before replacing it", state_word(current));
return true;
}
OtaStore* store = nullptr; const char* dname = nullptr;
if (to_flash) {
#if defined(OTA_SEEDER_ONLY)
strcpy(reply, "ERR seeder-only build cannot stage or install firmware; use `folder`");
return true;
#else
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
c.stopSdCacheFetch(); // manual install download takes priority over archiving
#endif
store = &c.fetch_store; c.fetch_store.clear(); dname = "flash"; validate = false; // seed lives in the folder
#if defined(NRF52_PLATFORM) && !defined(OTA_SD_STORE)
c.manager.set_accept_full(false); // nRF52 flash can install only in-place deltas
#endif
#endif
} else if (strncmp(dst, "folder", 6) == 0) {
if (!c.folder_dest) { strcpy(reply, "ERR no folder connected (run motatool serve --tcp/--serial)"); return true; }
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
c.stopSdCacheFetch(); // an explicit host capture also takes priority
#endif
c.folder_dest->set_mid(selmid); store = c.folder_dest; dname = validate ? "folder+validate" : "folder";
if (!c.manager.codecOk(selcodec)) {
snprintf(reply, 160,
"ERR %s codec %u cannot be installed by this build; use `ota pull %s folder` to capture it",
codec_kind(selcodec), (unsigned)selcodec, selstr);
return true;
}
#if defined(NRF52_PLATFORM)
c.manager.set_accept_full(true); // capture can store a full image; it is not applied
const OtaBlCaps& bl = c.bootloaderCaps();
if (!bl.present) {
strcpy(reply, "ERR bootloader has no mOTA apply support; update it over USB first");
return true;
}
if (bl.apply_abi < MOTA_FORMAT_VER || selcodec >= 16 || !(bl.codec_mask & (1u << selcodec))) {
snprintf(reply, 160, "ERR bootloader cannot apply mOTA ABI %u codec %u (has abi=%u codecs=0x%x)",
MOTA_FORMAT_VER, (unsigned)selcodec, bl.apply_abi, bl.codec_mask);
return true;
}
#if defined(OTA_SD_STORE)
if (!(bl.storage_flags & OTA_BL_STORAGE_SD)) {
strcpy(reply, "ERR bootloader cannot apply an update staged on SD; update it over USB first");
return true;
}
#endif
} else { strcpy(reply, "ERR destination must be `flash` or `folder`"); return true; }
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE)
SelfFwInfo self;
bool has_endf = ota_self_firmware(self) && self.valid;
if (!has_endf && !rescue) {
snprintf(reply, 160,
"ERR no EndF; retry `ota pull %s flash rescue`, then use `ota rescue install <hash16>`",
selstr);
return true;
}
if (!has_endf && seltgt != c.manager.target()) {
strcpy(reply, "ERR rescue requires an update for this exact target");
return true;
}
if (has_endf && rescue) {
strcpy(reply, "ERR rescue is only for a running firmware with no valid EndF; use `flash`");
return true;
}
#else
if (rescue) {
strcpy(reply, "ERR rescue is available only on internal-flash nRF52 builds");
return true;
}
#endif
store = &c.fetch_store; dname = rescue ? "flash+rescue" : "flash";
#endif
} else if (to_folder) {
if (!c.folder_dest) { strcpy(reply, "ERR no folder connected (run motatool serve --tcp/--serial)"); return true; }
c.folder_dest->set_mid(selmid); store = c.folder_dest; dname = validate ? "folder+validate" : "folder";
}
c.manager.reset_session();
c.fetch_to_folder = to_folder;
c.manager.set_fetch_store(store); // stage this pull to the chosen destination
c.manager.pull(selmid, seltgt, validate); // sets want + begins the manifest fetch now
OtaManager::PullResult result = to_folder
? c.manager.pull_archive(selmid, seltgt, validate)
: c.manager.pull(selmid, seltgt, false);
char midhx[9]; mesh::Utils::toHex(midhx, selmid, 4);
snprintf(reply, 160, "OK pulling mid=%s -> %s (primary traffic)", midhx, dname);
if (result != OtaManager::PULL_STARTED && result != OtaManager::PULL_RESUMED) {
c.manager.reset_session();
c.fetch_to_folder = false;
c.manager.set_fetch_store(&c.fetch_store);
const char* why = result == OtaManager::PULL_NO_STORE ? "no destination store"
: result == OtaManager::PULL_BUSY ? "receive slot busy" : "invalid manifest id";
snprintf(reply, 160, "ERR pull did not start: %s", why);
return true;
}
snprintf(reply, 160, "OK %s mid=%s -> %s (primary traffic)",
result == OtaManager::PULL_RESUMED ? "resuming" : "pulling", midhx, dname);
// ---- discard the current session (e.g. a stalled old fetch) to free the slot ----
} else if (is_cmd(a, "drop|cancel|stop", &rest)) {
@@ -359,6 +487,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
c.stopSdCacheFetch();
#endif
c.manager.reset_session(); c.manager.want(0); c.manager.want_mid(nullptr);
c.fetch_to_folder = false;
c.manager.set_fetch_store(&c.fetch_store); // revert to the default flash store (a folder pull switched it)
#if defined(NRF52_PLATFORM) && !defined(OTA_SD_STORE)
c.manager.set_accept_full(false);
@@ -405,6 +534,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// the exact 8-byte base hash carried by the already-fetched package. The bootloader independently
// hashes the running app and refuses a mismatch before writing any application flash.
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE) && !defined(OTA_SD_STORE) && !defined(OTA_SEEDER_ONLY)
if (c.fetch_to_folder) {
strcpy(reply, "ERR the complete update was captured to a folder, not staged for install; use `ota cancel`");
return true;
}
const char* hash_text = nullptr;
uint8_t operator_base_hash[8];
if (!is_cmd(rest, "install", &hash_text) ||
@@ -439,6 +572,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
return true;
}
#endif
if (c.fetch_to_folder) {
strcpy(reply, "ERR the complete update was captured to a folder, not staged for install; use `ota cancel`");
return true;
}
if (c.manager.fetchState() != OtaManager::COMPLETE || c.fetch_store.staged_size() == 0) {
sprintf(reply, "ERR no complete update fetched (fetch=%c %u/%u)",
fstate_char(c.manager.fetchState()), (unsigned)c.manager.blocksHave(),
@@ -455,7 +592,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// node hosts MANY images (any architecture) it doesn't hold in flash. Trustless (fetchers verify). --
} else if (is_cmd(a, "folder|fold", &rest)) {
const char* p = rest;
if (strncmp(p, "on", 2) == 0) {
if (strcmp(p, "on") == 0) {
#if defined(OTA_FOLDER_SERIAL)
#if !defined(OTA_SEEDER_ONLY)
if (!c.serving) c.serving = ota_serve_self(c, 0); // keep serving our own fw alongside the folder
@@ -465,22 +602,29 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
#else
strcpy(reply, "ERR not built with OTA_FOLDER_SERIAL (set the seeder UART in platformio.ini)");
#endif
} else if (strncmp(p, "off", 3) == 0) {
} else if (strcmp(p, "off") == 0) {
c.detach_folder(); c.manager.announce();
#if defined(OTA_SEEDER_ONLY)
strcpy(reply, "OK folder detached (serving nothing)");
#else
strcpy(reply, "OK folder detached (still serving own fw)");
#endif
} else { // status + list served entries (* = our own fw)
int n = snprintf(reply, 159, "folder=%s serving=%u:", c.folder_active ? "on" : "off",
(unsigned)c.manager.servedCount());
} else if (*p == 0) { // status + list served entries (* = our own fw)
uint16_t offered = 0, advertised = 0;
bool have_stats = c.folderSourceStats(offered, advertised);
int n = have_stats
? snprintf(reply, 159, "folder=%s host=%u/%u serving=%u:", c.folder_active ? "on" : "off",
(unsigned)advertised, (unsigned)offered, (unsigned)c.manager.servedCount())
: snprintf(reply, 159, "folder=%s serving=%u:", c.folder_active ? "on" : "off",
(unsigned)c.manager.servedCount());
for (uint8_t i = 0; i < c.manager.servedCount() && n < 148; i++) {
const OtaManager::ServeEntry* e = c.manager.servedEntry(i);
if (!e) break;
char midhx[9]; mesh::Utils::toHex(midhx, e->mid, 4);
n += snprintf(reply + n, 159 - n, " %s%s/%08X", e->is_self ? "*" : "", midhx, (unsigned)e->target_id);
}
} else {
strcpy(reply, "ERR usage: ota folder [on|off]");
}
// ---- SD OTA archive: default-on capture of every advertised mOTA, retained and served after reboot. ----
@@ -674,6 +818,10 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
strcpy(reply, "OK announced");
} else if (strncmp(d, "verify", 6) == 0) {
if (c.fetch_to_folder && c.manager.fetchState() == OtaManager::COMPLETE) {
strcpy(reply, "ERR completed fetch is in the host folder, not local verification storage");
return true;
}
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (c.manager.fetchState() == OtaManager::COMPLETE) {
VerifyResult r = ota_verify(static_cast<const OtaStore&>(c.fetch_store), c.allow);
@@ -729,7 +877,7 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
#endif
c.manager.clear_primary();
c.serve_expected = 0; c.serving = false; c.releaseServeBuffer();
c.fetch_store.clear(); c.manager.reset_session();
c.fetch_store.clear(); c.manager.reset_session(); c.fetch_to_folder = false;
strcpy(reply, "OK cleared");
} else {
+23 -10
View File
@@ -114,12 +114,13 @@ struct OtaContext {
bool config_dirty = false; // CLI set a policy/key -> CommonCLI persists + clears
char hw_id[33] = {0}; // this device's hardware tag (from board.getOtaHwId(), set in begin)
// ---- Pull destinations (`ota pull <#> <dest>`): where a fetched .mota is staged. `flash` (fetch_store,
// ---- Pull destinations (`ota pull <id> <dest>`): where a fetched .mota is staged. `flash` (fetch_store,
// always present) or `folder` - an external host folder over the seeder link, registered by the app
// while a motatool `serve` connection is attached (else no `folder` dest is offered). Captures the
// container to the host as <mid>.mota so an exact firmware copy can be pulled for delta-building. ----
FolderMotaStore* folder_dest = nullptr; // non-null == a folder destination is currently connected
char folder_dest_info[24] = {0}; // human id of the link (e.g. "tcp 192.168.4.5", "serial")
bool fetch_to_folder = false; // COMPLETE belongs to a capture destination, never install it
void set_folder_dest(FolderMotaStore* fs, const char* info) {
folder_dest = fs;
strncpy(folder_dest_info, info ? info : "?", sizeof(folder_dest_info) - 1);
@@ -150,6 +151,11 @@ struct OtaContext {
}
bool apply_fetched_impl(const uint8_t* rescue_base_hash, char* msg) {
if (fetch_to_folder) {
strncpy(msg, "refused: completed fetch was captured to a folder, not local install storage", 96);
msg[95] = 0;
return false;
}
#if defined(OTA_SEEDER_ONLY)
strncpy(msg, "refused: this build serves mOTA images but cannot install one", 96);
msg[95] = 0;
@@ -234,6 +240,9 @@ struct OtaContext {
FOLDER_LINK_TCP,
};
FolderLink folderLink() const { return _folder_link; }
bool folderSourceStats(uint16_t& offered, uint16_t& advertised) const {
return manager.sourceStats(_folder_source, offered, advertised);
}
bool attach_folder_source(MotaSource* source, FolderLink link, const char* label,
char* msg, size_t cap) {
@@ -250,8 +259,11 @@ struct OtaContext {
}
if (folder_active && _folder_source == source) {
manager.refresh_sources();
snprintf(msg, cap, "OK folder refreshed (%s) - serving %u mOTA total",
label ? label : "external", (unsigned)manager.servedCount());
uint16_t offered = 0, advertised = 0;
manager.sourceStats(source, offered, advertised);
snprintf(msg, cap, "OK folder refreshed (%s): advertising %u/%u host mOTAs%s",
label ? label : "external", (unsigned)advertised, (unsigned)offered,
advertised < offered ? " (some omitted: serve cap/invalid/duplicate)" : "");
return true;
}
if (folder_active && _folder_source) {
@@ -268,13 +280,11 @@ struct OtaContext {
folder_active = true;
_folder_link = link;
_folder_source = source;
#if defined(OTA_SEEDER_ONLY)
snprintf(msg, cap, "OK folder attached (%s) - serving %u host mOTA total",
label ? label : "external", (unsigned)manager.servedCount());
#else
snprintf(msg, cap, "OK folder attached (%s) - serving %u mOTA total (own fw + folder)",
label ? label : "external", (unsigned)manager.servedCount());
#endif
uint16_t offered = 0, advertised = 0;
manager.sourceStats(source, offered, advertised);
snprintf(msg, cap, "OK folder attached (%s): advertising %u/%u host mOTAs%s",
label ? label : "external", (unsigned)advertised, (unsigned)offered,
advertised < offered ? " (some omitted: serve cap/invalid/duplicate)" : "");
return true;
}
@@ -333,6 +343,7 @@ struct OtaContext {
manager.want(0);
manager.want_mid(nullptr);
manager.set_fetch_store(&fetch_store);
fetch_to_folder = false;
_sd_cache_fetching = false;
}
@@ -398,6 +409,7 @@ struct OtaContext {
memcpy(_sd_cache_mid, row->mid, 4);
sd_cache.set_mid(row->mid);
manager.set_fetch_store(&sd_cache);
fetch_to_folder = false;
_sd_cache_fetching = true;
manager.pull_archive(row->mid, row->target_id);
return;
@@ -427,6 +439,7 @@ struct OtaContext {
target_id = 0;
#endif
manager.begin(target_id, send, ctx);
fetch_to_folder = false;
if (hw) { strncpy(hw_id, hw, sizeof(hw_id) - 1); hw_id[sizeof(hw_id) - 1] = 0; }
// a node only fetches firmware it can apply: ESP32 A/B -> sequential, nRF52 single-slot -> in-place
#if defined(OTA_SEEDER_ONLY)
+228 -50
View File
@@ -44,9 +44,14 @@ uint8_t* OtaManager::ensureScratch() {
void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) {
_target = my_target_id; _send = send; _ctx = ctx;
_fstate = IDLE; _have = 0; _fbc = 0;
_fetch_error = FETCH_ERROR_NONE;
_archive_fetch = false; _validate = false;
clearFetchIntent();
_resume_verify_idx = 0; _resume_invalidated = false;
_resume_merkle.reset();
_n_serve = 0; _n_src_obj = 0; _view0.valid = false; _srcv.valid = false;
memset(_src_offered, 0, sizeof(_src_offered));
memset(_src_advertised, 0, sizeof(_src_advertised));
_n_src = 0; _n_cat = 0;
clearPendingEgress();
}
@@ -67,7 +72,7 @@ bool OtaManager::serve(const uint8_t* mota, uint32_t len) {
_view0.scratch = scratch; _view0.scratch_sz = OTA_PROOFGEN_SCRATCH; // <=1024 blocks (RAM .mota is small)
_view0.valid = true;
clearPendingEgress();
registerSelfEntry();
if (_n_src_obj) refresh_sources(); else registerSelfEntry();
return true;
}
@@ -84,7 +89,7 @@ bool OtaManager::serve_self(const uint8_t* manifest, uint16_t mfl, const uint8_t
_view0.scratch = proof_scratch; _view0.scratch_sz = proof_scratch_sz; // sized for our (large) image
_view0.valid = true;
clearPendingEgress();
registerSelfEntry();
if (_n_src_obj) refresh_sources(); else registerSelfEntry();
return true;
}
@@ -112,7 +117,10 @@ bool OtaManager::add_source(MotaSource* src) {
if (_src_list[i] == src) { refresh_sources(); return true; }
}
if (_n_src_obj >= OTA_MAX_SOURCE_OBJ) return false;
_src_list[_n_src_obj++] = src;
_src_list[_n_src_obj] = src;
_src_offered[_n_src_obj] = 0;
_src_advertised[_n_src_obj] = 0;
_n_src_obj++;
refresh_sources();
return true;
}
@@ -121,8 +129,15 @@ bool OtaManager::remove_source(MotaSource* src) {
if (!src) return false;
for (uint8_t i = 0; i < _n_src_obj; i++) {
if (_src_list[i] != src) continue;
for (uint8_t j = i + 1; j < _n_src_obj; j++) _src_list[j - 1] = _src_list[j];
_src_list[--_n_src_obj] = nullptr;
for (uint8_t j = i + 1; j < _n_src_obj; j++) {
_src_list[j - 1] = _src_list[j];
_src_offered[j - 1] = _src_offered[j];
_src_advertised[j - 1] = _src_advertised[j];
}
_n_src_obj--;
_src_list[_n_src_obj] = nullptr;
_src_offered[_n_src_obj] = 0;
_src_advertised[_n_src_obj] = 0;
refresh_sources();
return true;
}
@@ -134,10 +149,14 @@ void OtaManager::refresh_sources() {
_n_serve = 0;
if (_view0.valid) registerSelfEntry();
for (uint8_t s = 0; s < _n_src_obj; s++) {
_src_offered[s] = 0;
_src_advertised[s] = 0;
MotaSource* src = _src_list[s];
if (!src) continue;
uint8_t cnt = src->count();
for (uint8_t i = 0; i < cnt && _n_serve < OTA_MAX_SERVE; i++) {
_src_offered[s] = cnt;
for (uint8_t i = 0; i < cnt; i++) {
if (_n_serve >= OTA_MAX_SERVE) break;
MotaDesc d;
if (!src->describe(i, d)) continue;
if (d.block_count == 0 || (uint64_t)d.block_count * 4 > OTA_PROOFGEN_SCRATCH) continue;
@@ -161,14 +180,31 @@ void OtaManager::refresh_sources() {
e.target_id = d.target_id; e.fw_version = d.fw_version;
e.codec_id = d.codec_id; e.flags = d.flags; e.have_count = d.block_count; // a folder mota is fully held
e.is_self = false; e.src = src; e.src_idx = i; e.desc = d;
_src_advertised[s]++;
}
}
_srcv.valid = false; // a loaded source view may now be stale; reloads on demand
}
bool OtaManager::sourceStats(const MotaSource* src, uint16_t& offered, uint16_t& advertised) const {
offered = 0;
advertised = 0;
if (!src) return false;
for (uint8_t i = 0; i < _n_src_obj; i++) {
if (_src_list[i] != src) continue;
offered = _src_offered[i];
advertised = _src_advertised[i];
return true;
}
return false;
}
void OtaManager::clear_sources() {
clearPendingEgress();
_n_src_obj = 0; _srcv.valid = false;
memset(_src_list, 0, sizeof(_src_list));
memset(_src_offered, 0, sizeof(_src_offered));
memset(_src_advertised, 0, sizeof(_src_advertised));
_n_serve = 0;
if (_view0.valid) registerSelfEntry();
}
@@ -176,12 +212,8 @@ void OtaManager::clear_sources() {
void OtaManager::clear_primary() {
clearPendingEgress();
_view0.valid = false;
if (_n_serve > 0 && _serve[0].is_self) {
for (uint8_t i = 1; i < _n_serve; i++) {
_serve[i - 1] = _serve[i];
}
_n_serve--;
}
if (_n_src_obj) refresh_sources();
else _n_serve = 0;
}
int OtaManager::serveEntryIndex(const uint8_t* mid) const {
@@ -531,6 +563,40 @@ void OtaManager::serviceEgress() {
// ---------------- fetch ----------------
// A source's set digest is the lifetime of its catalog rows. When that digest changes (or the source table
// evicts the source), remove only that seeder's association from every row and recompute aggregate progress.
// Rows with no remaining source disappear, so numeric/MID selection cannot target firmware nobody offers.
void OtaManager::invalidateCatalogSeeder(const uint8_t* seeder) {
if (!seeder) return;
CatRow* catalog = catalogData();
for (uint16_t i = 0; i < _n_cat; ) {
CatRow& row = catalog[i];
int found = -1;
for (uint8_t k = 0; k < row.n_seeders; k++) {
if (memcmp(row.seeders[k], seeder, 4) == 0) { found = k; break; }
}
if (found < 0) { i++; continue; }
for (uint8_t k = (uint8_t)found + 1; k < row.n_seeders; k++) {
memcpy(row.seeders[k - 1], row.seeders[k], 4);
row.seeder_have[k - 1] = row.seeder_have[k];
row.seeder_last_ms[k - 1] = row.seeder_last_ms[k];
}
row.n_seeders--;
if (row.n_seeders == 0) {
for (uint16_t j = i + 1; j < _n_cat; j++) catalog[j - 1] = catalog[j];
_n_cat--;
continue;
}
row.have_max = 0;
row.last_ms = 0;
for (uint8_t k = 0; k < row.n_seeders; k++) {
if (row.seeder_have[k] > row.have_max) row.have_max = row.seeder_have[k];
if (row.seeder_last_ms[k] > row.last_ms) row.last_ms = row.seeder_last_ms[k];
}
i++;
}
}
// A tiny per-node BEACON: record the source; ask it for its catalog (OTA_QUERY) only when we're
// interested AND its set-digest is one we haven't catalogued yet (so a stable mesh is query-free).
void OtaManager::handleAdv(const uint8_t* m, uint16_t n) {
@@ -538,7 +604,16 @@ void OtaManager::handleAdv(const uint8_t* m, uint16_t n) {
if (!decode_adv(m, n, a)) return;
bool have_sid = (_seeder_id[0] | _seeder_id[1] | _seeder_id[2] | _seeder_id[3]) != 0;
if (have_sid && memcmp(a.seeder_id, _seeder_id, 4) == 0) return; // our own beacon, re-flooded
if (a.n_motas == 0) return; // source offers nothing
if (a.n_motas == 0) { // source explicitly withdrew its set
invalidateCatalogSeeder(a.seeder_id);
for (uint8_t i = 0; i < _n_src; i++) {
if (memcmp(_sources[i].seeder, a.seeder_id, 4) != 0) continue;
for (uint8_t j = i + 1; j < _n_src; j++) _sources[j - 1] = _sources[j];
_n_src--;
break;
}
return;
}
int slot = -1, lru = 0; // find/insert the source (LRU evict)
for (int i = 0; i < _n_src; i++) {
@@ -546,9 +621,20 @@ void OtaManager::handleAdv(const uint8_t* m, uint16_t n) {
if (_sources[i].last_ms < _sources[lru].last_ms) lru = i;
}
bool fresh = (slot < 0);
if (fresh) { slot = (_n_src < OTA_MAX_SOURCES) ? _n_src++ : lru; _sources[slot] = Source{}; }
if (fresh) {
// Passive HAVE traffic can leave rows for a source before its beacon is retained. Start its advertised
// digest with a clean association, and purge the evicted source when the fixed source table is full.
invalidateCatalogSeeder(a.seeder_id);
if (_n_src < OTA_MAX_SOURCES) slot = _n_src++;
else {
invalidateCatalogSeeder(_sources[lru].seeder);
slot = lru;
}
_sources[slot] = Source{};
}
Source& s = _sources[slot];
bool changed = fresh || memcmp(s.digest, a.set_digest, 4) != 0;
if (changed && !fresh) invalidateCatalogSeeder(s.seeder);
memcpy(s.seeder, a.seeder_id, 4); memcpy(s.digest, a.set_digest, 4);
s.n_motas = a.n_motas; s.last_ms = _now_ms;
if (changed) {
@@ -627,6 +713,13 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) {
if (hv.frag_total == 0 || hv.frag_total > OTA_HAVE_MAX_FRAGMENTS || hv.frag_idx >= hv.frag_total) return;
bool have_sid = (_seeder_id[0] | _seeder_id[1] | _seeder_id[2] | _seeder_id[3]) != 0;
if (have_sid && memcmp(hv.seeder_id, _seeder_id, 4) == 0) return; // our own catalog
// Once a newer beacon changed this source's digest, delayed HAVE fragments from its old set must not
// resurrect rows we just invalidated. Unknown sources remain cacheable because HAVE is broadcast/passive.
for (uint8_t i = 0; i < _n_src; i++) {
if (memcmp(_sources[i].seeder, hv.seeder_id, 4) != 0) continue;
if (memcmp(_sources[i].digest, hv.set_digest, 4) != 0) return;
break;
}
// PASSIVE: every node caches rows it overhears. A source is catalogued only after EVERY advertised
// fragment arrived; otherwise a timed recovery QUERY asks for just the missing bitmap.
for (uint8_t i = 0; i < _n_src; i++) {
@@ -667,17 +760,30 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) {
catalog[slot] = CatRow{};
memcpy(catalog[slot].mid, mid, 4);
memcpy(catalog[slot].seeders[0], hv.seeder_id, 4);
catalog[slot].seeder_have[0] = (uint16_t)have_count;
catalog[slot].seeder_last_ms[0] = _now_ms;
catalog[slot].n_seeders = 1;
} else {
CatRow& cc = catalog[slot]; // count DISTINCT sources (no double-count)
bool known = false;
int known = -1;
for (uint8_t k = 0; k < cc.n_seeders; k++)
if (memcmp(cc.seeders[k], hv.seeder_id, 4) == 0) { known = true; break; }
if (!known && cc.n_seeders < OTA_CAT_SEEDERS) memcpy(cc.seeders[cc.n_seeders++], hv.seeder_id, 4);
if (memcmp(cc.seeders[k], hv.seeder_id, 4) == 0) { known = k; break; }
if (known < 0 && cc.n_seeders < OTA_CAT_SEEDERS) {
known = cc.n_seeders++;
memcpy(cc.seeders[known], hv.seeder_id, 4);
}
if (known >= 0) {
cc.seeder_have[known] = (uint16_t)have_count;
cc.seeder_last_ms[known] = _now_ms;
}
}
CatRow& c = catalog[slot];
c.target_id = target; c.fw_version = fwver; c.codec = codec; c.flags = flags; c.last_ms = _now_ms;
if (have_count > c.have_max) c.have_max = have_count; // best-known progress among sources
c.target_id = target; c.fw_version = fwver; c.codec = codec; c.flags = flags;
c.have_max = 0; c.last_ms = 0;
for (uint8_t k = 0; k < c.n_seeders; k++) {
if (c.seeder_have[k] > c.have_max) c.have_max = c.seeder_have[k];
if (c.seeder_last_ms[k] > c.last_ms) c.last_ms = c.seeder_last_ms[k];
}
if (wantRow(mid, target, codec, flags)) startFetch(mid, target);
}
}
@@ -833,17 +939,79 @@ bool OtaManager::blockInPipeline(uint32_t block) const {
return findReassemblySlot(block) >= 0;
}
bool OtaManager::fetchActive() const {
return _fstate == FETCHING || _fstate == WANT_MANIFEST || _fstate == WANT_LEAVES
|| _fstate == VERIFYING_STAGED || _fstate == PAUSED;
}
void OtaManager::clearFetchIntent() {
_desired_target = 0;
_have_desired_mid = false;
memset(_desired_mid, 0, sizeof(_desired_mid));
}
void OtaManager::failFetch(FetchError error) {
_fetch_error = error;
_fstate = FAILED;
clearReassembly();
freeLeaves();
_validate = false;
_archive_fetch = false;
clearFetchIntent();
}
void OtaManager::completeFetch() {
_fetch_error = FETCH_ERROR_NONE;
_fstate = COMPLETE;
_validate = false;
_archive_fetch = false;
clearFetchIntent();
}
OtaManager::PullResult OtaManager::pull(const uint8_t* mid, uint32_t target, bool validate) {
if (!mid) return PULL_BAD_MID;
if (!_fetch) return PULL_NO_STORE;
if (fetchActive()) return PULL_BUSY;
_archive_fetch = false;
_desired_target = target;
memcpy(_desired_mid, mid, sizeof(_desired_mid));
_have_desired_mid = true;
reDiscover();
PullResult result = startFetch(mid, target, validate);
if (result != PULL_STARTED && result != PULL_RESUMED) clearFetchIntent();
return result;
}
OtaManager::PullResult OtaManager::pull_archive(const uint8_t* mid, uint32_t target, bool validate) {
if (!mid) return PULL_BAD_MID;
if (!_fetch) return PULL_NO_STORE;
if (fetchActive()) return PULL_BUSY;
_archive_fetch = true;
_desired_target = target;
memcpy(_desired_mid, mid, sizeof(_desired_mid));
_have_desired_mid = true;
reDiscover();
PullResult result = startFetch(mid, target, validate);
if (result != PULL_STARTED && result != PULL_RESUMED) {
_archive_fetch = false;
clearFetchIntent();
}
return result;
}
// Begin (or resume) fetching a chosen mid: try a staged-partial resume first, else request the manifest.
void OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate) {
OtaManager::PullResult OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate) {
(void)target;
if (!_fetch || _fstate == FETCHING || _fstate == WANT_MANIFEST
|| _fstate == WANT_LEAVES || _fstate == VERIFYING_STAGED
|| _fstate == PAUSED) return;
if (!mid) return PULL_BAD_MID;
if (!_fetch) return PULL_NO_STORE;
if (fetchActive()) return PULL_BUSY;
_fetch_error = FETCH_ERROR_NONE;
_validate = validate; // motatool folder-capture warm-start (seed leaf-diff)
// A validate pull is a FRESH seed capture, not a resume: the store already holds the seed's payload (not a
// real partial), so never adopt it via resumeStaged - always re-begin and run the manifest->leaves->diff.
if (!validate && resumeStaged(mid)) return; // (non-validate) resume a partial container left in flash
if (!validate && resumeStaged(mid)) return PULL_RESUMED; // adopt a partial container left in the store
memcpy(_fid, mid, 4);
_have = 0; _fbc = 0; _ftotal = 0; _fflags = 0;
_observed_path_transmissions = 0; // learn the actual source/relay path from this fetch's replies
_fstate = WANT_MANIFEST;
_mf_total = 0; _mf_mask = 0; _mf_len = 0; _mf_retries = 0; _loop_last_mfmask = 0; // fresh manifest reassembly
@@ -851,6 +1019,7 @@ void OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate)
GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); gm.want_mask = 0xFFFF; // first ask: send all fragments
uint8_t b[16];
emit(b, encode_get_manifest(b, sizeof(b), gm), false);
return PULL_STARTED;
}
void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
@@ -882,42 +1051,44 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
const uint8_t* mf = _mf_buf; // fully reassembled manifest-minus-leaves
uint32_t mfl = _mf_len;
if (mfl != MOTA_MFL) { _fstate = FAILED; return; } // manifest-minus-leaves is a fixed 197 bytes
if (mf[2] != HASH_ALGO_SHA256) { _fstate = FAILED; return; } // this implementation supports sha2-256 only
if (!_archive_fetch && !codecOk(mf[56])) { _fstate = IDLE; return; } // incompatible install codec
if (mfl != MOTA_MFL) { failFetch(FETCH_ERROR_MANIFEST); return; } // fixed 197-byte manifest
if (mf[2] != HASH_ALGO_SHA256) { failFetch(FETCH_ERROR_HASH_ALGO); return; }
if (!_archive_fetch && !codecOk(mf[56])) { failFetch(FETCH_ERROR_CODEC); return; }
uint32_t payload_size = rd_u32le(mf + 15);
uint8_t bsl = mf[19];
if (bsl >= 32) { _fstate = FAILED; return; }
if (bsl >= 32) { failFetch(FETCH_ERROR_GEOMETRY); return; }
uint32_t bs = 1u << bsl;
// a block must fit our reassembly buffer (and be non-empty) - reject an oversized block_size up front
if (bs == 0 || bs > OTA_MAX_BLOCK || payload_size == 0) { _fstate = FAILED; return; }
if (bs == 0 || bs > OTA_MAX_BLOCK || payload_size == 0) { failFetch(FETCH_ERROR_GEOMETRY); return; }
uint32_t bc = (payload_size + bs - 1) / bs;
if (bc > 0xFFFFu) { _fstate = FAILED; return; } // block_idx is uint16 on the wire - can't address more
if (bc > 0xFFFFu) { failFetch(FETCH_ERROR_TOO_LARGE); return; } // uint16 block index on the wire
if (_archive_fetch && (uint64_t)bc * 4 > OTA_PROOFGEN_SCRATCH) {
_fstate = FAILED; return; // retaining an image we cannot subsequently seed is useless
failFetch(FETCH_ERROR_TOO_LARGE); return; // retaining an image we cannot seed is useless
}
memcpy(_froot, mf + 20, 4);
uint32_t leaves_off = 8 + mfl;
uint32_t payload_off = leaves_off + bc * 4;
uint64_t total64 = (uint64_t)payload_off + payload_size + 5;
if (total64 > UINT32_MAX) { _fstate = FAILED; return; }
if (total64 > UINT32_MAX) { failFetch(FETCH_ERROR_TOO_LARGE); return; }
uint32_t total = (uint32_t)total64;
// Hand the store the parsed layout BEFORE begin(), so a partition-backed store (ESP32) can choose
// placement and refuse an unfittable fetch up front: a FULL payload streams to the inactive slot,
// a delta's whole container is staged together. (image_size at mf+11, is_full from flags at mf+1.)
bool is_full = (mf[1] & MFLAG_FULL) != 0;
if (!_fetch->plan_layout(is_full, rd_u32le(mf + 11), payload_off, payload_size)) { _fstate = FAILED; return; }
if (!_fetch->begin(total)) { _fstate = FAILED; return; }
if (!_fetch->plan_layout(is_full, rd_u32le(mf + 11), payload_off, payload_size)) {
failFetch(FETCH_ERROR_STORAGE); return;
}
if (!_fetch->begin(total)) { failFetch(FETCH_ERROR_STORAGE); return; }
// declare the metadata extent so a flash store can pin it (leaves are written all transfer long)
if (!_fetch->set_meta_size(payload_off)) { _fstate = FAILED; return; }
if (!_fetch->set_meta_size(payload_off)) { failFetch(FETCH_ERROR_STORAGE); return; }
uint8_t hdr[8];
memcpy(hdr, MOTA_MAGIC, 4);
wr_u32le(hdr + 4, total);
if (!_fetch->write(0, hdr, 8) ||
!_fetch->write(8, mf, mfl) ||
!_fetch->write(total - 5, MOTA_TRAILER, 5)) { _fstate = FAILED; return; }
!_fetch->write(total - 5, MOTA_TRAILER, 5)) { failFetch(FETCH_ERROR_STORAGE); return; }
_fflags = mf[1]; // manifest flags (FULL/SIGNED) of the fetch in progress (auto-install gate)
_fpoff = payload_off; _floff = leaves_off; _fpsize = payload_size; _fbc = bc; _fbs = bs;
@@ -1004,8 +1175,9 @@ void OtaManager::diffStep() {
OTA_DBG("OTA: leaf-diff %u/%u already valid; fetching the rest\n", (unsigned)_have, (unsigned)_fbc);
freeLeaves(); // clears _diffing + frees the buffer
if (_have >= _fbc) { // seed covered the whole image
_fstate = storedLeavesRootMatches() && _fetch->finalize()
? COMPLETE : FAILED;
if (!storedLeavesRootMatches()) failFetch(FETCH_ERROR_INTEGRITY);
else if (!_fetch->finalize()) failFetch(FETCH_ERROR_STORAGE);
else completeFetch();
return;
}
_fstate = FETCHING; requestMissing();
@@ -1045,6 +1217,7 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) {
_ftotal = total;
clearReassembly();
_observed_path_transmissions = 0;
_fetch_error = FETCH_ERROR_NONE;
beginStagedVerification();
OTA_DBG("OTA: RESUME verifying %u blocks total=%u\n",
(unsigned)bc, (unsigned)total);
@@ -1097,7 +1270,7 @@ void OtaManager::verifyStagedStep() {
const uint32_t index = _resume_verify_idx;
if (!_fetch->read(_floff + index * 4,
stored_leaf, sizeof(stored_leaf))) {
_fstate = FAILED;
failFetch(FETCH_ERROR_STORAGE);
return;
}
if (memcmp(stored_leaf, missing_leaf, sizeof(stored_leaf)) == 0) {
@@ -1106,7 +1279,7 @@ void OtaManager::verifyStagedStep() {
const uint32_t length = blockLen(index);
if (!_fetch->read(_fpoff + index * _fbs, _reasm[0].buf, length)) {
_fstate = FAILED;
failFetch(FETCH_ERROR_STORAGE);
return;
}
uint8_t computed_leaf[4];
@@ -1116,14 +1289,14 @@ void OtaManager::verifyStagedStep() {
// requests this block again; a stale marker must never bless bad bytes.
if (!_fetch->write(_floff + index * 4,
missing_leaf, sizeof(missing_leaf))) {
_fstate = FAILED;
failFetch(FETCH_ERROR_STORAGE);
return;
}
_resume_invalidated = true;
continue;
}
if (!_resume_merkle.add(computed_leaf)) {
_fstate = FAILED;
failFetch(FETCH_ERROR_INTEGRITY);
return;
}
_have++;
@@ -1135,10 +1308,13 @@ void OtaManager::verifyStagedStep() {
if (_have == _fbc) {
uint8_t root[4];
_fstate = _resume_merkle.finish(root)
&& memcmp(root, _froot, sizeof(root)) == 0
&& _fetch->finalize()
? COMPLETE : FAILED;
if (!_resume_merkle.finish(root) || memcmp(root, _froot, sizeof(root)) != 0) {
failFetch(FETCH_ERROR_INTEGRITY);
} else if (!_fetch->finalize()) {
failFetch(FETCH_ERROR_STORAGE);
} else {
completeFetch();
}
} else {
_fstate = FETCHING;
requestMissing();
@@ -1201,6 +1377,7 @@ bool OtaManager::handleProof(const uint8_t* m, uint16_t n) {
uint8_t leaf[4]; merkle_leaf(leaf, slot.buf, blen);
if (!_fetch->write(_fpoff + block * _fbs, slot.buf, blen) ||
!_fetch->write(_floff + block * 4, leaf, 4)) {
_fetch_error = FETCH_ERROR_STORAGE;
_fstate = PAUSED; clearReassembly(); return true;
}
_have++;
@@ -1218,8 +1395,9 @@ bool OtaManager::handleProof(const uint8_t* m, uint16_t n) {
// Every leaf must be readable and collectively match the manifest root.
// A scratch allocation/read failure is an integrity failure, never success.
clearReassembly();
_fstate = storedLeavesRootMatches() ? COMPLETE : FAILED;
if (_fstate == COMPLETE && !_fetch->finalize()) _fstate = FAILED;
if (!storedLeavesRootMatches()) failFetch(FETCH_ERROR_INTEGRITY);
else if (!_fetch->finalize()) failFetch(FETCH_ERROR_STORAGE);
else completeFetch();
OTA_DBG("OTA: transfer %s\n", _fstate == COMPLETE ? "COMPLETE" : "FAILED(integrity/storage)");
return true;
}
@@ -1361,7 +1539,7 @@ void OtaManager::loop() {
// the link and burn the retry cap while fragments are still arriving (mirrors FETCHING + WANT_LEAVES).
// Give up after a cap of stalled retries so an unreachable mid doesn't pin the single fetch slot forever.
if (_mf_mask == _loop_last_mfmask) {
if (++_mf_retries > OTA_MANIFEST_MAX_RETRY) { _fstate = FAILED; return; }
if (++_mf_retries > OTA_MANIFEST_MAX_RETRY) { failFetch(FETCH_ERROR_MANIFEST_TIMEOUT); return; }
GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4);
// request only the manifest fragments still missing; 0xFFFF ("send all") until we know frag_total
gm.want_mask = (_mf_total > 0) ? (uint16_t)(frag_full_mask(_mf_total) & ~_mf_mask) : 0xFFFF;
@@ -1378,7 +1556,7 @@ void OtaManager::loop() {
// tick congests the link (and burns the retry cap) while fragments are still streaming in. On a stall,
// ask for just the missing bitmap (anti-burst); give up (FAILED) after a cap of stalled retries.
if (_lv_mask == _loop_last_lvmask) {
if (++_lv_retries > OTA_LEAVES_MAX_RETRY) { freeLeaves(); _fstate = FAILED; return; }
if (++_lv_retries > OTA_LEAVES_MAX_RETRY) { failFetch(FETCH_ERROR_LEAVES_TIMEOUT); return; }
GetLeavesMsg gl; memcpy(gl.manifest_id, _fid, 4);
gl.want_mask = (_lv_total > 0) ? (uint16_t)(frag_full_mask(_lv_total) & ~_lv_mask) : 0xFFFF;
if (gl.want_mask == 0) gl.want_mask = 0xFFFF; // safety: never send an empty request
+44 -12
View File
@@ -84,7 +84,7 @@ typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len)
#if defined(OTA_SD_STORE)
#define OTA_MAX_SERVE 255 // protocol maximum: own fw plus up to 254 persistent SD archive entries
#else
#define OTA_MAX_SERVE 12 // mOTAs THIS node offers (own fw + external folder); == one HAVE fragment
#define OTA_MAX_SERVE 24 // own fw plus a 23-step external bridge chain (fragmented OTA_HAVE)
#endif
#endif
#ifndef OTA_MAX_SOURCE_OBJ
@@ -190,6 +190,27 @@ public:
COMPLETE, FAILED, PAUSED
};
// A manual pull is acknowledged only after the manager has actually acquired the receive slot. This
// keeps the CLI from reporting "OK pulling" when there is no store or another transfer is still active.
enum PullResult : uint8_t {
PULL_STARTED, PULL_RESUMED, PULL_NO_STORE, PULL_BUSY, PULL_BAD_MID
};
// Terminal failures remain visible in `ota status` instead of collapsing back to an indistinguishable
// IDLE/no-download state. Values are deliberately coarse: they are operator diagnostics, not wire data.
enum FetchError : uint8_t {
FETCH_ERROR_NONE,
FETCH_ERROR_MANIFEST,
FETCH_ERROR_HASH_ALGO,
FETCH_ERROR_CODEC,
FETCH_ERROR_GEOMETRY,
FETCH_ERROR_TOO_LARGE,
FETCH_ERROR_STORAGE,
FETCH_ERROR_INTEGRITY,
FETCH_ERROR_MANIFEST_TIMEOUT,
FETCH_ERROR_LEAVES_TIMEOUT
};
// Sentinel for "no block" in the reassembly / peer-REQ / recently-served slots (a real block index is
// a small uint16, so 0xFFFFFFFF is never valid).
static const uint32_t NO_BLOCK = 0xFFFFFFFFu;
@@ -247,6 +268,9 @@ public:
// Call this before releasing or overwriting the primary image's backing buffer.
void clear_primary();
uint8_t servedCount() const { return _n_serve; } // total mOTAs we offer (own fw + folder)
// Per-source enumeration accounting. `offered` is what the attached host/source reported; `advertised`
// is what fit in this build's serve registry after validation/deduplication/capacity limits.
bool sourceStats(const MotaSource* src, uint16_t& offered, uint16_t& advertised) const;
// Read-only view of one served entry (for `ota serve` listing): mid/target/fwver/codec/flags + is_self.
const ServeEntry* servedEntry(uint8_t i) const { return i < _n_serve ? &_serve[i] : nullptr; }
// 4-byte fingerprint of our served set (== the set_digest carried in the beacon); for admin OTA stats.
@@ -274,7 +298,7 @@ public:
uint32_t wanted() const { return _desired_target; }
uint32_t target() const { return _target; } // this node's own OTA target_id (set in begin)
// Pull a SPECIFIC advertised mOTA by manifest_id (e.g. `ota pull <#>` picks the one more peers have),
// Pull a SPECIFIC advertised mOTA by manifest_id (the CLI also accepts a current catalog index),
// not just any firmware for the target. mid=nullptr clears the filter (accept any mid for the target).
void want_mid(const uint8_t* mid) {
if (mid) { for (int i = 0; i < 4; i++) _desired_mid[i] = mid[i]; _have_desired_mid = true; }
@@ -287,16 +311,10 @@ public:
// `validate` enables the motatool folder-capture warm-start: bulk-fetch the target leaves, diff a seed
// build already staged in the destination, and pull DATA only for the differing blocks. Ignored unless a
// seed is present (a plain fetch just re-transfers everything). Normal P2P pulls pass false.
void pull(const uint8_t* mid, uint32_t target, bool validate = false) {
_archive_fetch = false;
want(target); want_mid(mid); startFetch(mid, target, validate);
}
PullResult pull(const uint8_t* mid, uint32_t target, bool validate = false);
// Capture an advertised container for relaying, not installation. Archive pulls accept every codec and
// target because the receiver only verifies and stores bytes; it never tries to apply the result locally.
void pull_archive(const uint8_t* mid, uint32_t target) {
_archive_fetch = true;
want(target); want_mid(mid); startFetch(mid, target, false);
}
PullResult pull_archive(const uint8_t* mid, uint32_t target, bool validate = false);
// Ask every known source for its catalog (populates `ota neighbors`). Async - rows arrive via OTA_HAVE.
void queryAll();
// Coarse clock for source/catalog ages + LRU (the Mesh adapter feeds millis; 0 in host tests is fine).
@@ -371,7 +389,10 @@ public:
// Drop the current fetch session back to IDLE so a fresh `ota pull` / advert starts a new one.
void reset_session() {
_fstate = IDLE; _have = 0; _req_count = 0; _mf_retries = 0;
_fstate = IDLE; _have = 0; _fbc = 0; _ftotal = 0; _fflags = 0;
_req_count = 0; _mf_retries = 0;
_fetch_error = FETCH_ERROR_NONE;
clearFetchIntent();
clearReassembly();
_observed_path_transmissions = 0;
_mf_total = 0; _mf_mask = 0; _mf_len = 0; _loop_last_mfmask = 0;
@@ -382,6 +403,7 @@ public:
}
FetchState fetchState() const { return _fstate; }
FetchError fetchError() const { return _fetch_error; }
uint32_t blocksHave() const { return _have; }
uint32_t blocksTotal() const { return _fbc; }
uint8_t fetchPipelineWidth() const { return _pipeline_width; }
@@ -397,6 +419,8 @@ public:
uint32_t target_id, fw_version;
uint8_t codec, flags;
uint8_t seeders[OTA_CAT_SEEDERS][4]; // distinct sources advertising this mid (deduped; capped)
uint16_t seeder_have[OTA_CAT_SEEDERS]; // progress reported by each tracked source
uint32_t seeder_last_ms[OTA_CAT_SEEDERS]; // age of each tracked source's latest HAVE
uint8_t n_seeders; // count of the above (capped at OTA_CAT_SEEDERS) - "N+ nodes have it"
uint32_t have_max; // best block-count any source reported (== total when a full copy exists)
uint32_t last_ms;
@@ -433,8 +457,13 @@ private:
bool handleData(const uint8_t* m, uint16_t n);
bool handleReqProof(const uint8_t* m, uint16_t n);
bool handleProof(const uint8_t* m, uint16_t n);
void startFetch(const uint8_t* mid, uint32_t target, bool validate = false); // begin/resume a fetch
PullResult startFetch(const uint8_t* mid, uint32_t target, bool validate = false); // begin/resume a fetch
bool wantRow(const uint8_t* mid, uint32_t target, uint8_t codec, uint8_t flags) const; // fetch this row?
bool fetchActive() const;
void clearFetchIntent();
void failFetch(FetchError error);
void completeFetch();
void invalidateCatalogSeeder(const uint8_t* seeder);
void clearReassembly(); // forget every in-flight pipeline slot
void clearReassemblySlot(uint8_t slot);
int findReassemblySlot(uint32_t block) const;
@@ -491,6 +520,8 @@ private:
ServeEntry _serve[OTA_MAX_SERVE]; // catalog (what we advertise) - entry 0 is view0
uint8_t _n_serve = 0;
MotaSource* _src_list[OTA_MAX_SOURCE_OBJ] = {nullptr};
uint16_t _src_offered[OTA_MAX_SOURCE_OBJ] = {0};
uint16_t _src_advertised[OTA_MAX_SOURCE_OBJ] = {0};
uint8_t _n_src_obj = 0;
uint8_t _src_manifest[OTA_SRC_MANIFEST_MAX]; // manifest-minus-leaves of the loaded source mota
#if defined(ESP32_PLATFORM)
@@ -520,6 +551,7 @@ private:
// fetch
OtaStore* _fetch = nullptr;
FetchState _fstate = IDLE;
FetchError _fetch_error = FETCH_ERROR_NONE;
uint8_t _fid[4] = {0};
uint8_t _froot[4] = {0};
uint32_t _ftotal = 0, _fpoff = 0, _floff = 0, _fpsize = 0, _fbc = 0, _fbs = 0;
+30
View File
@@ -13,6 +13,36 @@
#define RX_POWERSAVING_BALANCED_LEVEL 5
#define RX_POWERSAVING_PROFILE_PREAMBLE 16
// Optional initial settings for infrastructure roles. They are intentionally
// disabled unless a build profile supplies them, so upstream/default builds
// retain continuous receive. Companion has its own historical defaults below.
#ifndef DEFAULT_RXPS_ENABLED
#define DEFAULT_RXPS_ENABLED 0
#endif
#ifndef DEFAULT_RXPS_LEVEL
#if DEFAULT_RXPS_ENABLED
#define DEFAULT_RXPS_LEVEL RX_POWERSAVING_BALANCED_LEVEL
#else
#define DEFAULT_RXPS_LEVEL 0
#endif
#endif
#ifndef DEFAULT_RXPS_PREAMBLE
#if DEFAULT_RXPS_ENABLED
#define DEFAULT_RXPS_PREAMBLE RX_POWERSAVING_PROFILE_PREAMBLE
#else
#define DEFAULT_RXPS_PREAMBLE 0
#endif
#endif
#if DEFAULT_RXPS_ENABLED
#if DEFAULT_RXPS_LEVEL < 1 || DEFAULT_RXPS_LEVEL > 10
#error "DEFAULT_RXPS_LEVEL must be between 1 and 10"
#endif
#if DEFAULT_RXPS_PREAMBLE != 16 && DEFAULT_RXPS_PREAMBLE != 32
#error "DEFAULT_RXPS_PREAMBLE must be 16 or 32"
#endif
#endif
// Initial settings for companions. Build flags can override the defaults;
// roles with runtime RXPS controls persist the operator's selection afterward.
#ifndef RXPS_FIXED_ENABLED
+29 -8
View File
@@ -1,16 +1,17 @@
# Host unit tests
Fast, hardware-free unit tests for the fork's pure logic, run on the host with
GoogleTest via PlatformIO's `native` environment. They cover the extractable
observer/WebConfig logic (validation, preset table, topic templates, key
parsing) -- the parts that don't depend on the ESP32, radio, or network stack.
Integration behavior (AsyncTCP transport, WiFi/MQTT, SoftAP) is exercised
separately; see "Local testing without hardware" in `MQTT_IMPLEMENTATION.md`.
Fast, hardware-free unit tests for pure and host-simulated logic, run with
GoogleTest through PlatformIO. The `native` environment runs every suite except
the KISS modem; `native_kiss_modem` builds that suite with its separate source
filter. Hardware, real radio, AsyncTCP, Wi-Fi/MQTT, and SoftAP behavior still
require integration or target testing; see "Local testing without hardware" in
`MQTT_IMPLEMENTATION.md`.
## Running
```sh
pio test -e native # all suites
pio test -e native # all suites except KISS modem
pio test -e native_kiss_modem # KISS modem suite
pio test -e native -f test_webconfig_keys # a single suite
```
@@ -47,8 +48,28 @@ does not reflect the GoogleTest count -- run the built binary directly
| `test_identity_generation` | `src/helpers/IdentityGeneration.h` | reserved-prefix rejection; bounded retries; final provisioned attempt; fail-closed exhaustion |
| `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h`, `src/helpers/RemoteCliRequest.h`, `src/helpers/RemoteCliTimeout.h` | authenticated logical-request matching; bounded recent-reply history; backward-compatible retry identity; 300% response timeout; empty-response completion; on-air truncation and clearing |
| `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h` | response/required/best-effort classification; reserved capacity; stable priority; safe eviction; message-waiting coalescing |
| `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp`, `src/helpers/MultiSerialInterface.h` | independent terminal/seeder control-sequence recognition across reads and binary-frame boundaries; passthrough ownership of USB input and suppression of binary output; Bluetooth-only connection and pairing-request routing |
| `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp`, `src/helpers/MultiSerialInterface.h` | terminal/seeder control-sequence recognition and passthrough ownership; queued/atomic USB output under backpressure and short writes; partial-frame busy state; requester-affine replies, locked contact streams, and Bluetooth-only pairing routing |
| `test_ble_tx_stall_watchdog` | `src/helpers/BleTxStallWatchdog.h` | exact BLE fragment progress; blocked-reply timeout; rollover-safe elapsed time; disconnect recovery retry and completion |
| `test_atomic_file_writer` | `src/helpers/AtomicFileWriter.h` | verified temporary-file commit; short-write, readback, validation, and rename failures; preservation of the live file and stale-temp cleanup |
| `test_cad_timing` | `src/helpers/radiolib/CadTiming.h`, `LR2021SideDetectorConfig.h`, `RadioAirtime.h` | Cascade and slow-profile CAD deadlines; invalid airtime handling; bounded LR2021 side-detector parsing and LDRO recomputation |
| `test_companion_node_prefs` | `examples/companion_radio/NodePrefs.h` | independent device power saving, RXPS, Wi-Fi, and FEM preferences; one-time migration of the regressed power-saving default |
| `test_config_serializer` | `src/helpers/ConfigSerializer.cpp`, Companion `NodePrefs` | escaped config save/load, whitespace and malformed input, unknown fields, and FEM preference round trips |
| `test_deferred_cli_command` | `src/helpers/DeferredCliCommand.h` | copying authenticated command context, single-pending-command enforcement, clearing, and length rejection |
| `test_kiss_modem` | `examples/kiss_modem/KissModem.cpp` | KISS escaping/framing and packet metadata under partial writes, host TX backpressure, queue saturation, and radio completion; run with `native_kiss_modem` |
| `test_mesh_tables` | `src/helpers/SimpleMeshTables.h` | packet and ACK/multipart deduplication, scope-independent identity, route-prefix matching, and deterministic recent-repeater expiry/eviction |
| `test_mqtt_lifecycle` | `src/helpers/MQTTLifecycle.h` | idempotent start/stop, initialization rollback, cooperative stop acknowledgment and timeout, callback ownership, restart, and the OTA flash barrier |
| `test_mqtt_reply_format` | `src/helpers/MQTTReplyFormat.h` | bounded formatted appends, exact-fit and one-byte buffers, truncation, NUL termination, and invalid starting positions |
| `test_packet_manager` | `src/Packet.cpp`, `src/Dispatcher.cpp`, `src/helpers/StaticPoolPacketManager.cpp` | truncated-packet rejection, unavailable-radio behavior, scoped RX-delay replacement, queue/CAD scheduling, and staged radio/TX recovery |
| `test_persistent_store_format` | `src/helpers/PersistentStoreFormat.h` | contact-page headers and CRCs, dirty-page state, stable slot allocation, and bounded resumable legacy migration across power loss |
| `test_power_management` | `src/helpers/PowerManagementUtils.h` | median filtering of a brownout outlier and valid-reading requirements for the boot lock |
| `test_region_names` | `src/helpers/RegionNameUtils.h` | canonical public-region markers while preserving distinct private and differently named regions |
| `test_routing_policy` | `src/helpers/RoutingPolicy.h` | scoped/unscoped flood hop limits and selection of direct, path-return, mirrored-scope, default-scope, or unscoped replies |
| `test_rs232_uart` | `src/helpers/bridges/RS232UartUtils.h` | stopping the active UART peripheral before reassigning its pins |
| `test_security_session_timer` | `src/helpers/nrf52/SecuritySessionTimer.h` | two-minute security-session expiry, cancellation, restart, and `millis()` rollover |
| `test_trace_path_helpers` | `src/helpers/TracePathHelpers.h` | round-trip route construction, hash-width conversion, raw path parsing/limits, and terminal trace timeout bounds |
| `test_user_gpio` | `src/helpers/UserGpio.cpp`, `UserGpioReplyTracker.h` | board-approved pins, get/set/reset, timed nonblocking transitions, duplicate suppression, rollover, and completion-reply routing |
| `test_utf8_helpers` | `src/helpers/UTF8Helpers.h` | byte-limit truncation at complete code-point boundaries and rejection of malformed or truncated UTF-8 |
| `test_wifi_ota_seeder_policy` | `src/helpers/WiFiOtaSeederPolicy.h`, `WiFiOtaSeederStatus.h` | listener state versus network availability, serial/TCP folder ownership, detach detection, and bounded status formatting |
| `test_ota` | `src/helpers/ota/` | container and EndF integrity; protocol codecs; transfer, resume, and apply safety; adaptive 2-to-4 block-request window growth and stall contraction; active-transfer priority classification |
| `test_trace_retry` | `src/Mesh.cpp` retry and relay policy | opaque OTA relay behavior during TempRadio; background discovery priority; immediate primary transfer relay, receive-delay bypass, fast CAD retry, and no generic flood retry; trace and non-OTA flood retry timing |
| `test_utils` | `src/Utils.cpp` | `Utils::toHex` (upstream) |
+106 -2
View File
@@ -1428,6 +1428,20 @@ TEST(OtaCatalog, RejectsAdvertisedSourceWithOversizedBlocks) {
EXPECT_EQ(server.servedCount(), 0);
}
TEST(OtaFolder, ReportsEntriesOmittedByServeRegistryCapacity) {
OtaManager server;
server.begin(0, nullptr, nullptr);
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
SyntheticCatalogSource source((uint8_t)(OTA_MAX_SERVE + 3));
ASSERT_TRUE(server.add_source(&source));
uint16_t offered = 0, advertised = 0;
ASSERT_TRUE(server.sourceStats(&source, offered, advertised));
EXPECT_EQ(offered, (uint16_t)(OTA_MAX_SERVE + 3));
EXPECT_EQ(advertised, (uint16_t)(OTA_MAX_SERVE - 1)); // primary image occupies slot zero
EXPECT_EQ(server.servedCount(), OTA_MAX_SERVE);
}
// Fetch-resume across a reboot: a client commits some blocks, "reboots" (a fresh OtaManager on the SAME
// persisted store), and resumeStaged() re-adopts the partial container and finishes the remaining blocks -
// without re-fetching the manifest or the blocks already present.
@@ -1633,6 +1647,49 @@ TEST(OtaTransfer, RejectsIncompatibleCodec) {
g_q.clear();
}
TEST(OtaTransfer, ManualPullReportsIncompatibleManifestInsteadOfGoingIdle) {
g_q.clear();
OtaManager server, client;
OtaStoreRam<4096> store;
SendTo to_client{&client}, to_server{&server};
server.begin(0, sim_send, &to_client);
client.begin(SIM_TARGET_ID, sim_send, &to_server);
client.set_fetch_store(&store);
client.set_apply_codec(CODEC_DETOOLS_INPLACE);
client.set_accept_full(false);
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
MotaManifest manifest;
ASSERT_TRUE(mota_parse(SIM_MOTA, SIM_MOTA_LEN, manifest));
EXPECT_EQ(client.pull(manifest.merkle_root, manifest.target_id), OtaManager::PULL_STARTED);
pump(client, &server);
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_CODEC);
EXPECT_EQ(client.wanted(), 0u); // terminal rejection no longer leaves discovery armed
EXPECT_EQ(store.staged_size(), 0u); // compatibility is rejected before the store is begun
}
TEST(OtaTransfer, PullAdmissionReportsNoStoreAndBusyWithoutReplacingIntent) {
g_q.clear();
OtaManager client;
SendTo to_none{&client};
client.begin(SIM_TARGET_ID, sim_send, &to_none);
uint8_t first[4] = {1, 2, 3, 4}, second[4] = {5, 6, 7, 8};
EXPECT_EQ(client.pull(first, SIM_TARGET_ID), OtaManager::PULL_NO_STORE);
EXPECT_EQ(client.fetchState(), OtaManager::IDLE);
EXPECT_EQ(client.wanted(), 0u);
OtaStoreRam<4096> store;
client.set_fetch_store(&store);
EXPECT_EQ(client.pull(first, SIM_TARGET_ID), OtaManager::PULL_STARTED);
EXPECT_EQ(client.pull(second, SIM_TARGET_ID ^ 0x55AAu), OtaManager::PULL_BUSY);
EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
EXPECT_EQ(client.wanted(), SIM_TARGET_ID);
EXPECT_EQ(0, memcmp(client.fetchManifestId(), first, sizeof(first)));
g_q.clear();
}
// An archive capture is not an install. It must retain cross-target and otherwise unsupported containers
// byte-for-byte so this node can relay them to hardware that does understand their codec.
TEST(OtaTransfer, ArchivePullAcceptsCrossTargetUnsupportedCodec) {
@@ -1660,14 +1717,16 @@ TEST(OtaTransfer, ArchivePullAcceptsCrossTargetUnsupportedCodec) {
// Encode a 1-row OTA_HAVE from a specific seeder, carrying have_count (Phase-2 awareness).
static uint16_t make_have_row(uint8_t* buf, uint16_t cap, const uint8_t mid[4], uint32_t target,
uint32_t fwver, uint8_t codec, uint8_t flags,
const uint8_t seeder[4], uint16_t have_count) {
const uint8_t seeder[4], uint16_t have_count,
const uint8_t digest[4] = nullptr) {
uint8_t row[OTA_HAVE_ROW_BYTES];
memcpy(row, mid, 4);
row[4]=target; row[5]=target>>8; row[6]=target>>16; row[7]=target>>24;
row[8]=fwver; row[9]=fwver>>8; row[10]=fwver>>16; row[11]=fwver>>24;
row[12]=codec; row[13]=flags;
row[14]=(uint8_t)(have_count & 0xFF); row[15]=(uint8_t)(have_count >> 8);
HaveMsg hv; memcpy(hv.seeder_id, seeder, 4); memset(hv.set_digest, 0, 4);
HaveMsg hv; memcpy(hv.seeder_id, seeder, 4);
if (digest) memcpy(hv.set_digest, digest, 4); else memset(hv.set_digest, 0, 4);
hv.frag_idx=0; hv.frag_total=1; hv.n_rows=1; hv.rows=row;
return encode_have(buf, cap, hv);
}
@@ -1689,6 +1748,49 @@ TEST(OtaCatalog, DistinctSeederCountAndHaveCount) {
g_q.clear();
}
TEST(OtaCatalog, DigestChangePurgesOnlyThatSeedersRowsAndProgress) {
OtaManager m; SendTo none{&m}; m.begin(SIM_TARGET_ID, sim_send, &none);
m.set_archive_interest(true);
uint8_t wire[64], mid[4] = {9, 8, 7, 6};
uint8_t s1[4] = {1, 0, 0, 0}, s2[4] = {2, 0, 0, 0};
uint8_t d1[4] = {0x11, 0, 0, 0}, d2[4] = {0x22, 0, 0, 0}, changed[4] = {0x33, 0, 0, 0};
auto advertise = [&](const uint8_t sid[4], const uint8_t digest[4], uint8_t count = 1) {
AdvMsg adv{}; memcpy(adv.seeder_id, sid, 4); memcpy(adv.set_digest, digest, 4); adv.n_motas = count;
uint16_t n = encode_adv(wire, sizeof(wire), adv);
ASSERT_GT(n, 0);
m.on_message(wire, n);
};
advertise(s1, d1);
advertise(s2, d2);
m.set_clock(100);
m.on_message(wire, make_have_row(wire, sizeof(wire), mid, SIM_TARGET_ID, 0x01020300,
CODEC_FULL, MFLAG_FULL, s1, 7, d1));
m.set_clock(200);
m.on_message(wire, make_have_row(wire, sizeof(wire), mid, SIM_TARGET_ID, 0x01020300,
CODEC_FULL, MFLAG_FULL, s2, 3, d2));
ASSERT_EQ(m.catalogCount(), 1);
ASSERT_EQ(m.catalogRow(0)->n_seeders, 2);
ASSERT_EQ(m.catalogRow(0)->have_max, 7u);
advertise(s1, changed);
ASSERT_EQ(m.catalogCount(), 1);
EXPECT_EQ(m.catalogRow(0)->n_seeders, 1);
EXPECT_EQ(m.catalogRow(0)->have_max, 3u);
EXPECT_EQ(m.catalogRow(0)->last_ms, 200u);
m.set_clock(300);
m.on_message(wire, make_have_row(wire, sizeof(wire), mid, SIM_TARGET_ID, 0x01020300,
CODEC_FULL, MFLAG_FULL, s1, 9, d1)); // delayed row from the old digest
ASSERT_EQ(m.catalogCount(), 1);
EXPECT_EQ(m.catalogRow(0)->n_seeders, 1);
EXPECT_EQ(m.catalogRow(0)->have_max, 3u);
advertise(s2, changed, 0); // an explicit empty advert withdraws the source
EXPECT_EQ(m.catalogCount(), 0); // nobody still advertises the old set
g_q.clear();
}
// An unanswered GET_MANIFEST must not pin the fetch slot forever: after OTA_MANIFEST_MAX_RETRY ticks with
// no manifest, the session gives up (FAILED) so a new pull can take the slot. (Bounded primary operation.)
TEST(OtaTransfer, ManifestGiveUpAfterRetries) {
@@ -1701,6 +1803,8 @@ TEST(OtaTransfer, ManifestGiveUpAfterRetries) {
EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
for (int i = 0; i < OTA_MANIFEST_MAX_RETRY + 2; i++) { g_clk += 5000; client.set_clock(g_clk); client.loop(); g_q.clear(); }
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
EXPECT_EQ(client.fetchError(), OtaManager::FETCH_ERROR_MANIFEST_TIMEOUT);
EXPECT_EQ(client.wanted(), 0u);
}
// A receiver never becomes a source, either while fetching or after completion.
@@ -1,6 +1,8 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <deque>
#include <limits>
#include <vector>
#include "helpers/ArduinoSerialInterface.h"
@@ -10,6 +12,8 @@ class BufferStream : public Stream {
public:
std::deque<uint8_t> input;
std::vector<uint8_t> output;
int write_capacity = 4096;
size_t max_write = std::numeric_limits<size_t>::max();
void push(const char* data) {
while (*data) input.push_back((uint8_t)*data++);
@@ -20,6 +24,7 @@ public:
}
int available() override { return (int)input.size(); }
int availableForWrite() override { return write_capacity; }
int read() override {
if (input.empty()) return -1;
@@ -29,13 +34,15 @@ public:
}
size_t write(uint8_t value) override {
output.push_back(value);
return 1;
return write(&value, 1);
}
size_t write(const uint8_t* data, size_t len) override {
output.insert(output.end(), data, data + len);
return len;
size_t accepted = std::min(len, max_write);
accepted = std::min(accepted, (size_t)std::max(write_capacity, 0));
output.insert(output.end(), data, data + accepted);
write_capacity -= (int)accepted;
return accepted;
}
};
@@ -47,20 +54,32 @@ public:
bool enabled = false;
bool connected = false;
bool pairing_request = false;
bool write_busy = false;
std::deque<std::vector<uint8_t>> received_frames;
std::vector<std::vector<uint8_t>> sent_frames;
void enable() override { enabled = true; }
void disable() override { enabled = false; }
bool isEnabled() const override { return enabled; }
bool isConnected() const override { return connected; }
bool isReadBusy() const override { return false; }
bool isWriteBusy() const override { return false; }
bool isWriteBusy() const override { return write_busy; }
bool takePairingRequest() override {
bool pending = pairing_request;
pairing_request = false;
return pending;
}
size_t writeFrame(const uint8_t[], size_t len) override { return len; }
size_t checkRecvFrame(uint8_t[]) override { return 0; }
size_t writeFrame(const uint8_t src[], size_t len) override {
sent_frames.emplace_back(src, src + len);
return len;
}
size_t checkRecvFrame(uint8_t dest[]) override {
if (received_frames.empty()) return 0;
const std::vector<uint8_t> frame = received_frames.front();
received_frames.pop_front();
memcpy(dest, frame.data(), frame.size());
return frame.size();
}
};
TEST(MultiSerialInterface, TracksBluetoothConnectionSeparately) {
@@ -99,6 +118,120 @@ TEST(MultiSerialInterface, PairingRequestsComeOnlyFromBluetooth) {
EXPECT_FALSE(manager.takePairingRequest());
}
TEST(MultiSerialInterface, RoutesRequiredRepliesToTheirRequestingInterface) {
MultiSerialInterface manager;
FakeSerialInterface usb;
FakeSerialInterface wifi;
usb.connected = true;
wifi.connected = true;
ASSERT_TRUE(manager.addInterface(InterfaceType::USB, &usb));
ASSERT_TRUE(manager.addInterface(InterfaceType::WiFi, &wifi));
manager.enable();
usb.received_frames.push_back({0x01});
uint8_t command[MAX_FRAME_SIZE] = {};
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
const uint8_t response[] = {0x05, 0xAA};
EXPECT_EQ(manager.writeFrame(response, sizeof(response)), sizeof(response));
ASSERT_EQ(usb.sent_frames.size(), 1u);
EXPECT_TRUE(wifi.sent_frames.empty());
// Login/status-style pushes complete a client operation and follow the same
// requester route rather than exposing the result on another transport.
const uint8_t required_push[] = {0x85, 0xBB};
EXPECT_EQ(manager.writeFrame(required_push, sizeof(required_push)),
sizeof(required_push));
ASSERT_EQ(usb.sent_frames.size(), 2u);
EXPECT_TRUE(wifi.sent_frames.empty());
// Passive observations remain visible to every enabled client.
const uint8_t best_effort_push[] = {0x80, 0xCC};
EXPECT_EQ(manager.writeFrame(best_effort_push, sizeof(best_effort_push)),
sizeof(best_effort_push));
ASSERT_EQ(usb.sent_frames.size(), 3u);
ASSERT_EQ(wifi.sent_frames.size(), 1u);
}
TEST(MultiSerialInterface, LocksMultiFrameRepliesToOneRequester) {
MultiSerialInterface manager;
FakeSerialInterface usb;
FakeSerialInterface wifi;
usb.connected = true;
wifi.connected = true;
ASSERT_TRUE(manager.addInterface(InterfaceType::USB, &usb));
ASSERT_TRUE(manager.addInterface(InterfaceType::WiFi, &wifi));
manager.enable();
usb.received_frames.push_back({0x04});
uint8_t command[MAX_FRAME_SIZE] = {};
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
manager.lockReplyRoute();
wifi.received_frames.push_back({0x16});
EXPECT_EQ(manager.checkRecvFrame(command), 0u);
EXPECT_EQ(wifi.received_frames.size(), 1u);
const uint8_t contact[] = {0x03, 0x42};
EXPECT_EQ(manager.writeFrame(contact, sizeof(contact)), sizeof(contact));
ASSERT_EQ(usb.sent_frames.size(), 1u);
EXPECT_TRUE(wifi.sent_frames.empty());
manager.unlockReplyRoute();
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
EXPECT_EQ(command[0], 0x16);
const uint8_t device_info[] = {0x0D, 0x43};
EXPECT_EQ(manager.writeFrame(device_info, sizeof(device_info)),
sizeof(device_info));
ASSERT_EQ(wifi.sent_frames.size(), 1u);
}
TEST(MultiSerialInterface, LosingLockedRequesterCannotFallBackToBroadcast) {
MultiSerialInterface manager;
FakeSerialInterface usb;
FakeSerialInterface bluetooth;
usb.connected = true;
bluetooth.connected = true;
ASSERT_TRUE(manager.addInterface(InterfaceType::USB, &usb));
ASSERT_TRUE(manager.addInterface(InterfaceType::Bluetooth, &bluetooth));
manager.enable();
bluetooth.received_frames.push_back({0x04});
uint8_t command[MAX_FRAME_SIZE] = {};
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
manager.lockReplyRoute();
manager.disableBluetooth();
EXPECT_FALSE(manager.isConnected());
const uint8_t contact[] = {0x03, 0x42};
EXPECT_EQ(manager.writeFrame(contact, sizeof(contact)), 0u);
EXPECT_TRUE(usb.sent_frames.empty());
manager.unlockReplyRoute();
EXPECT_TRUE(manager.isConnected());
}
TEST(MultiSerialInterface, PacesOnlyTheActiveReplyTransport) {
MultiSerialInterface manager;
FakeSerialInterface usb;
FakeSerialInterface wifi;
usb.connected = true;
wifi.connected = true;
wifi.write_busy = true;
ASSERT_TRUE(manager.addInterface(InterfaceType::USB, &usb));
ASSERT_TRUE(manager.addInterface(InterfaceType::WiFi, &wifi));
manager.enable();
usb.received_frames.push_back({0x04});
uint8_t command[MAX_FRAME_SIZE] = {};
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
EXPECT_FALSE(manager.isWriteBusy());
wifi.received_frames.push_back({0x04});
ASSERT_EQ(manager.checkRecvFrame(command), 1u);
EXPECT_TRUE(manager.isWriteBusy());
}
TEST(SerialModeSwitch, RecognizesControlSequenceAcrossReads) {
BufferStream stream;
ArduinoSerialInterface interface;
@@ -230,6 +363,123 @@ TEST(SerialModeSwitch, PassthroughLeavesInputAndSuppressesBinaryOutput) {
EXPECT_EQ(stream.output[0], '>');
}
TEST(SerialFlowControl, KeepsAFrameQueuedUntilUsbHasSpace) {
BufferStream stream;
stream.write_capacity = 0;
ArduinoSerialInterface interface;
interface.begin(stream);
interface.enableFlowControl(true);
interface.enable();
const uint8_t payload[] = {0x05, 0xA5, 0x5A};
EXPECT_EQ(interface.writeFrame(payload, sizeof(payload)), sizeof(payload));
EXPECT_TRUE(stream.output.empty());
EXPECT_TRUE(interface.hasPendingIO());
EXPECT_TRUE(interface.isWriteBusy());
stream.write_capacity = MAX_FRAME_SIZE + 3;
interface.loop();
const std::vector<uint8_t> expected = {'>', 3, 0, 0x05, 0xA5, 0x5A};
EXPECT_EQ(stream.output, expected);
EXPECT_FALSE(interface.hasPendingIO());
}
TEST(SerialFlowControl, FinishesAnUnexpectedShortWriteBeforeNextFrame) {
BufferStream stream;
stream.write_capacity = MAX_FRAME_SIZE + 3;
stream.max_write = 2;
ArduinoSerialInterface interface;
interface.begin(stream);
interface.enableFlowControl(true);
interface.enable();
const uint8_t first[] = {0x05, 0x11, 0x22};
const uint8_t second[] = {0x00};
EXPECT_EQ(interface.writeFrame(first, sizeof(first)), sizeof(first));
ASSERT_EQ(stream.output.size(), 2u);
EXPECT_EQ(interface.writeFrame(second, sizeof(second)), sizeof(second));
stream.max_write = std::numeric_limits<size_t>::max();
interface.loop();
const std::vector<uint8_t> expected = {
'>', 3, 0, 0x05, 0x11, 0x22,
'>', 1, 0, 0x00};
EXPECT_EQ(stream.output, expected);
EXPECT_FALSE(interface.hasPendingIO());
}
TEST(SerialFlowControl, DrainsFramesThroughAFifoSmallerThanTheFrame) {
BufferStream stream;
stream.write_capacity = 4;
ArduinoSerialInterface interface;
interface.begin(stream);
interface.enableFlowControl(true);
interface.enable();
const uint8_t payload[] = {0x05, 1, 2, 3, 4, 5};
EXPECT_EQ(interface.writeFrame(payload, sizeof(payload)), sizeof(payload));
ASSERT_EQ(stream.output.size(), 4u);
EXPECT_TRUE(interface.hasPendingIO());
stream.write_capacity = 4;
interface.loop();
ASSERT_EQ(stream.output.size(), 8u);
EXPECT_TRUE(interface.hasPendingIO());
stream.write_capacity = 4;
interface.loop();
const std::vector<uint8_t> expected = {'>', 6, 0, 0x05, 1, 2, 3, 4, 5};
EXPECT_EQ(stream.output, expected);
EXPECT_FALSE(interface.hasPendingIO());
}
TEST(SerialFlowControl, PartialInboundFrameKeepsTransportBusy) {
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream);
interface.enable();
uint8_t frame[MAX_FRAME_SIZE] = {};
const uint8_t partial[] = {'<', 2};
stream.push(partial, sizeof(partial));
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_TRUE(interface.isReadBusy());
EXPECT_TRUE(interface.hasPendingIO());
const uint8_t remainder[] = {0, 0xA5, 0x5A};
stream.push(remainder, sizeof(remainder));
EXPECT_EQ(interface.checkRecvFrame(frame), 2u);
EXPECT_FALSE(interface.isReadBusy());
EXPECT_EQ(frame[0], 0xA5);
EXPECT_EQ(frame[1], 0x5A);
}
TEST(SerialFlowControl, AbandonsATruncatedInboundFrameAfterTimeout) {
resetArduinoMock();
BufferStream stream;
ArduinoSerialInterface interface;
interface.begin(stream);
interface.enable();
uint8_t frame[MAX_FRAME_SIZE] = {};
const uint8_t partial[] = {'<', 2, 0, 0xA5};
stream.push(partial, sizeof(partial));
EXPECT_EQ(interface.checkRecvFrame(frame), 0u);
EXPECT_TRUE(interface.isReadBusy());
delay(999);
interface.loop();
EXPECT_TRUE(interface.isReadBusy());
delay(1);
interface.loop();
EXPECT_FALSE(interface.isReadBusy());
const uint8_t complete[] = {'<', 1, 0, 0x5A};
stream.push(complete, sizeof(complete));
EXPECT_EQ(interface.checkRecvFrame(frame), 1u);
EXPECT_EQ(frame[0], 0x5A);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
+107 -18
View File
@@ -141,6 +141,7 @@ class TargetInfo:
platform: str
nrf_sd: bool
hw_id: str | None
bootloader_version: str | None
bootloader_abi: int | None
bootloader_codecs: int | None
status: str
@@ -637,21 +638,25 @@ def compatible_mota(info: MotaInfo, target: TargetInfo) -> tuple[bool, str]:
if info.codec_id != expected_codec:
return False, f"codec {info.codec_id}, need {expected_codec} for {target.platform}"
if target.platform == "nrf52":
version = target.bootloader_version or "unknown version"
if (
target.bootloader_abi is not None
and target.bootloader_abi < MOTA_FORMAT_VERSION
):
return False, (
f"bootloader ABI {target.bootloader_abi} cannot apply mOTA format "
f"{MOTA_FORMAT_VERSION}"
f"bootloader {version} ABI {target.bootloader_abi} cannot "
f"apply mOTA format {MOTA_FORMAT_VERSION}; install an "
"exact-board OTAFIX bootloader with current mOTA support"
)
if (
target.bootloader_codecs is not None
and not target.bootloader_codecs & (1 << info.codec_id)
):
return False, (
f"bootloader codec mask 0x{target.bootloader_codecs:X} does not "
f"support codec {info.codec_id}"
f"bootloader {version} codec mask "
f"0x{target.bootloader_codecs:X} does not support codec "
f"{info.codec_id}; install an exact-board OTAFIX bootloader "
"with the required codec support"
)
return True, ""
@@ -1008,6 +1013,12 @@ def reply_matches_command(command_text: str, reply: str) -> bool:
)
if command == "ota stats":
return text.startswith("OTA | fw ") or is_unknown or needs_temp
if command == "get bootloader.ver":
return (
bool(re.fullmatch(r">\s*\S+", text))
or is_unknown
or (is_error and "unsupported" in lowered)
)
if command == "ota ls":
return (
text.startswith("Updates ")
@@ -1018,14 +1029,21 @@ def reply_matches_command(command_text: str, reply: str) -> bool:
)
if command.startswith("ota pull "):
return (
lowered.startswith(("ok pulling ", "usage: ota pull", "choose a destination"))
lowered.startswith((
"ok pulling ", "ok resuming ", "usage: ota pull",
"choose a destination",
))
or is_unknown
or needs_temp
or (
is_error
and any(
word in lowered
for word in ("update", "destination", "pull", "fetch", "busy", "folder")
for word in (
"update", "destination", "pull", "fetch", "busy",
"folder", "slot", "codec", "bootloader", "apply",
"rescue", "endf", "storage",
)
)
)
)
@@ -1060,6 +1078,25 @@ def extract_reply_version(reply: str) -> int | None:
return parse_version(match.group(1)) if match else None
def parse_bootloader_version_reply(
reply: str,
) -> tuple[str | None, str | None]:
"""Return platform/version, or no platform for legacy firmware."""
text = reply.strip()
version_match = re.fullmatch(r">\s*(\S+)", text)
if version_match:
version = version_match.group(1)
return "nrf52", None if version.lower() == "unknown" else version
if re.fullmatch(r"err(?:or)?:\s*unsupported", text, re.IGNORECASE):
return "esp32", None
if text.lower().startswith(("unknown command", "command not found")):
return None, None
raise OtaError(
"could not interpret destination `get bootloader.ver` reply: "
f"{reply}"
)
class PersistentMeshcliSession:
"""Run meshcli scripts over one long-lived Companion connection."""
@@ -1522,33 +1559,59 @@ def query_target(
if not match:
raise OtaError("could not read destination target ID from `ota status`")
target_id = int(match.group(1), 16)
bootloader_reply = controller.remote_command(
args.target, "get bootloader.ver"
)
reported_platform, bootloader_version = parse_bootloader_version_reply(
bootloader_reply
)
self_status = controller.remote_command(args.target, "ota self")
hash_match = re.search(r"base_hash=([0-9A-Fa-f]{16})", self_status)
if not hash_match:
raise OtaError("could not read destination base hash from `ota self`")
base_hash = bytes.fromhex(hash_match.group(1))
combined = f"{status} {self_status}"
platform = "nrf52" if ("bootloader:" in combined or "| bl:" in combined) else "esp32"
nrf_sd = "SD apply OK" in combined or bool(re.search(r"\bbl:SD\b", combined))
if reported_platform is None:
platform = (
"nrf52"
if "bootloader:" in combined or "| bl:" in combined
else "esp32"
)
print(
"[warn] destination firmware does not implement "
"`get bootloader.ver`; using legacy `ota self` platform markers"
)
else:
platform = reported_platform
nrf_sd = "SD apply OK" in combined or bool(
re.search(r"\bbl:SD\b", combined)
)
if platform == "nrf52" and (
"NO mota-apply" in combined
or "NO SD mota-apply" in combined
or bool(re.search(r"\bbl:NONE\b", combined))
):
version = bootloader_version or "unknown version"
raise OtaError(
"destination nRF52 bootloader cannot apply this mOTA; install the exact-board OTAFIX bootloader first"
f"destination nRF52 bootloader {version} cannot apply this mOTA; "
"install the exact-board OTAFIX bootloader first"
)
hw_match = re.search(r"\bhw=([^ |]+)", status)
hw_id = hw_match.group(1) if hw_match and hw_match.group(1) != "?" else None
bootloader_abi = None
bootloader_codecs = None
caps_match = re.search(r"\babi=(\d+)\s+codecs=0x([0-9A-Fa-f]+)", combined)
caps_match = re.search(
r"\babi=(\d+)\s+codecs=0x([0-9A-Fa-f]+)", combined
)
if caps_match:
bootloader_abi = int(caps_match.group(1))
bootloader_codecs = int(caps_match.group(2), 16)
elif platform == "nrf52":
version = bootloader_version or "unknown version"
raise OtaError(
"could not read the nRF52 bootloader ABI and codec mask from `ota self`"
f"destination nRF52 bootloader {version} does not report a "
"compatible mOTA ABI and codec mask in `ota self`; install the "
"exact-board OTAFIX bootloader first"
)
current_version = None
current_version_source = None
@@ -1572,9 +1635,19 @@ def query_target(
current_version = format_version(version_value)
current_version_source = "ver"
return TargetInfo(
args.target, target_id, base_hash, platform, nrf_sd, hw_id,
bootloader_abi, bootloader_codecs, status, self_status, current_version,
current_version_source,
name=args.target,
target_id=target_id,
base_hash=base_hash,
platform=platform,
nrf_sd=nrf_sd,
hw_id=hw_id,
bootloader_version=bootloader_version,
bootloader_abi=bootloader_abi,
bootloader_codecs=bootloader_codecs,
status=status,
self_status=self_status,
current_version=current_version,
current_version_source=current_version_source,
)
@@ -1873,6 +1946,14 @@ def confirm_update(
print("\nValidated update plan:")
print(f" destination : {target.name} ({target.target_id:08X}, {target.platform})")
print(f" running base: {target.base_hash.hex().upper()}")
if target.platform == "nrf52":
version = target.bootloader_version or "unknown"
print(
f" bootloader : {version} (ABI {target.bootloader_abi}, "
f"codecs 0x{target.bootloader_codecs:X}; ready)"
)
else:
print(" bootloader : not required")
print(f" update : {package.version} {package.kind} hw={package.hw_id or '?'}")
print(f" mOTA id : {package.manifest_id}")
print(f" TempRadio : {args.temp_radio}")
@@ -2074,7 +2155,7 @@ def find_and_start_pull(
)
raise exc
if pull_reply.startswith("OK pulling"):
if pull_reply.startswith(("OK pulling", "OK resuming")):
reply_id = download_manifest_id(pull_reply.replace("mid=", "id="))
if reply_id != package.manifest_id:
raise OtaError(
@@ -2926,9 +3007,17 @@ def offline_target(args: argparse.Namespace) -> TargetInfo:
if args.target_base_hash else b"\0" * 8
)
return TargetInfo(
args.target, int(target_id_text, 16), base_hash,
args.platform, args.nrf_sd, args.target_hw, None, None,
"offline", "offline",
name=args.target,
target_id=int(target_id_text, 16),
base_hash=base_hash,
platform=args.platform,
nrf_sd=args.nrf_sd,
hw_id=args.target_hw,
bootloader_version=None,
bootloader_abi=None,
bootloader_codecs=None,
status="offline",
self_status="offline",
)
+4
View File
@@ -1216,6 +1216,10 @@ def confirm_chain(
print(f" public key : {full_key}")
print(f" target : {target.target_id:08X} hw={target.hw_id}")
print(f" running : {target.current_version} {target.base_hash.hex().upper()}")
print(
f" bootloader : {target.bootloader_version or 'unknown'} "
f"(ABI {target.bootloader_abi}, codecs 0x{target.bootloader_codecs:X}; ready)"
)
if first_index == len(steps):
print(" action : endpoint already installed")
else:
+124 -3
View File
@@ -85,12 +85,22 @@ def target(
base_hash: bytes = b"\0" * 8,
nrf_sd: bool = False,
boot_codecs: int | None = None,
boot_version: str | None = None,
current_version: str | None = None,
) -> ota.TargetInfo:
return ota.TargetInfo(
"remote", TARGET, base_hash, platform, nrf_sd, "TestBoard",
2 if platform == "nrf52" else None, boot_codecs,
"status", "self", current_version,
name="remote",
target_id=TARGET,
base_hash=base_hash,
platform=platform,
nrf_sd=nrf_sd,
hw_id="TestBoard",
bootloader_version=boot_version,
bootloader_abi=2 if platform == "nrf52" else None,
bootloader_codecs=boot_codecs,
status="status",
self_status="self",
current_version=current_version,
)
@@ -360,6 +370,7 @@ class CompatibilityTests(unittest.TestCase):
good, reason = ota.compatible_mota(delta, nrf)
self.assertFalse(good)
self.assertIn("codec mask", reason)
self.assertIn("exact-board OTAFIX", reason)
def test_sd_nrf52_accepts_full_when_bootloader_does(self) -> None:
full = ota.parse_mota(mota_blob(self.new_image))
@@ -483,6 +494,18 @@ class DownloadSessionTests(unittest.TestCase):
["ota status", "ota cancel", "ota ls", f"ota pull {self.package.manifest_id} flash"],
)
def test_pull_accepts_store_resume_confirmation(self) -> None:
controller = self.Controller([
"OTA | no download",
"Updates 1/1",
f"OK resuming mid={self.package.manifest_id} -> flash (primary traffic)",
])
ota.find_and_start_pull(controller, self.args(), self.package)
self.assertEqual(
controller.commands,
["ota status", "ota ls", f"ota pull {self.package.manifest_id} flash"],
)
def test_monitor_rejects_ready_session_for_another_package(self) -> None:
controller = self.Controller([
"OTA | download: ready to install 9/9 id=DEADBEEF 2s"
@@ -579,6 +602,7 @@ class ReliabilityTests(unittest.TestCase):
def __init__(self) -> None:
self.replies = iter([
"OTA | no download | target:1234ABCD hw=TestBoard",
"Error: unsupported",
"self body=1 image=2 base_hash=0011223344556677",
"Unknown command",
"v1.16.9 (Build: test)",
@@ -590,9 +614,106 @@ class ReliabilityTests(unittest.TestCase):
result = ota.query_target(
Controller(), argparse.Namespace(target="remote")
)
self.assertEqual(result.platform, "esp32")
self.assertIsNone(result.bootloader_version)
self.assertEqual(result.current_version, "v1.16.9")
self.assertEqual(result.current_version_source, "ver")
def test_bootloader_reply_match_rejects_unrelated_messages(self) -> None:
self.assertTrue(
ota.reply_matches_command(
"get bootloader.ver", "> 0.9.2-OTAFIX2.4"
)
)
self.assertTrue(
ota.reply_matches_command(
"get bootloader.ver", "Error: unsupported"
)
)
self.assertFalse(
ota.reply_matches_command(
"get bootloader.ver", "OTA | no download | target:1234ABCD"
)
)
self.assertTrue(
ota.reply_matches_command(
"ota pull 1234ABCD flash",
"OK resuming mid=1234ABCD -> flash (primary traffic)",
)
)
def test_target_uses_bootloader_version_to_identify_nrf52(self) -> None:
class Controller:
def __init__(self) -> None:
self.commands: list[str] = []
self.replies = iter([
"OTA | no download | target:1234ABCD hw=RAK_3401",
"> 0.9.2-OTAFIX2.4",
"self body=1 image=2 base_hash=0011223344556677 | "
"bootloader: apply OK (abi=2 codecs=0x4)",
"OTA | fw v1.17.0 id=00112233",
])
def remote_command(
self, _target: str, command: str, **_kwargs: object
) -> str:
self.commands.append(command)
return next(self.replies)
controller = Controller()
result = ota.query_target(
controller, argparse.Namespace(target="remote")
)
self.assertEqual(result.platform, "nrf52")
self.assertEqual(result.bootloader_version, "0.9.2-OTAFIX2.4")
self.assertEqual(result.bootloader_abi, 2)
self.assertEqual(result.bootloader_codecs, 0x4)
self.assertEqual(
controller.commands,
["ota status", "get bootloader.ver", "ota self", "ota stats"],
)
def test_stock_nrf52_bootloader_reports_required_action(self) -> None:
class Controller:
def __init__(self) -> None:
self.replies = iter([
"OTA | no download | target:1234ABCD hw=RAK_3401",
"> 0.9.2",
"self body=1 image=2 base_hash=0011223344556677 | "
"bootloader: NO mota-apply support (delta install will refuse)",
])
def remote_command(self, *_args: object, **_kwargs: object) -> str:
return next(self.replies)
with self.assertRaisesRegex(
ota.OtaError, "bootloader 0.9.2.*exact-board OTAFIX"
):
ota.query_target(Controller(), argparse.Namespace(target="remote"))
def test_legacy_firmware_falls_back_to_ota_self_platform_marker(self) -> None:
class Controller:
def __init__(self) -> None:
self.replies = iter([
"OTA | no download | target:1234ABCD hw=RAK_3401",
"Unknown command",
"self body=1 image=2 base_hash=0011223344556677 | "
"bootloader: apply OK (abi=2 codecs=0x4)",
"OTA | fw v1.17.0 id=00112233",
])
def remote_command(self, *_args: object, **_kwargs: object) -> str:
return next(self.replies)
output = io.StringIO()
with contextlib.redirect_stdout(output):
result = ota.query_target(
Controller(), argparse.Namespace(target="remote")
)
self.assertEqual(result.platform, "nrf52")
self.assertIsNone(result.bootloader_version)
self.assertIn("legacy `ota self` platform markers", output.getvalue())
def test_unattended_prompt_waits_ten_seconds_and_continues(self) -> None:
output = io.StringIO()
with (
+1 -1
View File
@@ -387,7 +387,7 @@ body.tab-cli{padding-bottom:0}
<span class="tgl"><input type="checkbox" data-k="radio.rxgain"><u></u></span></div>
<div class="sw" data-cap="2048"><span><b>FEM RX boost</b><i>External front-end-module LNA</i></span>
<span class="tgl"><input type="checkbox" data-k="radio.fem.rxgain"><u></u></span></div>
<div class="sw" data-cap="8192"><span><b>Device power saving</b><i>Reduce CPU and GPS idle power; USB, BLE, and WiFi stay available</i></span>
<div class="sw" data-cap="8192"><span><b>Device power saving</b><i>Reduce device and GPS idle power</i></span>
<span class="tgl"><input type="checkbox" data-k="powersaving"><u></u></span></div>
<div data-cap="4096" id="rxps-wrap">
<div class="sw"><span><b>RX power saving</b><i>Duty-cycle the LoRa receiver; off keeps continuous receive</i></span>