Add two-profile LoRa operation and radio CLI regression coverage

Implement radio2, tempradio2, radioat2 and tempradioat2 across Mesh roles,
with RX-only/RX+TX operation, optional preambles and persistent cross-TX
policy. Keep temporary OTA traffic on its profile by default and maintain
independent retry ownership and backoff for each profile.

Use slow-first receive scanning with 4.8-symbol visits and automatic
preambles rounded up in steps of eight. Preserve pending RX, restore power
saving on exit, and discard work bound to changed or expired profiles.

Restore the infrastructure path.hash.mode setter and report unsupported
extra.sf settings consistently. Add CLI, scheduling, scan and retry tests,
setup documentation, and the V4/XIAO hardware validation results.

Validation: 1,422 native tests, eight KISS tests, 63 final focused profile
tests, 18 checks from the staged source, sanitizer-enabled OTA transfers,
and builds for V4 Mesh roles, Full XIAO Companion and nRF52 T1000-E.
Hardware checks cover reception, cross-TX policy, expiry, reboot and OTA
discovery while receiving main-channel adverts.
This commit is contained in:
mikecarper committed 2026-09-13 08:25:00 -07:00
1 parent b87e86e10f
commit 3b6c894ee2
46 files changed
+5187 -143

No files matched your search

+8
View File
@@ -75,6 +75,8 @@ jobs:
- name: Verify temporary-radio timing and exact reply delivery
run: |
python3 -B test/test_radio_profile_cli.py
python3 -B test/test_radio_profile_scan.py
python3 -B test/test_repeater_radio_timing_integration.py
python3 -B test/test_temp_radio_reply_delivery_contract.py
@@ -106,6 +108,12 @@ jobs:
- name: Verify nRF52 UF2-reset CLI coverage
run: python3 -B test/test_nrf52_uf2reset_cli.py
- name: Verify CLI setting dispatch and get-set coverage
run: |
python3 -B test/test_path_hash_cli.py
python3 -B test/test_cli_settings_contract.py
python3 -B test/test_local_cli_access.py
- name: Verify firmware memory budgets and nRF52 Bluetooth startup
run: |
python3 -B test/test_firmware_ram.py
+8
View File
@@ -1,5 +1,8 @@
# CLI Commands
See [two LoRa profiles](radio_profiles.md) for `radio2`, `tempradio2`, scheduling,
RX-only mode, cross-transmission policy, and optional radio preambles.
For copy/paste on/off recipes and the differences from Full Companion, see
[feature switches by role](role_feature_switches.md). The
[USB web console](https://flasher.meshcore.io/console) opens the default ASCII
@@ -2026,6 +2029,11 @@ The pin number is the Arduino pin number used by that target (the normal GPIO nu
---
#### View or change this node's advert path hash size
**1.17.1.5 infrastructure bug:** affected Repeater, Room Server, and Sensor
images accept `get path.hash.mode` but report `unknown config` for the setter.
The command was not renamed. The source correction restores the setter;
installed images need an updated build. See the [setting-dispatch audit](cli_settings_audit.md).
**Usage:**
- `get path.hash.mode`
- `set path.hash.mode <value>`
+74
View File
@@ -0,0 +1,74 @@
# CLI setting dispatch audit
The 1.17.1.5 source retained `get path.hash.mode` in `CommonCLI` but lost its
setter when radio preferences moved into `CommonRadioPrefs`. Infrastructure
roles call `CommonCLI` directly, so the shared parser's setter was unreachable.
The correction restores `set path.hash.mode 0|1|2` there, validates the value,
and saves accepted changes. Missing/invalid values report a value error and
leave the setting unchanged.
The other radio setters removed by the original refactor had already been
restored. A feature-guarded check found a second mismatch: `get extra.sf` exposed
stored data on non-LR2021 radios while its setter was compiled out. Both
commands now return `Error: extra.sf requires an LR2021 radio` there. Supported
LR2021 set/get behavior is preserved. These are source corrections; existing
release binaries need rebuilding/updating to receive them.
## Coverage
| Surface | Check |
| --- | --- |
| Common infrastructure CLI | 72 literal setter keys and 84 query keys; every query has a setter or an explicitly reviewed read-only/alternate-command exception |
| MQTT/observer CLI | 33 literal setter keys and 39 query keys, including Wi-Fi, timezone, alerts, display and watchdog settings |
| MQTT slots | All nine writable slot subkeys have query/set coverage; `diag` is intentionally read-only |
| Repeater, Room Server, Sensor | Shared CLI delegation and role-specific query/set coverage |
| Companion | Shared-radio allowlist and parser agreement, persistence wiring, terminal-specific setters, and role query exceptions |
| Radio settings moved by the refactor | All 13 shared setters remain present in infrastructure dispatch |
| Literal command comparisons | Checks key lengths so a setter cannot accidentally compare its value against the key's terminating NUL |
| Feature guards | Preprocesses common and observer handlers for minimal, nRF52 GPS/SD, ESP32 WebConfig, ESP32 MQTT, RS232/GPS, ESP-NOW, and LR2021 profiles before comparing query/set coverage |
Read-only exceptions include runtime connection status, diagnostics, firmware
role, bootloader identity and reset information. Alternate-command exceptions
include the password command and `set battery.alert on <region>`. These
exceptions are explicit in the test, rather than silently allowing all missing
setters. Dynamic/delegated command families and hardware-specific features
continue to use their dedicated tests; the inventory is not a claim that every
build has every feature enabled.
## Executed behavior tests
The native C++ fixture compiles the production get/set dispatch branches and
the real setting bodies for 14 radio-related settings: `radio`, `freq`, `af`,
`dutycycle`, `int.thresh`, `cad`, `radio.rxgain`, `tx`, `rxdelay`,
`agc.reset.interval`, `path.hash.mode`, `multi.acks`, `txdelay`, and
`direct.txdelay`. It runs local and authenticated on-air caller timestamps,
checks round-trip values and save calls, preserves AGC interval rounding, and
checks that rejected hardware applies do not save or change preferences.
Hardware callbacks are mocked; these are not live-radio or flash-durability
tests. The fixture also runs with and without `USE_LR2021`, checking supported
`extra.sf` set/query/clear behavior, invalid values, and the unsupported-radio
error.
The focused hash-mode test checks all valid modes, missing values, negative and
out-of-range values, overflow, malformed numbers, whitespace, and similarly
named unknown keys. Existing transport tests exercise Companion local/framed
and on-air dispatch, local-only secret reads, and browser/stream routing under
AddressSanitizer and UndefinedBehaviorSanitizer on Linux.
Run the CI checks locally with a host C++ compiler (Linux is required for the
existing sanitizer-enabled transport suite):
```text
python3 -B test/test_path_hash_cli.py
python3 -B test/test_cli_settings_contract.py
python3 -B test/test_local_cli_access.py
```
All 14 tests passed on the Linux VM. The workflow runs these commands on future
pushes and pull requests. The new inventory and native radio tests also passed
with the Windows host compiler.
`pio run -e heltec_v4_repeater` passed after both corrections, including the
firmware's flash-size and runtime-RAM checks. The initial audit used host tests
and build checks; subsequent hardware checks are recorded in the
[dual-profile validation](radio_profiles_validation.md).
+196
View File
@@ -0,0 +1,196 @@
# Two LoRa profiles on one radio
`radio2` lets one LoRa transceiver alternate between two frequencies or modulation
settings. It is useful for listening to two networks, or keeping your usual
channel available during a LoRa firmware update.
There are **at most two active profiles**. `tempradio` temporarily replaces
`radio`; `tempradio2` temporarily replaces `radio2`. These are time-shared receive
windows, not simultaneous receivers. Receiving or transmitting a packet on one
profile makes the other unavailable until that packet finishes.
The second-profile commands are shared by repeater, Companion, room server,
sensor and standalone terminal-chat firmware with a LoRa radio. ESP-NOW-only
devices reject LoRa profile commands. KISS is a raw host-controlled modem and
does not expose these Mesh CLI commands or Mesh retry queues.
## Quick setup
Keep the primary channel configured with `radio`, then add the second:
```text
set radio2 910.5,500,8,5,rx
get radio2
get radio2.status
```
The tuple is **frequency in MHz, bandwidth in kHz, spreading factor, coding-rate
denominator, mode, optional preamble in symbols**. `5` means coding rate 4/5.
| Mode | Receive on profile 2 | Transmit on profile 2 |
| --- | --- | --- |
| `rx` | Yes | No |
| `rxtx` (also `rx&tx`) | Yes | Yes |
| `off` | No | No |
For transmission on both permanent profiles:
```text
set radio2 910.5,500,8,5,rxtx
```
The same encoded message is queued separately for each allowed transmit profile.
Each has its own direct/flood retry slots and channel-busy backoff. A forwarding
echo on one profile cannot cancel the other profile's retry. Packet storage and
the physical transceiver's airtime budget are shared; exhausted queues can still
drop packets.
## Keep the main channel during an update
```text
set tempradio2 910.5,500,8,5,rxtx,120
get tempradio2
```
This enables the second profile for 120 minutes (2 hours). Locally generated
LoRa OTA packets use the temporary second profile. Ordinary local traffic stays
on the primary profile by default. Use an OTA-capable build and the usual OTA
setup on every participating node; a temporary profile does not add OTA support
to firmware built without it.
New settings take effect after a short reply allowance. Temporary periods and
schedules live in RAM and disappear on reboot. Timer expiry also uses monotonic
time, so setting the clock backwards cannot extend a temporary session.
## Choose whether traffic crosses between profiles
```text
get radio2.cross
set radio2.cross auto
set radio2.cross on
set radio2.cross off
```
| Active primary | Active secondary | `auto` (default) | `on` | `off` |
| --- | --- | --- | --- | --- |
| `radio` | `radio2` | Cross | Cross | Isolated |
| `tempradio` | `tempradio2` | Cross | Cross | Isolated |
| `radio` | `tempradio2` | Isolated | Cross | Isolated |
| `tempradio` | `radio2` | Isolated | Cross | Isolated |
Crossing always respects `rx`: nothing transmits on an RX-only second profile.
With crossing allowed, a packet received there may still be forwarded on the
primary profile. Existing routing, forwarding and packet-filter settings apply.
An RX-only `tempradio2` also blocks locally generated OTA transmissions under
`auto` or `off`; they do not fall back to the normal primary channel. Use `rxtx`
for an update session that needs to exchange requests and data.
## Preamble
Append the preamble to the end of a command:
```text
set radio2 910.5,500,8,5,rxtx,88
set tempradio2 910.5,500,7,5,rxtx,120,128
set radio 909.5,62.5,7,5,32
set tempradio 909.5,62.5,7,5,120,32
```
Existing primary `radio`, `tempradio`, `radioat` and `tempradioat` commands accept
the optional trailing preamble where those primary commands are supported by
the role. Getters include the preamble. Omit it, or use `auto`/`0`, to calculate
it automatically. Explicit values are used as entered, subject to the radio's
limits and safe airtime arithmetic.
Automatic dual-profile preambles round **up to a multiple of eight**, with a
32-symbol minimum. Each idle scan starts with **4.8 symbols on the slower
profile**, then spends the remaining safe time on the faster profile. Slower
means a longer LoRa symbol (`2^SF / bandwidth`), regardless of profile number.
The slower preamble sets the return deadline. The calculation allows two slow
visits within that preamble, reserving 6 ms per switch and 4 ms of main-loop
margin. Fast-channel transmit preambles include sixteen acquisition symbols.
For a primary SF7 / 62.5 kHz profile:
| Secondary profile | Automatic secondary preamble | Tested explicit preamble | Primary preamble |
| --- | ---: | ---: | ---: |
| SF9 / 500 kHz | 48 | 32 | 32 |
| SF8 / 500 kHz | 88 | 64 | 32 |
| SF7 / 500 kHz | 120 | 80 | 32 |
With preamble 32 on that slower profile, its visit is 9.831 ms and the faster
visit is 6.937 ms, plus the actual switching time. Increasing the slow preamble
also increases the time available on the fast channel. If the symbols are equal
in length, the primary profile goes first. Detected packets hold the current
channel until reception finishes.
The SF8 / 500 kHz value also retains the measured 88-symbol floor from the
production V4/XIAO tests, where 72 and 80 each missed a packet.
The shorter explicit values come from the
[production validation](radio_profiles_validation.md): the final fast-channel
attempts each received 50/50 packets at 4.8 slow symbols. Automatic values retain
the additional switching and loop-jitter margin.
The automatic fast preamble covers the blind interval during the slow visit,
both switches and the acquisition margin. These settings do not guarantee
reception during overlapping packets or long pauses in the firmware loop.
**Transmitters on each channel also need a long enough preamble.** Changing the
receiver's setting does not lengthen packets sent by other nodes.
## Schedule the second profile
Use UTC Unix timestamps (seconds):
```text
set radioat2 910.5,500,8,5,rxtx,START[,PREAMBLE]
set tempradioat2 910.5,500,8,5,rxtx,START,END[,PREAMBLE]
get radioat2
get radioat2 1
get tempradioat2
get tempradioat2 1
del radioat2 1
del tempradioat2 all
```
Replace `START`, `END` and the optional `PREAMBLE` with numbers; omit the square
brackets. Each schedule family has four slots. Times must be in the future and
within 24 days. Overlapping temporary second-profile sessions are rejected.
`radioat2` changes the saved second profile when it starts; `tempradioat2`
restores the saved profile at its end. Pending schedules do not survive reboot.
## Return to one profile
```text
set radio2 off
```
This saves the disabled state and cancels all second-profile temporary activity
and schedules.
```text
set tempradio2 off
```
This ends temporary second-profile activity, cancels its temporary schedules and
restores saved `radio2`. If the saved second profile is off, this returns to one
profile. Packets/retries bound to an expired or changed session are discarded.
## Power saving and diagnostics
Dual mode suspends RX power saving and MCU idle sleep so receive visits can run
promptly. The saved RX power-saving setting returns when profile 2 is off.
The scan uses **normal receive**, following the
[CAD/RX measurements](cad_scan_validation.md): 4.8 symbols on the slower
channel, followed by the remaining fast-channel visit. It does not use CAD for scanning.
The separate CAD check before an initial transmission still follows `cad`.
Single-channel noise-floor calibration and RSSI interference comparison are
suspended while scanning. Retries use the radio's preamble/header activity
indicators. LR2021 extra-SF side detectors are suspended and restored as well.
`get radio2.status` reports the active second-profile mode, MeshCore RX/TX counts
for each profile, switch count, switch failures, longest measured switch and
both effective preambles. A long switch time or rising error count indicates
that the chosen settings need investigation on that board.
`get radio2.scan` shows which profile goes first, the two idle receive-window
lengths in microseconds, and both effective preambles. It reports `off` in
single-profile mode.
+204
View File
@@ -0,0 +1,204 @@
# Dual-profile receive validation
These measurements exercise the production two-profile implementation on one
SX1262 transceiver, using a Heltec V4 repeater as receiver and a XIAO ESP32-S3
with a Wio LoRa module as transmitter. They follow the dedicated
[mixed-bandwidth scan experiment](mixed_scan_validation.md).
Counts, diagnostic replies and firmware/source hashes are in
[radio_profiles_results.json](../tools/hil/radio_profiles_results.json).
The primary profile is 909.5 MHz, SF7, 62.5 kHz, CR 4/5, preamble 32.
The secondary is 910.5 MHz, 500 kHz, CR 4/5, with SF9, SF8 or SF7.
Scanning uses normal RX, starts with the slower profile, and reserves two slow
visits per slow preamble. Transmitters must use the corresponding preamble too.
## Method and limits
The transmitter sends MeshCore adverts at varied intervals. The receiver's
per-profile MeshCore RX counters are compared with the transmitter's completed
TX counters before and after each run. Initial OTA announcements can add packets
beyond the manually requested adverts; the table includes those transmissions.
These are aggregate counters, not sequence-matched payload captures.
Runs use one transmitting profile at a time and a strong signal. They do not
measure simultaneous traffic, weak-signal reception, interference or months of
uptime. A single transceiver remains unavailable to the other profile while
receiving or transmitting a packet. A clean finite run is evidence for a timing
choice, not a guarantee that it cannot drop packets.
## Five-symbol baseline
Slow visits were 10.240 ms and fast visits 6.528 ms, plus actual switching time.
The longest observed profile switch was 4.416 ms; no switch failures were reported.
| Fast profile | Fast preamble | Fast RX / TX | Slow RX / TX |
| --- | ---: | ---: | ---: |
| SF9 / 500 | 48 | 54 / 54 | 30 / 30 |
| SF8 / 500 | 72 | 49 / 50 | — |
| SF8 / 500 | 80 | 49 / 50 | — |
| SF8 / 500 | 88 | 50 / 50 | 30 / 30 |
| SF7 / 500 | 120 | 50 / 50 | 30 / 30 |
The SF8 automatic preamble retains a measured floor of 88 symbols. Automatically
selected preambles always round up in steps of eight.
## Fractional-symbol tuning
The initial sweep started at 4.2 slow symbols, with an increase of 0.1 planned
for any slow-channel loss. That first sweep passed all three fast settings.
Each completed run used 50 requested adverts per profile. The SF8 fast run was
interrupted to check SF9 preamble 32, then restarted; only its completed run is
included below.
| Slow symbols | Fast profile | Fast preamble | Fast RX / TX | Slow RX / TX |
| ---: | --- | ---: | ---: | ---: |
| 4.2 | SF9 / 500 | 48 | 50 / 50 | 50 / 50 |
| 4.2 | SF9 / 500 | 32 | 50 / 50 | 50 / 50 |
| 4.2 | SF8 / 500 | 88 | 50 / 50 | 50 / 50 |
| 4.2 | SF7 / 500 | 120 | 50 / 50 | 50 / 50 |
The four completed pairings received 200/200 packets on the slower channel and
200/200 on the faster channel. No switch failures were reported; the longest
measured switch during the 4.2-symbol sweep was 4.407 ms.
The explicit SF9 preamble-32 check uses the same 8.602 ms slow and 8.166 ms fast
windows as the preamble-48 check. Only the fast transmitter's preamble changes.
The automatic SF9 calculation still reserves a 48-symbol preamble for the
configured switching and loop-jitter budgets.
## Shorter fast preambles
The follow-up starts with preamble 48 on both fast settings. Each miss advances
the next candidate by eight symbols. The receive-window budgets stay at 4.2
slow symbols and the same remaining fast-channel time.
| Fast profile | Preamble | Fast RX / TX |
| --- | ---: | ---: |
| SF8 / 500 | 48 | 49 / 50 |
| SF8 / 500 | 56 | 50 / 50 |
| SF7 / 500 | 48 | 42 / 50 |
| SF7 / 500 | 56 | 41 / 50 |
| SF7 / 500 | 64 | 45 / 50 |
| SF7 / 500 | 72 | 50 / 50 |
The slower channel passed 50/50 paired with SF8/56, but then received **49/50**
paired with SF7/72. Across the completed 4.2-symbol slow-channel runs, that is
299/300. This later miss invalidated the provisional 4.2-symbol choice and
triggered the next headroom test at 4.3 symbols. Fast-preamble baselines carry
forward as 32 for SF9, 56 for SF8 and 72 for SF7, advancing by eight on a miss.
At 4.3 symbols, the SF9/32 pairing received 100/100 slow packets and 50/50 fast
packets. The next SF8/56 pairing received only 42/50 slow packets, so that
candidate also failed. A control run with scanning disabled received 50/50 on
the slow channel. At 4.4, SF8/56 passed 100/100 slow and 50/50 fast, but the
SF7/72 pairing then received 49/50 slow packets.
At 4.5, the SF7 pairing passed 100/100 slow packets. Fast preamble 72 received
47/50, so the next baseline increased to 80 and passed 50/50. The following
SF8/56 slow run received 49/50, triggering 4.6 symbols. Baselines now carry
forward as SF9/32, SF8/56 and SF7/80.
At 4.6, both SF8/56 and SF7/80 passed 100/100 slow packets and 50/50 fast
packets. The following SF9/32 slow run received 49/50, triggering 4.7 symbols.
There were no switch failures; the longest observed switch was 4.414 ms.
At 4.7, the SF9/32 slow run passed its first 50 packets but received 99/100
over the full run. This triggered 4.8 symbols, with the same fast preamble
baselines. No fast-channel run was started for the 4.7 candidate.
## Selected bench baseline: 4.8 slow symbols
The 4.8-symbol sweep passed all three slow-channel checks, totaling 300/300.
Slow visits are 9.831 ms and fast visits 6.937 ms, plus actual switching time.
The longest measured switch was 4.491 ms, with no switch failures.
| Fast profile | Fast preamble | Fast RX / TX | Slow RX / TX |
| --- | ---: | ---: | ---: |
| SF9 / 500 | 32 | 50 / 50 | 100 / 100 |
| SF8 / 500 | 56 | 49 / 50 | 100 / 100 |
| SF8 / 500 | 64 | 50 / 50 | — |
| SF7 / 500 | 80 | 50 / 50 | 100 / 100 |
The SF8 miss advanced its baseline from 56 to 64, exactly eight symbols. Its
slow-channel check preceded that increase; changing the fast preamble leaves
the idle receive-window calculation unchanged. The final fast-channel attempts
received 150/150, using explicit preambles **32 / 64 / 80** for SF9 / SF8 / SF7.
These become the starting values for further tests, increasing by eight on a
fast-channel miss. A slow-channel miss instead advances the listen window by
0.1 symbol.
The production automatic preambles remain **48 / 88 / 120**, retaining the
switching, loop-jitter and acquisition budgets. The shorter explicit values are
bench results under the conditions above, not a guarantee for other boards or
traffic patterns.
## Transmit policy
Hardware checks send three zero-hop adverts from the V4 with the XIAO listening
on profile 2. OTA startup traffic is allowed to settle before taking the counter
snapshots. The completed cases are:
| V4 profiles and policy | TX on primary / secondary | Received by XIAO |
| --- | ---: | ---: |
| Permanent / permanent, `auto` | 3 / 3 | 3 |
| Permanent / permanent, `off` | 3 / 0 | 0 |
| Permanent / temporary, `auto` | 3 / 0 | 0 |
| Permanent / temporary, `on` | 3 / 3 | 3 |
| Secondary RX only, `on` | 3 / 0 | 0 |
| Temporary / temporary, `auto` | 3 / 3 | 3 |
A separate isolation check issued three explicit OTA announce commands with
normal `radio`, RX-only `tempradio2` and `radio2.cross auto`. The completed TX
counters stayed unchanged on both profiles (0 / 0), confirming that locally
generated OTA traffic does not fall back to the normal channel in that mode.
Turning `radio2` off stopped scanning. A one-minute `tempradio2` session also
returned to one profile on expiry. Rebooting with a saved SF8 RX profile and an
active temporary SF9 RX/TX profile cleared the temporary session and restored
the saved SF8 profile, including its automatic 88-symbol preamble.
## OTA discovery while retaining the main channel
Both boards ran normal SF7 / 62.5 kHz on the primary and temporary SF8 / 500 kHz
RX/TX on the secondary, using `radio2.cross auto` and automatic preambles 32/88.
The XIAO discovered the V4's firmware catalogue over profile 2, and both boards
recorded received and transmitted OTA packets there. Ten V4 zero-hop adverts
then arrived on the XIAO's primary profile (10/10), with both temporary second
profiles still active. The XIAO reported no switch failures and a longest switch
of 4.547 ms during this check.
This hardware check covers discovery, bidirectional OTA traffic and concurrent
availability of the main channel between packets. It does not perform a complete
firmware transfer or install. Byte-exact transfer validation is provided by the
shared OTA software tests described below.
## Software coverage
Native tests cover identical-packet fanout, separate retry ownership and coding
rates, channel-busy backoff, generation changes, temporary-session isolation,
deferred replies and stale-packet removal. Tests using the production CLI cover
parsing, persistence failures, schedules, expiry and clock rollover. A physical
radio mock exercises the production scan transitions, pending RX interrupts,
RX power-saving restoration and failed profile switches.
The full native suite passed 1,422 cases after the RX-only OTA isolation fix;
all eight KISS modem tests also passed. Subsequent timing changes through 4.8
symbols passed the 63-case retry/profile suite and production CLI and scan
harnesses. Shared OTA transfer tests passed byte-exact checks with
AddressSanitizer and UndefinedBehaviorSanitizer for ESP32 and nRF52 variants.
Build coverage includes the Heltec V4 repeater, room server, sensor and terminal
chat, the Full XIAO Companion, and the nRF52 T1000-E repeater. The nRF52 result
is a build check; these receive measurements use the ESP32/SX1262 boards above.
## Hardware restored after validation
Both boards were returned to their original B soak images after the checks:
`v1.17.1-soak-v4-B` and `v1.17.1-soak-xiao-B`. The V4 retained 909.5 MHz,
SF7 / 62.5 kHz, CR 4/5 and TX 0; the XIAO retained 910.525 MHz with the same
modulation and TX 22. Both reconnected to Wi-Fi and their custom MQTT service.
The soak logs resumed under `B-post-radio2-soak`. These restored images are the
earlier memory-soak firmware; the new dual-profile code is validated by the
finite tests above, not by a months-long uptime run.
The Pi's soak service and Indicator telemetry were also healthy after restoration.
+55 -9
View File
@@ -1692,6 +1692,7 @@ void MyMesh::begin(bool has_display, bool radio_available) {
initializeContactStorage();
initializeOfflineQueue();
BaseChatMesh::begin();
_radio_profiles.begin(_store->getPrimaryFS(), _radio, getRTCClock());
const bool identity_loaded = _store->loadMainIdentity(self_id);
const bool is_new_install = !identity_loaded
@@ -2024,7 +2025,7 @@ void MyMesh::activateRadio() {
MESH_DEBUG_PRINTLN("Radio recovery completed; mesh transport is active");
}
mesh::RadioParamApplyResult MyMesh::tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) {
mesh::RadioParamApplyResult MyMesh::tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, bool temporary, uint16_t preamble) {
if (!_radio_available) return mesh::RadioParamApplyResult::FAILED;
uint32_t rx_us = _prefs.rx_ps_rx_us;
@@ -2037,7 +2038,8 @@ mesh::RadioParamApplyResult MyMesh::tryApplyRadioParams(float freq, float bw, ui
uint32_t timings[2] = {rx_us, sleep_us};
const uint32_t* applied_timings = _prefs.rx_powersaving_enabled
&& radio_driver.supportsRxPowerSaving() ? timings : NULL;
return radio_driver.trySetParams(freq, bw, sf, cr, applied_timings);
return _radio_profiles.applyPrimary(freq, bw, sf, cr, temporary,
temporary ? preamble : _radio_profiles.primaryPreamble(), applied_timings);
}
bool MyMesh::applySavedRadioParams() {
@@ -3051,12 +3053,20 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
snprintf(reply, reply_size, "TempRadio pending: %.3f,%.2f,%u,%u",
_temp_radio_freq, _temp_radio_bw,
(unsigned)_temp_radio_sf, (unsigned)_temp_radio_cr);
mesh::RadioProfiles preview;
if (_radio->profiles()) preview = *_radio->profiles();
preview.primary.freq = _temp_radio_freq; preview.primary.bw = _temp_radio_bw;
preview.primary.sf = _temp_radio_sf; preview.primary.cr = _temp_radio_cr;
preview.primary.preamble = _temp_radio_preamble;
const size_t used = strlen(reply);
if (used < reply_size) snprintf(reply + used, reply_size - used, ",preamble=%u", preview.preamble(0, rxPowerSavingPreambleForParams(_temp_radio_sf, _temp_radio_bw)));
} else if (isTempRadioActive()) {
uint32_t seconds = (_temp_radio_revert_at - _ms->getMillis()) / 1000UL;
snprintf(reply, reply_size, "TempRadio active: %.3f,%.2f,%u,%u %lus left",
_temp_radio_freq, _temp_radio_bw,
(unsigned)_temp_radio_sf, (unsigned)_temp_radio_cr,
(unsigned long)seconds);
_radio_profiles.appendPreamble(reply, reply_size);
} else {
snprintf(reply, reply_size, "TempRadio inactive");
}
@@ -3064,21 +3074,27 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
}
if (strncmp(command, "tempradio ", 10) == 0) {
char legacy[120];
uint16_t preamble = 0;
if (!mesh::RadioProfileCLI::parseSuffix(command + 10, 5, legacy, sizeof(legacy), preamble)) {
snprintf(reply, reply_size, "Error: invalid preamble"); return true;
}
float freq = 0.0f, bw = 0.0f;
uint8_t sf = 0, cr = 0;
uint32_t timeout_mins = 0;
if (!mesh::cli::parseTemporaryRadioTupleStrict(
command + 10, freq, bw, sf, cr, timeout_mins)
legacy, freq, bw, sf, cr, timeout_mins)
|| !isfinite(freq) || !isfinite(bw)
|| freq < 150.0f || freq > 2500.0f
|| !isFullCompanionBandwidth(bw)
|| sf < 5 || sf > 12 || cr < 5 || cr > 8
|| timeout_mins == 0 || timeout_mins > 10080UL) {
|| timeout_mins == 0 || timeout_mins > 10080UL
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
snprintf(reply, reply_size,
"ERR usage: tempradio freq,bw,sf,cr,minutes (minutes 1-10080)");
return true;
}
scheduleTempRadio(freq, bw, sf, cr, timeout_mins, reply, reply_size);
if (scheduleTempRadio(freq, bw, sf, cr, timeout_mins, reply, reply_size)) _temp_radio_preamble = preamble;
return true;
}
@@ -3136,7 +3152,7 @@ void MyMesh::serviceTempRadio() {
|| hasOutbound() || !retry_ready) return;
mesh::RadioParamApplyResult result = tryApplyRadioParams(
_temp_radio_freq, _temp_radio_bw, _temp_radio_sf, _temp_radio_cr);
_temp_radio_freq, _temp_radio_bw, _temp_radio_sf, _temp_radio_cr, true, _temp_radio_preamble);
if (result == mesh::RadioParamApplyResult::APPLIED) {
_temp_radio_set_at = 0;
_temp_radio_retry_at = 0;
@@ -8185,6 +8201,28 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
while (*command == ' ' || *command == '\t') command++;
}
if (_radio_profiles.handle(command, reply, reply_capacity)) return true;
if (!strncmp(command, "set tempradio ", 14)) command += 4;
if (!strcmp(command, "get tempradio")) command += 4;
char profile_command[140];
const uint16_t previous_primary_preamble = _radio_profiles.primaryPreamble();
if (!strncmp(command, "set radio ", 10)) {
char legacy[120];
uint16_t preamble = 0;
float f, bw; uint8_t sf, cr;
if (!mesh::RadioProfileCLI::parseSuffix(command + 10, 4, legacy, sizeof(legacy), preamble)
|| !mesh::cli::parseRadioTupleStrict(legacy, f, bw, sf, cr)) {
snprintf(reply, reply_capacity, "Error: invalid radio params"); return true;
}
if (!_radio_profiles.acceptsPrimary(f, bw, sf, cr, preamble)) {
snprintf(reply, reply_capacity, "Error: radio params unsupported"); return true;
}
if (!_radio_profiles.savePrimaryPreamble(preamble)) {
snprintf(reply, reply_capacity, "Error: preamble could not be saved"); return true;
}
snprintf(profile_command, sizeof(profile_command), "set radio %s", legacy);
command = profile_command;
}
if (sender_timestamp == 0 && handleDirectCommand(command, reply, reply_capacity)) return true;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
@@ -8283,9 +8321,15 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
_prefs.rx_ps_rx_us = previous_rx_us;
_prefs.rx_ps_sleep_us = previous_sleep_us;
_prefs.clearDirty();
strcpy(reply, "Error: setting changed but save failed");
if (strncmp(command, "set radio ", 10) == 0
&& !_radio_profiles.savePrimaryPreamble(previous_primary_preamble)) {
strcpy(reply, "Error: radio save failed; preamble rollback failed");
} else {
strcpy(reply, "Error: setting changed but save failed");
}
}
}
if (!strcmp(command, "get radio")) _radio_profiles.appendSavedPreamble(reply, reply_capacity, _prefs.sf, _prefs.bw);
return true;
}
@@ -8610,6 +8654,7 @@ void MyMesh::checkSerialInterface() {
}
void MyMesh::loop() {
_radio_profiles.loop();
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
if (_mqtt_bridge) _mqtt_bridge->servicePendingClockCorrection();
#endif
@@ -8628,9 +8673,9 @@ void MyMesh::loop() {
serviceTempRadio();
#endif
#if defined(OTA_SHARED_COMPANION_QUEUE)
mesh::ota::ota_release_context_if_idle(isTempRadioActive() || _temp_radio_set_at != 0);
mesh::ota::ota_release_context_if_idle(isAnyTempRadioActive() || _temp_radio_set_at != 0);
#elif defined(ENABLE_OTA) && defined(OTA_HEAP_CONTEXT)
mesh::ota::ota_service_temp_radio_context(isTempRadioActive()
mesh::ota::ota_service_temp_radio_context(isAnyTempRadioActive()
#if COMPANION_FEATURE_TEMP_RADIO
|| _temp_radio_set_at != 0
#endif
@@ -8721,6 +8766,7 @@ bool MyMesh::advert() {
// To check if there is pending work
bool MyMesh::hasPendingWork() const {
if (isDualRadioActive()) return true;
if (_radio_available
&& (radio_driver.isWatchdogObserving()
|| radio_driver.isCalibratingNoiseFloor())) return true;
+4 -1
View File
@@ -25,6 +25,7 @@
#endif
#include "DataStore.h"
#include <helpers/RadioProfileCLI.h>
#include "NodePrefs.h"
#if defined(ESP32_PLATFORM) && defined(WIFI_SSID) && !defined(WEBCONFIG_DISABLED)
@@ -438,7 +439,7 @@ private:
bool isValidClientRepeatFreq(uint32_t f) const;
bool hasLocationTelemetryRecipient();
void updateGpsTelemetryPolicy();
mesh::RadioParamApplyResult tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr);
mesh::RadioParamApplyResult tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, bool temporary = false, uint16_t preamble = 0);
bool applySavedRadioParams();
void configureRadioFromPrefs();
void finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr,
@@ -480,6 +481,8 @@ private:
#endif
DataStore* _store;
mesh::RadioProfileCLI _radio_profiles;
uint16_t _temp_radio_preamble = 0;
CompanionNodePrefs _prefs;
#ifdef COMPANION_MESH_CLOCK_SYNC
ArduinoMillis _clock_sync_millis;
+38 -17
View File
@@ -1279,7 +1279,7 @@ bool MyMesh::evaluateScopeRewriteTiming(const mesh::Packet* packet,
bool MyMesh::shouldBypassRxDelay(const mesh::Packet* packet) {
if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_OTA
&& isTempRadioActive()) return true;
&& isPacketOnTempRadio(packet)) return true;
bool fast_track = false;
return evaluateScopeRewriteTiming(packet, fast_track) && fast_track;
}
@@ -1287,7 +1287,7 @@ bool MyMesh::shouldBypassRxDelay(const mesh::Packet* packet) {
int MyMesh::calcRxDelayForPacket(const mesh::Packet* packet, float score,
uint32_t air_time) {
if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_OTA
&& isTempRadioActive()) return 0;
&& isPacketOnTempRadio(packet)) return 0;
bool fast_track = false;
if (!evaluateScopeRewriteTiming(packet, fast_track)) {
return calcRxDelay(score, air_time);
@@ -1917,7 +1917,8 @@ MyMesh::FloodRetryBridgeState* MyMesh::floodRetryBridgeStateFor(const mesh::Pack
uint8_t key[MAX_HASH_SIZE];
packet->calculatePacketHash(key);
FloodRetryBridgeState* free_slot = NULL;
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (i / MAX_FLOOD_RETRY_SLOTS != packet->radio_profile) continue;
if (flood_retry_bridge_states[i].active
&& memcmp(flood_retry_bridge_states[i].key, key, MAX_HASH_SIZE) == 0) {
return &flood_retry_bridge_states[i];
@@ -1980,11 +1981,12 @@ bool MyMesh::prepareFloodRetry(const mesh::Packet* packet) const {
return floodRetryBridgeStateFor(packet, true) != NULL;
}
void MyMesh::clearFloodRetryBridgeStateByKey(const uint8_t* retry_key) {
void MyMesh::clearFloodRetryBridgeStateByKey(const uint8_t* retry_key, uint8_t radio_profile) {
if (retry_key == NULL) {
return;
}
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (i / MAX_FLOOD_RETRY_SLOTS != radio_profile) continue;
if (flood_retry_bridge_states[i].active
&& memcmp(flood_retry_bridge_states[i].key, retry_key, MAX_HASH_SIZE) == 0) {
flood_retry_bridge_states[i].active = false;
@@ -2234,8 +2236,8 @@ void MyMesh::onFloodRetryEvent(const char* event, const mesh::Packet* packet, ui
}
void MyMesh::onFloodRetrySlotReleased(const uint8_t* retry_key) {
clearFloodRetryBridgeStateByKey(retry_key);
void MyMesh::onFloodRetrySlotReleased(const uint8_t* retry_key, uint8_t radio_profile) {
clearFloodRetryBridgeStateByKey(retry_key, radio_profile);
}
bool MyMesh::hasFloodRetryTargetPrefix(const mesh::Packet* packet) const {
@@ -2869,7 +2871,8 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx,
} else if (!deferred_cli_command.enqueue(i, sender_timestamp,
packet->getPathHashSize(), secret,
command, command_len,
request_id)) {
request_id, packet->radio_profile,
packet->radio_generation)) {
const char* error = deferred_cli_command.pending
? "Err - another remote command is still running"
: "Err - remote command is too long";
@@ -3004,6 +3007,9 @@ bool MyMesh::completeHostCliRequest(const char* service_reply) {
reply, mesh::HostCliBridge::REMOTE_REPLY_MAX + 1U,
deferred_cli_command.command, service_reply);
ReceiveProfileScope radio_scope(*this, deferred_cli_command.radio_profile,
deferred_cli_command.radio_generation);
const uint32_t command_fingerprint =
mesh::RemoteCliReplyCache::fingerprint(
deferred_cli_command.command,
@@ -3063,6 +3069,8 @@ bool MyMesh::handleHostCliSerialReply(const char* command, char* reply) {
void __attribute__((noinline)) MyMesh::processDeferredCliCommand() {
if (!deferred_cli_command.pending) return;
ReceiveProfileScope radio_scope(*this, deferred_cli_command.radio_profile,
deferred_cli_command.radio_generation);
const int client_index = deferred_cli_command.client_index;
if (client_index < 0 || client_index >= acl.getNumClients()) {
@@ -4027,7 +4035,7 @@ void MyMesh::setTempRadioTiming(uint32_t duration_seconds) {
updateFloodAdvertTimer();
}
bool MyMesh::applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) {
bool MyMesh::applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble, bool temporary) {
uint32_t rx_us = _prefs.rx_ps_rx_us;
uint32_t sleep_us = _prefs.rx_ps_sleep_us;
if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0) {
@@ -4039,8 +4047,8 @@ bool MyMesh::applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) {
uint32_t timings[2] = {rx_us, sleep_us};
const uint32_t* applied_timings = _prefs.rx_powersaving_enabled
&& radio_driver.supportsRxPowerSaving() ? timings : NULL;
if (!radio_driver.setParams(freq, bw, sf, cr,
applied_timings)) {
if (_cli.radioProfiles().applyPrimary(freq, bw, sf, cr, temporary, preamble,
applied_timings) != mesh::RadioParamApplyResult::APPLIED) {
MESH_DEBUG_PRINTLN("Radio schedule: radio busy or parameter apply failed");
return false;
}
@@ -4068,7 +4076,7 @@ bool MyMesh::applySavedRadioParams() {
}
#endif
if (!applyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr)) return false;
if (!applyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, _cli.radioProfiles().primaryPreamble())) return false;
#if defined(USE_LR2021)
return radio_driver.configSideDetectors(_prefs.extra_sf, extra_sf_count, _prefs.bw);
@@ -4292,7 +4300,13 @@ void MyMesh::formatRadioParamTuple(char* dest, size_t dest_len, const ScheduledR
char bw[16];
formatFixed3(freq, sizeof(freq), setting.freq);
StrHelper::strncpy(bw, StrHelper::ftoa3(setting.bw), sizeof(bw));
snprintf(dest, dest_len, "%s,%s,%u,%u", freq, bw, (uint32_t)setting.sf, (uint32_t)setting.cr);
mesh::RadioProfiles preview;
if (_radio->profiles()) preview = *_radio->profiles();
preview.primary.freq = setting.freq; preview.primary.bw = setting.bw;
preview.primary.sf = setting.sf; preview.primary.cr = setting.cr;
preview.primary.preamble = setting.preamble;
snprintf(dest, dest_len, "%s,%s,%u,%u,%u", freq, bw, (uint32_t)setting.sf, (uint32_t)setting.cr,
preview.preamble(0, rxPowerSavingPreambleForParams(setting.sf, setting.bw)));
}
void MyMesh::formatScheduledRadioSetting(char* reply, int setting_idx, int display_idx) const {
@@ -4330,7 +4344,7 @@ void MyMesh::formatScheduledRadioSetting(char* reply, int setting_idx, int displ
}
void MyMesh::addScheduledRadioParams(bool temporary, float freq, float bw, uint8_t sf, uint8_t cr,
uint32_t start_time, uint32_t end_time, char* reply) {
uint32_t start_time, uint32_t end_time, char* reply, uint16_t preamble) {
uint32_t now = getRTCClock()->getCurrentTime();
if (!isValidScheduledRadioParams(freq, bw, sf, cr)) {
strcpy(reply, "Error, invalid radio params");
@@ -4370,6 +4384,7 @@ void MyMesh::addScheduledRadioParams(bool temporary, float freq, float bw, uint8
scheduled_radio_settings[slot].bw = bw;
scheduled_radio_settings[slot].sf = sf;
scheduled_radio_settings[slot].cr = cr;
scheduled_radio_settings[slot].preamble = preamble;
scheduled_radio_settings[slot].start_time = start_time;
scheduled_radio_settings[slot].end_time = temporary ? end_time : 0;
const uint64_t current_uptime_millis =
@@ -4593,6 +4608,10 @@ void MyMesh::processScheduledRadioSettings() {
}
ScheduledRadioSetting& setting = scheduled_radio_settings[due_idx];
if (!_cli.radioProfiles().savePrimaryPreamble(setting.preamble)) {
scheduled_radio_retry_at = futureMillis(60000);
break;
}
_prefs.freq = setting.freq;
_prefs.bw = setting.bw;
_prefs.sf = setting.sf;
@@ -4633,7 +4652,7 @@ void MyMesh::processScheduledRadioSettings() {
temp_radio_handoff_pending = false;
queueSavedRadioApply();
}
} else if (applyRadioParams(setting.freq, setting.bw, setting.sf, setting.cr)) {
} else if (applyRadioParams(setting.freq, setting.bw, setting.sf, setting.cr, setting.preamble, true)) {
setting.started = true;
temp_radio_applied = true;
temp_radio_handoff_pending = false;
@@ -4757,7 +4776,7 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const {
return sleep_secs;
}
void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) {
void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble) {
// A newer TempRadio command supersedes the reply barrier belonging to the
// old schedule. processDeferredCliCommand() arms the new exact reply after
// it has been composed and successfully queued.
@@ -4798,6 +4817,7 @@ void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr,
scheduled_radio_settings[slot].bw = bw;
scheduled_radio_settings[slot].sf = sf;
scheduled_radio_settings[slot].cr = cr;
scheduled_radio_settings[slot].preamble = preamble;
scheduled_radio_settings[slot].start_time = start_time;
scheduled_radio_settings[slot].end_time = start_time + ((uint32_t)timeout_mins * 60);
const uint64_t current_uptime_millis =
@@ -12056,7 +12076,7 @@ void MyMesh::loop() {
processDeferredCliCommand();
servicePostMeshLoop();
#if defined(ENABLE_OTA) && OTA_DYNAMIC_CONTEXT
mesh::ota::ota_service_temp_radio_context(isTempRadioActive());
mesh::ota::ota_service_temp_radio_context(isAnyTempRadioActive());
#endif
}
@@ -13034,6 +13054,7 @@ bool MyMesh::startNeighborDiscover(char* reply) {
// To check if there is pending work
bool MyMesh::hasPendingWork() const {
if (isDualRadioActive()) return true;
if (hasPendingOtaApply()) return true;
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_local_cli_output.busy()) return true;
+8 -6
View File
@@ -286,6 +286,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
#endif
{
struct ScheduledRadioSetting {
uint16_t preamble = 0;
bool active;
bool temporary;
bool started;
@@ -465,7 +466,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint32_t epoch;
uint32_t received_millis;
};
mutable FloodRetryBridgeState flood_retry_bridge_states[MAX_FLOOD_RETRY_SLOTS];
mutable FloodRetryBridgeState flood_retry_bridge_states[TOTAL_FLOOD_RETRY_SLOTS];
FloodRetryBridgeReachability flood_retry_bridge_reachability[FLOOD_RETRY_BRIDGE_BUCKETS + 1];
static_assert(sizeof(FloodPacketFilterEntry) <= (MESH_ENABLE_FLOOD_RULE_ENGINE ? 200 : 40),
"Update the flood-table runtime RAM budget in check_firmware_ram.py");
@@ -677,7 +678,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
uint8_t progress_marker) const;
bool floodRetryBridgeEligible(const mesh::Packet* packet) const;
FloodRetryBridgeState* floodRetryBridgeStateFor(const mesh::Packet* packet, bool create) const;
void clearFloodRetryBridgeStateByKey(const uint8_t* retry_key);
void clearFloodRetryBridgeStateByKey(const uint8_t* retry_key, uint8_t radio_profile);
void refreshFloodRetryReachability(const mesh::Packet* packet);
void formatFloodRetryPath(char* dest, size_t dest_len, const mesh::Packet* packet) const;
bool handleClientPathCommand(ClientInfo* sender, char* command, char* reply);
@@ -732,7 +733,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
File openAppend(const char* fname);
bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]);
bool applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr);
bool applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble = 0, bool temporary = false);
bool applySavedRadioParams();
void queueSavedRadioApply();
void refreshScheduledRadioState();
@@ -906,7 +907,7 @@ protected:
bool allowFloodRetry(const mesh::Packet* packet) const override;
bool prepareFloodRetry(const mesh::Packet* packet) const override;
void onFloodRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis, uint8_t retry_attempt) override;
void onFloodRetrySlotReleased(const uint8_t* retry_key) override;
void onFloodRetrySlotReleased(const uint8_t* retry_key, uint8_t radio_profile) override;
bool hasFloodRetryTargetPrefix(const mesh::Packet* packet) const override;
uint8_t getFloodRetryMaxPathLength(const mesh::Packet* packet) const override;
uint8_t getFloodRetryMaxAttempts(const mesh::Packet* packet) const override;
@@ -988,7 +989,8 @@ public:
bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size);
// CommonCLICallbacks
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override;
mesh::Radio* getProfileRadio() override { return _radio; }
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble = 0) override;
bool scheduleNormalRadio() override;
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
uint32_t getUserGpioRequestSource() const override {
@@ -1010,7 +1012,7 @@ public:
#endif
bool resolveAlertScope(TransportKey& dest) override;
void addScheduledRadioParams(bool temporary, float freq, float bw, uint8_t sf, uint8_t cr,
uint32_t start_time, uint32_t end_time, char* reply) override;
uint32_t start_time, uint32_t end_time, char* reply, uint16_t preamble = 0) override;
void formatScheduledRadioParams(bool temporary, const char* selector, char* reply) override;
void deleteScheduledRadioParams(bool temporary, const char* selector, char* reply) override;
bool formatFileSystem() override;
+10 -7
View File
@@ -438,7 +438,7 @@ bool MyMesh::isLooped(const mesh::Packet* packet,
int MyMesh::calcRxDelayForPacket(const mesh::Packet* packet, float score,
uint32_t air_time) {
if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_OTA
&& isTempRadioActive()) return 0;
&& isPacketOnTempRadio(packet)) return 0;
bool fast_track = false;
if (!evaluateFloodRuleTiming(packet, fast_track)) {
return calcRxDelay(score, air_time);
@@ -1458,8 +1458,9 @@ bool MyMesh::applySavedRadioParams() {
uint32_t timings[2] = {_prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us};
const uint32_t* applied_timings = _prefs.rx_powersaving_enabled
&& radio_driver.supportsRxPowerSaving() ? timings : NULL;
if (!radio_driver.setParams(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, applied_timings)) {
if (_cli.radioProfiles().applyPrimary(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, false,
_cli.radioProfiles().primaryPreamble(), applied_timings) != mesh::RadioParamApplyResult::APPLIED) {
return false;
}
active_cr = _prefs.cr;
@@ -1517,7 +1518,8 @@ void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, ui
}
}
void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) {
void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble) {
pending_preamble = preamble;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params
@@ -2585,8 +2587,8 @@ void MyMesh::loop() {
uint32_t timings[2] = {rx_us, sleep_us};
const uint32_t* applied_timings = _prefs.rx_powersaving_enabled
&& radio_driver.supportsRxPowerSaving() ? timings : NULL;
if (timing_ok && radio_driver.setParams(
pending_freq, pending_bw, pending_sf, pending_cr, applied_timings)) {
if (timing_ok && _cli.radioProfiles().applyPrimary(
pending_freq, pending_bw, pending_sf, pending_cr, true, pending_preamble, applied_timings) == mesh::RadioParamApplyResult::APPLIED) {
set_radio_at = 0;
active_cr = pending_cr;
temp_radio_applied = true;
@@ -2783,7 +2785,7 @@ void MyMesh::loop() {
}
#endif
#if defined(ENABLE_OTA) && OTA_DYNAMIC_CONTEXT
mesh::ota::ota_service_temp_radio_context(isTempRadioActive());
mesh::ota::ota_service_temp_radio_context(isAnyTempRadioActive());
#endif
}
@@ -2838,6 +2840,7 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const {
// To check if there is pending work
bool MyMesh::hasPendingWork() const {
if (isDualRadioActive()) return true;
if (hasPendingOtaApply()) return true;
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_local_cli_output.busy()) return true;
+3 -1
View File
@@ -159,6 +159,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
#endif
{
FILESYSTEM* _fs;
uint16_t pending_preamble = 0;
mesh::StaticFloodAdvertLimiter<> flood_advert_limiter;
mesh::FloodAdvertLimiter* getFloodAdvertLimiter() override { return &flood_advert_limiter; }
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
@@ -443,7 +444,8 @@ public:
uint32_t delay_millis, uint8_t path_hash_size);
// CommonCLICallbacks
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override;
mesh::Radio* getProfileRadio() override { return _radio; }
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble = 0) override;
bool scheduleNormalRadio() override;
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
uint32_t getUserGpioRequestSource() const override {
+8
View File
@@ -1,3 +1,4 @@
#include <helpers/RadioProfileCLI.h>
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#if MESH_PACKET_LOGGING
@@ -84,6 +85,7 @@ struct NodePrefs { // persisted to file
class MyMesh : public BaseChatMesh, ContactVisitor {
FILESYSTEM* _fs;
mesh::RadioProfileCLI _radio_profiles;
NodePrefs _prefs;
uint32_t expected_ack_crc;
ChannelDetails* _public;
@@ -318,6 +320,7 @@ public:
_fs = &fs;
BaseChatMesh::begin();
_radio_profiles.begin(_fs, _radio, getRTCClock());
#if defined(NRF52_PLATFORM)
IdentityStore store(fs, "");
@@ -421,6 +424,10 @@ public:
}
void handleCommand(const char* command) {
char profile_reply[160];
if (_radio_profiles.handle(command, profile_reply, sizeof(profile_reply))) {
Serial.println(profile_reply); return;
}
while (*command == ' ') command++; // skip leading spaces
if (memcmp(command, "send ", 5) == 0) {
@@ -631,6 +638,7 @@ public:
}
void loop() {
_radio_profiles.loop();
BaseChatMesh::loop();
int len = strlen(command);
+9 -6
View File
@@ -1067,8 +1067,9 @@ bool SensorMesh::applySavedRadioParams() {
uint32_t timings[2] = {_prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us};
const uint32_t* applied_timings = _prefs.rx_powersaving_enabled
&& radio_driver.supportsRxPowerSaving() ? timings : NULL;
if (!radio_driver.setParams(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, applied_timings)) {
if (_cli.radioProfiles().applyPrimary(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, false,
_cli.radioProfiles().primaryPreamble(), applied_timings) != mesh::RadioParamApplyResult::APPLIED) {
return false;
}
active_cr = _prefs.cr;
@@ -1101,7 +1102,8 @@ void SensorMesh::saveIdentity(const mesh::LocalIdentity& new_id) {
store.save("_main", new_id);
}
void SensorMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) {
void SensorMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble) {
pending_preamble = preamble;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params
@@ -1264,8 +1266,8 @@ void SensorMesh::loop() {
uint32_t timings[2] = {rx_us, sleep_us};
const uint32_t* applied_timings = _prefs.rx_powersaving_enabled
&& radio_driver.supportsRxPowerSaving() ? timings : NULL;
if (timing_ok && radio_driver.setParams(
pending_freq, pending_bw, pending_sf, pending_cr, applied_timings)) {
if (timing_ok && _cli.radioProfiles().applyPrimary(
pending_freq, pending_bw, pending_sf, pending_cr, true, pending_preamble, applied_timings) == mesh::RadioParamApplyResult::APPLIED) {
set_radio_at = 0;
active_cr = pending_cr;
temp_radio_applied = true;
@@ -1362,7 +1364,7 @@ void SensorMesh::loop() {
}
}
#if defined(ENABLE_OTA) && OTA_DYNAMIC_CONTEXT
mesh::ota::ota_service_temp_radio_context(isTempRadioActive());
mesh::ota::ota_service_temp_radio_context(isAnyTempRadioActive());
#endif
}
@@ -1382,6 +1384,7 @@ uint32_t SensorMesh::limitSleepToMillisTimer(unsigned long timestamp,
}
bool SensorMesh::hasPendingWork() const {
if (isDualRadioActive()) return true;
if (hasPendingOtaApply()) return true;
if (_cli.hasActiveUserGpioTimer()) return true;
if (radio_driver.isWatchdogObserving()) return true;
+3 -1
View File
@@ -94,7 +94,8 @@ public:
mesh::LocalIdentity& getSelfId() override { return self_id; }
void saveIdentity(const mesh::LocalIdentity& new_id) override;
void clearStats() override { }
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override;
mesh::Radio* getProfileRadio() override { return _radio; }
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble = 0) override;
bool scheduleNormalRadio() override;
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
uint32_t getUserGpioRequestSource() const override {
@@ -187,6 +188,7 @@ protected:
void sendAckTo(const ClientInfo& dest, uint32_t ack_hash, uint8_t path_hash_size=1);
private:
FILESYSTEM* _fs;
uint16_t pending_preamble = 0;
unsigned long next_local_advert, next_flood_advert;
NodePrefs _prefs;
ClientACL acl;
+127 -5
View File
@@ -122,6 +122,8 @@ void Dispatcher::restoreOutboundTxOverrides() {
bool Dispatcher::startOutboundTransmit() {
if (outbound == NULL) return false;
if (!isPacketRadioCurrent(outbound)
|| _radio->prepareTransmitProfile(outbound->radio_profile) != RadioParamApplyResult::APPLIED) return false;
int len = 0;
uint8_t raw[MAX_TRANS_UNIT];
@@ -145,6 +147,7 @@ bool Dispatcher::startOutboundTransmit() {
uint32_t max_airtime = _radio->getEstAirtimeFor(len) * 3 / 2;
outbound_restore_cr = 0;
uint8_t default_cr = getDefaultTxCodingRate();
if (_radio->profiles()) default_cr = _radio->profiles()->params(outbound->radio_profile).cr;
if (outbound->tx_cr >= 4 && outbound->tx_cr <= 8
&& default_cr >= 4 && default_cr <= 8
&& outbound->tx_cr != default_cr) {
@@ -244,6 +247,7 @@ void Dispatcher::logPacketLine(const char* direction, const Packet* packet,
#endif
bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const {
if (isDualRadioActive()) { delay_millis = 0; return true; }
const uint32_t now = _ms->getMillis();
bool found = false;
uint32_t shortest_delay = 0;
@@ -342,10 +346,20 @@ void Dispatcher::loop() {
if (outbound) { // waiting for outbound send to complete, or for its one radio retry
if (outbound_radio_retry_pending) {
// The failed send has already returned the chip to RX. Drain a packet
// arriving during backoff before asking to retune; otherwise BUSY can
// keep the retry waiting forever on an unread RxDone interrupt.
if (isDualRadioActive()) checkRecv();
if (!millisHasNowPassed(outbound_radio_retry_at)) return;
const auto prepared = _radio->prepareTransmitProfile(outbound->radio_profile);
if (prepared == RadioParamApplyResult::BUSY) {
outbound_radio_retry_at = futureMillis(10);
return;
}
outbound_radio_retry_pending = false;
if (!allowPacketTransmit(outbound)) {
if (prepared == RadioParamApplyResult::FAILED || !isPacketRadioCurrent(outbound)
|| !allowPacketTransmit(outbound)) {
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): radio retry packet no longer allowed, type=%u",
getLogDateTime(), (uint32_t)outbound->getPayloadType());
failOutboundTransmit();
@@ -384,6 +398,7 @@ void Dispatcher::loop() {
restoreOutboundTxOverrides();
logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len);
onSendComplete(outbound);
if (auto* p = _radio->profiles()) ++p->tx_packets[outbound->radio_profile];
if (outbound->isRouteFlood()) {
n_sent_flood++;
} else {
@@ -523,6 +538,10 @@ bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) {
pkt->tx_cr = 0;
pkt->flood_retry_policy = FLOOD_RETRY_POLICY_DEFAULT;
pkt->radio_profile = pkt->radio_origin = _radio->receiveProfile();
pkt->radio_bound = false;
pkt->radio_local = false;
pkt->radio_generation = pkt->radio_origin_generation = _radio->receiveProfileGeneration();
pkt->header = raw[i++];
if (pkt->getPayloadVer() > PAYLOAD_VER_1) {
MESH_DEBUG_PRINTLN("%s Dispatcher::checkRecv(): unsupported packet version", getLogDateTime());
@@ -597,6 +616,7 @@ void Dispatcher::checkRecv() {
} else {
if (tryParsePacket(pkt, raw, len)) {
last_meshcore_recv_millis = _ms->getMillis();
if (auto* p = _radio->profiles()) ++p->rx_packets[pkt->radio_profile];
pkt->_snr = snr * 4.0f;
pkt->_rssi = (int16_t)rssi;
score = _radio->packetScore(snr, len);
@@ -642,7 +662,23 @@ void Dispatcher::checkRecv() {
}
void Dispatcher::processRecvPacket(Packet* pkt) {
const auto* profiles = _radio->profiles();
if (profiles && (pkt->radio_profile > 1
|| (pkt->radio_profile == 1 && !profiles->enabled())
|| pkt->radio_generation != profiles->generation[pkt->radio_profile])) {
releasePacket(pkt);
return;
}
const uint8_t previous_profile = receive_context_profile;
const uint32_t previous_generation = receive_context_generation;
const bool previous_active = receive_context_active;
receive_context_profile = pkt->radio_profile;
receive_context_generation = pkt->radio_generation;
receive_context_active = true;
DispatcherAction action = onRecvPacket(pkt);
receive_context_profile = previous_profile;
receive_context_generation = previous_generation;
receive_context_active = previous_active;
if (action == ACTION_RELEASE) {
_mgr->free(pkt);
} else if (action == ACTION_MANUAL_HOLD) {
@@ -671,9 +707,19 @@ void Dispatcher::checkSend() {
return;
}
// Discard work bound to an expired/changed profile before it can be retuned
// onto a different channel. This also retires its retry ownership normally.
Packet* pending = _mgr->peekNextOutbound(now);
if (pending && !isPacketRadioCurrent(pending)) {
outbound = _mgr->getNextOutbound(now);
failOutboundTransmit();
return;
}
updateTxBudget();
uint32_t est_airtime = _radio->getEstAirtimeFor(MAX_TRANS_UNIT);
uint32_t est_airtime = pending && _radio->profiles()
? _radio->getProfileAirtime(pending->radio_profile, MAX_TRANS_UNIT)
: _radio->getEstAirtimeFor(MAX_TRANS_UNIT);
if (tx_budget_ms < est_airtime / MIN_TX_BUDGET_AIRTIME_DIV) {
float duty_cycle = 1.0f / (1.0f + getAirtimeBudgetFactor());
unsigned long needed = est_airtime / MIN_TX_BUDGET_AIRTIME_DIV - tx_budget_ms;
@@ -683,15 +729,29 @@ void Dispatcher::checkSend() {
if (!millisHasNowPassed(next_tx_time)) return;
Packet* pending = _mgr->peekNextOutbound(_ms->getMillis());
// Waiting for airtime credit must leave the receive scanner free to run.
// Only retune once this queue entry is actually eligible to transmit.
if (pending && _radio->profiles()) {
const auto prepared = _radio->prepareTransmitProfile(pending->radio_profile);
if (prepared == RadioParamApplyResult::BUSY) return;
if (prepared == RadioParamApplyResult::FAILED) {
outbound = _mgr->getNextOutbound(now);
failOutboundTransmit();
return;
}
}
bool channel_busy = pending != NULL && usePassiveChannelCheck(pending)
? _radio->isReceivingPassive(getRetryInterferenceMargin())
: _radio->isReceiving();
if (channel_busy) {
const uint32_t cad_now = _ms->getMillis();
const int ready_count = _mgr->getOutboundCount(cad_now);
uint32_t& profile_busy = profile_cad_busy[pending ? pending->radio_profile : 0];
cad_busy_start = profile_busy;
if (cad_busy_start == 0) {
cad_busy_start = cad_now; // record when CAD busy state started
profile_busy = cad_now;
}
const uint32_t max_busy_duration = scaleCADDelayForQueue(
@@ -705,11 +765,14 @@ void Dispatcher::checkSend() {
} else {
const uint32_t retry_delay = scaleCADDelayForQueue(
getCADFailRetryDelay(), ready_count, MIN_CAD_FAIL_RETRY_DELAY_MS);
next_tx_time = futureMillis(retry_delay);
if (!isDualRadioActive() || !pending || !_mgr->deferOutbound(pending, futureMillis(retry_delay))) {
next_tx_time = futureMillis(retry_delay);
}
return;
}
}
cad_busy_start = 0; // reset busy state
profile_cad_busy[pending ? pending->radio_profile : 0] = 0;
outbound = _mgr->getNextOutbound(_ms->getMillis());
if (outbound) {
@@ -750,6 +813,10 @@ Packet* Dispatcher::obtainNewPacket() {
pkt->_snr = 0;
pkt->tx_cr = 0;
pkt->flood_retry_policy = FLOOD_RETRY_POLICY_DEFAULT;
pkt->radio_profile = pkt->radio_origin = receive_context_active ? receive_context_profile : 0;
pkt->radio_generation = pkt->radio_origin_generation = receive_context_active ? receive_context_generation : 0;
pkt->radio_bound = false;
pkt->radio_local = !receive_context_active;
}
return pkt;
}
@@ -767,7 +834,62 @@ bool Dispatcher::queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t
MESH_DEBUG_PRINTLN("%s Dispatcher::sendPacket(): ERROR: invalid packet... path_len=%d, payload_len=%d", getLogDateTime(), (uint32_t) packet->path_len, (uint32_t) packet->payload_len);
return false;
}
return _mgr->queueOutbound(packet, priority, futureMillis(delay_millis));
auto* profiles = _radio->profiles();
if (packet->radio_profile > 1) return false;
if (profiles == nullptr || packet->radio_bound) {
return _mgr->queueOutbound(packet, priority, futureMillis(delay_millis));
}
if (!packet->radio_local && packet->radio_generation
&& packet->radio_generation != profiles->generation[packet->radio_profile]) return false;
// Locally generated OTA traffic uses the temporary update profile. Replies
// created while processing RX inherit that request's profile instead.
// Keep that origin in RX-only mode too: isolation must drop a disallowed
// transmission instead of silently sending it on the normal channel.
if (packet->radio_local && packet->getPayloadType() == PAYLOAD_TYPE_OTA
&& profiles->secondary_temporary && !profiles->primary_temporary) {
packet->radio_profile = 1;
}
const uint8_t origin = packet->radio_profile;
const uint8_t mask = profiles->transmitMask(origin);
if (!mask) return false;
packet->radio_origin = origin;
packet->radio_origin_generation = profiles->generation[origin];
if (!(mask & (1U << origin))) {
onSendFail(packet); // retire any retry reserved on the RX-only profile
packet->radio_profile ^= 1;
}
packet->radio_generation = profiles->generation[packet->radio_profile];
packet->radio_bound = true;
if (!_mgr->queueOutbound(packet, priority, futureMillis(delay_millis))) return false;
if (packet->radio_profile != origin) onRadioProfileCopyQueued(packet, nullptr, priority);
const uint8_t other = packet->radio_profile ^ 1;
if (mask & (1U << other)) {
Packet* copy = obtainNewPacket();
if (copy) {
*copy = *packet;
copy->radio_profile = other;
copy->radio_generation = profiles->generation[other];
if (_mgr->queueOutbound(copy, priority, futureMillis(delay_millis))) {
onRadioProfileCopyQueued(copy, packet, priority);
onTracePacketQueuedForSend(copy);
} else {
onSendFail(copy);
releasePacket(copy);
}
}
}
return true;
}
bool Dispatcher::isPacketRadioCurrent(const Packet* packet) const {
const auto* p = _radio->profiles();
if (!p || !packet->radio_bound) return true;
const uint8_t target = packet->radio_profile;
const uint8_t origin = packet->radio_origin;
return target < 2 && origin < 2 && p->canTransmit(target)
&& packet->radio_generation == p->generation[target]
&& packet->radio_origin_generation == p->generation[origin]
&& (origin == target || p->canCross());
}
bool Dispatcher::sendPacket(Packet* packet, uint8_t priority, uint32_t delay_millis) {
+52
View File
@@ -4,6 +4,7 @@
#include <Identity.h>
#include <Packet.h>
#include <Utils.h>
#include <RadioProfiles.h>
#ifndef RADIO_LIVENESS_SOFT_ONLY
#include <helpers/RadioLivenessTracker.h>
#endif
@@ -31,6 +32,25 @@ public:
class Radio {
public:
virtual void begin() { }
virtual RadioProfiles* profiles() { return nullptr; }
virtual const RadioProfiles* profiles() const { return nullptr; }
virtual bool validateProfile(const RadioProfileParams&) const { return false; }
virtual uint8_t receiveProfile() const { return 0; }
virtual uint32_t receiveProfileGeneration() const {
return profiles() ? profiles()->generation[receiveProfile()] : 0;
}
virtual RadioParamApplyResult prepareTransmitProfile(uint8_t profile) {
return profile == 0 ? RadioParamApplyResult::APPLIED : RadioParamApplyResult::FAILED;
}
virtual uint16_t profilePreamble(uint8_t) const { return 0; }
virtual uint32_t getProfileAirtime(uint8_t, int len_bytes, uint8_t = 0) {
return getEstAirtimeFor(len_bytes);
}
virtual RadioParamApplyResult trySetPrimaryParams(const RadioProfileParams& p,
bool temporary, const uint32_t* timings = nullptr) {
(void)temporary;
return trySetParams(p.freq, p.bw, p.sf, p.cr, timings);
}
/**
* \brief polls for incoming raw packet.
@@ -193,6 +213,7 @@ public:
// freeing them silently. Dispatcher will run the normal send-failure
// lifecycle hook before returning each packet to the pool.
virtual Packet* getNextDroppedOutbound() { return NULL; }
virtual bool deferOutbound(Packet*, uint32_t) { return false; }
virtual int getOutboundCount(uint32_t now) const = 0;
virtual int getOutboundTotal() const = 0;
// Returns the earliest runnable time in the queue. A queue with any overdue
@@ -265,6 +286,10 @@ class Dispatcher {
unsigned long tx_budget_ms;
unsigned long last_budget_update;
unsigned long duty_cycle_window_ms;
uint8_t receive_context_profile = 0;
uint32_t receive_context_generation = 0;
bool receive_context_active = false;
uint32_t profile_cad_busy[2] = {};
void processRecvPacket(Packet* pkt);
void releaseDroppedOutbound();
@@ -275,6 +300,29 @@ class Dispatcher {
void updateTxBudget();
protected:
// Preserve RX affinity while an authenticated command is handled outside
// the receive call stack. Replies inherit this profile and session.
class ReceiveProfileScope {
Dispatcher& owner;
uint8_t old_profile;
uint32_t old_generation;
bool old_active;
public:
ReceiveProfileScope(Dispatcher& dispatcher, uint8_t profile, uint32_t generation)
: owner(dispatcher), old_profile(owner.receive_context_profile),
old_generation(owner.receive_context_generation), old_active(owner.receive_context_active) {
owner.receive_context_profile = profile;
owner.receive_context_generation = generation;
owner.receive_context_active = true;
}
~ReceiveProfileScope() {
owner.receive_context_profile = old_profile;
owner.receive_context_generation = old_generation;
owner.receive_context_active = old_active;
}
ReceiveProfileScope(const ReceiveProfileScope&) = delete;
ReceiveProfileScope& operator=(const ReceiveProfileScope&) = delete;
};
PacketManager* _mgr;
Radio* _radio;
MillisecondClock* _ms;
@@ -320,6 +368,7 @@ protected:
virtual void onTracePacketQueuedForSend(Packet* packet) { }
virtual void onSendComplete(Packet* packet) { }
virtual void onSendFail(Packet* packet) { }
virtual void onRadioProfileCopyQueued(Packet* packet, const Packet* original, uint8_t priority) { }
virtual const char* getLogDateTime() { return ""; }
virtual float getAirtimeBudgetFactor() const;
@@ -361,10 +410,13 @@ protected:
bool tryParsePacket(Packet* pkt, const uint8_t* raw, int len);
void setRadioAvailable(bool available);
bool isRadioAvailable() const { return radio_available; }
bool isPacketRadioCurrent(const Packet* packet) const;
public:
void begin();
void loop();
Radio* getProfileRadio() { return _radio; }
bool isDualRadioActive() const { return _radio->profiles() && _radio->profiles()->enabled(); }
Packet* obtainNewPacket();
void releasePacket(Packet* packet);
+127 -53
View File
@@ -138,6 +138,7 @@ void Mesh::configureDirectRetryPacket(Packet* retry, const Packet* original, uin
}
uint8_t default_cr = getDefaultTxCodingRate();
if (original && _radio->profiles()) default_cr = _radio->profiles()->params(original->radio_profile).cr;
if (default_cr < 4 || default_cr > 8) {
return;
}
@@ -151,7 +152,8 @@ void Mesh::configureFloodRetryPacket(Packet* retry, const Packet* original, uint
// Always start from the active radio setting, not the previous retry's
// override. Only the originating sender has no hops recorded yet; bridge
// path adjustments must not make a forwarded packet eligible for this.
const uint8_t default_cr = getDefaultTxCodingRate();
const uint8_t default_cr = original && _radio->profiles()
? _radio->profiles()->params(original->radio_profile).cr : getDefaultTxCodingRate();
retry->tx_cr = original != NULL && original->isRouteFlood()
&& original->getPathHashCount() == 0
? getDirectRetryCodingRateForAttempt(default_cr, retry_attempt)
@@ -189,7 +191,7 @@ static int queuedPacedOtaResponses(PacketManager* manager) {
// DATA/PROOF admission is credit-limited; false leaves the manager's response descriptor intact.
bool Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) {
Mesh* m = (Mesh*)ctx;
if (!m->isTempRadioActive()) return false;
if (!m->isAnyTempRadioActive()) return false;
if (isPacedOtaResponse(msg, len)
&& (queuedPacedOtaResponses(m->_mgr) >= OTA_EGRESS_QUEUE_CREDIT
|| m->_mgr->getFreeCount() <= OTA_EGRESS_MIN_FREE)) {
@@ -213,7 +215,7 @@ void Mesh::begin() {
_next_recent_advert_echo = 0;
_next_direct_retry_timeout = 0;
_next_flood_retry_timeout = 0;
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
_direct_retries[i].packet = NULL;
_direct_retries[i].trigger_packet = NULL;
_direct_retries[i].retry_started_at = 0;
@@ -238,7 +240,7 @@ void Mesh::begin() {
_direct_retries[i].has_message_replacement_key = false;
_direct_retries[i].active = false;
}
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
_flood_retries[i].packet = NULL;
_flood_retries[i].trigger_packet = NULL;
_flood_retries[i].retry_started_at = 0;
@@ -282,6 +284,33 @@ void Mesh::begin() {
}
void Mesh::loop() {
const auto* p = _radio->profiles();
if (p && (_retry_radio_generations[0] != p->generation[0]
|| _retry_radio_generations[1] != p->generation[1] || _retry_cross_mode != p->cross)) {
// A changed session is not a failed radio link. Retire its ownership
// without recording a failed final echo, and promptly return queued storage.
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; ++i) {
if (!_direct_retries[i].active) continue;
const auto* packet = _direct_retries[i].queued ? _direct_retries[i].packet : _direct_retries[i].trigger_packet;
if (_direct_retries[i].waiting_final_echo || (packet && !isPacketRadioCurrent(packet))) retireDirectRetrySlot(i);
}
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; ++i) {
if (!_flood_retries[i].active) continue;
const auto* packet = _flood_retries[i].queued ? _flood_retries[i].packet : _flood_retries[i].trigger_packet;
if ((_flood_retries[i].waiting_final_echo && _flood_retries[i].packet
&& !isPacketRadioCurrent(_flood_retries[i].packet))
|| (packet && !isPacketRadioCurrent(packet))) retireFloodRetrySlot(i);
}
for (int i = _mgr->getOutboundTotal() - 1; i >= 0; --i) {
if (isPacketRadioCurrent(_mgr->getOutboundByIdx(i))) continue;
auto* stale = _mgr->removeOutboundByIdx(i);
onSendFail(stale);
releasePacket(stale);
}
_retry_radio_generations[0] = p->generation[0];
_retry_radio_generations[1] = p->generation[1];
_retry_cross_mode = p->cross;
}
Dispatcher::loop();
serviceLoopMaintenance();
#if defined(ENABLE_OTA) && defined(ESP32_PLATFORM) && \
@@ -305,7 +334,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
if (auto* limiter = getFloodAdvertLimiter()) limiter->tick(_ms->getMillis());
if (_waiting_direct_retry_count != 0
&& millisHasNowPassed(_next_direct_retry_timeout)) {
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (!_direct_retries[i].active || !_direct_retries[i].waiting_final_echo) {
continue;
}
@@ -329,7 +358,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
if (_waiting_flood_retry_count != 0
&& millisHasNowPassed(_next_flood_retry_timeout)) {
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (!_flood_retries[i].active || !_flood_retries[i].waiting_final_echo) {
continue;
}
@@ -389,7 +418,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
}
}
#endif
const bool ota_active = isTempRadioActive();
const bool ota_active = isAnyTempRadioActive();
if (!ota_active) {
if (_ota_temp_was_active) {
ota::ota_ctx().manager.clearPendingEgress();
@@ -474,11 +503,11 @@ bool Mesh::allowPacketTransmit(const Packet* packet) const {
// This is an egress guard, separate from the receive-side TempRadio check below. A relay can queue an OTA
// packet just before its temporary window closes; never let that delayed packet leak onto the normal channel.
if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_OTA
&& !isTempRadioActive()) {
&& !isAnyTempRadioActive()) {
return false;
}
if (packet != NULL && _active_flood_retry_count != 0) {
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (!_flood_retries[i].active || !_flood_retries[i].queued
|| _flood_retries[i].packet != packet) {
continue;
@@ -494,7 +523,7 @@ bool Mesh::allowPacketForward(const mesh::Packet* packet) {
return false; // by default, Transport NOT enabled
}
uint32_t Mesh::getRetransmitDelay(const mesh::Packet* packet) {
uint32_t t = (_radio->getEstAirtimeFor(packet->getRawLength()) * 52 / 50) / 2;
uint32_t t = (_radio->getProfileAirtime(packet->radio_profile, packet->getRawLength(), packet->tx_cr) * 52 / 50) / 2;
return _rng->nextInt(0, 5)*t;
}
@@ -577,7 +606,7 @@ uint32_t Mesh::getOtaRetransmitDelay(const mesh::Packet* packet) {
return getRetransmitDelay(packet);
}
decayOtaRelayBackoff();
uint32_t airtime = _radio->getEstAirtimeFor(packet->getRawLength());
uint32_t airtime = _radio->getProfileAirtime(packet->radio_profile, packet->getRawLength(), packet->tx_cr);
if (airtime == 0) return 0;
// Quarter-airtime units by pressure level: 0.25-0.5, 0.5-1.0, 0.75-2.0, 1.0-3.0.
// The maximum uses floor division, so level 3 can never exceed exactly three measured airtimes.
@@ -592,14 +621,14 @@ uint32_t Mesh::getOtaRetransmitDelay(const mesh::Packet* packet) {
int Mesh::calcRxDelayForPacket(const Packet* packet, float score, uint32_t air_time) {
if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_OTA
&& isTempRadioActive()) {
&& isAnyTempRadioActive()) {
return 0;
}
return Dispatcher::calcRxDelayForPacket(packet, score, air_time);
}
uint32_t Mesh::getCADFailRetryDelay() const {
if (!isTempRadioActive()) return _rng->nextInt(1, 4) * 120;
if (!isAnyTempRadioActive()) return _rng->nextInt(1, 4) * 120;
uint32_t airtime = _radio->getEstAirtimeFor(MAX_TRANS_UNIT);
uint32_t retry = airtime / 4;
if (retry < 5) retry = 5;
@@ -634,7 +663,7 @@ uint32_t Mesh::getDirectRetryPacketAirtimeDelay(const Packet* packet) const {
return 0;
}
return _radio->getEstAirtimeFor(packet->getRawLength()) * (uint32_t)getDirectRetryPacketAirtimeFactor(packet);
return _radio->getProfileAirtime(packet->radio_profile, packet->getRawLength(), packet->tx_cr) * (uint32_t)getDirectRetryPacketAirtimeFactor(packet);
}
uint32_t Mesh::getDirectRetryEchoDelay(const Packet* packet) const {
return 200 + getDirectRetryPacketAirtimeDelay(packet);
@@ -734,8 +763,8 @@ uint32_t Mesh::getFloodRetryAttemptDelay(const Packet* packet, uint8_t attempt_i
return _radio->getEstAirtimeFor(MAX_TRANS_UNIT);
}
uint32_t max_packet_airtime = _radio->getEstAirtimeFor(MAX_TRANS_UNIT);
uint32_t packet_airtime = _radio->getEstAirtimeFor(packet->getRawLength());
uint32_t max_packet_airtime = _radio->getProfileAirtime(packet->radio_profile, MAX_TRANS_UNIT);
uint32_t packet_airtime = _radio->getProfileAirtime(packet->radio_profile, packet->getRawLength(), packet->tx_cr);
uint32_t jitter_percent = _rng->nextInt(0, 201);
uint32_t jitter = (packet_airtime * jitter_percent) / 100UL;
uint32_t delay = max_packet_airtime + (20UL * packet_airtime) + jitter;
@@ -754,6 +783,24 @@ void Mesh::onSendComplete(Packet* packet) {
armFloodRetryOnSendComplete(packet);
}
void Mesh::onRadioProfileCopyQueued(Packet* packet, const Packet* original, uint8_t priority) {
if (packet->isRouteFlood()) {
maybeScheduleFloodRetry(packet, priority);
replaceQueuedSelfAdvertRetries(packet);
} else {
bool final_hop = false;
if (original) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; ++i) {
if (_direct_retries[i].active && _direct_retries[i].trigger_packet == original) {
final_hop = _direct_retries[i].final_hop_retry;
break;
}
}
}
maybeScheduleDirectRetry(packet, priority, final_hop);
}
}
void Mesh::onTracePacketQueuedForSend(Packet* packet) {
replaceQueuedTraceRetries(packet);
}
@@ -1130,7 +1177,10 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
case PAYLOAD_TYPE_OTA: {
// OTA is invisible outside an actually-running temporary-radio window. In particular, do not add it
// to the seen table: a copy heard on the normal channel must not suppress one received after temp radio starts.
if (!isTempRadioActive()) break;
if (!isAnyTempRadioActive() || (_radio->profiles()
&& !(_radio->profiles()->primary_temporary && pkt->radio_profile == 0)
&& !(_radio->profiles()->secondary_temporary && pkt->radio_profile == 1)
&& _radio->profiles()->cross != RadioCrossMode::On)) break;
observeOtaRequestPressure(pkt);
uint8_t n = pkt->getPathHashCount(); // hops travelled to reach us (flood path-hash count)
#if defined(ENABLE_OTA)
@@ -1323,7 +1373,7 @@ void Mesh::clearDirectRetrySlot(int idx) {
}
void Mesh::retireDirectRetrySlot(int idx) {
if (idx < 0 || idx >= MAX_DIRECT_RETRY_SLOTS || !_direct_retries[idx].active) {
if (idx < 0 || idx >= TOTAL_DIRECT_RETRY_SLOTS || !_direct_retries[idx].active) {
return;
}
@@ -1346,7 +1396,7 @@ void Mesh::rebuildNextDirectRetryTimeout() {
bool found = false;
uint32_t shortest_delay = 0;
const uint32_t now = _ms->getMillis();
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (!_direct_retries[i].active || !_direct_retries[i].waiting_final_echo) continue;
int32_t signed_delay = (int32_t)(_direct_retries[i].retry_at - now);
uint32_t delay = signed_delay > 0 ? (uint32_t)signed_delay : 0;
@@ -1361,7 +1411,7 @@ void Mesh::rebuildNextDirectRetryTimeout() {
bool Mesh::usePassiveChannelCheck(const Packet* packet) const {
if (_active_direct_retry_count != 0) {
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (_direct_retries[i].active && _direct_retries[i].queued
&& _direct_retries[i].packet == packet) {
return true;
@@ -1375,7 +1425,7 @@ bool Mesh::usePassiveChannelCheck(const Packet* packet) const {
// has trigger_packet set but queued=false, so ordinary flood forwarding
// continues to use the normal CAD check.
if (_active_flood_retry_count != 0) {
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (_flood_retries[i].active && _flood_retries[i].queued
&& _flood_retries[i].packet == packet) {
return true;
@@ -1437,7 +1487,8 @@ void Mesh::replaceQueuedTraceRetries(const Packet* packet) {
int replacement_slot = -1;
bool found_prior = false;
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (i / MAX_DIRECT_RETRY_SLOTS != packet->radio_profile) continue;
if (!_direct_retries[i].active || _direct_retries[i].payload_type != PAYLOAD_TYPE_TRACE
|| memcmp(replacement_key, _direct_retries[i].trace_replacement_key, MAX_HASH_SIZE) != 0) {
continue;
@@ -1451,7 +1502,8 @@ void Mesh::replaceQueuedTraceRetries(const Packet* packet) {
if (!found_prior) return;
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (i / MAX_DIRECT_RETRY_SLOTS != packet->radio_profile) continue;
if (i == replacement_slot || !_direct_retries[i].active
|| _direct_retries[i].payload_type != PAYLOAD_TYPE_TRACE
|| memcmp(replacement_key, _direct_retries[i].trace_replacement_key, MAX_HASH_SIZE) != 0) {
@@ -1475,7 +1527,8 @@ bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) {
calculateDirectRetryKey(packet, recv_key);
bool cleared = false;
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (i / MAX_DIRECT_RETRY_SLOTS != packet->radio_profile) continue;
if (!_direct_retries[i].active || memcmp(recv_key, _direct_retries[i].retry_key, MAX_HASH_SIZE) != 0) {
continue;
}
@@ -1535,7 +1588,7 @@ bool Mesh::cancelDirectRetryOnEcho(const Packet* packet) {
void Mesh::armDirectRetryOnSendComplete(const Packet* packet) {
if (_active_direct_retry_count == 0) return;
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (!_direct_retries[i].active) {
continue;
}
@@ -1657,7 +1710,7 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) {
void Mesh::clearPendingDirectRetryOnSendFail(const Packet* packet) {
if (_active_direct_retry_count == 0) return;
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (!_direct_retries[i].active) {
continue;
}
@@ -1825,7 +1878,8 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool
calculateDirectRetryKey(packet, retry_key);
uint8_t trace_replacement_key[MAX_HASH_SIZE] = { 0 };
bool has_trace_replacement_key = calculateTraceReplacementKey(packet, trace_replacement_key);
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (i / MAX_DIRECT_RETRY_SLOTS != packet->radio_profile) continue;
if (_direct_retries[i].active
&& memcmp(retry_key, _direct_retries[i].retry_key, MAX_HASH_SIZE) == 0) {
return; // the normal direct send still happens, but only one retry sequence owns this logical packet
@@ -1833,7 +1887,8 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool
}
int slot_idx = -1;
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (i / MAX_DIRECT_RETRY_SLOTS != packet->radio_profile) continue;
if (!_direct_retries[i].active) {
slot_idx = i;
break;
@@ -1841,7 +1896,8 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool
}
if (slot_idx < 0) {
if (has_trace_replacement_key) {
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (i / MAX_DIRECT_RETRY_SLOTS != packet->radio_profile) continue;
if (_direct_retries[i].active && _direct_retries[i].payload_type == PAYLOAD_TYPE_TRACE
&& memcmp(trace_replacement_key, _direct_retries[i].trace_replacement_key,
MAX_HASH_SIZE) == 0) {
@@ -1893,7 +1949,7 @@ void Mesh::clearFloodRetrySlot(int idx) {
if (_flood_retries[idx].waiting_final_echo && _waiting_flood_retry_count > 0) {
_waiting_flood_retry_count--;
}
onFloodRetrySlotReleased(_flood_retries[idx].retry_key);
onFloodRetrySlotReleased(_flood_retries[idx].retry_key, idx / MAX_FLOOD_RETRY_SLOTS);
}
if (_flood_retries[idx].waiting_final_echo && _flood_retries[idx].packet != NULL) {
releasePacket(_flood_retries[idx].packet);
@@ -1919,7 +1975,7 @@ void Mesh::clearFloodRetrySlot(int idx) {
}
void Mesh::retireFloodRetrySlot(int idx) {
if (idx < 0 || idx >= MAX_FLOOD_RETRY_SLOTS || !_flood_retries[idx].active) {
if (idx < 0 || idx >= TOTAL_FLOOD_RETRY_SLOTS || !_flood_retries[idx].active) {
return;
}
@@ -1945,7 +2001,8 @@ void Mesh::replaceQueuedSelfAdvertRetries(const Packet* packet) {
int replacement_slot = -1;
bool found_prior = false;
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (i / MAX_FLOOD_RETRY_SLOTS != packet->radio_profile) continue;
if (!_flood_retries[i].active
|| !_flood_retries[i].self_advert) {
continue;
@@ -1959,7 +2016,8 @@ void Mesh::replaceQueuedSelfAdvertRetries(const Packet* packet) {
if (!found_prior) return;
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (i / MAX_FLOOD_RETRY_SLOTS != packet->radio_profile) continue;
if (i == replacement_slot || !_flood_retries[i].active
|| !_flood_retries[i].self_advert) {
continue;
@@ -1979,7 +2037,7 @@ void Mesh::rebuildNextFloodRetryTimeout() {
bool found = false;
uint32_t shortest_delay = 0;
const uint32_t now = _ms->getMillis();
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (!_flood_retries[i].active || !_flood_retries[i].waiting_final_echo) continue;
int32_t signed_delay = (int32_t)(_flood_retries[i].retry_at - now);
uint32_t delay = signed_delay > 0 ? (uint32_t)signed_delay : 0;
@@ -1995,7 +2053,7 @@ void Mesh::rebuildNextFloodRetryTimeout() {
void Mesh::cancelAllDirectRetries() {
if (_active_direct_retry_count == 0) return;
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (!_direct_retries[i].active) continue;
retireDirectRetrySlot(i);
}
@@ -2004,7 +2062,7 @@ void Mesh::cancelAllDirectRetries() {
void Mesh::cancelAllFloodRetries() {
if (_active_flood_retry_count == 0) return;
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (!_flood_retries[i].active) continue;
retireFloodRetrySlot(i);
}
@@ -2018,7 +2076,7 @@ bool Mesh::cancelActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) {
uint8_t key[MAX_HASH_SIZE];
memcpy(key, retry_key, sizeof(key)); // tolerate callers passing storage owned by a retry slot
bool cancelled = false;
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (!_direct_retries[i].active
|| memcmp(key, _direct_retries[i].retry_key, MAX_HASH_SIZE) != 0) {
continue;
@@ -2028,7 +2086,7 @@ bool Mesh::cancelActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) {
cancelled = true;
}
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (!_flood_retries[i].active
|| memcmp(key, _flood_retries[i].retry_key, MAX_HASH_SIZE) != 0) {
continue;
@@ -2079,7 +2137,8 @@ void Mesh::replaceActiveMessageRetries(
int replacement_flood_slot = -1;
bool found_prior = false;
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (i / MAX_DIRECT_RETRY_SLOTS != replacement_packet->radio_profile) continue;
if (!_direct_retries[i].active) continue;
if (_direct_retries[i].trigger_packet == replacement_packet) {
replacement_direct_slot = i;
@@ -2092,7 +2151,8 @@ void Mesh::replaceActiveMessageRetries(
found_prior = true;
}
}
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (i / MAX_FLOOD_RETRY_SLOTS != replacement_packet->radio_profile) continue;
if (!_flood_retries[i].active) continue;
if (_flood_retries[i].trigger_packet == replacement_packet) {
replacement_flood_slot = i;
@@ -2107,7 +2167,8 @@ void Mesh::replaceActiveMessageRetries(
}
if (found_prior) {
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (i / MAX_DIRECT_RETRY_SLOTS != replacement_packet->radio_profile) continue;
if (i == replacement_direct_slot || !_direct_retries[i].active
|| !_direct_retries[i].has_message_replacement_key
|| _direct_retries[i].message_timestamp == message_timestamp
@@ -2117,7 +2178,8 @@ void Mesh::replaceActiveMessageRetries(
}
retireDirectRetrySlot(i);
}
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (i / MAX_FLOOD_RETRY_SLOTS != replacement_packet->radio_profile) continue;
if (i == replacement_flood_slot || !_flood_retries[i].active
|| !_flood_retries[i].has_message_replacement_key
|| _flood_retries[i].message_timestamp == message_timestamp
@@ -2143,7 +2205,8 @@ void Mesh::replaceActiveMessageRetries(
// new packet. If no slot exists, the role's ordinary retry policy declined
// this message and there is no retry state to tag.
if (replacement_direct_slot < 0 && replacement_flood_slot < 0) {
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (i / MAX_DIRECT_RETRY_SLOTS != replacement_packet->radio_profile) continue;
if (_direct_retries[i].active
&& _direct_retries[i].trigger_packet == replacement_packet) {
replacement_direct_slot = i;
@@ -2151,7 +2214,8 @@ void Mesh::replaceActiveMessageRetries(
}
}
if (replacement_direct_slot < 0) {
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (i / MAX_FLOOD_RETRY_SLOTS != replacement_packet->radio_profile) continue;
if (_flood_retries[i].active
&& _flood_retries[i].trigger_packet == replacement_packet) {
replacement_flood_slot = i;
@@ -2179,13 +2243,13 @@ bool Mesh::hasActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) const {
return false;
}
for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_DIRECT_RETRY_SLOTS; i++) {
if (_direct_retries[i].active
&& memcmp(retry_key, _direct_retries[i].retry_key, MAX_HASH_SIZE) == 0) {
return true;
}
}
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (_flood_retries[i].active
&& memcmp(retry_key, _flood_retries[i].retry_key, MAX_HASH_SIZE) == 0) {
return true;
@@ -2228,7 +2292,9 @@ void Mesh::watchForwardedAdvertEcho(const Packet* packet) {
int slot_idx = -1;
for (int i = 0; i < MAX_RECENT_ADVERT_ECHOS; i++) {
RecentAdvertEchoEntry& entry = _recent_advert_echoes[i];
if (entry.valid && memcmp(entry.packet_hash, packet_hash, MAX_HASH_SIZE) == 0) {
if (entry.valid && entry.radio_profile == packet->radio_profile
&& entry.radio_generation == packet->radio_generation
&& memcmp(entry.packet_hash, packet_hash, MAX_HASH_SIZE) == 0) {
if (entry.confirmed) {
return;
}
@@ -2254,6 +2320,8 @@ void Mesh::watchForwardedAdvertEcho(const Packet* packet) {
entry.advert_timestamp = advert_timestamp;
entry.watch_started_at = now_millis;
entry.progress_marker = packet->getPathHashCount();
entry.radio_profile = packet->radio_profile;
entry.radio_generation = packet->radio_generation;
entry.confirmed = false;
entry.valid = true;
}
@@ -2273,7 +2341,8 @@ void Mesh::observeForwardedAdvertEcho(const Packet* packet) {
uint32_t now_millis = _ms->getMillis();
for (int i = 0; i < MAX_RECENT_ADVERT_ECHOS; i++) {
RecentAdvertEchoEntry& entry = _recent_advert_echoes[i];
if (!entry.valid || entry.confirmed
if (!entry.valid || entry.confirmed || entry.radio_profile != packet->radio_profile
|| entry.radio_generation != packet->radio_generation
|| memcmp(entry.packet_hash, packet_hash, MAX_HASH_SIZE) != 0) {
continue;
}
@@ -2306,6 +2375,8 @@ bool Mesh::shouldSuppressEchoedAdvertForward(const Packet* packet) const {
for (int i = 0; i < MAX_RECENT_ADVERT_ECHOS; i++) {
const RecentAdvertEchoEntry& entry = _recent_advert_echoes[i];
if (entry.valid && entry.confirmed && entry.advert_timestamp == advert_timestamp
&& entry.radio_profile == packet->radio_profile
&& entry.radio_generation == packet->radio_generation
&& memcmp(entry.packet_hash, packet_hash, MAX_HASH_SIZE) == 0) {
return true;
}
@@ -2320,7 +2391,8 @@ bool Mesh::cancelFloodRetryOnEcho(const Packet* packet) {
packet->calculatePacketHash(recv_key);
bool cleared = false;
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (i / MAX_FLOOD_RETRY_SLOTS != packet->radio_profile) continue;
if (!_flood_retries[i].active || memcmp(recv_key, _flood_retries[i].retry_key, MAX_HASH_SIZE) != 0) {
continue;
}
@@ -2346,7 +2418,7 @@ bool Mesh::cancelFloodRetryOnEcho(const Packet* packet) {
void Mesh::armFloodRetryOnSendComplete(const Packet* packet) {
if (_active_flood_retry_count == 0) return;
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (!_flood_retries[i].active) {
continue;
}
@@ -2449,7 +2521,7 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) {
void Mesh::clearPendingFloodRetryOnSendFail(const Packet* packet) {
if (_active_flood_retry_count == 0) return;
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (!_flood_retries[i].active) {
continue;
}
@@ -2484,7 +2556,8 @@ void Mesh::maybeScheduleFloodRetry(const Packet* packet, uint8_t priority) {
uint8_t retry_key[MAX_HASH_SIZE];
packet->calculatePacketHash(retry_key);
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (i / MAX_FLOOD_RETRY_SLOTS != packet->radio_profile) continue;
if (_flood_retries[i].active
&& memcmp(retry_key, _flood_retries[i].retry_key, MAX_HASH_SIZE) == 0) {
return; // the normal flood still sends, but only one retry sequence owns this logical packet
@@ -2492,7 +2565,8 @@ void Mesh::maybeScheduleFloodRetry(const Packet* packet, uint8_t priority) {
}
int slot_idx = -1;
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; i++) {
if (i / MAX_FLOOD_RETRY_SLOTS != packet->radio_profile) continue;
if (!_flood_retries[i].active) {
slot_idx = i;
break;
+19 -3
View File
@@ -42,6 +42,9 @@ namespace mesh {
#define MAX_FLOOD_RETRY_SLOTS 6
#endif
#define TOTAL_DIRECT_RETRY_SLOTS (2 * MAX_DIRECT_RETRY_SLOTS)
#define TOTAL_FLOOD_RETRY_SLOTS (2 * MAX_FLOOD_RETRY_SLOTS)
#ifndef MAX_RECENT_ADVERT_ECHOS
#define MAX_RECENT_ADVERT_ECHOS 8
#endif
@@ -126,6 +129,8 @@ class Mesh : public Dispatcher {
uint8_t progress_marker;
bool confirmed;
bool valid;
uint8_t radio_profile;
uint32_t radio_generation;
};
static const uint8_t OTA_REQUEST_TRACK_SLOTS = 4;
@@ -139,8 +144,8 @@ class Mesh : public Dispatcher {
RTCClock* _rtc;
RNG* _rng;
MeshTables* _tables;
DirectRetryEntry _direct_retries[MAX_DIRECT_RETRY_SLOTS];
FloodRetryEntry _flood_retries[MAX_FLOOD_RETRY_SLOTS];
DirectRetryEntry _direct_retries[TOTAL_DIRECT_RETRY_SLOTS];
FloodRetryEntry _flood_retries[TOTAL_FLOOD_RETRY_SLOTS];
RecentAdvertEchoEntry _recent_advert_echoes[MAX_RECENT_ADVERT_ECHOS];
OtaRequestTrackEntry _ota_request_track[OTA_REQUEST_TRACK_SLOTS] = {};
uint32_t _ota_relay_decay_at = 0;
@@ -152,6 +157,8 @@ class Mesh : public Dispatcher {
uint8_t _next_recent_advert_echo;
unsigned long _next_direct_retry_timeout;
unsigned long _next_flood_retry_timeout;
uint32_t _retry_radio_generations[2] = {};
RadioCrossMode _retry_cross_mode = RadioCrossMode::Auto;
void removePathPrefix(Packet* packet, uint8_t prefix_count);
void routeDirectRecvAcks(Packet* packet, uint32_t delay_millis);
@@ -201,6 +208,7 @@ protected:
DispatcherAction onRecvPacket(Packet* pkt) override;
void onTracePacketQueuedForSend(Packet* packet) override;
void onSendComplete(Packet* packet) override;
void onRadioProfileCopyQueued(Packet* packet, const Packet* original, uint8_t priority) override;
void onSendFail(Packet* packet) override;
bool allowPacketTransmit(const Packet* packet) const override;
bool usePassiveChannelCheck(const Packet* packet) const override;
@@ -387,7 +395,7 @@ protected:
/**
* \brief Called exactly once whenever an active flood-retry slot is released.
*/
virtual void onFloodRetrySlotReleased(const uint8_t* retry_key) { }
virtual void onFloodRetrySlotReleased(const uint8_t* retry_key, uint8_t radio_profile) { }
/**
* \returns number of extra (Direct) ACK transmissions wanted.
@@ -568,6 +576,14 @@ public:
RNG* getRNG() const { return _rng; }
RTCClock* getRTCClock() const { return _rtc; }
bool isAnyTempRadioActive() const {
return isTempRadioActive() || (_radio->profiles() && _radio->profiles()->secondary_temporary);
}
bool isPacketOnTempRadio(const Packet* packet) const {
const auto* p = _radio->profiles();
if (!p || !packet) return isTempRadioActive();
return packet->radio_profile == 1 ? p->secondary_temporary : p->primary_temporary;
}
Packet* createAdvert(const LocalIdentity& id, const uint8_t* app_data=NULL, size_t app_data_len=0);
Packet* createDatagram(uint8_t type, const Identity& dest, const uint8_t* secret, const uint8_t* data, size_t len);
+6
View File
@@ -12,6 +12,9 @@ Packet::Packet() {
_snr = 0;
tx_cr = 0;
flood_retry_policy = FLOOD_RETRY_POLICY_DEFAULT;
radio_generation = radio_origin_generation = 0;
radio_profile = radio_origin = 0;
radio_bound = false; radio_local = true;
}
bool Packet::isValidPathLen(uint8_t path_len) {
@@ -74,6 +77,9 @@ bool Packet::readFrom(const uint8_t src[], uint8_t len) {
_snr = 0;
tx_cr = 0;
flood_retry_policy = FLOOD_RETRY_POLICY_DEFAULT;
radio_generation = radio_origin_generation = 0;
radio_profile = radio_origin = 0;
radio_bound = false; radio_local = true;
header = src[i++];
if (getPayloadVer() > PAYLOAD_VER_1) return false;
+6
View File
@@ -59,6 +59,12 @@ public:
int8_t _snr;
uint8_t tx_cr; // volatile local-only TX coding-rate override; not serialized
uint8_t flood_retry_policy; // volatile receive-policy result; not serialized
uint32_t radio_generation = 0;
uint32_t radio_origin_generation = 0;
uint8_t radio_profile = 0;
uint8_t radio_origin = 0;
bool radio_bound = false; // retries retain one profile and never fan out again
bool radio_local = true;
/**
* \brief calculate the hash of payload + type
+174
View File
@@ -0,0 +1,174 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <math.h>
namespace mesh {
enum class RadioProfileMode : uint8_t { Off = 0, Rx = 1, RxTx = 2 };
enum class RadioCrossMode : uint8_t { Auto = 0, On = 1, Off = 2 };
// These are modulation profiles of ONE transceiver, never additional radios.
struct RadioProfileParams {
float freq = 0;
float bw = 0;
uint16_t preamble = 0; // 0 = automatic, otherwise symbols
uint8_t sf = 0;
uint8_t cr = 0;
bool operator==(const RadioProfileParams& other) const {
return freq == other.freq && bw == other.bw && sf == other.sf
&& cr == other.cr && preamble == other.preamble;
}
bool operator!=(const RadioProfileParams& other) const { return !(*this == other); }
};
struct RadioProfileConfig {
RadioProfileParams params;
RadioProfileMode mode = RadioProfileMode::Off;
};
// Portable timing/routing model, also used by the native tests. Hardware access
// remains in RadioLibWrapper; timers and persisted settings remain in the CLI.
class RadioProfiles {
public:
// Normal RX uses a microsecond window, so fractional symbols are supported.
static constexpr double SlowListenSymbols = 4.8;
static constexpr uint8_t MinFastListenSymbols = 4;
static constexpr uint8_t AcquisitionSymbols = 8;
static constexpr uint16_t MaxPreamble = 65528;
// The mixed-profile bench tests measured ~1.2 ms/visit with fast RX. Normal
// drivers may wake a TCXO; production V4 measured up to 4.4 ms. Budget
// 6 ms per change plus 4 ms of loop jitter. Packet receptions can extend
// a visit; these budgets describe an otherwise idle scan.
static constexpr uint32_t SwitchBudgetUs = 6000;
static constexpr uint32_t LoopBudgetUs = 4000;
RadioProfileParams primary;
RadioProfileConfig secondary;
bool primary_temporary = false;
bool secondary_temporary = false;
RadioCrossMode cross = RadioCrossMode::Auto;
uint32_t generation[2] = {1, 1};
uint32_t switches = 0;
uint32_t rx_packets[2] = {};
uint32_t tx_packets[2] = {};
uint32_t switch_failures = 0;
uint32_t longest_switch_us = 0;
bool enabled() const { return secondary.mode != RadioProfileMode::Off; }
bool canTransmit(uint8_t profile) const {
return profile == 0 || (profile == 1 && secondary.mode == RadioProfileMode::RxTx);
}
bool canCross() const {
return cross == RadioCrossMode::On || (cross == RadioCrossMode::Auto
&& primary_temporary == secondary_temporary);
}
uint8_t transmitMask(uint8_t origin) const {
if (origin > 1) origin = 0;
uint8_t result = canTransmit(origin) ? (1U << origin) : 0;
if (enabled() && canCross() && canTransmit(origin ^ 1)) result |= 1U << (origin ^ 1);
return result;
}
const RadioProfileParams& params(uint8_t profile) const {
return profile == 1 ? secondary.params : primary;
}
static bool valid(const RadioProfileParams& p) {
if (!isfinite(p.freq) || !isfinite(p.bw) || p.freq < 150 || p.freq > 2500
|| p.sf < 5 || p.sf > 12 || p.cr < 5 || p.cr > 8
|| (p.preamble != 0 && (p.preamble < 8 || p.preamble > MaxPreamble))) return false;
const float bandwidths[] = {7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f,
62.5f, 125, 250, 500, 1000, 812.5f, 1625};
for (float bw : bandwidths) if (fabsf(p.bw - bw) < 0.01f) return true;
return false;
}
static double symbolUs(const RadioProfileParams& p) {
return p.bw > 0 && p.sf <= 12 ? (double)(1U << p.sf) * 1000.0 / p.bw : 0;
}
static bool safePreamble(const RadioProfileParams& p, uint16_t symbols) {
const double symbol = symbolUs(p);
if (symbol <= 0) return false;
const int de = symbol >= 16000 ? 1 : 0;
const double payload = 8 + ceil((8.0 * 255 - 4 * p.sf
+ (p.sf <= 6 ? 20 : 28) + 16) / (4 * (p.sf - 2 * de))) * 8;
// RadioLib's microsecond airtime calculation multiplies by four before
// dividing. Reject settings that overflow that intermediate for a full
// packet, including a retry using coding rate 4/8.
return (symbols + (p.sf <= 6 ? 6.25 : 4.25) + payload) * symbol * 4 <= UINT32_MAX;
}
uint8_t slowerProfile() const {
return symbolUs(primary) >= symbolUs(secondary.params) ? 0 : 1;
}
static double roundPreamble(double symbols) {
return ceil((symbols < 32 ? 32 : symbols) / 8.0) * 8.0;
}
double automaticPreamble(uint8_t profile) const {
const double symbol = symbolUs(params(profile));
if (symbol <= 0) return 32;
// Allow two slow-channel visits per slow preamble: one short detection
// opportunity was insufficient in the production V4 test. The fast
// preamble covers the blind interval during the slow visit and two sets
// of acquisition symbols (the extra eight come from the hardware test).
const double overhead = 2.0 * SwitchBudgetUs + LoopBudgetUs;
if (profile == slowerProfile()) {
const double fast_visit = MinFastListenSymbols * symbolUs(params(profile ^ 1));
return roundPreamble(2.0 * (SlowListenSymbols * symbol + fast_visit + overhead) / symbol);
}
const double slow_visit = SlowListenSymbols * symbolUs(params(profile ^ 1));
double symbols = roundPreamble((slow_visit + overhead) / symbol + 2 * AcquisitionSymbols);
// Production V4 + XIAO testing lost SF8/500 packets at 72 and 80;
// 88 passed with the paired SF7/62.5 profile. Keep that measured floor
// even when the theoretical blind interval permits a shorter preamble.
const auto& other = params(profile ^ 1);
if (params(profile).sf == 8 && params(profile).bw == 500
&& other.sf == 7 && other.bw == 62.5f && symbols < 88) symbols = 88;
return symbols;
}
uint32_t listenUs(uint8_t profile, uint16_t slow_preamble = 0) const {
const uint8_t slow = slowerProfile();
const double minimum = symbolUs(params(profile))
* (profile == slow ? SlowListenSymbols : MinFastListenSymbols);
if (!enabled() || profile == slow) return (uint32_t)ceil(minimum);
if (!slow_preamble) slow_preamble = preamble(slow, 32);
const double available = (double(slow_preamble) / 2.0 - SlowListenSymbols)
* symbolUs(params(slow)) - 2.0 * SwitchBudgetUs - LoopBudgetUs;
return available > minimum ? (uint32_t)floor(available) : (uint32_t)ceil(minimum);
}
uint16_t preamble(uint8_t profile, uint16_t single_profile_default) const {
const auto& p = params(profile);
if (p.preamble) return p.preamble;
if (!enabled()) return single_profile_default;
double symbols = automaticPreamble(profile);
if (symbols < single_profile_default) symbols = single_profile_default;
if (symbols > MaxPreamble) symbols = MaxPreamble;
return (uint16_t)symbols;
}
bool automaticPreambleFits() const {
for (uint8_t i = 0; i < (enabled() ? 2 : 1); ++i) {
if (!valid(params(i)) || !safePreamble(params(i), preamble(i, 32))) return false;
if (!enabled()) continue;
if (!params(i).preamble && automaticPreamble(i) > MaxPreamble) return false;
}
if (enabled()) {
const uint8_t slow = slowerProfile();
const double available = (double(preamble(slow, 32)) / 2.0 - SlowListenSymbols)
* symbolUs(params(slow)) - 2.0 * SwitchBudgetUs - LoopBudgetUs;
if (available < MinFastListenSymbols * symbolUs(params(slow ^ 1))) return false;
}
return true;
}
void setPrimary(const RadioProfileParams& p, bool temporary) {
if (primary != p || primary_temporary != temporary) ++generation[0];
primary = p;
primary_temporary = temporary;
}
void setSecondary(const RadioProfileConfig& p, bool temporary) {
if (secondary.params != p.params || secondary.mode != p.mode
|| secondary_temporary != temporary) ++generation[1];
secondary = p;
secondary_temporary = temporary;
}
};
} // namespace mesh
+71 -15
View File
@@ -142,6 +142,7 @@ static bool isGpioConfig(const char* config) {
#endif
void CommonCLI::loop() {
_radio_profiles.loop();
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
_user_gpio.loop();
UserGpio::Completion completion;
@@ -732,6 +733,7 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) {
}
void CommonCLI::loadPrefs(FILESYSTEM* fs) {
_radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc);
const bool display_settings_loaded = mesh::ui::loadDisplayPowerSettings(fs, false);
(void)display_settings_loaded;
bool is_fresh_install = false;
@@ -2462,6 +2464,20 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
mesh::cli::normalizeCommandVerb(command);
if (_radio_profiles.handle(command, reply)) return;
if (strncmp(command, "set tempradio ", 14) == 0) {
handleCommand(sender_timestamp, command + 4, reply);
return;
}
if (!strcmp(command, "get tempradio") || !strcmp(command, "tempradio")) {
const auto* radio = _callbacks->getProfileRadio();
if (!radio || !radio->profiles() || !radio->profiles()->primary_temporary) strcpy(reply, "> off");
else {
const auto& p = radio->profiles()->primary;
snprintf(reply, 160, "> %.3f,%.3f,%u,%u,%u", p.freq, p.bw, p.sf, p.cr, radio->profilePreamble(0));
}
return;
}
if (mesh::ui::handleDisplayPowerCommand(command, reply, 160)) return;
// Observer-only top-level commands (ota check/update, tls.bundletest, alert test)
@@ -2601,15 +2617,18 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
strcpy(reply, "Error: unsupported");
}
} else if (memcmp(command, "tempradio ", 10) == 0) {
strcpy(tmp, &command[10]);
uint16_t preamble = 0;
if (!mesh::RadioProfileCLI::parseSuffix(command + 10, 5, tmp, sizeof(tmp), preamble)) {
strcpy(reply, "Error, use: tempradio f,bw,sf,cr,minutes[,preamble]"); return;
}
const char *parts[5];
int num = mesh::Utils::parseTextParts(tmp, parts, 5);
float freq = 0.0f;
float bw = 0.0f;
uint8_t sf = num > 2 ? atoi(parts[2]) : 0;
uint8_t cr = num > 3 ? atoi(parts[3]) : 0;
uint32_t sf = 0, cr = 0;
uint32_t temp_timeout_mins = 0;
if (num == 5
&& parseUint32Strict(parts[2], sf) && parseUint32Strict(parts[3], cr)
&& parseUint32Strict(parts[4], temp_timeout_mins)
&& temp_timeout_mins > 0
&& (uint64_t)getRTCClock()->getCurrentTime() + 2
@@ -2618,8 +2637,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
&& mesh::cli::parseDecimalStrict(parts[1], bw)
&& freq >= 150.0f && freq <= 2500.0f
&& sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8
&& isValidLoRaBandwidth(bw)) {
_callbacks->applyTempRadioParams(freq, bw, sf, cr, temp_timeout_mins);
&& isValidLoRaBandwidth(bw)
&& _radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
_callbacks->applyTempRadioParams(freq, bw, sf, cr, temp_timeout_mins, preamble);
char duration[64];
const uint32_t seconds = (uint32_t)temp_timeout_mins * 60UL;
mesh::RepeaterRadioTiming::formatDuration(duration, sizeof(duration), seconds);
@@ -2659,7 +2679,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
sprintf(reply, "%s", _board->getManufacturerName());
#if defined(ENABLE_OTA)
} else if (memcmp(command, "ota", 3) == 0 && (command[3] == 0 || command[3] == ' ')) {
if (!_callbacks->isTempRadioActive() && otaCommandNeedsTempRadio(command)) {
if (!_callbacks->isTempRadioActive() && !_radio_profiles.secondaryTemporary()
&& otaCommandNeedsTempRadio(command)) {
strcpy(reply, "LoRa OTA needs temp radio on every node. Run: tempradio 909.950,250,5,5,120");
} else {
mesh::ota::handle_ota_command(command, reply, *_board);
@@ -3118,6 +3139,19 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) {
void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) {
const char* config = &command[4];
// Infrastructure roles dispatch here rather than through CommonRadioPrefs.
if (strncmp(config, "path.hash.mode", 14) == 0
&& (config[14] == 0 || config[14] == ' ' || config[14] == '\t')) {
uint32_t mode = 0;
if (mesh::cli::parseUnsignedIntegerStrict(config + 14, mode) && mode <= 2) {
_prefs->path_hash_mode = static_cast<uint8_t>(mode);
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 0,1, or 2");
}
return;
}
#if ENV_INCLUDE_GPS == 1
if (strncmp(config, "gps ", 4) == 0) {
if (strcmp(config + 4, "on") != 0 && strcmp(config + 4, "off") != 0) {
@@ -3603,10 +3637,17 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
float bw = 0.0f;
uint8_t sf = 0;
uint8_t cr = 0;
if (mesh::cli::parseRadioTupleStrict(&config[6], freq, bw, sf, cr)
uint16_t preamble = 0;
char legacy[96];
if (mesh::RadioProfileCLI::parseSuffix(&config[6], 4, legacy, sizeof(legacy), preamble)
&& mesh::cli::parseRadioTupleStrict(legacy, freq, bw, sf, cr)
&& freq >= 150.0f && freq <= 2500.0f
&& sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8
&& isValidLoRaBandwidth(bw)) {
&& isValidLoRaBandwidth(bw)
&& _radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
if (!_radio_profiles.savePrimaryPreamble(preamble)) {
strcpy(reply, "Error: preamble could not be saved"); return;
}
_prefs->sf = sf;
_prefs->cr = cr;
_prefs->freq = freq;
@@ -3634,12 +3675,16 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
float freq, bw;
uint8_t sf, cr;
uint32_t start_time, end_time;
if (!parseScheduledRadioArgs(&config[8], false, freq, bw, sf, cr, start_time, end_time)) {
uint16_t preamble = 0;
char legacy[120];
if (!mesh::RadioProfileCLI::parseSuffix(&config[8], 5, legacy, sizeof(legacy), preamble)
|| !parseScheduledRadioArgs(legacy, false, freq, bw, sf, cr, start_time, end_time)) {
strcpy(reply, "Error, use: set radioat f,bw,sf,cr,start");
} else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)) {
} else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
strcpy(reply, "Error, invalid radio params");
} else {
_callbacks->addScheduledRadioParams(false, freq, bw, sf, cr, start_time, end_time, reply);
_callbacks->addScheduledRadioParams(false, freq, bw, sf, cr, start_time, end_time, reply, preamble);
if (strncmp(reply, "OK", 2) == 0) {
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
}
@@ -3648,12 +3693,16 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
float freq, bw;
uint8_t sf, cr;
uint32_t start_time, end_time;
if (!parseScheduledRadioArgs(&config[12], true, freq, bw, sf, cr, start_time, end_time)) {
uint16_t preamble = 0;
char legacy[120];
if (!mesh::RadioProfileCLI::parseSuffix(&config[12], 6, legacy, sizeof(legacy), preamble)
|| !parseScheduledRadioArgs(legacy, true, freq, bw, sf, cr, start_time, end_time)) {
strcpy(reply, "Error, use: set tempradioat f,bw,sf,cr,start,end");
} else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)) {
} else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
strcpy(reply, "Error, invalid radio params");
} else {
_callbacks->addScheduledRadioParams(true, freq, bw, sf, cr, start_time, end_time, reply);
_callbacks->addScheduledRadioParams(true, freq, bw, sf, cr, start_time, end_time, reply, preamble);
if (strncmp(reply, "OK", 2) == 0) {
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
}
@@ -4288,8 +4337,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
savePrefs();
sprintf(reply, "OK - reboot.interval set to %d", _prefs->reboot_interval);
}
#if defined(USE_LR2021)
} else if (strcmp(config, "extra.sf") == 0 || memcmp(config, "extra.sf ", 9) == 0) {
#if defined(USE_LR2021)
uint8_t sideDetSFs[mesh::lr2021::STORED_SIDE_DETECTOR_BYTES] = {};
uint8_t num = 0;
const char* value = config[8] == '\0' ? "" : &config[9];
@@ -4307,6 +4356,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(reply, "Invalid extra SF config");
}
}
#else
strcpy(reply, "Error: extra.sf requires an LR2021 radio");
#endif
} else {
sprintf(reply, "unknown config: %s", config);
@@ -4538,6 +4589,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(freq, StrHelper::ftoa(_prefs->freq));
strcpy(bw, StrHelper::ftoa3(_prefs->bw));
sprintf(reply, "> %s,%s,%d,%d", freq, bw, (uint32_t)_prefs->sf, (uint32_t)_prefs->cr);
_radio_profiles.appendSavedPreamble(reply, 160, _prefs->sf, _prefs->bw);
} else if (configKeyEquals(config, "rxdelay")) {
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->rx_delay_base));
} else if (configKeyEquals(config, "txdelay")) {
@@ -4738,6 +4790,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "> %d", (uint8_t)_prefs->reboot_interval);
}
} else if (strcmp(config, "extra.sf") == 0) {
#if defined(USE_LR2021)
char* tmp = reply;
for (int i = 0; i < 3 && _prefs->extra_sf[i] != 0; i++) {
tmp += sprintf(tmp, "%s%d", (i == 0) ? "" : ",", _prefs->extra_sf[i]);
@@ -4745,6 +4798,9 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
if (tmp == reply) {
strcpy(reply, "No extra SF configured");
}
#else
strcpy(reply, "Error: extra.sf requires an LR2021 radio");
#endif
} else {
mesh::cli::formatUnknownSetting(reply, 160, config);
}
+8 -2
View File
@@ -13,6 +13,7 @@
#include <helpers/CommonRadioPrefs.h>
#include <helpers/DynamicConfigSerializer.h>
#include <helpers/RepeaterRadioTiming.h>
#include <helpers/RadioProfileCLI.h>
#ifndef DEFAULT_CAD_ENABLED
#define DEFAULT_CAD_ENABLED 0
@@ -447,6 +448,7 @@ struct LegacyObserverTail {
class CommonCLICallbacks {
public:
virtual mesh::Radio* getProfileRadio() { return nullptr; }
virtual mesh::FloodAdvertLimiter* getFloodAdvertLimiter() { return nullptr; }
// Ordinary CommonCLI setters mutate NodePrefs and therefore save only the
// common image. Observer setters explicitly request Observer; only
@@ -511,7 +513,7 @@ public:
virtual mesh::LocalIdentity& getSelfId() = 0;
virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0;
virtual void clearStats() = 0;
virtual void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) = 0;
virtual void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble = 0) = 0;
virtual uint32_t getTempRadioDurationSeconds() const { return 0; }
virtual void appendTempRadioTimingNote(char* reply, size_t size, uint32_t seconds) const { }
// Cancel pending/active temporary-radio windows and restore the saved tuple
@@ -521,7 +523,8 @@ public:
virtual bool isTempRadioActive() const { return false; }
#endif
virtual void addScheduledRadioParams(bool temporary, float freq, float bw, uint8_t sf, uint8_t cr,
uint32_t start_time, uint32_t end_time, char* reply) {
uint32_t start_time, uint32_t end_time, char* reply, uint16_t preamble = 0) {
(void)preamble;
(void)temporary;
(void)freq;
(void)bw;
@@ -744,6 +747,7 @@ class CommonCLI {
bool _observer_save_succeeded = false;
#endif
bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load
mesh::RadioProfileCLI _radio_profiles;
mesh::RTCClock* getRTCClock() { return _rtc; }
void savePrefs(
@@ -780,6 +784,8 @@ class CommonCLI {
bool handleObserverCommand(uint32_t sender_timestamp, char* command, char* reply);
public:
mesh::RadioProfileCLI& radioProfiles() { return _radio_profiles; }
const mesh::RadioProfileCLI& radioProfiles() const { return _radio_profiles; }
static bool calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble,
uint32_t* rx_us, uint32_t* sleep_us);
static bool recalculateRxPowerSavingFromLevel(NodePrefs* prefs);
+8 -1
View File
@@ -18,6 +18,8 @@ struct DeferredCliCommand {
uint32_t sender_timestamp;
uint32_t request_id;
uint8_t path_hash_size;
uint8_t radio_profile = 0;
uint32_t radio_generation = 0;
uint8_t secret[PUB_KEY_SIZE];
char command[MAX_PACKET_PAYLOAD + 1];
@@ -31,7 +33,8 @@ struct DeferredCliCommand {
bool enqueue(int new_client_index, uint32_t new_sender_timestamp,
uint8_t new_path_hash_size, const uint8_t* new_secret,
const char* new_command, size_t command_len,
uint32_t new_request_id = 0) {
uint32_t new_request_id = 0, uint8_t new_radio_profile = 0,
uint32_t new_radio_generation = 0) {
if (pending || new_secret == NULL || new_command == NULL
|| command_len >= sizeof(command)) {
return false;
@@ -42,6 +45,8 @@ struct DeferredCliCommand {
request_id = new_request_id != 0
? new_request_id : new_sender_timestamp;
path_hash_size = new_path_hash_size;
radio_profile = new_radio_profile;
radio_generation = new_radio_generation;
memcpy(secret, new_secret, sizeof(secret));
memcpy(command, new_command, command_len);
command[command_len] = 0;
@@ -65,6 +70,8 @@ struct DeferredCliCommand {
sender_timestamp = 0;
request_id = 0;
path_hash_size = 1;
radio_profile = 0;
radio_generation = 0;
memset(secret, 0, sizeof(secret));
memset(command, 0, sizeof(command));
}
+421
View File
@@ -0,0 +1,421 @@
#include "RadioProfileCLI.h"
#include "RadioProfileCommandUtils.h"
#include "radiolib/RXPowerSaving.h"
#include <Arduino.h>
#include <stdio.h>
#include <string.h>
namespace mesh {
namespace {
constexpr size_t ImageSize = 24;
const char* const ImagePath = "/radio_profiles";
const char* const BackupPath = "/radio_profiles.bak";
const char* const TempPath = "/radio_profiles.tmp";
uint32_t checksum(const uint8_t* p, size_t n) {
uint32_t crc = 0xffffffffU;
while (n--) {
crc ^= *p++;
for (int i = 0; i < 8; ++i) crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320U : 0);
}
return crc;
}
const char* modeName(RadioProfileMode m) {
return m == RadioProfileMode::RxTx ? "rxtx" : m == RadioProfileMode::Rx ? "rx" : "off";
}
bool parseMode(const char* text, RadioProfileMode& mode) {
if (!strcmp(text, "rx")) mode = RadioProfileMode::Rx;
else if (!strcmp(text, "rxtx") || !strcmp(text, "rx&tx")) mode = RadioProfileMode::RxTx;
else return false;
return true;
}
unsigned split(char* s, char* parts[], unsigned maximum) {
unsigned count = 0;
do {
if (count == maximum || !*s) return 0;
parts[count++] = s;
char* comma = strchr(s, ',');
if (!comma) break;
*comma = 0;
s = comma + 1;
} while (true);
return count;
}
bool parsePreamble(const char* s, uint16_t& result) {
return cli::parseRadioPreamble(s, result);
}
}
bool RadioProfileCLI::readImage(const char* path, uint8_t* bytes, size_t size) {
#if defined(NRF52_PLATFORM)
File file(*fs_);
if (!file.open(path, FILE_O_READ)) return false;
#elif defined(STM32_PLATFORM)
File file = fs_->open(path, FILE_O_READ);
#else
File file = fs_->open(path, "r");
#endif
if (!file) return false;
bool ok = file.size() == size && file.read(bytes, size) == (int)size;
file.close();
if (!ok) return false;
uint32_t stored;
memcpy(&stored, bytes + size - 4, 4);
return bytes[0] == 'R' && bytes[1] == '2' && bytes[2] == 1
&& stored == checksum(bytes, size - 4);
}
bool RadioProfileCLI::writeImage(const char* path, const uint8_t* bytes, size_t size) {
if (fs_->exists(path)) fs_->remove(path);
#if defined(NRF52_PLATFORM)
File file(*fs_);
if (!file.open(path, FILE_O_WRITE)) return false;
#elif defined(STM32_PLATFORM)
File file = fs_->open(path, FILE_O_WRITE);
#else
File file = fs_->open(path, "w");
#endif
if (!file) return false;
bool ok = file.write(bytes, size) == size;
file.flush();
file.close();
uint8_t verify[ImageSize];
return ok && readImage(path, verify, size) && memcmp(bytes, verify, size) == 0;
}
bool RadioProfileCLI::save(const RadioProfileConfig& config, uint16_t preamble, RadioCrossMode cross) {
if (!fs_ || hold_) return false;
uint8_t image[ImageSize] = {'R', '2', 1, (uint8_t)config.mode};
memcpy(image + 4, &config.params.freq, 4);
memcpy(image + 8, &config.params.bw, 4);
image[12] = config.params.sf; image[13] = config.params.cr;
memcpy(image + 14, &config.params.preamble, 2);
memcpy(image + 16, &preamble, 2);
image[18] = (uint8_t)cross;
const uint32_t crc = checksum(image, ImageSize - 4);
memcpy(image + ImageSize - 4, &crc, 4);
if (!writeImage(TempPath, image, sizeof(image))) return false;
if (fs_->exists(ImagePath)) {
if (fs_->exists(BackupPath)) fs_->remove(BackupPath);
if (!fs_->rename(ImagePath, BackupPath)) return false;
}
if (!fs_->rename(TempPath, ImagePath)) {
if (fs_->exists(BackupPath)) fs_->rename(BackupPath, ImagePath);
return false;
}
if (fs_->exists(BackupPath)) fs_->remove(BackupPath);
return true;
}
void RadioProfileCLI::begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc) {
fs_ = fs; radio_ = radio; rtc_ = rtc; last_ms_ = millis();
if (!fs_ || !radio_ || !radio_->profiles()) return;
uint8_t bytes[ImageSize];
bool loaded = readImage(ImagePath, bytes, sizeof(bytes));
if (!loaded && readImage(BackupPath, bytes, sizeof(bytes))) {
if (fs_->exists(ImagePath)) fs_->remove(ImagePath);
loaded = fs_->rename(BackupPath, ImagePath);
}
if (!loaded) {
hold_ = fs_->exists(ImagePath) || fs_->exists(BackupPath);
return;
}
saved_.mode = (RadioProfileMode)bytes[3];
memcpy(&saved_.params.freq, bytes + 4, 4);
memcpy(&saved_.params.bw, bytes + 8, 4);
saved_.params.sf = bytes[12]; saved_.params.cr = bytes[13];
memcpy(&saved_.params.preamble, bytes + 14, 2);
memcpy(&primary_preamble_, bytes + 16, 2);
cross_ = (RadioCrossMode)bytes[18];
if ((uint8_t)saved_.mode > 2 || (uint8_t)cross_ > 2
|| (saved_.mode != RadioProfileMode::Off && !radio_->validateProfile(saved_.params))
|| (primary_preamble_ && (primary_preamble_ < 8 || primary_preamble_ > RadioProfiles::MaxPreamble))) {
hold_ = true; saved_ = {}; primary_preamble_ = 0; cross_ = RadioCrossMode::Auto;
return;
}
stagePrimary(primary_preamble_, false);
publish();
}
bool RadioProfileCLI::savePrimaryPreamble(uint16_t symbols) {
if (symbols && (symbols < 8 || symbols > RadioProfiles::MaxPreamble)) return false;
if (!radio_ || !radio_->profiles()) return symbols == 0;
if (symbols == primary_preamble_) return true;
if (!save(saved_, symbols, cross_)) return false;
primary_preamble_ = symbols;
return true;
}
bool RadioProfileCLI::acceptsPrimary(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble) const {
if (!radio_ || !radio_->profiles()) return preamble == 0;
auto preview = *radio_->profiles();
auto& p = preview.primary;
p.freq = freq; p.bw = bw; p.sf = sf; p.cr = cr; p.preamble = preamble;
return radio_->validateProfile(p) && preview.automaticPreambleFits();
}
void RadioProfileCLI::publish() {
if (!radio_ || !radio_->profiles()) return;
radio_->profiles()->cross = cross_;
if (publish_pending_ && (int32_t)(millis() - publish_after_ms_) < 0) return;
publish_pending_ = false;
radio_->profiles()->setSecondary(temp_active_ ? temporary_ : saved_, temp_active_);
}
void RadioProfileCLI::loop() {
if (!radio_ || !radio_->profiles()) return;
const uint32_t now = millis(), elapsed = now - last_ms_;
last_ms_ = now;
if (temp_pending_ && (int32_t)(now - temp_start_ms_) >= 0) {
temporary_ = pending_temporary_;
const uint32_t late = now - temp_start_ms_;
temp_remaining_ms_ = late < pending_duration_ms_ ? pending_duration_ms_ - late : 0;
temp_pending_ = false; temp_active_ = temp_remaining_ms_ != 0;
} else if (temp_active_) {
if (elapsed >= temp_remaining_ms_) { temp_remaining_ms_ = 0; temp_active_ = false; }
else temp_remaining_ms_ -= elapsed;
}
const uint32_t epoch = rtc_ ? rtc_->getCurrentTime() : 0;
for (auto& s : schedules_) {
if (!s.active) continue;
if (s.temporary) {
if (elapsed >= s.remaining_ms || epoch >= s.end) {
if (s.started) { temp_active_ = false; temp_remaining_ms_ = 0; }
s = {}; continue;
}
s.remaining_ms -= elapsed;
}
if (s.started || epoch < s.start) continue;
if (s.temporary) {
if (temp_active_ || temp_pending_) continue;
temporary_ = s.config;
temp_remaining_ms_ = s.remaining_ms;
const uint32_t epoch_left = (s.end - epoch) * 1000UL;
if (temp_remaining_ms_ > epoch_left) temp_remaining_ms_ = epoch_left;
temp_active_ = true; s.started = true;
} else if (save(s.config, primary_preamble_, cross_)) {
saved_ = s.config; s = {};
} else {
// Avoid a flash write on every main-loop iteration after a storage fault.
s.start = epoch <= UINT32_MAX - 60 ? epoch + 60 : UINT32_MAX;
}
}
publish();
}
bool RadioProfileCLI::parseSuffix(const char* input, unsigned fields, char* legacy,
size_t capacity, uint16_t& preamble) {
return cli::parseRadioPreambleSuffix(input, fields, legacy, capacity, preamble);
}
void RadioProfileCLI::appendPreamble(char* reply, size_t capacity, uint8_t profile) const {
if (!radio_ || !radio_->profiles()) return;
const size_t used = strlen(reply);
if (used < capacity) snprintf(reply + used, capacity - used, ",preamble=%u",
(unsigned)radio_->profilePreamble(profile));
}
void RadioProfileCLI::appendSavedPreamble(char* reply, size_t capacity, uint8_t sf, float bw) const {
if (!radio_ || !radio_->profiles()) return;
auto preview = *radio_->profiles();
preview.primary.sf = sf; preview.primary.bw = bw;
preview.primary.preamble = primary_preamble_;
const size_t used = strlen(reply);
if (used < capacity) snprintf(reply + used, capacity - used, ",preamble=%u%s",
preview.preamble(0, rxPowerSavingPreambleForParams(sf, bw)), primary_preamble_ ? "" : " (auto)");
}
void RadioProfileCLI::formatConfig(char* reply, size_t capacity, const RadioProfileConfig& config,
bool temporary, uint32_t remaining) const {
if (config.mode == RadioProfileMode::Off) { snprintf(reply, capacity, "> off"); return; }
RadioProfiles preview = *radio_->profiles();
preview.secondary = config;
uint16_t preamble = preview.preamble(1, 32);
snprintf(reply, capacity, "> %.3f,%.3f,%u,%u,%s,%u%s", config.params.freq, config.params.bw,
config.params.sf, config.params.cr, modeName(config.mode), preamble,
config.params.preamble ? "" : " (auto)");
if (temporary) {
uint32_t minutes = (remaining + 59999UL) / 60000UL;
const size_t used = strlen(reply);
if (used < capacity) snprintf(reply + used, capacity - used, "; %lud%luh%lum (%lu min) left",
(unsigned long)(minutes / 1440), (unsigned long)(minutes / 60 % 24),
(unsigned long)(minutes % 60), (unsigned long)minutes);
}
}
bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity) {
const char* text = command;
enum { Get, Set, Delete } verb = Get;
if (!strncmp(text, "get ", 4)) text += 4;
else if (!strncmp(text, "set ", 4)) { text += 4; verb = Set; }
else if (!strncmp(text, "del ", 4)) { text += 4; verb = Delete; }
char key[24];
size_t n = strcspn(text, " \t");
if (n >= sizeof(key)) return false;
memcpy(key, text, n); key[n] = 0;
const char* args = text + n;
while (*args == ' ' || *args == '\t') ++args;
const bool base = !strcmp(key, "radio2");
const bool temporary = !strcmp(key, "tempradio2");
const bool scheduled = !strcmp(key, "radioat2") || !strcmp(key, "tempradioat2");
const bool scheduled_temp = !strcmp(key, "tempradioat2");
const bool crossing = !strcmp(key, "radio2.cross");
const bool status = !strcmp(key, "radio2.status");
const bool scan = !strcmp(key, "radio2.scan");
if (!base && !temporary && !scheduled && !crossing && !status && !scan) return false;
if (!radio_ || !radio_->profiles()) { snprintf(reply, capacity, "Error: radio profiles unsupported"); return true; }
if (command == text && *args && verb == Get && (temporary || base)) verb = Set;
if (crossing) {
if (verb == Get && !*args) {
snprintf(reply, capacity, "> %s", cross_ == RadioCrossMode::Auto ? "auto" : cross_ == RadioCrossMode::On ? "on" : "off");
} else if (verb == Set && (!strcmp(args, "auto") || !strcmp(args, "on") || !strcmp(args, "off"))) {
auto mode = !strcmp(args, "auto") ? RadioCrossMode::Auto : !strcmp(args, "on") ? RadioCrossMode::On : RadioCrossMode::Off;
if (save(saved_, primary_preamble_, mode)) { cross_ = mode; publish(); snprintf(reply, capacity, "OK"); }
else snprintf(reply, capacity, "Error: settings could not be saved");
} else snprintf(reply, capacity, "Error: use get/set radio2.cross [auto|on|off]");
return true;
}
if (scan) {
if (verb != Get || *args) { snprintf(reply, capacity, "Error: radio2.scan is read-only"); return true; }
const auto& p = *radio_->profiles();
if (!p.enabled()) snprintf(reply, capacity, "> off");
else {
const uint8_t slow = p.slowerProfile();
const uint16_t preamble = radio_->profilePreamble(slow);
snprintf(reply, capacity, "> slow=%s; listen_us=%lu,%lu; preamble=%u,%u",
slow ? "radio2" : "radio", (unsigned long)p.listenUs(0, preamble),
(unsigned long)p.listenUs(1, preamble), radio_->profilePreamble(0), radio_->profilePreamble(1));
}
return true;
}
if (status) {
if (verb != Get || *args) { snprintf(reply, capacity, "Error: radio2.status is read-only"); return true; }
const auto& p = *radio_->profiles();
snprintf(reply, capacity, "> %s; RX=%lu,%lu TX=%lu,%lu switches=%lu errors=%lu max=%luus preamble=%u,%u",
modeName(p.secondary.mode), (unsigned long)p.rx_packets[0], (unsigned long)p.rx_packets[1],
(unsigned long)p.tx_packets[0], (unsigned long)p.tx_packets[1],
(unsigned long)p.switches, (unsigned long)p.switch_failures,
(unsigned long)p.longest_switch_us, radio_->profilePreamble(0), radio_->profilePreamble(1));
return true;
}
if ((base || temporary) && verb == Get && !*args) {
if (temporary && !temp_active_ && !temp_pending_) snprintf(reply, capacity, "> off");
else formatConfig(reply, capacity, temporary ? (temp_pending_ ? pending_temporary_ : temporary_) : saved_,
temporary, temp_pending_ ? pending_duration_ms_ : temp_remaining_ms_);
return true;
}
if ((base || temporary) && verb == Set && !strcmp(args, "off")) {
if (base && !save({}, primary_preamble_, cross_)) {
snprintf(reply, capacity, "Error: settings could not be saved"); return true;
}
if (base) saved_ = {};
temp_pending_ = temp_active_ = false; temp_remaining_ms_ = 0;
for (auto& s : schedules_) if (base || s.temporary) s = {};
publish_pending_ = true; publish_after_ms_ = millis() + 2000;
publish(); snprintf(reply, capacity, "OK - %s in 2s", saved_.mode != RadioProfileMode::Off ? "saved radio2 restored" : "single radio");
return true;
}
if (scheduled && (verb == Get || verb == Delete)) {
uint32_t index = 0;
if (*args && strcmp(args, "all") && (!cli::parseUnsignedIntegerStrict(args, index) || index < 1 || index > 4)) {
snprintf(reply, capacity, "Error: slot must be 1-4 or all"); return true;
}
const unsigned first = scheduled_temp ? 4 : 0;
if (verb == Delete) {
for (unsigned i = 0; i < 4; ++i) if (!index || index == i + 1) {
if (schedules_[first+i].started) {
temp_active_ = false; temp_remaining_ms_ = 0;
publish_pending_ = true; publish_after_ms_ = millis() + 2000;
}
schedules_[first+i] = {};
}
publish(); snprintf(reply, capacity, "OK"); return true;
}
if (!index) {
snprintf(reply, capacity, "> slots: ");
for (unsigned i = 0; i < 4; ++i) if (schedules_[first+i].active) {
const size_t used = strlen(reply);
if (used < capacity) snprintf(reply + used, capacity-used, "%u@%lu ", i+1, (unsigned long)schedules_[first+i].start);
}
return true;
}
const auto& s = schedules_[first+index-1];
if (!s.active) snprintf(reply, capacity, "> off");
else {
formatConfig(reply, capacity, s.config, s.temporary, s.remaining_ms);
const size_t used = strlen(reply);
if (used < capacity) snprintf(reply+used, capacity-used, "; @%lu-%lu", (unsigned long)s.start, (unsigned long)s.end);
}
return true;
}
if (verb != Set) { snprintf(reply, capacity, "Error: invalid radio2 command"); return true; }
char local[140];
if (strlen(args) >= sizeof(local)) { snprintf(reply, capacity, "Error: params too long"); return true; }
strcpy(local, args);
char* parts[8];
const unsigned count = split(local, parts, 8);
const unsigned expected = scheduled_temp ? 7 : scheduled || temporary ? 6 : 5;
RadioProfileConfig config;
uint32_t sf = 0, cr = 0, start = 0, end = 0, minutes = 0;
bool valid = (count == expected || count == expected + 1)
&& cli::parseDecimalStrict(parts[0], config.params.freq)
&& cli::parseDecimalStrict(parts[1], config.params.bw)
&& cli::parseUnsignedIntegerStrict(parts[2], sf) && sf <= 255
&& cli::parseUnsignedIntegerStrict(parts[3], cr) && cr <= 255
&& parseMode(parts[4], config.mode);
config.params.sf = sf; config.params.cr = cr;
if (valid && count > expected) valid = parsePreamble(parts[expected], config.params.preamble);
if (valid && temporary) valid = cli::parseUnsignedIntegerStrict(parts[5], minutes) && minutes >= 1 && minutes <= 10080;
if (valid && scheduled) valid = cli::parseUnsignedIntegerStrict(parts[5], start);
if (valid && scheduled_temp) valid = cli::parseUnsignedIntegerStrict(parts[6], end);
if (valid) valid = radio_->validateProfile(config.params);
RadioProfiles preview = *radio_->profiles(); preview.secondary = config;
if (valid) valid = preview.automaticPreambleFits();
if (!valid) { snprintf(reply, capacity, "Error: use %s f,bw,sf,cr,rx|rxtx%s[,preamble|auto]", key,
scheduled_temp ? ",start,end" : scheduled ? ",start" : temporary ? ",minutes" : ""); return true; }
if (scheduled) {
const uint32_t epoch = rtc_ ? rtc_->getCurrentTime() : 0;
if (!epoch || start <= epoch || (uint64_t)(start-epoch) * 1000 > 0x7fffffffULL
|| (scheduled_temp && (end <= start || (uint64_t)(end-epoch) * 1000 > 0x7fffffffULL))) {
snprintf(reply, capacity, "Error: future UTC start/end required, within 24 days"); return true;
}
if (scheduled_temp) {
const uint64_t pending_left = temp_pending_
? pending_duration_ms_ + (uint64_t)((int32_t)(temp_start_ms_ - millis()) > 0 ? temp_start_ms_ - millis() : 0)
: 0;
const uint64_t session_left = pending_left > temp_remaining_ms_ ? pending_left : temp_remaining_ms_;
if ((temp_active_ || temp_pending_) && (uint64_t)(start-epoch)*1000 < session_left) {
snprintf(reply, capacity, "Error: temporary schedule overlaps active session"); return true;
}
for (const auto& s : schedules_) if (s.active && s.temporary && start < s.end && end > s.start) {
snprintf(reply, capacity, "Error: temporary schedules overlap"); return true;
}
}
const unsigned first = scheduled_temp ? 4 : 0;
for (unsigned i = 0; i < 4; ++i) if (!schedules_[first+i].active) {
auto& s = schedules_[first+i]; s = {};
s.config = config; s.start = start; s.end = end; s.temporary = scheduled_temp;
s.remaining_ms = scheduled_temp ? (end-epoch)*1000UL : 0; s.active = true;
snprintf(reply, capacity, "OK - %s %u queued, preamble=%u", key, i+1, preview.preamble(1, 32)); return true;
}
snprintf(reply, capacity, "Error: all four schedule slots occupied"); return true;
}
if (temporary) {
for (const auto& s : schedules_) if (s.active && s.temporary) {
snprintf(reply, capacity, "Error: clear tempradioat2 schedules first"); return true;
}
pending_temporary_ = config; pending_duration_ms_ = minutes * 60000UL;
temp_start_ms_ = millis() + 2000; temp_pending_ = true;
// An existing session remains active until the new request can take over.
snprintf(reply, capacity, "OK - tempradio2 %lud%luh%lum (%lu min); preamble=%u",
(unsigned long)(minutes/1440), (unsigned long)(minutes/60%24),
(unsigned long)(minutes%60), (unsigned long)minutes, preview.preamble(1, 32));
} else if (save(config, primary_preamble_, cross_)) {
saved_ = config;
publish_pending_ = true; publish_after_ms_ = millis() + 2000;
publish();
snprintf(reply, capacity, "OK - radio2 %s; preamble=%u", modeName(config.mode), preview.preamble(1, 32));
} else snprintf(reply, capacity, "Error: settings could not be saved");
return true;
}
} // namespace mesh
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <Dispatcher.h>
#include <helpers/IdentityStore.h>
#include <helpers/CLICommandUtils.h>
namespace mesh {
// Shared by infrastructure, Companion and standalone roles. Settings use a
// separate versioned image so existing preference offsets stay compatible.
class RadioProfileCLI {
public:
struct Schedule {
RadioProfileConfig config;
uint32_t start = 0, end = 0;
uint32_t remaining_ms = 0; // monotonic upper bound, including start delay
bool active = false, temporary = false, started = false;
};
private:
FILESYSTEM* fs_ = nullptr;
Radio* radio_ = nullptr;
RTCClock* rtc_ = nullptr;
RadioProfileConfig saved_;
RadioProfileConfig temporary_;
RadioProfileConfig pending_temporary_;
uint16_t primary_preamble_ = 0;
RadioCrossMode cross_ = RadioCrossMode::Auto;
Schedule schedules_[8];
uint32_t temp_remaining_ms_ = 0;
uint32_t temp_start_ms_ = 0;
uint32_t pending_duration_ms_ = 0;
uint32_t publish_after_ms_ = 0;
uint32_t last_ms_ = 0;
bool temp_pending_ = false, temp_active_ = false;
bool hold_ = false;
bool publish_pending_ = false;
bool save(const RadioProfileConfig& config, uint16_t preamble, RadioCrossMode cross);
bool readImage(const char* path, uint8_t* bytes, size_t size);
bool writeImage(const char* path, const uint8_t* bytes, size_t size);
void publish();
void formatConfig(char* reply, size_t capacity, const RadioProfileConfig& config,
bool temporary, uint32_t remaining_ms) const;
public:
void begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc);
void loop();
bool handle(const char* command, char* reply, size_t capacity = 160);
uint16_t primaryPreamble() const { return primary_preamble_; }
bool savePrimaryPreamble(uint16_t symbols);
bool acceptsPrimary(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble) const;
RadioParamApplyResult applyPrimary(float freq, float bw, uint8_t sf, uint8_t cr,
bool temporary, uint16_t preamble, const uint32_t* timings = nullptr) {
if (!radio_) return RadioParamApplyResult::FAILED;
RadioProfileParams p;
p.freq = freq; p.bw = bw; p.sf = sf; p.cr = cr; p.preamble = preamble;
return radio_->trySetPrimaryParams(p, temporary, timings);
}
void stagePrimary(uint16_t preamble, bool temporary) {
if (!radio_ || !radio_->profiles()) return;
auto p = radio_->profiles()->primary;
p.preamble = preamble;
radio_->profiles()->setPrimary(p, temporary);
}
bool secondaryTemporary() const { return temp_active_; }
uint32_t secondaryRemainingSeconds() const { return (temp_remaining_ms_ + 999) / 1000; }
void appendPreamble(char* reply, size_t capacity, uint8_t profile = 0) const;
void appendSavedPreamble(char* reply, size_t capacity, uint8_t sf, float bw) const;
// Strip exactly one optional trailing preamble before a legacy primary
// parser runs. Missing means auto. Reject extra fields and malformed input.
static bool parseSuffix(const char* input, unsigned fields, char* legacy,
size_t capacity, uint16_t& preamble);
};
} // namespace mesh
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <RadioProfiles.h>
#include <helpers/CLICommandUtils.h>
namespace mesh { namespace cli {
inline bool parseRadioPreamble(const char* text, uint16_t& result) {
uint32_t value;
if (!strcmp(text, "auto")) { result = 0; return true; }
if (!parseUnsignedIntegerStrict(text, value) || value > RadioProfiles::MaxPreamble
|| (value != 0 && value < 8)) return false;
result = (uint16_t)value;
return true;
}
inline bool parseRadioPreambleSuffix(const char* input, unsigned fields, char* legacy,
size_t capacity, uint16_t& preamble) {
preamble = 0;
if (!input || strlen(input) >= capacity) return false;
strcpy(legacy, input);
unsigned count = 1;
char* last = nullptr;
for (char* p = legacy; *p; ++p) if (*p == ',') { ++count; last = p; }
if (count == fields) return true;
if (count != fields + 1 || !last || !parseRadioPreamble(last + 1, preamble)) return false;
*last = 0;
return true;
}
} }
+13 -1
View File
@@ -89,6 +89,16 @@ mesh::Packet* PacketQueue::removeByIdx(int i) {
return item;
}
bool PacketQueue::reschedule(mesh::Packet* packet, uint32_t scheduled_for) {
for (int i = 0; i < _num; ++i) {
if (_table[i] != packet) continue;
_schedule_table[i] = scheduled_for;
rebuildNextTime();
return true;
}
return false;
}
bool PacketQueue::add(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) {
if (_num == _size) {
return false;
@@ -120,7 +130,9 @@ void PacketQueue::applyBestFloodTransportScope(mesh::Packet* packet,
uint8_t best_preference = 0;
for (int i = 0; i < _num; i++) {
const mesh::Packet* candidate = _table[i];
if (candidate == NULL || candidate->getRouteType() != ROUTE_TYPE_TRANSPORT_FLOOD) continue;
if (candidate == NULL || candidate->getRouteType() != ROUTE_TYPE_TRANSPORT_FLOOD
|| candidate->radio_profile != packet->radio_profile
|| candidate->radio_generation != packet->radio_generation) continue;
uint8_t candidate_hash[MAX_HASH_SIZE];
candidate->calculatePacketHash(candidate_hash);
if (memcmp(packet_hash, candidate_hash, sizeof(packet_hash)) != 0) continue;
+4
View File
@@ -30,6 +30,7 @@ public:
bool getNextTime(uint32_t now, uint32_t& scheduled_for) const;
mesh::Packet* itemAt(int i) const { return _table[i]; }
mesh::Packet* removeByIdx(int i);
bool reschedule(mesh::Packet* packet, uint32_t scheduled_for);
};
class StaticPoolPacketManager : public mesh::PacketManager {
@@ -50,6 +51,9 @@ public:
bool queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) override;
mesh::Packet* getNextOutbound(uint32_t now) override;
mesh::Packet* peekNextOutbound(uint32_t now) override;
bool deferOutbound(mesh::Packet* packet, uint32_t scheduled_for) override {
return send_queue.reschedule(packet, scheduled_for);
}
int getOutboundCount(uint32_t now) const override;
int getOutboundTotal() const override;
bool getNextOutboundTime(uint32_t now, uint32_t& scheduled_for) const override;
+5 -1
View File
@@ -56,6 +56,7 @@ public:
}
bool configSideDetectors(const uint8_t* sideDetSFs, uint8_t num, float bw) override {
if (_profiles.enabled()) return false;
const uint8_t primary_sf = getSpreadingFactor();
float active_bw = _params_valid ? _cur_bw : ((CustomLR2021 *)_radio)->getBandwidthKhz();
if (active_bw <= 0.0f) active_bw = bw;
@@ -104,6 +105,9 @@ protected:
}
bool applySideDetectorConfig(uint8_t primary_sf, float bw) {
if (_profiles.enabled()) {
return ((CustomLR2021 *)_radio)->setSideDetector(nullptr, 0) == RADIOLIB_ERR_NONE;
}
if (!mesh::lr2021::validateSideDetectorSFs(_sideDetSFs, _numSideDet, primary_sf, bw)) {
return false;
}
@@ -114,7 +118,7 @@ protected:
}
int16_t performChannelScan() override {
if (_numSideDet == 0) return RadioLibWrapper::performChannelScan();
if (_numSideDet == 0 || _profiles.enabled()) return RadioLibWrapper::performChannelScan();
CustomLR2021* radio = (CustomLR2021 *)_radio;
const uint8_t rx_primary_sf = getSpreadingFactor();
+183 -5
View File
@@ -44,6 +44,10 @@ void RadioLibWrapper::begin() {
const uint8_t initial_cr = 5;
#endif
cacheParams(LORA_FREQ, LORA_BW, LORA_SF, initial_cr);
_profiles.primary.freq = LORA_FREQ;
_profiles.primary.bw = LORA_BW;
_profiles.primary.sf = LORA_SF;
_profiles.primary.cr = initial_cr;
}
#endif
#ifdef LORA_TX_POWER
@@ -162,18 +166,31 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw,
const float previous_bw = _cur_bw;
const uint8_t previous_sf = _cur_sf;
const uint8_t previous_cr = _cur_cr;
const uint16_t previous_preamble = _physical_preamble;
mesh::RadioProfileParams requested = _profiles.primary;
requested.freq = freq; requested.bw = bw; requested.sf = sf; requested.cr = cr;
const auto previous_primary = _profiles.primary;
_profiles.primary = requested;
_physical_preamble = profilePreamble(0);
bool success = applyParams(freq, bw, sf, cr);
if (success) {
cacheParams(freq, bw, sf, cr);
_profiles.primary = previous_primary;
_profiles.setPrimary(requested, _profiles.primary_temporary);
_active_profile = 0;
_profile_generation = _profiles.generation[0];
if (rx_ps_timings != NULL) {
_rx_ps_enabled = true;
if (_profile_rxps_suspended) _profile_saved_rxps = true;
else _rx_ps_enabled = true;
_rx_ps_rx_us = rx_ps_timings[0];
_rx_ps_sleep_us = rx_ps_timings[1];
_rx_ps_continuous_fallback = rxPowerSavingUsesContinuousFallback(
_rx_ps_rx_us, _rx_ps_sleep_us);
}
} else {
_profiles.primary = previous_primary;
_physical_preamble = previous_preamble;
bool restored = had_previous_params
&& applyParams(previous_freq, previous_bw, previous_sf, previous_cr);
@@ -185,14 +202,158 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw,
}
endReconfigure(resume_rx);
_profile_visit_us = micros();
return success ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED;
}
bool RadioLibWrapper::validateProfile(const mesh::RadioProfileParams& p) const {
if (!mesh::RadioProfiles::valid(p)) return false;
#if defined(USE_SX1276)
return p.freq >= 137 && p.freq <= 1020 && p.sf >= 7 && p.bw <= 500;
#elif defined(USE_SX1272)
return p.freq >= 860 && p.freq <= 1020 && p.sf >= 7 && p.bw >= 125 && p.bw <= 500;
#elif defined(USE_LLCC68)
return p.freq >= 150 && p.freq <= 960
&& ((p.bw == 125 && p.sf <= 9) || (p.bw == 250 && p.sf <= 10)
|| (p.bw == 500 && p.sf <= 11));
#elif defined(USE_LR2021)
return (p.freq <= 1090 || p.freq >= 1900) && p.bw >= 31.25 && p.bw <= 1000;
#elif defined(USE_LR1110)
return p.freq <= 960 && p.bw >= 62.5 && p.bw <= 500;
#elif defined(USE_SX1268)
return p.freq >= 410 && p.freq <= 810 && p.bw <= 500;
#else
return p.freq <= 960 && p.bw <= 500;
#endif
}
mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) {
if (profile > 1 || (profile == 1 && !_profiles.enabled())) return mesh::RadioParamApplyResult::FAILED;
const auto& p = _profiles.params(profile);
const uint16_t preamble = profilePreamble(profile);
if (!_profile_refresh_required && _active_profile == profile && _profile_generation == _profiles.generation[profile]
&& _physical_preamble == preamble) return mesh::RadioParamApplyResult::APPLIED;
if (!validateProfile(p)) return mesh::RadioParamApplyResult::FAILED;
const uint32_t started = micros();
const uint8_t resume = beginReconfigure();
if (resume > 1) return mesh::RadioParamApplyResult::BUSY;
const uint16_t old_preamble = _physical_preamble;
_physical_preamble = preamble;
const bool applied = applyParams(p.freq, p.bw, p.sf, p.cr);
if (applied) {
cacheParams(p.freq, p.bw, p.sf, p.cr);
_active_profile = profile;
_profile_generation = _profiles.generation[profile];
_profile_refresh_required = false;
++_profiles.switches;
} else {
++_profiles.switch_failures;
_physical_preamble = old_preamble;
if (!applyParams(_cur_freq, _cur_bw, _cur_sf, _cur_cr)) restoreAfterDeepInit();
}
endReconfigure(resume);
_profile_visit_us = micros();
const uint32_t elapsed = _profile_visit_us - started;
if (elapsed > _profiles.longest_switch_us) _profiles.longest_switch_us = elapsed;
return applied ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED;
}
mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile) {
if (!_profiles.canTransmit(profile)) return mesh::RadioParamApplyResult::FAILED;
if (_profiles.enabled() && (isChipBusy() || isPacketPendingOrReceiving())) {
return mesh::RadioParamApplyResult::BUSY;
}
return tuneProfile(profile);
}
void RadioLibWrapper::serviceProfileScan() {
if (!_params_valid || (_profile_retry_at && (int32_t)(millis() - _profile_retry_at) < 0)) return;
_profile_retry_at = 0;
if (_profiles.enabled() && !_profile_rxps_suspended) {
const uint8_t resume = beginReconfigure();
if (resume > 1) return;
_profile_saved_rxps = _rx_ps_enabled;
_rx_ps_enabled = false;
_profile_rxps_suspended = true;
_nf_calib_active = false;
_noise_floor_valid = false; // a single-channel floor cannot describe both channels
_profile_refresh_required = true; // refresh side detectors even if the tuple is unchanged
endReconfigure(resume);
}
uint8_t target = _active_profile;
const bool restart_scan = _profiles.enabled()
&& (_profile_scan_generation[0] != _profiles.generation[0]
|| _profile_scan_generation[1] != _profiles.generation[1]);
if (!_profiles.enabled()) target = 0;
else if (restart_scan) target = _profiles.slowerProfile();
else if ((uint32_t)(micros() - _profile_visit_us) >= _profiles.listenUs(
_active_profile, profilePreamble(_profiles.slowerProfile()))) {
target ^= 1;
}
const auto result = tuneProfile(target);
if (restart_scan && result == mesh::RadioParamApplyResult::APPLIED) {
_profile_scan_generation[0] = _profiles.generation[0];
_profile_scan_generation[1] = _profiles.generation[1];
_profile_visit_us = micros();
}
if (result == mesh::RadioParamApplyResult::FAILED) {
// Retry a rejected or failing profile at a bounded rate; retain primary RX.
_profile_retry_at = millis() + 1000;
tuneProfile(0);
}
if (!_profiles.enabled() && _profile_rxps_suspended && _active_profile == 0
&& result == mesh::RadioParamApplyResult::APPLIED) {
const uint8_t resume = beginReconfigure();
if (resume > 1) return;
_rx_ps_enabled = _profile_saved_rxps;
_profile_rxps_suspended = false;
_profile_refresh_required = true;
recalibrateNoiseFloor();
endReconfigure(resume);
}
}
uint32_t RadioLibWrapper::getProfileAirtime(uint8_t profile, int len, uint8_t cr) {
if (!_profiles.enabled() && profile == 0 && cr == 0) return getEstAirtimeFor(len);
const auto& p = _profiles.params(profile);
const double symbol = mesh::RadioProfiles::symbolUs(p);
if (symbol <= 0 || len < 0) return 0;
if (cr < 5 || cr > 8) cr = p.cr;
const int de = symbol >= 16000 ? 1 : 0;
const double coded = ceil((8.0 * len - 4 * p.sf + (p.sf <= 6 ? 20 : 28) + 16)
/ (4 * (p.sf - 2 * de)));
const double payload_symbols = 8 + (coded > 0 ? coded * cr : 0);
const double suffix = p.sf <= 6 ? 6.25 : 4.25;
return (uint32_t)ceil((profilePreamble(profile) + suffix + payload_symbols) * symbol / 1000.0);
}
bool RadioLibWrapper::setParams(float freq, float bw, uint8_t sf, uint8_t cr,
const uint32_t* rx_ps_timings) {
return trySetParams(freq, bw, sf, cr, rx_ps_timings) == mesh::RadioParamApplyResult::APPLIED;
}
mesh::RadioParamApplyResult RadioLibWrapper::trySetPrimaryParams(const mesh::RadioProfileParams& p,
bool temporary, const uint32_t* timings) {
auto preview = _profiles;
preview.primary = p;
if (!validateProfile(p) || !preview.automaticPreambleFits()) return mesh::RadioParamApplyResult::FAILED;
const auto previous = _profiles.primary;
const bool was_temp = _profiles.primary_temporary;
const uint32_t generation = _profiles.generation[0];
_profiles.primary.preamble = p.preamble;
_profiles.primary_temporary = temporary;
const auto result = trySetParams(p.freq, p.bw, p.sf, p.cr, timings);
if (result != mesh::RadioParamApplyResult::APPLIED) {
_profiles.primary = previous;
_profiles.primary_temporary = was_temp;
_profiles.generation[0] = generation;
} else {
_profiles.generation[0] = generation + (previous != p || was_temp != temporary);
_profile_generation = _profiles.generation[0];
}
return result;
}
bool RadioLibWrapper::setRxBoostedGainMode(bool enabled) {
uint8_t resume_rx = beginReconfigure();
if (resume_rx > 1) return false;
@@ -471,6 +632,13 @@ void RadioLibWrapper::checkReceiveMode(uint32_t now) {
}
void RadioLibWrapper::loop() {
serviceProfileScan();
// Calibration batches need one stable channel. Do not publish a noise floor
// assembled from different frequencies, or let a batch pin the scan on one.
if (_profiles.enabled()) {
checkReceiveMode(static_cast<uint32_t>(millis()));
return;
}
if (_rx_ps_enabled && !_rx_ps_continuous_fallback) {
rxPsWatchdogCheck();
}
@@ -552,6 +720,7 @@ void RadioLibWrapper::startRecv() {
int err = startReceiveMode();
if (err == RADIOLIB_ERR_NONE) {
// A very short frame may complete while startReceiveMode() returns.
_profile_visit_us = micros();
// Retain that RX interrupt instead of overwriting it with software state.
noInterrupts();
state = (state & STATE_INT_READY) | STATE_RX;
@@ -611,6 +780,12 @@ bool RadioLibWrapper::isInRecvMode() const {
// RX PowerSaving
bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) {
if (_profile_rxps_suspended) {
_profile_saved_rxps = enabled;
_rx_ps_rx_us = rx_us;
_rx_ps_sleep_us = sleep_us;
return !enabled || supportsRxPowerSaving();
}
if (enabled && !supportsRxPowerSaving()) {
return false;
}
@@ -791,7 +966,7 @@ bool RadioLibWrapper::isChannelActive() {
// int.thresh: RSSI-based interference detection (relative to noise floor).
// In RX duty-cycle mode only checked while the chip is in a listen window
// (during the sleep window the frontend is off and the read would stall).
if (_threshold != 0 && !(_rx_ps_armed && isChipBusy())
if (_threshold != 0 && !_profiles.enabled() && !(_rx_ps_armed && isChipBusy())
&& getCurrentRSSI() > _noise_floor + _threshold) return true;
// cad: hardware channel activity detection
@@ -823,7 +998,10 @@ bool RadioLibWrapper::isReceivingPassive(int interference_margin_db) {
// busy so the retry is deferred instead of transmitting blind; Dispatcher
// retains its bounded busy timeout as a last-resort escape.
if (isChipBusy()) return true;
if (isReceivingPacket()) return true;
if (isPacketPendingOrReceiving()) return true;
// Four-symbol visits are too short for the settled single-channel noise
// calibration. Keep retries in RX and use the preamble/header IRQs here.
if (_profiles.enabled()) return false;
unsigned long now = millis();
if ((!_noise_floor_valid && _nf_last_calib == 0)
@@ -866,13 +1044,13 @@ float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) {
return score < 1.0f ? score : 1.0f;
}
PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols) {
PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint16_t preambleSymbols) {
// based on RadioLib's calculateTimeOnAir()
uint32_t tsym_us = ((uint32_t)10000 << sf) / (bw * 10);
uint32_t sfCoeff1_x4 = (sf == 5 || sf == 6) ? 25 : 17; // 6.25 : 4.25, semtech magic numbers to account for sync word + sfd
// preamble + syncword + sfd + header
uint32_t preamble_us = (((preambleSymbols + 8) * 4 + sfCoeff1_x4) * tsym_us) / 4;
uint64_t preamble_us = (((uint64_t)(preambleSymbols + 8) * 4 + sfCoeff1_x4) * tsym_us) / 4;
// airtime for max packet at current radio settings
uint32_t total_us = _radio->getTimeOnAir(MAX_TRANS_UNIT);
+28 -3
View File
@@ -73,6 +73,18 @@ protected:
int8_t _cur_dbm;
bool _cur_rx_boosted_gain;
bool _params_valid, _dbm_valid, _rx_boosted_gain_valid;
mesh::RadioProfiles _profiles;
uint8_t _active_profile = 0;
uint16_t _physical_preamble = 0;
uint32_t _profile_generation = 0;
uint32_t _profile_scan_generation[2] = {};
uint32_t _profile_visit_us = 0;
uint32_t _profile_retry_at = 0;
bool _profile_rxps_suspended = false;
bool _profile_saved_rxps = false;
bool _profile_refresh_required = false;
mesh::RadioParamApplyResult tuneProfile(uint8_t profile);
void serviceProfileScan();
// On-demand noise-floor calibration while RX duty-cycle powersaving is
// armed. A duty-cycled receiver can't be sampled reliably, so a requested
@@ -169,6 +181,19 @@ public:
}
void begin() override;
mesh::RadioProfiles* profiles() override { return &_profiles; }
const mesh::RadioProfiles* profiles() const override { return &_profiles; }
bool validateProfile(const mesh::RadioProfileParams& params) const override;
uint8_t receiveProfile() const override { return _active_profile; }
uint32_t receiveProfileGeneration() const override { return _profile_generation; }
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t profile) override;
mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams& params,
bool temporary, const uint32_t* timings = nullptr) override;
uint16_t profilePreamble(uint8_t profile) const override {
const auto& p = _profiles.params(profile);
return _profiles.preamble(profile, rxPowerSavingPreambleForParams(p.sf, p.bw));
}
uint32_t getProfileAirtime(uint8_t profile, int len_bytes, uint8_t cr = 0) override;
int recvRaw(uint8_t* bytes, int sz) override;
uint32_t getEstAirtimeFor(int len_bytes) override;
bool startSendRaw(const uint8_t* bytes, int len) override;
@@ -202,8 +227,8 @@ public:
virtual float getCurrentRSSI() =0;
virtual uint8_t getSpreadingFactor() const { return LORA_SF; }
static uint16_t preambleLengthForParams(uint8_t sf, float bw) {
return rxPowerSavingPreambleForParams(sf, bw);
uint16_t preambleLengthForParams(uint8_t sf, float bw) const {
return _physical_preamble ? _physical_preamble : rxPowerSavingPreambleForParams(sf, bw);
}
uint16_t currentPreambleLength() const {
return _params_valid
@@ -213,7 +238,7 @@ public:
bool updatePreamble(uint8_t sf, float bw) {
return _radio->setPreambleLength(preambleLengthForParams(sf, bw)) == RADIOLIB_ERR_NONE;
}
PacketMillis calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols);
PacketMillis calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint16_t preambleSymbols);
virtual int16_t performChannelScan();
int getNoiseFloor() const override { return _noise_floor; }
+156
View File
@@ -0,0 +1,156 @@
#include <cassert>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <initializer_list>
#include <helpers/CLICommandUtils.h>
#include <helpers/RadioProfileCommandUtils.h>
// Persistence has its own production-parser tests with an in-memory FS.
// This fixture isolates the actual CommonCLI setting branches and callbacks.
namespace mesh {
struct RadioProfileCLI {
static bool parseSuffix(const char* input, unsigned fields, char* legacy,
size_t size, uint16_t& preamble) {
return cli::parseRadioPreambleSuffix(input, fields, legacy, size, preamble);
}
bool savePrimaryPreamble(uint16_t) { return true; }
void appendPreamble(char*, size_t) {}
void appendSavedPreamble(char*, size_t, uint8_t, float) {}
bool acceptsPrimary(float, float, uint8_t, uint8_t, uint16_t) const { return true; }
};
}
#define MIN_LORA_TX_POWER -9
#define MAX_LORA_TX_POWER 22
#include <helpers/radiolib/RadioPowerLimits.h>
#include <helpers/radiolib/LR2021SideDetectorConfig.h>
struct StrHelper {
static const char* ftoa(float value) {
static char buffer[24]; snprintf(buffer, sizeof(buffer), "%.3f", double(value)); return buffer;
}
static const char* ftoa3(float value) { return ftoa(value); }
};
struct Prefs {
float freq=909.5f, bw=62.5f, airtime_factor=1, rx_delay_base=0;
float tx_delay_factor=0, direct_tx_delay_factor=0;
uint8_t sf=7, cr=5, interference_threshold=0, agc_reset_interval=0;
uint8_t path_hash_mode=0, multi_acks=0, rx_ps_level=0, rx_ps_preamble=0;
int8_t tx_power_dbm=10;
uint32_t rx_ps_rx_us=1000, rx_ps_sleep_us=1000;
bool cad_enabled=false, rx_boosted_gain=false;
uint8_t extra_sf[4]={};
};
struct Callbacks {
unsigned saves=0, tx_calls=0, gain_calls=0;
bool accept=true;
int8_t power=0;
bool gain=false;
void savePrefs() { ++saves; }
bool setTxPower(int8_t value) { ++tx_calls; if (!accept) return false; power=value; return true; }
bool setRxBoostedGain(bool value) { ++gain_calls; if (!accept) return false; gain=value; return true; }
bool configSideDetectors(const uint8_t*,uint8_t,float) { return accept; }
};
struct Board { unsigned n_cad_busy=0; };
bool isValidLoRaBandwidth(float bw) { return bw==125 || bw==62.5f; }
void recalcRxPowerSavingFromLevel(uint8_t,uint8_t,float,uint8_t,uint32_t*,uint32_t*) {}
void appendRxPowerSavingAdjustmentNote(char*,Prefs*,uint8_t,float) {}
@KEY_EQUALS@
class CLI {
public:
Prefs prefs;
Callbacks callbacks;
Board board;
mesh::RadioProfileCLI _radio_profiles;
Prefs* _prefs=&prefs;
Callbacks* _callbacks=&callbacks;
Board* _board=&board;
void savePrefs() { callbacks.savePrefs(); }
void handleSetCmd(uint32_t, char* command, char* reply) {
const char* config=command+4;
@SET@
else strcpy(reply,"unknown config");
}
void handleGetCmd(uint32_t, char* command, char* reply) {
const char* config=command+4;
@GET@
else strcpy(reply,"unknown config");
}
void command(uint32_t sender_timestamp, char* command, char* reply) {
@DISPATCH@
else strcpy(reply,"unknown command");
}
void call(uint32_t sender, const char* text, char* reply) {
char command[160]; snprintf(command,sizeof(command),"%s",text);
this->command(sender,command,reply);
}
};
struct Case { const char *key, *value; double expected; };
int main() {
// Local USB and authenticated on-air calls use the same infrastructure CLI.
for (uint32_t sender : {0u, 123456u}) {
CLI cli; char text[160], reply[160];
for (const Case& c : {
Case{"freq","915.5",915.5}, {"af","2.5",2.5}, {"dutycycle","25",25},
{"int.thresh","128",128}, {"tx","14",14}, {"rxdelay","2.5",2.5},
{"agc.reset.interval","19",16}, {"path.hash.mode","2",2},
{"multi.acks","1",1}, {"txdelay","1.5",1.5}, {"direct.txdelay","0.5",0.5}}) {
unsigned saves=cli.callbacks.saves;
snprintf(text,sizeof(text),"set %s %s",c.key,c.value);
cli.call(sender,text,reply);
if (strncmp(reply,"OK",2)) { fprintf(stderr,"%s -> %s\n",text,reply); return 1; }
assert(cli.callbacks.saves==saves+1);
snprintf(text,sizeof(text),"get %s",c.key);
cli.call(sender,text,reply);
double actual=0;
assert(sscanf(reply,"> %lf",&actual)==1 && fabs(actual-c.expected)<0.001);
assert(cli.callbacks.saves==saves+1); // reading must not persist
}
for (const char* key : {"cad","radio.rxgain"}) {
for (const char* state : {"on","off"}) {
unsigned saves=cli.callbacks.saves;
snprintf(text,sizeof(text),"set %s %s",key,state); cli.call(sender,text,reply);
assert(!strcmp(reply,"OK") && cli.callbacks.saves==saves+1);
snprintf(text,sizeof(text),"get %s",key); cli.call(sender,text,reply);
assert(!strncmp(reply+2,state,strlen(state)));
}
}
unsigned saves=cli.callbacks.saves;
cli.call(sender,"set radio 916,125,8,6",reply);
assert(!strncmp(reply,"OK",2) && cli.callbacks.saves==saves+1);
cli.call(sender,"get radio",reply);
float freq=0,bw=0; int sf=0,cr=0;
assert(sscanf(reply,"> %f,%f,%d,%d",&freq,&bw,&sf,&cr)==4);
assert(freq==916 && bw==125 && sf==8 && cr==6);
saves=cli.callbacks.saves;
cli.call(sender,"set extra.sf 9,10",reply);
#if defined(USE_LR2021)
assert(!strncmp(reply,"OK",2) && cli.callbacks.saves==saves+1);
assert(cli.prefs.extra_sf[0]==9 && cli.prefs.extra_sf[1]==10 && cli.prefs.extra_sf[2]==0);
cli.call(sender,"get extra.sf",reply); assert(!strcmp(reply,"9,10"));
saves=cli.callbacks.saves;
cli.call(sender,"set extra.sf 8",reply);
assert(strstr(reply,"Invalid") && cli.callbacks.saves==saves && cli.prefs.extra_sf[0]==9);
cli.call(sender,"set extra.sf none",reply);
assert(!strncmp(reply,"OK",2) && cli.prefs.extra_sf[0]==0);
cli.call(sender,"get extra.sf",reply); assert(!strcmp(reply,"No extra SF configured"));
#else
assert(strstr(reply,"requires an LR2021") && cli.callbacks.saves==saves);
cli.call(sender,"get extra.sf",reply);
assert(strstr(reply,"requires an LR2021") && cli.prefs.extra_sf[0]==0);
#endif
// A failed hardware apply must not persist or advertise a changed setting.
cli.callbacks.accept=false;
const int8_t old_power=cli.prefs.tx_power_dbm;
const bool old_gain=cli.prefs.rx_boosted_gain;
saves=cli.callbacks.saves;
cli.call(sender,"set tx 16",reply);
assert(!strncmp(reply,"Error",5) && cli.prefs.tx_power_dbm==old_power);
cli.call(sender,"set radio.rxgain on",reply);
assert(!strncmp(reply,"Error",5) && cli.prefs.rx_boosted_gain==old_gain);
assert(cli.callbacks.saves==saves);
cli.call(sender,"set unknown.setting 1",reply);
assert(!strcmp(reply,"unknown config") && cli.callbacks.saves==saves);
}
}
+148
View File
@@ -0,0 +1,148 @@
#include <Arduino.h>
#include <helpers/RadioProfileCLI.h>
#include <cassert>
#include <cstdio>
#include <cstring>
struct Clock : mesh::RTCClock {
uint32_t epoch = 1700000000;
uint32_t getCurrentTime() override { return epoch; }
void setCurrentTime(uint32_t time) override { epoch = time; }
};
struct Radio : mesh::Radio {
mesh::RadioProfiles p;
Radio() { p.primary.freq=909.5; p.primary.bw=62.5; p.primary.sf=7; p.primary.cr=5; }
mesh::RadioProfiles* profiles() override { return &p; }
const mesh::RadioProfiles* profiles() const override { return &p; }
bool validateProfile(const mesh::RadioProfileParams& v) const override { return mesh::RadioProfiles::valid(v) && v.bw<=500; }
uint16_t profilePreamble(uint8_t profile) const override { return p.preamble(profile, 32); }
int recvRaw(uint8_t*,int) override { return 0; }
uint32_t getEstAirtimeFor(int) override { return 10; }
float packetScore(float,int) override { return 0; }
bool startSendRaw(const uint8_t*,int) override { return true; }
bool isSendComplete() override { return true; }
void onSendFinished() override {}
bool isInRecvMode() const override { return true; }
};
struct Fixture {
MemoryFS fs;
Clock clock;
Radio radio;
mesh::RadioProfileCLI cli;
char reply[160];
Fixture() { g_mock_millis=0; cli.begin(&fs,&radio,&clock); }
const char* cmd(const char* text, bool ok=true) {
memset(reply,0x55,sizeof(reply));
assert(cli.handle(text,reply,sizeof(reply)));
assert(memchr(reply,0,sizeof(reply)));
if (strncmp(text,"get ",4) && strncmp(text,"del ",4)) {
if ((!strncmp(reply,"OK",2))!=ok) { fprintf(stderr,"%s -> %s\n",text,reply); assert(false); }
}
return reply;
}
void advance(uint32_t ms, bool epoch=true) { g_mock_millis+=ms; if(epoch)clock.epoch+=ms/1000; cli.loop(); }
};
int main() {
{
Fixture f;
assert(!strcmp(f.cmd("get radio2"),"> off"));
assert(!strcmp(f.cmd("get radio2.cross"),"> auto"));
f.cmd("set radio2 910.5,500,7,5,rxtx");
assert(!f.radio.p.enabled()); f.advance(2000); assert(f.radio.p.enabled());
assert(strstr(f.cmd("get radio2"),"rxtx,120 (auto)"));
assert(strstr(f.cmd("get radio2.scan"),"slow=radio; listen_us=9831,6937"));
f.cmd("set tempradio2 911.5,500,8,5,rx,2,64");
assert(!f.radio.p.secondary_temporary); f.advance(2000);
assert(f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq==911.5f);
assert(!f.radio.p.canCross());
assert(strstr(f.cmd("get tempradio2"),"0d0h2m"));
f.cmd("set radio2.cross on"); assert(f.radio.p.canCross());
f.cmd("set radio2.cross off"); assert(!f.radio.p.canCross());
f.cmd("set tempradio2 off"); f.advance(2000);
assert(!f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq==910.5f);
f.cmd("set radio2 off"); f.advance(2000); assert(!f.radio.p.enabled());
f.cmd("get radio2.status");
}
{
Fixture f;
const char* invalid[] = {"910.5,500,7,5", "NaN,500,7,5,rx", "910.5,0,7,5,rx",
"910.5,500,256,5,rx", "910.5,500,7,256,rx", "910.5,500,7,5,invalid",
"910.5,500,7,5,rx,7", "910.5,500,7,5,rx,65535", "910.5,500,7,5,rx,32,junk",
"910.5,500,7,5,rx,32,", "910.5,500,7,5,rx,32junk"};
char text[160];
for (auto args : invalid) { snprintf(text,sizeof(text),"set radio2 %s",args); f.cmd(text,false); assert(!f.radio.p.enabled()); }
f.cmd("set tempradio2 910.5,500,7,5,rxtx,0",false);
f.cmd("set tempradio2 910.5,500,7,5,rxtx,10081",false);
f.cmd("set radio2.status 1",false);
assert(!f.cli.handle("set radio2junk 910.5",f.reply));
}
{
Fixture f;
f.cmd("set radio2 910.5,500,9,5,rxtx,40"); f.advance(2000);
f.fs.fail_write=true;
f.cmd("set radio2 off",false); assert(f.radio.p.enabled());
f.fs.fail_write=false;
f.fs.fail_rename=2; // publishing temp fails after moving old image to backup
f.cmd("set radio2 off",false); assert(f.radio.p.enabled());
Radio reboot; mesh::RadioProfileCLI restored; restored.begin(&f.fs,&reboot,&f.clock);
assert(reboot.p.enabled() && reboot.p.secondary.params.preamble==40);
f.cmd("set tempradio2 911.5,500,7,5,rxtx,1"); f.advance(2000);
Radio again; mesh::RadioProfileCLI restored_again; restored_again.begin(&f.fs,&again,&f.clock);
assert(!again.p.secondary_temporary && again.p.secondary.params.freq==910.5f);
}
{
Fixture f;
f.cmd("set tempradio2 910.5,500,7,5,rxtx,1"); f.advance(2000);
f.clock.epoch-=3600; f.advance(60000,false);
assert(!f.radio.p.enabled());
g_mock_millis=UINT32_MAX-1000; f.cli.loop();
f.cmd("set tempradio2 910.5,500,7,5,rxtx,1"); f.advance(2000,false);
assert(f.radio.p.secondary_temporary); f.advance(60000,false); assert(!f.radio.p.enabled());
}
{
Fixture f;
assert(f.cli.savePrimaryPreamble(48));
f.radio.p.primary.preamble=96; f.radio.p.primary_temporary=true;
strcpy(f.reply, "> 909.5,62.5,7,5");
f.cli.appendSavedPreamble(f.reply, sizeof(f.reply), 7, 62.5);
assert(strstr(f.reply, "preamble=48")); // saved getter cannot display temporary preamble
assert(!f.cli.savePrimaryPreamble(7));
f.cmd("set tempradio2 910.5,500,8,5,rx,1");
f.advance(65000); // servicing a late start cannot extend its lease
assert(!f.radio.p.enabled());
f.cmd("set tempradio2 910.5,500,8,5,rx,2");
char text[160];
snprintf(text, sizeof(text), "set tempradioat2 910.5,500,8,5,rx,%lu,%lu",
(unsigned long)(f.clock.epoch+60),(unsigned long)(f.clock.epoch+120));
f.cmd(text, false); // overlaps the pending session, before it starts
}
{
Fixture f;
f.cmd("set radio2 910.5,7.8,12,8,rx,65528",false); // driver airtime overflow
f.cmd("set radio2 910.5,500,8,5,rx,48");
assert(f.fs.rename("/radio_profiles", "/radio_profiles.bak"));
Radio radio; mesh::RadioProfileCLI restored;
restored.begin(&f.fs, &radio, &f.clock);
assert(radio.p.enabled()); // interrupted publication recovered
f.fs.files["/radio_profiles"][2]=99;
Radio corrupt; mesh::RadioProfileCLI invalid;
invalid.begin(&f.fs, &corrupt, &f.clock);
assert(!corrupt.p.enabled());
assert(invalid.handle("set radio2 off", f.reply));
assert(strstr(f.reply,"Error")); // newer/corrupt image is not overwritten
}
{
Fixture f;
char text[160];
snprintf(text,sizeof(text),"set tempradioat2 910.5,500,8,5,rxtx,%lu,%lu,48",
(unsigned long)(f.clock.epoch+60),(unsigned long)(f.clock.epoch+120));
f.cmd(text); assert(strstr(f.cmd("get tempradioat2 1"),"48"));
f.cmd(text,false); // overlap
f.advance(60000); assert(f.radio.p.secondary_temporary);
f.clock.epoch-=3600; f.advance(60000,false); assert(!f.radio.p.enabled());
snprintf(text,sizeof(text),"set radioat2 910.5,500,9,5,rx,%lu",(unsigned long)(f.clock.epoch+60));
f.cmd(text); f.advance(60000); assert(f.radio.p.secondary.mode==mesh::RadioProfileMode::Rx);
f.cmd("del radioat2 all"); f.cmd("del tempradioat2 all");
}
puts("RadioProfileCLI: command, persistence, failure, schedule and rollover tests passed");
}
+299
View File
@@ -0,0 +1,299 @@
// Included by test_trace_retry.cpp to exercise the production Mesh/Dispatcher.
class DualProfileTestRadio : public RetryCodingRateRadio {
public:
mesh::RadioProfiles config;
uint8_t selected = 0;
int busy_profile = -1;
bool fail_next_send = false;
uint32_t estimated_airtime = 10;
std::vector<uint8_t> incoming;
std::vector<uint8_t> transmissions;
DualProfileTestRadio() {
config.primary.freq = 909.5f; config.primary.bw = 62.5f;
config.primary.sf = 7; config.primary.cr = 5;
config.secondary.params = config.primary;
config.secondary.params.freq = 910.5f;
config.secondary.params.bw = 500;
config.secondary.mode = mesh::RadioProfileMode::RxTx;
}
mesh::RadioProfiles* profiles() override { return &config; }
const mesh::RadioProfiles* profiles() const override { return &config; }
uint32_t getEstAirtimeFor(int) override { return estimated_airtime; }
uint8_t receiveProfile() const override { return selected; }
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t p) override {
if (!incoming.empty()) return mesh::RadioParamApplyResult::BUSY;
if (!config.canTransmit(p)) return mesh::RadioParamApplyResult::FAILED;
selected = p; cr = config.params(p).cr;
return mesh::RadioParamApplyResult::APPLIED;
}
bool isReceiving() override { return selected == busy_profile; }
bool isReceivingPassive(int) override { return isReceiving(); }
bool startSendRaw(const uint8_t* bytes, int size) override {
if (fail_next_send) { fail_next_send = false; return false; }
transmissions.push_back(selected);
return RetryCodingRateRadio::startSendRaw(bytes, size);
}
int recvRaw(uint8_t* bytes, int size) override {
if (incoming.empty() || (int)incoming.size() > size) return 0;
const int length = incoming.size();
memcpy(bytes, incoming.data(), length); incoming.clear();
return length;
}
};
class DualProfileTestMesh : public RetryCodingRateMesh {
public:
using RetryCodingRateMesh::RetryCodingRateMesh;
mesh::Packet* replyOn(uint8_t profile, uint32_t generation) {
ReceiveProfileScope context(*this, profile, generation);
auto* packet = obtainNewPacket();
if (!packet) return nullptr;
packet->header = (PAYLOAD_TYPE_RESPONSE << PH_TYPE_SHIFT) | ROUTE_TYPE_DIRECT;
packet->setPathHashSizeAndCount(1, 0);
packet->payload_len = 1; packet->payload[0] = 0x5a;
return packet;
}
};
class DualProfileTest : public testing::Test {
protected:
TraceTestClock clock;
TraceTestRTC rtc;
TraceTestRNG rng;
DualProfileTestRadio radio;
TraceTestTables tables;
StaticPoolPacketManager manager{40};
DualProfileTestMesh node{radio, clock, rng, rtc, manager, tables};
void SetUp() override { node.begin(); node.flood_attempts = 2; }
mesh::Packet* queue(uint8_t type = PAYLOAD_TYPE_GRP_TXT) {
auto* p = node.obtainNewPacket();
if (!p) return nullptr;
*p = makeFloodPacket(type);
if (!node.sendFlood(p)) return nullptr;
return p;
}
void tick(uint32_t ms = 1) { clock.now += ms; node.loop(); }
};
TEST(RadioProfiles, CrossPolicyAndReceiveOnlyMatrix) {
mesh::RadioProfiles p;
for (int a = 0; a < 2; ++a) for (int b = 0; b < 2; ++b) {
p.primary_temporary = a; p.secondary_temporary = b;
for (int policy = 0; policy < 3; ++policy) {
p.cross = (mesh::RadioCrossMode)policy;
const bool cross = policy == 1 || (policy == 0 && a == b);
p.secondary.mode = mesh::RadioProfileMode::RxTx;
EXPECT_EQ(cross ? 3 : 1, p.transmitMask(0));
EXPECT_EQ(cross ? 3 : 2, p.transmitMask(1));
p.secondary.mode = mesh::RadioProfileMode::Rx;
EXPECT_EQ(1, p.transmitMask(0));
EXPECT_EQ(cross ? 1 : 0, p.transmitMask(1));
p.secondary.mode = mesh::RadioProfileMode::Off;
EXPECT_EQ(1, p.transmitMask(0));
}
}
}
TEST(RadioProfiles, AutomaticPreamblesIncludeMeasuredSwitchingMargin) {
DualProfileTestRadio radio;
auto& p = radio.config;
const uint16_t expected[] = {120, 88, 48};
for (int sf = 7; sf <= 9; ++sf) {
p.secondary.params.sf = sf;
EXPECT_EQ(expected[sf-7], p.preamble(1, 32));
EXPECT_EQ(32, p.preamble(0, 32));
EXPECT_EQ(0, p.preamble(1, 32) % 8);
EXPECT_EQ(9831, p.listenUs(0));
EXPECT_EQ(6937, p.listenUs(1));
EXPECT_LE(2 * (p.listenUs(0) + p.listenUs(1) + 2 * p.SwitchBudgetUs + p.LoopBudgetUs),
p.preamble(0, 32) * p.symbolUs(p.primary));
EXPECT_LE(p.listenUs(1) + 2 * p.SwitchBudgetUs + p.LoopBudgetUs
+ p.AcquisitionSymbols * p.symbolUs(p.primary), p.preamble(0, 32) * p.symbolUs(p.primary));
}
p.secondary.params.preamble = 40;
EXPECT_EQ(40, p.preamble(1, 32));
p.secondary.mode = mesh::RadioProfileMode::Off;
EXPECT_EQ(16, p.preamble(0, 16));
}
TEST(RadioProfiles, SlowerProfileDeterminesOrderAndFasterReceiveWindow) {
DualProfileTestRadio radio;
auto& p = radio.config;
EXPECT_EQ(0, p.slowerProfile());
std::swap(p.primary, p.secondary.params);
EXPECT_EQ(1, p.slowerProfile());
EXPECT_EQ(9831, p.listenUs(1));
EXPECT_EQ(6937, p.listenUs(0));
p.secondary.params.preamble = 40;
EXPECT_EQ(15129, p.listenUs(0));
p.secondary.params.preamble = 8;
EXPECT_FALSE(p.automaticPreambleFits()); // no safe time for the faster channel
}
TEST_F(DualProfileTest, TransmitsIdenticalMessageOnBothProfilesOnce) {
node.flood_attempts = 0;
ASSERT_NE(nullptr, queue());
ASSERT_EQ(2, manager.getOutboundTotal());
auto* a = manager.getOutboundByIdx(0);
auto* b = manager.getOutboundByIdx(1);
EXPECT_EQ(a->payload_len, b->payload_len);
EXPECT_EQ(0, memcmp(a->payload, b->payload, a->payload_len));
tick(); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
radio.complete = true; tick();
radio.complete = true; tick();
EXPECT_EQ((std::vector<uint8_t>{0, 1}), radio.transmissions);
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, DefaultSeparatesNormalTrafficFromTemporaryOta) {
radio.config.secondary_temporary = true;
ASSERT_NE(nullptr, queue());
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(0, manager.getOutboundByIdx(0)->radio_profile);
ASSERT_NE(nullptr, queue(PAYLOAD_TYPE_OTA));
ASSERT_EQ(2, manager.getOutboundTotal());
EXPECT_EQ(1, manager.getOutboundByIdx(1)->radio_profile);
}
TEST_F(DualProfileTest, ReceiveOnlyNeverTransmitsSecondary) {
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
ASSERT_NE(nullptr, queue());
EXPECT_EQ(1, manager.getOutboundTotal());
tick(); EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
}
TEST_F(DualProfileTest, ReceiveOnlyTemporaryOtaDoesNotLeakOntoNormalChannel) {
radio.config.secondary_temporary = true;
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
EXPECT_EQ(nullptr, queue(PAYLOAD_TYPE_OTA));
EXPECT_EQ(0, manager.getOutboundTotal());
EXPECT_EQ(40, manager.getFreeCount());
tick(); EXPECT_TRUE(radio.transmissions.empty());
}
TEST_F(DualProfileTest, ExplicitCrossAllowsReceiveOnlyTemporaryOtaOnPrimary) {
radio.config.secondary_temporary = true;
radio.config.secondary.mode = mesh::RadioProfileMode::Rx;
radio.config.cross = mesh::RadioCrossMode::On;
ASSERT_NE(nullptr, queue(PAYLOAD_TYPE_OTA));
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(1, manager.getOutboundByIdx(0)->radio_origin);
EXPECT_EQ(0, manager.getOutboundByIdx(0)->radio_profile);
tick(); EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
}
TEST_F(DualProfileTest, BusyPrimaryDoesNotBlockSecondaryQueue) {
node.flood_attempts = 0;
radio.busy_profile = 0;
ASSERT_NE(nullptr, queue());
tick(); EXPECT_FALSE(radio.sending);
tick(); EXPECT_EQ((std::vector<uint8_t>{1}), radio.transmissions);
}
TEST_F(DualProfileTest, PacketArrivingAfterFailedTransmitCannotDeadlockRadioRetry) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_FALSE(radio.sending);
radio.incoming = {uint8_t(ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT)), 0, 0x42};
tick(500);
EXPECT_TRUE(radio.incoming.empty());
EXPECT_EQ(1, radio.config.rx_packets[0]);
EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
radio.complete = true; tick();
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, AirtimeBudgetWaitDoesNotPinReceiverToQueuedTransmitProfile) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.estimated_airtime = 100000000;
ASSERT_NE(nullptr, queue());
radio.selected = 1; // scanner is currently on the other channel
tick();
EXPECT_FALSE(radio.sending);
EXPECT_EQ(1, radio.selected);
tick();
EXPECT_EQ(1, radio.selected);
}
TEST_F(DualProfileTest, DirectRetriesKeepSeparateCodingRatesAndEchoOwnership) {
node.direct_attempts = 2;
radio.config.secondary.params.cr = 7;
const uint8_t route[] = {0x11, 0x22};
ASSERT_NE(nullptr, makeDirectText(node, 0x55, route, sizeof(route)));
tick(); radio.complete = true; tick();
radio.complete = true; tick();
radio.complete = true; tick();
ASSERT_EQ(2, manager.getOutboundTotal());
auto* a = manager.getOutboundByIdx(0);
auto* b = manager.getOutboundByIdx(1);
ASSERT_NE(a->radio_profile, b->radio_profile);
EXPECT_EQ((std::vector<uint8_t>{5, 7}), radio.transmitted_crs);
mesh::Packet echo = *a;
echo.radio_bound = false; echo.radio_local = false;
echo.setPathHashCount(a->getPathHashCount() - 1);
node.receivePacket(&echo);
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(b, manager.getOutboundByIdx(0));
}
TEST_F(DualProfileTest, ExpiredSecondaryPacketsAreDiscarded) {
node.flood_attempts = 0;
radio.config.secondary_temporary = true;
ASSERT_NE(nullptr, queue(PAYLOAD_TYPE_OTA));
radio.config.setSecondary({}, false);
tick();
EXPECT_TRUE(radio.transmissions.empty());
EXPECT_EQ(0, manager.getOutboundTotal());
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, EchoOnOneProfileDoesNotCancelOtherProfileRetry) {
ASSERT_NE(nullptr, queue());
tick(); radio.complete = true; tick();
radio.complete = true; tick();
radio.complete = true; tick();
ASSERT_EQ(2, manager.getOutboundTotal());
auto* first = manager.getOutboundByIdx(0);
auto* second = manager.getOutboundByIdx(1);
EXPECT_NE(first->radio_profile, second->radio_profile);
mesh::Packet echo = *first;
echo.radio_bound = false; echo.radio_local = false;
echo.setPathHashCount(first->getPathHashCount() + 1);
node.receivePacket(&echo);
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(second, manager.getOutboundByIdx(0));
EXPECT_NE(echo.radio_profile, second->radio_profile);
}
TEST_F(DualProfileTest, DeferredReplyRetainsItsReceiveProfileAndSession) {
radio.config.secondary_temporary = true;
auto* reply = node.replyOn(1, radio.config.generation[1]);
ASSERT_NE(nullptr, reply);
ASSERT_TRUE(node.sendPacket(reply, 0));
ASSERT_EQ(1, manager.getOutboundTotal());
EXPECT_EQ(1, manager.getOutboundByIdx(0)->radio_profile);
auto* local = node.obtainNewPacket();
ASSERT_NE(nullptr, local);
EXPECT_TRUE(local->radio_local); // the scope did not leak into later local work
node.releasePacket(local);
auto* stale = node.replyOn(1, radio.config.generation[1]);
radio.config.setSecondary({}, false);
EXPECT_FALSE(node.sendPacket(stale, 0));
tick();
EXPECT_EQ(40, manager.getFreeCount());
EXPECT_TRUE(radio.transmissions.empty());
}
TEST_F(DualProfileTest, ChangedPolicyRemovesCrossCopiesBeforeTransmission) {
node.flood_attempts = 0;
ASSERT_NE(nullptr, queue());
radio.config.cross = mesh::RadioCrossMode::Off;
tick();
radio.complete = true; tick();
EXPECT_EQ((std::vector<uint8_t>{0}), radio.transmissions);
EXPECT_EQ(40, manager.getFreeCount());
}
@@ -0,0 +1,52 @@
#pragma once
#include <map>
#include <string>
#include <vector>
#include <cstring>
#include <algorithm>
class MemoryFS;
class File {
MemoryFS* fs_ = nullptr;
std::string path_;
size_t cursor_ = 0;
public:
File() = default;
File(MemoryFS* fs, const char* path) : fs_(fs), path_(path) {}
operator bool() const { return fs_ != nullptr; }
size_t size() const;
int read(uint8_t* data, size_t size);
size_t write(const uint8_t* data, size_t size);
void flush() {}
void close() { fs_ = nullptr; }
};
class MemoryFS {
public:
std::map<std::string, std::vector<uint8_t>> files;
bool fail_write = false;
int fail_rename = 0;
bool exists(const char* path) const { return files.count(path); }
bool remove(const char* path) { return files.erase(path); }
bool rename(const char* from, const char* to) {
if (fail_rename > 0 && --fail_rename == 0) return false;
if (!exists(from) || exists(to)) return false;
files[to] = files[from]; files.erase(from); return true;
}
File open(const char* path, const char* mode) {
if (*mode == 'w') files[path].clear();
if (!exists(path)) return {};
return File(this, path);
}
};
inline size_t File::size() const { return fs_ ? fs_->files[path_].size() : 0; }
inline int File::read(uint8_t* data, size_t size) {
if (!fs_) return -1;
auto& bytes = fs_->files[path_];
size = std::min(size, bytes.size() - cursor_);
memcpy(data, bytes.data() + cursor_, size); cursor_ += size; return size;
}
inline size_t File::write(const uint8_t* data, size_t size) {
if (!fs_ || fs_->fail_write) return 0;
auto& bytes = fs_->files[path_];
bytes.insert(bytes.end(), data, data + size); return size;
}
#define FILESYSTEM MemoryFS
+245
View File
@@ -0,0 +1,245 @@
"""Audit CLI get/set coverage; execute migrated radio settings through real branches.
The inventory checks parser entry points, not documentation/whitelist mentions.
Read-only queries and settings written through another command have explicit
exceptions. Hardware callbacks are mocked in the native round-trip test.
"""
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
COMMON = ROOT / 'src/helpers/CommonCLI.cpp'
OBSERVER = ROOT / 'src/helpers/CommonCLI_Observer.cpp'
RADIO = ROOT / 'src/helpers/CommonRadioPrefs.cpp'
TOKEN = re.compile(r'//[^\n]*|/\*[\s\S]*?\*/|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]')
def literal_keys(method, variable='config', top_level=True):
"""Extract literal key comparisons, excluding nested comparisons of values."""
matches = list(re.finditer(
r'(?:memcmp|strncmp|strcmp|configKeyEquals)\(' + variable +
r',\s*"([a-z][\w.]*)(?: [^"\n]*)?"', method))
depth, cursor, result = 0, 0, set()
tokens = list(TOKEN.finditer(method))
for match in matches:
while cursor < len(tokens) and tokens[cursor].start() < match.start():
depth += (tokens[cursor].group() == '{') - (tokens[cursor].group() == '}')
cursor += 1
if not top_level or depth == 1:
result.add(match[1])
return result
def methods(text, prefix):
return tuple(extract_braced(text, f'{prefix}{verb}Cmd(') for verb in ('Set', 'Get'))
def full_command_keys(text):
comparisons = re.findall(
r'(?:strcmp|strncmp|memcmp)\((?:command|cmd|text),\s*"(get|set) ([a-z][\w.]*)[ "]', text)
return tuple({key for kind, key in comparisons if kind == verb} for verb in ('set', 'get'))
COMMON_QUERY_EXCEPTIONS = {
'bootloader.ver': 'Installed bootloader identity',
'bridge.running': 'Live bridge state; set bridge.enabled controls intent',
'bridge.type': 'Build-selected bridge transport',
'password': 'Written by the password command, not set password',
'public.key': 'Derived from identity; written through set prv.key',
'pwrmgt.bootmv': 'Boot-time voltage measurement',
'pwrmgt.bootreason': 'Boot/reset cause',
'pwrmgt.source': 'Measured power source',
'pwrmgt.support': 'Hardware power-management capabilities',
'radio.rxps.config': 'Detailed view of set radio.rxps',
'role': 'Build-selected firmware role',
'rxps.wd': 'Runtime RX power-saving recovery counters',
'wifi.pwd': 'Setter is in the observer or standalone-WiFi delegate',
}
OBSERVER_QUERY_EXCEPTIONS = {
'mqtt.config.valid': 'Configuration validation result',
'mqtt.ntp.diag': 'NTP diagnostics',
'mqtt.presets': 'List of available presets',
'mqtt.running': 'Live state; set mqtt.enabled controls intent',
'mqtt.stats': 'Runtime MQTT statistics',
'wifi.status': 'Live connection status',
}
class CLISettingsContractTest(unittest.TestCase):
def test_feature_guarded_query_coverage(self):
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler, 'Host C++ compiler required')
profiles = {
'minimal': [],
'nrf52_gps_sd': ['NRF52_PLATFORM', 'ENV_INCLUDE_GPS', 'OTA_SD_STORE', 'NRF52_POWER_MANAGEMENT'],
'esp32_web': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'ADMIN_PASSWORD', 'MESH_USB_LOGGING_AVAILABLE'],
'esp32_mqtt': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_MQTT_BRIDGE', 'WITH_BRIDGE',
'WITH_MQTT_NEIGHBORS', 'MESH_USB_LOGGING_AVAILABLE'],
'rs232_gps': ['WITH_BRIDGE', 'WITH_RS232_BRIDGE', 'ENV_INCLUDE_GPS'],
'espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE', 'WITH_ESPNOW_BRIDGE', 'MESH_PRIMARY_ESPNOW'],
'lr2021': ['USE_LR2021'],
}
for path, prefix, exceptions in (
(COMMON, 'void CommonCLI::handle', COMMON_QUERY_EXCEPTIONS),
(OBSERVER, 'bool CommonCLI::handleObserver', OBSERVER_QUERY_EXCEPTIONS),
):
original = '\n'.join(methods(path.read_text(encoding='utf-8'), prefix))
for profile, macros in profiles.items():
with self.subTest(parser=path.name, profile=profile):
# Preprocess the production bodies so a setter behind a
# different feature guard cannot satisfy a visible getter.
result = subprocess.run(
[compiler, '-E', '-P', '-x', 'c++', *[f'-D{key}=1' for key in macros], '-'],
input=original, text=True, capture_output=True, check=True)
setter, getter = methods(result.stdout, prefix)
sets, gets = literal_keys(setter), literal_keys(getter)
gets = {'flood.retry.bucket' if k == 'flood.retry.bucket.' else k for k in gets}
self.assertFalse(gets - sets - set(exceptions))
if path == COMMON:
self.assertIn('path.hash.mode', sets)
def test_common_and_observer_query_coverage(self):
for path, prefix, exceptions in (
(COMMON, 'void CommonCLI::handle', COMMON_QUERY_EXCEPTIONS),
(OBSERVER, 'bool CommonCLI::handleObserver', OBSERVER_QUERY_EXCEPTIONS),
):
with self.subTest(parser=path.name):
setter, getter = methods(path.read_text(encoding='utf-8'), prefix)
sets, gets = literal_keys(setter), literal_keys(getter)
# The query embeds the bucket index in the key; the setter
# accepts it as a separate argument.
gets = {'flood.retry.bucket' if k == 'flood.retry.bucket.' else k for k in gets}
self.assertEqual(gets - sets, set(exceptions),
f'{path.name}: missing setter or undocumented read-only query')
def test_mqtt_slot_query_coverage(self):
setter, getter = methods(OBSERVER.read_text(encoding='utf-8'), 'bool CommonCLI::handleObserver')
sets = literal_keys(setter, 'subcmd', False)
gets = literal_keys(getter, 'subcmd', False)
self.assertGreater(len(sets), 5, 'MQTT slot parser moved; update the audit')
self.assertEqual(gets - sets, {'diag'})
def test_every_migrated_radio_setter_is_reachable_in_infrastructure(self):
common_set, common_get = methods(COMMON.read_text(encoding='utf-8'), 'void CommonCLI::handle')
shared = extract_braced(RADIO.read_text(encoding='utf-8'), 'bool CommonRadioPrefs::handleCommand(')
migrated = set(re.findall(r'startsWith\(command, "set ([\w.]+) ', shared))
self.assertEqual(len(migrated), 13, 'Review changes to the shared radio command surface')
self.assertFalse(migrated - literal_keys(common_set))
self.assertFalse(migrated - literal_keys(common_get))
def test_split_parser_delegates_are_wired(self):
common = COMMON.read_text(encoding='utf-8')
setter, getter = methods(common, 'void CommonCLI::handle')
self.assertIn('handleObserverSetCmd(sender_timestamp, config, reply)', setter)
self.assertIn('handleObserverGetCmd(sender_timestamp, config, reply)', getter)
for role, filename in (('simple_repeater', 'MyMesh.cpp'),
('simple_room_server', 'MyMesh.cpp'),
('simple_sensor', 'SensorMesh.cpp')):
text = (ROOT/'examples'/role/filename).read_text(encoding='utf-8')
self.assertIn('_cli.handleCommand(sender_timestamp, command, reply)', text)
for verb in ('get', 'set'):
block = extract_braced(common, f'if (memcmp(command, "{verb} ", 4) == 0)')
self.assertIn(f'handle{verb.title()}Cmd(sender_timestamp, command, reply)', block)
def test_companion_allowlist_is_backed_by_shared_parser(self):
text = (ROOT/'examples/companion_radio/MyMesh.cpp').read_text(encoding='utf-8')
allowlist = extract_braced(text, 'static bool isCompanionRadioPrefsCommand(')
shared = extract_braced(RADIO.read_text(encoding='utf-8'), 'bool CommonRadioPrefs::handleCommand(')
for verb, key in re.findall(r'"(get|set) ([\w.]+) ?"', allowlist):
self.assertIn(f'"{verb} {key}' + (' ' if verb == 'set' else '') + '"', shared)
handler = extract_braced(text, 'bool MyMesh::handleCommand(')
self.assertIn('_prefs.getRadioPrefs()->handleCommand(', handler)
self.assertIn('_prefs.getRadioPrefs()->isDirty()', handler)
self.assertIn('savePrefs()', handler)
def test_role_query_coverage(self):
common_set, _ = methods(COMMON.read_text(encoding='utf-8'), 'void CommonCLI::handle')
common_keys = literal_keys(common_set)
for role, filename, exceptions in (
('simple_repeater', 'MyMesh.cpp', {
'acl', # ACL list, changed with ACL commands
'battery.alert.region', # set battery.alert on <region>
'clock.sync', # clock-sync delegate
'host', # Runtime host configuration view
'recent.repeaters', # Discovery/cache query
}),
('simple_room_server', 'MyMesh.cpp', {'acl'}),
('simple_sensor', 'SensorMesh.cpp', {'acl'}),
):
text = (ROOT/'examples'/role/filename).read_text(encoding='utf-8')
sets, gets = full_command_keys(text)
with self.subTest(role=role):
self.assertEqual(gets - sets - common_keys, exceptions)
companion = (ROOT/'examples/companion_radio/MyMesh.cpp').read_text(encoding='utf-8')
sets, gets = full_command_keys(companion)
terminal = extract_braced(companion, 'void MyMesh::handleTerminalCommand(')
terminal_set = extract_braced(terminal, 'if (strncmp(command, "set ", 4) == 0)')
sets |= literal_keys(terminal_set)
shared = extract_braced(companion, 'static bool isCompanionRadioPrefsCommand(')
sets |= {k for verb, k in re.findall(r'"(get|set) ([\w.]+) ?"', shared) if verb == 'set'}
self.assertEqual(gets - sets, {
'contact.cache', 'contact.cache.timing', # Contact-storage diagnostics
'display.wifi', # Connection/display status
'mqtt', 'mqtt.running', # MQTT connection status
'password', 'prv.key', # Local secret/identity reads
'pwrmgt.bootreason', 'role', # Boot/build facts
'radio.rxps.config', # Detail view of radio.rxps
'wifi.pwd', # Saved transport credentials
})
def test_literal_key_comparison_lengths(self):
for path in (COMMON, OBSERVER):
text = path.read_text(encoding='utf-8')
for match in re.finditer(r'(?:memcmp|strncmp)\((?:config|subcmd),\s*"([^"\n]*)",\s*(\d+)\)', text):
key, length = match[1], int(match[2])
with self.subTest(file=path.name, key=key):
# Some exact getters compare the terminating NUL too.
self.assertIn(length, (len(key), len(key) + 1))
if key.endswith(' '):
self.assertEqual(length, len(key), 'Setter must not compare the value against NUL')
def test_native_radio_setting_round_trips(self):
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler, 'Host C++ compiler required')
source = COMMON.read_text(encoding='utf-8')
setter, getter = methods(source, 'void CommonCLI::handle')
keys = ['radio', 'freq', 'af', 'dutycycle', 'int.thresh', 'cad', 'radio.rxgain',
'tx', 'rxdelay', 'agc.reset.interval', 'multi.acks', 'txdelay', 'direct.txdelay']
# Preserve the actual conditions and bodies. The only excluded branches
# are unrelated settings needing board-specific dependencies.
set_blocks = [extract_braced(setter, 'if (strncmp(config, "path.hash.mode", 14)')]
get_blocks = [extract_braced(getter, 'if (configKeyEquals(config, "path.hash.mode"))')]
set_blocks.append(extract_braced(setter, 'if (strcmp(config, "extra.sf") == 0 ||'))
get_blocks.append(extract_braced(getter, 'if (strcmp(config, "extra.sf") == 0)'))
for key in keys:
block = extract_braced(setter, f'if (memcmp(config, "{key} ", {len(key)+1}) == 0)')
# A feature guard immediately before this closing brace controls
# the following else-if arm, which is not part of this fixture.
block = re.sub(r'\n#if[^\n]*\n\s*}$', '\n}', block)
set_blocks.append(block)
get_blocks.append(extract_braced(getter, f'if (configKeyEquals(config, "{key}"))'))
dispatch = ' else '.join(extract_braced(source, f'if (memcmp(command, "{verb} ", 4) == 0)')
for verb in ('get', 'set'))
fixture = (ROOT/'test/fixtures/cli_settings/main.cpp').read_text(encoding='utf-8')
fixture = fixture.replace('@SET@', ' else '.join(set_blocks))
fixture = fixture.replace('@GET@', ' else '.join(get_blocks))
fixture = fixture.replace('@DISPATCH@', dispatch)
fixture = fixture.replace('@KEY_EQUALS@', extract_braced(source, 'static bool configKeyEquals('))
with tempfile.TemporaryDirectory() as directory:
cpp, exe = Path(directory)/'test.cpp', Path(directory)/'test.exe'
cpp.write_text(fixture, encoding='utf-8')
for macros in ([], ['-DUSE_LR2021=1']):
with self.subTest(macros=macros):
subprocess.run([compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', *macros,
'-I', str(ROOT/'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
@@ -9,12 +9,14 @@ TEST(DeferredCliCommand, CopiesAuthenticatedCommandContext) {
const char command[] = "del flood.moderation.all";
ASSERT_TRUE(deferred.enqueue(7, 123456U, 2, secret, command,
strlen(command), 654321U));
strlen(command), 654321U, 1, 91));
EXPECT_TRUE(deferred.pending);
EXPECT_EQ(7, deferred.client_index);
EXPECT_EQ(123456U, deferred.sender_timestamp);
EXPECT_EQ(654321U, deferred.request_id);
EXPECT_EQ(2, deferred.path_hash_size);
EXPECT_EQ(1, deferred.radio_profile);
EXPECT_EQ(91U, deferred.radio_generation);
EXPECT_EQ(0, memcmp(secret, deferred.secret, sizeof(secret)));
EXPECT_STREQ(command, deferred.command);
EXPECT_TRUE(deferred.matches(7, 654321U, command, strlen(command)));
+12
View File
@@ -14,6 +14,7 @@ PREAMBLE = r'''
#include <cstdint>
#include <initializer_list>
#include <helpers/CLICommandUtils.h>
#include <helpers/RadioProfileCommandUtils.h>
#define ESP32 1
#define WIFI_SSID "test"
#define WITH_MQTT_BRIDGE 1
@@ -22,6 +23,16 @@ PREAMBLE = r'''
#define MAX_LORA_TX_POWER 22
#define TXT_TYPE_CLI_COMMAND 1
namespace mesh {
struct RadioProfileCLI {
bool handle(const char*, char*, size_t) { return false; }
static bool parseSuffix(const char* input, unsigned fields, char* legacy, size_t size, uint16_t& preamble) {
return cli::parseRadioPreambleSuffix(input, fields, legacy, size, preamble);
}
bool acceptsPrimary(float, float, uint8_t, uint8_t, uint16_t) { return true; }
bool savePrimaryPreamble(uint16_t) { return true; }
uint16_t primaryPreamble() const { return 0; }
void appendSavedPreamble(char*, size_t, uint8_t, float) {}
};
struct Packet {};
void resetLazyPersistenceAfterSuccess(unsigned& when, uint8_t& failures) { when=0; failures=0; }
struct Utils {
@@ -77,6 +88,7 @@ struct WebConfigServer {
const char* FIRMWARE_VERSION="test";
const char* FIRMWARE_BUILD_DATE="test";
struct MyMesh {
mesh::RadioProfileCLI _radio_profiles;
Identity self_id;
Store store; Store* _store=&store;
Prefs _prefs;
+65
View File
@@ -0,0 +1,65 @@
"""Exercise the production infrastructure setter and its persistence boundary."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
class PathHashCLITest(unittest.TestCase):
def test_production_setter(self):
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler, 'Host C++ compiler required')
source = (ROOT/'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8')
setter = extract_braced(source, 'void CommonCLI::handleSetCmd(')
branch = extract_braced(setter, 'if (strncmp(config, "path.hash.mode", 14)')
fixture = r'''
#include <cassert>
#include <initializer_list>
#include <helpers/CLICommandUtils.h>
struct CLI {
struct Prefs { uint8_t path_hash_mode = 0; } prefs;
Prefs* _prefs = &prefs;
unsigned saves = 0;
void savePrefs() { ++saves; }
void set(const char* config, char* reply) {
''' + branch + r'''
strcpy(reply, "unknown config");
}
};
int main() {
CLI cli; char reply[160];
for (unsigned mode : {0u, 1u, 2u}) {
char command[64]; sprintf(command,"path.hash.mode %u",mode);
unsigned saves=cli.saves;
cli.set(command,reply);
assert(strcmp(reply,"OK")==0);
assert(cli.prefs.path_hash_mode==mode && cli.saves==saves+1);
}
cli.set("path.hash.mode\t1 ",reply);
assert(strcmp(reply,"OK")==0 && cli.prefs.path_hash_mode==1);
for (const char* value : {"", " ", " -1", " 3", " 256", " 4294967296", " nope", " 1x", " 1 2"}) {
char command[80]; sprintf(command,"path.hash.mode%s",value);
unsigned saves=cli.saves;
cli.set(command,reply);
assert(strcmp(reply,"Error, must be 0,1, or 2")==0);
assert(cli.prefs.path_hash_mode==1 && cli.saves==saves);
}
cli.set("path.hash.mode.extra 2",reply);
assert(strcmp(reply,"unknown config")==0 && cli.prefs.path_hash_mode==1);
}
'''
with tempfile.TemporaryDirectory() as directory:
cpp = Path(directory)/'test.cpp'
exe = Path(directory)/'test.exe'
cpp.write_text(fixture, encoding='utf-8')
subprocess.run([compiler, '-std=c++11', '-Wall', '-Wextra', '-Werror',
'-I', str(ROOT/'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
+25
View File
@@ -0,0 +1,25 @@
"""Run the production shared profile command parser and persistence with a memory FS."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class RadioProfileCLITest(unittest.TestCase):
def test_commands_persistence_and_timers(self):
compiler = shutil.which('g++') or shutil.which('clang++')
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory() as work:
exe = Path(work) / 'profiles.exe'
subprocess.run([compiler, '-std=c++17', '-Wall', '-Wextra',
'-I', str(ROOT/'test/fixtures/radio_profiles/mocks'),
'-I', str(ROOT/'test/mocks'), '-I', str(ROOT/'src'),
str(ROOT/'src/helpers/RadioProfileCLI.cpp'),
str(ROOT/'test/fixtures/radio_profiles/cli_test.cpp'),
'-o', str(exe)], check=True, capture_output=True, text=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
"""Exercise production profile transitions with a controllable physical radio."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <RadioProfiles.h>
#include <helpers/radiolib/RXPowerSaving.h>
#define RADIOLIB_ERR_NONE 0
#define STATE_IDLE 0
#define STATE_RX 1
#define STATE_TX_WAIT 3
#define STATE_INT_READY 16
#define MESH_DEBUG_PRINTLN(...) ((void)0)
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
static uint64_t elapsed_us;
static uint8_t state;
uint32_t micros() { return (uint32_t)elapsed_us; }
uint32_t millis() { return (uint32_t)(elapsed_us / 1000); }
struct Chip { int standby() { return 0; } };
struct RadioLibWrapper {
Chip chip; Chip* _radio = &chip;
mesh::RadioProfiles _profiles;
bool _params_valid=true, packet=false, busy=false, fail=false;
bool _rx_ps_enabled=true, _rx_ps_armed=true, _rx_ps_continuous_fallback=false;
bool _profile_saved_rxps=false, _profile_rxps_suspended=false;
bool _nf_calib_active=false, _noise_floor_valid=true, _profile_refresh_required=false;
uint32_t _rx_ps_rx_us=50000, _rx_ps_sleep_us=50000;
uint8_t _active_profile=0, _cur_sf=7, _cur_cr=5;
uint32_t _profile_generation=1, _profile_visit_us=0, _profile_retry_at=0;
uint32_t _profile_scan_generation[2]={};
uint16_t _physical_preamble=32;
float _cur_freq=909.5, _cur_bw=62.5;
unsigned applies=0;
RadioLibWrapper() {
state=STATE_RX; elapsed_us=100000;
auto& p=_profiles.primary;
p.freq=_cur_freq; p.bw=_cur_bw; p.sf=_cur_sf; p.cr=_cur_cr;
_profile_visit_us=micros();
}
bool isChipBusy() { return busy; }
bool isReceivingPacket() { return packet; }
bool isPacketPendingOrReceiving() { return packet || (state & STATE_INT_READY); }
bool supportsRxPowerSaving() { return true; }
void startRecv() { state=STATE_RX; _profile_visit_us=micros(); _rx_ps_armed=_rx_ps_enabled; }
void stopReceiveDutyCycle() { _rx_ps_armed=false; }
bool applyParams(float f,float,uint8_t,uint8_t) { ++applies; elapsed_us+=1200; return !fail || f==909.5f; }
void cacheParams(float f,float b,uint8_t s,uint8_t c) { _cur_freq=f;_cur_bw=b;_cur_sf=s;_cur_cr=c; }
bool restoreAfterDeepInit() { return true; }
void recalibrateNoiseFloor() { _noise_floor_valid=false; }
bool validateProfile(const mesh::RadioProfileParams& p) const { return mesh::RadioProfiles::valid(p); }
uint16_t profilePreamble(uint8_t n) const { return _profiles.preamble(n,32); }
uint8_t beginReconfigure();
void endReconfigure(bool);
void serviceProfileScan();
mesh::RadioParamApplyResult tuneProfile(uint8_t);
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t);
mesh::RadioParamApplyResult trySetParams(float,float,uint8_t,uint8_t,const uint32_t* = nullptr);
mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams&,bool,const uint32_t* = nullptr);
void enable() {
mesh::RadioProfileConfig second;
second.params=_profiles.primary;second.params.freq=910.5;second.params.bw=500;
second.mode=mesh::RadioProfileMode::RxTx;_profiles.setSecondary(second,false);
serviceProfileScan();
}
};
@METHODS@
int main() {
using Result=mesh::RadioParamApplyResult;
{
RadioLibWrapper w; w.enable();
assert(w._profile_rxps_suspended && !w._rx_ps_enabled && !w._rx_ps_armed);
elapsed_us+=w._profiles.listenUs(0)-1;w.serviceProfileScan();assert(w._active_profile==0);
elapsed_us++;w.serviceProfileScan();assert(w._active_profile==1);
elapsed_us+=w._profiles.listenUs(1)-1;w.serviceProfileScan();assert(w._active_profile==1);
elapsed_us++;w.serviceProfileScan();assert(w._active_profile==0);
elapsed_us+=w._profiles.listenUs(0);w.serviceProfileScan();assert(w._active_profile==1);
const auto generation=w._profile_generation;
w.packet=true;elapsed_us+=100000;w.serviceProfileScan();assert(w._active_profile==1);
assert(w.prepareTransmitProfile(0)==Result::BUSY);
w._profiles.setSecondary({},false);w.serviceProfileScan();
assert(w._active_profile==1 && w._profile_generation==generation);
w.packet=false;w.serviceProfileScan();
assert(w._active_profile==0 && w._rx_ps_enabled && !w._profile_rxps_suspended);
assert(w._rx_ps_armed);
w.serviceProfileScan(); assert(!w._profile_refresh_required);
}
{
RadioLibWrapper w;
w._profiles.primary.bw=500; w._cur_bw=500;
w.enable();
w._profiles.secondary.params.bw=62.5;
++w._profiles.generation[1];
w.serviceProfileScan();assert(w._active_profile==1); // slower channel first
elapsed_us+=w._profiles.listenUs(1)-1;w.serviceProfileScan();assert(w._active_profile==1);
elapsed_us++;w.serviceProfileScan();assert(w._active_profile==0);
}
{
RadioLibWrapper w;w.enable();w.fail=true;
elapsed_us+=100000;w.serviceProfileScan();
assert(w._profiles.switch_failures==1 && w._active_profile==0);
const auto applies=w.applies;
for (int i=0;i<10;++i) w.serviceProfileScan();
assert(w.applies==applies); // failed target is not hammered every loop
elapsed_us+=1001000;w.fail=false;w.serviceProfileScan();assert(w._active_profile==1);
}
{
RadioLibWrapper w;
// Zero is the inactive retry timer even after 24.8 days of uptime.
elapsed_us=uint64_t(0xf0000000UL)*1000;w.enable();
assert(w._profile_rxps_suspended);
elapsed_us+=100000;w.serviceProfileScan();assert(w._profiles.switches>=1);
}
{
RadioLibWrapper w;auto primary=w._profiles.primary;
auto temp=primary;temp.freq=910.5;temp.preamble=64;
w.packet=true;
assert(w.trySetPrimaryParams(temp,true)==Result::BUSY);
assert(w._profiles.primary==primary && !w._profiles.primary_temporary);
w.packet=false;w.fail=true;
assert(w.trySetPrimaryParams(temp,true)==Result::FAILED);
assert(w._profiles.primary==primary && w._physical_preamble==32);
w.fail=false;
assert(w.trySetPrimaryParams(temp,true)==Result::APPLIED);
assert(w._profiles.primary==temp && w._physical_preamble==64);
assert(w._profile_generation==w._profiles.generation[0]);
}
}
'''
class ProfileScanTest(unittest.TestCase):
def test_production_scan_and_transitions(self):
source=(ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
names=[('uint8_t','beginReconfigure'),('void','endReconfigure'),
('void','serviceProfileScan')]+[('mesh::RadioParamApplyResult',name) for name in
['tuneProfile','prepareTransmitProfile','trySetParams','trySetPrimaryParams']]
methods='\n'.join(method(source,f'{kind} RadioLibWrapper::{name}(') for kind,name in names)
with tempfile.TemporaryDirectory() as folder:
cpp=Path(folder)/'test.cpp'; exe=Path(folder)/'test.exe'
(Path(folder)/'Arduino.h').write_text('#pragma once\n#include <cstdint>\n#include <cmath>\n')
cpp.write_text(HARNESS.replace('@METHODS@',methods))
result=subprocess.run([os.environ.get('CXX','g++'),'-std=c++17','-Wall','-Wextra',
'-I',folder,'-I',str(ROOT/'src'),str(cpp),'-o',str(exe)],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
result=subprocess.run([str(exe)],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
if __name__=='__main__': unittest.main()
+5
View File
@@ -21,6 +21,7 @@ HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <initializer_list>
#include <RadioProfiles.h>
#include <helpers/radiolib/NoiseFloorEstimator.h>
#define STATE_IDLE 0
#define STATE_RX 1
@@ -39,6 +40,7 @@ HARNESS = r'''
static volatile uint8_t state = STATE_RX;
static uint32_t now_ms = 0;
uint32_t millis() { return now_ms; }
uint32_t micros() { return now_ms * 1000UL; }
void noInterrupts() {}
void interrupts() {}
void yield() { ++now_ms; }
@@ -56,6 +58,9 @@ struct Radio {
}
};
struct RadioLibWrapper {
mesh::RadioProfiles _profiles;
uint32_t _profile_visit_us = 0;
void serviceProfileScan() {} // separate profile-scan harness exercises tuning
Board board; Board* _board = &board;
Radio radio; Radio* _radio = &radio;
bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_ps_continuous_fallback = false;
+18 -5
View File
@@ -50,13 +50,18 @@ struct RTC {
uint32_t getCurrentTime() const { return now; }
};
struct Board { int reboots = 0; void reboot() { ++reboots; } };
struct CLI { Board board; Board* getBoard() { return &board; } };
struct CLI {
Board board; Board* getBoard() { return &board; }
CLI& radioProfiles() { return *this; }
bool savePrimaryPreamble(uint16_t) { return true; }
};
struct Radio { uint32_t last_rx = 0; uint32_t getLastRecvMillis() { return last_rx; } };
struct Barrier { bool held = false; bool waiting() const { return held; } void clear() { held = false; } };
struct ScheduledRadioSetting {
bool active = false, temporary = false, started = false;
float freq = 0, bw = 0;
uint8_t sf = 0, cr = 0;
uint16_t preamble = 0;
uint32_t start_time = 0, end_time = 0;
uint64_t hard_end_uptime_millis = 0;
};
@@ -100,7 +105,7 @@ public:
bool millisHasNowPassed(uint32_t deadline) const { return (int32_t)(now_ms - deadline) >= 0; }
bool hasOutbound() const { return outbound; }
bool hasStartedScheduledTempRadio() const { return scheduled_temp_radio_started; }
bool applyRadioParams(float, float, uint8_t, uint8_t) { ++applies; return apply_success; }
bool applyRadioParams(float, float, uint8_t, uint8_t, uint16_t = 0, bool = false) { ++applies; return apply_success; }
bool applySavedRadioParams() { ++restores; return restore_success; }
bool isValidScheduledRadioParams(float, float, uint8_t, uint8_t) { return true; }
void savePrefs() { ++saves; }
@@ -130,13 +135,13 @@ public:
void queueSavedRadioApply();
void refreshScheduledRadioState();
void processScheduledRadioSettings();
void applyTempRadioParams(float, float, uint8_t, uint8_t, int);
void applyTempRadioParams(float, float, uint8_t, uint8_t, int, uint16_t = 0);
bool scheduleNormalRadio();
void clearScheduledRadioSetting(int, bool);
int findFreeScheduledRadioSlot() const;
int countScheduledRadioSettings(bool) const;
bool scheduledRadioConflicts(bool, uint32_t, uint32_t) const;
void addScheduledRadioParams(bool, float, float, uint8_t, uint8_t, uint32_t, uint32_t, char*);
void addScheduledRadioParams(bool, float, float, uint8_t, uint8_t, uint32_t, uint32_t, char*, uint16_t = 0);
void servicePostMeshLoop();
};
@METHODS@
@@ -290,7 +295,14 @@ CLI_HARNESS = r'''
#include <initializer_list>
#include <helpers/RepeaterRadioTiming.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/RadioProfileCommandUtils.h>
namespace mesh {
struct RadioProfileCLI {
static bool parseSuffix(const char* input, unsigned fields, char* legacy, size_t size, uint16_t& preamble) {
return cli::parseRadioPreambleSuffix(input, fields, legacy, size, preamble);
}
bool acceptsPrimary(float, float, uint8_t, uint8_t, uint16_t) { return true; }
};
class Utils { public: static int parseTextParts(char*, const char*[], int, char separator=','); };
@PARTS@
}
@@ -300,7 +312,7 @@ RTC* getRTCClock() { return &rtc; }
struct Callbacks {
int calls = 0;
uint32_t duration = 0;
void applyTempRadioParams(float, float, uint8_t, uint8_t, int minutes) {
void applyTempRadioParams(float, float, uint8_t, uint8_t, int minutes, uint16_t) {
++calls; duration = (uint32_t)minutes * 60;
}
void appendTempRadioTimingNote(char* reply, size_t size, uint32_t seconds) {
@@ -309,6 +321,7 @@ struct Callbacks {
} callbacks;
void appendRxPowerSavingAdjustmentNote(char*, const void*, uint8_t, float) {}
void handle(const char* command, char* reply) {
mesh::RadioProfileCLI _radio_profiles;
char tmp[160];
Callbacks* _callbacks = &callbacks;
const void* _prefs = nullptr;
@@ -1415,3 +1415,5 @@ int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
#include "../fixtures/radio_profiles/mesh_tests.h"
File diff suppressed because it is too large. Load diff