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.
This commit is contained in:
mikecarper committed 2026-10-26 15:55:36 -07:00
1 parent b00d346df7
commit 8a8c80ee6a
32 files changed
+1913 -34

No files matched your search

+4
View File
@@ -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
+51
View File
@@ -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.
+10
View File
@@ -1,4 +1,7 @@
#include <helpers/RegulatorSettings.h>
#if defined(USE_LR2021)
#include <helpers/radiolib/LR2021CalibrationSettings.h>
#endif
#if defined(ESP32_PLATFORM)
#include <helpers/esp32/CpuClockPolicy.h>
#include <helpers/esp32/PowerTestControls.h>
@@ -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
+9
View File
@@ -1,4 +1,7 @@
#include <helpers/RegulatorSettings.h>
#if defined(USE_LR2021)
#include <helpers/radiolib/LR2021CalibrationSettings.h>
#endif
#include <helpers/LedSettings.h>
#include <Arduino.h> // needed for PlatformIO
#include <helpers/ui/StartupScreen.h>
@@ -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();
+8
View File
@@ -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
+9
View File
@@ -1,4 +1,7 @@
#include <helpers/RegulatorSettings.h>
#if defined(USE_LR2021)
#include <helpers/radiolib/LR2021CalibrationSettings.h>
#endif
#include <helpers/sensors/SolarXiaoPower.h>
#include <helpers/LedSettings.h>
#include <Arduino.h> // 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
+8
View File
@@ -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;
+9
View File
@@ -1,4 +1,7 @@
#include <helpers/RegulatorSettings.h>
#if defined(USE_LR2021)
#include <helpers/radiolib/LR2021CalibrationSettings.h>
#endif
#include <helpers/sensors/SolarXiaoPower.h>
#include <helpers/LedSettings.h>
#include <Arduino.h> // 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
+8
View File
@@ -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;
+9
View File
@@ -1,4 +1,7 @@
#include <helpers/RegulatorSettings.h>
#if defined(USE_LR2021)
#include <helpers/radiolib/LR2021CalibrationSettings.h>
#endif
#include <helpers/LedSettings.h>
#include "SensorMesh.h"
#include <math.h>
@@ -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
+10
View File
@@ -1,4 +1,7 @@
#include <helpers/RegulatorSettings.h>
#if defined(USE_LR2021)
#include <helpers/radiolib/LR2021CalibrationSettings.h>
#endif
#include <helpers/sensors/SolarXiaoPower.h>
#include <helpers/LedSettings.h>
#include <Arduino.h>
@@ -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
+100 -1
View File
@@ -4,7 +4,9 @@
#include "RadioRegulatorState.h"
#include "MeshCore.h"
#include "LR2021Band.h"
#include "LR2021CalibrationPolicy.h"
#include "../MorseRx.h"
#include <stdio.h>
#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<long>(_last_frequency_error_hz), static_cast<double>(_last_frequency_error_mhz),
static_cast<unsigned long>(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) {
+194 -19
View File
@@ -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<CustomLR2021*>(_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<const CustomLR2021*>(_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<const CustomLR2021*>(_radio)->supportsXoscTrim();
}
bool configureBootXoscTrim(bool enabled, uint8_t a, uint8_t b) {
return static_cast<CustomLR2021*>(_radio)->configureBootXoscTrim(enabled, a, b);
}
bool getXoscTrim(uint8_t& a, uint8_t& b) const {
return static_cast<const CustomLR2021*>(_radio)->getXoscTrim(a, b);
}
void formatFrequencyError(char* reply, size_t capacity) const {
static_cast<const CustomLR2021*>(_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<CustomLR2021*>(_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<CustomLR2021*>(_radio)->setProfileProtocol(sync) == RADIOLIB_ERR_NONE;
return recordApply(mesh::lr2021::ApplyStatus::Stage::Protocol,
static_cast<CustomLR2021*>(_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;
}
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
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
@@ -0,0 +1,88 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
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<uint8_t>(pair[0]);
selection.b = static_cast<uint8_t>(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
@@ -0,0 +1,169 @@
#if defined(USE_LR2021)
#include "LR2021CalibrationSettings.h"
#include "LR2021CalibrationPolicy.h"
#include "CustomLR2021Wrapper.h"
#include <helpers/CheckedSettingsFile.h>
#include <stdio.h>
#include <string.h>
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
@@ -0,0 +1,15 @@
#pragma once
#include <helpers/IdentityStore.h>
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
@@ -0,0 +1,54 @@
#pragma once
#include <RadioProfiles.h>
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<uint32_t>(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<uint32_t>(now - started_ms_) >= wait_ms_;
}
};
} // namespace mesh
+73 -11
View File
@@ -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;
+12
View File
@@ -11,6 +11,9 @@
#include <helpers/IdentityGeneration.h>
#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.
+1
View File
@@ -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};
+1 -1
View File
@@ -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"),
+249
View File
@@ -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 <cassert>
#include <cstdint>
#include <helpers/radiolib/LR2021ApplyStatus.h>
#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 <cassert>
#include <cstdint>
#include <cstring>
#include <helpers/radiolib/LR2021ApplyStatus.h>
#include <helpers/radiolib/LR2021CalibrationPolicy.h>
#include <helpers/radiolib/PrimaryRadioApplyWait.h>
#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 <cassert>", "#include <cassert>\n#include <initializer_list>")
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()
+360
View File
@@ -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 <cassert>
#include <cstring>
#include <initializer_list>
#include <helpers/radiolib/LR2021CalibrationPolicy.h>
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<sizeof(bytes);++i) for(unsigned bit=0;bit<8;++bit) {
bytes[i]^=1U<<bit;selection={true,17,19};
assert(!decodeXoscTrim(bytes,sizeof(bytes),selection));
assert(selection.enabled && selection.a==17 && selection.b==19);
bytes[i]^=1U<<bit;
}
}
for(const char* value:{"0","47","03","0,47","47,0","default"}) {
assert(parseXoscTrim(value,selection));
if(!strcmp(value,"default")) assert(!selection.enabled);
else assert(selection.enabled);
}
for(const char* invalid:{"","48","256","-1","+3","3,48","3,",",3",
"3,4,5","3.5","3,4 ","3, 4","default ","Default","99999999999999999"}) {
selection={true,17,19};
assert(!parseXoscTrim(invalid,selection));
assert(selection.enabled && selection.a==17 && selection.b==19);
}
assert(!parseXoscTrim(nullptr,selection));
XoscTrimSelection native;uint8_t bytes[XoscTrimRecordSize];encodeXoscTrim(native,bytes);
assert(decodeXoscTrim(bytes,sizeof(bytes),selection) && !selection.enabled);
bytes[4]=1;bytes[7]^=1;
assert(!decodeXoscTrim(bytes,sizeof(bytes),selection)); // no hidden default pair
const uint8_t samples[][3]={{0,0,0},{0,0,1},{0xff,0xff,0xff},
{0x7f,0xff,0xff},{0x80,0,0},{0xff,0xfc,0x18},{0,3,0xe8}};
const int32_t expected[]={0,1,-1,8388607,-8388608,-1000,1000};
for(unsigned i=0;i<7;++i) assert(decodeFrequencyErrorHz(samples[i])==expected[i]);
}
'''
DRIVER = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <vector>
#include <helpers/radiolib/LR2021CalibrationPolicy.h>
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_ERR_UNKNOWN -16
#define RADIOLIB_ERR_UNSUPPORTED -25
#define RADIOLIB_ERR_PACKET_TOO_SHORT -4
#define RADIOLIB_LR2021_STANDBY_RC 0
#define RADIOLIB_LR2021_CMD_SET_XOSC_CP_TRIM 0x0131
#define RADIOLIB_LR2021_CMD_GET_LORA_PACKET_STATUS 0x022a
#define RADIOLIB_LR2021_PACKET_TYPE_NONE 0
#define RADIOLIB_LR2021_PACKET_TYPE_LORA 1
struct Hal {uint32_t now=1000;uint32_t millis(){return now;}};
struct Module {Hal clock;Hal* hal=&clock;};
class LR2021 {
public:
Module storage;Module* mod=&storage;
float freqMHz=910.525f;
int standby_result=0,command_result=0,read_result=0,type_result=0,fifo_result=0;
uint8_t modem=1,trim_a=23,trim_b=25;
uint16_t fifo=4;
int trim_commands=0,status_commands=0,reads=0,standbys=0,clears=0;
uint8_t raw_offset[3]={0xff,0xfc,0x18};
std::vector<int> sequence;
int16_t standby(uint8_t mode) {assert(mode==0);++standbys;sequence.push_back(1);return standby_result;}
int16_t SPIcommand(uint16_t command,bool write,uint8_t* data,size_t length) {
if(command==0x0131) {
assert(write && length==2);++trim_commands;sequence.push_back(2);
// A failed command can still change the chip. Wrapper rollback is required.
trim_a=data[0];trim_b=data[1];
} else {
assert(command==0x022a && !write && length==9);++status_commands;sequence.push_back(3);
memcpy(data+6,raw_offset,3);
}
return command_result;
}
int16_t getPacketType(uint8_t* type) {*type=modem;return type_result;}
int16_t getRxFifoLevel(uint16_t* size) {*size=fifo;return fifo_result;}
size_t getPacketLength() {return 4;}
int16_t clearRxFifo() {++clears;fifo=0;return 0;}
int16_t readData(uint8_t*,size_t) {++reads;sequence.push_back(4);return read_result;}
};
class CustomLR2021:public LR2021 {
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;
public:
@METHODS@
};
int main() {
CustomLR2021 radio;
uint8_t a=1,b=2;
assert(!radio.getXoscTrim(a,b) && a==1 && b==2);
#if defined(LR2021_TCXO_VOLTAGE) && LR2021_TCXO_VOLTAGE == 0
assert(radio.supportsXoscTrim());
assert(radio.restoreXoscTrim()==0 && radio.trim_commands==0);
assert(!radio.configureBootXoscTrim(true,48,0));
assert(radio.configureBootXoscTrim(true,3,4) && radio.trim_commands==0);
assert(radio.getXoscTrim(a,b) && a==3 && b==4);
assert(radio.restoreXoscTrim()==0 && radio.trim_a==3 && radio.trim_b==4);
assert(radio.sequence[0]==1 && radio.sequence[1]==2);
radio.standby_result=-71;
assert(radio.applyXoscTrim(8,9)==-71 && radio.trim_commands==1);
assert(radio.getXoscTrim(a,b) && a==3 && b==4);
radio.standby_result=0;radio.command_result=-72;
assert(radio.applyXoscTrim(8,9)==-72 && radio.trim_commands==2);
assert(radio.getXoscTrim(a,b) && a==3 && b==4);
radio.command_result=0;
assert(radio.restoreXoscTrim()==0 && radio.trim_a==3 && radio.trim_b==4);
radio.clearXoscTrim();const int before=radio.trim_commands;
assert(!radio.getXoscTrim(a,b) && radio.restoreXoscTrim()==0 && radio.trim_commands==before);
#else
assert(!radio.supportsXoscTrim());
assert(!radio.configureBootXoscTrim(true,3,4));
assert(radio.applyXoscTrim(3,4)==RADIOLIB_ERR_UNSUPPORTED && radio.trim_commands==0);
#endif
char result[160];radio.formatFrequencyError(result,sizeof(result));
assert(strstr(result,"unavailable"));
uint8_t packet[4];radio.sequence.clear();
assert(radio.readData(packet,4)==0);
assert(radio.sequence.size()==2 && radio.sequence[0]==3 && radio.sequence[1]==4);
radio.mod->hal->now+=2000;radio.freqMHz=915;
radio.formatFrequencyError(result,sizeof(result));
assert(strstr(result,"-1000 Hz") && strstr(result,"freq=910.525") && strstr(result,"age=2s"));
radio.raw_offset[0]=0;radio.raw_offset[1]=0;radio.raw_offset[2]=0;
radio.read_result=-7;
assert(radio.readData(packet,4)==-7);
radio.formatFrequencyError(result,sizeof(result));assert(strstr(result,"-1000 Hz"));
radio.read_result=0;radio.command_result=-72;
assert(radio.readData(packet,4)==0);
radio.formatFrequencyError(result,sizeof(result));assert(strstr(result,"-1000 Hz"));
radio.command_result=0;radio.modem=2;
const int status_before=radio.status_commands;
assert(radio.readData(packet,4)==0 && radio.status_commands==status_before);
radio.modem=1;radio.fifo=3;
assert(radio.readData(packet,4)==RADIOLIB_ERR_PACKET_TOO_SHORT && radio.clears==1);
radio.formatFrequencyError(result,sizeof(result));assert(strstr(result,"-1000 Hz"));
radio.fifo=4;
assert(radio.readData(packet,4)==0);
radio.formatFrequencyError(result,sizeof(result));assert(strstr(result,"+0 Hz") && strstr(result,"freq=915.000000"));
}
'''
WRAPPER = r'''
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <helpers/radiolib/LR2021CalibrationPolicy.h>
namespace mesh {enum class RadioParamApplyResult {APPLIED,BUSY,FAILED};}
inline uint32_t test_now=100;
inline uint32_t millis(){return test_now;}
class CustomLR2021Wrapper {
public:
bool supported=true,selected=false;
uint8_t a=0,b=0;
unsigned calls=0,boot_calls=0,cancels=0;
mesh::RadioParamApplyResult result=mesh::RadioParamApplyResult::APPLIED;
bool supportsXoscTrim() const {return supported;}
void cancelXoscTrimApply(){++cancels;}
bool getXoscTrim(uint8_t& first,uint8_t& second) const {
if(!selected)return false;
first=a;second=b;return true;
}
bool configureBootXoscTrim(bool enabled,uint8_t first,uint8_t second) {
++boot_calls;selected=enabled;a=first;b=second;return true;
}
mesh::RadioParamApplyResult trySetXoscTrim(bool enabled,uint8_t first,uint8_t second) {
++calls;
if(result==mesh::RadioParamApplyResult::APPLIED){selected=enabled;a=first;b=second;}
return result;
}
void formatFrequencyError(char* out,size_t size) const {snprintf(out,size,"> +125 Hz; age=1s");}
};
'''
SETTINGS = r'''
#include <cassert>
#include <cstring>
#include <helpers/radiolib/LR2021CalibrationSettings.h>
#include <helpers/radiolib/CustomLR2021Wrapper.h>
using namespace mesh::lr2021;
char reply[160];
static bool command(CustomLR2021Wrapper& radio,const char* text){return handleLR2021CalibrationCommand(text,reply,sizeof(reply),radio);}
int main() {
FILESYSTEM fs;CustomLR2021Wrapper radio;
assert(!loadLR2021CalibrationSettings(&fs,radio) && !radio.selected && !radio.calls);
assert(!command(radio,"get freq") && !command(radio,"get radio.xtrimmed"));
assert(command(radio,"get radio.xtrim") && !strcmp(reply,"> default"));
assert(command(radio,"get radio.freqerror") && strstr(reply,"+125 Hz"));
for(const char* bad:{"48","-1","1,48","1.5","1,2 extra",""}) {
const std::string input=std::string("set radio.xtrim ")+bad;
assert(command(radio,input.c_str()) && strstr(reply,"Error:"));
assert(!radio.calls && fs.files.empty());
}
assert(command(radio,"set radio.xtrim 3,4") && strstr(reply,"applying in 2s"));
assert(!radio.calls && !radio.selected && fs.files.count("/radio_xtrim"));
test_now+=1999;loopLR2021CalibrationSettings(radio);assert(!radio.calls && !radio.selected);
test_now+=1;loopLR2021CalibrationSettings(radio);
assert(radio.selected && radio.a==3 && radio.b==4 && radio.calls==1);
const auto original=fs.files["/radio_xtrim"];
const unsigned calls=radio.calls;
assert(command(radio,"set radio.xtrim 3,4") && strstr(reply,"unchanged") && radio.calls==calls);
for(int failure=0;failure<5;++failure) {
fs.fail_write=failure==0;fs.corrupt_write=failure==1;fs.fail_temp_read=failure==2;
fs.fail_publish=failure==3;fs.fail_open_write=failure==4;
assert(command(radio,"set radio.xtrim 8") && strstr(reply,"save failed"));
assert(radio.calls==calls && radio.a==3 && fs.files["/radio_xtrim"]==original);
}
fs.fail_write=fs.corrupt_write=fs.fail_temp_read=fs.fail_publish=fs.fail_open_write=false;
radio.result=mesh::RadioParamApplyResult::BUSY;
assert(command(radio,"set radio.xtrim 8") && strstr(reply,"applying in 2s"));
assert(radio.a==3 && radio.b==4);
assert(command(radio,"get radio.xtrim") && !strcmp(reply,"> 8,8 (pending)"));
const unsigned busy_calls=radio.calls;
test_now+=2000;
loopLR2021CalibrationSettings(radio);assert(radio.calls==busy_calls+1);
radio.result=mesh::RadioParamApplyResult::APPLIED;
loopLR2021CalibrationSettings(radio);
assert(radio.a==8 && radio.b==8);
assert(command(radio,"get radio.xtrim") && !strcmp(reply,"> 8,8"));
CustomLR2021Wrapper reboot;
assert(loadLR2021CalibrationSettings(&fs,reboot) && reboot.selected && reboot.a==8 && reboot.b==8 && !reboot.calls);
radio.result=mesh::RadioParamApplyResult::FAILED;
assert(command(radio,"set radio.xtrim 1,2") && strstr(reply,"applying in 2s"));
test_now+=2000;loopLR2021CalibrationSettings(radio);
assert(command(radio,"get radio.xtrim") && strstr(reply,"> 8,8 (failed"));
assert(loadLR2021CalibrationSettings(&fs,reboot) && reboot.a==8 && reboot.b==8);
radio.result=mesh::RadioParamApplyResult::BUSY;
assert(command(radio,"set radio.xtrim 4") && strstr(reply,"applying in 2s"));
test_now+=2000;loopLR2021CalibrationSettings(radio);
test_now+=30000;const unsigned timeout_calls=radio.calls,timeout_cancels=radio.cancels;
loopLR2021CalibrationSettings(radio);assert(radio.calls==timeout_calls+1);
assert(radio.cancels==timeout_cancels+1);
assert(command(radio,"get radio.xtrim") && strstr(reply,"> 8,8 (failed"));
assert(command(radio,"set radio.xtrim 4") && strstr(reply,"applying in 2s"));
test_now+=2000;loopLR2021CalibrationSettings(radio);
test_now+=29999;
loopLR2021CalibrationSettings(radio);
assert(command(radio,"get radio.xtrim") && !strcmp(reply,"> 4,4 (pending)"));
assert(command(radio,"set radio.xtrim 5") && strstr(reply,"applying in 2s"));
test_now+=2000;loopLR2021CalibrationSettings(radio);
test_now+=30000;const unsigned admitted_calls=radio.calls;
radio.result=mesh::RadioParamApplyResult::APPLIED;
loopLR2021CalibrationSettings(radio);assert(radio.calls==admitted_calls+1);
assert(radio.a==5 && radio.b==5);
assert(command(radio,"get radio.xtrim") && !strcmp(reply,"> 5,5"));
assert(command(radio,"set radio.xtrim default") && strstr(reply,"applying in 2s") && radio.selected);
test_now+=2000;loopLR2021CalibrationSettings(radio);assert(!radio.selected);
assert(loadLR2021CalibrationSettings(&fs,reboot) && !reboot.selected);
fs.files["/radio_xtrim.bak"]=original;fs.files["/radio_xtrim"]={0,1,2};
assert(loadLR2021CalibrationSettings(&fs,reboot) && reboot.a==3 && reboot.b==4);
fs.files["/radio_xtrim"][0]^=1;
assert(!loadLR2021CalibrationSettings(&fs,reboot) && !reboot.selected);
CustomLR2021Wrapper tcxo;tcxo.supported=false;
const auto before=fs.files;
assert(!loadLR2021CalibrationSettings(&fs,tcxo));
assert(command(tcxo,"set radio.xtrim 3") && strstr(reply,"TCXO") && fs.files==before && !tcxo.calls);
}
'''
class LR2021CalibrationTests(unittest.TestCase):
def compile_run(self, source, flags=(), stubs=None, production=None):
compiler = shutil.which(os.environ.get("CXX", "g++"))
if not compiler:
self.skipTest("C++ compiler unavailable")
with tempfile.TemporaryDirectory() as temporary:
work = Path(temporary)
for path, content in (stubs or {}).items():
target = work / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
test = work / "test.cpp"
test.write_text(source)
binary = work / "test"
command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-I", str(work),
"-I", str(ROOT / "src"), *flags, str(test)]
if production:
command.append(str(production))
built = subprocess.run([*command, "-o", str(binary)], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
run = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
def test_policy(self):
self.compile_run(POLICY)
def test_driver_commands_and_measurements(self):
source = (RADIO / "CustomLR2021.h").read_text()
signatures = ["bool supportsXoscTrim() const", "bool configureBootXoscTrim(bool enabled, uint8_t a, uint8_t b)",
"bool getXoscTrim(uint8_t& a, uint8_t& b) const", "void clearXoscTrim()",
"int16_t applyXoscTrim(uint8_t a, uint8_t b)", "int16_t restoreXoscTrim()",
"int16_t readPacketFrequencyErrorHz(int32_t& value)",
"void formatFrequencyError(char* reply, size_t capacity) const",
"int16_t readData(uint8_t* data, size_t len) override"]
methods = "\n".join(method(source, signature).replace(" override", "") for signature in signatures)
for flags in [("-DLR2021_TCXO_VOLTAGE=0",), ("-DLR2021_TCXO_VOLTAGE=1",), ()]:
with self.subTest(flags=flags):
self.compile_run(DRIVER.replace("@METHODS@", methods), flags)
def test_checked_settings_and_pending_apply(self):
stubs = {"helpers/IdentityStore.h": FS_STUB.replace("/display_prefs", "/radio_xtrim"),
"helpers/radiolib/CustomLR2021Wrapper.h": WRAPPER}
# The source uses a quoted include; isolate it beside its mocked boundary.
source = (RADIO / "LR2021CalibrationSettings.cpp").read_text()
source = source.replace('#include "CustomLR2021Wrapper.h"',
'#include <helpers/radiolib/CustomLR2021Wrapper.h>')
source = source.replace('#include "LR2021CalibrationSettings.h"',
'#include <helpers/radiolib/LR2021CalibrationSettings.h>')
source = source.replace('#include "LR2021CalibrationPolicy.h"',
'#include <helpers/radiolib/LR2021CalibrationPolicy.h>')
self.compile_run(SETTINGS + "\n" + source, ("-DUSE_LR2021=1",), stubs)
def test_reset_and_morse_reapply_only_selected_trim(self):
source = (RADIO / "CustomLR2021.h").read_text()
self.assertIn("restoreXoscTrim()", body(source, "bool std_init("))
self.assertIn("restoreXoscTrim()", body(source, "int16_t beginMorseReceive("))
def test_all_cli_roles_load_before_init_and_service_pending(self):
for role in ("simple_repeater", "simple_room_server", "simple_sensor", "companion_radio"):
source = (ROOT / "examples" / role / "main.cpp").read_text()
setup = body(source, "void setup()")
with self.subTest(role=role):
self.assertLess(setup.index("loadLR2021CalibrationSettings("), setup.index("radio_init()"))
self.assertIn("loopLR2021CalibrationSettings(radio_driver)", source)
self.assertIn("#if defined(USE_LR2021)", source)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -129,6 +129,7 @@ class CustomLR2021:public LR2021 {
@ROUTING@
public:
using LR2021::LR2021;
int16_t restoreXoscTrim() { return 0; } // No calibration override in this regulator-only fixture.
@TABLE@
@INIT@
@MORSE@
+204
View File
@@ -0,0 +1,204 @@
"""Exercise actual LR2021 activity/admission/primary transaction under RX noise."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_radio_profile_scan import HARNESS
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
MAIN = r'''
static void noise(RadioLibWrapper& w, unsigned tick) {
w.chip.irq = (tick & 1) ? RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID
: RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED;
}
int main() {
using Result = mesh::RadioParamApplyResult;
for (bool temporary : {false, true}) {
RadioLibWrapper w;
auto target = w._profiles.primary; target.freq = 910.54f;
bool applied = false;
for (unsigned tick = 0; tick < 751; ++tick) {
elapsed_us = 100000 + tick * 40000ULL; noise(w, tick);
auto result = w.trySetPrimaryParams(target, temporary);
if (result == Result::APPLIED) { applied = true; break; }
assert(result == Result::BUSY && w.applies == 0 && !w._primary_receive_preempt);
}
assert(applied && w.applies == 1 && w._cur_freq == target.freq);
assert(!w._primary_receive_preempt && w._profiles.primary_temporary == temporary);
}
// A different accepted tuple restarts the wait; invalid edits do not arm it.
{
RadioLibWrapper w;
auto target = w._profiles.primary; target.freq = 910.54f;
noise(w, 0); assert(w.trySetPrimaryParams(target, true) == Result::BUSY);
elapsed_us += 29000000; noise(w, 1);
target.freq = 911; assert(w.trySetPrimaryParams(target, true) == Result::BUSY);
assert(w.applies == 0);
}
// Even after its bound, no ordinary settings/scan/TX entry gets an exception.
{
RadioLibWrapper w;
auto target = w._profiles.primary; target.freq = 910.54f;
for (unsigned tick = 0; tick < 1500; ++tick) {
elapsed_us = 100000 + tick * 40000ULL; noise(w, tick);
assert(w.beginReconfigure() == 2);
assert(w.trySetParams(target.freq,target.bw,target.sf,target.cr) == Result::BUSY);
}
assert(w.applies == 0 && w.chip.standbys == 0);
}
// An opted-out wrapper retains its existing non-preemptive behavior.
{
RadioLibWrapper w; w.bounded = false;
auto target = w._profiles.primary; target.freq = 910.54f;
for (unsigned tick = 0; tick < 1500; ++tick) {
elapsed_us = 100000 + tick * 40000ULL; noise(w, tick);
assert(w.trySetPrimaryParams(target, true) == Result::BUSY);
}
assert(w.applies == 0 && w.chip.standbys == 0);
}
// Completion, chip BUSY and every exclusive owner remain stronger than the
// bounded command. Once each guard clears the accepted tuple still applies.
for (unsigned guard = 0; guard < 7; ++guard) {
RadioLibWrapper w;
auto target = w._profiles.primary; target.freq = 910.54f;
noise(w, 0); assert(w.trySetPrimaryParams(target, false) == Result::BUSY);
elapsed_us += 31000000; noise(w, 1);
if (guard == 0) w.events.record();
if (guard == 1) w.busy = true;
if (guard == 2) w.state = STATE_TX_WAIT;
if (guard == 3) w._cw_active = true;
if (guard == 4) w._beacon_owned = true;
if (guard == 5) w._morse_owned = true;
if (guard == 6) w._morse_rx_owned = true;
assert(w.trySetPrimaryParams(target, false) == Result::BUSY);
assert(w.applies == 0 && w.chip.standbys == 0);
w.events.claim(); w.busy = w._cw_active = w._beacon_owned = false;
w._morse_owned = w._morse_rx_owned = false; w.state = STATE_RX;
assert(w.trySetPrimaryParams(target, false) == Result::APPLIED);
}
// A completion racing the activity probe or the actual standby retains its
// old tuple/FIFO; neither settings apply nor receive rearm may consume it.
for (bool during_standby : {false,true}) {
RadioLibWrapper w;
auto target = w._profiles.primary; target.freq = 910.54f;
noise(w, 0); assert(w.trySetPrimaryParams(target, false) == Result::BUSY);
elapsed_us += 31000000; noise(w, 1);
if (during_standby) w.chip.standby_irq = &w.events;
else w.activity_irq = true;
assert(w.trySetPrimaryParams(target, false) == Result::BUSY);
assert(w.events.pending() && w.applies == 0 && w._cur_freq != target.freq);
assert(w.chip.standbys == unsigned(during_standby));
w.events.claim(); w.activity_irq = false; w.chip.standby_irq = nullptr;
assert(w.trySetPrimaryParams(target, false) == Result::APPLIED);
}
// Hardware failure and failed RX resume keep ordinary rollback semantics.
for (unsigned failure = 0; failure < 3; ++failure) {
RadioLibWrapper w;
auto original = w._profiles.primary;
auto target = original; target.freq = 910.54f;
noise(w, 0); assert(w.trySetPrimaryParams(target, true) == Result::BUSY);
elapsed_us += 31000000; noise(w, 1);
if (failure == 0) w.chip.standby_error = -706;
if (failure == 1) w.fail = true;
if (failure == 2) w.failRxStarts = 1;
assert(w.trySetPrimaryParams(target, true) == Result::FAILED);
assert(w._profiles.primary == original && !w._profiles.primary_temporary);
assert(w._cur_freq == original.freq && !w._primary_receive_preempt);
w.chip.standby_error = 0; w.fail = false;
assert(w.trySetPrimaryParams(target, true) == Result::APPLIED);
}
// The public deadline helper handles long-airtime cap and millis rollover.
{
mesh::PrimaryRadioApplyWait wait;
mesh::RadioProfileParams slow; slow.freq=910; slow.bw=7.8f; slow.sf=12; slow.cr=8;
const uint32_t start = UINT32_MAX - 10000;
assert(!wait.mayAbandonReceive(start,slow,false,slow,65528,65528,255));
assert(!wait.mayAbandonReceive(start+29999,slow,false,slow,65528,65528,255));
assert(wait.mayAbandonReceive(start+30000,slow,false,slow,65528,65528,255));
wait.clear();
assert(!wait.mayAbandonReceive(start+30001,slow,false,slow,65528,65528,255));
}
}
'''
class LR2021PrimaryApplyWaitTests(unittest.TestCase):
def test_actual_activity_admission_primary_transaction(self):
wrapper = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
native = (ROOT / 'src/helpers/radiolib/CustomLR2021.h').read_text()
signatures = ('uint8_t RadioLibWrapper::beginReconfigure(',
'void RadioLibWrapper::endReconfigure(',
'mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(',
'mesh::RadioParamApplyResult RadioLibWrapper::trySetPrimaryParams(')
methods = '\n'.join(method(wrapper, signature) for signature in signatures)
template = HARNESS[:HARNESS.index('int main()')]
template = template.replace('#include <cassert>', '''#include <cassert>
#define USE_LR2021 1
#define MESH_ENABLE_TX_BEACONS 1
#define MESH_ENABLE_CARRIER_TOOLS 1
#define MESH_ENABLE_MORSE_RX 1
#define MAX_TRANS_UNIT 255
#define RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED 32
#define RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID 64
#define RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR 512
#include <helpers/radiolib/PrimaryRadioApplyWait.h>
''')
chip = '''struct Chip {
bool standbyXOSC=false, _headerSeen=false;
uint32_t _activityAt=0, _preambleMillis=66, _maxPayloadMillis=3934, irq=0;
unsigned standbys=0;
int standby_error=0;
RadioInterruptEvents* standby_irq=nullptr;
bool isChipBusy(){return false;}
uint32_t getIrqStatus(){return irq;}
int clearIrqFlags(uint32_t flags){irq &= ~flags; return 0;}
int standby(){++standbys;if(standby_irq)standby_irq->record();return standby_error;}
@ACTIVITY@
};'''.replace('@ACTIVITY@',method(native,'bool isReceiving()'))
template = template.replace('struct Chip { bool standbyXOSC=false; int standby() { return 0; } };',chip)
template = template.replace(' bool _cw_active=false;', ''' bool _cw_active=false;
bool _beacon_owned=false, _beacon_restoring=false, _morse_owned=false, _morse_rx_owned=false;
bool _primary_receive_preempt=false, bounded=true, activity_irq=false;
mesh::PrimaryRadioApplyWait _primary_apply_wait;
bool supportsBoundedPrimaryReconfigure() const { return bounded; }
void noteReconfigureAdmission(const char*,int16_t) {}
uint16_t currentPreambleLength()const{return _physical_preamble;}
''')
template = template.replace('bool isReceivingPacket() { return packet; }',
'bool isReceivingPacket() { if(activity_irq)events.record();return chip.isReceiving(); }')
code = template.replace('@METHODS@',methods) + MAIN
with tempfile.TemporaryDirectory() as folder:
cpp, exe = Path(folder)/'test.cpp', Path(folder)/'test'
(Path(folder)/'Arduino.h').write_text('#pragma once\n#include <cstdint>\n#include <cmath>\n')
cpp.write_text(code)
result = subprocess.run([os.environ.get('CXX','g++'),'-std=c++17','-Wall','-Wextra',
'-fsanitize=address,undefined','-fno-pie','-no-pie',
'-I',folder,'-I',str(ROOT/'src'),str(cpp),'-o',str(exe)],
capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
result = subprocess.run([str(exe)],capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
# The regression must fail with either the former unbounded
# activity gate or a removed completion guard, not just compile.
mutations = (
('isReceivingPacket() && !_primary_receive_preempt', 'isReceivingPacket()'),
('if (hasPendingRadioInterrupt())', 'if (false)'),
)
for old, replacement in mutations:
self.assertIn(old, code)
cpp.write_text(code.replace(old,replacement))
result = subprocess.run([os.environ.get('CXX','g++'),'-std=c++17',
'-I',folder,'-I',str(ROOT/'src'),str(cpp),'-o',str(exe)],
capture_output=True,text=True)
self.assertEqual(result.returncode,0,result.stderr)
result = subprocess.run([str(exe)],capture_output=True,text=True)
self.assertNotEqual(result.returncode,0,'negative control unexpectedly passed')
self.assertIn('Assertion',result.stderr)
if __name__ == '__main__':
unittest.main()
+166
View File
@@ -0,0 +1,166 @@
"""Real trim transaction and LR2021 RX-noise admission, with completed IRQ races."""
from pathlib import Path
import subprocess
import tempfile
import unittest
import test_lr2021_apply_transaction as transaction_fixture
from test_lr2021_apply_transaction import method
ROOT = Path(__file__).resolve().parents[1]
MAIN = r'''
static void noise(Wrapper& w,unsigned tick) {
w.chip.irq=(tick&1)?RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID:RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED;
}
static void arm(Wrapper& w) {
now_ms=100;noise(w,0);
assert(w.trySetXoscTrim(true,8,8)==mesh::RadioParamApplyResult::BUSY);
}
int main(){
using Result=mesh::RadioParamApplyResult;
{
Wrapper w;bool applied=false;
for(unsigned tick=0;tick<751;++tick){
now_ms=100+tick*40;noise(w,tick);
const auto result=w.trySetXoscTrim(true,8,8);
if(result==Result::APPLIED){applied=true;break;}
assert(result==Result::BUSY&&!w._primary_receive_preempt&&w.chip.commands==0);
}
assert(applied&&w.chip.enabled&&w.chip.a==8&&w.chip.b==8&&w.rearms==1);
assert(!w._primary_receive_preempt&&w._trim_apply_key==UINT16_MAX);
}
// Ordinary reconfiguration never inherits the expired accepted-trim lease.
{
Wrapper w;arm(w);
for(unsigned tick=1;tick<1500;++tick){
now_ms=100+tick*40;noise(w,tick);assert(w.beginReconfigure()==2);
}
assert(w.chip.standbys==0&&w.chip.commands==0);
}
// Every protected owner and completed packet remains stronger than the bound.
for(unsigned guard=0;guard<7;++guard){
Wrapper w;arm(w);now_ms+=31000;noise(w,1);
if(guard==0)w.pending=true;
if(guard==1)w.busy=true;
if(guard==2)w.state=STATE_TX_WAIT;
if(guard==3)w._cw_active=true;
if(guard==4)w._beacon_owned=true;
if(guard==5)w._morse_owned=true;
if(guard==6)w._morse_rx_owned=true;
assert(w.trySetXoscTrim(true,8,8)==Result::BUSY);
assert(w.chip.commands==0&&w.chip.standbys==0&&!w._primary_receive_preempt);
w.pending=w.busy=w._cw_active=w._beacon_owned=w._morse_owned=w._morse_rx_owned=false;
w.state=STATE_RX;
// Morse is checked before the timer starts, so release then permit its own
// complete bound instead of borrowing another owner's elapsed time.
auto result=w.trySetXoscTrim(true,8,8);
if(result==Result::BUSY){now_ms+=31000;noise(w,0);result=w.trySetXoscTrim(true,8,8);}
assert(result==Result::APPLIED);
}
// Completion during the probe or standby preserves the old packet and trim.
for(bool during_standby:{false,true}){
Wrapper w;arm(w);now_ms+=31000;noise(w,1);
if(during_standby)w.chip.standby_irq=&w.pending;else w.activity_irq=true;
assert(w.trySetXoscTrim(true,8,8)==Result::BUSY);
assert(w.pending&&w.chip.commands==0&&!w.chip.enabled&&!w._primary_receive_preempt);
assert(w.chip.standbys==unsigned(during_standby));
w.pending=w.activity_irq=false;w.chip.standby_irq=nullptr;
assert(w.trySetXoscTrim(true,8,8)==Result::APPLIED);
}
// Replacing the requested capacitor codes must restart the admission wait.
{
Wrapper w;arm(w);now_ms+=31000;noise(w,1);
assert(w.trySetXoscTrim(true,9,10)==Result::BUSY);
assert(w.chip.commands==0);
now_ms+=31000;noise(w,0);
assert(w.trySetXoscTrim(true,9,10)==Result::APPLIED&&w.chip.a==9&&w.chip.b==10);
}
// Timeout/cancellation followed by an identical retry earns a fresh wait.
{
Wrapper w;arm(w);now_ms+=31000;noise(w,1);w.pending=true;
assert(w.trySetXoscTrim(true,8,8)==Result::BUSY);
w.cancelXoscTrimApply();w.pending=false;
assert(w.trySetXoscTrim(true,8,8)==Result::BUSY&&w.chip.commands==0);
now_ms+=31000;noise(w,0);
assert(w.trySetXoscTrim(true,8,8)==Result::APPLIED);
}
// A scope restores its caller's admission flag on both guarded and successful
// returns, rather than clearing a surrounding accepted-primary transaction.
for(bool guarded:{false,true}){
Wrapper w;w._primary_receive_preempt=true;w.pending=guarded;
assert(w.trySetXoscTrim(true,8,8)==(guarded?Result::BUSY:Result::APPLIED));
assert(w._primary_receive_preempt);
}
}
'''
class Capture:
def run_cpp(self, code):
self.code = code
class LR2021TrimApplyWaitTests(unittest.TestCase):
def test_actual_trim_activity_admission_and_completion_guards(self):
captured = Capture()
transaction_fixture.LR2021ApplyTransactionTest.test_real_trim_transaction_restores_selection_modem_and_receiver(captured)
code = captured.code[:captured.code.index('int main()')]
code = code.replace('#include <cassert>', '''#include <cassert>
#define USE_LR2021 1
#define MESH_ENABLE_TX_BEACONS 1
#define STATE_IDLE 0
#define STATE_RX 1
#define STATE_TX_WAIT 3
#define RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED 32
#define RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID 64
#define RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR 512
#define MESH_DEBUG_PRINTLN(...) ((void)0)
''')
native = (ROOT/'src/helpers/radiolib/CustomLR2021.h').read_text()
code = code.replace('struct CustomLR2021 {', '''struct CustomLR2021 {
uint32_t _activityAt=0,_preambleMillis=66,_maxPayloadMillis=3934,irq=0;
bool _headerSeen=false;
unsigned standbys=0;
bool* standby_irq=nullptr;
bool isChipBusy(){return false;}
uint32_t getIrqStatus(){return irq;}
int clearIrqFlags(uint32_t flags){irq &= ~flags;return 0;}
int standby(){++standbys;if(standby_irq)*standby_irq=true;return 0;}
''' + method(native,'bool isReceiving()'))
code = code.replace(' bool _primary_receive_preempt=false;', ''' bool _primary_receive_preempt=false;
bool pending=false,busy=false,activity_irq=false;
bool _cw_active=false,_beacon_owned=false,_beacon_restoring=false;
bool _rx_hold_continuous=false,_rx_ps_armed=false;
uint8_t state=STATE_RX;
bool hasPendingRadioInterrupt()const{return pending;}
bool claimRadioInterrupt(){const bool had=pending;pending=false;return had;}
bool isChipBusy()const{return busy;}
bool isReceivingPacket(){if(activity_irq)pending=true;return chip.isReceiving();}
''')
wrapper = (ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
admission = method(wrapper,'uint8_t RadioLibWrapper::beginReconfigure()').replace('RadioLibWrapper::','')
code = code.replace(method(code,'uint8_t beginReconfigure()'),admission)
code = code.replace('else rx=true;','else {rx=true;state=STATE_RX;}')
code = code.replace('bool isInRecvMode()const{return rx;}','bool isInRecvMode()const{return state==STATE_RX;}')
code += MAIN
with tempfile.TemporaryDirectory(prefix='lr2021-trim-admission-') as folder:
cpp,exe=Path(folder)/'test.cpp',Path(folder)/'test'
for mutation in (False,True):
cpp.write_text(code.replace('isReceivingPacket() && !_primary_receive_preempt',
'isReceivingPacket()') if mutation else code)
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(exe)],
capture_output=True,text=True)
self.assertEqual(built.returncode,0,built.stderr)
ran=subprocess.run([str(exe)],capture_output=True,text=True)
if mutation:
self.assertNotEqual(ran.returncode,0,'unbounded admission negative control passed')
self.assertIn('Assertion',ran.stderr)
else:
self.assertEqual(ran.returncode,0,ran.stderr)
if __name__=='__main__':
unittest.main()
+1
View File
@@ -220,6 +220,7 @@ public:
};
class CustomLR2021:public LR2021 {
@REGULATOR@
int16_t restoreXoscTrim() { return 0; } // No calibration override in this RF-switch fixture.
public:
using LR2021::LR2021;
uint32_t _rf_switch_pins[Module::RFSWITCH_MAX_PINS]={};
+10 -2
View File
@@ -25,10 +25,17 @@ COMMON = r'''
#include <initializer_list>
#include <RadioProfiles.h>
#include <helpers/radiolib/LR1110ProfileSwitchState.h>
#include <helpers/radiolib/LR2021ApplyStatus.h>
#define MESH_ENABLE_TX_BEACONS 1
#define MESH_ENABLE_MESHTEXT_WIRE 1
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE 0x12
struct ApplyRecorder {
mesh::lr2021::ApplyStatus _apply_status;
int16_t recordApply(mesh::lr2021::ApplyStatus::Stage stage, int16_t code) {
return _apply_status.record(stage, code);
}
};
struct Wire {
unsigned calls = 0, fail = 0;
uint8_t sync = 0x12;
@@ -74,7 +81,7 @@ struct Custom@FAMILY@ : @FAMILY@ {
bool _profilePacketDirty = true;
@PROTOCOL@
};
struct Wrapper {
struct Wrapper : ApplyRecorder {
Custom@FAMILY@ chip;
Custom@FAMILY@* _radio = &chip;
@SUPPORT@
@@ -138,6 +145,7 @@ int main() {
#define USE_LR2021 1
#define LORA_BW 125
#define RADIOLIB_ERR_UNKNOWN -1
#define RADIOLIB_ERR_INVALID_SIDE_DETECT -1000
#define RADIOLIB_CHANNEL_FREE -2
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#include <helpers/radiolib/LR2021SideDetectorConfig.h>
@@ -176,7 +184,7 @@ struct Board {
unsigned watchdog_services = 0;
void serviceWatchdog() { ++watchdog_services; }
};
struct Wrapper : RadioLibWrapper {
struct Wrapper : RadioLibWrapper, ApplyRecorder {
CustomLR2021 chip;
CustomLR2021* _radio = &chip;
Board board;
+12
View File
@@ -139,6 +139,16 @@ struct LR2021 {
struct Chip : LR2021 {
uint32_t _activityAt=0, _preambleMillis=66, _maxPayloadMillis=3934;
bool _headerSeen=false;
float freqMHz=910.525f, _last_frequency_error_mhz=0;
int32_t _last_frequency_error_hz=0;
uint32_t _last_frequency_error_at=0;
bool _last_frequency_error_valid=false;
struct MeasurementHal {uint32_t millis(){return ::millis();}} measurement_hal;
struct MeasurementModule {MeasurementHal* hal;} measurement_mod{&measurement_hal};
MeasurementModule* mod=&measurement_mod;
// The packet-status wire decoder has its own production-method tests. Keep
// this fixture focused on preserving the IRQ/FIFO while publishing metadata.
int16_t readPacketFrequencyErrorHz(int32_t& hz){hz=250;return 0;}
bool isChipBusy(){return false;}
@LR_METHODS@
};
@@ -153,6 +163,7 @@ struct RadioLibWrapper {
bool _loop_event_pending=false, _cw_active=false, _profile_repair_required=false, _profile_refresh_required=false;
bool _rx_ps_armed=false, _rx_hold_continuous=false, _rx_ps_enabled=false;
bool _rx_ps_continuous_fallback=false, _nf_calib_active=false;
bool _primary_receive_preempt=false;
uint32_t _rx_ps_eff_rx_us=0, _rx_ps_eff_sleep_us=0;
float _last_snr=0, _last_rssi=0;
unsigned n_recv=0,n_recv_errors=0,n_sent=0;
@@ -168,6 +179,7 @@ public:
bool registerInterruptAction();void unregisterInterruptAction();
bool hasAssertedRadioInterrupt()const;bool hasPendingRadioInterrupt()const;bool claimRadioInterrupt();
uint8_t beginReconfigure();
void noteReconfigureAdmission(const char*,int16_t){}
void requestRestartRecv();bool isPacketReady();
bool startSendRaw(const uint8_t*,int);bool isSendComplete();
int recvRaw(uint8_t*,int);
+1
View File
@@ -81,6 +81,7 @@ class Custom:public @CHIP@ {
RfSwitch _morse_rf_switch;
public:
int restoreRfSwitchTable() { return 0; }
int restoreXoscTrim() { return 0; }
@INIT@
@MORSE@
int init() { return @INIT_CALL@; }
+2
View File
@@ -49,6 +49,8 @@ struct Driver {
bool _rx_ps_armed = false, _cw_active = false;
bool _profile_repair_required = false, _profile_refresh_required = false;
bool _rx_hold_continuous = false;
bool _primary_receive_preempt = false;
void noteReconfigureAdmission(const char*, int16_t) {}
bool _dbm_valid = true, _dbm_applied = true, _rx_boosted_gain_valid = false;
bool _rx_boosted_gain_applied = false;
int8_t _cur_dbm = 7;