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

+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.