From 3b6c894ee28f8ddde478baea99cb8bb98f2e3d7d Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 13 Sep 2026 08:25:00 -0700 Subject: [PATCH] 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. --- .github/workflows/run-unit-tests.yml | 8 + docs/cli_commands.md | 8 + docs/cli_settings_audit.md | 74 + docs/radio_profiles.md | 196 ++ docs/radio_profiles_validation.md | 204 ++ examples/companion_radio/MyMesh.cpp | 64 +- examples/companion_radio/MyMesh.h | 5 +- examples/simple_repeater/MyMesh.cpp | 55 +- examples/simple_repeater/MyMesh.h | 14 +- examples/simple_room_server/MyMesh.cpp | 17 +- examples/simple_room_server/MyMesh.h | 4 +- examples/simple_secure_chat/main.cpp | 8 + examples/simple_sensor/SensorMesh.cpp | 15 +- examples/simple_sensor/SensorMesh.h | 4 +- src/Dispatcher.cpp | 132 +- src/Dispatcher.h | 52 + src/Mesh.cpp | 180 +- src/Mesh.h | 22 +- src/Packet.cpp | 6 + src/Packet.h | 6 + src/RadioProfiles.h | 174 ++ src/helpers/CommonCLI.cpp | 86 +- src/helpers/CommonCLI.h | 10 +- src/helpers/DeferredCliCommand.h | 9 +- src/helpers/RadioProfileCLI.cpp | 421 ++++ src/helpers/RadioProfileCLI.h | 73 + src/helpers/RadioProfileCommandUtils.h | 27 + src/helpers/StaticPoolPacketManager.cpp | 14 +- src/helpers/StaticPoolPacketManager.h | 4 + src/helpers/radiolib/CustomLR2021Wrapper.h | 6 +- src/helpers/radiolib/RadioLibWrappers.cpp | 188 +- src/helpers/radiolib/RadioLibWrappers.h | 31 +- test/fixtures/cli_settings/main.cpp | 156 ++ test/fixtures/radio_profiles/cli_test.cpp | 148 ++ test/fixtures/radio_profiles/mesh_tests.h | 299 +++ .../mocks/helpers/IdentityStore.h | 52 + test/test_cli_settings_contract.py | 245 ++ .../test_deferred_cli_command.cpp | 4 +- test/test_local_cli_access.py | 12 + test/test_path_hash_cli.py | 65 + test/test_radio_profile_cli.py | 25 + test/test_radio_profile_scan.py | 153 ++ test/test_radio_receive_contract.py | 5 + .../test_repeater_radio_timing_integration.py | 23 +- test/test_trace_retry/test_trace_retry.cpp | 2 + tools/hil/radio_profiles_results.json | 2024 +++++++++++++++++ 46 files changed, 5187 insertions(+), 143 deletions(-) create mode 100644 docs/cli_settings_audit.md create mode 100644 docs/radio_profiles.md create mode 100644 docs/radio_profiles_validation.md create mode 100644 src/RadioProfiles.h create mode 100644 src/helpers/RadioProfileCLI.cpp create mode 100644 src/helpers/RadioProfileCLI.h create mode 100644 src/helpers/RadioProfileCommandUtils.h create mode 100644 test/fixtures/cli_settings/main.cpp create mode 100644 test/fixtures/radio_profiles/cli_test.cpp create mode 100644 test/fixtures/radio_profiles/mesh_tests.h create mode 100644 test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h create mode 100644 test/test_cli_settings_contract.py create mode 100644 test/test_path_hash_cli.py create mode 100644 test/test_radio_profile_cli.py create mode 100644 test/test_radio_profile_scan.py create mode 100644 tools/hil/radio_profiles_results.json diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 3c52af52..8fa5e74d 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/docs/cli_commands.md b/docs/cli_commands.md index e6283928..dd48cce8 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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 ` diff --git a/docs/cli_settings_audit.md b/docs/cli_settings_audit.md new file mode 100644 index 00000000..c4dc0a3d --- /dev/null +++ b/docs/cli_settings_audit.md @@ -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 `. 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). diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md new file mode 100644 index 00000000..6b0a20fc --- /dev/null +++ b/docs/radio_profiles.md @@ -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. diff --git a/docs/radio_profiles_validation.md b/docs/radio_profiles_validation.md new file mode 100644 index 00000000..cdb7cb10 --- /dev/null +++ b/docs/radio_profiles_validation.md @@ -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. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a3def25f..f6522587 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index fa1453bc..efdcd155 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -25,6 +25,7 @@ #endif #include "DataStore.h" +#include #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; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 0b00dd28..e67e666b 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 2d8a9dc4..b6cc78f4 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index d6236cf2..6aa504b5 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -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; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 3705c05c..b719e246 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -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 { diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index c665e8e2..787fe58c 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -1,3 +1,4 @@ +#include #include // needed for PlatformIO #include #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); diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 6d4c7cb2..10fdaa70 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -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; diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 4743c085..d11a455c 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -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; diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 53577ed1..e95f5428 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -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) { diff --git a/src/Dispatcher.h b/src/Dispatcher.h index f6bf2141..30404e65 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -4,6 +4,7 @@ #include #include #include +#include #ifndef RADIO_LIVENESS_SOFT_ONLY #include #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); diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 28faca0e..1e9a1c62 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -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; diff --git a/src/Mesh.h b/src/Mesh.h index d6e2369e..4635ee77 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -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); diff --git a/src/Packet.cpp b/src/Packet.cpp index 7618a2dc..54401442 100644 --- a/src/Packet.cpp +++ b/src/Packet.cpp @@ -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; diff --git a/src/Packet.h b/src/Packet.h index 64778bc3..84fc35f0 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -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 diff --git a/src/RadioProfiles.h b/src/RadioProfiles.h new file mode 100644 index 00000000..b180fb98 --- /dev/null +++ b/src/RadioProfiles.h @@ -0,0 +1,174 @@ +#pragma once + +#include +#include +#include + +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 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 50a7140b..f1ee7d33 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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(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); } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index c6954109..e363c41d 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -13,6 +13,7 @@ #include #include #include +#include #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); diff --git a/src/helpers/DeferredCliCommand.h b/src/helpers/DeferredCliCommand.h index a3c19394..e6258c6e 100644 --- a/src/helpers/DeferredCliCommand.h +++ b/src/helpers/DeferredCliCommand.h @@ -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)); } diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp new file mode 100644 index 00000000..9036167c --- /dev/null +++ b/src/helpers/RadioProfileCLI.cpp @@ -0,0 +1,421 @@ +#include "RadioProfileCLI.h" +#include "RadioProfileCommandUtils.h" +#include "radiolib/RXPowerSaving.h" +#include +#include +#include + +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 diff --git a/src/helpers/RadioProfileCLI.h b/src/helpers/RadioProfileCLI.h new file mode 100644 index 00000000..3fc8870f --- /dev/null +++ b/src/helpers/RadioProfileCLI.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include + +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 diff --git a/src/helpers/RadioProfileCommandUtils.h b/src/helpers/RadioProfileCommandUtils.h new file mode 100644 index 00000000..5b49df7a --- /dev/null +++ b/src/helpers/RadioProfileCommandUtils.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include + +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; +} +} } diff --git a/src/helpers/StaticPoolPacketManager.cpp b/src/helpers/StaticPoolPacketManager.cpp index 3d854464..997b2994 100644 --- a/src/helpers/StaticPoolPacketManager.cpp +++ b/src/helpers/StaticPoolPacketManager.cpp @@ -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; diff --git a/src/helpers/StaticPoolPacketManager.h b/src/helpers/StaticPoolPacketManager.h index 41c2bcd9..0cfb26f8 100644 --- a/src/helpers/StaticPoolPacketManager.h +++ b/src/helpers/StaticPoolPacketManager.h @@ -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; diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index 5ef909d0..2b3d88f8 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -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(); diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 1336dbf6..915460e1 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -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(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); diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 42cd542b..5917e5e2 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -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; } diff --git a/test/fixtures/cli_settings/main.cpp b/test/fixtures/cli_settings/main.cpp new file mode 100644 index 00000000..2f5dc399 --- /dev/null +++ b/test/fixtures/cli_settings/main.cpp @@ -0,0 +1,156 @@ +#include +#include +#include +#include +#include +#include +#include +#include +// 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 +#include + +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); + } +} diff --git a/test/fixtures/radio_profiles/cli_test.cpp b/test/fixtures/radio_profiles/cli_test.cpp new file mode 100644 index 00000000..7a58a79e --- /dev/null +++ b/test/fixtures/radio_profiles/cli_test.cpp @@ -0,0 +1,148 @@ +#include +#include +#include +#include +#include + +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"); +} diff --git a/test/fixtures/radio_profiles/mesh_tests.h b/test/fixtures/radio_profiles/mesh_tests.h new file mode 100644 index 00000000..da973e2d --- /dev/null +++ b/test/fixtures/radio_profiles/mesh_tests.h @@ -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 incoming; + std::vector 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{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{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{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{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{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{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{0}), radio.transmissions); + EXPECT_EQ(40, manager.getFreeCount()); +} diff --git a/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h b/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h new file mode 100644 index 00000000..4bf6020d --- /dev/null +++ b/test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h @@ -0,0 +1,52 @@ +#pragma once +#include +#include +#include +#include +#include +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> 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 diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py new file mode 100644 index 00000000..1e0270c5 --- /dev/null +++ b/test/test_cli_settings_contract.py @@ -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 + '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() diff --git a/test/test_deferred_cli_command/test_deferred_cli_command.cpp b/test/test_deferred_cli_command/test_deferred_cli_command.cpp index 3db92f19..21fd4b9e 100644 --- a/test/test_deferred_cli_command/test_deferred_cli_command.cpp +++ b/test/test_deferred_cli_command/test_deferred_cli_command.cpp @@ -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))); diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index 4907bb0b..bb06e0b0 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -14,6 +14,7 @@ PREAMBLE = r''' #include #include #include +#include #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; diff --git a/test/test_path_hash_cli.py b/test/test_path_hash_cli.py new file mode 100644 index 00000000..82abdfd5 --- /dev/null +++ b/test/test_path_hash_cli.py @@ -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 +#include +#include +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() diff --git a/test/test_radio_profile_cli.py b/test/test_radio_profile_cli.py new file mode 100644 index 00000000..58fdc711 --- /dev/null +++ b/test/test_radio_profile_cli.py @@ -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() diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py new file mode 100644 index 00000000..b6368efa --- /dev/null +++ b/test/test_radio_profile_scan.py @@ -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 +#include +#include +#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 \n#include \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() diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py index b6d5ba05..e6fa81b3 100644 --- a/test/test_radio_receive_contract.py +++ b/test/test_radio_receive_contract.py @@ -21,6 +21,7 @@ HARNESS = r''' #include #include #include +#include #include #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; diff --git a/test/test_repeater_radio_timing_integration.py b/test/test_repeater_radio_timing_integration.py index 15282cac..0beefe67 100644 --- a/test/test_repeater_radio_timing_integration.py +++ b/test/test_repeater_radio_timing_integration.py @@ -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 #include #include +#include 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; diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index 89ee7024..93da274a 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -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" diff --git a/tools/hil/radio_profiles_results.json b/tools/hil/radio_profiles_results.json new file mode 100644 index 00000000..0abb1a9c --- /dev/null +++ b/tools/hil/radio_profiles_results.json @@ -0,0 +1,2024 @@ +{ + "date": "2026-09-13", + "receiver": "Heltec V4 / SX1262 repeater", + "transmitter": "XIAO ESP32-S3 / Wio SX1262 Full Companion", + "primary": { + "frequency_mhz": 909.5, + "bandwidth_khz": 62.5, + "sf": 7, + "cr": 5, + "preamble": 32 + }, + "secondary": { + "frequency_mhz": 910.5, + "bandwidth_khz": 500, + "cr": 5 + }, + "method": "Aggregate MeshCore RX and completed TX counter differences; one transmitting profile at a time; strong signal; varied advert intervals. Not sequence-matched or a long-duration reliability test.", + "five_symbol_baseline": [ + { + "sf": 9, + "preamble": 48, + "channel": 1, + "commands": 50, + "received": 54, + "transmitted": 54, + "rx_status": "> rx; RX=0,55 TX=1,0 switches=11861 errors=0 max=4348us preamble=32,48", + "tx_status": "get radio2.status\r\n > off; RX=0,0 TX=55,0 switches=0 errors=0 max=0us preamble=48,32\r\n>", + "slow_symbols": 5 + }, + { + "sf": 9, + "preamble": 48, + "channel": 0, + "commands": 30, + "received": 30, + "transmitted": 30, + "rx_status": "> rx; RX=30,55 TX=1,0 switches=15090 errors=0 max=4348us preamble=32,48", + "tx_status": "get radio2.status\r\n > off; RX=0,0 TX=85,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "slow_symbols": 5 + }, + { + "sf": 8, + "preamble": 72, + "channel": 1, + "commands": 50, + "received": 49, + "transmitted": 50, + "rx_status": "> rx; RX=30,104 TX=1,0 switches=21246 errors=0 max=4348us preamble=32,72", + "tx_status": "get radio2.status\r\n > off; RX=0,0 TX=135,0 switches=0 errors=0 max=0us preamble=72,32\r\n>", + "slow_symbols": 5 + }, + { + "sf": 8, + "preamble": 80, + "channel": 1, + "commands": 50, + "received": 49, + "transmitted": 50, + "rx_status": "> rx; RX=30,153 TX=1,0 switches=27245 errors=0 max=4348us preamble=32,80", + "tx_status": "get radio2.status\r\n > off; RX=0,0 TX=185,0 switches=0 errors=0 max=0us preamble=80,32\r\n>", + "slow_symbols": 5 + }, + { + "sf": 8, + "preamble": 88, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=30,203 TX=1,0 switches=33368 errors=0 max=4416us preamble=32,88", + "tx_status": "get radio2.status\r\n > off; RX=0,0 TX=235,0 switches=0 errors=0 max=0us preamble=88,32\r\n>", + "slow_symbols": 5 + }, + { + "sf": 8, + "preamble": 88, + "channel": 0, + "commands": 30, + "received": 30, + "transmitted": 30, + "rx_status": "> rx; RX=60,203 TX=1,0 switches=36449 errors=0 max=4416us preamble=32,88", + "tx_status": "get radio2.status\r\n > off; RX=0,0 TX=265,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "slow_symbols": 5 + }, + { + "sf": 7, + "preamble": 120, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=60,253 TX=1,0 switches=42395 errors=0 max=4416us preamble=32,120", + "tx_status": "get radio2.status\r\n > off; RX=0,0 TX=315,0 switches=0 errors=0 max=0us preamble=120,32\r\n>", + "slow_symbols": 5 + }, + { + "sf": 7, + "preamble": 120, + "channel": 0, + "commands": 30, + "received": 30, + "transmitted": 30, + "rx_status": "> rx; RX=90,253 TX=1,0 switches=45484 errors=0 max=4416us preamble=32,120", + "tx_status": "get radio2.status\r\n > off; RX=0,0 TX=345,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "slow_symbols": 5 + } + ], + "fractional_sweep": [ + { + "sf": 9, + "preamble": 48, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=50,0 TX=1,0 switches=5657 errors=0 max=4364us preamble=32,48", + "tx_status": "get radio2.status\r\n > off; RX=1,0 TX=395,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 9, + "preamble": 48, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=50,50 TX=1,0 switches=11109 errors=0 max=4364us preamble=32,48", + "tx_status": "get radio2.status\r\n > off; RX=1,0 TX=445,0 switches=0 errors=0 max=0us preamble=48,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 8, + "preamble": 88, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=100,50 TX=1,0 switches=16434 errors=0 max=4385us preamble=32,88", + "tx_status": "get radio2.status\r\n > off; RX=1,0 TX=495,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 8, + "preamble": 88, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=150,179 TX=1,0 switches=42258 errors=0 max=4407us preamble=32,88", + "tx_status": "get radio2.status\r\n > off; RX=1,0 TX=674,0 switches=0 errors=0 max=0us preamble=88,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 7, + "preamble": 120, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=200,179 TX=1,0 switches=47237 errors=0 max=4407us preamble=32,120", + "tx_status": "get radio2.status\r\n > off; RX=1,0 TX=724,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 7, + "preamble": 120, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=200,229 TX=1,0 switches=53000 errors=0 max=4407us preamble=32,120", + "tx_status": "get radio2.status\r\n > off; RX=1,0 TX=774,0 switches=0 errors=0 max=0us preamble=120,32\r\n>", + "slow_symbols": 4.2 + } + ], + "sf9_explicit_preamble_32": [ + { + "sf": 9, + "preamble": 32, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=100,129 TX=1,0 switches=28019 errors=0 max=4385us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=1,0 TX=574,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 9, + "preamble": 32, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=150,129 TX=1,0 switches=32551 errors=0 max=4385us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=1,0 TX=624,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "slow_symbols": 4.2 + } + ], + "firmware_sha256": { + "v4-five": "55dbb23803d5b842f203dc08a5d6ad4c47db6336300f79d88790506af17e5c1a", + "xiao-five": "68142e4cd085ca0cf3a68dafed9e939fb551bb9ff9e312f91c3b309b92618f31", + "v4-headroom42": "f089c0b006d302937e59129e251961fb492e2d666384e56bad72ea1c6106e728", + "xiao-headroom42": "951e304adb4887b5fa52218f79500b9821e2b6d4530b2d3fc6e907bbe8b211c4", + "v4-headroom43": "2ff3c1e6bbaf13082200eb9ca4462efb28641f93b2b838a54fa68f013116aa7c", + "v4-headroom44": "0493ed3f9cb63a9793fd5fea648486104ae5a7eac31bdb80b5e669b07995c04a", + "v4-headroom45": "107da85bb5ce48f2f2516610b2cf6f1e89f824f06c92d5754a868f39c0f7623f", + "v4-headroom46": "140ee50fc4780bc11d2edac19e748743b5812e1e7ee28c627f0e94b09e018a6c", + "v4-headroom47": "df4f0cd4c87efa5501df492e5aa5a3f8ae31c898d10bea5b2dbc8bd71b8e9c9f", + "v4-headroom48": "febc2f1784078b5ec1a729134e4f00c5844cb450b77c4ac1c4e84c7c9bd328ff", + "xiao-headroom43": "c121d2075e06ebedf3705508b6ba31685855b3919d6cae0f45f006ad2eba86b2", + "xiao-headroom44": "bd7b6d7c9469b4bed5f5a11f59a488be1615fd5c2a57869c0d3a6b151e658256", + "xiao-headroom47": "0c2cac1d77cacf15c38a4a89c93379a3d504a72637f65fd2e69a1a04982d385a", + "xiao-headroom48": "0576583a7928d0400df4af0f6feb268f8aff910a691327ff4985b470a4c9bc6d", + "v4-final47": "b628e48ecbaf0963d30fac19982b9157b6bf1ae669cda337a4b68987f3a1b4d2", + "v4-final48": "febc2f1784078b5ec1a729134e4f00c5844cb450b77c4ac1c4e84c7c9bd328ff", + "xiao-final47": "dbbb74656db3cc78850edc294962756755248fa0b5dc9464764f26de34b9cc5c", + "xiao-final48": "0576583a7928d0400df4af0f6feb268f8aff910a691327ff4985b470a4c9bc6d" + }, + "source_sha256": { + "src/RadioProfiles.h": "0774b73f0e8ad966595eb6e587d8dee85903ce56f2ae286cfe154a20a058f555", + "src/helpers/radiolib/RadioLibWrappers.cpp": "d238378c900a6e2beec25217fa2cf76f7e2eddab1a5afaf6e91449ec139e3a58", + "src/Dispatcher.cpp": "296be003f4bedf2b21a4f02e25b914bed6d62a019087b03369822a3c9248c18e" + }, + "source_note": "Source hashes describe the current working source. Older trial images have separate firmware hashes and timing values.", + "transmit_policy": [ + { + "case": "permanent/permanent auto", + "transmit_delta": [ + 3, + 3 + ], + "receiver_rx": [ + 3, + 0 + ], + "before": [ + 200, + 229, + 1, + 0, + 61219 + ], + "after": [ + 200, + 230, + 4, + 3, + 62363 + ], + "receiver_before": [ + 0, + 0, + 1, + 0, + 0 + ], + "receiver_after": [ + 3, + 0, + 2, + 0, + 0 + ] + }, + { + "case": "permanent/permanent off", + "transmit_delta": [ + 3, + 0 + ], + "receiver_rx": [ + 0, + 0 + ], + "before": [ + 200, + 231, + 4, + 3, + 63198 + ], + "after": [ + 200, + 231, + 7, + 3, + 64374 + ], + "receiver_before": [ + 3, + 0, + 3, + 0, + 0 + ], + "receiver_after": [ + 3, + 0, + 4, + 0, + 0 + ] + }, + { + "case": "permanent/temporary auto", + "transmit_delta": [ + 3, + 0 + ], + "receiver_rx": [ + 0, + 0 + ], + "before": [ + 200, + 233, + 7, + 7, + 70908 + ], + "after": [ + 200, + 233, + 10, + 7, + 72107 + ], + "receiver_before": [ + 7, + 0, + 5, + 0, + 0 + ], + "receiver_after": [ + 7, + 0, + 5, + 0, + 0 + ] + }, + { + "case": "permanent/temporary on", + "transmit_delta": [ + 3, + 3 + ], + "receiver_rx": [ + 3, + 0 + ], + "before": [ + 200, + 233, + 10, + 7, + 72947 + ], + "after": [ + 200, + 233, + 13, + 10, + 74185 + ], + "receiver_before": [ + 7, + 0, + 5, + 0, + 0 + ], + "receiver_after": [ + 10, + 0, + 5, + 0, + 0 + ] + }, + { + "case": "receive-only with cross on", + "transmit_delta": [ + 3, + 0 + ], + "receiver_rx": [ + 0, + 0 + ], + "before": [ + 200, + 233, + 13, + 10, + 75034 + ], + "after": [ + 200, + 233, + 16, + 10, + 76226 + ], + "receiver_before": [ + 10, + 0, + 5, + 0, + 0 + ], + "receiver_after": [ + 10, + 0, + 5, + 0, + 0 + ] + }, + { + "case": "receive-only temporary OTA stays isolated with auto", + "requested_announcements": 3, + "transmit_delta": [ + 0, + 0 + ], + "before": [ + 300, + 199, + 1, + 0, + 57657 + ], + "after": [ + 300, + 200, + 1, + 0, + 59618 + ] + }, + { + "case": "temporary/temporary auto", + "transmit_delta": [ + 3, + 3 + ], + "receiver_rx": [ + 3, + 0 + ], + "before": [ + 300, + 202, + 3, + 2, + 66617 + ], + "after": [ + 300, + 202, + 6, + 5, + 67787 + ], + "receiver_before": [ + 2, + 0, + 5, + 0, + 0 + ], + "receiver_after": [ + 5, + 0, + 5, + 0, + 0 + ] + }, + { + "case": "temporary expiry restores single profile", + "passed": true + }, + { + "case": "reboot keeps permanent and clears temporary profile", + "saved": "> 910.500,500.000,8,5,rx,88 (auto)", + "temporary": "> off" + } + ], + "lower_preamble_sweep": [ + { + "sf": 8, + "preamble": 48, + "channel": 1, + "commands": 50, + "received": 49, + "transmitted": 50, + "rx_status": "> rx; RX=200,282 TX=16,10 switches=89027 errors=0 max=4446us preamble=32,48", + "tx_status": "get radio2.status\r\n > off; RX=10,0 TX=55,0 switches=0 errors=0 max=0us preamble=48,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 7, + "preamble": 48, + "channel": 1, + "commands": 50, + "received": 42, + "transmitted": 50, + "rx_status": "> rx; RX=200,324 TX=16,10 switches=95731 errors=0 max=4446us preamble=32,48", + "tx_status": "get radio2.status\r\n > off; RX=10,0 TX=105,0 switches=0 errors=0 max=0us preamble=48,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 8, + "preamble": 56, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=200,374 TX=16,10 switches=101825 errors=0 max=4446us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=10,0 TX=155,0 switches=0 errors=0 max=0us preamble=56,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 8, + "preamble": 56, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=250,374 TX=16,10 switches=107184 errors=0 max=4446us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=10,0 TX=205,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 7, + "preamble": 56, + "channel": 1, + "commands": 50, + "received": 41, + "transmitted": 50, + "rx_status": "> rx; RX=250,415 TX=16,10 switches=114243 errors=0 max=4446us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=10,0 TX=255,0 switches=0 errors=0 max=0us preamble=56,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 7, + "preamble": 64, + "channel": 1, + "commands": 50, + "received": 45, + "transmitted": 50, + "rx_status": "> rx; RX=250,460 TX=16,10 switches=120608 errors=0 max=4446us preamble=32,64", + "tx_status": "get radio2.status\r\n > off; RX=10,0 TX=305,0 switches=0 errors=0 max=0us preamble=64,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 7, + "preamble": 72, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "rx_status": "> rx; RX=250,510 TX=16,10 switches=127020 errors=0 max=4446us preamble=32,72", + "tx_status": "get radio2.status\r\n > off; RX=10,0 TX=355,0 switches=0 errors=0 max=0us preamble=72,32\r\n>", + "slow_symbols": 4.2 + }, + { + "sf": 7, + "preamble": 72, + "channel": 0, + "commands": 50, + "received": 49, + "transmitted": 50, + "rx_status": "> rx; RX=299,510 TX=16,10 switches=132123 errors=0 max=4446us preamble=32,72", + "tx_status": "get radio2.status\r\n > off; RX=10,0 TX=405,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "slow_symbols": 4.2 + } + ], + "adaptive-43": [ + { + "slow_symbols": 4.3, + "sf": 9, + "preamble": 32, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 0, + 0, + 1, + 0, + 1504 + ], + "after": [ + 50, + 0, + 1, + 0, + 5403 + ], + "tx_before": [ + 11, + 0, + 405, + 0, + 0 + ], + "tx_after": [ + 11, + 0, + 455, + 0, + 0 + ], + "rx_status": "> rx; RX=50,0 TX=1,0 switches=5403 errors=0 max=4409us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=11,0 TX=455,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + }, + { + "slow_symbols": 4.3, + "sf": 9, + "preamble": 32, + "channel": 0, + "commands": 100, + "received": 100, + "transmitted": 100, + "before": [ + 0, + 0, + 1, + 0, + 1504 + ], + "after": [ + 100, + 0, + 1, + 0, + 9217 + ], + "tx_before": [ + 11, + 0, + 405, + 0, + 0 + ], + "tx_after": [ + 11, + 0, + 505, + 0, + 0 + ], + "rx_status": "> rx; RX=100,0 TX=1,0 switches=9217 errors=0 max=4409us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=11,0 TX=505,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.3, + "sf": 9, + "preamble": 32, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 100, + 0, + 1, + 0, + 9928 + ], + "after": [ + 100, + 50, + 1, + 0, + 14931 + ], + "tx_before": [ + 11, + 0, + 505, + 0, + 0 + ], + "tx_after": [ + 11, + 0, + 555, + 0, + 0 + ], + "rx_status": "> rx; RX=100,50 TX=1,0 switches=14931 errors=0 max=4409us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=11,0 TX=555,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.3, + "sf": 8, + "preamble": 56, + "channel": 0, + "commands": 50, + "received": 42, + "transmitted": 50, + "before": [ + 100, + 50, + 1, + 0, + 16104 + ], + "after": [ + 142, + 50, + 1, + 0, + 19503 + ], + "tx_before": [ + 11, + 0, + 555, + 0, + 0 + ], + "tx_after": [ + 11, + 0, + 605, + 0, + 0 + ], + "rx_status": "> rx; RX=142,50 TX=1,0 switches=19503 errors=0 max=4506us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=11,0 TX=605,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + } + ], + "adaptive-44": [ + { + "slow_symbols": 4.4, + "sf": 8, + "preamble": 56, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 0, + 0, + 1, + 0, + 853 + ], + "after": [ + 50, + 0, + 1, + 0, + 4622 + ], + "tx_before": [ + 12, + 0, + 655, + 0, + 0 + ], + "tx_after": [ + 12, + 0, + 705, + 0, + 0 + ], + "rx_status": "> rx; RX=50,0 TX=1,0 switches=4622 errors=0 max=4276us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=12,0 TX=705,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + }, + { + "slow_symbols": 4.4, + "sf": 8, + "preamble": 56, + "channel": 0, + "commands": 100, + "received": 100, + "transmitted": 100, + "before": [ + 0, + 0, + 1, + 0, + 853 + ], + "after": [ + 100, + 0, + 1, + 0, + 8749 + ], + "tx_before": [ + 12, + 0, + 655, + 0, + 0 + ], + "tx_after": [ + 12, + 0, + 755, + 0, + 0 + ], + "rx_status": "> rx; RX=100,0 TX=1,0 switches=8749 errors=0 max=4277us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=12,0 TX=755,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.4, + "sf": 8, + "preamble": 56, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 100, + 0, + 1, + 0, + 9452 + ], + "after": [ + 100, + 50, + 1, + 0, + 14515 + ], + "tx_before": [ + 12, + 0, + 755, + 0, + 0 + ], + "tx_after": [ + 12, + 0, + 805, + 0, + 0 + ], + "rx_status": "> rx; RX=100,50 TX=1,0 switches=14515 errors=0 max=4289us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=12,0 TX=805,0 switches=0 errors=0 max=0us preamble=56,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.4, + "sf": 7, + "preamble": 72, + "channel": 0, + "commands": 50, + "received": 49, + "transmitted": 50, + "before": [ + 100, + 50, + 1, + 0, + 15674 + ], + "after": [ + 149, + 50, + 1, + 0, + 19881 + ], + "tx_before": [ + 12, + 0, + 805, + 0, + 0 + ], + "tx_after": [ + 12, + 0, + 855, + 0, + 0 + ], + "rx_status": "> rx; RX=149,50 TX=1,0 switches=19881 errors=0 max=4289us preamble=32,72", + "tx_status": "get radio2.status\r\n > off; RX=12,0 TX=855,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + } + ], + "adaptive-45": [ + { + "slow_symbols": 4.5, + "sf": 7, + "preamble": 72, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 0, + 0, + 1, + 0, + 1721 + ], + "after": [ + 50, + 0, + 1, + 0, + 5351 + ], + "tx_before": [ + 13, + 0, + 855, + 0, + 0 + ], + "tx_after": [ + 13, + 0, + 905, + 0, + 0 + ], + "rx_status": "> rx; RX=50,0 TX=1,0 switches=5351 errors=0 max=4284us preamble=32,72", + "tx_status": "get radio2.status\r\n > off; RX=13,0 TX=905,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + }, + { + "slow_symbols": 4.5, + "sf": 7, + "preamble": 72, + "channel": 0, + "commands": 100, + "received": 100, + "transmitted": 100, + "before": [ + 0, + 0, + 1, + 0, + 1721 + ], + "after": [ + 100, + 0, + 1, + 0, + 9117 + ], + "tx_before": [ + 13, + 0, + 855, + 0, + 0 + ], + "tx_after": [ + 13, + 0, + 955, + 0, + 0 + ], + "rx_status": "> rx; RX=100,0 TX=1,0 switches=9117 errors=0 max=4284us preamble=32,72", + "tx_status": "get radio2.status\r\n > off; RX=13,0 TX=955,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.5, + "sf": 7, + "preamble": 72, + "channel": 1, + "commands": 50, + "received": 47, + "transmitted": 50, + "before": [ + 100, + 0, + 1, + 0, + 9822 + ], + "after": [ + 100, + 47, + 1, + 0, + 15420 + ], + "tx_before": [ + 13, + 0, + 955, + 0, + 0 + ], + "tx_after": [ + 13, + 0, + 1005, + 0, + 0 + ], + "rx_status": "> rx; RX=100,47 TX=1,0 switches=15420 errors=0 max=4375us preamble=32,72", + "tx_status": "get radio2.status\r\n > off; RX=13,0 TX=1005,0 switches=0 errors=0 max=0us preamble=72,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.5, + "sf": 7, + "preamble": 80, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 100, + 47, + 1, + 0, + 16582 + ], + "after": [ + 100, + 97, + 1, + 0, + 21713 + ], + "tx_before": [ + 13, + 0, + 1005, + 0, + 0 + ], + "tx_after": [ + 13, + 0, + 1055, + 0, + 0 + ], + "rx_status": "> rx; RX=100,97 TX=1,0 switches=21713 errors=0 max=4375us preamble=32,80", + "tx_status": "get radio2.status\r\n > off; RX=13,0 TX=1055,0 switches=0 errors=0 max=0us preamble=80,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.5, + "sf": 8, + "preamble": 56, + "channel": 0, + "commands": 50, + "received": 49, + "transmitted": 50, + "before": [ + 100, + 97, + 1, + 0, + 22863 + ], + "after": [ + 149, + 97, + 1, + 0, + 26749 + ], + "tx_before": [ + 13, + 0, + 1055, + 0, + 0 + ], + "tx_after": [ + 13, + 0, + 1105, + 0, + 0 + ], + "rx_status": "> rx; RX=149,97 TX=1,0 switches=26749 errors=0 max=4429us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=13,0 TX=1105,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + } + ], + "adaptive-46": [ + { + "slow_symbols": 4.6, + "sf": 8, + "preamble": 56, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 0, + 0, + 1, + 0, + 1511 + ], + "after": [ + 50, + 0, + 1, + 0, + 5286 + ], + "tx_before": [ + 14, + 0, + 1105, + 0, + 0 + ], + "tx_after": [ + 14, + 0, + 1155, + 0, + 0 + ], + "rx_status": "> rx; RX=50,0 TX=1,0 switches=5286 errors=0 max=4356us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=14,0 TX=1155,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + }, + { + "slow_symbols": 4.6, + "sf": 8, + "preamble": 56, + "channel": 0, + "commands": 100, + "received": 100, + "transmitted": 100, + "before": [ + 0, + 0, + 1, + 0, + 1511 + ], + "after": [ + 100, + 0, + 1, + 0, + 9170 + ], + "tx_before": [ + 14, + 0, + 1105, + 0, + 0 + ], + "tx_after": [ + 14, + 0, + 1205, + 0, + 0 + ], + "rx_status": "> rx; RX=100,0 TX=1,0 switches=9170 errors=0 max=4391us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=14,0 TX=1205,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.6, + "sf": 8, + "preamble": 56, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 100, + 0, + 1, + 0, + 9870 + ], + "after": [ + 100, + 50, + 1, + 0, + 15021 + ], + "tx_before": [ + 14, + 0, + 1205, + 0, + 0 + ], + "tx_after": [ + 14, + 0, + 1255, + 0, + 0 + ], + "rx_status": "> rx; RX=100,50 TX=1,0 switches=15021 errors=0 max=4391us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=14,0 TX=1255,0 switches=0 errors=0 max=0us preamble=56,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.6, + "sf": 7, + "preamble": 80, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 100, + 50, + 1, + 0, + 16184 + ], + "after": [ + 150, + 50, + 1, + 0, + 20138 + ], + "tx_before": [ + 14, + 0, + 1255, + 0, + 0 + ], + "tx_after": [ + 14, + 0, + 1305, + 0, + 0 + ], + "rx_status": "> rx; RX=150,50 TX=1,0 switches=20138 errors=0 max=4391us preamble=32,80", + "tx_status": "get radio2.status\r\n > off; RX=14,0 TX=1305,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + }, + { + "slow_symbols": 4.6, + "sf": 7, + "preamble": 80, + "channel": 0, + "commands": 100, + "received": 100, + "transmitted": 100, + "before": [ + 100, + 50, + 1, + 0, + 16184 + ], + "after": [ + 200, + 50, + 1, + 0, + 23852 + ], + "tx_before": [ + 14, + 0, + 1255, + 0, + 0 + ], + "tx_after": [ + 14, + 0, + 1355, + 0, + 0 + ], + "rx_status": "> rx; RX=200,50 TX=1,0 switches=23852 errors=0 max=4391us preamble=32,80", + "tx_status": "get radio2.status\r\n > off; RX=14,0 TX=1355,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.6, + "sf": 7, + "preamble": 80, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 200, + 50, + 1, + 0, + 24560 + ], + "after": [ + 200, + 100, + 1, + 0, + 29939 + ], + "tx_before": [ + 14, + 0, + 1355, + 0, + 0 + ], + "tx_after": [ + 14, + 0, + 1405, + 0, + 0 + ], + "rx_status": "> rx; RX=200,100 TX=1,0 switches=29939 errors=0 max=4391us preamble=32,80", + "tx_status": "get radio2.status\r\n > off; RX=14,0 TX=1405,0 switches=0 errors=0 max=0us preamble=80,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.6, + "sf": 9, + "preamble": 32, + "channel": 0, + "commands": 50, + "received": 49, + "transmitted": 50, + "before": [ + 200, + 100, + 1, + 0, + 31110 + ], + "after": [ + 249, + 100, + 1, + 0, + 35067 + ], + "tx_before": [ + 14, + 0, + 1405, + 0, + 0 + ], + "tx_after": [ + 14, + 0, + 1455, + 0, + 0 + ], + "rx_status": "> rx; RX=249,100 TX=1,0 switches=35067 errors=0 max=4414us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=14,0 TX=1455,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + } + ], + "adaptive-47": [ + { + "slow_symbols": 4.7, + "sf": 9, + "preamble": 32, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 0, + 0, + 1, + 0, + 1553 + ], + "after": [ + 50, + 0, + 1, + 0, + 5284 + ], + "tx_before": [ + 15, + 0, + 1455, + 0, + 0 + ], + "tx_after": [ + 15, + 0, + 1505, + 0, + 0 + ], + "rx_status": "> rx; RX=50,0 TX=1,0 switches=5284 errors=0 max=4357us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=15,0 TX=1505,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + }, + { + "slow_symbols": 4.7, + "sf": 9, + "preamble": 32, + "channel": 0, + "commands": 100, + "received": 99, + "transmitted": 100, + "before": [ + 0, + 0, + 1, + 0, + 1553 + ], + "after": [ + 99, + 0, + 1, + 0, + 9127 + ], + "tx_before": [ + 15, + 0, + 1455, + 0, + 0 + ], + "tx_after": [ + 15, + 0, + 1555, + 0, + 0 + ], + "rx_status": "> rx; RX=99,0 TX=1,0 switches=9127 errors=0 max=4357us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=15,0 TX=1555,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": false + } + ], + "adaptive-48": [ + { + "slow_symbols": 4.8, + "sf": 9, + "preamble": 32, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 0, + 0, + 1, + 0, + 1531 + ], + "after": [ + 50, + 0, + 1, + 0, + 5812 + ], + "tx_before": [ + 16, + 0, + 1555, + 0, + 0 + ], + "tx_after": [ + 16, + 0, + 1605, + 0, + 0 + ], + "rx_status": "> rx; RX=50,0 TX=1,0 switches=5812 errors=0 max=4381us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=16,0 TX=1605,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + }, + { + "slow_symbols": 4.8, + "sf": 9, + "preamble": 32, + "channel": 0, + "commands": 100, + "received": 100, + "transmitted": 100, + "before": [ + 0, + 0, + 1, + 0, + 1531 + ], + "after": [ + 100, + 0, + 1, + 0, + 9594 + ], + "tx_before": [ + 16, + 0, + 1555, + 0, + 0 + ], + "tx_after": [ + 16, + 0, + 1655, + 0, + 0 + ], + "rx_status": "> rx; RX=100,0 TX=1,0 switches=9594 errors=0 max=4381us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=16,0 TX=1655,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.8, + "sf": 9, + "preamble": 32, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 100, + 0, + 1, + 0, + 10291 + ], + "after": [ + 100, + 50, + 1, + 0, + 14991 + ], + "tx_before": [ + 16, + 0, + 1655, + 0, + 0 + ], + "tx_after": [ + 16, + 0, + 1705, + 0, + 0 + ], + "rx_status": "> rx; RX=100,50 TX=1,0 switches=14991 errors=0 max=4381us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=16,0 TX=1705,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.8, + "sf": 8, + "preamble": 56, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 100, + 50, + 1, + 0, + 16296 + ], + "after": [ + 150, + 50, + 1, + 0, + 19987 + ], + "tx_before": [ + 16, + 0, + 1705, + 0, + 0 + ], + "tx_after": [ + 16, + 0, + 1755, + 0, + 0 + ], + "rx_status": "> rx; RX=150,50 TX=1,0 switches=19987 errors=0 max=4449us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=16,0 TX=1755,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + }, + { + "slow_symbols": 4.8, + "sf": 8, + "preamble": 56, + "channel": 0, + "commands": 100, + "received": 100, + "transmitted": 100, + "before": [ + 100, + 50, + 1, + 0, + 16296 + ], + "after": [ + 200, + 50, + 1, + 0, + 23776 + ], + "tx_before": [ + 16, + 0, + 1705, + 0, + 0 + ], + "tx_after": [ + 16, + 0, + 1805, + 0, + 0 + ], + "rx_status": "> rx; RX=200,50 TX=1,0 switches=23776 errors=0 max=4449us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=16,0 TX=1805,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.8, + "sf": 8, + "preamble": 56, + "channel": 1, + "commands": 50, + "received": 49, + "transmitted": 50, + "before": [ + 200, + 50, + 1, + 0, + 24478 + ], + "after": [ + 200, + 99, + 1, + 0, + 29838 + ], + "tx_before": [ + 16, + 0, + 1805, + 0, + 0 + ], + "tx_after": [ + 16, + 0, + 1855, + 0, + 0 + ], + "rx_status": "> rx; RX=200,99 TX=1,0 switches=29838 errors=0 max=4449us preamble=32,56", + "tx_status": "get radio2.status\r\n > off; RX=16,0 TX=1855,0 switches=0 errors=0 max=0us preamble=56,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.8, + "sf": 8, + "preamble": 64, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 200, + 99, + 1, + 0, + 31001 + ], + "after": [ + 200, + 149, + 1, + 0, + 35947 + ], + "tx_before": [ + 16, + 0, + 1855, + 0, + 0 + ], + "tx_after": [ + 16, + 0, + 1905, + 0, + 0 + ], + "rx_status": "> rx; RX=200,149 TX=1,0 switches=35947 errors=0 max=4491us preamble=32,64", + "tx_status": "get radio2.status\r\n > off; RX=16,0 TX=1905,0 switches=0 errors=0 max=0us preamble=64,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.8, + "sf": 7, + "preamble": 80, + "channel": 0, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 200, + 149, + 1, + 0, + 37106 + ], + "after": [ + 250, + 149, + 1, + 0, + 41207 + ], + "tx_before": [ + 16, + 0, + 1905, + 0, + 0 + ], + "tx_after": [ + 16, + 0, + 1955, + 0, + 0 + ], + "rx_status": "> rx; RX=250,149 TX=1,0 switches=41207 errors=0 max=4491us preamble=32,80", + "tx_status": "get radio2.status\r\n > off; RX=16,0 TX=1955,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": true + }, + { + "slow_symbols": 4.8, + "sf": 7, + "preamble": 80, + "channel": 0, + "commands": 100, + "received": 100, + "transmitted": 100, + "before": [ + 200, + 149, + 1, + 0, + 37106 + ], + "after": [ + 300, + 149, + 1, + 0, + 44963 + ], + "tx_before": [ + 16, + 0, + 1905, + 0, + 0 + ], + "tx_after": [ + 16, + 0, + 2005, + 0, + 0 + ], + "rx_status": "> rx; RX=300,149 TX=1,0 switches=44963 errors=0 max=4491us preamble=32,80", + "tx_status": "get radio2.status\r\n > off; RX=16,0 TX=2005,0 switches=0 errors=0 max=0us preamble=32,32\r\n>", + "checkpoint": false + }, + { + "slow_symbols": 4.8, + "sf": 7, + "preamble": 80, + "channel": 1, + "commands": 50, + "received": 50, + "transmitted": 50, + "before": [ + 300, + 149, + 1, + 0, + 45665 + ], + "after": [ + 300, + 199, + 1, + 0, + 50761 + ], + "tx_before": [ + 16, + 0, + 2005, + 0, + 0 + ], + "tx_after": [ + 16, + 0, + 2055, + 0, + 0 + ], + "rx_status": "> rx; RX=300,199 TX=1,0 switches=50761 errors=0 max=4491us preamble=32,80", + "tx_status": "get radio2.status\r\n > off; RX=16,0 TX=2055,0 switches=0 errors=0 max=0us preamble=80,32\r\n>", + "checkpoint": false + } + ], + "selected_timing": { + "slow_symbols": 4.8, + "latest_checks": [ + { + "sf": 9, + "preamble": 32, + "channel": 0, + "received": 100, + "transmitted": 100 + }, + { + "sf": 9, + "preamble": 32, + "channel": 1, + "received": 50, + "transmitted": 50 + }, + { + "sf": 8, + "preamble": 56, + "channel": 0, + "received": 100, + "transmitted": 100 + }, + { + "sf": 8, + "preamble": 64, + "channel": 1, + "received": 50, + "transmitted": 50 + }, + { + "sf": 7, + "preamble": 80, + "channel": 0, + "received": 100, + "transmitted": 100 + }, + { + "sf": 7, + "preamble": 80, + "channel": 1, + "received": 50, + "transmitted": 50 + } + ], + "preamble_note": "A fast-preamble increase follows a loss on that channel; each check records the actual configured preamble. The slow receive-window timing remains unchanged during a fast-preamble increase.", + "qualification": "All three pairings passed this finite run; this is not a zero-loss or uptime guarantee." + }, + "current_preamble_baselines": { + "9": 32, + "8": 64, + "7": 80 + }, + "fixed_slow_reference": { + "mode": "fixed slow channel, no scan", + "sf": 7, + "bw": 62.5, + "preamble": 32, + "received": 50, + "transmitted": 50, + "before": [ + 142, + 50, + 1, + 0, + 40646 + ], + "after": [ + 192, + 50, + 1, + 0, + 40646 + ], + "tx_before": [ + 11, + 0, + 605, + 0, + 0 + ], + "tx_after": [ + 11, + 0, + 655, + 0, + 0 + ], + "rx_status": "> off; RX=192,50 TX=1,0 switches=40646 errors=0 max=4506us preamble=32,32", + "tx_status": "get radio2.status\r\n > off; RX=11,0 TX=655,0 switches=0 errors=0 max=0us preamble=32,32\r\n>" + }, + "temporary_secondary_ota": { + "xiao_saved_radio": "get radio\r\n > 869.6179809,62.5,8,5,preamble=32 (auto)\r\n>", + "before": { + "v4": [ + 0, + 0, + 1, + 0, + 1242 + ], + "xiao": [ + 0, + 0, + 0, + 0, + 0 + ] + }, + "scan": { + "v4": "> slow=radio; listen_us=9831,6937; preamble=32,88", + "xiao": "get radio2.scan\r\n > slow=radio; listen_us=9831,6937; preamble=32,88\r\n>" + }, + "ota_status": { + "v4": "OTA | this fw 84AA9735 (1556K) hw=heltec_v4 | target:E792A051 | maxblk:2048 | no download | serving:on (1) | keys:0 | env:heltec_v4_repeater", + "xiao": "ota status\r\n OTA seeder | install:disabled | target:00000000 | maxblk:2048 | folder:not connected | serving:0 dg=00000000 (source only)\r\n>" + }, + "catalogue": "ota ls\r\n Updates 1/1 (1 src; refreshing):\n 1) 5006D6F1 v1.17.1 full [heltec_v4_repeater] 1n 15s\r\n>", + "after_discovery": { + "v4": [ + 0, + 2, + 1, + 3, + 3443 + ], + "xiao": [ + 0, + 3, + 0, + 3, + 2626 + ] + }, + "main_channel_adverts": { + "requested": 10, + "received": 10 + }, + "temporary_still_active": { + "v4": "> 910.500,500.000,8,5,rxtx,88 (auto); 0d0h14m (14 min) left", + "xiao": "get tempradio2\r\n > 910.500,500.000,8,5,rxtx,88 (auto); 0d0h14m (14 min) left\r\n>" + }, + "passed": true + }, + "soak_restoration": { + "restored": true, + "phase": "B-post-radio2-soak" + } +}