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