From 8a8c80ee6ad47cfa7c39d4c546222d69edd2c167 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 26 Oct 2026 15:55:36 -0700 Subject: [PATCH] Unblock LR2021 retunes and add saved crystal calibration Repeated preamble/header activity can keep a requested primary or TempRadio change busy indefinitely. Bound unfinished-RX admission for accepted LR2021 changes while preserving completed packets, TX, BUSY and exclusive owners. Ordinary scans and CAD keep their existing admission rules. Expose the failing stage and native status through get radio.apply. Add saved radio.xtrim capacitor calibration and measured radio.freqerror in signed Hz from the last valid LoRa packet. Preserve native trim by default, restore explicit calibration after resets/Morse, verify saves before applying, wait two seconds for the acknowledgement, and roll back failed changes. Calibration motivation and prior upstream work: https://github.com/meshcore-dev/MeshCore/issues/3365 https://github.com/recrof/MeshCore/tree/lr2021-trim Packet-status format follows Semtech's LR20xx driver and RadioLib staging. Tests execute production activity/admission and transaction paths with noisy RX, completion races, protected owners, failed setters, RX restart and save rollback. Physical W12 retuning and RF calibration still require confirmation. --- .github/workflows/run-unit-tests.yml | 4 + docs/cli_commands.md | 51 +++ examples/companion_radio/MyMesh.cpp | 10 + examples/companion_radio/main.cpp | 9 + examples/simple_repeater/MyMesh.cpp | 8 + examples/simple_repeater/main.cpp | 9 + examples/simple_room_server/MyMesh.cpp | 8 + examples/simple_room_server/main.cpp | 9 + examples/simple_sensor/SensorMesh.cpp | 8 + examples/simple_sensor/main.cpp | 9 + src/helpers/CommonCLI.cpp | 10 + src/helpers/radiolib/CustomLR2021.h | 101 ++++- src/helpers/radiolib/CustomLR2021Wrapper.h | 213 ++++++++++- src/helpers/radiolib/LR2021ApplyStatus.h | 65 ++++ .../radiolib/LR2021CalibrationPolicy.h | 88 +++++ .../radiolib/LR2021CalibrationSettings.cpp | 169 ++++++++ .../radiolib/LR2021CalibrationSettings.h | 15 + src/helpers/radiolib/PrimaryRadioApplyWait.h | 54 +++ src/helpers/radiolib/RadioLibWrappers.cpp | 84 +++- src/helpers/radiolib/RadioLibWrappers.h | 12 + test/fixtures/morse_rx_native.cpp | 1 + test/test_beacon_radio.py | 2 +- test/test_lr2021_apply_transaction.py | 249 ++++++++++++ test/test_lr2021_calibration.py | 360 ++++++++++++++++++ test/test_lr2021_power.py | 1 + test/test_lr2021_primary_apply_wait.py | 204 ++++++++++ test/test_lr2021_trim_apply_wait.py | 166 ++++++++ test/test_lr2021_tx_prearm.py | 1 + test/test_lr_meshtext_wire.py | 12 +- test/test_radio_interrupt_recovery.py | 12 + test/test_radio_regulator_modes.py | 1 + test/test_room_sensor_radio_restore.py | 2 + 32 files changed, 1913 insertions(+), 34 deletions(-) create mode 100644 src/helpers/radiolib/LR2021ApplyStatus.h create mode 100644 src/helpers/radiolib/LR2021CalibrationPolicy.h create mode 100644 src/helpers/radiolib/LR2021CalibrationSettings.cpp create mode 100644 src/helpers/radiolib/LR2021CalibrationSettings.h create mode 100644 src/helpers/radiolib/PrimaryRadioApplyWait.h create mode 100644 test/test_lr2021_apply_transaction.py create mode 100644 test/test_lr2021_calibration.py create mode 100644 test/test_lr2021_primary_apply_wait.py create mode 100644 test/test_lr2021_trim_apply_wait.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 982741b81..a3251e5f6 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -421,6 +421,10 @@ jobs: python3 -B test/test_lr1121_power_merge.py -v python3 -B test/test_lr2021_power.py -v python3 -B test/test_lr2021_tx_prearm.py -v + python3 -B test/test_lr2021_primary_apply_wait.py -v + python3 -B test/test_lr2021_apply_transaction.py -v + python3 -B test/test_lr2021_trim_apply_wait.py -v + python3 -B test/test_lr2021_calibration.py -v python3 -B test/test_meshtracker_x1_battery.py -v python3 -B test/test_drv2605_power.py -v python3 -B test/test_radio_interrupt_recovery.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 9370d282c..ff403430f 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2117,6 +2117,17 @@ is free and its reply has drained. No reboot is needed. This ends pending or active TempRadio sessions. Companion LoRa CLI applies after its exact reply copies drain; an active TempRadio lease rejects the change. +On LR2021 radios, repeated unfinished preamble/header activity cannot block an +accepted primary or TempRadio change indefinitely. The driver allows one maximum +packet airtime plus a margin, capped at 30 seconds, before stopping an unfinished +reception at the next apply attempt. Completed packets, active transmission, +chip BUSY and exclusive radio tools retain priority. Normal radio2 scanning and +CAD do not use this exception. + +If an LR2021 change remains pending, `get radio.apply` reports its admission or +failure stage and native error code. Its frequency field is the driver's last +accepted setting, not a measurement of the transmitted carrier. + **Connections:** USB, browser/TCP/Ethernet CLI, Companion binary command `0x42` (USB/BLE/TCP), and authorized LoRa CLI. `set freq` saves the frequency; bandwidth, spreading factor, and coding rate stay as configured. Infrastructure @@ -2125,6 +2136,46 @@ roles also clamp transmit power to the board's limit for that frequency. Like --- +#### LR2021 frequency measurement and crystal calibration + +Available on LR2021 builds, including Meshnology W12: + +```text +get radio.freqerror +get radio.xtrim +set radio.xtrim <0-47>[,<0-47>] +set radio.xtrim default +``` + +`get radio.freqerror` reports the last successfully received LoRa packet's +frequency offset in Hz, its configured frequency and sample age. It reports +`unavailable` before a valid measurement. Failed reads and CRC errors keep the +previous sample and its age. This is the difference between two radios; it does +not establish which radio needs calibration. + +`radio.xtrim` adjusts the LR2021 crystal's two load-capacitor codes. A single +number sets both codes; two numbers set XTA and XTB separately. Lower codes +generally raise frequency; each code is a capacitor step, not a fixed Hz offset. +Use a known reference to choose values. TCXO boards reject this setting. + +The default leaves the chip's original trim unchanged; no shared calibration +value is imposed on boards. An explicit selection is saved first and waits two +seconds so its acknowledgement can leave with the previous calibration. It then +applies without a reboot when the radio is free, and is restored after resets +and temporary modem changes. A busy radio retries automatically for 30 seconds +after that handoff, including one final admission attempt. If protected radio +work still prevents the change, the +request fails and restores the previous saved selection; `get radio.xtrim` +shows it. A reported radio recovery failure still needs diagnosis. +`set radio.xtrim default` removes the override and resets/reinitializes the radio +to restore its original trim. Node identity and other settings remain saved. + +These controls are available through local CLI and authorized LoRa CLI. They do +not change the configured channel frequency shown by `get radio` or `get freq`. +The [Semtech LR20xx driver documents the capacitor range and step size](https://github.com/Lora-net/usp/blob/main/smtc_rac_lib/radio_drivers/lr20xx_driver/inc/lr20xx_system.h). + +--- + #### View or change this node's rx boosted gain mode (SX12xx and LR1110, v1.14.1+) **Search terms:** RX boost, receive gain, receiver sensitivity, boosted reception. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index bd1c3a1fb..c56c4c5e0 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,7 @@ #include +#if defined(USE_LR2021) +#include +#endif #if defined(ESP32_PLATFORM) #include #include @@ -4529,6 +4532,13 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, return true; } +#if defined(USE_LR2021) + if (mesh::lr2021::handleLR2021CalibrationCommand(command, reply, reply_size, radio_driver)) return true; + if (strcmp(command, "get radio.apply") == 0) { + radio_driver.formatApplyStatus(reply, reply_size); + return true; + } +#endif #if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED && defined(DISPLAY_CLASS) if (mesh::ui::handleHeltecV4DisplayCommand(command, reply, reply_size, display)) return true; #endif diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index dfdf46084..c4022f39a 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1,4 +1,7 @@ #include +#if defined(USE_LR2021) +#include +#endif #include #include // needed for PlatformIO #include @@ -3690,6 +3693,9 @@ void setup() { MESH_DEBUG_PRINTLN("Regulator selection could not be applied"); } mesh::RadioProfileCLI::configureBootTuning(regulator_prefs_fs, radio_driver); +#if defined(USE_LR2021) + mesh::lr2021::loadLR2021CalibrationSettings(regulator_prefs_fs, radio_driver); +#endif #ifdef DISPLAY_CLASS @@ -4291,6 +4297,9 @@ void loop() { ble_mota_source_control.loop(); #endif const bool morse_timing = mesh::morseTimingActive(&radio_driver); +#if defined(USE_LR2021) + if (!morse_timing) mesh::lr2021::loopLR2021CalibrationSettings(radio_driver); +#endif if (!morse_timing) sensors.loop(); #if defined(WIO_E5_MINI_HEADLESS_COMPANION_UI) && defined(PIN_USER_BTN) serviceWioE5HeadlessControls(); diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 1ae4fdb66..e3fa0a507 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -4584,10 +4584,18 @@ bool MyMesh::applySavedRadioParams() { // Each setter may independently defer while a packet is being received. // Only complete recovery when the whole saved configuration was accepted. +#if !defined(USE_LR2021) if (radio_driver.supportsRxBoostedGainMode() && !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) return false; +#endif if (!applyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, _cli.radioProfiles().primaryPreamble())) return false; +#if defined(USE_LR2021) + // Primary admission owns the bounded unfinished-RX handoff. Applying gain + // first could keep that accepted command from ever reaching admission. + if (radio_driver.supportsRxBoostedGainMode() + && !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) return false; +#endif #if defined(USE_LR2021) if (!radio_driver.configSideDetectors(_prefs.extra_sf, extra_sf_count, _prefs.bw)) return false; #endif diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 3a89e3b79..89025aa73 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -1,4 +1,7 @@ #include +#if defined(USE_LR2021) +#include +#endif #include #include #include // needed for PlatformIO @@ -228,6 +231,9 @@ void setup() { MESH_DEBUG_PRINTLN("Regulator selection could not be applied"); } mesh::RadioProfileCLI::configureBootTuning(regulator_prefs_fs, radio_driver); +#if defined(USE_LR2021) + mesh::lr2021::loadLR2021CalibrationSettings(regulator_prefs_fs, radio_driver); +#endif mesh::hilStartupTrace("fs_ready"); #ifdef DISPLAY_CLASS mesh::hilStartupTrace("display_begin"); @@ -650,6 +656,9 @@ void loop() { the_mesh.loop(); const bool morse_timing = mesh::morseTimingActive(&radio_driver); +#if defined(USE_LR2021) + if (!morse_timing) mesh::lr2021::loopLR2021CalibrationSettings(radio_driver); +#endif if (!morse_timing) sensors.loop(); #ifdef DISPLAY_CLASS #if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index f6fcb76a5..71ecbe0c7 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -2960,8 +2960,10 @@ bool MyMesh::applySavedRadioParams() { #endif // Each setter can defer while the radio is busy. Keep recovery pending // until the complete saved configuration has been accepted. +#if !defined(USE_LR2021) if (radio_driver.supportsRxBoostedGainMode() && !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) return false; +#endif uint32_t timings[2] = {_prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us}; const uint32_t* applied_timings = _prefs.rx_powersaving_enabled && radio_driver.supportsRxPowerSaving() ? timings : NULL; @@ -2971,6 +2973,12 @@ bool MyMesh::applySavedRadioParams() { return false; } active_cr = _prefs.cr; +#if defined(USE_LR2021) + // Primary admission owns the bounded unfinished-RX handoff. Applying gain + // first could keep that accepted command from ever reaching admission. + if (radio_driver.supportsRxBoostedGainMode() + && !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) return false; +#endif #if defined(USE_LR2021) if (side_detectors_allowed && !radio_driver.configSideDetectors(_prefs.extra_sf, extra_sf_count, _prefs.bw)) return false; diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index d933e3041..acfa4f63b 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -1,4 +1,7 @@ #include +#if defined(USE_LR2021) +#include +#endif #include #include #include // needed for PlatformIO @@ -162,6 +165,9 @@ void setup() { MESH_DEBUG_PRINTLN("Regulator selection could not be applied"); } mesh::RadioProfileCLI::configureBootTuning(regulator_prefs_fs, radio_driver); +#if defined(USE_LR2021) + mesh::lr2021::loadLR2021CalibrationSettings(regulator_prefs_fs, radio_driver); +#endif #ifdef DISPLAY_CLASS mesh::ui::loadDisplayPowerSettings(fs, false); #if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED @@ -424,6 +430,9 @@ void loop() { the_mesh.loop(); const bool morse_timing = mesh::morseTimingActive(&radio_driver); +#if defined(USE_LR2021) + if (!morse_timing) mesh::lr2021::loopLR2021CalibrationSettings(radio_driver); +#endif if (!morse_timing) sensors.loop(); #ifdef DISPLAY_CLASS #if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 44c546e93..4c4c4bb6c 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1427,8 +1427,10 @@ bool SensorMesh::applySavedRadioParams() { #endif // A busy radio can accept the tuple before deferring TX power. Recovery // is complete only after both saved settings have been accepted. +#if !defined(USE_LR2021) if (radio_driver.supportsRxBoostedGainMode() && !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) return false; +#endif uint32_t timings[2] = {_prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us}; const uint32_t* applied_timings = _prefs.rx_powersaving_enabled && radio_driver.supportsRxPowerSaving() ? timings : NULL; @@ -1438,6 +1440,12 @@ bool SensorMesh::applySavedRadioParams() { return false; } active_cr = _prefs.cr; +#if defined(USE_LR2021) + // Primary admission owns the bounded unfinished-RX handoff. Applying gain + // first could keep that accepted command from ever reaching admission. + if (radio_driver.supportsRxBoostedGainMode() + && !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) return false; +#endif #if defined(USE_LR2021) if (side_detectors_allowed && !radio_driver.configSideDetectors(_prefs.extra_sf, extra_sf_count, _prefs.bw)) return false; diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 771bd24db..a6f185ac4 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -1,4 +1,7 @@ #include +#if defined(USE_LR2021) +#include +#endif #include #include "SensorMesh.h" #include @@ -180,6 +183,9 @@ void setup() { MESH_DEBUG_PRINTLN("Regulator selection could not be applied"); } mesh::RadioProfileCLI::configureBootTuning(regulator_prefs_fs, radio_driver); +#if defined(USE_LR2021) + mesh::lr2021::loadLR2021CalibrationSettings(regulator_prefs_fs, radio_driver); +#endif #ifdef DISPLAY_CLASS mesh::ui::loadDisplayPowerSettings(fs, false); #if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED @@ -385,6 +391,9 @@ void loop() { the_mesh.loop(); const bool morse_timing = mesh::morseTimingActive(&radio_driver); +#if defined(USE_LR2021) + if (!morse_timing) mesh::lr2021::loopLR2021CalibrationSettings(radio_driver); +#endif if (!morse_timing) sensors.loop(); #ifdef DISPLAY_CLASS #if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 5354b8e0b..224346ecc 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,4 +1,7 @@ #include +#if defined(USE_LR2021) +#include +#endif #include #include #include @@ -3556,6 +3559,13 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } return; } +#if defined(USE_LR2021) + if (mesh::lr2021::handleLR2021CalibrationCommand(command, reply, 160, radio_driver)) return; + if (strcmp(command, "get radio.apply") == 0) { + radio_driver.formatApplyStatus(reply, 160); + return; + } +#endif #if defined(HELTEC_V4_DISPLAY_UNIFIED) && HELTEC_V4_DISPLAY_UNIFIED && defined(DISPLAY_CLASS) if (mesh::ui::handleHeltecV4DisplayCommand(command, reply, 160, display)) return; #endif diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 3f2426f8b..bf15f148b 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -4,7 +4,9 @@ #include "RadioRegulatorState.h" #include "MeshCore.h" #include "LR2021Band.h" +#include "LR2021CalibrationPolicy.h" #include "../MorseRx.h" +#include #ifndef LR2021_TX_BUSY_TIMEOUT_MS #define LR2021_TX_BUSY_TIMEOUT_MS 1000UL @@ -34,6 +36,11 @@ class CustomLR2021 : public LR2021 { uint32_t _activityAt = 0; bool _headerSeen = false; bool _rx_boosted = false; + mesh::lr2021::XoscTrimSelection _xosc_trim; + int32_t _last_frequency_error_hz = 0; + float _last_frequency_error_mhz = 0; + uint32_t _last_frequency_error_at = 0; + bool _last_frequency_error_valid = false; uint32_t _rf_switch_pins[Module::RFSWITCH_MAX_PINS] = {}; const Module::RfSwitchMode_t* _rf_switch_table = nullptr; @@ -146,6 +153,78 @@ class CustomLR2021 : public LR2021 { bool configureBootRegulator(mesh::power::RegulatorMode mode) { return _regulator.configureBoot(mode); } mesh::power::RegulatorMode activeRegulatorMode() const { return _regulator.activeMode(); } + bool supportsXoscTrim() const { + #ifdef LR2021_TCXO_VOLTAGE + return LR2021_TCXO_VOLTAGE <= 0.0f; + #else + // std_init() otherwise selects the legacy 1.6 V TCXO default. + return false; + #endif + } + + bool configureBootXoscTrim(bool enabled, uint8_t a, uint8_t b) { + if (!supportsXoscTrim() || (enabled && !mesh::lr2021::validXoscTrim(a, b))) return false; + _xosc_trim.enabled = enabled; + _xosc_trim.a = enabled ? a : 0; + _xosc_trim.b = enabled ? b : 0; + return true; + } + + bool getXoscTrim(uint8_t& a, uint8_t& b) const { + if (!_xosc_trim.enabled) return false; + a = _xosc_trim.a; + b = _xosc_trim.b; + return true; + } + + // Selection only. The wrapper restores native defaults through a real + // reset before claiming that clearing an override changed the hardware. + void clearXoscTrim() { _xosc_trim = mesh::lr2021::XoscTrimSelection{}; } + + int16_t applyXoscTrim(uint8_t a, uint8_t b) { + if (!supportsXoscTrim()) return RADIOLIB_ERR_UNSUPPORTED; + if (!mesh::lr2021::validXoscTrim(a, b)) return RADIOLIB_ERR_UNKNOWN; + // The new capacitor codes take effect when XOSC restarts. Stop it first; + // beginReconfigure() owns packet/IRQ safety and the subsequent RX restart. + int16_t status = LR2021::standby(RADIOLIB_LR2021_STANDBY_RC); + if (status == RADIOLIB_ERR_NONE) { + uint8_t bytes[] = {a, b}; + // The third command byte is optional. Omitting it preserves the + // board's additional oscillator start delay (datasheet 6.11.4). + status = SPIcommand(RADIOLIB_LR2021_CMD_SET_XOSC_CP_TRIM, true, bytes, sizeof(bytes)); + } + if (status == RADIOLIB_ERR_NONE) configureBootXoscTrim(true, a, b); + return status; + } + + int16_t restoreXoscTrim() { + return _xosc_trim.enabled ? applyXoscTrim(_xosc_trim.a, _xosc_trim.b) : RADIOLIB_ERR_NONE; + } + + int16_t readPacketFrequencyErrorHz(int32_t& value) { + uint8_t modem = RADIOLIB_LR2021_PACKET_TYPE_NONE; + int16_t status = getPacketType(&modem); + if (status != RADIOLIB_ERR_NONE) return status; + if (modem != RADIOLIB_LR2021_PACKET_TYPE_LORA) return RADIOLIB_ERR_UNSUPPORTED; + // Datasheet 2.2 extends the earlier six-byte status with signed Hz. + // Read before FIFO consumption/CAD/new RX can replace packet metadata. + uint8_t bytes[9] = {}; + status = SPIcommand(RADIOLIB_LR2021_CMD_GET_LORA_PACKET_STATUS, false, bytes, sizeof(bytes)); + if (status == RADIOLIB_ERR_NONE) value = mesh::lr2021::decodeFrequencyErrorHz(bytes + 6); + return status; + } + + void formatFrequencyError(char* reply, size_t capacity) const { + if (!_last_frequency_error_valid) { + snprintf(reply, capacity, "> unavailable; no valid LoRa packet frequency measurement"); + return; + } + const uint32_t age_seconds = uint32_t(this->mod->hal->millis() - _last_frequency_error_at) / 1000; + snprintf(reply, capacity, "> %+ld Hz; freq=%.6f MHz; age=%lus", + static_cast(_last_frequency_error_hz), static_cast(_last_frequency_error_mhz), + static_cast(age_seconds)); + } + #if LR2021_TX_PREARM_MS > 0 using LR2021::standby; int16_t standby() override { @@ -184,6 +263,7 @@ class CustomLR2021 : public LR2021 { const uint8_t gain = mesh::lr2021::isHighBand(frequency_mhz) ? gainModeHf : gainModeLf; int16_t status = LR2021::beginGFSK(frequency_mhz, mesh::MorseRx::ReceiverBitRateKbps, 0.6f, 4.8f, 2, 16, tcxoVoltage); + if (status == RADIOLIB_ERR_NONE) status = restoreXoscTrim(); // Native initialization resets to LDO. SIMO is allowed only on verified // layouts; an explicit LDO choice on those boards is checked as well. if (status == RADIOLIB_ERR_NONE && regulator == mesh::power::RegulatorMode::Dcdc) @@ -244,6 +324,14 @@ class CustomLR2021 : public LR2021 { return false; // fail } + // No override means no trim command, preserving the native chip default. + // Reapply an explicit selection after recovery and temporary Morse mode. + status = restoreXoscTrim(); + if (status != RADIOLIB_ERR_NONE) { + _regulator.finishInitialization(status); + return false; + } + // Also reached when restoring LoRa after Morse RX or hard recovery. if (regulator == mesh::power::RegulatorMode::Dcdc) status = setRegulatorDCDC(); #if LR2021_USE_DCDC @@ -449,7 +537,18 @@ class CustomLR2021 : public LR2021 { if (cleared != RADIOLIB_ERR_NONE) return cleared; return RADIOLIB_ERR_PACKET_TOO_SHORT; } - return LR2021::readData(data, len); + int32_t frequency_error_hz = 0; + const bool measured = readPacketFrequencyErrorHz(frequency_error_hz) == RADIOLIB_ERR_NONE; + const float measured_frequency = freqMHz; + const int16_t result = LR2021::readData(data, len); + if (result == RADIOLIB_ERR_NONE && measured) { + _last_frequency_error_hz = frequency_error_hz; + _last_frequency_error_mhz = measured_frequency; + _last_frequency_error_at = this->mod->hal->millis(); + _last_frequency_error_valid = true; + } + // A bad CRC/read leaves the last valid sample and its original age. + return result; } void setPreambleMillis(uint32_t preambleMillis) { diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index f75d0fe6c..42af5c4de 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -2,6 +2,8 @@ #include "CustomLR2021.h" #include "LR2021SideDetectorConfig.h" +#include "LR2021ApplyStatus.h" +#include "PrimaryRadioApplyWait.h" #include "RadioLibWrappers.h" #ifndef USE_LR2021 @@ -15,6 +17,18 @@ #endif class CustomLR2021Wrapper : public RadioLibWrapper { + mesh::lr2021::ApplyStatus _apply_status; + mesh::PrimaryRadioApplyWait _trim_apply_wait; + uint16_t _trim_apply_key = UINT16_MAX; + + int16_t recordApply(mesh::lr2021::ApplyStatus::Stage stage, int16_t code) { + if (code != RADIOLIB_ERR_NONE) { + MESH_DEBUG_PRINTLN("LR2021 apply %s failed (%d)", + mesh::lr2021::ApplyStatus::name(stage), (int)code); + } + return _apply_status.record(stage, code); + } + public: uint8_t regulatorModes() const override { return static_cast(_radio)->regulatorModes(); } mesh::power::RegulatorMode defaultRegulatorMode() const override { @@ -30,6 +44,132 @@ public: CustomLR2021Wrapper(CustomLR2021& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } bool supportsMorseTx() const override { return true; } bool supportsMorseRx() const override { return MESH_ENABLE_MORSE_RX && supportsRadioDeepInit(); } + void formatApplyStatus(char* reply, size_t capacity) const { + snprintf(reply, capacity, "stage=%s code=%d state=%u repair=%u freq=%.6f", + mesh::lr2021::ApplyStatus::name(_apply_status.stage), + (int)_apply_status.code, (unsigned)state, + (unsigned)_profile_repair_required, + (double)static_cast(_radio)->getFreqMHz()); + } + + mesh::RadioParamApplyResult trySetParams(float freq, float bw, uint8_t sf, uint8_t cr, + const uint32_t* timings = nullptr) override { + _apply_status.begin(); + const auto result = RadioLibWrapper::trySetParams(freq, bw, sf, cr, timings); + if (result == mesh::RadioParamApplyResult::APPLIED) _apply_status.applied(); + return result; + } + + bool supportsXoscTrim() const { + return static_cast(_radio)->supportsXoscTrim(); + } + bool configureBootXoscTrim(bool enabled, uint8_t a, uint8_t b) { + return static_cast(_radio)->configureBootXoscTrim(enabled, a, b); + } + bool getXoscTrim(uint8_t& a, uint8_t& b) const { + return static_cast(_radio)->getXoscTrim(a, b); + } + void formatFrequencyError(char* reply, size_t capacity) const { + static_cast(_radio)->formatFrequencyError(reply, capacity); + } + void cancelXoscTrimApply() { + _trim_apply_wait.clear(); + _trim_apply_key = UINT16_MAX; + } + + mesh::RadioParamApplyResult trySetXoscTrim(bool enabled, uint8_t a, uint8_t b) { + using Result = mesh::RadioParamApplyResult; + using Stage = mesh::lr2021::ApplyStatus::Stage; + if (!supportsXoscTrim() || (enabled && !mesh::lr2021::validXoscTrim(a, b))) { + cancelXoscTrimApply(); + return Result::FAILED; + } + auto* radio = static_cast(_radio); + uint8_t old_a = 0, old_b = 0; + const bool was_enabled = radio->getXoscTrim(old_a, old_b); + if (enabled == was_enabled && (!enabled || (a == old_a && b == old_b)) + && !_profile_repair_required) { + cancelXoscTrimApply(); + return Result::APPLIED; + } + + const uint16_t key = enabled ? 0x1000U | (uint16_t(a) << 6) | b : 0; + if (_trim_apply_key != key) { + cancelXoscTrimApply(); + _trim_apply_key = key; + } + + _apply_status.begin(); +#if MESH_ENABLE_CARRIER_TOOLS + if (_morse_owned) { + noteReconfigureAdmission("morse-owned", 0); + return Result::BUSY; + } +#endif +#if MESH_ENABLE_MORSE_RX + if (_morse_rx_owned) { + noteReconfigureAdmission("morse-owned", 0); + return Result::BUSY; + } +#endif + const bool previous_preempt = _primary_receive_preempt; + const auto& current = _profiles.params(_active_profile); + _primary_receive_preempt = _trim_apply_wait.mayAbandonReceive( + millis(), _profiles.primary, false, current, currentPreambleLength(), + profilePreamble(0), MAX_TRANS_UNIT); + const uint8_t resume = beginReconfigure(); + _primary_receive_preempt = previous_preempt; + if (resume > 1) { + if (resume != 2) cancelXoscTrimApply(); + return resume == 2 ? Result::BUSY : Result::FAILED; + } + + bool restored = false; + if (!enabled) { + // No register read exposes the factory trim. A real reset is required + // to restore that default rather than guessing a capacitor code. + radio->clearXoscTrim(); + restored = restoreAfterDeepInit(); + if (!restored) recordApply(Stage::Restore, RADIOLIB_ERR_UNKNOWN); + } else if (recordApply(Stage::Trim, radio->applyXoscTrim(a, b)) == RADIOLIB_ERR_NONE) { + restored = !_params_valid || applyParams(_cur_freq, _cur_bw, _cur_sf, _cur_cr); +#if MESH_ENABLE_MESHTEXT_WIRE || MESH_ENABLE_ETHERNET_WIRE + restored = restored && applyProtocol(_physical_protocol); +#endif + if (_dbm_valid) { + _dbm_applied = restored && applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE; + restored = _dbm_applied && restored; + } + if (_rx_boosted_gain_valid) { + _rx_boosted_gain_applied = restored && applyRxBoostedGainMode(_cur_rx_boosted_gain); + restored = _rx_boosted_gain_applied && restored; + } + if (restored) recalibrateNoiseFloor(); + } + + if (restored) { + _profile_repair_required = false; + // This is a live command, so prove that the corrected oscillator can + // receive even if an earlier failed RX start left software idle. + endReconfigure(true); + if (isInRecvMode()) { + _apply_status.applied(); + cancelXoscTrimApply(); + return Result::APPLIED; + } + } + + // A failed command must restore both the retained selection and the old + // receiver. The deep reset also restores native defaults after a failed + // first override and repairs partial register writes. + radio->configureBootXoscTrim(was_enabled, old_a, old_b); + const bool rollback = restoreAfterDeepInit(); + if (rollback) endReconfigure(true); + if (!rollback || !isInRecvMode()) + _profile_refresh_required = _profile_repair_required = true; + cancelXoscTrimApply(); + return Result::FAILED; + } #if MESH_ENABLE_MESHTEXT_WIRE bool supportsMeshtext() const override { return true; } #endif @@ -40,12 +180,31 @@ public: } protected: + bool supportsBoundedPrimaryReconfigure() const override { return true; } + + void noteReconfigureAdmission(const char* reason, int16_t status) override { + using Stage = mesh::lr2021::ApplyStatus::Stage; + _apply_status.begin(); + Stage stage = Stage::Admitted; + if (strcmp(reason, "pending-irq") == 0) stage = Stage::Interrupt; + else if (strcmp(reason, "transmitting") == 0) stage = Stage::Transmitting; + else if (strcmp(reason, "chip-busy") == 0) stage = Stage::Busy; + else if (strcmp(reason, "receiving") == 0) stage = Stage::Receiving; + else if (strcmp(reason, "beacon-owned") == 0) stage = Stage::BeaconOwned; + else if (strcmp(reason, "carrier-owned") == 0) stage = Stage::CarrierOwned; + else if (strcmp(reason, "morse-owned") == 0) stage = Stage::MorseOwned; + else if (strcmp(reason, "standby") == 0) stage = Stage::Standby; + _apply_status.stage = stage; + recordApply(stage, status); + } + #if MESH_ENABLE_MESHTEXT_WIRE bool applyProtocol(mesh::RadioProfileProtocol protocol) override { if (protocol != mesh::RadioProfileProtocol::MeshCore && protocol != mesh::RadioProfileProtocol::Meshtext) return false; const uint8_t sync = protocol == mesh::RadioProfileProtocol::Meshtext ? 0x2b : 0x12; - return static_cast(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE; + return recordApply(mesh::lr2021::ApplyStatus::Stage::Protocol, + static_cast(_radio)->setProfileProtocol(sync)) == RADIOLIB_ERR_NONE; } #endif int16_t startMorseReceiver() override { @@ -97,21 +256,27 @@ protected: _dbm_valid ? _cur_dbm : LORA_TX_POWER, _cur_freq)) == RADIOLIB_ERR_NONE; } bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + using Stage = mesh::lr2021::ApplyStatus::Stage; CustomLR2021* radio = (CustomLR2021 *)_radio; - if (!_board->prepareRadioFrequency(freq)) return false; + if (!_board->prepareRadioFrequency(freq)) { + recordApply(Stage::FrequencyRail, RADIOLIB_ERR_UNKNOWN); + return false; + } const int8_t requested_power = _dbm_valid ? _cur_dbm : LORA_TX_POWER; const int8_t effective_power = mesh::clampLoRaTxPower(requested_power, freq); // LR2021 selects its LF/HF PA from the cached frequency. Reapply power // immediately after every live retune so a band crossing cannot retain the // previous PA configuration. - bool success = radio->setFrequency(freq) == RADIOLIB_ERR_NONE - && radio->setOutputPower(effective_power) == RADIOLIB_ERR_NONE - && radio->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE - && radio->setBandwidth(bw) == RADIOLIB_ERR_NONE - && radio->setCodingRate(cr) == RADIOLIB_ERR_NONE - && updatePreamble(sf, bw) - && applySideDetectorConfig(sf, bw); - if (!success) return false; + if (recordApply(Stage::Frequency, radio->setFrequency(freq)) != RADIOLIB_ERR_NONE + || recordApply(Stage::Power, radio->setOutputPower(effective_power)) != RADIOLIB_ERR_NONE + || recordApply(Stage::SpreadingFactor, radio->setSpreadingFactor(sf)) != RADIOLIB_ERR_NONE + || recordApply(Stage::Bandwidth, radio->setBandwidth(bw)) != RADIOLIB_ERR_NONE + || recordApply(Stage::CodingRate, radio->setCodingRate(cr)) != RADIOLIB_ERR_NONE) + return false; + if (recordApply(Stage::Preamble, + radio->setPreambleLength(preambleLengthForParams(sf, bw))) != RADIOLIB_ERR_NONE) + return false; + if (!applySideDetectorConfig(sf, bw)) return false; PacketMillis pm = calcMaxPacketMillis(sf, bw, cr, preambleLengthForParams(sf, bw)); ((CustomLR2021 *)_radio)->setPreambleMillis(pm.preambleMillis); @@ -123,7 +288,8 @@ protected: // A TX-only excursion deliberately preserves the normal wrapper cache. // Clamp for the physically selected band, not that normal RX frequency. const float frequency = ((CustomLR2021 *)_radio)->getFreqMHz(); - return _radio->setOutputPower(mesh::clampLoRaTxPower(dbm, frequency)); + return recordApply(mesh::lr2021::ApplyStatus::Stage::Power, + _radio->setOutputPower(mesh::clampLoRaTxPower(dbm, frequency))); } public: @@ -187,30 +353,36 @@ protected: } bool applySideDetectorConfig(uint8_t primary_sf, float bw) { + using Stage = mesh::lr2021::ApplyStatus::Stage; #if MESH_ENABLE_TX_BEACONS // Saved RX side detectors are not part of an independent TX-only tuple. // Disable them without changing their configuration, then restore the // normal set when the owner's receive-restoration transaction runs. if (isBeaconRadioOwned() && !_beacon_restoring) { - return ((CustomLR2021 *)_radio)->setSideDetector(nullptr, 0) == RADIOLIB_ERR_NONE; + return recordApply(Stage::SideDetectors, + ((CustomLR2021 *)_radio)->setSideDetector(nullptr, 0)) == RADIOLIB_ERR_NONE; } #endif if (_profiles.enabled()) { - return ((CustomLR2021 *)_radio)->setSideDetector(nullptr, 0) == RADIOLIB_ERR_NONE; + return recordApply(Stage::SideDetectors, + ((CustomLR2021 *)_radio)->setSideDetector(nullptr, 0)) == RADIOLIB_ERR_NONE; } if (!mesh::lr2021::validateSideDetectorSFs(_sideDetSFs, _numSideDet, primary_sf, bw)) { // Extra SFs belong to the saved receive tuple. A temporary primary can // make that list illegal; suspend it without discarding the saved set. // Applying the normal tuple enables the same detectors again. if (_profiles.primary_temporary) { - return ((CustomLR2021 *)_radio)->setSideDetector(nullptr, 0) == RADIOLIB_ERR_NONE; + return recordApply(Stage::SideDetectors, + ((CustomLR2021 *)_radio)->setSideDetector(nullptr, 0)) == RADIOLIB_ERR_NONE; } + recordApply(Stage::SideDetectors, RADIOLIB_ERR_INVALID_SIDE_DETECT); return false; } LR2021LoRaSideDetector_t config[mesh::lr2021::MAX_SIDE_DETECTORS] = {}; buildSideDetectorConfig(_sideDetSFs, _numSideDet, bw, config); - return ((CustomLR2021 *)_radio)->setSideDetector(_numSideDet > 0 ? config : nullptr, - _numSideDet) == RADIOLIB_ERR_NONE; + return recordApply(Stage::SideDetectors, + ((CustomLR2021 *)_radio)->setSideDetector(_numSideDet > 0 ? config : nullptr, + _numSideDet)) == RADIOLIB_ERR_NONE; } int16_t performChannelScan() override { @@ -308,8 +480,10 @@ protected: int startReceiveMode() override { // LR2021 must leave CAD/RX before restarting with side detectors, or it // can reject startReceive() with RADIOLIB_ERR_SPI_CMD_INVALID (-706). - _radio->standby(); - return _radio->startReceive(); + const int16_t standby = recordApply(mesh::lr2021::ApplyStatus::Stage::Standby, + _radio->standby()); + if (standby != RADIOLIB_ERR_NONE) return standby; + return recordApply(mesh::lr2021::ApplyStatus::Stage::Receive, _radio->startReceive()); } bool radioDeepInit() override { @@ -320,7 +494,8 @@ protected: bool supportsRadioDeepInit() const override { return supportsRadioHardResetPath(); } bool applyRxBoostedGainMode(bool en) override { - return ((CustomLR2021 *)_radio)->setRxBoostedGainMode(en ? LR2021_RX_BOOST_LEVEL : 0) + return recordApply(mesh::lr2021::ApplyStatus::Stage::Gain, + ((CustomLR2021 *)_radio)->setRxBoostedGainMode(en ? LR2021_RX_BOOST_LEVEL : 0)) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/LR2021ApplyStatus.h b/src/helpers/radiolib/LR2021ApplyStatus.h new file mode 100644 index 000000000..fd2d9439f --- /dev/null +++ b/src/helpers/radiolib/LR2021ApplyStatus.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include + +namespace mesh { namespace lr2021 { + +// Retain the first native failure while its rollback restores the old profile. +// A successful rollback must not hide the command which actually failed. +struct ApplyStatus { + enum class Stage : uint8_t { + Idle, Admitted, Applied, Interrupt, Transmitting, Busy, Receiving, + BeaconOwned, CarrierOwned, MorseOwned, + Standby, FrequencyRail, Frequency, Power, SpreadingFactor, Bandwidth, + CodingRate, Preamble, SideDetectors, Protocol, Receive, Gain, Trim, Restore + }; + + Stage stage = Stage::Idle; + int16_t code = 0; + bool failed = false; + + void begin() { stage = Stage::Admitted; code = 0; failed = false; } + int16_t record(Stage next, int16_t native_code) { + if (native_code != 0 && !failed) { + stage = next; + code = native_code; + failed = native_code < 0; + } + return native_code; + } + void applied() { if (!failed) { stage = Stage::Applied; code = 0; } } + + static const char* name(Stage value) { + switch (value) { + case Stage::Idle: return "idle"; + case Stage::Admitted: return "admitted"; + case Stage::Applied: return "applied"; + case Stage::Interrupt: return "pending-irq"; + case Stage::Transmitting: return "transmitting"; + case Stage::Busy: return "chip-busy"; + case Stage::Receiving: return "receiving"; + case Stage::BeaconOwned: return "beacon-owned"; + case Stage::CarrierOwned: return "carrier-owned"; + case Stage::MorseOwned: return "morse-owned"; + case Stage::Standby: return "standby"; + case Stage::FrequencyRail: return "frequency-rail"; + case Stage::Frequency: return "frequency"; + case Stage::Power: return "power"; + case Stage::SpreadingFactor: return "sf"; + case Stage::Bandwidth: return "bandwidth"; + case Stage::CodingRate: return "coding-rate"; + case Stage::Preamble: return "preamble"; + case Stage::SideDetectors: return "side-detectors"; + case Stage::Protocol: return "protocol"; + case Stage::Receive: return "receive-start"; + case Stage::Gain: return "rx-gain"; + case Stage::Trim: return "crystal-trim"; + case Stage::Restore: return "restore"; + } + return "unknown"; + } +}; + +} } // namespace mesh::lr2021 diff --git a/src/helpers/radiolib/LR2021CalibrationPolicy.h b/src/helpers/radiolib/LR2021CalibrationPolicy.h new file mode 100644 index 000000000..320fc3b8e --- /dev/null +++ b/src/helpers/radiolib/LR2021CalibrationPolicy.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include + +namespace mesh { namespace lr2021 { + +constexpr uint8_t XoscTrimMaximum = 47; + +struct XoscTrimSelection { + bool enabled = false; + uint8_t a = 0; + uint8_t b = 0; +}; + +inline bool validXoscTrim(uint8_t a, uint8_t b) { + return a <= XoscTrimMaximum && b <= XoscTrimMaximum; +} + +// GET_LORA_PACKET_STATUS returns a big-endian signed 24-bit value in Hz. +inline int32_t decodeFrequencyErrorHz(const uint8_t* bytes) { + const uint32_t raw = (uint32_t(bytes[0]) << 16) | (uint32_t(bytes[1]) << 8) | bytes[2]; + return (raw & 0x800000U) ? int32_t(raw) - 0x1000000L : int32_t(raw); +} + +inline bool sameXoscTrim(const XoscTrimSelection& a, const XoscTrimSelection& b) { + return a.enabled == b.enabled && (!a.enabled || (a.a == b.a && a.b == b.b)); +} + +// Parse decimal capacitor codes, not a guessed frequency correction in Hz. +inline bool parseXoscTrim(const char* value, XoscTrimSelection& selection) { + if (!value) return false; + if (!strcmp(value, "default")) { + selection = XoscTrimSelection{}; + return true; + } + const char* cursor = value; + unsigned pair[2] = {}; + for (unsigned field = 0; field < 2; ++field) { + if (*cursor < '0' || *cursor > '9') return false; + do { + pair[field] = pair[field] * 10 + unsigned(*cursor++ - '0'); + if (pair[field] > XoscTrimMaximum) return false; + } while (*cursor >= '0' && *cursor <= '9'); + if (field == 0 && *cursor == 0) { + pair[1] = pair[0]; + break; + } + if (field == 0) { + if (*cursor++ != ',') return false; + } else if (*cursor != 0) return false; + } + selection.enabled = true; + selection.a = static_cast(pair[0]); + selection.b = static_cast(pair[1]); + return true; +} + +constexpr size_t XoscTrimRecordSize = 8; + +inline void encodeXoscTrim(const XoscTrimSelection& selection, + uint8_t (&record)[XoscTrimRecordSize]) { + record[0] = 'X'; record[1] = 'T'; record[2] = 1; + record[3] = selection.enabled ? 1 : 0; + record[4] = selection.enabled ? selection.a : 0; + record[5] = selection.enabled ? selection.b : 0; + record[6] = 0; + record[7] = 0; + for (size_t i = 0; i < XoscTrimRecordSize - 1; ++i) record[7] ^= record[i]; +} + +inline bool decodeXoscTrim(const uint8_t* record, size_t length, + XoscTrimSelection& selection) { + if (!record || length != XoscTrimRecordSize || record[3] > 1 + || !validXoscTrim(record[4], record[5])) return false; + XoscTrimSelection candidate; + candidate.enabled = record[3] != 0; + candidate.a = record[4]; + candidate.b = record[5]; + uint8_t expected[XoscTrimRecordSize]; + encodeXoscTrim(candidate, expected); + if (memcmp(record, expected, length)) return false; + selection = candidate; + return true; +} + +}} // namespace mesh::lr2021 diff --git a/src/helpers/radiolib/LR2021CalibrationSettings.cpp b/src/helpers/radiolib/LR2021CalibrationSettings.cpp new file mode 100644 index 000000000..7ebb87fe6 --- /dev/null +++ b/src/helpers/radiolib/LR2021CalibrationSettings.cpp @@ -0,0 +1,169 @@ +#if defined(USE_LR2021) + +#include "LR2021CalibrationSettings.h" +#include "LR2021CalibrationPolicy.h" +#include "CustomLR2021Wrapper.h" +#include +#include +#include + +namespace mesh { namespace lr2021 { +namespace { +FILESYSTEM* settings_fs = nullptr; +XoscTrimSelection saved_selection; +XoscTrimSelection previous_selection; +bool pending = false; +bool failed = false; +bool rollback_failed = false; +uint32_t pending_started = 0; +constexpr uint32_t ApplyTimeoutMillis = 30000; +constexpr uint32_t ApplyHandoffMillis = 2000; +constexpr char path[] = "/radio_xtrim"; +constexpr char backup[] = "/radio_xtrim.bak"; +constexpr char temporary[] = "/radio_xtrim.tmp"; + +bool read(const char* name, XoscTrimSelection& selection) { + uint8_t bytes[XoscTrimRecordSize]; + return storage::readFixedSettingsFile(settings_fs, name, bytes, sizeof(bytes)) + && decodeXoscTrim(bytes, sizeof(bytes), selection); +} + +bool recover() { + XoscTrimSelection checked; + if (read(path, checked) || !read(backup, checked)) return true; + if (settings_fs->exists(path) && !settings_fs->remove(path)) return false; + return settings_fs->rename(backup, path) && read(path, checked); +} + +bool save(const XoscTrimSelection& selection) { + if (!settings_fs || !recover()) return false; + if (settings_fs->exists(temporary) && !settings_fs->remove(temporary)) return false; + if (settings_fs->exists(temporary)) return false; + File file = settings_fs->open(temporary, "w", true); + if (!file) return false; + uint8_t bytes[XoscTrimRecordSize]; + encodeXoscTrim(selection, bytes); + const bool written = file.write(bytes, sizeof(bytes)) == sizeof(bytes); + file.close(); + XoscTrimSelection checked; + if (!written || !read(temporary, checked) || !sameXoscTrim(selection, checked)) return false; + if (settings_fs->exists(backup) && !settings_fs->remove(backup)) return false; + if (settings_fs->exists(path) && !settings_fs->rename(path, backup)) return false; + const bool published = settings_fs->rename(temporary, path); + if (!published || !read(path, checked) || !sameXoscTrim(selection, checked)) { + // A failed rename/readback may have changed the public record anyway. + // Remove it before restoring the last complete setting, without touching + // radio hardware until the new public record has been verified. + if (settings_fs->exists(path)) settings_fs->remove(path); + if (settings_fs->exists(backup)) settings_fs->rename(backup, path); + return false; + } + if (settings_fs->exists(backup)) settings_fs->remove(backup); + return true; +} + +void failPending(CustomLR2021Wrapper& radio) { + radio.cancelXoscTrimApply(); + pending = false; + failed = true; + rollback_failed = !save(previous_selection); + if (!rollback_failed) saved_selection = previous_selection; +} + +void formatSelection(char* reply, size_t capacity) { + const char* suffix = pending ? " (pending)" : failed + ? rollback_failed ? " (failed; saved rollback failed)" : " (failed; previous saved setting restored)" : ""; + if (saved_selection.enabled) { + snprintf(reply, capacity, "> %u,%u%s", saved_selection.a, saved_selection.b, suffix); + } else { + snprintf(reply, capacity, "> default%s", suffix); + } +} +} // namespace + +bool loadLR2021CalibrationSettings(FILESYSTEM* fs, CustomLR2021Wrapper& radio) { + settings_fs = fs; + saved_selection = XoscTrimSelection{}; + previous_selection = XoscTrimSelection{}; + pending = failed = rollback_failed = false; + radio.cancelXoscTrimApply(); + if (!radio.supportsXoscTrim()) return false; + if (fs) recover(); + const bool loaded = fs && (read(path, saved_selection) || read(backup, saved_selection)); + if (!radio.configureBootXoscTrim(saved_selection.enabled, saved_selection.a, saved_selection.b)) { + failed = true; + return false; + } + return loaded; +} + +void loopLR2021CalibrationSettings(CustomLR2021Wrapper& radio) { + if (!pending) return; + const uint32_t elapsed = uint32_t(millis() - pending_started); + // Let the acknowledgement leave with the previous calibration first. + if (elapsed < ApplyHandoffMillis) return; + const auto result = radio.trySetXoscTrim(saved_selection.enabled, + saved_selection.a, saved_selection.b); + if (result == RadioParamApplyResult::APPLIED) { + pending = false; + failed = rollback_failed = false; + } else if (result == RadioParamApplyResult::FAILED + || (result == RadioParamApplyResult::BUSY + && elapsed - ApplyHandoffMillis >= ApplyTimeoutMillis)) { + // Give the wrapper its bounded RX-only admission attempt at the deadline. + // Protected work that remains busy must still terminate this request. + failPending(radio); + } +} + +bool handleLR2021CalibrationCommand(const char* command, char* reply, + size_t capacity, CustomLR2021Wrapper& radio) { + if (!strcmp(command, "get radio.freqerror")) { + radio.formatFrequencyError(reply, capacity); + return true; + } + const bool get = !strncmp(command, "get ", 4); + if (!get && strncmp(command, "set ", 4)) return false; + constexpr char key[] = "radio.xtrim"; + constexpr size_t length = sizeof(key) - 1; + if (strncmp(command + 4, key, length) + || (command[4 + length] && command[4 + length] != ' ')) return false; + if (!radio.supportsXoscTrim()) { + snprintf(reply, capacity, "Error: radio.xtrim requires an LR2021 with a crystal, not a TCXO"); + return true; + } + const char* value = command + 4 + length; + if (get) { + if (*value) snprintf(reply, capacity, "Error: use get radio.xtrim"); + else formatSelection(reply, capacity); + return true; + } + while (*value == ' ') ++value; + XoscTrimSelection candidate; + if (!parseXoscTrim(value, candidate)) { + snprintf(reply, capacity, "Error: use set radio.xtrim <0-47>[,<0-47>] or default"); + return true; + } + if (sameXoscTrim(candidate, saved_selection) && !failed) { + snprintf(reply, capacity, pending ? "OK - oscillator calibration pending" : "OK - oscillator calibration unchanged"); + return true; + } + if (!save(candidate)) { + snprintf(reply, capacity, "Error: oscillator calibration save failed; radio unchanged"); + return true; + } + if (!pending) { + previous_selection = XoscTrimSelection{}; + previous_selection.enabled = radio.getXoscTrim(previous_selection.a, previous_selection.b); + } + saved_selection = candidate; + pending = true; + failed = rollback_failed = false; + pending_started = millis(); + snprintf(reply, capacity, "OK - oscillator calibration applying in 2s"); + return true; +} + +}} // namespace mesh::lr2021 + +#endif // USE_LR2021 diff --git a/src/helpers/radiolib/LR2021CalibrationSettings.h b/src/helpers/radiolib/LR2021CalibrationSettings.h new file mode 100644 index 000000000..d575161aa --- /dev/null +++ b/src/helpers/radiolib/LR2021CalibrationSettings.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +class CustomLR2021Wrapper; + +namespace mesh { namespace lr2021 { + +// Load before radio initialization. No record leaves native trim unchanged. +bool loadLR2021CalibrationSettings(FILESYSTEM* fs, CustomLR2021Wrapper& radio); +void loopLR2021CalibrationSettings(CustomLR2021Wrapper& radio); +bool handleLR2021CalibrationCommand(const char* command, char* reply, + size_t reply_size, CustomLR2021Wrapper& radio); + +}} // namespace mesh::lr2021 diff --git a/src/helpers/radiolib/PrimaryRadioApplyWait.h b/src/helpers/radiolib/PrimaryRadioApplyWait.h new file mode 100644 index 000000000..5ec2fe7f0 --- /dev/null +++ b/src/helpers/radiolib/PrimaryRadioApplyWait.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +namespace mesh { + +// Only an accepted primary tuple may abandon an unfinished RX after this +// bounded handoff. Completed RX, chip BUSY, TX and exclusive owners are guarded +// separately by RadioLibWrapper. Ordinary scan/CAD/TX admission never uses it. +class PrimaryRadioApplyWait { + RadioProfileParams target_; + uint32_t started_ms_ = 0; + uint32_t wait_ms_ = 0; + bool temporary_ = false; + bool active_ = false; + + static uint32_t packetMillis(const RadioProfileParams& params, + uint16_t preamble, unsigned length) { + const double symbol_us = RadioProfiles::symbolUs(params); + if (symbol_us <= 0) return 30000; + const int de = symbol_us >= 16000 ? 1 : 0; + const int denominator = params.sf - 2 * de; + if (denominator <= 0) return 30000; + // Use the maximum coding redundancy, not just the selected TX CR. An RX + // header may select any supported coding rate. Match the wrapper's LoRa + // airtime formula without tuning the chip to the requested tuple first. + const int coded = (2 * length + (params.sf <= 6 ? 8 : 10) - 2 * de) / denominator; + const double symbols = preamble + (params.sf <= 6 ? 6.25 : 4.25) + 8 + coded * 8; + const double milliseconds = ceil(symbols * symbol_us / 1000.0); + return milliseconds >= 30000 ? 30000 : static_cast(milliseconds); + } + + public: + void clear() { active_ = false; } + + bool mayAbandonReceive(uint32_t now, const RadioProfileParams& target, bool temporary, + const RadioProfileParams& current, uint16_t current_preamble, + uint16_t target_preamble, unsigned maximum_length) { + if (!active_ || target_ != target || temporary_ != temporary) { + target_ = target; + temporary_ = temporary; + started_ms_ = now; + const uint32_t current_ms = packetMillis(current, current_preamble, maximum_length); + const uint32_t target_ms = packetMillis(target, target_preamble, maximum_length); + wait_ms_ = current_ms > target_ms ? current_ms : target_ms; + wait_ms_ = wait_ms_ >= 29000 ? 30000 : wait_ms_ + 1000; + if (wait_ms_ < 2000) wait_ms_ = 2000; + active_ = true; + } + return static_cast(now - started_ms_) >= wait_ms_; + } +}; + +} // namespace mesh diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 5637e8f7e..cd3461b5d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -418,9 +418,29 @@ uint8_t RadioLibWrapper::beginReconfigure() { mesh::radiotrace::trace().context(_radio, _active_profile, _profile_generation); #endif #if MESH_ENABLE_TX_BEACONS - if (_beacon_owned && !_beacon_restoring) return 2; + if (_beacon_owned && !_beacon_restoring) { +#if defined(USE_LR2021) + noteReconfigureAdmission("beacon-owned", 0); +#endif + return 2; + } +#endif + if (_cw_active) { +#if defined(USE_LR2021) + noteReconfigureAdmission("carrier-owned", 0); +#endif + return 2; + } +#if defined(USE_LR2021) && (MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX) + if (_morse_owned +#if MESH_ENABLE_MORSE_RX + || _morse_rx_owned +#endif + ) { + noteReconfigureAdmission("morse-owned", 0); + return 2; + } #endif - if (_cw_active) return 2; const uint8_t base_state = state; if (_profile_repair_required) { // The last transaction failed after changing the chip. Its old FIFO and @@ -431,13 +451,16 @@ uint8_t RadioLibWrapper::beginReconfigure() { #if MESH_HIL_PROFILE_TIMING profile_timing.admissionComplete(); #endif - if ( + const int16_t standby_status = #if MESH_HIL_PROFILE_TIMING - profile_timing.standbyResult(_radio->standby()) + profile_timing.standbyResult(_radio->standby()); #else - _radio->standby() + _radio->standby(); +#endif + if (standby_status != RADIOLIB_ERR_NONE) { +#if defined(USE_LR2021) + noteReconfigureAdmission("standby", standby_status); #endif - != RADIOLIB_ERR_NONE) { state = STATE_IDLE; return 3; } @@ -449,23 +472,39 @@ uint8_t RadioLibWrapper::beginReconfigure() { // On SX126x/LR11xx duty-cycle RX, BUSY may remain asserted during the sleep // side of the cycle. Do not issue an IRQ/preamble query over SPI then; due // scheduled work will retry as soon as the next safe listen window opens. +#if defined(USE_LR2021) + if (hasPendingRadioInterrupt()) { noteReconfigureAdmission("pending-irq", 0); return 2; } + if (base_state == STATE_TX_WAIT) { noteReconfigureAdmission("transmitting", 0); return 2; } + if (isChipBusy()) { noteReconfigureAdmission("chip-busy", 0); return 2; } + if (isReceivingPacket() && !_primary_receive_preempt) { + noteReconfigureAdmission("receiving", 0); + return 2; + } + // A completion can race the activity probe. Stop only unfinished RX; never + // claim/clear an old-profile FIFO in order to apply a command faster. + if (hasPendingRadioInterrupt()) { noteReconfigureAdmission("pending-irq", 0); return 2; } +#else if (hasPendingRadioInterrupt() || base_state == STATE_TX_WAIT || isChipBusy() || isReceivingPacket()) { return 2; } +#endif #if MESH_HIL_PROFILE_TIMING profile_timing.admissionComplete(); #endif const bool resume_rx = base_state == STATE_RX; if (_rx_ps_armed || resume_rx) { - if ( + const int16_t standby_status = #if MESH_HIL_PROFILE_TIMING - profile_timing.standbyResult(_radio->standby()) + profile_timing.standbyResult(_radio->standby()); #else - _radio->standby() + _radio->standby(); +#endif + if (standby_status != RADIOLIB_ERR_NONE) { +#if defined(USE_LR2021) + noteReconfigureAdmission("standby", standby_status); #endif - != RADIOLIB_ERR_NONE) { state = STATE_IDLE; _profile_refresh_required = _profile_repair_required = true; return 3; @@ -475,8 +514,16 @@ uint8_t RadioLibWrapper::beginReconfigure() { // An RX completion during standby still owns its FIFO and old profile. // Defer reconfiguration until recvRaw consumes it, rather than rearming and // clearing that completion as part of endReconfigure(). - if (hasPendingRadioInterrupt()) return 2; + if (hasPendingRadioInterrupt()) { +#if defined(USE_LR2021) + noteReconfigureAdmission("pending-irq", 0); +#endif + return 2; + } state = STATE_IDLE; +#if defined(USE_LR2021) + noteReconfigureAdmission("admitted", 0); +#endif return resume_rx; } @@ -1411,7 +1458,22 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetPrimaryParams(const mesh::Rad _profiles.primary.preamble = p.preamble; _profiles.primary.protocol = p.protocol; _profiles.primary_temporary = temporary; +#if defined(USE_LR2021) + const bool previous_preempt = _primary_receive_preempt; + if (supportsBoundedPrimaryReconfigure()) { + const auto& current = _profiles.params(_active_profile); + const uint16_t requested_preamble = preview.preamble(0, + rxPowerSavingPreambleForParams(p.sf, p.bw)); + _primary_receive_preempt = _primary_apply_wait.mayAbandonReceive( + millis(), p, temporary, current, currentPreambleLength(), + requested_preamble, MAX_TRANS_UNIT); + } +#endif const auto result = trySetParams(p.freq, p.bw, p.sf, p.cr, timings); +#if defined(USE_LR2021) + _primary_receive_preempt = previous_preempt; + if (result == mesh::RadioParamApplyResult::APPLIED) _primary_apply_wait.clear(); +#endif if (result != mesh::RadioParamApplyResult::APPLIED) { _profiles.primary = previous; _profiles.primary_temporary = was_temp; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index f27808242..ce8bd1e12 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -11,6 +11,9 @@ #include #include "CadTiming.h" #include "RadioAirtime.h" +#if defined(USE_LR2021) +#include "PrimaryRadioApplyWait.h" +#endif #include "RXPowerSaving.h" #include "HilTxFrequencyGuard.h" #include "ESP32RadioTrace.h" @@ -131,6 +134,15 @@ protected: // A failed parameter transaction left the physical tuple untrusted. Unlike // an ordinary refresh, its FIFO and activity flags cannot identify a packet. bool _profile_repair_required = false; +#if defined(USE_LR2021) + mesh::PrimaryRadioApplyWait _primary_apply_wait; + bool _primary_receive_preempt = false; +#endif + virtual bool supportsBoundedPrimaryReconfigure() const { return false; } + virtual void noteReconfigureAdmission(const char* reason, int16_t status) { + (void)reason; (void)status; + } + #if defined(NRF52_PLATFORM) // Published by the scanner before the final sleep predicates. The late // query uses integers only: floating-point work can leave a stale FPU event. diff --git a/test/fixtures/morse_rx_native.cpp b/test/fixtures/morse_rx_native.cpp index 8b19151fa..4fe096cf0 100644 --- a/test/fixtures/morse_rx_native.cpp +++ b/test/fixtures/morse_rx_native.cpp @@ -128,6 +128,7 @@ struct LR1121Probe : LR1121 { @LR1121@ }; struct LR2021Probe : LR2021 { + int16_t restoreXoscTrim() { return 0; } @LR2021_REGULATOR@ uint8_t gainModeLf=1,gainModeHf=3; uint32_t _rf_switch_pins[Module::RFSWITCH_MAX_PINS]={0x80,0x81,RADIOLIB_NC}; diff --git a/test/test_beacon_radio.py b/test/test_beacon_radio.py index 8f47e5db1..20ff257e3 100644 --- a/test/test_beacon_radio.py +++ b/test/test_beacon_radio.py @@ -435,7 +435,7 @@ class BeaconRadioTest(unittest.TestCase): "if (_beacon_owned || _cw_active) return Result::BUSY;"), ("_profile_repair_required = false; // Full normal tuple/protocol/power/IRQ restore succeeded.", "/* omit repair retirement before ordinary RX */"), - ("if (_beacon_owned && !_beacon_restoring) return 2;", "/* remove ownership guard */"), + ("if (_beacon_owned && !_beacon_restoring) {", "if (false) { /* remove ownership guard */"), ("if (_beacon_owned && !_beacon_restoring) return;", "/* permit RX on beacon tuple */"), ("bool applied = _radio->standby() == RADIOLIB_ERR_NONE", "cacheParams(p.freq, p.bw, p.sf, p.cr); bool applied = _radio->standby() == RADIOLIB_ERR_NONE"), diff --git a/test/test_lr2021_apply_transaction.py b/test/test_lr2021_apply_transaction.py new file mode 100644 index 000000000..4efe9e484 --- /dev/null +++ b/test/test_lr2021_apply_transaction.py @@ -0,0 +1,249 @@ +"""Exercise the production LR2021 calibration transaction and diagnostics.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def method(source, signature): + start = source.index(signature) + end = source.index("{", start) + 1 + depth = 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start:end] + + +class LR2021ApplyTransactionTest(unittest.TestCase): + def test_actual_apply_params_reports_each_native_failure(self): + source = (ROOT / "src/helpers/radiolib/CustomLR2021Wrapper.h").read_text() + apply = method(source, "bool applyParams(").replace(" override", "") + receive = method(source, "int startReceiveMode(").replace(" override", "") + code = r''' +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_UNKNOWN -1 +#define LORA_TX_POWER 5 +namespace mesh {int8_t clampLoRaTxPower(int8_t power,float){return power;}} +struct PacketMillis {uint32_t preambleMillis,payloadMillis;}; +struct CustomLR2021 { + int calls=0,fail=0,updated=0; + int16_t next(){return ++calls==fail?-705:0;} + int16_t setFrequency(float){return next();} + int16_t setOutputPower(int8_t){return next();} + int16_t setSpreadingFactor(uint8_t){return next();} + int16_t setBandwidth(float){return next();} + int16_t setCodingRate(uint8_t){return next();} + int16_t setPreambleLength(uint16_t){return next();} + void setPreambleMillis(uint32_t v){assert(v==44);++updated;} + void setMaxPayloadMillis(uint32_t v){assert(v==440);++updated;} + int16_t standby(){return next();} + int16_t startReceive(){return next();} +}; +struct Board {bool ready=true;bool prepareRadioFrequency(float){return ready;}}; +struct Wrapper { + mesh::lr2021::ApplyStatus _apply_status; + CustomLR2021 chip;CustomLR2021* _radio=&chip; + Board board;Board* _board=&board; + bool _dbm_valid=true;int8_t _cur_dbm=5; + int16_t recordApply(mesh::lr2021::ApplyStatus::Stage s,int16_t c){return _apply_status.record(s,c);} + uint16_t preambleLengthForParams(uint8_t,float){return 32;} + bool applySideDetectorConfig(uint8_t,float){return recordApply(mesh::lr2021::ApplyStatus::Stage::SideDetectors,chip.next())==0;} + PacketMillis calcMaxPacketMillis(uint8_t,float,uint8_t,uint16_t){return {44,440};} + @APPLY@ + @RECEIVE@ +}; +int main(){ + using Stage=mesh::lr2021::ApplyStatus::Stage; + const Stage stages[]={Stage::Frequency,Stage::Power,Stage::SpreadingFactor, + Stage::Bandwidth,Stage::CodingRate,Stage::Preamble,Stage::SideDetectors}; + for(int failure=1;failure<=7;++failure){ + Wrapper w;w._apply_status.begin();w.chip.fail=failure; + assert(!w.applyParams(910.54f,62.5f,7,5)); + assert(w._apply_status.stage==stages[failure-1]&&w._apply_status.code==-705); + assert(w.chip.calls==failure&&w.chip.updated==0); + w.chip.fail=0;assert(w.applyParams(910.525f,62.5f,7,5)); + assert(w._apply_status.stage==stages[failure-1]&&w._apply_status.code==-705); + } + Wrapper rail;rail.board.ready=false; + assert(!rail.applyParams(910.54f,62.5f,7,5)); + assert(rail._apply_status.stage==Stage::FrequencyRail&&rail.chip.calls==0); + Wrapper ready;assert(ready.applyParams(910.54f,62.5f,7,5));assert(ready.chip.updated==2); + for(int failure=1;failure<=2;++failure){ + Wrapper rx;rx.chip.fail=failure; + assert(rx.startReceiveMode()==-705); + assert(rx._apply_status.stage==(failure==1?Stage::Standby:Stage::Receive)); + assert(rx.chip.calls==failure); + } +} +'''.replace("@APPLY@", apply).replace("@RECEIVE@", receive) + self.run_cpp(code) + + def test_real_trim_transaction_restores_selection_modem_and_receiver(self): + source = (ROOT / "src/helpers/radiolib/CustomLR2021Wrapper.h").read_text() + transaction = method(source, "mesh::RadioParamApplyResult trySetXoscTrim(") + cancel = method(source, "void cancelXoscTrimApply(") + code = r''' +#include +#include +#include +#include +#include +#include +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_UNKNOWN -1 +#define MESH_ENABLE_CARRIER_TOOLS 1 +#define MESH_ENABLE_MORSE_RX 1 +#define MESH_ENABLE_MESHTEXT_WIRE 1 +#define MAX_TRANS_UNIT 255 +static uint32_t now_ms=100; +uint32_t millis(){return now_ms;} +namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; } +struct CustomLR2021 { + bool supported=true, enabled=false; + uint8_t a=0,b=0; + int commands=0, configurations=0; + int16_t error=0; + bool getXoscTrim(uint8_t& x,uint8_t& y)const{x=a;y=b;return enabled;} + bool configureBootXoscTrim(bool e,uint8_t x,uint8_t y){ + ++configurations;enabled=e;a=e?x:0;b=e?y:0;return true; + } + void clearXoscTrim(){enabled=false;a=b=0;} + int16_t applyXoscTrim(uint8_t x,uint8_t y){ + ++commands;if(error)return error;configureBootXoscTrim(true,x,y);return 0; + } +}; +struct Wrapper { + mesh::lr2021::ApplyStatus _apply_status; + mesh::PrimaryRadioApplyWait _trim_apply_wait; + uint16_t _trim_apply_key=UINT16_MAX; + mesh::RadioProfiles _profiles; + uint8_t _active_profile=0; + bool _primary_receive_preempt=false; + CustomLR2021 chip; + CustomLR2021* _radio=&chip; + bool _profile_repair_required=false,_profile_refresh_required=false; + bool _morse_owned=false,_morse_rx_owned=false,_params_valid=true; + bool _dbm_valid=true,_dbm_applied=true,_rx_boosted_gain_valid=true; + bool _rx_boosted_gain_applied=true,_cur_rx_boosted_gain=true; + float _cur_freq=910.525f,_cur_bw=62.5f; + uint8_t _cur_sf=7,_cur_cr=5,_physical_protocol=1; + int8_t _cur_dbm=5; + uint8_t admission=1; + int native_fail=0,resets=0,tuple_applies=0,rearms=0; + int fail_rearm=0; + bool rx=true,fail_reset=false; + Wrapper(){_profiles.primary.freq=910.525f;_profiles.primary.bw=62.5f; + _profiles.primary.sf=7;_profiles.primary.cr=5;} + uint16_t currentPreambleLength()const{return 32;} + uint16_t profilePreamble(uint8_t)const{return 32;} + bool supportsXoscTrim()const{return chip.supported;} + void noteReconfigureAdmission(const char*,int16_t){} + uint8_t beginReconfigure(){if(admission<=1)rx=false;return admission;} + int16_t recordApply(mesh::lr2021::ApplyStatus::Stage s,int16_t c){return _apply_status.record(s,c);} + bool applyParams(float f,float bw,uint8_t sf,uint8_t cr){ + assert(f==_cur_freq&&bw==_cur_bw&&sf==_cur_sf&&cr==_cur_cr); + ++tuple_applies; + if(native_fail==1){recordApply(mesh::lr2021::ApplyStatus::Stage::Frequency,-705);return false;} + return true; + } + bool applyProtocol(uint8_t p){ + assert(p==_physical_protocol); + if(native_fail==2){recordApply(mesh::lr2021::ApplyStatus::Stage::Protocol,-706);return false;} + return true; + } + int16_t applyCachedTxPower(int8_t p){ + assert(p==_cur_dbm); + return recordApply(mesh::lr2021::ApplyStatus::Stage::Power,native_fail==3?-707:0); + } + bool applyRxBoostedGainMode(bool b){ + assert(b==_cur_rx_boosted_gain); + if(native_fail==4){recordApply(mesh::lr2021::ApplyStatus::Stage::Gain,-708);return false;} + return true; + } + void recalibrateNoiseFloor(){} + bool restoreAfterDeepInit(){++resets;_profile_repair_required=false;return !fail_reset;} + void endReconfigure(bool resume){ + assert(resume);++rearms; + if(fail_rearm>0){--fail_rearm;rx=false;recordApply(mesh::lr2021::ApplyStatus::Stage::Receive,-709);} + else rx=true; + } + bool isInRecvMode()const{return rx;} + @CANCEL@ + @TRANSACTION@ +}; +int main(){ + using Result=mesh::RadioParamApplyResult; + using Stage=mesh::lr2021::ApplyStatus::Stage; + for(uint8_t admission:{2,3}){ + Wrapper w;w.admission=admission; + assert(w.trySetXoscTrim(true,8,8)==(admission==2?Result::BUSY:Result::FAILED)); + assert(!w.chip.enabled&&w.chip.commands==0&&w.chip.configurations==0&&w.resets==0); + assert(w._trim_apply_key==(admission==2?uint16_t(0x1208):UINT16_MAX)); + assert(!w._primary_receive_preempt); + w.cancelXoscTrimApply();assert(w._trim_apply_key==UINT16_MAX); + } + for(int owner=0;owner<2;++owner){ + Wrapper w;(owner?w._morse_rx_owned:w._morse_owned)=true; + assert(w.trySetXoscTrim(true,8,8)==Result::BUSY&&w.chip.commands==0&&w.rx); + } + for(int invalid:{48,255}){ + Wrapper w;assert(w.trySetXoscTrim(true,invalid,8)==Result::FAILED&&w.chip.commands==0); + } + Wrapper unsupported;unsupported.chip.supported=false; + assert(unsupported.trySetXoscTrim(true,8,8)==Result::FAILED); + Wrapper success; + assert(success.trySetXoscTrim(true,8,8)==Result::APPLIED); + assert(success.chip.enabled&&success.chip.a==8&&success.chip.b==8&&success.rx); + assert(success.tuple_applies==1&&success.rearms==1&&success.resets==0); + assert(success._apply_status.stage==Stage::Applied&&success._apply_status.code==0); + assert(success._trim_apply_key==UINT16_MAX&&!success._primary_receive_preempt); + int commands=success.chip.commands; + assert(success.trySetXoscTrim(true,8,8)==Result::APPLIED&&success.chip.commands==commands); + assert(success.trySetXoscTrim(false,0,0)==Result::APPLIED); + assert(!success.chip.enabled&&success.resets==1&&success.rx); + for(bool old:{false,true})for(int failure=0;failure<=5;++failure){ + Wrapper w;w.chip.configureBootXoscTrim(old,3,4); + if(failure==0)w.chip.error=-704; + else if(failure==5)w.fail_rearm=1; + else w.native_fail=failure; + assert(w.trySetXoscTrim(true,8,8)==Result::FAILED); + assert(w.chip.enabled==old&&(!old||(w.chip.a==3&&w.chip.b==4))); + assert(w.resets==1&&w.rx&&!w._profile_repair_required); + assert(w._trim_apply_key==UINT16_MAX&&!w._primary_receive_preempt); + assert(w._apply_status.code==-704-failure&&w._apply_status.failed); + } + Wrapper failed_restore;failed_restore.chip.error=-704;failed_restore.fail_reset=true; + assert(failed_restore.trySetXoscTrim(true,8,8)==Result::FAILED); + assert(failed_restore._profile_repair_required&&failed_restore._profile_refresh_required); + // First failure survives successful rollback writes; the next transaction + // resets it, and positive admission guards remain distinguishable. + mesh::lr2021::ApplyStatus status;status.begin();status.record(Stage::Frequency,-705); + status.record(Stage::Receive,0);status.applied(); + assert(status.stage==Stage::Frequency&&status.code==-705); + status.begin();status.applied();assert(status.stage==Stage::Applied&&status.code==0); +} +'''.replace("@TRANSACTION@", transaction).replace("@CANCEL@", cancel) + code = code.replace("#include ", "#include \n#include ") + self.run_cpp(code) + + def run_cpp(self, code): + with tempfile.TemporaryDirectory(prefix="meshcore-lr2021-apply-") as directory: + cpp, binary = Path(directory) / "test.cpp", Path(directory) / "test" + cpp.write_text(code, encoding="ascii") + built = subprocess.run(["g++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie", "-I", str(ROOT / "src"), + str(cpp), "-o", str(binary)], capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_lr2021_calibration.py b/test/test_lr2021_calibration.py new file mode 100644 index 000000000..38b9e33fe --- /dev/null +++ b/test/test_lr2021_calibration.py @@ -0,0 +1,360 @@ +"""Execute LR2021 trim persistence, native commands and packet-offset capture.""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from cpp_source import body +from test_display_power import FS_STUB + + +ROOT = Path(__file__).resolve().parents[1] +RADIO = ROOT / "src/helpers/radiolib" + + +def method(source, signature): + return body(source, signature) + + +POLICY = r''' +#include +#include +#include +#include +using namespace mesh::lr2021; +int main() { + XoscTrimSelection selection; + for (unsigned a=0;a<=47;++a) for (unsigned b=0;b<=47;++b) { + XoscTrimSelection expected{true,uint8_t(a),uint8_t(b)}; + uint8_t bytes[XoscTrimRecordSize];encodeXoscTrim(expected,bytes); + assert(decodeXoscTrim(bytes,sizeof(bytes),selection)); + assert(sameXoscTrim(expected,selection)); + for (unsigned i=0;i