diff --git a/docs/WiFi.md b/docs/WiFi.md index 3e66461d..ed3062a6 100644 --- a/docs/WiFi.md +++ b/docs/WiFi.md @@ -261,7 +261,12 @@ infrastructure node can sleep when device power saving is enabled, so its WiFi services may be temporarily unavailable. RXPS only duty-cycles the LoRa receiver. Fresh Cascade-profile builds default to device power saving on, RXPS on at level 8 with a 16-symbol preamble, and WiFi modem power saving at -`min` on every build that includes ESP32 WiFi. +`min` on every build that includes ESP32 WiFi. The RXPS preamble value is a +saved timing assumption, not the wire length: starting with v1.17.1.5, +SF5-SF8 packets normally use a 32-symbol physical preamble. Firmware selects +64 for the whole SF/BW tuple only when 32 cannot enable RXPS at any level and +64 can (SF5/BW250 and SF6/BW500), then 128 only when neither shorter length +works (SF5/BW500). When `ENABLE_OTA` is included, a WiFi companion also listens on: diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 2bf697a2..270b06e5 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -731,29 +731,37 @@ minimum and return to it exactly when it is safe. available. Returning to slower settings recalculates from the saved RXPS minimum. -On SX1262+TCXO boards, the tested fast-setting boundaries are: +On SX1262+TCXO boards, the fast-setting boundaries are: -| SF | BW (kHz) | Effective preamble | Minimum effective RXPS level | Result | -|---:|---------:|-------------------:|-----------------------------:|:-------| -| 7 | 500 | 32 | 7 | RXPS duty cycling | -| 6 | 250 | 32 | 7 | RXPS duty cycling | -| 5 | 125 | 32 | 7 | RXPS duty cycling | -| 5 | 62.5 | 16 | 10 | RXPS duty cycling | +| SF | BW (kHz) | Wire preamble | Effective timing preamble | Minimum effective RXPS level | RX / sleep | +|---:|---------:|--------------:|--------------------------:|-----------------------------:|-----------:| +| 7 | 500 | 32 | 32 | 7 | 2731 / 6101 us | +| 6 | 250 | 32 | 32 | 7 | 2731 / 6101 us | +| 5 | 125 | 32 | 32 | 7 | 2731 / 6101 us | +| 5 | 250 | 64 | 64 | 8 | 1252 / 6424 us | +| 6 | 500 | 64 | 64 | 8 | 1252 / 6424 us | +| 5 | 500 | 128 | 128 | 8 | 626 / 6398 us | +| 5 | 62.5 | 32 | 16 | 10 | 4096 / 6272 us | SF7/BW500, SF6/BW250, and SF5/BW125 are timing-equivalent because each has a -256 us LoRa symbol. SF5/BW250 and SF5/BW500 use `continuous-fast` even with -preamble 32 in the normal build. +256 us LoRa symbol. SF5/BW250 and SF6/BW500 have 128 us symbols; both need 64 +wire symbols to cover the 6 ms transition. SF5/BW500 has 64 us symbols and +needs 128. -The table's effective preamble is the length RXPS uses for its timing window. -The radio's actual transmitted preamble is a firmware property, not a -`radio.rxps` preference. Normal firmware transmits 32 symbols at SF5-SF8 and -16 symbols at SF9-SF12. A controlled build can set -`-D LORA_FAST_PREAMBLE=64`; this changes the actual SF5-SF8 wire preamble and -lets SF5/BW250 duty-cycle at effective level 8 (`1252 / 6424 us`). That profile -passed a 16/16 on-air qualification at 909.950 MHz. SF5/BW500 remains too fast. -Use the 64-symbol option only when every possible sender to an RXPS receiver -uses the longer preamble; treating a 32-symbol sender as if it sent 64 would -create a receive gap. +The wire preamble is a firmware property, not a `radio.rxps` preference. The +effective preamble is the conservative length RXPS uses for its timing window, +which is why SF5/BW62.5 can retain a saved 16-symbol assumption while the radio +transmits 32. Starting with v1.17.1.5, firmware tries 32 first at SF5-SF8, then +64 and 128 only when each shorter length cannot enable RXPS at any level. It +uses 16 at SF9-SF12. Every packet on a tuple, including retries, uses that +tuple's selected length. + +Cascade/USA builds on a Heltec V4 and WisMesh Tag (RAK4631 target) passed 16/16 +packets in each direction at both SF5/BW250/64 and SF5/BW500/128, CR5, +909.950 MHz. A 64- or 128-symbol timing window is safe only when every possible +sender to the RXPS receiver follows the v1.17.1.5-or-newer adaptive-preamble +contract; a shorter legacy sender would create a receive gap. LoRa OTA +automation treats that version as the wire-format capability boundary. --- @@ -794,6 +802,7 @@ Station G2/G3 targets default to `off`. #### View or change RX duty-cycle power saving **Usage:** - `get radio.rxps` +- `get radio.rxps.config` - `get radio.rxps.rfrx_disabled` - `get rxps.wd` - `set radio.rxps.rfrx_disabled ` @@ -815,15 +824,20 @@ Station G2/G3 targets default to `off`. **Notes:** - `get rxps.wd` reports the RXPS watchdog's soft and hard recovery counts. +- `get radio.rxps.config` adds the persisted level and preamble assumption to + the on/off and timing values. Deployment tools use it to restore a + level-based preference without converting it to fixed manual timings. - `radio.rxps.rfrx_disabled` is a runtime-only diagnostic setting and resets to `off` after reboot. - Its default `off` state keeps the host-controlled SX1262 receive path enabled during RX duty-cycle mode. Setting it to `on` reproduces the old missing-RF_RX behavior and can significantly reduce receive sensitivity, making remote commands harder to receive. - `radio.rxps.rfrx_disabled` is supported only on SX1262 targets with a host-controlled RX enable pin. - `on` and `conservative` select level `1` with a 16-symbol preamble; `balanced` selects level `5` with a 16-symbol preamble. - Fresh Cascade-profile builds start with RXPS on at level `8` and a 16-symbol preamble. Saved operator settings still take precedence after an upgrade. - Level-based settings automatically recalculate their timings when the spreading factor or bandwidth changes. Custom ` ` timings remain fixed. -- `get radio.rxps` reports both the saved timing assumption and any effective - preamble/level selected for the current tuple. A 64-symbol effective preamble - appears only in a firmware build whose actual fast preamble is 64. +- `get radio.rxps` keeps the legacy on/off, RX, and sleep reply. The new + `get radio.rxps.config` reply adds the saved level and preamble assumption. + Radio-change replies and the Full Companion terminal status report any + effective level/preamble adjustment. Effective preamble 64 or 128 appears + only when the active tuple's physical wire preamble has that length. - The selected mode is applied immediately, persisted, and restored after reboot. --- @@ -2963,7 +2977,7 @@ set direct.retry.margin 10 - Room-server and sensor firmware accept `on` or `off`; numeric SNR thresholds are repeater-only because those roles do not keep recent-repeater SNR data. - `off` disables per-packet retry CR overrides and uses the current radio CR. -- Direct path retry packets sent at CR4 or CR5 temporarily use a shorter 16-symbol preamble, then restore the radio's default preamble. +- Retry packets may use a different coding rate, but they keep the radio's normal physical preamble. - Unknown repeaters start at `+3.00 dB` for adaptive CR selection. - A failed unknown repeater is seeded at `+2.75 dB`. - Each later failure lowers the SNR estimate by `0.25 dB`. diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index d99db9b8..dca88df6 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -210,6 +210,56 @@ North American example: choose a legal frequency supported by every participating radio and appropriate to your location. Older radios that do not support SF5 require a complete replacement tuple passed with `--temp-radio`. +### RXPS handling during TempRadio + +RXPS improves receive performance per unit of radio-on time, so the runner +keeps the destination's saved RXPS preference enabled whenever the selected +fast tuple has a qualified timing window. Before changing any radio, it reads +`ver` from the destination, controller, source, and every named relay. It treats +v1.17.1.5 as the first forward contract in which every SF5-SF8 transmission, +including a retry, uses the same tuple-selected physical preamble: normally 32 +symbols, 64 only when 32 cannot enable RXPS, and 128 only when neither 32 nor +64 can. A saved RXPS level is also safely retuned after a radio change. An +older or unparseable destination version fails closed: the runner temporarily +sends `set radio.rxps off` instead of assuming an ad-hoc build contains the +timing fixes. Automation deliberately treats the version as a wire-format +contract. + +For the default SF5/BW250 tuple, the runner keeps destination RXPS on only when +the destination and every possible sender are v1.17.1.5 or newer. It does not +overwrite an existing level-based preference. Current firmware retunes that +preference against the tuple-selected 64-symbol wire preamble; the qualified boundary is +effective level 8, preamble 64 (`1252 / 6424 us`). A manually configured node +may save `set radio.rxps level 8 preamble 32` before entering TempRadio: when +32 symbols cannot cover the TCXO transition, firmware safely selects the real +64-symbol preamble. If the saved setting uses fixed manual timings, or even one +participant is older or unknown, RXPS is temporarily off. This avoids the +receive gap that a 32-symbol sender or an unretuned manual window would create. + +The complete qualified SX1262+TCXO policy is: + +| TempRadio SF/BW | Saved reference setting | Qualified boundary | Automation | +| --- | --- | --- | --- | +| SF7/BW500 | `level 7 preamble 32` | 7 / 32 | RXPS on for a current destination | +| SF6/BW250 | `level 7 preamble 32` | 7 / 32 | RXPS on for a current destination | +| SF5/BW125 | `level 7 preamble 32` | 7 / 32 | RXPS on for a current destination | +| SF5/BW250 | `level 8 preamble 32` | 8 / 64 | RXPS on only when every participant follows the 64-symbol contract | +| SF6/BW500 | `level 8 preamble 32` | 8 / 64 | RXPS on only when every participant follows the adaptive-preamble contract | +| SF5/BW500 | `level 8 preamble 32` | 8 / 128 | RXPS on only when every participant follows the adaptive-preamble contract | +| SF5/BW62.5 | `level 10 preamble 16` | 10 / 16 | RXPS on for a current destination | +| Unqualified tuple | none | continuous RX | RXPS temporarily off | + +If RXPS was already off, the runner leaves it off. Otherwise it writes the +original level, preamble assumption, receive/sleep periods, and temporary +decision to protected +`target-rxps-settings.json` in the run's working directory, verifies the +temporary state, and restores the exact original setting after the target is +back on its normal radio. Current firmware exposes this complete state through +`get radio.rxps.config`; a legacy reply has only on/off and periods, so the +runner can restore those periods but cannot reconstruct an unreported saved +level. A radio change later recalculates from a preserved saved minimum, so +moving back to a slower tuple returns to the operator's saved level. + ## 4. Run an ESP32 update The ZIP can contain a compatible ready `.mota` or the exact board-and-role @@ -400,6 +450,9 @@ Useful controls: default the runner then schedules the target, relays, and a script-configured source back to their normal radios. Combining it with `--leave-controller-radio` deliberately preserves the TempRadio topology. + If the version gate required RXPS off, it stays off while that topology is + preserved; use `target-rxps-settings.json` to restore it only after sending + `normalradio`. - `--allow-non-upgrade` deliberately permits the same or an older version. - `--replace-active-download` deliberately discards a different update already downloading or staged on the target. Without it, that update is preserved. @@ -449,8 +502,11 @@ the destination. hashes, Merkle root, full-image hash where applicable, identity fields, signature, codec, base, and the firmware's 1024-byte maximum block size. Version-3 bootloader packages are refused before any target state changes. -4. Save the controller's normal radio tuple and show the confirmation prompt. -5. Start TempRadio on the target, then far-to-near relays, then the source; +4. Save the controller's normal radio tuple, read every participant's version, + save the destination's RXPS state, select the qualified RXPS policy, and + show the confirmation prompt. +5. Apply and verify that RXPS policy, then start TempRadio on the target, + far-to-near relays, and the source; finally switch the controller to the same tuple and read it back. The runner rejects a TempRadio window that cannot cover setup, seeder startup, discovery, the transfer timeout, final polling, and install checks. @@ -468,7 +524,8 @@ the destination. continues the 10-second probes through the mandatory relay-return window. A source supplied with `--source-already-temp` is never modified. `--leave-controller-radio` moves the controller back to TempRadio only after - this normal-channel verification. + this normal-channel verification. Restore the destination's exact original + RXPS setting after normal-channel identity is proven. Remote replies are matched only after queued messages have been drained and only when they come from the intended contact and fit the command. A ready @@ -501,7 +558,9 @@ download without replacing it. The working directory is retained and printed at exit. It contains the exact served mOTA, `motatool-serve.log`, extracted build inputs when needed, and -`controller-radio.txt`. It contains no saved admin password. +`controller-radio.txt`. When the destination started with RXPS enabled it also +contains protected `target-rxps-settings.json` for manual recovery. It contains +no saved admin password. ## Interruption and recovery @@ -509,6 +568,9 @@ Ctrl-C stops the seeder, detaches its serial folder, makes one best-effort request to shorten a source TempRadio window started by the script, and attempts to restore the controller. The target and relays remain on TempRadio only until their bounded windows end; rebooting also restores their saved radio settings. +A normal cleanup restores the destination's exact RXPS periods. If that remote +restore cannot be confirmed, use `target-rxps-settings.json` after the target +returns to its normal channel. A partial download remains safe. Once the target is reachable again (after its TempRadio window ends, or after putting the controller back on that tuple), rerunning the same package recognizes its manifest ID and resumes the existing diff --git a/docs/ota_easy.md b/docs/ota_easy.md index b69a8668..41e84f12 100644 --- a/docs/ota_easy.md +++ b/docs/ota_easy.md @@ -47,6 +47,25 @@ reboots. Current full-parser firmware also accepts `normalradio`, which cancels restores the saved tuple after replying on the current channel. This frequency is intended for North American configurations. Confirm that it is permitted in your location and change it when necessary. +At SF5/BW250, an SX1262+TCXO destination can keep RXPS enabled only when it and +every sender that can reach it (controller, source, and relays) follow the +v1.17.1.5-or-newer adaptive-preamble contract. The qualified destination +boundary is effective level 8, preamble 64 and `1252 / 6424 us`. For manual +setup, save `set radio.rxps level 8 preamble 32` before entering TempRadio; the +`32` is a conservative saved timing assumption and current firmware selects +the real 64-symbol wire preamble when it retunes the temporary radio. For an +older, unknown, or mixed-version path, use +`set radio.rxps off` for the maintenance window. First record +`get radio.rxps.config`; after `normalradio`, restore a reported level with +`set radio.rxps level N preamble P` (omit `preamble P` when `P` is `0`). A +legacy node returns only on/off and periods, which can be restored with +`set radio.rxps RX_US SLEEP_US`. The scripted runner performs the version +checks, temporary change, verification, and restoration automatically while +preserving automatic retuning. SF5/BW500 is too fast for RXPS even with a +64-symbol preamble, so current firmware selects 128 and uses the qualified +level-8 `626 / 6398 us` timing when every participant supports the adaptive +contract. + ## Before you start Both paths require: diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 0d5ac86a..cfd6494a 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -24,6 +24,16 @@ tempradio 909.950,250,5,5,120 Use the node's current permitted regional frequency in place of `909.950` when necessary. +The scripted updater checks the firmware version of the receiver and every +sender before deciding whether RX power saving can remain active. At +SF5/BW250, an all-v1.17.1.5-or-newer path can use effective RXPS level 8 with +the tuple-selected 64-symbol physical preamble. SF5/BW500 can use level 8 with +128 symbols. Other fast tuples remain at the shortest viable preamble, normally +32. A mixed, older, or unknown long-preamble path is put in continuous receive +for the update, then the receiver's exact saved RXPS setting is restored. This +preserves the RX benefit without risking missed packets from a legacy +32-symbol sender. + > **Can my node install the update?** Choose a release-table artifact explicitly labelled LoRa-OTA capable, > then confirm `ota self` and `ota status` expose install support; do not infer support from the filename alone. > LoRa OTA firmware is available for supported **ESP32** boards and nRF52 repeater targets. Every nRF52 diff --git a/docs/rak3401_mota_chain.md b/docs/rak3401_mota_chain.md index 35a2cb01..ae2c563e 100644 --- a/docs/rak3401_mota_chain.md +++ b/docs/rak3401_mota_chain.md @@ -218,6 +218,14 @@ tests use `hops 0`: `hops 1` makes the target echo each source response despite there being no intermediate relay, increasing half-duplex loss and legacy three-second retries. +The exact compact chain never qualifies for fast RXPS: its bridge and endpoint +versions predate the v1.17.1.5 adaptive-preamble contract. Keep RXPS off for +this historical chain whether using SF5/BW250 or the faster direct-bench +SF5/BW500 tuple. For a later update whose target, controller, source, and +relays are all v1.17.1.5 or newer, use the generic LoRa OTA runner; it can +retain RXPS at the qualified level-8/preamble-64 boundary for SF5/BW250 or the +level-8/preamble-128 boundary for SF5/BW500. + Put the source on the identical TempRadio tuple. If it is a binary-mode Full Companion, the current `motatool` can switch modes for the serving session: @@ -369,8 +377,12 @@ predates the rescue command. Before the first mutation, the runner saves the destination's RXPS periods, CPU power-saving state, RX flood delay, airtime factor, and OTA hop reach in -the persistent work directory. It verifies `radio.rxps off`, `powersaving off`, -and `rxdelay 0` after every bridge reboot, plus `af 0` when +the persistent work directory. It reads the destination, controller, source, +and relay versions before applying the same RXPS policy as the generic runner. +The pinned historical chain therefore verifies `radio.rxps off` after every +bridge reboot; a future all-v1.17.1.5-or-newer SF5/BW250 chain would instead +keep RXPS on under the qualified level-8/preamble-64 boundary. It also verifies +`powersaving off` and `rxdelay 0`, plus `af 0` when `--legacy-full-airtime` was explicitly selected. It restores every original value only after the exact endpoint is proven. An interrupted run deliberately leaves those transfer guardrails active; rerun the same command with the same diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 2f678ead..021af986 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -104,46 +104,50 @@ protocol frames and terminal replies remain enabled on interface `00`. ``` get radio.rxps +get radio.rxps.config set radio.rxps {off|on|level 1-10 [preamble 16|32]|rx_us sleep_us} ``` -Shows or changes LoRa receive duty cycling on supported radios. Fresh Cascade +Shows or changes LoRa receive duty cycling on supported radios. +`get radio.rxps.config` also reports the saved level and preamble assumption so +automation can restore a level-based preference exactly. Fresh Cascade builds select level 8 with a 16-symbol timing assumption. The `preamble` argument controls the RXPS calculation; it does not change the physical wire preamble. A configured level is the minimum: when faster SF/BW settings shorten the timing window, firmware raises the effective level only as far as needed, -up to level 10. For SF5-SF8 the normal build can then use the radio's actual -32-symbol transmit preamble if no level is safe under the legacy 16-symbol -assumption. The configured values remain unchanged, so every radio change -recalculates from the saved minimum. A slower tuple returns to that exact level -when it is safe; otherwise firmware keeps using the smallest safe effective -level. +up to level 10. Starting with v1.17.1.5, SF5-SF8 packets normally use a +32-symbol physical preamble. Firmware selects 64, then 128, only when every +shorter choice fails to enable RXPS at any level. The configured values remain +unchanged, so every radio change recalculates from the saved minimum. A slower +tuple returns to that exact level and the shortest viable wire preamble. If neither adjustment leaves enough time for the radio to wake, RXPS stays logically enabled but receives continuously instead of rejecting the radio -setting or starting an invalid duty cycle. `get radio.rxps` reports this as -`mode=continuous-fast` and reports any `effective-level` or -`effective-preamble` adjustment. On SX1262 boards with a TCXO, SF5/BW125 works -at effective level 7 with preamble 32; SF5/BW250 and SF5/BW500 use the -continuous fallback. A later compatible SF/BW change resumes duty cycling -without another RXPS command. +setting or starting an invalid duty cycle. The Companion terminal's +`get radio.rxps` reports this as `mode=continuous-fast` and reports any +`effective-level` or `effective-preamble` adjustment; +`get radio.rxps.config` reports the persisted preference. A later compatible +SF/BW change resumes duty cycling without another RXPS command. For the SX1262+TCXO boards tested here, these fast combinations are the useful RXPS boundary profiles (CR does not change the RXPS preamble timing): -| SF | BW (kHz) | Effective preamble | Minimum effective level | RX / sleep | -|---:|---------:|-------------------:|------------------------:|-----------:| -| 7 | 500 | 32 | 7 | 2731 / 6101 us | -| 6 | 250 | 32 | 7 | 2731 / 6101 us | -| 5 | 125 | 32 | 7 | 2731 / 6101 us | -| 5 | 62.5 | 16 | 10 | 4096 / 6272 us | +| SF | BW (kHz) | Wire preamble | Effective preamble | Minimum effective level | RX / sleep | +|---:|---------:|--------------:|-------------------:|------------------------:|-----------:| +| 7 | 500 | 32 | 32 | 7 | 2731 / 6101 us | +| 6 | 250 | 32 | 32 | 7 | 2731 / 6101 us | +| 5 | 125 | 32 | 32 | 7 | 2731 / 6101 us | +| 5 | 250 | 64 | 64 | 8 | 1252 / 6424 us | +| 6 | 500 | 64 | 64 | 8 | 1252 / 6424 us | +| 5 | 500 | 128 | 128 | 8 | 626 / 6398 us | +| 5 | 62.5 | 32 | 16 | 10 | 4096 / 6272 us | -SF5/BW250 and SF5/BW500 remain too fast for a true TCXO duty cycle in the -normal 32-symbol build, so they use `continuous-fast`. A controlled build with -`-D LORA_FAST_PREAMBLE=64` changes the actual SF5-SF8 wire preamble. SF5/BW250 -then uses effective level 8 and `1252 / 6424 us`; that combination passed a -16/16 on-air qualification at 909.950 MHz. SF5/BW500 still uses -`continuous-fast`. Deploy a longer physical preamble consistently across all -senders that may reach an RXPS receiver. +The 64- and 128-symbol rows require the v1.17.1.5-or-newer adaptive-preamble +contract on every sender that may reach the RXPS receiver. Cascade/USA builds +on a Heltec V4 and WisMesh Tag (RAK4631 target) passed 16/16 packets in each +direction at both SF5/BW250/64 and SF5/BW500/128, CR5, 909.950 MHz. An older +sender makes a long-preamble timing window unsafe, so use continuous RX for a +mixed deployment. Retry packets use the same physical preamble as other +packets. ``` get wifi.powersave @@ -390,7 +394,7 @@ For example: ```text channels -channel #rgdata Hello from Eugene 👋 +channel #rgdata Hello from Eugene channel 2 Another message show channels on ``` diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index ae206f49..eeaa85a9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -4053,12 +4053,12 @@ bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { preamble = 0; level_requested = true; } else if (sscanf(value, "%lu %lu %c", &parsed_rx, &parsed_sleep, &extra) == 2) { - if (parsed_rx < RX_POWERSAVING_MIN_PERIOD_US + if (parsed_rx < RX_POWERSAVING_MIN_MANUAL_PERIOD_US || parsed_rx > RX_POWERSAVING_MAX_PERIOD_US - || parsed_sleep < RX_POWERSAVING_MIN_PERIOD_US + || parsed_sleep < RX_POWERSAVING_MIN_MANUAL_PERIOD_US || parsed_sleep > RX_POWERSAVING_MAX_PERIOD_US) { snprintf(reply, 160, "Error: RX/SLEEP must be %lu-%lu us", - (unsigned long)RX_POWERSAVING_MIN_PERIOD_US, + (unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US, (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); return false; } @@ -4086,7 +4086,7 @@ bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { && (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us))) { snprintf(reply, 160, "Error: RX/SLEEP must be %lu-%lu us", - (unsigned long)RX_POWERSAVING_MIN_PERIOD_US, + (unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US, (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); return false; } @@ -4137,7 +4137,7 @@ void MyMesh::appendRxPowerSavingAdjustmentNote(char* reply, size_t reply_size, } const uint8_t requested_preamble = _prefs.rx_ps_preamble == 0 - ? rxPowerSavingPreambleForSF(sf) : _prefs.rx_ps_preamble; + ? rxPowerSavingPreambleForParams(sf, bw) : _prefs.rx_ps_preamble; if (effective_level == _prefs.rx_ps_level && effective_preamble == requested_preamble) { return; @@ -4990,6 +4990,18 @@ void MyMesh::handleTerminalCommand(char* command) { char reply[160]; applyAndSavePowerSaving(command + 12, reply); terminalOutput().printf(" %s\r\n", reply); + } else if (strcmp(command, "get radio.rxps.config") == 0) { + if (!radio_driver.supportsRxPowerSaving()) { + terminalOutput().print(" ERROR: RX power saving is unsupported on this radio\r\n"); + } else { + terminalOutput().printf( + " radio.rxps.config %s,level=%u,preamble=%u,rx=%lu,sleep=%lu\r\n", + _prefs.rx_powersaving_enabled ? "on" : "off", + (unsigned)_prefs.rx_ps_level, + (unsigned)_prefs.rx_ps_preamble, + (unsigned long)_prefs.rx_ps_rx_us, + (unsigned long)_prefs.rx_ps_sleep_us); + } } else if (strcmp(command, "get radio.rxps") == 0) { if (!radio_driver.supportsRxPowerSaving()) { terminalOutput().print(" ERROR: RX power saving is unsupported on this radio\r\n"); @@ -5233,6 +5245,7 @@ void MyMesh::handleTerminalCommand(char* command) { #endif #endif terminalOutput().print(" get radio.rxps\r\n"); + terminalOutput().print(" get radio.rxps.config\r\n"); terminalOutput().print(" set radio.rxps \r\n"); terminalOutput().print(" get radio.rxgain\r\n"); terminalOutput().print(" set radio.rxgain \r\n"); diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 4f5b0480..20e23385 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1464,13 +1464,6 @@ static uint8_t getRetryLogCodingRate(const mesh::Packet* packet, uint8_t default return default_cr; } -static uint16_t getRetryLogPreambleLength(const mesh::Packet* packet, uint16_t default_preamble_len) { - if (packet != NULL && packet->tx_cr >= 4 && packet->tx_cr <= 8) { - return 32; - } - return default_preamble_len; -} - void MyMesh::onDirectRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis, uint8_t retry_attempt, const uint8_t* target_hash, uint8_t target_hash_len, int16_t payload_type) { #if defined(PORTABLE_MQTT_OBSERVER) @@ -1490,7 +1483,7 @@ void MyMesh::onDirectRetryEvent(const char* event, const mesh::Packet* packet, u } formatDirectRetryTarget(target_label, sizeof(target_label), target_hash, target_hash_len); uint8_t log_cr = getRetryLogCodingRate(packet, getDefaultTxCodingRate()); - uint16_t log_preamble_len = getRetryLogPreambleLength(packet, radio_driver.getDefaultPreambleLength()); + uint16_t log_preamble_len = rxPowerSavingPreambleForParams(active_sf, active_bw); #if MESH_DEBUG MESH_DEBUG_PRINTLN("direct retry %s attempt=%u delay=%lu type=%s route=%s target=%s cr=%u preamble_len=%u", @@ -2115,7 +2108,7 @@ void MyMesh::onFloodRetryEvent(const char* event, const mesh::Packet* packet, ui snprintf(heard_suffix, sizeof(heard_suffix), ", heard=%s", heard_log); } uint8_t log_cr = getRetryLogCodingRate(packet, getDefaultTxCodingRate()); - uint16_t log_preamble_len = getRetryLogPreambleLength(packet, radio_driver.getDefaultPreambleLength()); + uint16_t log_preamble_len = rxPowerSavingPreambleForParams(active_sf, active_bw); MESH_DEBUG_PRINTLN("flood retry %s (retry=%u, type=%d, route=%s, payload_len=%d, hop=%u, path=%s%s, %s=%lu, cr=%u, preamble_len=%u)", event, @@ -3777,7 +3770,8 @@ bool MyMesh::applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { 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) { - uint32_t preamble = _prefs.rx_ps_preamble ? _prefs.rx_ps_preamble : (sf <= 8 ? 32UL : 16UL); + uint32_t preamble = _prefs.rx_ps_preamble + ? _prefs.rx_ps_preamble : rxPowerSavingPreambleForParams(sf, bw); if (!CommonCLI::calculateRxPowerSavingLevel( _prefs.rx_ps_level, sf, bw, preamble, &rx_us, &sleep_us)) return false; } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index d1447d2b..881e8fc5 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -2230,7 +2230,7 @@ void MyMesh::loop() { bool timing_ok = true; if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0) { uint32_t preamble = _prefs.rx_ps_preamble ? _prefs.rx_ps_preamble - : (pending_sf <= 8 ? 32UL : 16UL); + : rxPowerSavingPreambleForParams(pending_sf, pending_bw); timing_ok = CommonCLI::calculateRxPowerSavingLevel( _prefs.rx_ps_level, pending_sf, pending_bw, preamble, &rx_us, &sleep_us); } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 229d6494..d0792ecb 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1207,7 +1207,7 @@ void SensorMesh::loop() { bool timing_ok = true; if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0) { uint32_t preamble = _prefs.rx_ps_preamble ? _prefs.rx_ps_preamble - : (pending_sf <= 8 ? 32UL : 16UL); + : rxPowerSavingPreambleForParams(pending_sf, pending_bw); timing_ok = CommonCLI::calculateRxPowerSavingLevel( _prefs.rx_ps_level, pending_sf, pending_bw, preamble, &rx_us, &sleep_us); } diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 5745a912..e04c5931 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -116,10 +116,6 @@ void Dispatcher::restoreOutboundTxOverrides() { _radio->setCodingRate(outbound_restore_cr); outbound_restore_cr = 0; } - if (outbound_restore_preamble_len != 0) { - _radio->setPreambleLength(outbound_restore_preamble_len); - outbound_restore_preamble_len = 0; - } } bool Dispatcher::startOutboundTransmit() { @@ -146,9 +142,7 @@ bool Dispatcher::startOutboundTransmit() { uint32_t max_airtime = _radio->getEstAirtimeFor(len) * 3 / 2; outbound_restore_cr = 0; - outbound_restore_preamble_len = 0; uint8_t default_cr = getDefaultTxCodingRate(); - bool is_retry = outbound->tx_cr >= 4 && outbound->tx_cr <= 8; if (outbound->tx_cr >= 4 && outbound->tx_cr <= 8 && default_cr >= 4 && default_cr <= 8 && outbound->tx_cr != default_cr) { @@ -160,14 +154,6 @@ bool Dispatcher::startOutboundTransmit() { getLogDateTime(), (uint32_t)outbound->tx_cr); } } - if (is_retry) { - uint16_t default_preamble_len = _radio->getDefaultPreambleLength(); - if (default_preamble_len != 32 && _radio->setPreambleLength(32)) { - outbound_restore_preamble_len = default_preamble_len; - max_airtime = _radio->getEstAirtimeFor(len) * 3 / 2; - } - } - outbound_start = _ms->getMillis(); if (!_radio->startSendRaw(raw, len)) { MESH_DEBUG_PRINTLN("%s Dispatcher::startOutboundTransmit(): ERROR: send start failed!", diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 4feaf43e..73a4a1c3 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -63,8 +63,6 @@ public: * \returns true if the radio accepted the coding rate. */ virtual bool setCodingRate(uint8_t cr) { return false; } - virtual uint16_t getDefaultPreambleLength() const { return 0; } - virtual bool setPreambleLength(uint16_t len) { return false; } /** * \returns true if the previous 'startSendRaw()' completed successfully. @@ -239,7 +237,6 @@ class Dispatcher { #else RadioLivenessTracker radio_liveness; #endif - uint16_t outbound_restore_preamble_len; uint8_t outbound_restore_cr; unsigned long next_tx_time; unsigned long cad_busy_start; @@ -280,7 +277,6 @@ protected: outbound_radio_retry_at = 0; outbound_radio_retry_pending = false; outbound_radio_retry_used = false; - outbound_restore_preamble_len = 0; outbound_restore_cr = 0; total_air_time = rx_air_time = 0; next_tx_time = ms.getMillis(); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index f3f444e4..5f897cab 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -450,7 +450,8 @@ static const char* retryPresetName(uint8_t preset) { bool CommonCLI::calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble, uint32_t* rx_us, uint32_t* sleep_us) { - if (level < 1 || level > 10 || (preamble != 16 && preamble != 32)) { + if (level < 1 || level > 10 || preamble > 0xFF + || !isRxPowerSavingPreamble((uint8_t)preamble)) { return false; } return calcRxPowerSavingLevelAtOrAbove( @@ -482,7 +483,7 @@ static void appendRxPowerSavingAdjustmentNote(char* reply, const NodePrefs* pref (unsigned)prefs->rx_ps_level); } else { const uint8_t requested_preamble = prefs->rx_ps_preamble == 0 - ? rxPowerSavingPreambleForSF(sf) : prefs->rx_ps_preamble; + ? rxPowerSavingPreambleForParams(sf, bw) : prefs->rx_ps_preamble; if (effective_level == prefs->rx_ps_level && effective_preamble == requested_preamble) { return; @@ -3419,9 +3420,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep uint32_t rx_us = _prefs->rx_ps_rx_us; uint32_t sleep_us = _prefs->rx_ps_sleep_us; uint8_t level = 0; - uint8_t preamble = rxPowerSavingPreambleForSF(_prefs->sf); + uint8_t preamble = rxPowerSavingPreambleForParams(_prefs->sf, _prefs->bw); bool level_requested = false; bool preamble_overridden = false; + bool manual_timings_requested = false; ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us); rx_us = _prefs->rx_ps_rx_us; @@ -3464,20 +3466,36 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep rx_us = _atoi(parts[0]); sleep_us = _atoi(parts[1]); enable = 1; + manual_timings_requested = true; } else { strcpy(reply, "ERROR: use off|on|conservative|balanced|level <1-10>| "); return; } } + if (level_requested && preamble_overridden + && preamble != 16 && preamble != 32) { + strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32"); + return; + } + if (level_requested && !calculateRxPowerSavingLevel(level, _prefs->sf, _prefs->bw, preamble, &rx_us, &sleep_us)) { strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32"); return; } + if (manual_timings_requested + && (rx_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US + || sleep_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US)) { + sprintf(reply, "ERROR: range is %lu-%lu us", + (unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US, + (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); + return; + } + if (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us)) { sprintf(reply, "ERROR: range is %lu-%lu us", - (unsigned long)RX_POWERSAVING_MIN_PERIOD_US, + (unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US, (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); return; } @@ -4248,6 +4266,14 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else { sprintf(reply, "> %s", _callbacks->isRxPowerSavingRfRxDisabled() ? "on" : "off"); } + } else if (strcmp(config, "radio.rxps.config") == 0) { + ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us); + sprintf(reply, "> %s,level=%u,preamble=%u,rx=%lu,sleep=%lu", + _prefs->rx_powersaving_enabled ? "on" : "off", + (unsigned)_prefs->rx_ps_level, + (unsigned)_prefs->rx_ps_preamble, + (unsigned long)_prefs->rx_ps_rx_us, + (unsigned long)_prefs->rx_ps_sleep_us); } else if (memcmp(config, "radio.rxps", 10) == 0) { // RX PowerSaving ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us); sprintf(reply, "> %s,%lu,%lu", _prefs->rx_powersaving_enabled ? "on" : "off", diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index ba894b69..799b7fb9 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -16,10 +16,10 @@ protected: && ((CustomLLCC68 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE && ((CustomLLCC68 *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE && ((CustomLLCC68 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE - && updatePreamble(sf); + && updatePreamble(sf, bw); if (!success) return false; - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForParams(sf, bw)); ((CustomLLCC68 *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomLLCC68 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); return true; diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 79527cf5..8ee64b71 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -26,10 +26,10 @@ protected: && ((CustomLR1110 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE && ((CustomLR1110 *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE && ((CustomLR1110 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE - && updatePreamble(sf); + && updatePreamble(sf, bw); if (!success) return false; - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForParams(sf, bw)); ((CustomLR1110 *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomLR1110 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); return true; @@ -60,7 +60,7 @@ public: void onSendFinished() override { RadioLibWrapper::onSendFinished(); - _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts + _radio->setPreambleLength(currentPreambleLength()); // overcomes weird issues with small and big pkts } bool supportsRxPowerSaving() const override { return true; } diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index fa01c170..1e07f80b 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -24,11 +24,11 @@ protected: && ((CustomLR2021 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE && ((CustomLR2021 *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE && ((CustomLR2021 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE - && updatePreamble(sf) + && updatePreamble(sf, bw) && applySideDetectorConfig(sf, bw); if (!success) return false; - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForParams(sf, bw)); ((CustomLR2021 *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomLR2021 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); return true; @@ -171,7 +171,7 @@ public: void onSendFinished() override { RadioLibWrapper::onSendFinished(); - _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts + _radio->setPreambleLength(currentPreambleLength()); // overcomes weird issues with small and big pkts } uint8_t getSpreadingFactor() const override { return ((CustomLR2021 *)_radio)->getSpreadingFactor(); } diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index a3ae64a0..301c50a5 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -15,7 +15,7 @@ protected: && ((CustomSTM32WLx *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE && ((CustomSTM32WLx *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE && ((CustomSTM32WLx *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE - && updatePreamble(sf); + && updatePreamble(sf, bw); } public: diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 9fc9b71e..6a85f10f 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -18,10 +18,10 @@ protected: && ((CustomSX1262 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE && ((CustomSX1262 *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE && ((CustomSX1262 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE - && updatePreamble(sf); + && updatePreamble(sf, bw); if (!success) return false; - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForParams(sf, bw)); ((CustomSX1262 *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomSX1262 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); return true; diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index ce53b358..09a3a2ff 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -20,10 +20,10 @@ protected: && ((CustomSX1268 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE && ((CustomSX1268 *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE && ((CustomSX1268 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE - && updatePreamble(sf); + && updatePreamble(sf, bw); if (!success) return false; - PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForSF(sf)); + PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForParams(sf, bw)); ((CustomSX1268 *)_radio)->setPreambleMillis(pm.preambleMillis); ((CustomSX1268 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); return true; diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index ddb09431..cdfc188b 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -19,7 +19,7 @@ protected: && ((CustomSX1276 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE && ((CustomSX1276 *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE && ((CustomSX1276 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE - && updatePreamble(sf); + && updatePreamble(sf, bw); } public: diff --git a/src/helpers/radiolib/RXPowerSaving.h b/src/helpers/radiolib/RXPowerSaving.h index a4327ab7..e7e32db2 100644 --- a/src/helpers/radiolib/RXPowerSaving.h +++ b/src/helpers/radiolib/RXPowerSaving.h @@ -3,30 +3,26 @@ #define RX_POWERSAVING_DEFAULT_RX_US 65625UL #define RX_POWERSAVING_DEFAULT_SLEEP_US 60000UL -#define RX_POWERSAVING_MIN_PERIOD_US 1000UL +#define RX_POWERSAVING_MIN_PERIOD_US 32UL +#define RX_POWERSAVING_MIN_MANUAL_PERIOD_US 1000UL #define RX_POWERSAVING_MAX_PERIOD_US 30000000UL -// Actual transmit preamble used by MeshCore at SF5-SF8. Keep 32 as the wire -// compatible default; controlled qualification builds can select 64 to test -// faster RX duty-cycle profiles without pretending the sender transmits a -// longer preamble than it really does. -#ifndef LORA_FAST_PREAMBLE -#define LORA_FAST_PREAMBLE 32 -#endif -#if LORA_FAST_PREAMBLE != 32 && LORA_FAST_PREAMBLE != 64 -#error "LORA_FAST_PREAMBLE must be 32 or 64" -#endif +// Every sender must make the same wire-preamble choice for a given SF/BW, +// regardless of whether that sender enables RXPS locally. Qualify the choice +// against the worst supported SX1262+TCXO transition so a receiver never bases +// its sleep window on a longer preamble than another current sender transmits. +#define RX_POWERSAVING_WIRE_TRANSITION_US 6000UL // A level-derived timing pair equal to this value means the requested LoRa // tuple is too fast for a safe duty cycle. Keep the operator's RXPS preference // enabled, but receive continuously until a later SF/BW change produces usable // timings. Using an in-range pair lets the existing radio-parameter API carry // the fallback without persisting invalid sub-millisecond periods. -#define RX_POWERSAVING_CONTINUOUS_FALLBACK_US RX_POWERSAVING_MIN_PERIOD_US +#define RX_POWERSAVING_CONTINUOUS_FALLBACK_US 1000UL -// The named profiles are level presets pinned to a 16-symbol preamble: most -// deployed senders still transmit 16-symbol preambles regardless of the newer -// SF-based rule (32 for SF <= 8). Revisit once the field has largely migrated. +// Named profiles keep a conservative 16-symbol timing assumption. The adaptive +// calculation may use the longer physical preamble only when the configured +// assumption cannot safely cover the current tuple. #define RX_POWERSAVING_CONSERVATIVE_LEVEL 1 #define RX_POWERSAVING_BALANCED_LEVEL 5 #define RX_POWERSAVING_PROFILE_PREAMBLE 16 @@ -102,15 +98,8 @@ inline bool canStartRxPowerSavingDutyCycle(uint32_t rx_us, uint32_t sleep_us, && (((sleep_us - transition_us) * 8UL) / 125UL) != 0; } -// MeshCore preamble convention used for the RX powersaving timing calculation. -// Must stay in sync with RadioLibWrapper::preambleLengthForSF() (the value the -// radio actually transmits); kept local here because CommonCLI is radio-agnostic. -inline uint8_t rxPowerSavingPreambleForSF(uint8_t sf) { - return sf <= 8 ? LORA_FAST_PREAMBLE : 16; -} - inline bool isRxPowerSavingPreamble(uint8_t preamble) { - return preamble == 16 || preamble == 32 || preamble == 64; + return preamble == 16 || preamble == 32 || preamble == 64 || preamble == 128; } inline bool isNumeric(const char *sp) { @@ -147,11 +136,9 @@ inline bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t uint32_t calculated_rx_us = ceilPositiveFloat(rx_symbols * symbol_us); uint32_t calculated_sleep_us = (uint32_t)(sleep_symbols * symbol_us); - // At high-rate settings (notably SF5/BW500), the entire preamble can be - // shorter than the radio's sleep/wake transition. Scaling either period up - // would break the preamble-overlap guarantee and silently lose packets. - // Canonicalize that case to continuous RX instead; the wrapper will resume - // a real duty cycle automatically when slower parameters are restored. + // Do not scale an undersized period up: that would break the preamble-overlap + // guarantee. The 32 us floor is the first whole-microsecond value that + // produces a non-zero timer tick on every supported duty-cycle radio. if (calculated_rx_us < RX_POWERSAVING_MIN_PERIOD_US || calculated_sleep_us < RX_POWERSAVING_MIN_PERIOD_US) { calculated_rx_us = RX_POWERSAVING_CONTINUOUS_FALLBACK_US; @@ -163,6 +150,23 @@ inline bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t return true; } +// MeshCore wire-preamble convention. SF5-SF8 normally use 32 symbols. Try 64 +// and then 128 only when every shorter choice fails to support RXPS at any +// level. If none works, retain 32 and fall back to continuous receive. This +// must stay in sync with RadioLibWrapper::preambleLengthForParams(). +inline uint8_t rxPowerSavingPreambleForParams(uint8_t sf, float bw) { + if (sf > 8) return 16; + if (sf < 5 || bw <= 0.0f) return 32; + + // The validated LoRa bandwidth grid tops out at 500 kHz and contains 125, + // 250, and 500 kHz at the fast end. Checking the resulting safe boundaries + // directly is equivalent to searching all ten levels, but keeps this common + // sender-side decision small enough for the 256 KiB STM32WL targets. + if (sf >= 7 || bw <= 125.0f || (sf == 6 && bw <= 250.0f)) return 32; + if (sf == 6 || bw <= 250.0f) return 64; + return 128; +} + // Treat requested_level as the operator's minimum. Faster tuples shorten each // LoRa symbol, so progressively more aggressive levels are tried until the // sleep window is long enough to cover the radio/TCXO transition. The @@ -181,15 +185,17 @@ inline bool calcRxPowerSavingLevelAtOrAbove( return false; } - // Keep the configured/legacy preamble assumption if any level can satisfy - // it. A second pass may use the radio's real, longer transmit preamble when - // the configured assumption cannot cover the transition at a faster tuple. - const uint8_t transmitted_preamble = rxPowerSavingPreambleForSF(sf); - const bool can_try_transmitted = transmitted_preamble > preamble; + // Never derive a receive window from more symbols than are actually sent. + // Keep the configured/conservative assumption when possible, then use the + // tuple's real, longer wire preamble only when that is required. + const uint8_t transmitted_preamble = rxPowerSavingPreambleForParams(sf, bw); + const uint8_t configured_preamble = preamble < transmitted_preamble + ? preamble : transmitted_preamble; + const bool can_try_transmitted = transmitted_preamble > configured_preamble; const uint8_t pass_count = can_try_transmitted ? 2 : 1; for (uint8_t pass = 0; pass < pass_count; pass++) { const uint8_t candidate_preamble = pass == 0 - ? preamble : transmitted_preamble; + ? configured_preamble : transmitted_preamble; for (uint8_t level = requested_level; level <= 10; level++) { const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; const float amount = (float)(level - 1) / 9.0f; @@ -247,7 +253,7 @@ inline bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, ui } if (preamble == 0) { - preamble = rxPowerSavingPreambleForSF(sf); + preamble = rxPowerSavingPreambleForParams(sf, bw); } uint32_t rx_us, sleep_us; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index d3c891f6..9efe6157 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -38,8 +38,7 @@ void RadioLibWrapper::begin() { (void) mesh::initializeCC310Crypto(); #endif _radio->setPacketReceivedAction(setFlag); // this is also SentComplete interrupt - _preamble_sf = getSpreadingFactor(); - _radio->setPreambleLength(preambleLengthForSF(_preamble_sf)); // longer preamble for lower SF improves reliability + _radio->setPreambleLength(currentPreambleLength()); // longer preamble for lower SF improves reliability state = STATE_IDLE; if (_board->getStartupReason() == BD_STARTUP_RX_PACKET) { // received a LoRa packet (while in deep sleep) @@ -114,8 +113,7 @@ bool RadioLibWrapper::restoreAfterDeepInit() { if (_params_valid) { restored = applyParams(_cur_freq, _cur_bw, _cur_sf, _cur_cr); } else { - _preamble_sf = getSpreadingFactor(); - restored = _radio->setPreambleLength(preambleLengthForSF(_preamble_sf)) + restored = _radio->setPreambleLength(currentPreambleLength()) == RADIOLIB_ERR_NONE; } if (_dbm_valid) { diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 87c59554..408bac16 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -38,7 +38,6 @@ protected: int32_t _floor_sample_sum; unsigned long last_recv_millis; unsigned long last_radio_interrupt_millis; // updated on any ISR event, even CRC errors - uint8_t _preamble_sf; bool _rx_ps_enabled; bool _rx_ps_armed; // radio is currently in RX duty-cycle mode bool _rx_ps_continuous_fallback; // requested RXPS is receiving continuously for this tuple @@ -120,7 +119,7 @@ protected: public: RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _noise_floor_valid(false), _nf_refresh_requested(true), - _preamble_sf(0), _rx_ps_enabled(false), _rx_ps_armed(false), + _rx_ps_enabled(false), _rx_ps_armed(false), _rx_ps_continuous_fallback(false), _rx_hold_continuous(false), _rx_ps_rx_us(RX_PS_FALLBACK_RX_US), _rx_ps_sleep_us(RX_PS_FALLBACK_SLEEP_US), _wd_last_busy(false), _wd_stage(0), _wd_strikes(0), _startrx_fails(0), _wd_last_transition(0), @@ -165,20 +164,21 @@ public: const uint32_t* rx_ps_timings = NULL) override; bool setParams(float freq, float bw, uint8_t sf, uint8_t cr, const uint32_t* rx_ps_timings = NULL); - uint16_t getDefaultPreambleLength() const override { return preambleLengthForSF(_preamble_sf); } - bool setPreambleLength(uint16_t len) override { return _radio->setPreambleLength(len) == RADIOLIB_ERR_NONE; } uint32_t getRngSeed(); void setTxPower(int8_t dbm); virtual float getCurrentRSSI() =0; virtual uint8_t getSpreadingFactor() const { return LORA_SF; } - static uint16_t preambleLengthForSF(uint8_t sf) { - return rxPowerSavingPreambleForSF(sf); + static uint16_t preambleLengthForParams(uint8_t sf, float bw) { + return rxPowerSavingPreambleForParams(sf, bw); } - bool updatePreamble(uint8_t sf) { - if (_radio->setPreambleLength(preambleLengthForSF(sf)) != RADIOLIB_ERR_NONE) return false; - _preamble_sf = sf; - return true; + uint16_t currentPreambleLength() const { + return _params_valid + ? preambleLengthForParams(_cur_sf, _cur_bw) + : preambleLengthForParams(getSpreadingFactor(), static_cast(LORA_BW)); + } + 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); virtual int16_t performChannelScan(); diff --git a/test/README.md b/test/README.md index dbd8b0a6..ab78943c 100644 --- a/test/README.md +++ b/test/README.md @@ -63,7 +63,7 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_packet_manager` | `src/Packet.cpp`, `src/Dispatcher.cpp`, `src/helpers/StaticPoolPacketManager.cpp` | truncated-packet rejection, unavailable-radio behavior, scoped RX-delay replacement, queue/CAD scheduling, and staged radio/TX recovery | | `test_persistent_store_format` | `src/helpers/PersistentStoreFormat.h` | contact-page headers and CRCs, dirty-page state, stable slot allocation, and bounded resumable legacy migration across power loss | | `test_power_management` | `src/helpers/PowerManagementUtils.h` | median filtering of a brownout outlier and valid-reading requirements for the boot lock | -| `test_rx_power_saving` | `src/helpers/radiolib/RXPowerSaving.h` | level-derived timing, automatic level/preamble selection, equivalent SF7/BW500, SF6/BW250, and SF5/BW125 profiles, SF5/BW62.5 with preamble 16, normal-preamble SF5/BW250/BW500 continuous-RX fallback, opt-in 64-symbol SF5/BW250 level-8 timing, automatic duty-cycle resume, and SX1262 TCXO timing thresholds | +| `test_rx_power_saving` | `src/helpers/radiolib/RXPowerSaving.h` | level-derived timing, tuple-selected 32/64/128-symbol wire preambles, equivalent SF7/BW500, SF6/BW250, and SF5/BW125 profiles, SF5/BW250 and SF6/BW500 level-8/64 timing, SF5/BW500 level-8/128 timing, SF5/BW62.5 with a 16-symbol timing assumption, automatic retuning, and SX1262 TCXO timing thresholds | | `test_region_names` | `src/helpers/RegionNameUtils.h` | canonical public-region markers while preserving distinct private and differently named regions | | `test_routing_policy` | `src/helpers/RoutingPolicy.h` | scoped/unscoped flood hop limits and selection of direct, path-return, mirrored-scope, default-scope, or unscoped replies | | `test_rs232_uart` | `src/helpers/bridges/RS232UartUtils.h` | stopping the active UART peripheral before reassigning its pins | diff --git a/test/test_rx_power_saving/test_rx_power_saving.cpp b/test/test_rx_power_saving/test_rx_power_saving.cpp index 1db24d6c..f0a6a42c 100644 --- a/test/test_rx_power_saving/test_rx_power_saving.cpp +++ b/test/test_rx_power_saving/test_rx_power_saving.cpp @@ -12,29 +12,41 @@ TEST(RxPowerSaving, BalancedNormalProfileKeepsDerivedDutyCycle) { EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); } -TEST(RxPowerSaving, Sf5Bw500FallsBackToContinuousReceive) { +TEST(RxPowerSaving, Sf5Bw500UsesPreamble128) { uint32_t rx_us = 0; uint32_t sleep_us = 0; + uint8_t effective_level = 0; + uint8_t effective_preamble = 0; ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 5, 500.0f, 16, - &rx_us, &sleep_us)); - EXPECT_EQ(RX_POWERSAVING_CONTINUOUS_FALLBACK_US, rx_us); - EXPECT_EQ(RX_POWERSAVING_CONTINUOUS_FALLBACK_US, sleep_us); - EXPECT_TRUE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); + &rx_us, &sleep_us, + &effective_level, + &effective_preamble)); + EXPECT_EQ(5U, effective_level); + EXPECT_EQ(128U, effective_preamble); + EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); } -TEST(RxPowerSaving, ReturningToSlowerProfileResumesDutyCycle) { +TEST(RxPowerSaving, ReturningToSlowerProfileRestoresSmallerPreambleTiming) { uint32_t rx_us = 0; uint32_t sleep_us = 0; + uint8_t effective_level = 0; + uint8_t effective_preamble = 0; ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 5, 500.0f, 16, - &rx_us, &sleep_us)); - ASSERT_TRUE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); + &rx_us, &sleep_us, + &effective_level, + &effective_preamble)); + ASSERT_EQ(128U, effective_preamble); ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 9, 500.0f, 16, - &rx_us, &sleep_us)); + &rx_us, &sleep_us, + &effective_level, + &effective_preamble)); EXPECT_EQ(10468U, rx_us); EXPECT_EQ(6712U, sleep_us); + EXPECT_EQ(5U, effective_level); + EXPECT_EQ(16U, effective_preamble); EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); } @@ -54,6 +66,19 @@ TEST(RxPowerSaving, TcxoThresholdSeparatesFastestUsableProfiles) { rx_us, sleep_us, sx1262_tcxo_transition_us)); } +TEST(RxPowerSaving, WirePreambleUses64OnlyWhen32CannotEnableRxps) { + EXPECT_EQ(32U, rxPowerSavingPreambleForParams(7, 500.0f)); + EXPECT_EQ(32U, rxPowerSavingPreambleForParams(6, 250.0f)); + EXPECT_EQ(32U, rxPowerSavingPreambleForParams(5, 125.0f)); + EXPECT_EQ(32U, rxPowerSavingPreambleForParams(5, 62.5f)); + + EXPECT_EQ(64U, rxPowerSavingPreambleForParams(5, 250.0f)); + EXPECT_EQ(64U, rxPowerSavingPreambleForParams(6, 500.0f)); + + EXPECT_EQ(128U, rxPowerSavingPreambleForParams(5, 500.0f)); + EXPECT_EQ(16U, rxPowerSavingPreambleForParams(9, 500.0f)); +} + TEST(RxPowerSaving, RequestedLevelIsRaisedOnlyToRequiredMinimum) { constexpr uint32_t sx1262_tcxo_transition_us = 6000; uint32_t rx_us = 0; @@ -64,8 +89,8 @@ TEST(RxPowerSaving, RequestedLevelIsRaisedOnlyToRequiredMinimum) { ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( 1, 7, 500.0f, 16, sx1262_tcxo_transition_us, &rx_us, &sleep_us, &effective_level, &effective_preamble)); - EXPECT_EQ(LORA_FAST_PREAMBLE == 64 ? 3U : 7U, effective_level); - EXPECT_EQ((uint8_t)LORA_FAST_PREAMBLE, effective_preamble); + EXPECT_EQ(7U, effective_level); + EXPECT_EQ(32U, effective_preamble); EXPECT_TRUE(canStartRxPowerSavingDutyCycle( rx_us, sleep_us, sx1262_tcxo_transition_us)); @@ -73,12 +98,12 @@ TEST(RxPowerSaving, RequestedLevelIsRaisedOnlyToRequiredMinimum) { 8, 7, 500.0f, 16, sx1262_tcxo_transition_us, &rx_us, &sleep_us, &effective_level, &effective_preamble)); EXPECT_EQ(8U, effective_level); - EXPECT_EQ((uint8_t)LORA_FAST_PREAMBLE, effective_preamble); + EXPECT_EQ(32U, effective_preamble); EXPECT_TRUE(canStartRxPowerSavingDutyCycle( rx_us, sleep_us, sx1262_tcxo_transition_us)); } -TEST(RxPowerSaving, Sf5Bw500StillFallsBackWithPreamble32) { +TEST(RxPowerSaving, Sf5Bw500UsesPreamble128AtEffectiveLevel8) { constexpr uint32_t sx1262_tcxo_transition_us = 6000; uint32_t rx_us = 0; uint32_t sleep_us = 0; @@ -88,9 +113,13 @@ TEST(RxPowerSaving, Sf5Bw500StillFallsBackWithPreamble32) { ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( 1, 5, 500.0f, 16, sx1262_tcxo_transition_us, &rx_us, &sleep_us, &effective_level, &effective_preamble)); - EXPECT_EQ(0U, effective_level); - EXPECT_EQ(0U, effective_preamble); - EXPECT_TRUE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); + EXPECT_EQ(8U, effective_level); + EXPECT_EQ(128U, effective_preamble); + EXPECT_EQ(626U, rx_us); + EXPECT_EQ(6398U, sleep_us); + EXPECT_TRUE(canStartRxPowerSavingDutyCycle( + rx_us, sleep_us, sx1262_tcxo_transition_us)); + EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); } TEST(RxPowerSaving, Sf5Bw250NeedsLongerTransmittedPreambleOnTcxoRadio) { @@ -110,19 +139,13 @@ TEST(RxPowerSaving, Sf5Bw250NeedsLongerTransmittedPreambleOnTcxoRadio) { rx_us, sleep_us, sx1262_tcxo_transition_us)); ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( - 1, 5, 250.0f, 16, sx1262_tcxo_transition_us, + 8, 5, 250.0f, 32, sx1262_tcxo_transition_us, &rx_us, &sleep_us, &effective_level, &effective_preamble)); -#if LORA_FAST_PREAMBLE == 64 EXPECT_EQ(8U, effective_level); EXPECT_EQ(64U, effective_preamble); EXPECT_EQ(1252U, rx_us); EXPECT_EQ(6424U, sleep_us); EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); -#else - EXPECT_EQ(0U, effective_level); - EXPECT_EQ(0U, effective_preamble); - EXPECT_TRUE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); -#endif } TEST(RxPowerSaving, Sf5Bw250UsesPreamble64AtEffectiveLevel8) { @@ -143,8 +166,7 @@ TEST(RxPowerSaving, Sf5Bw250UsesPreamble64AtEffectiveLevel8) { rx_us, sleep_us, sx1262_tcxo_transition_us)); } -#if LORA_FAST_PREAMBLE == 64 -TEST(RxPowerSaving, FastPreamble64BuildSelectsItAutomatically) { +TEST(RxPowerSaving, Sf5Bw250SelectorUsesPreamble64Automatically) { constexpr uint32_t sx1262_tcxo_transition_us = 6000; uint32_t rx_us = 0; uint32_t sleep_us = 0; @@ -159,9 +181,8 @@ TEST(RxPowerSaving, FastPreamble64BuildSelectsItAutomatically) { EXPECT_EQ(1252U, rx_us); EXPECT_EQ(6424U, sleep_us); } -#endif -TEST(RxPowerSaving, Sf5Bw125UsesPreamble32AtEffectiveLevel7) { +TEST(RxPowerSaving, Sf5Bw125KeepsPreamble32) { constexpr uint32_t sx1262_tcxo_transition_us = 6000; uint32_t rx_us = 0; uint32_t sleep_us = 0; @@ -171,20 +192,43 @@ TEST(RxPowerSaving, Sf5Bw125UsesPreamble32AtEffectiveLevel7) { ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( 1, 5, 125.0f, 16, sx1262_tcxo_transition_us, &rx_us, &sleep_us, &effective_level, &effective_preamble)); - EXPECT_EQ(LORA_FAST_PREAMBLE == 64 ? 3U : 7U, effective_level); - EXPECT_EQ((uint8_t)LORA_FAST_PREAMBLE, effective_preamble); -#if LORA_FAST_PREAMBLE == 64 - EXPECT_EQ(3641U, rx_us); - EXPECT_EQ(6414U, sleep_us); -#else + EXPECT_EQ(7U, effective_level); + EXPECT_EQ(32U, effective_preamble); EXPECT_EQ(2731U, rx_us); EXPECT_EQ(6101U, sleep_us); -#endif EXPECT_TRUE(canStartRxPowerSavingDutyCycle( rx_us, sleep_us, sx1262_tcxo_transition_us)); } -TEST(RxPowerSaving, Sf6Bw250UsesPreamble32AtEffectiveLevel7) { +TEST(RxPowerSaving, EquivalentFastProfilesSupportExplicitPreamble32Level7) { + constexpr uint32_t sx1262_tcxo_transition_us = 6000; + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + uint8_t effective_level = 0; + uint8_t effective_preamble = 0; + + ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( + 7, 7, 500.0f, 32, sx1262_tcxo_transition_us, + &rx_us, &sleep_us, &effective_level, &effective_preamble)); + EXPECT_EQ(7U, effective_level); + EXPECT_EQ(32U, effective_preamble); + EXPECT_EQ(2731U, rx_us); + EXPECT_EQ(6101U, sleep_us); + + ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( + 7, 6, 250.0f, 32, sx1262_tcxo_transition_us, + &rx_us, &sleep_us, &effective_level, &effective_preamble)); + EXPECT_EQ(7U, effective_level); + EXPECT_EQ(32U, effective_preamble); + + ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( + 7, 5, 125.0f, 32, sx1262_tcxo_transition_us, + &rx_us, &sleep_us, &effective_level, &effective_preamble)); + EXPECT_EQ(7U, effective_level); + EXPECT_EQ(32U, effective_preamble); +} + +TEST(RxPowerSaving, Sf6Bw250KeepsPreamble32) { constexpr uint32_t sx1262_tcxo_transition_us = 6000; uint32_t rx_us = 0; uint32_t sleep_us = 0; @@ -194,15 +238,42 @@ TEST(RxPowerSaving, Sf6Bw250UsesPreamble32AtEffectiveLevel7) { ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( 1, 6, 250.0f, 16, sx1262_tcxo_transition_us, &rx_us, &sleep_us, &effective_level, &effective_preamble)); - EXPECT_EQ(LORA_FAST_PREAMBLE == 64 ? 3U : 7U, effective_level); - EXPECT_EQ((uint8_t)LORA_FAST_PREAMBLE, effective_preamble); -#if LORA_FAST_PREAMBLE == 64 - EXPECT_EQ(3641U, rx_us); - EXPECT_EQ(6414U, sleep_us); -#else + EXPECT_EQ(7U, effective_level); + EXPECT_EQ(32U, effective_preamble); + EXPECT_EQ(2731U, rx_us); + EXPECT_EQ(6101U, sleep_us); +} + +TEST(RxPowerSaving, Sf6Bw500UsesPreamble64WhenRequired) { + constexpr uint32_t sx1262_tcxo_transition_us = 6000; + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + uint8_t effective_level = 0; + uint8_t effective_preamble = 0; + + ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( + 1, 6, 500.0f, 16, sx1262_tcxo_transition_us, + &rx_us, &sleep_us, &effective_level, &effective_preamble)); + EXPECT_EQ(8U, effective_level); + EXPECT_EQ(64U, effective_preamble); + EXPECT_EQ(1252U, rx_us); + EXPECT_EQ(6424U, sleep_us); +} + +TEST(RxPowerSaving, TimingAssumptionCannotExceedWirePreamble) { + constexpr uint32_t sx1262_tcxo_transition_us = 6000; + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + uint8_t effective_level = 0; + uint8_t effective_preamble = 0; + + ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( + 1, 7, 500.0f, 64, sx1262_tcxo_transition_us, + &rx_us, &sleep_us, &effective_level, &effective_preamble)); + EXPECT_EQ(7U, effective_level); + EXPECT_EQ(32U, effective_preamble); EXPECT_EQ(2731U, rx_us); EXPECT_EQ(6101U, sleep_us); -#endif } TEST(RxPowerSaving, Sf5Bw62Point5UsesPreamble16AtEffectiveLevel10) { diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index 960c70f2..3ef086d9 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -77,7 +77,17 @@ INSTALL_RECONCILE_WAIT_SECONDS = 20 DEFAULT_RELAY_TX_DELAY = 0.3 RELAY_TIMING_COMMANDS_PER_RELAY = 12 RELAY_TIMING_RECOVERY_FILE = "relay-timing-settings.json" +TARGET_RXPS_RECOVERY_FILE = "target-rxps-settings.json" MIN_MESHCLI_VERSION = (1, 6, 0) +# v1.17.1.5 is the first release-version contract in which every packet, +# including retries, uses the same tuple-selected physical preamble: normally +# 32 symbols at SF5-SF8, then 64 or 128 only where each shorter choice cannot +# make RXPS viable. Radio changes also safely retune a saved level-based RXPS +# preference. Older or unparseable versions are deliberately treated as legacy. +RXPS_ADAPTIVE_PREAMBLE_MIN_VERSION = 0x01110105 +RXPS_MIN_PERIOD_US = 32 +RXPS_MAX_PERIOD_US = 30_000_000 +RXPS_WIRE_TRANSITION_US = 6_000 # This module is also imported by the pinned RAK3401 chain runner and by the # offline test suite. Keep diagnostics disabled until either CLI main enables @@ -201,6 +211,21 @@ class RelayTimingSettings: txdelay: float +@dataclass(frozen=True) +class RxpsSettings: + enabled: bool + rx_us: int + sleep_us: int + level: int | None = None + preamble: int | None = None + + +@dataclass(frozen=True) +class RxpsTempProfile: + boundary_level: int + boundary_preamble: int + + def prompt_after_transmission_failure( label: str, error: TransmissionError, @@ -318,15 +343,80 @@ def adaptive_poll_ceiling(baseline_seconds: float) -> float: return max(baseline_seconds, baseline_seconds * ADAPTIVE_POLL_MAX_FACTOR) +def rxps_level_timings_us( + level: int, + bandwidth_khz: float, + spreading_factor: int, + preamble_symbols: int, +) -> tuple[int, int]: + """Mirror MeshCore's level timing calculation for wire-preamble selection.""" + symbol_us = (1000.0 * (1 << spreading_factor)) / bandwidth_khz + amount = (level - 1) / 9.0 + rx_start_symbols = 12.0 if preamble_symbols == 16 else 16.0 + sleep_start_symbols = 2.0 if preamble_symbols == 16 else 15.0 + rx_symbols = rx_start_symbols + amount * (8.0 - rx_start_symbols) + sleep_edge_symbols = preamble_symbols + 4.25 - 8.0 + sleep_symbols = sleep_start_symbols + amount * ( + sleep_edge_symbols - sleep_start_symbols + ) + rx_us = math.ceil(rx_symbols * symbol_us) + sleep_us = int(sleep_symbols * symbol_us) + if rx_us < RXPS_MIN_PERIOD_US or sleep_us < RXPS_MIN_PERIOD_US: + return RXPS_MIN_PERIOD_US, RXPS_MIN_PERIOD_US + return rx_us, sleep_us + + +def preamble_supports_rxps( + bandwidth_khz: float, + spreading_factor: int, + preamble_symbols: int, +) -> bool: + for level in range(1, 11): + rx_us, sleep_us = rxps_level_timings_us( + level, bandwidth_khz, spreading_factor, preamble_symbols + ) + if ( + RXPS_MIN_PERIOD_US <= rx_us <= RXPS_MAX_PERIOD_US + and RXPS_MIN_PERIOD_US <= sleep_us <= RXPS_MAX_PERIOD_US + and sleep_us > RXPS_WIRE_TRANSITION_US + and (rx_us * 8) // 125 != 0 + and ((sleep_us - RXPS_WIRE_TRANSITION_US) * 8) // 125 != 0 + ): + return True + return False + + +def wire_preamble_symbols( + bandwidth_khz: float, + spreading_factor: int, +) -> int: + """Return the deterministic MeshCore wire preamble for an SF/BW tuple.""" + if spreading_factor > 8: + return 16 + if spreading_factor < 5 or bandwidth_khz <= 0.0: + return 32 + if preamble_supports_rxps(bandwidth_khz, spreading_factor, 32): + return 32 + if preamble_supports_rxps(bandwidth_khz, spreading_factor, 64): + return 64 + if preamble_supports_rxps(bandwidth_khz, spreading_factor, 128): + return 128 + return 32 + + def lora_airtime_seconds( payload_bytes: int, bandwidth_khz: float, spreading_factor: int, coding_rate: int, *, - preamble_symbols: int = 16, + preamble_symbols: int | None = None, ) -> float: """Estimate explicit-header LoRa airtime for one Mesh packet.""" + if preamble_symbols is None: + preamble_symbols = wire_preamble_symbols( + bandwidth_khz, spreading_factor + ) symbol_seconds = (2 ** spreading_factor) / (bandwidth_khz * 1000.0) low_data_rate = 1 if symbol_seconds >= 0.016 else 0 numerator = ( @@ -438,6 +528,61 @@ def parse_version(value: str) -> int | None: return sum(part << shift for part, shift in zip(parts, (24, 16, 8, 0))) +def select_rxps_temp_profile( + current_version: str | None, + temp_values: tuple[float, float, int, int, int], + *, + all_participants_support_adaptive_preamble: bool, +) -> RxpsTempProfile | None: + """Return a qualified RXPS profile, or None when automation must use off. + + The version boundary is intentional. Several historical builds reused the + same human version while carrying different RXPS implementations, so only + the first forward release contract and later are allowed to keep RXPS on. + """ + version = parse_version(current_version) if current_version else None + if version is None or version < RXPS_ADAPTIVE_PREAMBLE_MIN_VERSION: + return None + + _frequency, bandwidth, sf, _cr, _minutes = temp_values + + def is_bandwidth(expected: float) -> bool: + return abs(bandwidth - expected) <= 0.001 + + # A tuple-selected long preamble relies on every possible sender following + # the same adaptive wire contract. A single legacy source, controller, or + # relay makes it unsafe even when the destination itself is current. + if ( + (sf == 5 and is_bandwidth(250.0)) + or (sf == 6 and is_bandwidth(500.0)) + ): + if not all_participants_support_adaptive_preamble: + return None + return RxpsTempProfile(8, 64) + + if sf == 5 and is_bandwidth(500.0): + if not all_participants_support_adaptive_preamble: + return None + return RxpsTempProfile(8, 128) + + # These three tuples all have a 256 us symbol and were qualified with the + # same 32-symbol receive-window assumption. Since 32 is sufficient, current + # firmware keeps the normal 32-symbol wire preamble. + if ( + (sf == 7 and is_bandwidth(500.0)) + or (sf == 6 and is_bandwidth(250.0)) + or (sf == 5 and is_bandwidth(125.0)) + ): + return RxpsTempProfile(7, 32) + + if sf == 5 and is_bandwidth(62.5): + return RxpsTempProfile(10, 16) + + # Unknown tuples remain continuous-RX rather than extrapolating beyond + # qualified timing data. + return None + + def parse_hex_exact(value: str, size: int, label: str) -> bytes: value = value.strip().removeprefix("0x").removeprefix("0X") if not re.fullmatch(rf"[0-9A-Fa-f]{{{size * 2}}}", value): @@ -1556,6 +1701,17 @@ class Controller: return public_key.lower() raise OtaError("meshcli did not return the controller public key") + def get_firmware_version(self) -> tuple[str, int]: + objects = self._run(["ver"], "read controller firmware version") + for value in reversed(objects): + version_text = value.get("ver") + if not isinstance(version_text, str): + continue + version = extract_reply_version(version_text) + if version is not None: + return version_text, version + raise OtaError("meshcli did not return the controller firmware version") + def set_radio(self, settings: RadioSettings, label: str) -> None: command_error: OtaError | None = None try: @@ -1973,6 +2129,82 @@ def preflight_source_cli(args: argparse.Namespace) -> None: ) +def read_lora_ota_participant_versions( + controller: Controller, + args: argparse.Namespace, + target: TargetInfo, +) -> dict[str, int | None]: + """Read every version that can transmit during the maintenance window.""" + versions: dict[str, int | None] = { + "destination": ( + parse_version(target.current_version) + if target.current_version else None + ) + } + + try: + controller_text, controller_version = controller.get_firmware_version() + versions["controller"] = controller_version + print(f"[rxps] controller version {controller_text}") + except TransmissionStopped: + raise + except OtaError as exc: + versions["controller"] = None + print(f"[rxps] controller version unavailable: {exc}") + + if getattr(args, "source_shares_controller", False): + versions["source"] = versions["controller"] + print("[rxps] source version is the shared controller version") + elif getattr(args, "source_already_temp", False) and not ( + getattr(args, "source_cli_serial", None) + or getattr(args, "source_cli_tcp", None) + ): + versions["source"] = None + print("[rxps] source version unavailable (--source-already-temp)") + else: + try: + source_reply = source_cli_command(args, "ver") + source_version = extract_reply_version(source_reply) + versions["source"] = source_version + if source_version is None: + print("[rxps] source returned no parseable version") + except TransmissionStopped: + raise + except OtaError as exc: + versions["source"] = None + print(f"[rxps] source version unavailable: {exc}") + + for relay_name, relay_password in getattr(args, "relay_values", []): + label = f"relay:{relay_name}" + try: + reply = controller.remote_command( + relay_name, "ver", password=relay_password + ) + versions[label] = extract_reply_version(reply) + if versions[label] is None: + print(f"[rxps] {relay_name} returned no parseable version") + except TransmissionStopped: + raise + except OtaError as exc: + versions[label] = None + print(f"[rxps] {relay_name} version unavailable: {exc}") + + for label, version in versions.items(): + rendered = format_version(version) if version is not None else "unknown" + print(f"[rxps] participant {label}={rendered}") + return versions + + +def participants_support_adaptive_preamble( + versions: dict[str, int | None], +) -> bool: + return bool(versions) and all( + version is not None + and version >= RXPS_ADAPTIVE_PREAMBLE_MIN_VERSION + for version in versions.values() + ) + + def verify_shared_source_identity( controller: Controller, args: argparse.Namespace, @@ -2123,6 +2355,250 @@ def parse_relay(value: str, default_password: str) -> tuple[str, str]: return value, default_password +def parse_rxps_settings(reply: str, label: str) -> RxpsSettings: + detailed = re.fullmatch( + r"\s*>?\s*(?:radio\.rxps(?:\.config)?\s+)?(on|off),level=(\d+)," + r"preamble=(\d+),rx=(\d+),sleep=(\d+)\s*", + reply, + re.IGNORECASE, + ) + if detailed is not None: + level = int(detailed.group(2)) + preamble = int(detailed.group(3)) + rx_us = int(detailed.group(4)) + sleep_us = int(detailed.group(5)) + if ( + level > 10 + or preamble not in (0, 16, 32) + or not RXPS_MIN_PERIOD_US <= rx_us <= RXPS_MAX_PERIOD_US + or not RXPS_MIN_PERIOD_US <= sleep_us <= RXPS_MAX_PERIOD_US + ): + raise OtaError(f"could not read {label} RXPS config: {reply}") + return RxpsSettings( + enabled=detailed.group(1).lower() == "on", + rx_us=rx_us, + sleep_us=sleep_us, + level=level, + preamble=preamble, + ) + + match = re.fullmatch( + r"\s*>?\s*(?:radio\.rxps\s+)?(on|off),(\d+),(\d+)\s*", + reply, + re.IGNORECASE, + ) + if match is None: + raise OtaError(f"could not read {label} RXPS state: {reply}") + rx_us = int(match.group(2)) + sleep_us = int(match.group(3)) + if ( + not RXPS_MIN_PERIOD_US <= rx_us <= RXPS_MAX_PERIOD_US + or not RXPS_MIN_PERIOD_US <= sleep_us <= RXPS_MAX_PERIOD_US + ): + raise OtaError(f"could not read {label} RXPS state: {reply}") + return RxpsSettings( + enabled=match.group(1).lower() == "on", + rx_us=rx_us, + sleep_us=sleep_us, + ) + + +def read_remote_rxps( + controller: Controller, + target_name: str, + *, + password: str | None = None, +) -> RxpsSettings: + try: + reply = controller.remote_command( + target_name, "get radio.rxps.config", password=password + ) + return parse_rxps_settings(reply, target_name) + except TransmissionStopped: + raise + except OtaError as detailed_error: + # Old firmware does not expose the saved level metadata. Query the + # legacy form explicitly so its fixed periods can still be preserved. + try: + reply = controller.remote_command( + target_name, "get radio.rxps", password=password + ) + return parse_rxps_settings(reply, target_name) + except TransmissionStopped: + raise + except OtaError as legacy_error: + raise OtaError( + f"could not read {target_name} RXPS state using current or " + f"legacy CLI: {legacy_error}" + ) from detailed_error + + +def write_target_rxps_recovery( + work_dir: Path, + target_name: str, + saved: RxpsSettings, + profile: RxpsTempProfile | None, + participant_versions: dict[str, int | None], +) -> Path: + path = work_dir / TARGET_RXPS_RECOVERY_FILE + payload = { + "target": target_name, + "rxps_enabled": saved.enabled, + "rxps_rx_us": saved.rx_us, + "rxps_sleep_us": saved.sleep_us, + "rxps_level": saved.level, + "rxps_preamble": saved.preamble, + "temporary_policy": ( + "preserve saved level-based preference" + if profile else "set radio.rxps off" + ), + "participant_versions": { + label: format_version(version) if version is not None else None + for label, version in participant_versions.items() + }, + } + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="ascii", + ) + temporary.chmod(0o600) + os.replace(temporary, path) + return path + + +def apply_remote_rxps_policy( + controller: Controller, + target_name: str, + saved: RxpsSettings, + profile: RxpsTempProfile | None, + *, + password: str | None = None, +) -> bool: + if not saved.enabled: + print(f"[rxps] {target_name} was already off; leaving it off") + return False + + if profile is not None and saved.level is not None and 1 <= saved.level <= 10: + current = read_remote_rxps( + controller, target_name, password=password + ) + preference_matches = ( + current.enabled + and current.level == saved.level + and current.preamble == saved.preamble + ) + if not preference_matches: + if saved.preamble in (16, 32): + command = ( + f"set radio.rxps level {saved.level} " + f"preamble {saved.preamble}" + ) + else: + command = f"set radio.rxps level {saved.level}" + reply = controller.remote_command( + target_name, command, password=password + ) + if re.search(r"\bon\b", reply, re.IGNORECASE) is None: + raise OtaError( + f"{target_name} did not restore its saved RXPS " + f"preference: {reply}" + ) + current = read_remote_rxps( + controller, target_name, password=password + ) + if ( + not current.enabled + or current.level != saved.level + or current.preamble != saved.preamble + ): + raise OtaError( + f"{target_name} saved RXPS preference did not read back" + ) + print( + f"[rxps] {target_name} saved preference preserved; qualified " + f"boundary level {profile.boundary_level}, preamble " + f"{profile.boundary_preamble}" + ) + return False + + command = "set radio.rxps off" + reply = controller.remote_command( + target_name, command, password=password + ) + expected_enabled = False + expected_word = "on" if expected_enabled else "off" + if re.search(rf"\b{expected_word}\b", reply, re.IGNORECASE) is None: + raise OtaError( + f"{target_name} did not apply temporary RXPS policy: {reply}" + ) + verified = read_remote_rxps( + controller, target_name, password=password + ) + if verified.enabled != expected_enabled: + raise OtaError( + f"{target_name} RXPS policy did not read back as {expected_word}" + ) + print(f"[rxps] {target_name} temporarily off for this version/tuple") + return True + + +def restore_remote_rxps( + controller: Controller, + target_name: str, + saved: RxpsSettings, + *, + password: str | None = None, +) -> None: + current = read_remote_rxps( + controller, target_name, password=password + ) + if saved.enabled: + if current != saved: + if saved.level is not None and 1 <= saved.level <= 10: + if saved.preamble in (16, 32): + command = ( + f"set radio.rxps level {saved.level} " + f"preamble {saved.preamble}" + ) + else: + command = f"set radio.rxps level {saved.level}" + else: + command = f"set radio.rxps {saved.rx_us} {saved.sleep_us}" + reply = controller.remote_command( + target_name, + command, + password=password, + ) + if re.search(r"\bon\b", reply, re.IGNORECASE) is None: + raise OtaError(f"{target_name} did not restore RXPS: {reply}") + elif current.enabled: + reply = controller.remote_command( + target_name, "set radio.rxps off", password=password + ) + if re.search(r"\boff\b", reply, re.IGNORECASE) is None: + raise OtaError(f"{target_name} did not restore RXPS-off: {reply}") + + verified = read_remote_rxps( + controller, target_name, password=password + ) + metadata_matches = ( + saved.level is None + or ( + verified.level == saved.level + and verified.preamble == saved.preamble + ) + ) + if ( + verified.enabled != saved.enabled + or verified.rx_us != saved.rx_us + or verified.sleep_us != saved.sleep_us + or not metadata_matches + ): + raise OtaError(f"{target_name} RXPS settings did not restore exactly") + print(f"[rxps] {target_name} original RXPS settings restored") + + def parse_delay_reply(reply: str, label: str) -> float: match = re.fullmatch(r"\s*>\s*([0-9]+(?:\.[0-9]+)?)\s*", reply) if match is None: @@ -2266,6 +2742,22 @@ def confirm_update( print(f" update : {package.version} {package.kind} hw={package.hw_id or '?'}") print(f" mOTA id : {package.manifest_id}") print(f" TempRadio : {args.temp_radio}") + saved_rxps = getattr(args, "target_rxps_saved", None) + rxps_profile = getattr(args, "target_rxps_profile", None) + if isinstance(saved_rxps, RxpsSettings) and saved_rxps.enabled: + if isinstance(rxps_profile, RxpsTempProfile): + print( + " RXPS : preserve saved level-based preference; " + f"qualified boundary level {rxps_profile.boundary_level}, " + f"preamble {rxps_profile.boundary_preamble}" + ) + else: + print( + " RXPS : temporarily off (version/preamble gate); " + "restore original settings" + ) + elif isinstance(saved_rxps, RxpsSettings): + print(" RXPS : already off; unchanged") if args.relay: print( f" relay timing: rxdelay 0, " @@ -3411,6 +3903,8 @@ def main( seeder_attempted = False source_temp_owned = False target_temp_owned = False + target_rxps_saved: RxpsSettings | None = None + target_rxps_changed = False armed_relay_values: list[tuple[str, str]] = [] relay_timing_settings: list[RelayTimingSettings] = [] password = args.password or os.environ.get("MESHCORE_ADMIN_PASSWORD", "") @@ -3464,6 +3958,38 @@ def main( return 0 assert controller is not None and original_radio is not None + participant_versions = read_lora_ota_participant_versions( + controller, args, target + ) + all_participants_support_adaptive_preamble = ( + participants_support_adaptive_preamble( + participant_versions + ) + ) + try: + target_rxps_saved = read_remote_rxps(controller, args.target) + except TransmissionStopped: + raise + except OtaError as exc: + raise OtaError( + "cannot safely preserve or disable destination RXPS because " + f"its current state is unavailable: {exc}" + ) from exc + target_rxps_profile = select_rxps_temp_profile( + target.current_version, + args.temp_values, + all_participants_support_adaptive_preamble=( + all_participants_support_adaptive_preamble + ), + ) + if ( + target_rxps_saved is None + or target_rxps_saved.level is None + or not 1 <= target_rxps_saved.level <= 10 + ): + target_rxps_profile = None + args.target_rxps_saved = target_rxps_saved + args.target_rxps_profile = target_rxps_profile confirm_update(args, target, package) freq, bandwidth, sf, cr, _minutes = args.temp_values temp_command = f"tempradio {args.temp_radio}" @@ -3472,6 +3998,26 @@ def main( freq, bandwidth, sf, cr, original_radio.repeat ) + if target_rxps_saved is not None and target_rxps_saved.enabled: + recovery_path = write_target_rxps_recovery( + work_dir, + args.target, + target_rxps_saved, + target_rxps_profile, + participant_versions, + ) + print(f"[rxps] recovery settings: {recovery_path}") + # Arm cleanup before the mutating command. If the command reaches + # the node but its confirmation is lost, restoration must still + # run from the saved recovery record. + target_rxps_changed = target_rxps_profile is None + target_rxps_changed = apply_remote_rxps_policy( + controller, + args.target, + target_rxps_saved, + target_rxps_profile, + ) + # Move far nodes first while the controller is still on the normal # channel. Relays are supplied in farthest-to-nearest order. A target # can process TempRadio even when its reply is lost, so resolve that @@ -3540,6 +4086,18 @@ def main( if source_temp_owned: shorten_source_temp_window(args) source_temp_owned = False + if target_rxps_changed and target_rxps_saved is not None: + if args.leave_controller_radio: + print( + "[rxps] destination remains off while TempRadio is " + "preserved; restore it from " + f"{TARGET_RXPS_RECOVERY_FILE} after normalradio" + ) + else: + restore_remote_rxps( + controller, args.target, target_rxps_saved + ) + target_rxps_changed = False return 0 install_confirmed = request_install(controller, args, package) @@ -3591,6 +4149,11 @@ def main( target.base_hash, identity_reply, ) + if target_rxps_changed and target_rxps_saved is not None: + restore_remote_rxps( + controller, args.target, target_rxps_saved + ) + target_rxps_changed = False finally: if args.leave_controller_radio: controller.set_radio( @@ -3671,6 +4234,28 @@ def main( f"lease: {exc}", file=sys.stderr, ) + if target_rxps_changed and target_rxps_saved is not None: + if args.leave_controller_radio: + print( + "WARNING: destination RXPS remains off while " + "TempRadio is preserved; restore it from " + f"{TARGET_RXPS_RECOVERY_FILE} after normalradio", + file=sys.stderr, + ) + target_rxps_changed = False + else: + try: + restore_remote_rxps( + controller, args.target, target_rxps_saved + ) + target_rxps_changed = False + except (OtaError, OSError) as exc: + print( + "WARNING: could not restore destination RXPS " + "on TempRadio; retrying after radio restore: " + f"{exc}", + file=sys.stderr, + ) if source_temp_owned and not args.leave_controller_radio: shorten_source_temp_window(args, check=False) if ( @@ -3681,6 +4266,7 @@ def main( ): try: controller.set_radio(original_radio, "restore controller radio after failure") + controller_changed = False print("[controller] original radio restored") except (OtaError, OSError) as exc: print( @@ -3688,6 +4274,23 @@ def main( f"Restore it manually to {original_radio.meshcli_value()}: {exc}", file=sys.stderr, ) + if ( + target_rxps_changed + and target_rxps_saved is not None + and controller is not None + and not controller_changed + ): + try: + restore_remote_rxps( + controller, args.target, target_rxps_saved + ) + target_rxps_changed = False + except (OtaError, OSError) as exc: + print( + "CRITICAL: could not restore destination RXPS; use " + f"{TARGET_RXPS_RECOVERY_FILE}: {exc}", + file=sys.stderr, + ) if work_dir is not None: print(f"[work] retained at {work_dir}") diff --git a/tools/lora_ota/rak3401_mota_chain.py b/tools/lora_ota/rak3401_mota_chain.py index 5f1d02ff..28cc133b 100755 --- a/tools/lora_ota/rak3401_mota_chain.py +++ b/tools/lora_ota/rak3401_mota_chain.py @@ -381,6 +381,8 @@ class TargetTransferSettings: rxps_enabled: bool rxps_rx_us: int rxps_sleep_us: int + rxps_level: int | None + rxps_preamble: int | None powersaving_enabled: bool rxdelay: str airtime_factor: str @@ -890,14 +892,7 @@ def read_target_transfer_settings( controller: ota.Controller, target_name: str, ) -> TargetTransferSettings: - rxps_reply = controller.remote_command(target_name, "get radio.rxps") - rxps_match = re.fullmatch( - r"\s*>\s*(on|off),(\d+),(\d+)\s*", - rxps_reply, - re.IGNORECASE, - ) - if rxps_match is None: - raise ota.OtaError(f"could not read destination RXPS state: {rxps_reply}") + rxps = ota.read_remote_rxps(controller, target_name) powersaving_reply = controller.remote_command(target_name, "powersaving") powersaving_match = re.fullmatch( @@ -934,9 +929,11 @@ def read_target_transfer_settings( ) return TargetTransferSettings( - rxps_enabled=rxps_match.group(1).lower() == "on", - rxps_rx_us=int(rxps_match.group(2)), - rxps_sleep_us=int(rxps_match.group(3)), + rxps_enabled=rxps.enabled, + rxps_rx_us=rxps.rx_us, + rxps_sleep_us=rxps.sleep_us, + rxps_level=rxps.level, + rxps_preamble=rxps.preamble, powersaving_enabled=powersaving_match.group(1).lower() == "on", rxdelay=rxdelay_match.group(1), airtime_factor=airtime_match.group(1), @@ -966,10 +963,24 @@ def load_or_capture_transfer_settings( saved["ota_hops"], int ): raise TypeError("saved OTA hop reach must be a JSON integer") + for name in ("rxps_level", "rxps_preamble"): + value = saved.get(name) + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) + ): + raise TypeError(f"saved {name} must be a JSON integer or null") settings = TargetTransferSettings( rxps_enabled=saved["rxps_enabled"], rxps_rx_us=int(saved["rxps_rx_us"]), rxps_sleep_us=int(saved["rxps_sleep_us"]), + rxps_level=( + int(saved["rxps_level"]) + if saved.get("rxps_level") is not None else None + ), + rxps_preamble=( + int(saved["rxps_preamble"]) + if saved.get("rxps_preamble") is not None else None + ), powersaving_enabled=saved["powersaving_enabled"], rxdelay=str(saved["rxdelay"]), airtime_factor=str(saved["airtime_factor"]), @@ -981,6 +992,25 @@ def load_or_capture_transfer_settings( raise ValueError("saved airtime factor is invalid") if not 0 <= settings.ota_hops <= 8: raise ValueError("saved OTA hop reach is invalid") + if ( + settings.rxps_level is not None + and not 0 <= settings.rxps_level <= 10 + ): + raise ValueError("saved RXPS level is invalid") + if settings.rxps_preamble not in (None, 0, 16, 32): + raise ValueError("saved RXPS preamble is invalid") + if not ( + ota.RXPS_MIN_PERIOD_US + <= settings.rxps_rx_us + <= ota.RXPS_MAX_PERIOD_US + ): + raise ValueError("saved RXPS receive period is invalid") + if not ( + ota.RXPS_MIN_PERIOD_US + <= settings.rxps_sleep_us + <= ota.RXPS_MAX_PERIOD_US + ): + raise ValueError("saved RXPS sleep period is invalid") except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: raise ota.OtaError(f"invalid saved transfer settings in {path}") from exc print(f"[guardrail] loaded original destination settings from {path}") @@ -992,6 +1022,8 @@ def load_or_capture_transfer_settings( "rxps_enabled": settings.rxps_enabled, "rxps_rx_us": settings.rxps_rx_us, "rxps_sleep_us": settings.rxps_sleep_us, + "rxps_level": settings.rxps_level, + "rxps_preamble": settings.rxps_preamble, "powersaving_enabled": settings.powersaving_enabled, "rxdelay": settings.rxdelay, "airtime_factor": settings.airtime_factor, @@ -1009,6 +1041,10 @@ def enforce_transfer_guardrails( controller: ota.Controller, target_name: str, *, + saved: TargetTransferSettings | None = None, + current_version: str | None = None, + temp_values: tuple[float, float, int, int, int] | None = None, + all_participants_support_adaptive_preamble: bool = False, legacy_full_airtime: bool = False, ) -> None: current = read_target_transfer_settings(controller, target_name) @@ -1016,10 +1052,39 @@ def enforce_transfer_guardrails( reply = controller.remote_command(target_name, "powersaving off") if re.search(r"\boff\b", reply, re.IGNORECASE) is None: raise ota.OtaError(f"target did not disable CPU power saving: {reply}") - if current.rxps_enabled: + rxps_requested = saved.rxps_enabled if saved is not None else current.rxps_enabled + rxps_profile = ( + ota.select_rxps_temp_profile( + current_version, + temp_values, + all_participants_support_adaptive_preamble=( + all_participants_support_adaptive_preamble + ), + ) + if rxps_requested and temp_values is not None + else None + ) + saved_level = saved.rxps_level if saved is not None else current.rxps_level + if saved_level is None or not 1 <= saved_level <= 10: + rxps_profile = None + expected_rxps = rxps_requested and rxps_profile is not None + if rxps_requested: + ota.apply_remote_rxps_policy( + controller, + target_name, + ota.RxpsSettings( + True, + saved.rxps_rx_us if saved is not None else current.rxps_rx_us, + saved.rxps_sleep_us if saved is not None else current.rxps_sleep_us, + saved.rxps_level if saved is not None else current.rxps_level, + saved.rxps_preamble if saved is not None else current.rxps_preamble, + ), + rxps_profile, + ) + elif current.rxps_enabled: reply = controller.remote_command(target_name, "set radio.rxps off") if re.search(r"\boff\b", reply, re.IGNORECASE) is None: - raise ota.OtaError(f"target did not disable RXPS: {reply}") + raise ota.OtaError(f"target did not restore RXPS-off policy: {reply}") if abs(float(current.rxdelay)) > 0.0001: reply = controller.remote_command(target_name, "set rxdelay 0") if not reply.upper().startswith("OK"): @@ -1032,7 +1097,7 @@ def enforce_transfer_guardrails( verified = read_target_transfer_settings(controller, target_name) if ( verified.powersaving_enabled - or verified.rxps_enabled + or verified.rxps_enabled != expected_rxps or abs(float(verified.rxdelay)) > 0.0001 or ( legacy_full_airtime @@ -1041,10 +1106,18 @@ def enforce_transfer_guardrails( ): raise ota.OtaError( "destination transfer guardrails did not read back as " - "powersaving=off, RXPS=off, rxdelay=0" + f"powersaving=off, RXPS={'on' if expected_rxps else 'off'}, rxdelay=0" + (", af=0" if legacy_full_airtime else "") ) - detail = "RXPS off, rxdelay 0, CPU power saving off" + if rxps_profile is None: + rxps_detail = "RXPS off (version/preamble gate)" + else: + rxps_detail = ( + f"RXPS on (qualified boundary level " + f"{rxps_profile.boundary_level}, preamble " + f"{rxps_profile.boundary_preamble})" + ) + detail = f"{rxps_detail}, rxdelay 0, CPU power saving off" if legacy_full_airtime: detail += ", legacy airtime factor 0" print(f"[guardrail] destination verified: {detail}") @@ -1061,22 +1134,18 @@ def restore_transfer_settings( if not reply.upper().startswith("OK"): raise ota.OtaError(f"target did not restore RX flood delay: {reply}") - if saved.rxps_enabled: - if ( - not current.rxps_enabled - or current.rxps_rx_us != saved.rxps_rx_us - or current.rxps_sleep_us != saved.rxps_sleep_us - ): - reply = controller.remote_command( - target_name, - f"set radio.rxps {saved.rxps_rx_us} {saved.rxps_sleep_us}", - ) - if re.search(r"\bon\b", reply, re.IGNORECASE) is None: - raise ota.OtaError(f"target did not restore RXPS: {reply}") - elif current.rxps_enabled: - reply = controller.remote_command(target_name, "set radio.rxps off") - if re.search(r"\boff\b", reply, re.IGNORECASE) is None: - raise ota.OtaError(f"target did not restore RXPS-off state: {reply}") + ota.restore_remote_rxps( + controller, + target_name, + ota.RxpsSettings( + saved.rxps_enabled, + saved.rxps_rx_us, + saved.rxps_sleep_us, + saved.rxps_level, + saved.rxps_preamble, + ), + ) + current = read_target_transfer_settings(controller, target_name) if abs(float(current.airtime_factor) - float(saved.airtime_factor)) > 0.0001: reply = controller.remote_command( @@ -1105,6 +1174,13 @@ def restore_transfer_settings( and ( verified.rxps_rx_us != saved.rxps_rx_us or verified.rxps_sleep_us != saved.rxps_sleep_us + or ( + saved.rxps_level is not None + and ( + verified.rxps_level != saved.rxps_level + or verified.rxps_preamble != saved.rxps_preamble + ) + ) ) ) ): @@ -1488,6 +1564,7 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> temp_values = ota.parse_temp_radio(args.temp_radio) except argparse.ArgumentTypeError as exc: parser.error(f"--temp-radio: {exc}") + args.temp_values = temp_values remote_setup_seconds = (1 + len(args.relay)) * args.reply_timeout required_seconds = ( remote_setup_seconds @@ -1543,8 +1620,8 @@ def confirm_chain( "then restore" ) print( - " guardrails : save, disable RXPS/rxdelay/CPU sleep, restore all " - "settings at endpoint" + " guardrails : save settings; version-gate RXPS, disable rxdelay/CPU " + "sleep, restore all settings at endpoint" ) if args.legacy_full_airtime: print( @@ -1657,10 +1734,35 @@ def main(argv: list[str] | None = None) -> int: transfer_settings = load_or_capture_transfer_settings( controller, target_name, full_key, work_dir ) + participant_args = source_namespace(args) + participant_args.relay_values = [ + ota.parse_relay(value, password) for value in args.relay + ] + participant_versions = ota.read_lora_ota_participant_versions( + controller, participant_args, target + ) + + def all_current_participants_support_adaptive_preamble( + current_target: ota.TargetInfo, + ) -> bool: + participant_versions["destination"] = ( + ota.parse_version(current_target.current_version) + if current_target.current_version else None + ) + return ota.participants_support_adaptive_preamble( + participant_versions + ) + prepare_watchdog(controller, target_name) enforce_transfer_guardrails( controller, target_name, + saved=transfer_settings, + current_version=target.current_version, + temp_values=args.temp_values, + all_participants_support_adaptive_preamble=( + all_current_participants_support_adaptive_preamble(target) + ), legacy_full_airtime=args.legacy_full_airtime, ) enforce_ota_hops(controller, target_name, args.ota_hops) @@ -1682,6 +1784,12 @@ def main(argv: list[str] | None = None) -> int: enforce_transfer_guardrails( controller, target_name, + saved=transfer_settings, + current_version=target.current_version, + temp_values=args.temp_values, + all_participants_support_adaptive_preamble=( + all_current_participants_support_adaptive_preamble(target) + ), legacy_full_airtime=args.legacy_full_airtime, ) enforce_ota_hops(controller, target_name, args.ota_hops) diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index 78bfb6b7..c8bbd8d3 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -163,6 +163,130 @@ class FormatTests(unittest.TestCase): self.assertGreater(ota.parse_version("v1.10.0"), ota.parse_version("v1.9.9")) self.assertIsNone(ota.parse_version("release-one")) + def test_adaptive_preamble_tuples_require_current_participants(self) -> None: + for temp_values, expected_profile in ( + ((909.95, 250.0, 5, 5, 120), ota.RxpsTempProfile(8, 64)), + ((909.95, 500.0, 6, 5, 120), ota.RxpsTempProfile(8, 64)), + ((909.95, 500.0, 5, 5, 120), ota.RxpsTempProfile(8, 128)), + ): + with self.subTest(temp_values=temp_values): + self.assertIsNone( + ota.select_rxps_temp_profile( + "v1.17.1.4", + temp_values, + all_participants_support_adaptive_preamble=True, + ) + ) + self.assertIsNone( + ota.select_rxps_temp_profile( + "v1.17.1.5", + temp_values, + all_participants_support_adaptive_preamble=False, + ) + ) + self.assertEqual( + ota.select_rxps_temp_profile( + "v1.17.1.5", + temp_values, + all_participants_support_adaptive_preamble=True, + ), + expected_profile, + ) + + def test_rxps_temp_profiles_cover_only_qualified_fast_tuples(self) -> None: + qualified = ( + ((909.95, 500.0, 5, 5, 120), 8, 128), + ((909.95, 500.0, 6, 5, 120), 8, 64), + ((909.95, 500.0, 7, 5, 120), 7, 32), + ((909.95, 250.0, 6, 5, 120), 7, 32), + ((909.95, 125.0, 5, 5, 120), 7, 32), + ((909.95, 62.5, 5, 5, 120), 10, 16), + ) + for values, level, preamble in qualified: + with self.subTest(values=values): + profile = ota.select_rxps_temp_profile( + "v1.17.1.5", + values, + all_participants_support_adaptive_preamble=True, + ) + self.assertIsNotNone(profile) + self.assertEqual(profile.boundary_level, level) + self.assertEqual(profile.boundary_preamble, preamble) + + self.assertIsNone( + ota.select_rxps_temp_profile( + "v1.17.1.5", + (909.95, 500.0, 8, 5, 120), + all_participants_support_adaptive_preamble=True, + ) + ) + + def test_unknown_participant_version_fails_closed(self) -> None: + current = ota.RXPS_ADAPTIVE_PREAMBLE_MIN_VERSION + self.assertTrue( + ota.participants_support_adaptive_preamble( + {"destination": current, "source": current} + ) + ) + self.assertFalse( + ota.participants_support_adaptive_preamble( + {"destination": current, "source": None} + ) + ) + + def test_rxps_config_parser_keeps_legacy_compatibility(self) -> None: + self.assertEqual( + ota.parse_rxps_settings("> on,65625,60000", "remote"), + ota.RxpsSettings(True, 65625, 60000), + ) + self.assertEqual( + ota.parse_rxps_settings( + "> on,level=8,preamble=16,rx=65625,sleep=60000", + "remote", + ), + ota.RxpsSettings(True, 65625, 60000, 8, 16), + ) + with self.assertRaisesRegex(ota.OtaError, "RXPS state"): + ota.parse_rxps_settings("> on,31,60000", "remote") + self.assertEqual( + ota.parse_rxps_settings( + "> on,level=8,preamble=0,rx=626,sleep=6398", + "remote", + ), + ota.RxpsSettings(True, 626, 6398, 8, 0), + ) + self.assertEqual( + ota.parse_rxps_settings( + "radio.rxps.config on,level=8,preamble=16," + "rx=65625,sleep=60000", + "local", + ), + ota.RxpsSettings(True, 65625, 60000, 8, 16), + ) + + def test_rxps_reader_falls_back_to_legacy_query(self) -> None: + class LegacyController: + def __init__(self) -> None: + self.commands: list[str] = [] + + def remote_command( + self, _target: str, command: str, **_kwargs: object + ) -> str: + self.commands.append(command) + if command == "get radio.rxps.config": + return "Unknown command" + return "> on,65625,60000" + + controller = LegacyController() + self.assertEqual( + ota.read_remote_rxps(controller, "remote"), + ota.RxpsSettings(True, 65625, 60000), + ) + self.assertEqual( + controller.commands, + ["get radio.rxps.config", "get radio.rxps"], + ) + def test_temp_radio_accepts_only_cli_bandwidths(self) -> None: self.assertEqual( ota.parse_temp_radio("909.950,250,5,5,120"), @@ -178,6 +302,34 @@ class FormatTests(unittest.TestCase): self.assertEqual(chain.temp_radio, "909.950,250,5,5,120") self.assertFalse(chain.legacy_full_airtime) + def test_airtime_estimator_uses_forward_preamble_contract(self) -> None: + expected_preambles = ( + (500.0, 7, 32), + (250.0, 6, 32), + (125.0, 5, 32), + (62.5, 5, 32), + (250.0, 5, 64), + (500.0, 6, 64), + (500.0, 5, 128), + (125.0, 9, 16), + ) + for bandwidth, sf, preamble in expected_preambles: + with self.subTest(bandwidth=bandwidth, sf=sf): + self.assertEqual( + ota.wire_preamble_symbols(bandwidth, sf), + preamble, + ) + self.assertEqual( + ota.lora_airtime_seconds(184, bandwidth, sf, 5), + ota.lora_airtime_seconds( + 184, + bandwidth, + sf, + 5, + preamble_symbols=preamble, + ), + ) + def test_rak_chain_full_airtime_is_explicit(self) -> None: args = rak_chain.build_parser().parse_args(["--legacy-full-airtime"]) self.assertTrue(args.legacy_full_airtime) @@ -1812,25 +1964,49 @@ class Rak3401TransferGuardrailTests(unittest.TestCase): self.rxps_enabled = True self.rxps_rx_us = 65625 self.rxps_sleep_us = 60000 + self.rxps_level = 8 + self.rxps_preamble = 16 self.powersaving_enabled = True self.rxdelay = "2.0" self.airtime_factor = "1.0" self.ota_hops = 3 self.commands: list[str] = [] - def remote_command(self, _target: str, command: str) -> str: + def remote_command( + self, _target: str, command: str, **_kwargs: object + ) -> str: self.commands.append(command) - if command == "get radio.rxps": + if command == "get radio.rxps.config": state = "on" if self.rxps_enabled else "off" - return f"> {state},{self.rxps_rx_us},{self.rxps_sleep_us}" + return ( + f"> {state},level={self.rxps_level}," + f"preamble={self.rxps_preamble},rx={self.rxps_rx_us}," + f"sleep={self.rxps_sleep_us}" + ) if command == "set radio.rxps off": self.rxps_enabled = False return f"OK - off,{self.rxps_rx_us},{self.rxps_sleep_us}" + if command.startswith("set radio.rxps level "): + parts = command.split() + level = int(parts[3]) + preamble = int(parts[5]) if len(parts) == 6 else 0 + self.rxps_enabled = True + self.rxps_level = level + self.rxps_preamble = preamble + if (level, preamble) == (8, 32): + self.rxps_rx_us = 1252 + self.rxps_sleep_us = 6424 + return ( + f"OK - level {level},on,{self.rxps_rx_us}," + f"{self.rxps_sleep_us},preamble={preamble}" + ) if command.startswith("set radio.rxps "): _set, _key, rx_us, sleep_us = command.split() self.rxps_enabled = True self.rxps_rx_us = int(rx_us) self.rxps_sleep_us = int(sleep_us) + self.rxps_level = 0 + self.rxps_preamble = 0 return f"OK - on,{rx_us},{sleep_us}" if command == "powersaving": return "on" if self.powersaving_enabled else "off" @@ -1875,6 +2051,8 @@ class Rak3401TransferGuardrailTests(unittest.TestCase): self.assertTrue(controller.rxps_enabled) self.assertEqual(controller.rxps_rx_us, 65625) self.assertEqual(controller.rxps_sleep_us, 60000) + self.assertEqual(controller.rxps_level, 8) + self.assertEqual(controller.rxps_preamble, 16) self.assertTrue(controller.powersaving_enabled) self.assertEqual(controller.rxdelay, "2.0") self.assertEqual(controller.airtime_factor, "1.0") @@ -1889,6 +2067,49 @@ class Rak3401TransferGuardrailTests(unittest.TestCase): self.assertEqual(controller.airtime_factor, "1.0") self.assertNotIn("set af 0", controller.commands) + def test_guardrails_keep_rxps_for_current_qualified_participants(self) -> None: + controller = self.Controller() + saved = rak_chain.read_target_transfer_settings(controller, "remote") + + rak_chain.enforce_transfer_guardrails( + controller, + "remote", + saved=saved, + current_version="v1.17.1.5", + temp_values=(909.95, 250.0, 5, 5, 120), + all_participants_support_adaptive_preamble=True, + ) + + self.assertTrue(controller.rxps_enabled) + self.assertEqual(controller.rxps_rx_us, saved.rxps_rx_us) + self.assertEqual(controller.rxps_sleep_us, saved.rxps_sleep_us) + self.assertNotIn( + "set radio.rxps level 8 preamble 32", controller.commands + ) + self.assertFalse(controller.powersaving_enabled) + self.assertEqual(controller.rxdelay, "0") + + def test_guardrails_restore_saved_rxps_preference_on_resume(self) -> None: + controller = self.Controller() + saved = rak_chain.read_target_transfer_settings(controller, "remote") + controller.rxps_enabled = False + + rak_chain.enforce_transfer_guardrails( + controller, + "remote", + saved=saved, + current_version="v1.17.1.5", + temp_values=(909.95, 250.0, 5, 5, 120), + all_participants_support_adaptive_preamble=True, + ) + + self.assertTrue(controller.rxps_enabled) + self.assertEqual(controller.rxps_level, saved.rxps_level) + self.assertEqual(controller.rxps_preamble, saved.rxps_preamble) + self.assertIn( + "set radio.rxps level 8 preamble 16", controller.commands + ) + def test_original_settings_are_persisted_for_resume(self) -> None: controller = self.Controller() with tempfile.TemporaryDirectory() as directory: diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 6aa329a3..777ddbee 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -20,6 +20,10 @@ lib_deps = ${stm32_base.lib_deps} [env:wio-e5-mini_repeater] extends = lora_e5_mini board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS +; GCC 14.2 emits a materially smaller LTO image than the platform's GCC 12 +; default, preserving the deployed filesystem boundary without cutting roles. +platform_packages = + platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os build_flags = ${lora_e5_mini.build_flags} -Oz ; GCC 12's strongest size profile preserves the deployed 16 KiB filesystem boundary diff --git a/variants/xiao_c6/XiaoC6Board.cpp b/variants/xiao_c6/XiaoC6Board.cpp index dc62c20e..bbf72134 100644 --- a/variants/xiao_c6/XiaoC6Board.cpp +++ b/variants/xiao_c6/XiaoC6Board.cpp @@ -37,7 +37,7 @@ void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { radio.setSpreadingFactor(sf); radio.setBandwidth(bw); radio.setCodingRate(cr); - radio_driver.updatePreamble(sf); + radio_driver.updatePreamble(sf, bw); } void radio_set_tx_power(int8_t dbm) { @@ -49,4 +49,3 @@ mesh::LocalIdentity radio_new_identity() { return mesh::LocalIdentity(&rng); // create new random identity } -