diff --git a/build-iotthinks.sh b/build-iotthinks.sh index 49382199..124d0e38 100644 --- a/build-iotthinks.sh +++ b/build-iotthinks.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # ./build-iotthinks.sh -export FIRMWARE_VERSION="PowerSaving17.1" +export FIRMWARE_VERSION="PowerSaving17.1.2" ############# Repeaters ############# # Commonly-used boards -## ESP32 - 19 boards +## ESP32 - 21 boards ./build.sh build-firmware \ Heltec_ct62_repeater \ Heltec_E290_repeater \ @@ -15,8 +15,10 @@ heltec_tracker_v2_repeater \ Heltec_Wireless_Paper_repeater \ Heltec_Wireless_Tracker_repeater \ Heltec_WSL3_repeater \ +MKE_s3_repeater \ LilyGo_T3S3_sx1262_repeater \ LilyGo_TBeam_1W_repeater \ +LilyGo_TDeck_repeater \ Station_G2_repeater \ T_Beam_S3_Supreme_SX1262_repeater \ Tbeam_SX1262_repeater \ @@ -26,7 +28,7 @@ Xiao_C6_repeater_ \ Xiao_S3_repeater \ Xiao_S3_WIO_repeater -## NRF52 - 22 boards +## NRF52 - 23 boards ./build.sh build-firmware \ GAT562_30S_Mesh_Kit_repeater \ GAT562_Mesh_Tracker_Pro_repeater \ @@ -39,6 +41,7 @@ ikoka_nano_nrf_30dbm_repeater \ ikoka_nano_nrf_33dbm_repeater \ LilyGo_T-Echo_Card_repeater \ LilyGo_T-Echo_repeater \ +LilyGo_T-Echo-Lite_repeater \ ProMicro_repeater \ RAK_3401_repeater \ RAK_4631_repeater \ @@ -58,7 +61,7 @@ LilyGo_TLora_V2_1_1_6_repeater \ Tbeam_SX1276_repeater ############# Room Server ############# -# ESP32 - 8 boards +# ESP32 - 9 boards ./build.sh build-firmware \ Heltec_v3_room_server \ heltec_v4_room_server \ @@ -66,6 +69,7 @@ heltec_v4_r8_room_server \ heltec_tracker_v2_room_server \ Heltec_Wireless_Paper_room_server \ Heltec_WSL3_room_server \ +MKE_s3_room_server \ LilyGo_TBeam_1W_room_server \ Xiao_S3_room_server @@ -80,14 +84,17 @@ WioTrackerL1_room_server \ Xiao_nrf52_room_server ############# Companions BLE ############# -# NRF52 - 17 boards +# NRF52 and ESP32 ./build.sh build-firmware \ Heltec_t096_companion_radio_ble_femon \ Heltec_t096_companion_radio_ble_femoff \ Heltec_t1_companion_radio_ble \ Heltec_t114_companion_radio_ble \ +MKE_s3_companion_radio_ble \ LilyGo_T-Echo_Card_companion_radio_ble \ LilyGo_T-Echo_companion_radio_ble \ +LilyGo_T-Echo-Lite_companion_radio_ble \ +LilyGo_T-Echo-Lite_non_shell_companion_radio_ble \ ProMicro_companion_radio_ble \ RAK_3401_companion_radio_ble \ RAK_4631_companion_radio_ble \ @@ -101,7 +108,7 @@ WioTrackerL1_companion_radio_ble \ Xiao_nrf52_companion_radio_ble ############# Companions BLE PS ############# -# ESP32 - 23 builds +# ESP32 companion builds ./build.sh build-firmware \ Heltec_ct62_companion_radio_ble_ps \ heltec_tracker_v2_companion_radio_ble_femon \ @@ -116,6 +123,8 @@ heltec_v4_r8_companion_radio_ble_ps \ Heltec_Wireless_Paper_companion_radio_ble_ps \ Heltec_Wireless_Tracker_companion_radio_ble_ps \ Heltec_WSL3_companion_radio_ble_ps \ +MKE_s3_companion_radio_ble \ +LilyGo_TDeck_companion_radio_ble \ LilyGo_T3S3_sx1262_companion_radio_ble_ps \ LilyGo_TBeam_1W_companion_radio_ble_ps \ LilyGo_TLora_V2_1_1_6_companion_radio_ble_ps \ @@ -128,13 +137,27 @@ Xiao_S3_companion_radio_ble_ps \ Xiao_S3_WIO_companion_radio_ble_ps ############# Companions USB ############# -# ESP32 and NRF52 - 5 builds +# ESP32 and NRF52 ./build.sh build-firmware \ Heltec_t096_companion_radio_usb_femon \ Heltec_t096_companion_radio_usb_femoff \ heltec_v4_companion_radio_usb \ heltec_v4_companion_radio_usb_ps_femoff \ -heltec_v4_companion_radio_usb_ps_femon +heltec_v4_companion_radio_usb_ps_femon \ +heltec_tracker_v2_companion_radio_usb_femoff \ +heltec_tracker_v2_companion_radio_usb_femon \ +MKE_s3_companion_radio_usb \ +LilyGo_TBeam_1W_companion_radio_usb \ +LilyGo_TDeck_companion_radio_usb \ +LilyGo_T-Echo-Lite_non_shell_companion_radio_usb \ +Xiao_C3_companion_radio_usb \ +Xiao_S3_companion_radio_usb \ +Xiao_S3_WIO_companion_radio_usb + +############# Sensors ############# +./build.sh build-firmware \ +Heltec_t114_sensor \ +t1000e_sensor ############# Sample builds ############# # 27 boards @@ -172,4 +195,18 @@ Xiao_nrf52_repeater heltec_v4_companion_radio_ble_ps_femon \ heltec_v4_companion_radio_usb \ heltec_v4_companion_radio_usb_ps_femoff \ -heltec_v4_companion_radio_usb_ps_femon +heltec_v4_companion_radio_usb_ps_femon \ +heltec_tracker_v2_companion_radio_usb_femoff \ +heltec_tracker_v2_companion_radio_usb_femon \ +MKE_s3_companion_radio_usb \ +LilyGo_TBeam_1W_companion_radio_usb \ +LilyGo_TDeck_companion_radio_usb \ +LilyGo_T-Echo-Lite_non_shell_companion_radio_usb \ +Xiao_C3_companion_radio_usb \ +Xiao_S3_companion_radio_usb \ +Xiao_S3_WIO_companion_radio_usb + +############# Sensors ############# +./build.sh build-firmware \ +Heltec_t114_sensor \ +t1000e_sensor diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 33d9c000..a6b8bbf0 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1091,37 +1091,40 @@ settings use a safe effective power without replacing the saved preference. available. Returning to slower settings recalculates from the saved RXPS minimum. -On SX1262+TCXO boards, the fast-setting boundaries are: +With the measured RXPS model and the default 1600 us TCXO startup delay +(2600 us total transition), a requested minimum level 1 gives: -| 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 | +| SF | BW (kHz) | Wire preamble | Effective timing preamble | Effective RXPS level | RX / sleep | +|---:|---------:|--------------:|--------------------------:|---------------------:|-----------:| +| 7 | 500 | 32 | 32 | 2 | 5594 / 3022 us | +| 6 | 250 | 32 | 32 | 2 | 5844 / 3022 us | +| 5 | 125 | 32 | 32 | 2 | 5844 / 3022 us | +| 5 | 250 | 64 | 64 | 1 | 3829 / 5100 us | +| 6 | 500 | 64 | 64 | 1 | 3829 / 5100 us | +| 5 | 500 | 128 | 128 | 1 | 2563 / 6647 us | +| 5 | 62.5 | 32 | 16 | 6 | 11922 / 2975 us | -SF7/BW500, SF6/BW250, and SF5/BW125 are timing-equivalent because each has a -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. +These values include the actual transmitted preamble, explicit header, and a +one-symbol timer margin. SF5/SF6 need two more sync symbols than SF7, so their +receive windows differ even when symbol duration and sleep time match. -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. +The wire preamble remains compatible with the existing Cascade convention: +32 symbols at SF5-SF8, extended to 64 for SF5/BW250 and SF6/BW500, or 128 for +SF5/BW500; SF9-SF12 use 16. Local RXPS settings and oscillator delays never +change the sender's preamble choice. The receiver may use a shorter catch +assumption, but its header timeout covers the full transmitted preamble. -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. +Automatic adjustment stops at guarded level 8. If no guarded timing fits, +the radio receives continuously until the tuple changes. For example, a board +configured with the previous 5000 us TCXO delay uses continuous RX at SF7/BW500; +it cannot fit the new guard within that tuple's 32-symbol wire preamble. +Experimental levels 9 and 10 require an explicit selection. + +Earlier firmware passed 16/16 packets in each direction between Heltec V4 and +WisMesh Tag at SF5/BW250/64 and SF5/BW500/128, CR5, 909.950 MHz. That result +established the existing wire-preamble convention; it does not validate the +new timing model. All senders to an RXPS receiver must use a compatible wire +preamble. LoRa OTA treats v1.17.1.5 as that capability boundary. --- @@ -1184,6 +1187,12 @@ Station G2/G3 targets default to `off`. - `set radio.rxps on` - `set radio.rxps conservative` - `set radio.rxps balanced` +- `set radio.rxps max` +- `set radio.rxps max preamble <16|32>` +- `set radio.rxps overdrive` +- `set radio.rxps overdrive preamble <16|32>` +- `set radio.rxps riskyWorkingMax` +- `set radio.rxps riskyWorkingMax preamble <16|32>` - `set radio.rxps <1-10>` - `set radio.rxps level <1-10>` - `set radio.rxps level <1-10> preamble <16|32>` @@ -1191,20 +1200,35 @@ Station G2/G3 targets default to `off`. **Parameters:** - `rx_us`, `sleep_us`: Receive and sleep durations in microseconds (`1000`-`30000000`). -- `level`: A power-saving level from `1` (most conservative) to `10` (least power saving). +- `level`: Guarded levels `1`-`8`, followed by experimental levels `9` and `10`. Higher guarded levels allow more sleep. - `preamble`: RXPS timing assumption in symbols; `16` or `32`. This does not change the radio's actual transmitted preamble. - `state`: `on` or `off`. **Notes:** -- `get rxps.wd` reports the RXPS watchdog's soft and hard recovery counts. +- `get rxps.wd` reports the radio watchdogs' soft and hard recovery counts. + See [receive calibration and recovery](radio_receive_calibration.md) for + noise sampling, CAD handling, and the continuous-RX mode check. - `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. +- `on` and `conservative` select level `3`, `balanced` selects level `6`, and + `max` selects level `8`; each named profile assumes a 16-symbol preamble. +- The guarded catch ladders for levels 1-8 are `15,14,13,12,11,10,9,8` symbols + for preamble 16 and `24,20,16,14,12,10,9,8` for preamble 32 or longer. + Generated sleep leaves another 0.2 symbols of margin. The receive timer covers + the actual wire preamble, sync, explicit header, and one extra symbol. +- `overdrive` selects level `9`; `riskyWorkingMax` selects level `10`. Both + operate outside the guarded timer condition. Upstream bench measurements + found packet loss at level 10; neither profile is a general guarantee of + reliable delivery. They are never selected by automatic level adjustment. +- Named experimental profiles accept `preamble 32`; numeric levels keep the + existing automatic-preamble selection. Manual timings bypass the level model. +- `MC_TCXO_DELAY_US` defaults to 1600 us. Initialization applies it and checks + TCXO/calibration errors. A board can override it for its oscillator. - 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` keeps the legacy on/off, RX, and sleep reply. The new diff --git a/docs/radio_receive_calibration.md b/docs/radio_receive_calibration.md new file mode 100644 index 00000000..bb719fd5 --- /dev/null +++ b/docs/radio_receive_calibration.md @@ -0,0 +1,49 @@ +# Radio receive calibration and recovery + +Noise calibration collects 64 idle RSSI samples at least 50 ms apart. A quiet +block takes about 3.2 seconds. Both continuous RX and temporary RXPS calibration +windows allow up to 10 seconds; a partial block times out without publishing. +Periodic requests coalesce while a block is active. RXPS resumes after a complete +block, including a held block, or at timeout. Receive and transmit activity are +never interrupted just to finish calibration. + +The block estimate is the median, retaining fractional dBm. A published update +uses 25% of the previous floor and 75% of the new median, with a -120 dBm lower +bound. Initial calibration and explicit gain/tuning changes seed a new baseline. +The old published value remains available while collecting. An AGC reset drops +partial samples while retaining a valid baseline for channel checks. + +A median more than 15 dB above the established floor is held twice. The third +consecutive complete high block is accepted, allowing recovery after a permanent +ambient rise. A normal or quieter complete block clears the hold. Partial +blocks do not advance it. There is no admission threshold based on the old +floor, so an old low baseline cannot reject every sample from a higher floor. + +The median handles minority outliers. If interference occupies most accepted +samples, the median eventually represents that energy; it cannot prove whether +the energy is ambient noise or sustained traffic. The rise hold delays that +change but does not distinguish the two. Busy-channel completion and recovery +times therefore depend on traffic. Longer sampling also spends more time in +continuous RX during calibration than the previous rapid 64-sample average. + +CAD has a bounded wait, stops RX duty cycling before scanning, and re-arms the +configured receive mode on success, busy detection, or error. A completed or +currently receiving packet owns the radio and blocks CAD. A packet interrupt +arriving during re-arm is retained. Failed re-arm and a newly arrived packet +both defer transmission. + +SX126x wrappers additionally verify the chip's operating mode at most once per +10 seconds while continuous RX is expected. RXPS sleep, busy SPI, pending packets, +and invalid status reads do not count as non-RX evidence. Two confirmed non-RX +observations request soft recovery; a third uses the existing hard recovery path +where supported. An observed healthy RX state clears the sequence. Recovery +preserves tuning, TX power, receive gain, and the operator's power-saving intent. +The existing radio liveness and RXPS watchdogs remain active for other failures. + +The status read uses the two-byte GetStatus transaction. The pinned RadioLib +getStatus() helper requests zero copied data bytes, so its return value is not +usable for this mode check. The dedicated read restores the normal SPI command +layout afterward and does not wake a duty-cycling radio to inspect its mode. + +Validation lives in `test_noise_floor_estimator`, `test_rx_power_saving`, +`test_radio_receive_contract.py`, and `test_sx126x_receive_mode.py`. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e48ebc59..4fb5bf68 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -6787,7 +6787,7 @@ void MyMesh::appendRxPowerSavingAdjustmentNote(char* reply, size_t reply_size, if (rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)) { snprintf(reply + used, reply_size - used, - "; RXPS continuous-fast (no safe level %u-10)", + "; RXPS continuous-fast (requested level %u)", (unsigned)_prefs.rx_ps_level); return; } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 7fed6d00..2d869826 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -225,7 +225,18 @@ uint8_t SensorMesh::handleRequest(uint8_t perms, uint32_t sender_timestamp, uint if (telemetry_permissions & TELEM_PERM_BASE) { telemetry.addVoltage(TELEM_CHANNEL_SELF, (float)board.getBattMilliVolts() / 1000.0f); } + if (telemetry_permissions & TELEM_PERM_BASE) { + const float temperature = board.getMCUTemperature(); + if (!isnan(temperature)) { + telemetry.addTemperature(TELEM_CHANNEL_SELF, temperature); + } + } // query other sensors -- target specific + if ((telemetry_permissions & TELEM_PERM_LOCATION) + && sensors.getLocationProvider() != nullptr + && sensors.getLocationProvider()->getGPSPowerSaving()) { + sensors.getLocationProvider()->syncTime(); + } sensors.querySensors(telemetry_permissions, telemetry); // TODO: let requester know permissions they have: telemetry.addPresence(TELEM_CHANNEL_SELF, perms); diff --git a/platformio.ini b/platformio.ini index f7e4fc1f..d8a07300 100644 --- a/platformio.ini +++ b/platformio.ini @@ -29,6 +29,15 @@ build_flags = -w -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1 -D LORA_FREQ=869.618 -D LORA_BW=62.5 -D LORA_SF=8 + ; TCXO startup delay in microseconds, written by CustomSX1262/CustomLR1110 + ; after begin(). RadioLib defaults to 5000 and nothing used to override it, so + ; every board paid 5 ms on every duty-cycle wake. Lowest working value measured + ; on five modules: T096 400, Tracker V2 300, Waveshare 150, ThinkNode M3 200, + ; T1000-E 200 us - 1600 keeps 4x margin over the worst of them. It also feeds + ; the RXPS sleep floor and timer guard (transition = this + 1000), so lowering + ; it makes low-SF profiles armable that were not. A board needing its own value + ; puts -D MC_TCXO_DELAY_US=... in its own build_flags; the later -D wins. + -D MC_TCXO_DELAY_US=1600 -D ENABLE_ADVERT_ON_BOOT=1 -D ENABLE_PRIVATE_KEY_IMPORT=1 ; NOTE: comment these out for more secure firmware -D ENABLE_PRIVATE_KEY_EXPORT=1 diff --git a/src/helpers/CLICommandUtils.h b/src/helpers/CLICommandUtils.h index 9a322ae3..142677b9 100644 --- a/src/helpers/CLICommandUtils.h +++ b/src/helpers/CLICommandUtils.h @@ -730,6 +730,13 @@ struct RxPowerSavingArguments { uint32_t sleep_us; }; +inline uint8_t rxPowerSavingNamedLevel(const char* name) { + if (strcmp(name, "max") == 0) return 8; + if (strcmp(name, "overdrive") == 0) return 9; + if (strcmp(name, "riskyWorkingMax") == 0) return 10; + return 0; +} + // Parse every documented radio.rxps spelling while leaving hardware-specific // period and level ranges to the caller. Exact token shapes prevent suffixes or // injected extra arguments from being silently accepted. @@ -753,6 +760,10 @@ inline bool parseRxPowerSavingArgumentsStrict( parsed.mode = RxPowerSavingArgumentMode::Conservative; } else if (strcmp(fields[0], "balanced") == 0) { parsed.mode = RxPowerSavingArgumentMode::Balanced; + } else if (rxPowerSavingNamedLevel(fields[0]) != 0) { + parsed.mode = RxPowerSavingArgumentMode::Level; + parsed.level = rxPowerSavingNamedLevel(fields[0]); + parsed.preamble = 16; } else if (parseUnsignedIntegerStrict(fields[0], parsed.level)) { parsed.mode = RxPowerSavingArgumentMode::Level; } else { @@ -769,6 +780,12 @@ inline bool parseRxPowerSavingArgumentsStrict( } parsed.mode = RxPowerSavingArgumentMode::Manual; } + } else if (field_count == 3 + && rxPowerSavingNamedLevel(fields[0]) != 0 + && strcmp(fields[1], "preamble") == 0 + && parseUnsignedIntegerStrict(fields[2], parsed.preamble)) { + parsed.mode = RxPowerSavingArgumentMode::Level; + parsed.level = rxPowerSavingNamedLevel(fields[0]); } else if (field_count == 4 && strcmp(fields[0], "level") == 0 && strcmp(fields[2], "preamble") == 0 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index cd9da1a3..47991811 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -452,7 +452,7 @@ static void appendRxPowerSavingAdjustmentNote(char* reply, const NodePrefs* pref char note[96] = {}; if (rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)) { snprintf(note, sizeof(note), - "; RXPS continuous-fast (no safe level %u-10)", + "; RXPS continuous-fast (requested level %u)", (unsigned)prefs->rx_ps_level); } else { const uint8_t requested_preamble = prefs->rx_ps_preamble == 0 @@ -3477,48 +3477,38 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep rx_us = _prefs->rx_ps_rx_us; sleep_us = _prefs->rx_ps_sleep_us; - if (strcmp(value, "off") == 0) { + mesh::cli::RxPowerSavingArguments parsed = {}; + if (!mesh::cli::parseRxPowerSavingArgumentsStrict(value, parsed)) { + strcpy(reply, "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|level <1-10>| "); + return; + } + using mesh::cli::RxPowerSavingArgumentMode; + if (parsed.mode == RxPowerSavingArgumentMode::Off) { enable = 0; - } else if (strcmp(value, "on") == 0 || strcmp(value, "conservative") == 0) { + } else if (parsed.mode == RxPowerSavingArgumentMode::Manual) { + rx_us = parsed.rx_us; + sleep_us = parsed.sleep_us; enable = 1; - level = RX_POWERSAVING_CONSERVATIVE_LEVEL; - preamble = RX_POWERSAVING_PROFILE_PREAMBLE; - level_requested = true; - preamble_overridden = true; - } else if (strcmp(value, "balanced") == 0) { - enable = 1; - level = RX_POWERSAVING_BALANCED_LEVEL; - preamble = RX_POWERSAVING_PROFILE_PREAMBLE; - level_requested = true; - preamble_overridden = true; + manual_timings_requested = true; } else { - StrHelper::strncpy(tmp, value, sizeof(tmp)); - const char *parts[4]; - int num = mesh::Utils::parseTextParts(tmp, parts, 4, ' '); - if (num == 1 && isNumeric(parts[0])) { - level = _atoi(parts[0]); - level_requested = true; - enable = 1; - } else if (num == 2 && strcmp(parts[0], "level") == 0 && isNumeric(parts[1])) { - level = _atoi(parts[1]); - level_requested = true; - enable = 1; - } else if (num == 4 && strcmp(parts[0], "level") == 0 && isNumeric(parts[1]) && - strcmp(parts[2], "preamble") == 0 && isNumeric(parts[3])) { - level = _atoi(parts[1]); - preamble = _atoi(parts[3]); - level_requested = true; - preamble_overridden = true; - enable = 1; - } else if (num == 2 && isNumeric(parts[0]) && isNumeric(parts[1])) { - 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>| "); + uint32_t requested_level = parsed.level; + uint32_t requested_preamble = parsed.preamble; + if (parsed.mode == RxPowerSavingArgumentMode::Conservative + || parsed.mode == RxPowerSavingArgumentMode::Balanced) { + requested_level = parsed.mode == RxPowerSavingArgumentMode::Conservative + ? RX_POWERSAVING_CONSERVATIVE_LEVEL : RX_POWERSAVING_BALANCED_LEVEL; + requested_preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + } + if (requested_level < 1 || requested_level > 10 + || (requested_preamble != 0 && requested_preamble != 16 && requested_preamble != 32)) { + strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32"); return; } + level = requested_level; + if (requested_preamble != 0) preamble = requested_preamble; + preamble_overridden = requested_preamble != 0; + level_requested = true; + enable = 1; } if (level_requested && preamble_overridden diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index 6349fe5c..aca75fdb 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -1,10 +1,14 @@ #pragma once +#include "SX126xReceiveMode.h" #include "CustomLLCC68.h" #include "RadioLibWrappers.h" #include "SX126xReset.h" class CustomLLCC68Wrapper : public RadioLibWrapper { +protected: + int8_t readReceiveMode() override { return sx126xReceiveMode((SX126x*)_radio); } + public: CustomLLCC68Wrapper(CustomLLCC68& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index fc751534..ec38c8d9 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -2,6 +2,7 @@ #include #include "MeshCore.h" +#include "RXPowerSaving.h" #include "LR1110RxRecovery.h" #ifndef LR11X0_TX_BUSY_TIMEOUT_MS @@ -18,6 +19,26 @@ class CustomLR1110 : public LR1110 { public: CustomLR1110(Module *mod) : LR1110(mod) { } + // Apply the measured TCXO delay on every initialization, including recovery. + int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, + uint8_t syncWord = RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, int8_t power = 10, + uint16_t preambleLength = 8, float tcxoVoltage = 1.6) { + int16_t state = LR1110::begin(freq, bw, sf, cr, syncWord, power, preambleLength, + tcxoVoltage); + if (state == RADIOLIB_ERR_NONE) state = applyMeshCoreTcxoDelay(); + return state; + } + + int16_t applyMeshCoreTcxoDelay() { + if (tcxoVoltage <= 0.0f) return RADIOLIB_ERR_NONE; + int16_t state = setTCXO(tcxoVoltage, MC_TCXO_DELAY_US); + RADIOLIB_ASSERT(state); + state = calibrate(0x3F); + RADIOLIB_ASSERT(state); // all blocks; setTCXO moved the gating window + delay(50); + return RADIOLIB_ERR_NONE; + } + // MeshCore keeps the LR1110 in LoRa mode. Calculate from RadioLib's cached // parameters so an airtime query never issues GetPacketType while RX duty // cycling has the chip asleep. RadioLib's LR11x0 implementation ignores a diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index 301c50a5..ef4b21fb 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -1,4 +1,5 @@ #pragma once +#include "SX126xReceiveMode.h" #include "CustomSTM32WLx.h" #include "RadioLibWrappers.h" @@ -6,6 +7,9 @@ #include class CustomSTM32WLxWrapper : public RadioLibWrapper { +protected: + int8_t readReceiveMode() override { return sx126xReceiveMode((SX126x*)_radio); } + public: CustomSTM32WLxWrapper(CustomSTM32WLx& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index 9f8ef9ee..1d15d919 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -19,6 +19,30 @@ class CustomSX1262 : public SX1262 { public: CustomSX1262(Module *mod) : SX1262(mod) { } + // Apply the measured TCXO delay on every initialization, including recovery. + int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, + uint8_t syncWord = RADIOLIB_SX126X_SYNC_WORD_PRIVATE, int8_t power = 10, + uint16_t preambleLength = 8, float tcxoVoltage = 1.6, + bool useRegulatorLDO = false) { + int16_t state = SX1262::begin(freq, bw, sf, cr, syncWord, power, preambleLength, + tcxoVoltage, useRegulatorLDO); + if (state == RADIOLIB_ERR_NONE) state = applyMeshCoreTcxoDelay(); + return state; + } + + // Read tcxoVoltage back from the chip object rather than from the argument: + // begin() zeroes it when it falls back to an XTAL, and re-asserting a TCXO + // supply on DIO3 for a board that has none would be worse than a long delay. + int16_t applyMeshCoreTcxoDelay() { + if (tcxoVoltage <= 0.0f) return RADIOLIB_ERR_NONE; + int16_t state = setTCXO(tcxoVoltage, MC_TCXO_DELAY_US); + RADIOLIB_ASSERT(state); + state = calibrate(RADIOLIB_SX126X_CALIBRATE_ALL); + RADIOLIB_ASSERT(state); + delay(50); + return RADIOLIB_ERR_NONE; + } + // MeshCore keeps the SX1262 in LoRa mode. Use RadioLib's cached modem // parameters instead of issuing GetPacketType while RX duty cycling may // have the chip asleep. A failed live query otherwise becomes an encoded @@ -111,12 +135,12 @@ class CustomSX1262 : public SX1262 { #endif } - // RadioLib's default DIO3 TCXO delay is 5 ms and its RX duty-cycle + // MeshCore configures the DIO3 TCXO delay and the RX duty-cycle // command adds another 1 ms for sleep/wake transitions. If begin() // fell back to a crystal, only the fixed 1 ms transition remains. Use // RadioLib's resolved oscillator mode: it only falls back after reading // the SX126x XOSC_START_ERR device flag, not for an arbitrary SPI error. - _rxDutyCycleTransitionUs = this->tcxoVoltage > 0.0f ? 6000UL : 1000UL; + _rxDutyCycleTransitionUs = this->tcxoDelay + 1000UL; setCRC(1); diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 17897812..2fcf5b80 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -1,4 +1,5 @@ #pragma once +#include "SX126xReceiveMode.h" #include "CustomSX1262.h" #include "RadioLibWrappers.h" @@ -9,6 +10,9 @@ #endif class CustomSX1262Wrapper : public RadioLibWrapper { +protected: + int8_t readReceiveMode() override { return sx126xReceiveMode((SX126x*)_radio); } + public: CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index 0fd7ee3d..a2cf3c21 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -1,4 +1,5 @@ #pragma once +#include "SX126xReceiveMode.h" #include "CustomSX1268.h" #include "RadioLibWrappers.h" @@ -9,6 +10,9 @@ #endif class CustomSX1268Wrapper : public RadioLibWrapper { +protected: + int8_t readReceiveMode() override { return sx126xReceiveMode((SX126x*)_radio); } + public: CustomSX1268Wrapper(CustomSX1268& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } diff --git a/src/helpers/radiolib/NoiseFloorEstimator.h b/src/helpers/radiolib/NoiseFloorEstimator.h new file mode 100644 index 00000000..ae362aaa --- /dev/null +++ b/src/helpers/radiolib/NoiseFloorEstimator.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include + +// PR #2933's spaced median and bounded rise hold, retaining Cascade's +// fractional dBm and 25% previous / 75% new weighting. No radio or clock I/O. +class NoiseFloorEstimator { +public: + static constexpr uint16_t SAMPLE_COUNT = 64; + static constexpr uint32_t SAMPLE_INTERVAL_MS = 50; + static constexpr uint32_t WINDOW_TIMEOUT_MS = 10000; + static constexpr uint8_t MAX_HELD_BLOCKS = 3; + static constexpr int32_t MAX_RISE_CENTI_DB = 1500; + +private: + int16_t _samples[SAMPLE_COUNT] = {}; + uint16_t _count = 0; + uint32_t _last_sample_at = 0; + uint8_t _held_blocks = 0; + +public: + uint16_t count() const { return _count; } + bool complete() const { return _count == SAMPLE_COUNT; } + bool ready(uint32_t now) const { + return !complete() && (_count == 0 || uint32_t(now - _last_sample_at) >= SAMPLE_INTERVAL_MS); + } + uint8_t heldBlocks() const { return _held_blocks; } + + // A timeout discards a partial block. Only completed blocks count toward + // persistent-rise recovery. Radio/gain resets also clear contamination history. + void reset(bool clear_history = false) { + _count = 0; + if (clear_history) _held_blocks = 0; + } + + bool add(float rssi, uint32_t now) { + if (!ready(now) + || !isfinite(rssi) || rssi < -200.0f || rssi >= 0.0f) return false; + _samples[_count++] = static_cast(rssi * 100.0f - 0.5f); + _last_sample_at = now; + return true; + } + + // Call once per complete block. A held block leaves the published value intact. + bool publish(int32_t& floor_centi_dbm, bool previous_valid) { + if (!complete()) return false; + for (uint16_t i = 1; i < SAMPLE_COUNT; ++i) { + const int16_t key = _samples[i]; + uint16_t j = i; + while (j > 0 && _samples[j - 1] > key) { + _samples[j] = _samples[j - 1]; + --j; + } + _samples[j] = key; + } + int32_t median = (int32_t(_samples[SAMPLE_COUNT / 2 - 1]) + + _samples[SAMPLE_COUNT / 2]) / 2; + if (median < -12000) median = -12000; + if (previous_valid && median > floor_centi_dbm + MAX_RISE_CENTI_DB) { + if (++_held_blocks < MAX_HELD_BLOCKS) return false; + } + _held_blocks = 0; + if (previous_valid) { + const int32_t weighted = floor_centi_dbm + 3 * median; + floor_centi_dbm = (weighted - 2) / 4; + } else { + floor_centi_dbm = median; + } + return true; + } +}; diff --git a/src/helpers/radiolib/RXPowerSaving.h b/src/helpers/radiolib/RXPowerSaving.h index e7e32db2..072f2ccb 100644 --- a/src/helpers/radiolib/RXPowerSaving.h +++ b/src/helpers/radiolib/RXPowerSaving.h @@ -20,12 +20,158 @@ // the fallback without persisting invalid sub-millisecond periods. #define RX_POWERSAVING_CONTINUOUS_FALLBACK_US 1000UL -// 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 +// The level scale is a catch dial, in symbols of the sender's preamble. +// Its empirical constants and measurements below come from PowerSaving-v17; +// those bench results do not establish reception guarantees on every board. +// +// Level N means: "at least this many symbols of the sender's preamble land +// inside an open RX window." That is the quantity the radio actually needs - +// a preamble is latched once enough of it has been heard - so the level states +// the requirement directly instead of stating a distance from an edge. +// +// The arithmetic follows from that in one step. Worst case, a preamble starts +// the instant an RX window closes, so what survives into the next window is +// `preamble - sleep`. Requiring that to be at least the level's catch gives +// +// sleep = preamble - catch +// +// and the chip's capture cost never enters the geometry. It becomes a +// validation instead: a level is usable when its catch is at least the cost. +// The floor of the scale is 8 symbols precisely because that is what the +// slower of the two families needs (LR11x0 8, SX126x 6), so **every level +// means the same geometry on both radios** - which the previous margin-based +// scale could not do, because the cost sat inside its formula. +// +// The two profiles get their own ladders. On a 16-symbol preamble there are +// only 8 symbols to spend between the floor and the whole preamble, so the +// steps are single symbols; on a 32-symbol preamble there are 24, so the low +// levels can take much larger strides while retaining a larger capture margin. +static constexpr uint8_t RX_POWERSAVING_GUARDED_LEVELS = 8; +static constexpr float RX_POWERSAVING_LEVEL_CATCH_P16[RX_POWERSAVING_GUARDED_LEVELS] = { + 15.0f, 14.0f, 13.0f, 12.0f, 11.0f, 10.0f, 9.0f, 8.0f +}; +static constexpr float RX_POWERSAVING_LEVEL_CATCH_P32[RX_POWERSAVING_GUARDED_LEVELS] = { + 24.0f, 20.0f, 16.0f, 14.0f, 12.0f, 10.0f, 9.0f, 8.0f +}; + +// Smallest catch any guarded level asks for, and the reason it is 8: the +// LR11x0 needs 8 symbols to latch where the SX126x needs 6. +static constexpr float RX_POWERSAVING_MIN_CATCH_SYMBOLS = 8.0f; + +// On top of the ladder, every guarded level sleeps this much less than the +// arithmetic demands - so it catches 0.2 symbols more than its level promises. +// The reason is not capture safety: it is that specific register pairs the +// calculation lands on are defective (preamble latched, header never +// validated) in the same way isolated rxPeriod ticks 320 and 640 are. Nudging +// every generated geometry off those points cost 0.05 mA and took the worst +// measured cell from 3.33% packet loss to 0.11% across the whole ladder. +// Levels 9 and 10 are exempt - their geometries are pinned to bench +// measurements and shifting them would invalidate that data. +static constexpr float RX_POWERSAVING_MARGIN_PAD_SYMBOLS = 0.2f; + +// Past the guarded scale sit two settings that break the datasheet's +// SetRxDutyCycle timer condition on purpose. Upstream bench measurements +// reported lower duty cycle for overdrive and packet loss at riskyWorkingMax. +// Neither measurement establishes a guarantee for other boards or RF links. +static constexpr uint8_t RX_POWERSAVING_OVERDRIVE_LEVEL = 9; +static constexpr uint8_t RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL = 10; +static constexpr float RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P32 = 11.0f; +static constexpr float RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P16 = 10.25f; + +// Top of the guarded scale: catches the ladder floor of 8 symbols (8.2 with +// the pad), the cheapest setting that still meets the datasheet condition and +// still works on both radio families. The CLI calls it `max`. +static constexpr uint8_t RX_POWERSAVING_MAX_LEVEL = RX_POWERSAVING_GUARDED_LEVELS; + +#define RX_POWERSAVING_CONSERVATIVE_LEVEL 3 // catches 13 (P16) / 16 (P32) +#define RX_POWERSAVING_BALANCED_LEVEL 6 // catches 10 (P16) / 10 (P32) +#define RX_POWERSAVING_PROFILE_PREAMBLE 16 +static constexpr uint8_t RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES = 3; + +// Symbols of a sender's preamble consumed before a packet can be latched: +// ~2 to latch the preamble plus ~2 lost to RX-window startup, measured on +// SX1262 at SF8/BW62.5. Capture needs sleep <= sender_preamble - this. +// Note this is well below the 8 symbols RadioLib quotes (SX126x.h) - that +// figure is the datasheet's margin for *reliable* latching, not the floor. +// +// Chip dependent, so it belongs with the driver rather than with each board: +// the build selects the family capture cost. On the upstream bench, LR1110 +// held 300/300 at a margin of 7.78 symbols and started dropping packets at +// 6.89, for both profiles, where SX1262 is clean down to 6. +static constexpr float RX_POWERSAVING_CAPTURE_COST_SYMBOLS = 6.0f; +static constexpr float RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0 = 8.0f; + +// TCXO startup delay handed to SetDIO3AsTCXOCtrl (SX126x DS 13.3.6) / +// SetTcxoMode (LR11x0 UM 6.3.2). RadioLib defaults it to 5000 us and nothing +// overrode it, so every board paid 5 ms on every duty-cycle wake whatever +// crystal was fitted. Measured lowest working value on five modules: T096 400, +// Tracker V2 300, Waveshare 150, ThinkNode M3 200, T1000-E 200 us - so 1600 +// keeps 4x margin over the worst of them. +// +// One knob for the whole tree, in `arduino_base`, because it is a property of +// the parts MeshCore is built with rather than of any one board. A variant that +// needs its own value defines it in its own build_flags and the later -D wins. +#ifndef MC_TCXO_DELAY_US + #define MC_TCXO_DELAY_US 1600 +#endif + +// Sleep -> RX transition: RadioLib subtracts tcxoDelay + 1000 us from the +// requested sleep before writing the register, and the hardware spends that +// long saving context, restarting the XTAL and locking the PLL. Everything +// below derives from it. Callers can supply the resolved transition delay so +// both the sleep floor and the timer guard follow it. +static constexpr uint32_t RX_POWERSAVING_TRANSITION_US = MC_TCXO_DELAY_US + 1000; + +// Slack the timer guard leaves on top of the datasheet condition, in symbols. +// +// Closing the condition exactly is not enough. The calculator lands on register +// pairs that satisfy it by a hair and still lose packets: rx=2476/sleep=2455 +// ticks clears it by 22 us at 0.12 symbol and drops 2-6% on every SX1262 tried, +// while moving any one component clears the fault. It is the same family as the +// isolated bad ticks 320 and 640 - the condition does not predict which pairs +// misbehave, so the answer is to stop generating geometries that sit on its +// edge. One whole symbol of slack is what the bench evidence supports. +#ifndef MC_RXPS_TIMER_SLACK_SYMBOLS + #define MC_RXPS_TIMER_SLACK_SYMBOLS 1.0f +#endif +static constexpr float RX_POWERSAVING_TIMER_SLACK_SYMBOLS = MC_RXPS_TIMER_SLACK_SYMBOLS; + +// SX126x programs its duty cycle in these units and truncates the +// microsecond value on the way in. Everything the timer guard compares has to +// be expressed in whole ticks or it comes out short by a fraction of one. +static constexpr float RX_POWERSAVING_TICK_US = 15.625f; + +// Margin above the transition before a duty cycle actually works; see below. +static constexpr uint32_t RX_POWERSAVING_MIN_SLEEP_MARGIN_US = 250; + +// Symbols between the end of the sender's preamble and the end of the header: +// the LoRa sync word plus the 8-symbol explicit header. SF5/SF6 use a longer +// sync word than SF7 and above. Used by the SetRxDutyCycle timer guard. +static constexpr float RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF = 6.25f; +static constexpr float RX_POWERSAVING_SYNC_SYMBOLS = 4.25f; +static constexpr float RX_POWERSAVING_HEADER_SYMBOLS = 8.0f; + +// Shortest sleep that actually produces a working duty cycle. +// +// RadioLib subtracts tcxoDelay + 1000 us from the requested sleep before +// writing the register. At or below that the register underflows and arming +// fails with RADIOLIB_ERR_INVALID_SLEEP_PERIOD (-708), after which the wrapper +// falls back to continuous RX while the config still says power saving is on. +// Measured on SX1262: the P16 profile fails to arm on levels 1-5 at SF6 and on +// levels 1-2 at SF7, exactly where that bound predicts. +// +// But arming successfully is NOT the boundary. Just above it the SX1262 accepts +// the command, returns ERR_NONE, and then detects **zero** preambles - a +// silently deaf receiver, which is worse than the honest -708. Measured twice, +// on two different SF and RX windows: sleep 6050-6100 us is dead, 6200 us and +// everything above it is lossless (40/40 at every value up to 21617 us). The +// chip needs roughly 200 us of *programmed* sleep, i.e. ~12 register ticks, so +// this leaves 250 us of margin on top of the transition time. +// +// The default follows MC_TCXO_DELAY_US; a caller can supply another transition. +static constexpr uint32_t RX_POWERSAVING_MIN_SLEEP_US = + RX_POWERSAVING_TRANSITION_US + RX_POWERSAVING_MIN_SLEEP_MARGIN_US; + // Optional initial settings for infrastructure roles. They are intentionally // disabled unless a build profile supplies them, so upstream/default builds @@ -80,11 +226,19 @@ inline bool rxPowerSavingUsesContinuousFallback(uint32_t rx_us, uint32_t sleep_u inline uint32_t rxPowerSavingDefaultTransitionUs() { #if defined(SX126X_DIO3_TCXO_VOLTAGE) - // RadioLib uses a 5 ms DIO3 TCXO delay plus 1 ms for sleep/wake. - return 6000UL; + return SX126X_DIO3_TCXO_VOLTAGE > 0.0f ? MC_TCXO_DELAY_US + 1000UL : 1000UL; +#elif defined(LR11X0_DIO3_TCXO_VOLTAGE) + return LR11X0_DIO3_TCXO_VOLTAGE > 0.0f ? MC_TCXO_DELAY_US + 1000UL : 1000UL; #else - // Crystal and non-SX126x implementations still need transition headroom. - return 1000UL; + return MC_TCXO_DELAY_US + 1000UL; +#endif +} + +inline float rxPowerSavingDefaultCaptureCost() { +#ifdef USE_LR1110 + return RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0; +#else + return RX_POWERSAVING_CAPTURE_COST_SYMBOLS; #endif } @@ -116,52 +270,186 @@ inline uint32_t ceilPositiveFloat(float value) { return value > (float)rounded ? rounded + 1 : rounded; } -inline bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, uint32_t *rx_us, - uint32_t *sleep_us) { - if (!rx_us || !sleep_us || level < 1 || level > 10 || sf < 5 || sf > 12 - || bw <= 0.0f || !isRxPowerSavingPreamble(preamble)) { - return false; - } - - const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; - const float amount = (float)(level - 1) / 9.0f; - const float rx_start_symbols = preamble == 16 ? 12.0f : 16.0f; - const float sleep_start_symbols = preamble == 16 ? 2.0f : 15.0f; - const float rx_edge_symbols = 8.0f; - const float sleep_edge_symbols = (float)preamble + 4.25f - 8.0f; - - const float rx_symbols = rx_start_symbols + amount * (rx_edge_symbols - rx_start_symbols); - const float sleep_symbols = sleep_start_symbols + amount * (sleep_edge_symbols - sleep_start_symbols); - - uint32_t calculated_rx_us = ceilPositiveFloat(rx_symbols * symbol_us); - uint32_t calculated_sleep_us = (uint32_t)(sleep_symbols * symbol_us); - - // 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; - calculated_sleep_us = RX_POWERSAVING_CONTINUOUS_FALLBACK_US; - } - - *rx_us = calculated_rx_us; - *sleep_us = calculated_sleep_us; - return true; +inline uint32_t ceilRxPowerSavingValue(float value) { + uint32_t rounded = (uint32_t)value; + return value > (float)rounded ? rounded + 1 : rounded; } -// 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(). +// SX126x programs its duty cycle in 15.625 us ticks and truncates the +// microsecond value on the way in, so a period of 10013 us is really 10000 us +// on air while the CLI happily reports 10013. Snapping to the nearest tick and +// returning the smallest microsecond value that still lands on it makes the +// reported periods the ones the hardware runs - which matters here, because the +// register value turned out to be what a duty-cycle defect keys on, not the +// microseconds we asked for. +inline uint32_t rxPowerSavingTickToUs(uint64_t tick) { + if (tick == 0) tick = 1; + return (uint32_t)((tick * 125 + 7) / 8); // ceil(tick * 15.625) +} + +inline uint32_t snapRxPowerSavingToTick(uint32_t us) { + return rxPowerSavingTickToUs(((uint64_t)us * 8 + 62) / 125); // nearest +} + +// Rounding the listen window to the *nearest* tick can shave a few microseconds +// off, and the timer guard below has no room to give: it must hold with the +// register values the radio actually runs. So that one rounds up. +inline uint32_t snapRxPowerSavingUpToTick(uint32_t us) { + return rxPowerSavingTickToUs(((uint64_t)us * 8 + 124) / 125); // ceil +} + +inline float rxPowerSavingLevelCatch(uint8_t level, uint8_t preamble) { + if (level < 1 || level > RX_POWERSAVING_GUARDED_LEVELS) return 0.0f; + return preamble >= 32 ? RX_POWERSAVING_LEVEL_CATCH_P32[level - 1] + : RX_POWERSAVING_LEVEL_CATCH_P16[level - 1]; +} + +inline bool isRxPowerSavingOverdriveLevel(uint8_t level) { + return level == RX_POWERSAVING_OVERDRIVE_LEVEL; +} + +inline bool isRxPowerSavingRiskyWorkingMaxLevel(uint8_t level) { + return level == RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; +} + +// Both levels past the guarded scale run outside the datasheet's timer +// condition. Worth surfacing wherever a level is reported. +inline bool isRxPowerSavingUnguardedLevel(uint8_t level) { + return level >= RX_POWERSAVING_OVERDRIVE_LEVEL; +} + +// rxPeriod register values measured to break SetRxDutyCycle on SX1262: the +// radio latches the preamble and then never validates a header, losing 35% of +// packets at SF6 and all of them at SF7. Both were reproduced on two boards +// with an LR1110 witnessing 100% of the same transmissions, and both bands are +// exactly one tick wide - 639 and 641 are lossless. +// +// This list is certainly incomplete: about thirty values were sampled out of +// the thousands a profile can generate. Levels 1-8 do not need it because the +// timer guard makes any value safe; it exists only for the unguarded maximum, +// where it is the one protection left. +static const uint32_t RX_POWERSAVING_BAD_RX_TICKS[] = {320, 640}; + +inline uint32_t avoidBadRxPowerSavingTick(uint32_t rx_us) { + const uint32_t tick = (rx_us * 8) / 125; + for (uint32_t bad : RX_POWERSAVING_BAD_RX_TICKS) { + // One tick longer costs 15.625 us of listening and steps clear of the band. + if (tick == bad) return (uint32_t)((((uint64_t)tick + 1) * 125 + 7) / 8); + } + return rx_us; +} + +inline bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols = rxPowerSavingDefaultCaptureCost(), + uint32_t transition_us = rxPowerSavingDefaultTransitionUs()) { + if (rx_us == nullptr || sleep_us == nullptr || level < 1 || + level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || sf < 5 || sf > 12 || + !(bw > 0.0f) || bw > 1000000.0f || !isRxPowerSavingPreamble(preamble)) { + return false; + } + const bool unguarded = isRxPowerSavingUnguardedLevel(level); + + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + // Worst case a preamble starts the instant an RX window closes, so the part + // that survives into the next window is `preamble - sleep`. The absolute + // limit is therefore reached when that equals the chip's capture cost; the + // unguarded levels are pinned to geometries measured at or past that point, + // which is why they still need it. + const float sleep_edge_symbols = (float)preamble - capture_cost_symbols; + + float sleep_symbols; + float rx_symbols = 0.0f; // only used by the unguarded levels + if (unguarded) { + // Kept on the old interpolation because that is the geometry the bench + // measured: overdrive is the top of the profile, and `riskyWorkingMax` is + // the point past it where delivery started to fall. + float amount = 1.0f; + if (isRxPowerSavingRiskyWorkingMaxLevel(level)) { + const float virtual_level = preamble == 16 ? RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P16 + : RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P32; + amount = (virtual_level - 1.0f) / 9.0f; + } + const float rx_start_symbols = preamble == 16 ? 12.0f : 16.0f; + const float sleep_start_symbols = preamble == 16 ? 2.0f : 15.0f; + rx_symbols = rx_start_symbols + amount * (8.0f - rx_start_symbols); + sleep_symbols = sleep_start_symbols + amount * (sleep_edge_symbols - sleep_start_symbols); + } else { + // Guarded levels state their catch directly, so the sleep is just what is + // left of the preamble - no capture cost involved. The pad makes the node + // catch a fifth of a symbol more than promised, which is what keeps the + // generated register pairs off the defective ones. + const float catch_symbols = rxPowerSavingLevelCatch(level, preamble); + // A level that undertakes to catch less than this radio needs to latch is + // not usable: say so rather than return a geometry that cannot work. + if (catch_symbols < capture_cost_symbols) return false; + sleep_symbols = (float)preamble - catch_symbols - RX_POWERSAVING_MARGIN_PAD_SYMBOLS; + if (sleep_symbols < 0.0f) sleep_symbols = 0.0f; + } + + *sleep_us = (uint32_t)(sleep_symbols * symbol_us); + + // Below the hardware floor the duty cycle cannot be armed at all: the driver + // returns -708 and the wrapper quietly runs continuous RX, so the node keeps + // reporting power saving while spending full RX current. Just above that the + // SX1262 arms with ERR_NONE and then detects no preambles at all, which is + // worse. Measured floor: 6050-6100 us dead, 6200 us and up lossless. + const uint32_t min_sleep_us = transition_us + RX_POWERSAVING_MIN_SLEEP_MARGIN_US; + if (*sleep_us < min_sleep_us) { + if (!unguarded || (float)min_sleep_us > sleep_edge_symbols * symbol_us) { + return false; + } + *sleep_us = min_sleep_us; + } + *sleep_us = transition_us + snapRxPowerSavingToTick(*sleep_us - transition_us); + + // SetRxDutyCycle timer guard, straight out of the SX1261/2 datasheet: on + // preamble detection the radio restarts its timer with 2*rxPeriod + + // sleepPeriod (register values), and the packet dies if the header does not + // arrive inside it. The datasheet states the requirement as + // Tpreamble + Theader <= 2 * rxPeriod + sleepPeriod + // Measured: when it holds, no period value causes trouble. When it is broken + // the SX1262 usually gets away with it - but not always, and the exceptions + // are a single register tick wide. rxPeriod 640 (exactly 10.000 ms) loses + // 60-65% of packets at SF6 and everything at SF7, on two different boards, + // while 639 and 641 are lossless; rxPeriod 320 behaves the same way. Nothing + // in the register value predicts which ticks misbehave, so satisfy the + // condition instead of dodging the values. The listen window is therefore not + // a free parameter on the guarded scale - it is whatever the condition needs. + const float sync_symbols = + sf <= 6 ? RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF : RX_POWERSAVING_SYNC_SYMBOLS; + const float need_us = + ((float)preamble + sync_symbols + RX_POWERSAVING_HEADER_SYMBOLS + + RX_POWERSAVING_TIMER_SLACK_SYMBOLS) * symbol_us; + // Work in the radio's own units: the driver truncates (sleep - transition) to + // 15.625 us ticks, so the microsecond difference overstates the programmed + // sleep by up to one tick - enough to leave the condition short on every + // level of a profile. + const uint32_t sleep_ticks = ((*sleep_us - transition_us) * 8) / 125; + const float programmed_sleep_us = (float)sleep_ticks * RX_POWERSAVING_TICK_US; + + if (unguarded) { + *rx_us = ceilRxPowerSavingValue(rx_symbols * symbol_us); + } else { + const float min_rx_us = (need_us - programmed_sleep_us) / 2.0f; + *rx_us = ceilRxPowerSavingValue(min_rx_us > 0.0f ? min_rx_us : symbol_us); + } + + *rx_us = snapRxPowerSavingUpToTick(*rx_us); + if (unguarded) *rx_us = avoidBadRxPowerSavingTick(*rx_us); + return isValidRxPowerSavingPeriod(*rx_us) && isValidRxPowerSavingPeriod(*sleep_us); +} + +// Preserve the deployed Cascade wire-preamble convention. The longer choices +// were established with the previous timing model and 6 ms transition budget. +// They remain a network compatibility contract, independent of the new local +// RXPS model, oscillator configuration, or whether RXPS is enabled. 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. + // Fixed boundaries keep every sender compatible and avoid a timing-model + // search in the sender path on small 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; @@ -180,7 +468,7 @@ inline bool calcRxPowerSavingLevelAtOrAbove( uint8_t *effective_level = nullptr, uint8_t *effective_preamble = nullptr) { if (!rx_us || !sleep_us || requested_level < 1 || requested_level > 10 - || sf < 5 || sf > 12 || bw <= 0.0f + || sf < 5 || sf > 12 || !(bw > 0.0f) || bw > 1000000.0f || !isRxPowerSavingPreamble(preamble)) { return false; } @@ -196,19 +484,28 @@ inline bool calcRxPowerSavingLevelAtOrAbove( for (uint8_t pass = 0; pass < pass_count; pass++) { const uint8_t candidate_preamble = pass == 0 ? 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; - const float rx_start_symbols = candidate_preamble == 16 ? 12.0f : 16.0f; - const float sleep_start_symbols = candidate_preamble == 16 ? 2.0f : 15.0f; - const float rx_edge_symbols = 8.0f; - const float sleep_edge_symbols = (float)candidate_preamble + 4.25f - 8.0f; - const float rx_symbols = rx_start_symbols - + amount * (rx_edge_symbols - rx_start_symbols); - const float sleep_symbols = sleep_start_symbols - + amount * (sleep_edge_symbols - sleep_start_symbols); - const uint32_t candidate_rx_us = ceilPositiveFloat(rx_symbols * symbol_us); - const uint32_t candidate_sleep_us = (uint32_t)(sleep_symbols * symbol_us); + // Automatic adjustment never opts an operator into experimental levels. + const uint8_t last_level = requested_level <= RX_POWERSAVING_MAX_LEVEL + ? RX_POWERSAVING_MAX_LEVEL : requested_level; + for (uint8_t level = requested_level; level <= last_level; level++) { + uint32_t candidate_rx_us = 0, candidate_sleep_us = 0; + if (!calcRxPowerSavingLevel(level, sf, bw, candidate_preamble, + &candidate_rx_us, &candidate_sleep_us, + rxPowerSavingDefaultCaptureCost(), transition_us)) continue; + + // The catch assumption can be conservative, but the receive timeout + // must still cover the entire preamble our senders actually transmit. + if (!isRxPowerSavingUnguardedLevel(level)) { + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float sync = sf <= 6 ? RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF + : RX_POWERSAVING_SYNC_SYMBOLS; + const float need = (transmitted_preamble + sync + + RX_POWERSAVING_HEADER_SYMBOLS + RX_POWERSAVING_TIMER_SLACK_SYMBOLS) * symbol_us; + const uint32_t sleep_ticks = ((candidate_sleep_us - transition_us) * 8UL) / 125UL; + const uint32_t guarded_rx = snapRxPowerSavingUpToTick(ceilRxPowerSavingValue( + (need - sleep_ticks * RX_POWERSAVING_TICK_US) / 2.0f)); + if (guarded_rx > candidate_rx_us) candidate_rx_us = guarded_rx; + } if (isValidRxPowerSavingPeriod(candidate_rx_us) && isValidRxPowerSavingPeriod(candidate_sleep_us) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 899649c8..6eb27d8c 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -8,14 +8,11 @@ #define STATE_TX_DONE 4 #define STATE_INT_READY 16 -#define NUM_NOISE_FLOOR_SAMPLES 64 -#define SAMPLING_THRESHOLD 14 - -// On-demand noise-floor calibration windows (RX duty-cycle powersaving only). +// Bounded noise-floor calibration in continuous RX, including RXPS refreshes. // Requests are coalesced so retries cannot repeatedly force continuous RX. #define NF_CALIB_INTERVAL_MS 2000UL // match the original 2-second refresh cadence -#define NF_CALIB_TIMEOUT_MS 5000UL // give up on the batch (busy channel) -#define NF_CONTINUOUS_TIMEOUT_MS 1000UL // bound awake time without RX powersaving +#define NF_CALIB_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS +#define NF_CONTINUOUS_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS #define NF_CALIB_SETTLE_MS 20UL // frontend/AGC settle after RX entry static volatile uint8_t state = STATE_IDLE; @@ -66,13 +63,14 @@ void RadioLibWrapper::begin() { _noise_floor_valid = false; _threshold = 0; _cad_enabled = false; + _rx_mode_checked_at = millis(); + _rx_mode_failures = 0; _last_rssi = 0; _last_snr = 0; _rx_hold_continuous = false; // start average out some samples - _num_floor_samples = 0; - _floor_sample_sum_centi_dbm = 0; + _floor_estimator.reset(true); _nf_calib_active = false; _nf_last_calib = 0; _nf_sample_from = 0; @@ -226,14 +224,12 @@ void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { } void RadioLibWrapper::recalibrateNoiseFloor() { - // A receive-gain change can move the real floor above the old floor's - // sampling gate. Invalidate that reference so the next batch is seeded from - // scratch instead of rejecting the new baseline as interference. + // A gain or tuning change starts a new baseline. Keep the published value + // available while discarding the old samples and rise-hold history. _noise_floor_valid = false; _nf_refresh_requested = true; _nf_last_calib = 0; - _num_floor_samples = 0; - _floor_sample_sum_centi_dbm = 0; + _floor_estimator.reset(true); const unsigned long now = millis(); _nf_sample_from = now + NF_CALIB_SETTLE_MS; @@ -251,8 +247,7 @@ void RadioLibWrapper::recalibrateNoiseFloor() { void RadioLibWrapper::requestNoiseFloorRefresh() { if (_nf_refresh_requested) return; _nf_refresh_requested = true; - _num_floor_samples = 0; - _floor_sample_sum_centi_dbm = 0; + _floor_estimator.reset(); _nf_calib_deadline = 0; // starts when continuous RX is actually available } @@ -268,10 +263,12 @@ void RadioLibWrapper::resetAGC() { state = STATE_IDLE; // trigger a startReceive() if (_rx_boosted_gain_valid) applyRxBoostedGainMode(_cur_rx_boosted_gain); - // Preserve the last published value while a fresh post-reset batch is - // collected. Invalidating the sample gate lets the new floor converge even - // when the old value was stuck at its lower clamp. + // AGC reset discards stale samples, but the established floor remains usable + // for LBT while the next spaced block is collected. Gain/tuning changes use + // recalibrateNoiseFloor() directly to seed a fresh baseline. + const bool previous_valid = _noise_floor_valid; recalibrateNoiseFloor(); + _noise_floor_valid = previous_valid; } bool RadioLibWrapper::recoverRadio(bool hard) { @@ -407,9 +404,9 @@ void RadioLibWrapper::noiseFloorCalibCheck(unsigned long now) { if (_nf_calib_active) { if (!_rx_ps_enabled || ((long)(now - _nf_calib_deadline) >= 0 - && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES)) { + && !_floor_estimator.complete())) { // powersaving turned off mid-window, or the batch couldn't complete - // (busy channel / stuck filter) - keep the previous floor + // (busy channel) - keep the previous floor endNoiseFloorCalib(now); } else if (_rx_ps_armed && !isPacketPendingOrReceiving()) { // A packet can delay the transition from RXPS to continuous RX. Retry @@ -425,8 +422,7 @@ void RadioLibWrapper::noiseFloorCalibCheck(unsigned long now) { _nf_calib_active = true; _nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS; _nf_sample_from = now + NF_CALIB_SETTLE_MS; - _num_floor_samples = 0; // start a fresh batch for this window - _floor_sample_sum_centi_dbm = 0; + _floor_estimator.reset(); if (!isPacketPendingOrReceiving()) { requestRestartRecv(); // startReceiveMode() selects continuous RX } @@ -447,11 +443,39 @@ void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) { } } +void RadioLibWrapper::checkReceiveMode(uint32_t now) { + // Duty-cycle standby/sleep is expected. Never wake it for a status probe, + // touch an in-flight TX, or discard a completed/pending receive interrupt. + if (state != STATE_RX || _rx_ps_armed || isChipBusy() + || uint32_t(now - _rx_mode_checked_at) < 10000UL) return; + _rx_mode_checked_at = now; + if (isPacketPendingOrReceiving()) return; + const int8_t mode = readReceiveMode(); + if (isPacketPendingOrReceiving()) return; + if (mode < 0) return; // an SPI error is not evidence of a particular mode + if (mode == 1) { + _rx_mode_failures = 0; + return; + } + if (_rx_mode_failures < 3) ++_rx_mode_failures; + if (_rx_mode_failures < 2) return; + + // Confirmed silent loss of RX (including a late RTC event after CAD). + // Reuse the existing recovery path: it preserves tuning, TX power and gain. + // A healthy status observation, rather than an accepted re-arm command, + // clears the streak. A failed soft recovery can therefore reach hard reset. + _floor_estimator.reset(true); + MESH_DEBUG_PRINTLN("RadioLibWrapper: chip left RX, %s recovery", + _rx_mode_failures < 3 ? "soft" : "hard"); + recoverRadio(_rx_mode_failures >= 3); +} + void RadioLibWrapper::loop() { if (_rx_ps_enabled && !_rx_ps_continuous_fallback) { rxPsWatchdogCheck(); } unsigned long now = millis(); + checkReceiveMode(static_cast(now)); if (_nf_calib_active || _nf_refresh_requested) { noiseFloorCalibCheck(now); } @@ -463,31 +487,12 @@ void RadioLibWrapper::loop() { // or a request made while the radio is idle, transmitting, or starting up. _nf_calib_deadline = now + NF_CONTINUOUS_TIMEOUT_MS; } - if (_nf_refresh_requested && _num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES - && _floor_sample_sum_centi_dbm != 0) { - int32_t sampled_floor_centi_dbm = - _floor_sample_sum_centi_dbm / NUM_NOISE_FLOOR_SAMPLES; - if (sampled_floor_centi_dbm < -12000) { - sampled_floor_centi_dbm = -12000; + if (_nf_refresh_requested && _floor_estimator.complete()) { + if (_floor_estimator.publish(_noise_floor_centi_dbm, _noise_floor_valid)) { + _noise_floor_valid = true; + // Retain the whole-dB API/wire format and the fractional display value. + _noise_floor = (_noise_floor_centi_dbm - 50) / 100; } - if (_noise_floor_valid) { - // Favor the fresh high-rate batch while retaining a small amount of - // history: 25% previous floor + 75% newly sampled floor. - int32_t weighted_floor = _noise_floor_centi_dbm - + 3L * sampled_floor_centi_dbm; - _noise_floor_centi_dbm = weighted_floor < 0 - ? (weighted_floor - 2) / 4 - : (weighted_floor + 2) / 4; - } else { - _noise_floor_centi_dbm = sampled_floor_centi_dbm; - } - // Preserve the existing whole-dB API and wire formats while exposing the - // fractional 64-sample mean to local displays. - _noise_floor = _noise_floor_centi_dbm < 0 - ? (_noise_floor_centi_dbm - 50) / 100 - : (_noise_floor_centi_dbm + 50) / 100; - _floor_sample_sum_centi_dbm = 0; - _noise_floor_valid = true; _nf_refresh_requested = false; _nf_last_calib = now; _nf_calib_deadline = 0; @@ -495,7 +500,7 @@ void RadioLibWrapper::loop() { MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); if (_nf_calib_active) { - endNoiseFloorCalib(now); // fresh floor published - back to duty cycle + endNoiseFloorCalib(now); // complete block, including a held rise - back to duty cycle } return; } @@ -510,30 +515,23 @@ void RadioLibWrapper::loop() { _nf_refresh_requested = false; _nf_last_calib = now; _nf_calib_deadline = 0; - _num_floor_samples = 0; - _floor_sample_sum_centi_dbm = 0; + _floor_estimator.reset(); } if (_nf_refresh_requested && state == STATE_RX - && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { + && !_floor_estimator.complete()) { // Noise floor is only sampled outside RX duty-cycle mode: continuously in // plain RX (powersaving off), or inside an on-demand calibration window // (powersaving on). Skip the first moments after entering RX or changing // gain while the frontend/AGC settles (unsettled GetRssiInst reads // ~-127 dBm garbage). if (!_rx_ps_armed + && _floor_estimator.ready(static_cast(now)) && !(_nf_sample_from != 0 && (long)(now - _nf_sample_from) < 0) && !isReceivingPacket()) { - float rssi = getCurrentRSSI(); - if (!_noise_floor_valid - || rssi < getNoiseFloorDbm() + SAMPLING_THRESHOLD) { - // With no valid baseline (startup, AGC reset, or gain change), seed - // unconditionally. Otherwise reject likely traffic above the current - // floor plus the sampling margin. - _num_floor_samples++; - _floor_sample_sum_centi_dbm += - static_cast(rssi * 100.0f); - } + // Admission is independent of the old floor, allowing upward recovery. + // The estimator enforces 50 ms spacing and rejects invalid radio readings. + _floor_estimator.add(getCurrentRSSI(), static_cast(now)); } } } @@ -541,7 +539,11 @@ void RadioLibWrapper::loop() { void RadioLibWrapper::startRecv() { int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { - state = STATE_RX; + // A very short frame may complete while startReceiveMode() returns. + // Retain that RX interrupt instead of overwriting it with software state. + noInterrupts(); + state = (state & STATE_INT_READY) | STATE_RX; + interrupts(); _startrx_fails = 0; if (_rx_ps_armed) { // (re)base the duty-cycle watchdog on the freshly armed cycle @@ -770,6 +772,7 @@ int16_t RadioLibWrapper::performChannelScanWithTimeout(unsigned long timeout_ms) } bool RadioLibWrapper::isChannelActive() { + if (isPacketPendingOrReceiving() || (state & ~STATE_INT_READY) == STATE_TX_WAIT) return true; // int.thresh: RSSI-based interference detection (relative to noise floor). // In RX duty-cycle mode only checked while the chip is in a listen window // (during the sleep window the frontend is off and the read would stall). @@ -789,7 +792,8 @@ bool RadioLibWrapper::isChannelActive() { // try to read a non-existent packet and count a spurious recv error. state = STATE_IDLE; startRecv(); - if (result != RADIOLIB_CHANNEL_FREE) { + if (result != RADIOLIB_CHANNEL_FREE || (state & ~STATE_INT_READY) != STATE_RX + || isPacketPendingOrReceiving()) { _board->n_cad_busy++; return true; } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index a4f771e3..42cd542b 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -1,5 +1,7 @@ #pragma once +#include "NoiseFloorEstimator.h" + #include #include #include "CadTiming.h" @@ -36,8 +38,9 @@ protected: uint32_t _cad_scan_timeout_override_ms; bool _noise_floor_valid; bool _nf_refresh_requested; - uint16_t _num_floor_samples; - int32_t _floor_sample_sum_centi_dbm; + NoiseFloorEstimator _floor_estimator; + uint32_t _rx_mode_checked_at = 0; + uint8_t _rx_mode_failures = 0; unsigned long last_recv_millis; unsigned long last_radio_interrupt_millis; // updated on any ISR event, even CRC errors bool _rx_ps_enabled; @@ -141,6 +144,8 @@ protected: bool restoreAfterDeepInit(); float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; + virtual int8_t readReceiveMode() { return -1; } + void checkReceiveMode(uint32_t now); virtual void doResetAGC(); public: @@ -244,7 +249,7 @@ public: // the app's hasPendingWork() to keep the MCU out of light sleep for the window bool isWatchdogObserving() const { return _wd_observe_until != 0; } // true while a noise-floor batch needs prompt loop service; the app's - // hasPendingWork() keeps the MCU awake only for this short sample burst. + // hasPendingWork() keeps the MCU awake for the bounded spaced-sample window. bool isCalibratingNoiseFloor() const { return _nf_calib_active || (_nf_refresh_requested diff --git a/src/helpers/radiolib/SX126xReceiveMode.h b/src/helpers/radiolib/SX126xReceiveMode.h new file mode 100644 index 00000000..706ff773 --- /dev/null +++ b/src/helpers/radiolib/SX126xReceiveMode.h @@ -0,0 +1,25 @@ +#pragma once +#include + +// -1: no reliable observation, 0: confirmed non-RX mode, 1: RX. +// GetStatus is C0 followed by one response byte (Semtech sx126x driver). +// RadioLib's pinned getStatus() requests zero payload bytes and returns zero. +template +inline int8_t sx126xReceiveMode(Radio* radio) { + Module* module = radio->getMod(); + const uint32_t busy = module->getGpio(); + if (busy != RADIOLIB_NC && module->hal->digitalRead(busy)) return -1; + + // GetStatus has no separate status-prefix byte. Use the normal SPI transport + // with that command's layout, then restore the layout used by other commands. + const Module::BitWidth_t status_width = module->spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_STATUS]; + module->spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_STATUS] = Module::BITS_0; + uint8_t status = 0; + const int16_t result = module->SPIreadStream( + RADIOLIB_SX126X_CMD_GET_STATUS, &status, 1, false, false); + module->spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_STATUS] = status_width; + if (result != RADIOLIB_ERR_NONE || status == 0 || status == 0xFF) return -1; + const uint8_t mode = (status >> 4) & 7; + if (mode < 2 || mode > 6) return -1; + return mode == 5 ? 1 : 0; +} diff --git a/test/README.md b/test/README.md index 7beba4ea..e26c60fe 100644 --- a/test/README.md +++ b/test/README.md @@ -13,6 +13,8 @@ require integration or target testing; see "Local testing without hardware" in pio test -e native # all suites except KISS modem pio test -e native_kiss_modem # KISS modem suite pio test -e native -f test_webconfig_keys # a single suite +python3 test/test_radio_receive_contract.py # real CAD/re-arm/calibration/mode-watchdog methods +python3 test/test_sx126x_receive_mode.py # pinned RadioLib SPI transport/status-byte regression python3 test/test_firmware_ram.py # every firmware hook, ELF heaps, reservations, RAM report binding python3 test/test_indicator_display_profile.py # Indicator RAM/scale contract python3 test/test_indicator_render_profile.py # four-mode Indicator canvas matrix/fallback contract @@ -136,7 +138,8 @@ 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, 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_rx_power_saving` | `src/helpers/radiolib/RXPowerSaving.h` | guarded timer/capture bounds across SF5-SF12, bandwidths, TCXO delays and wire preambles; experimental profiles; retuning and strict CLI parsing | +| `test_noise_floor_estimator` | `src/helpers/radiolib/NoiseFloorEstimator.h` | spaced median, weighting, outliers, persistent rises, timeout/reset semantics and clock rollover | | `test_region_names` | `src/helpers/RegionNameUtils.h` | canonical public-region markers while preserving distinct private and differently named regions | | `test_datagram_payload_limits` | `src/helpers/DatagramPayloadLimits.h` | encrypted datagram plaintext ceilings, including the anonymous region-reply prefix and worst-case cipher padding | | `test_serial_packet_log` | `src/helpers/SerialPacketLog.h` | bounded USB packet logging and dropped-line reporting | diff --git a/test/test_noise_floor_estimator/test_noise_floor_estimator.cpp b/test/test_noise_floor_estimator/test_noise_floor_estimator.cpp new file mode 100644 index 00000000..b62f387f --- /dev/null +++ b/test/test_noise_floor_estimator/test_noise_floor_estimator.cpp @@ -0,0 +1,114 @@ +#include +#include + +static void fill(NoiseFloorEstimator& estimator, float dbm, uint32_t start = 0) { + for (unsigned i = 0; i < 64; ++i) ASSERT_TRUE(estimator.add(dbm, start + i * 50)); +} + +TEST(NoiseFloorEstimator, RequiresACompleteSpacedBlock) { + NoiseFloorEstimator n; + int32_t floor = -11000; + for (uint32_t t = 0; t < 1000; ++t) n.add(-100, t); + EXPECT_EQ(20, n.count()); + EXPECT_FALSE(n.publish(floor, true)); + EXPECT_EQ(-11000, floor); + for (uint32_t t = 1000; t <= 3150; ++t) n.add(-100, t); + EXPECT_TRUE(n.complete()); + EXPECT_TRUE(n.publish(floor, true)); + EXPECT_EQ(-10250, floor); + EXPECT_GT(NoiseFloorEstimator::WINDOW_TIMEOUT_MS, 3150U); +} + +TEST(NoiseFloorEstimator, MedianRejectsMinorityOutliersAndKeepsFractionalDbm) { + NoiseFloorEstimator n; + int32_t floor = 0; + for (unsigned i = 0; i < 64; ++i) { + ASSERT_TRUE(n.add(i < 15 ? -30 : i > 48 ? -127 : -103.25f, i * 50)); + } + EXPECT_TRUE(n.publish(floor, false)); + EXPECT_EQ(-10325, floor); +} + +TEST(NoiseFloorEstimator, HandlesEvenMedianAndClampsAtMinus120) { + NoiseFloorEstimator n; + int32_t floor = 0; + for (unsigned i = 0; i < 64; ++i) n.add(i < 32 ? -105.5f : -104.5f, i * 50); + ASSERT_TRUE(n.publish(floor, false)); + EXPECT_EQ(-10500, floor); + n.reset(); fill(n, -127); + ASSERT_TRUE(n.publish(floor, false)); + EXPECT_EQ(-12000, floor); +} + +TEST(NoiseFloorEstimator, PersistentRiseEscapesTheOldAdmissionGate) { + NoiseFloorEstimator n; + int32_t floor = -12000; + for (unsigned block = 1; block <= 3; ++block) { + n.reset(); fill(n, -100); + EXPECT_EQ(block == 3, n.publish(floor, true)); + EXPECT_EQ(block == 3 ? -10500 : -12000, floor); + } + n.reset(); fill(n, -100); + ASSERT_TRUE(n.publish(floor, true)); + EXPECT_EQ(-10125, floor); +} + +TEST(NoiseFloorEstimator, QuieterBlockBreaksTheConsecutiveRiseHold) { + NoiseFloorEstimator n; + int32_t floor = -10000; + fill(n, -70); EXPECT_FALSE(n.publish(floor, true)); + n.reset(); fill(n, -110); EXPECT_TRUE(n.publish(floor, true)); + EXPECT_EQ(-10750, floor); + n.reset(); fill(n, -70); EXPECT_FALSE(n.publish(floor, true)); + EXPECT_EQ(1, n.heldBlocks()); +} + +TEST(NoiseFloorEstimator, PartialTimeoutDoesNotCountAsACompleteHeldBlock) { + NoiseFloorEstimator n; + int32_t floor = -12000; + fill(n, -100); EXPECT_FALSE(n.publish(floor, true)); + n.reset(); n.add(-40, 10000); + EXPECT_FALSE(n.publish(floor, true)); + n.reset(); fill(n, -100); EXPECT_FALSE(n.publish(floor, true)); + EXPECT_EQ(-12000, floor); + n.reset(); fill(n, -100); EXPECT_TRUE(n.publish(floor, true)); +} + +TEST(NoiseFloorEstimator, GainResetDiscardsSamplesAndRiseHistory) { + NoiseFloorEstimator n; + int32_t floor = -12000; + fill(n, -100); EXPECT_FALSE(n.publish(floor, true)); + n.reset(true); + EXPECT_EQ(0, n.count()); + EXPECT_EQ(0, n.heldBlocks()); + EXPECT_EQ(-12000, floor); + fill(n, -80); EXPECT_TRUE(n.publish(floor, false)); + EXPECT_EQ(-8000, floor); +} + +TEST(NoiseFloorEstimator, SpacingSurvivesMillisRolloverAndRejectsInvalidReads) { + NoiseFloorEstimator n; + EXPECT_FALSE(n.add(NAN, 0)); + EXPECT_FALSE(n.add(INFINITY, 0)); + EXPECT_FALSE(n.add(0, 0)); + EXPECT_FALSE(n.add(-201, 0)); + EXPECT_TRUE(n.add(-105, 0xFFFFFFF0U)); + EXPECT_FALSE(n.add(-105, 0x20)); + EXPECT_TRUE(n.add(-105, 0x22)); +} + +TEST(NoiseFloorEstimator, MajorityContaminationIsAnExplicitLimit) { + NoiseFloorEstimator n; + int32_t floor = -10500; + for (unsigned block = 0; block < 3; ++block) { + n.reset(); + for (unsigned i = 0; i < 64; ++i) n.add(i < 48 ? -60 : -105, i * 50); + EXPECT_EQ(block == 2, n.publish(floor, true)); + } + EXPECT_EQ(-7125, floor); // a median cannot distinguish sustained energy from noise +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py new file mode 100644 index 00000000..fd4122c9 --- /dev/null +++ b/test/test_radio_receive_contract.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Execute production CAD, receive re-arm, calibration and mode-watchdog methods.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +def method(text, signature): + start = text.index(signature) + end = text.index('{', start) + 1 + depth = 1 + while depth: + depth += (text[end] == '{') - (text[end] == '}') + end += 1 + return text[start:end] + +HARNESS = r''' +#include +#include +#include +#include +#define STATE_IDLE 0 +#define STATE_RX 1 +#define STATE_TX_WAIT 3 +#define STATE_INT_READY 16 +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_CHANNEL_FREE -15 +#define RADIOLIB_ERR_UNKNOWN -16 +#define RADIOLIB_ERR_RX_TIMEOUT -6 +#define MAX_TRANS_UNIT 255 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define NF_CALIB_INTERVAL_MS 2000UL +#define NF_CALIB_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS +#define NF_CONTINUOUS_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS +#define NF_CALIB_SETTLE_MS 20UL +static volatile uint8_t state = STATE_RX; +static uint32_t now_ms = 0; +uint32_t millis() { return now_ms; } +void noInterrupts() {} +void interrupts() {} +void yield() { ++now_ms; } +struct Board { + unsigned n_cad_busy = 0, services = 0; + void serviceWatchdog() { ++services; } +}; +struct Radio { + int scan_result = RADIOLIB_CHANNEL_FREE, start_result = 0; + unsigned scans = 0; + int startChannelScan() { ++scans; return start_result; } + int getChannelScanResult() { + if (scan_result != RADIOLIB_ERR_UNKNOWN) state |= STATE_INT_READY; + return scan_result; + } +}; +struct RadioLibWrapper { + Board board; Board* _board = &board; + Radio radio; Radio* _radio = &radio; + bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_ps_continuous_fallback = false; + bool _nf_calib_active = false, _nf_refresh_requested = true, _noise_floor_valid = true; + bool _cad_enabled = true, packet = false, busy = false, inject_arm_irq = false; + bool _rx_boosted_gain_valid = false, _cur_rx_boosted_gain = false; + bool _wd_last_busy = false; + uint8_t _startrx_fails = 0, _rx_mode_failures = 0; + uint32_t _rx_mode_checked_at = 0, _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000; + unsigned long _nf_last_calib = 0, _nf_calib_deadline = 0, _nf_sample_from = 0; + unsigned long _wd_last_transition = 0, _wd_stuck_thresh = 0, _wd_observe_ms = 0; + int16_t _threshold = 0, _noise_floor = -105; + int32_t _noise_floor_centi_dbm = -10500; + int arm_result = 0, chip_mode = 1; + float rssi = -100; + unsigned arms = 0, stops = 0, soft = 0, hard = 0, reads = 0; + NoiseFloorEstimator _floor_estimator; + RadioLibWrapper() { state = STATE_RX; now_ms = 100; } + bool isChipBusy() { return busy; } + bool isReceivingPacket() { return packet; } + float getCurrentRSSI() { ++reads; return rssi; } + int8_t readReceiveMode() { return chip_mode; } + bool recoverRadio(bool use_hard) { use_hard ? ++hard : ++soft; return true; } + void rxPsWatchdogCheck() {} + unsigned long getEstAirtimeFor(int) { return 10; } + void stopReceiveDutyCycle() { ++stops; _rx_ps_armed = false; } + void doResetAGC() {} + bool applyRxBoostedGainMode(bool) { return true; } + int startReceiveMode() { + ++arms; + if (!arm_result) _rx_ps_armed = _rx_ps_enabled && !_nf_calib_active; + if (inject_arm_irq) state |= STATE_INT_READY; + return arm_result; + } + int16_t performChannelScan() { return performChannelScanWithTimeout(2500); } + int16_t performChannelScanWithTimeout(unsigned long); + bool isPacketPendingOrReceiving(); + bool isChannelActive(); + void startRecv(); + void checkReceiveMode(uint32_t); + void loop(); + void requestRestartRecv(); + void noiseFloorCalibCheck(unsigned long); + void endNoiseFloorCalib(unsigned long); + void requestNoiseFloorRefresh(); + void recalibrateNoiseFloor(); + void resetAGC(); +}; +@METHODS@ + +int main() { + // A completed packet, current frame, or TX owns the radio; CAD cannot erase it. + for (int owned : {1, 2, 3}) { + RadioLibWrapper w; + if (owned == 1) state |= STATE_INT_READY; + if (owned == 2) w.packet = true; + if (owned == 3) state = STATE_TX_WAIT; + assert(w.isChannelActive()); + assert(w.radio.scans == 0 && w.arms == 0); + } + for (int result : {RADIOLIB_CHANNEL_FREE, 1, -5, RADIOLIB_ERR_UNKNOWN}) { + for (bool ps : {false, true}) { + RadioLibWrapper w; + w._rx_ps_enabled = w._rx_ps_armed = ps; + w.radio.scan_result = result; + const bool active = w.isChannelActive(); + assert(active == (result != RADIOLIB_CHANNEL_FREE)); + assert(w.stops == unsigned(ps)); + assert(w.arms == 1 && state == STATE_RX); + assert(w._rx_ps_armed == ps); + if (result == RADIOLIB_ERR_UNKNOWN) { + assert(now_ms == 2600 && w.board.services == 2); + } + } + } + { + RadioLibWrapper w; + w.radio.start_result = -5; + assert(w.isChannelActive()); + assert(w.arms == 1 && state == STATE_RX); + } + { + RadioLibWrapper w; + w.arm_result = -5; + assert(w.isChannelActive()); // a free CAD with failed RX restoration is not success + assert(state == STATE_IDLE); + } + { + RadioLibWrapper w; + w.inject_arm_irq = true; + assert(w.isChannelActive()); + assert(state == (STATE_RX | STATE_INT_READY)); // preserves the new packet + } + // A quiet ten-second window completes in ~3.2 s and powersaving resumes. + for (bool ps : {false, true}) { + RadioLibWrapper w; + w._rx_ps_enabled = w._rx_ps_armed = ps; + w._noise_floor_valid = false; + w._nf_last_calib = 0; + for (; now_ms < 4000; ++now_ms) { + w.loop(); + if (state == STATE_IDLE) w.startRecv(); + } + assert(!w._nf_refresh_requested && !w._nf_calib_active); + assert(w._noise_floor_valid && w._noise_floor_centi_dbm == -10000); + assert(w._rx_ps_armed == ps); + assert(w.reads == 64); // no unnecessary SPI RSSI reads between accepted samples + } + // Repeated scheduled requests must not reset a partial block. + { + RadioLibWrapper w; + for (; now_ms < 4100; ++now_ms) { + if (now_ms % 2000 == 0) w.requestNoiseFloorRefresh(); + w.loop(); + } + assert(w._noise_floor_centi_dbm == -10125); + } + // Busy continuous RX times out without changing the published floor. + { + RadioLibWrapper w; + w.packet = true; + for (; now_ms <= 10101; ++now_ms) w.loop(); + assert(!w._nf_refresh_requested); + assert(w._noise_floor_centi_dbm == -10500); + assert(w.reads == 0); + } + { + RadioLibWrapper w; + w._floor_estimator.add(-30, 100); + w.resetAGC(); + assert(w._noise_floor_valid && w._noise_floor_centi_dbm == -10500); + assert(w._floor_estimator.count() == 0); + } + // Unknown/SPI-busy/sleep and packet ownership cannot trigger false recovery. + for (int skip : {0,1,2,3,4}) { + RadioLibWrapper w; + w.chip_mode = 0; + if (skip == 0) w.chip_mode = -1; + if (skip == 1) w.busy = true; + if (skip == 2) w._rx_ps_armed = true; + if (skip == 3) w.packet = true; + if (skip == 4) state |= STATE_INT_READY; + for (uint32_t t : {10000U,20000U,30000U}) w.checkReceiveMode(t); + assert(w.soft == 0 && w.hard == 0); + } + { + RadioLibWrapper w; + w.chip_mode = 0; + w.checkReceiveMode(10000); assert(w.soft == 0); + w.checkReceiveMode(20000); assert(w.soft == 1); + w.checkReceiveMode(30000); assert(w.hard == 1); + w.chip_mode = 1; w.checkReceiveMode(40000); + w.chip_mode = 0; w.checkReceiveMode(50000); + assert(w.soft == 1 && w.hard == 1); + } + { + RadioLibWrapper w; + w._rx_mode_checked_at = 0xFFFFF000U; + w.checkReceiveMode(0x10); assert(w._rx_mode_checked_at == 0xFFFFF000U); + w.checkReceiveMode(0x2000); assert(w._rx_mode_checked_at == 0x2000); + } +} +''' + +class RadioReceiveContractTest(unittest.TestCase): + def test_production_transitions(self): + source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text() + names = [('int16_t','performChannelScanWithTimeout'), ('bool','isPacketPendingOrReceiving'), + ('bool','isChannelActive'), ('void','startRecv'), ('void','checkReceiveMode'), + ('void','loop'), ('void','requestRestartRecv'), ('void','noiseFloorCalibCheck'), + ('void','endNoiseFloorCalib'), ('void','requestNoiseFloorRefresh'), + ('void','recalibrateNoiseFloor'), ('void','resetAGC')] + methods = '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(') for kind,name in names) + with tempfile.TemporaryDirectory(prefix='meshcore-radio-transitions-') as tmp: + cpp = Path(tmp) / 'test.cpp' + binary = Path(tmp) / 'test' + cpp.write_text(HARNESS.replace('@METHODS@', methods)) + result = subprocess.run([os.environ.get('CXX','c++'), '-std=c++17', '-O1', + '-Wall', '-Wextra', '-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + +if __name__ == '__main__': + unittest.main() 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 f0a6a42c..969f8b98 100644 --- a/test/test_rx_power_saving/test_rx_power_saving.cpp +++ b/test/test_rx_power_saving/test_rx_power_saving.cpp @@ -1,309 +1,149 @@ #include - +#include #include +#include -TEST(RxPowerSaving, BalancedNormalProfileKeepsDerivedDutyCycle) { - uint32_t rx_us = 0; - uint32_t sleep_us = 0; - - ASSERT_TRUE(calcRxPowerSavingLevel(5, 7, 62.5f, 16, &rx_us, &sleep_us)); - EXPECT_EQ(20936U, rx_us); - EXPECT_EQ(13425U, sleep_us); - EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); +TEST(RxPowerSaving, WirePreamblesStayCompatibleRegardlessOfLocalPowerSaving) { + EXPECT_EQ(32U, rxPowerSavingPreambleForParams(7, 500)); + EXPECT_EQ(32U, rxPowerSavingPreambleForParams(6, 250)); + EXPECT_EQ(32U, rxPowerSavingPreambleForParams(5, 125)); + EXPECT_EQ(64U, rxPowerSavingPreambleForParams(6, 500)); + EXPECT_EQ(64U, rxPowerSavingPreambleForParams(5, 250)); + EXPECT_EQ(128U, rxPowerSavingPreambleForParams(5, 500)); + EXPECT_EQ(16U, rxPowerSavingPreambleForParams(9, 500)); } -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, - &effective_level, - &effective_preamble)); - EXPECT_EQ(5U, effective_level); - EXPECT_EQ(128U, effective_preamble); - EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); +TEST(RxPowerSaving, GuardedProfilesCoverActualWireHeaderAndPromisedCatch) { + for (float bw : {7.8f, 15.6f, 31.25f, 62.5f, 125.0f, 250.0f, 500.0f}) { + for (uint8_t sf = 5; sf <= 12; ++sf) { + for (uint32_t transition : {1000U, 2600U, 6000U}) { + for (uint8_t requested = 1; requested <= 8; ++requested) { + for (uint8_t assumption : {16, 32, 64, 128}) { + SCOPED_TRACE(::testing::Message() << "sf=" << int(sf) << " bw=" << bw + << " level=" << int(requested) << " preamble=" << int(assumption) + << " transition=" << transition); + uint32_t rx, sleep; + uint8_t level, preamble; + ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove(requested, sf, bw, assumption, + transition, &rx, &sleep, &level, &preamble)); + if (rxPowerSavingUsesContinuousFallback(rx, sleep)) { + EXPECT_EQ(0, level); + EXPECT_EQ(0, preamble); + continue; + } + EXPECT_GE(level, requested); + EXPECT_LE(level, RX_POWERSAVING_MAX_LEVEL); + EXPECT_LE(preamble, rxPowerSavingPreambleForParams(sf, bw)); + EXPECT_GE(sleep, transition + RX_POWERSAVING_MIN_SLEEP_MARGIN_US); + EXPECT_TRUE(canStartRxPowerSavingDutyCycle(rx, sleep, transition)); + const double symbol = 1000.0 * (1U << sf) / bw; + EXPECT_GE(preamble - sleep / symbol, + rxPowerSavingLevelCatch(level, preamble)); + const double actual_rx = (rx * 8U / 125U) * 15.625; + const double actual_sleep = ((sleep - transition) * 8U / 125U) * 15.625; + const double required = (rxPowerSavingPreambleForParams(sf, bw) + + (sf <= 6 ? 6.25 : 4.25) + 8.0 + 1.0) * symbol; + EXPECT_GE(2 * actual_rx + actual_sleep + 0.01, required); + } + } + } + } + } } -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, - &effective_level, - &effective_preamble)); - ASSERT_EQ(128U, effective_preamble); - - ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 9, 500.0f, 16, - &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)); +TEST(RxPowerSaving, AutomaticAdjustmentNeverSelectsExperimentalLevels) { + uint32_t rx, sleep; + uint8_t level, preamble; + // The previous 6 ms TCXO budget cannot fit a guarded P32 at this tuple. + ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove(1, 7, 500, 16, 6000, + &rx, &sleep, &level, &preamble)); + EXPECT_TRUE(rxPowerSavingUsesContinuousFallback(rx, sleep)); + ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove(1, 7, 500, 16, 2600, + &rx, &sleep, &level, &preamble)); + EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(rx, sleep)); + EXPECT_LE(level, 8); + EXPECT_EQ(32, preamble); } -TEST(RxPowerSaving, TcxoThresholdSeparatesFastestUsableProfiles) { - constexpr uint32_t sx1262_tcxo_transition_us = 6000; - uint32_t rx_us = 0; - uint32_t sleep_us = 0; - - ASSERT_TRUE(calcRxPowerSavingLevel(6, 7, 500.0f, 32, - &rx_us, &sleep_us)); - EXPECT_FALSE(canStartRxPowerSavingDutyCycle( - rx_us, sleep_us, sx1262_tcxo_transition_us)); - - ASSERT_TRUE(calcRxPowerSavingLevel(7, 7, 500.0f, 32, - &rx_us, &sleep_us)); - EXPECT_TRUE(canStartRxPowerSavingDutyCycle( - rx_us, sleep_us, sx1262_tcxo_transition_us)); +TEST(RxPowerSaving, FastTuplesRetainLongPreambleSupport) { + uint32_t rx, sleep; + uint8_t level, preamble; + for (uint8_t sf : {5, 6}) { + ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove(8, sf, 500, 16, 6000, + &rx, &sleep, &level, &preamble)); + EXPECT_EQ(8, level); + EXPECT_EQ(sf == 5 ? 128 : 64, preamble); + EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(rx, sleep)); + } } -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, NamedProfilesAndExplicitExperimentalModes) { + EXPECT_EQ(3, RX_POWERSAVING_CONSERVATIVE_LEVEL); + EXPECT_EQ(6, RX_POWERSAVING_BALANCED_LEVEL); + EXPECT_EQ(8, RX_POWERSAVING_MAX_LEVEL); + uint32_t rx9, sleep9, rx10, sleep10; + ASSERT_TRUE(calcRxPowerSavingLevel(9, 8, 62.5f, 32, &rx9, &sleep9)); + ASSERT_TRUE(calcRxPowerSavingLevel(10, 8, 62.5f, 32, &rx10, &sleep10)); + EXPECT_LT(rx10, rx9); + EXPECT_GT(sleep10, sleep9); + EXPECT_FALSE(isRxPowerSavingUnguardedLevel(8)); + EXPECT_TRUE(isRxPowerSavingUnguardedLevel(9)); } -TEST(RxPowerSaving, RequestedLevelIsRaisedOnlyToRequiredMinimum) { - 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, 16, sx1262_tcxo_transition_us, - &rx_us, &sleep_us, &effective_level, &effective_preamble)); - EXPECT_EQ(7U, effective_level); - EXPECT_EQ(32U, effective_preamble); - EXPECT_TRUE(canStartRxPowerSavingDutyCycle( - rx_us, sleep_us, sx1262_tcxo_transition_us)); - - ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( - 8, 7, 500.0f, 16, sx1262_tcxo_transition_us, - &rx_us, &sleep_us, &effective_level, &effective_preamble)); - EXPECT_EQ(8U, effective_level); - EXPECT_EQ(32U, effective_preamble); - EXPECT_TRUE(canStartRxPowerSavingDutyCycle( - rx_us, sleep_us, sx1262_tcxo_transition_us)); +TEST(RxPowerSaving, BothFamiliesShareGuardedGeometry) { + for (uint8_t level = 1; level <= 8; ++level) { + uint32_t sx_rx, sx_sleep, lr_rx, lr_sleep; + ASSERT_TRUE(calcRxPowerSavingLevel(level, 8, 62.5f, 32, &sx_rx, &sx_sleep, 6, 2600)); + ASSERT_TRUE(calcRxPowerSavingLevel(level, 8, 62.5f, 32, &lr_rx, &lr_sleep, 8, 2600)); + EXPECT_EQ(sx_rx, lr_rx); + EXPECT_EQ(sx_sleep, lr_sleep); + } } -TEST(RxPowerSaving, Sf5Bw500UsesPreamble128AtEffectiveLevel8) { - constexpr uint32_t sx1262_tcxo_transition_us = 6000; - uint32_t rx_us = 0; - uint32_t sleep_us = 0; - uint8_t effective_level = 99; - uint8_t effective_preamble = 99; - - ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( - 1, 5, 500.0f, 16, sx1262_tcxo_transition_us, - &rx_us, &sleep_us, &effective_level, &effective_preamble)); - 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, ExperimentalProfilesAvoidKnownBadSx126xTicks) { + for (uint32_t tick : {320U, 640U}) { + EXPECT_EQ(tick + 1, avoidBadRxPowerSavingTick(rxPowerSavingTickToUs(tick)) * 8U / 125U); + } } -TEST(RxPowerSaving, Sf5Bw250NeedsLongerTransmittedPreambleOnTcxoRadio) { - constexpr uint32_t sx1262_tcxo_transition_us = 6000; - uint32_t rx_us = 0; - uint32_t sleep_us = 0; - uint8_t effective_level = 99; - uint8_t effective_preamble = 99; - - // Even the maximum level only provides a 3.616 ms sleep window. RadioLib - // must reserve 6 ms for the SX1262 TCXO and sleep/wake transitions. - ASSERT_TRUE(calcRxPowerSavingLevel(10, 5, 250.0f, 32, - &rx_us, &sleep_us)); - EXPECT_EQ(1024U, rx_us); - EXPECT_EQ(3616U, sleep_us); - EXPECT_FALSE(canStartRxPowerSavingDutyCycle( - rx_us, sleep_us, sx1262_tcxo_transition_us)); - - ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( - 8, 5, 250.0f, 32, 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); - EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)); -} - -TEST(RxPowerSaving, Sf5Bw250UsesPreamble64AtEffectiveLevel8) { - 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, 5, 250.0f, 64, 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); - EXPECT_TRUE(canStartRxPowerSavingDutyCycle( - rx_us, sleep_us, sx1262_tcxo_transition_us)); -} - -TEST(RxPowerSaving, Sf5Bw250SelectorUsesPreamble64Automatically) { - 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, 5, 250.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, Sf5Bw125KeepsPreamble32) { - 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, 5, 125.0f, 16, 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); - EXPECT_TRUE(canStartRxPowerSavingDutyCycle( - rx_us, sleep_us, sx1262_tcxo_transition_us)); -} - -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; - uint8_t effective_level = 0; - uint8_t effective_preamble = 0; - - ASSERT_TRUE(calcRxPowerSavingLevelAtOrAbove( - 1, 6, 250.0f, 16, 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); -} - -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); -} - -TEST(RxPowerSaving, Sf5Bw62Point5UsesPreamble16AtEffectiveLevel10) { - 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, 5, 62.5f, 16, sx1262_tcxo_transition_us, - &rx_us, &sleep_us, &effective_level, &effective_preamble)); - EXPECT_EQ(10U, effective_level); - EXPECT_EQ(16U, effective_preamble); - EXPECT_EQ(4096U, rx_us); - EXPECT_EQ(6272U, sleep_us); +TEST(RxPowerSaving, RetuningPreservesManualTimingsAndRestoresRequestedLevel) { + uint32_t rx = 12345, sleep = 67890; + EXPECT_FALSE(recalcRxPowerSavingFromLevel(0, 8, 62.5f, 16, &rx, &sleep)); + EXPECT_EQ(12345U, rx); + EXPECT_EQ(67890U, sleep); + uint8_t level, preamble; + ASSERT_TRUE(recalcRxPowerSavingFromLevel(3, 5, 500, 16, &rx, &sleep, &level, &preamble)); + ASSERT_TRUE(recalcRxPowerSavingFromLevel(3, 9, 62.5f, 16, &rx, &sleep, &level, &preamble)); + EXPECT_EQ(3, level); + EXPECT_EQ(16, preamble); } TEST(RxPowerSaving, RejectsInvalidInputs) { - uint32_t rx_us = 123; - uint32_t sleep_us = 456; + uint32_t rx, sleep; + EXPECT_FALSE(calcRxPowerSavingLevel(0, 8, 62.5f, 16, &rx, &sleep)); + EXPECT_FALSE(calcRxPowerSavingLevel(11, 8, 62.5f, 16, &rx, &sleep)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 4, 62.5f, 16, &rx, &sleep)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 8, 0, 16, &rx, &sleep)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 8, NAN, 16, &rx, &sleep)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 8, INFINITY, 16, &rx, &sleep)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 8, 62.5f, 24, &rx, &sleep)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 8, 62.5f, 16, nullptr, &sleep)); +} - EXPECT_FALSE(calcRxPowerSavingLevel(0, 7, 500.0f, 32, - &rx_us, &sleep_us)); - EXPECT_FALSE(calcRxPowerSavingLevel(5, 4, 500.0f, 32, - &rx_us, &sleep_us)); - EXPECT_FALSE(calcRxPowerSavingLevel(5, 7, 0.0f, 32, - &rx_us, &sleep_us)); - EXPECT_FALSE(calcRxPowerSavingLevel(5, 7, 500.0f, 24, - &rx_us, &sleep_us)); +TEST(RxPowerSavingCLI, ParsesNewNamesAndRejectsTrailingJunk) { + mesh::cli::RxPowerSavingArguments args = {}; + for (const char* name : {"max", "overdrive", "riskyWorkingMax"}) { + ASSERT_TRUE(mesh::cli::parseRxPowerSavingArgumentsStrict(name, args)); + EXPECT_EQ(mesh::cli::rxPowerSavingNamedLevel(name), args.level); + EXPECT_EQ(16U, args.preamble); + } + ASSERT_TRUE(mesh::cli::parseRxPowerSavingArgumentsStrict("max preamble 32", args)); + EXPECT_EQ(8U, args.level); + EXPECT_EQ(32U, args.preamble); + for (const char* text : {"maxx", "max extra", "max preamble 32 extra", "riskyworkingmax", + "level 4294967296", "overdrive preamble nope"}) { + EXPECT_FALSE(mesh::cli::parseRxPowerSavingArgumentsStrict(text, args)) << text; + } } int main(int argc, char** argv) { diff --git a/test/test_sx126x_receive_mode.py b/test/test_sx126x_receive_mode.py new file mode 100644 index 00000000..32d98bb3 --- /dev/null +++ b/test/test_sx126x_receive_mode.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Exercise the pinned RadioLib SPI transport with a fake Semtech status response.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +struct Hal : RadioLibHal { + Hal() : RadioLibHal(0,1,0,1,1,0) {} + bool busy = false; + uint8_t status = 0x54; + unsigned transfers = 0; + uint32_t time = 0; + void pinMode(uint32_t,uint32_t) override {} + void digitalWrite(uint32_t,uint32_t) override {} + uint32_t digitalRead(uint32_t) override { return busy; } + void attachInterrupt(uint32_t,void(*)(),uint32_t) override {} + void detachInterrupt(uint32_t) override {} + void delay(RadioLibTime_t ms) override { time += ms; } + void delayMicroseconds(RadioLibTime_t) override {} + RadioLibTime_t millis() override { return time; } + RadioLibTime_t micros() override { return time * 1000; } + long pulseIn(uint32_t,uint32_t,RadioLibTime_t) override { return 0; } + void spiBegin() override {} + void spiBeginTransaction() override {} + void spiTransfer(uint8_t* out, size_t size, uint8_t* in) override { + assert(size == 2 && out[0] == 0xC0); + memset(in, 0, size); + in[1] = status; // GetStatus has one response byte, no payload after it. + ++transfers; + } + void spiEndTransaction() override {} + void spiEnd() override {} +}; +struct Radio { + Module* mod; + Module* getMod() { return mod; } + @LEGACY@ +}; +int main() { + Hal hal; + Module module(&hal,1,2,3,4); + module.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_CMD] = Module::BITS_8; + module.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_STATUS] = Module::BITS_8; + module.spiConfig.statusPos = 1; + module.spiConfig.parseStatusCb = nullptr; + module.spiConfig.checkStatusCb = nullptr; + Radio radio{&module}; + assert(radio.getStatus() == 0); // reproduces the PR's false non-RX observation + assert(sx126xReceiveMode(&radio) == 1); + assert(module.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_STATUS] == 8); + for (uint8_t mode : {0x24, 0x34, 0x44, 0x64}) { + hal.status = mode; + assert(sx126xReceiveMode(&radio) == 0); + } + for (uint8_t invalid : {0x00, 0xFF, 0x14, 0x74}) { + hal.status = invalid; + assert(sx126xReceiveMode(&radio) == -1); + assert(module.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_STATUS] == 8); + } + const unsigned before = hal.transfers; + hal.busy = true; + assert(sx126xReceiveMode(&radio) == -1); + assert(hal.transfers == before); // no SPI while the chip is asleep/busy +} +''' + + +class ReceiveModeTest(unittest.TestCase): + def test_real_spi_transport_and_legacy_status(self): + candidates = sorted((ROOT / '.pio/libdeps').glob('*/RadioLib/src/Module.cpp')) + pin = re.search(r'jgromes/RadioLib.git#([0-9a-f]{40})', + (ROOT / 'platformio.ini').read_text()).group(1) + lib = None + for candidate in candidates: + installed = subprocess.run( + ['git', '-C', str(candidate.parent.parent), 'rev-parse', 'HEAD'], + capture_output=True, text=True) + if installed.returncode == 0 and installed.stdout.strip() == pin: + lib = candidate.parent + break + if lib is None: + self.skipTest('Build a firmware environment to install the pinned RadioLib dependency') + commands = (lib / 'modules/SX126x/SX126x_commands.cpp').read_text() + start = commands.index('uint8_t SX126x::getStatus()') + end = commands.index('\n}', start) + 2 + legacy = commands[start:end].replace('SX126x::', '') + with tempfile.TemporaryDirectory(prefix='meshcore-status-') as tmp: + source = Path(tmp) / 'test.cpp' + binary = Path(tmp) / 'test' + source.write_text(HARNESS.replace('@LEGACY@', legacy)) + command = [os.environ.get('CXX', 'c++'), '-std=c++17', '-O1', + '-DRADIOLIB_GODMODE=1', '-DRADIOLIB_STATIC_ONLY=1', + '-ffunction-sections', '-fdata-sections', '-Wl,--gc-sections', + '-I', str(lib), '-I', str(ROOT / 'src'), str(source), + str(lib / 'Module.cpp'), str(lib / 'Hal.cpp'), '-o', str(binary)] + result = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/variants/heltec_t114/platformio.ini b/variants/heltec_t114/platformio.ini index 96254e40..684bb4c2 100644 --- a/variants/heltec_t114/platformio.ini +++ b/variants/heltec_t114/platformio.ini @@ -300,3 +300,18 @@ lib_deps = extends = Heltec_t114 build_src_filter = ${Heltec_t114.build_src_filter} +<../examples/kiss_modem/> + +[env:Heltec_t114_sensor] +extends = Heltec_t114_with_display +build_src_filter = ${Heltec_t114_with_display.build_src_filter} + +<../examples/simple_sensor> +build_flags = + ${Heltec_t114_with_display.build_flags} + -D ADVERT_NAME='"Heltec_T114 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +; -D POWERSAVING_DEBUG=1 diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index f970bd16..98e36a73 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -76,6 +76,7 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 ; -D CORE_DEBUG_LEVEL=3 + -D PIN_USER_BTN=-1 ; Disabled button press which prevents repeater to boot lib_deps = ${LilyGo_TLora_V2_1_1_6.lib_deps} ${esp32_ota.lib_deps} diff --git a/variants/mke_s3/MKES3Board.h b/variants/mke_s3/MKES3Board.h new file mode 100644 index 00000000..a0ccf9e1 --- /dev/null +++ b/variants/mke_s3/MKES3Board.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +class MKES3Board : public ESP32Board { +public: + MKES3Board() { } + + const char* getManufacturerName() const override { + return "MKE S3"; + } +}; diff --git a/variants/mke_s3/platformio.ini b/variants/mke_s3/platformio.ini new file mode 100644 index 00000000..d3adb11c --- /dev/null +++ b/variants/mke_s3/platformio.ini @@ -0,0 +1,231 @@ +; https://github.com/makereduvn/MKE-K01-ESP32-S3-DEV-KIT +[MKE_s3] +extends = esp32_base +board = esp32-s3-devkitc-1 +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/mke_s3 + -D MKE_S3 + -D ESP32_CPU_FREQ=80 + ; First heading + -D P_LORA_NSS=14 + -D P_LORA_MOSI=13 + -D P_LORA_MISO=12 + -D P_LORA_SCLK=11 + -D P_LORA_BUSY=10 + ; Second heading + ; GND + ; 3.3v + -D P_LORA_RESET=5 + -D P_LORA_DIO_1=4 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 +; -D P_LORA_TX_LED=48 ; RGB LED + -D PIN_BOARD_SDA=8 + -D PIN_BOARD_SCL=9 + -D PIN_USER_BTN=-1 +; -D PIN_VEXT_EN=36 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=0 ; Ra01SH uses XTAL + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D PIN_GPS_RX=47 + -D PIN_GPS_TX=48 + -D PIN_GPS_EN=26 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/mke_s3> + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + +[env:MKE_s3_repeater] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"MKE Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +[env:MKE_s3_repeater_bridge_rs232] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"RS232 Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_RS232_BRIDGE=Serial2 + ; Wire the bridge UART to GPIO16/17; GPIO5 is the LoRa reset line. + -D WITH_RS232_BRIDGE_RX=16 + -D WITH_RS232_BRIDGE_TX=17 +; -D BRIDGE_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} + +[env:MKE_s3_repeater_bridge_espnow] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"ESPNow Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 +; -D BRIDGE_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} + +[env:MKE_s3_room_server] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"MKE RoomServer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} + +[env:MKE_s3_terminal_chat] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_companion_radio_usb] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE +; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 +; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_companion_radio_ble] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D BLE_PIN_CODE=123456 ; fixed PIN if display is not detected + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 +; -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_companion_radio_wifi] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display +; -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_sensor] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D ADVERT_NAME='"MKE Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ENV_PIN_SDA=33 + -D ENV_PIN_SCL=34 + -D DISPLAY_CLASS=SSD1306Display +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + +<../examples/simple_sensor> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/mke_s3/target.cpp b/variants/mke_s3/target.cpp new file mode 100644 index 00000000..0878c623 --- /dev/null +++ b/variants/mke_s3/target.cpp @@ -0,0 +1,45 @@ +#include +#include "target.h" + +MKES3Board board; + +#if defined(P_LORA_SCLK) + static SPIClass spi; + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +#else + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#endif + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + #include + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + +#if defined(P_LORA_SCLK) + return radio.std_init(&spi); +#else + return radio.std_init(); +#endif +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} diff --git a/variants/mke_s3/target.h b/variants/mke_s3/target.h new file mode 100644 index 00000000..4111cd40 --- /dev/null +++ b/variants/mke_s3/target.h @@ -0,0 +1,27 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #include + #include +#endif + +extern MKES3Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/rak3401/fix_bsec_lib.py b/variants/rak3401/fix_bsec_lib.py new file mode 100644 index 00000000..e21e9e39 --- /dev/null +++ b/variants/rak3401/fix_bsec_lib.py @@ -0,0 +1,15 @@ +Import('env') +import os + +# Bosch has a goof in their PlatformIO packaging making linking fail. +# The BSEC library's extra_script.py selects cortex-m4/libalgobsec.a (soft-float ABI). +# nRF52840 compiles with -mfloat-abi=hard, requiring the fpv4-sp-d16-hard blob. +# Workaround to prepend the hard-float path so the linker finds it before the +# soft-float one. +bsec_hard = os.path.join( + env.subst('$PROJECT_DIR'), + '.pio', 'libdeps', env.subst('$PIOENV'), + 'BSEC Software Library', 'src', + 'cortex-m4', 'fpv4-sp-d16-hard' +) +env.Prepend(LIBPATH=[bsec_hard]) diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index 8610fec1..4f534052 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -3,6 +3,8 @@ extends = nrf52_base board = rak3401 board_check = true board_build.ldscript = boards/nrf52840_s140_v6.ld +extra_scripts = ${nrf52_base.extra_scripts} + post:variants/rak3401/fix_bsec_lib.py build_flags = ${nrf52_base.build_flags} ${sensor_base.build_flags} -I variants/rak3401 @@ -22,6 +24,8 @@ build_flags = ${nrf52_base.build_flags} -D SX126X_REGISTER_PATCH=1 ; Patch register 0x8B5 for improved RX with SKY66122 FEM -D FORCE_GPS_ALIVE ; WB_IO2 controls the shared 3V3_S rail; stop_gps() must not drop it. -D PIN_GPS_EN=-1 ; -1 = no dedicated GPS enable pin (power is the shared 3V3_S rail), same as rak4631 + -UENV_INCLUDE_BME680 + -D ENV_INCLUDE_BME680_BSEC=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/rak3401> + @@ -32,10 +36,12 @@ lib_deps = ${sensor_base.lib_deps} adafruit/Adafruit SSD1306 @ ^2.5.13 sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 + boschsensortec/BSEC Software Library @ ^1.8.1492 [env:RAK_3401_repeater] extends = rak3401 extra_scripts = ${nrf52_lora_ota.extra_scripts} + post:variants/rak3401/fix_bsec_lib.py build_flags = ${rak3401.build_flags} -UENABLE_OTA @@ -64,6 +70,7 @@ build_src_filter = ${rak3401.build_src_filter} [env:RAK_3401_repeater_lora_ota_no_external_sensors] extends = rak3401 extra_scripts = ${nrf52_lora_ota.extra_scripts} + post:variants/rak3401/fix_bsec_lib.py build_flags = ${rak3401.build_flags} ${nrf52_reduced_sensors_keep_ina_gps.build_flags} @@ -97,6 +104,7 @@ lib_deps = [env:RAK_3401_repeater_rak13302_w25q16_lora_ota] extends = rak3401 extra_scripts = ${nrf52_lora_ota.extra_scripts} + post:variants/rak3401/fix_bsec_lib.py build_flags = ${rak3401.build_flags} ${nrf52_wisblock_w25q16_ota.build_flags} diff --git a/variants/t1000-e/platformio.ini b/variants/t1000-e/platformio.ini index 55b34e3c..a358cf40 100644 --- a/variants/t1000-e/platformio.ini +++ b/variants/t1000-e/platformio.ini @@ -130,3 +130,19 @@ build_src_filter = ${t1000-e.build_src_filter} +<../examples/kiss_modem/> lib_deps = ${t1000-e.lib_deps} stevemarple/MicroNMEA @ ^2.0.6 + +[env:t1000e_sensor] +extends = t1000-e +build_flags = ${t1000-e.build_flags} + -I examples/companion_radio/ui-orig + -D ADVERT_NAME='"t1000-e Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${t1000-e.build_src_filter} + +<../examples/simple_sensor> +lib_deps = ${t1000-e.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 diff --git a/variants/xiao_nrf52/platformio.ini b/variants/xiao_nrf52/platformio.ini index 0c50d62b..dcfc2cc8 100644 --- a/variants/xiao_nrf52/platformio.ini +++ b/variants/xiao_nrf52/platformio.ini @@ -119,6 +119,24 @@ build_flags = build_src_filter = ${Xiao_nrf52.build_src_filter} +<../examples/simple_room_server/*.cpp> +[env:Xiao_nrf52_repeater_bridge_rs232] +extends = Xiao_nrf52 +build_flags = + ${Xiao_nrf52.build_flags} + -D ADVERT_NAME='"RS232 Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_RS232_BRIDGE=Serial1 + -D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX + -D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Xiao_nrf52.build_src_filter} + + + +<../examples/simple_repeater> + [env:Xiao_nrf52_kiss_modem] extends = Xiao_nrf52 build_flags =