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" + } +}