mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-01 21:38:18 +00:00
Fix Cascade power saving, USB Companion, and mOTA flows
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>` |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PsychicMqttClient
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://opensource.org/license/MIT)
|
||||
[](https://github.com/theelims/PsychicMqttClient/actions/workflows/ci.yml)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1617
-1618
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user