properly reset CAD offset when needed

This commit is contained in:
liquidraver committed 2026-09-10 21:56:52 +02:00
1 parent ce6e71067d
commit 69cc148fbe
12 files changed
+249 -44

No files matched your search

+70 -12
View File
@@ -53,7 +53,7 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board,
_sample_rssi(0), _sample_channel_quiet(false), _sample_fresh(false),
_rx_entry_cyc(0),
_rssi_bursts(0), _rssi_spread_sum(0), _rssi_degenerate(0),
_cad_auto(false), _cad_offset(0), _probe_interval_s(0),
_cad_auto(false), _cad_offset(0), _cad_temp_offset(0), _probe_interval_s(0),
_cad_busycap_pct(0), _cad_pending_level(INT8_MIN),
_cad_pending_deadline_ms(0),
_cad_last_probe_ms(0), _cad_last_decay_ms(0),
@@ -583,7 +583,8 @@ void LoRaRadioBase::reconfigureWithParams(float freq, float bw, uint8_t sf, uint
reconfigure();
}
void LoRaRadioBase::setRadioOverride(float freq, float bw, uint8_t sf, uint8_t cr)
void LoRaRadioBase::setRadioOverride(float freq, float bw, uint8_t sf, uint8_t cr,
bool visiting_new_preset)
{
_override_freq = freq;
_override_bw = bw;
@@ -591,6 +592,19 @@ void LoRaRadioBase::setRadioOverride(float freq, float bw, uint8_t sf, uint8_t c
_override_cr = cr;
_has_radio_override = true;
reconfigure();
/* Visit a new preset at its own base detPeak. The learned offset belongs
* to the preset it was measured on and the base table it addresses is
* per-SF/per-bandwidth, so carrying it across programs a threshold nobody
* measured (SF7->SF12 is 5 counts on SX126x, 16 on LR11xx). A freeze
* changes nothing on air, so it keeps the offset that is already right.
*
* _cad_offset is left untouched — not snapshotted — so the configured
* preset gets its offset back verbatim in clearRadioOverride(), and no
* visitor value can reach prefs in between. Adaptation is suspended for
* the duration; see cadMaintenance(). */
_cad_temp_offset = visiting_new_preset ? 0 : _cad_offset;
hwCadSetPeakOffset(cadEffectiveOffset());
}
void LoRaRadioBase::clearRadioOverride()
@@ -600,6 +614,7 @@ void LoRaRadioBase::clearRadioOverride()
}
_has_radio_override = false;
reconfigure();
hwCadSetPeakOffset(cadEffectiveOffset());
}
void LoRaRadioBase::startReceive()
@@ -1650,7 +1665,11 @@ void LoRaRadioBase::setCadParams(bool auto_enabled, int8_t offset,
? (uint32_t)probe_interval_s * 1000U
: (uint32_t)CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS;
hwCadSetPeakOffset(_cad_offset);
/* Effective, not _cad_offset: applyCadPrefs() reaches here from ordinary
* `set cad.*` commands, which an operator can issue while a `tempradio`
* window is open. Programming the configured offset then would put the
* visited preset back on a threshold that does not belong to it. */
hwCadSetPeakOffset(cadEffectiveOffset());
LOG_INF("cad: auto=%d offset=%d base=%u measure_interval=%ums busycap=%u%%",
(int)auto_enabled, (int)offset, (unsigned)base,
@@ -1666,6 +1685,15 @@ void LoRaRadioBase::resetCadStats()
{
memset(_cad_stats, 0, sizeof(_cad_stats));
_cad_probe_rr = 0;
/* Drop any probe still in flight with the table it belongs to.
* cadMaintenance() books a verdict into whatever stats exist when it
* resolves, not the ones that were current when it was armed — so a
* probe left pending across reconfigure() writes an old-preset sample
* into the fresh table, and across `set cad.reset` writes a pre-reset
* sample into a table the operator just cleared. */
_cad_pending_level = INT8_MIN;
_cad_pending_deadline_ms = 0;
}
void LoRaRadioBase::decayCadStats()
@@ -1868,16 +1896,26 @@ bool LoRaRadioBase::cadRelaxOnTxStarvation()
*
* One step at a time, never a jump to base: on a genuinely congested
* site the operator's sensitive setting may be almost right, and the
* smallest change that restores transmission is the one to make. */
if (_cad_offset >= cadLevelMaxEff()) {
* smallest change that restores transmission is the one to make.
*
* This one safety DOES run during a temporary override — unlike the
* staircase, which cadMaintenance() suspends there. A node muted for
* the length of a `tempradio` window, or until the reboot a `set radio`
* is waiting for, is muted for real. It steps the visitor's offset, so
* the recovery lasts exactly as long as the visit and still cannot reach
* prefs: getCadOffset() keeps reporting the configured value. */
int8_t &off = _has_radio_override ? _cad_temp_offset : _cad_offset;
if (off >= cadLevelMaxEff()) {
return false;
}
_cad_offset++;
hwCadSetPeakOffset(_cad_offset);
LOG_WRN("cad: TX starvation override -> offset %d (auto=%d) — LBT was "
off++;
hwCadSetPeakOffset(cadEffectiveOffset());
LOG_WRN("cad: TX starvation override -> offset %d (auto=%d%s) — LBT was "
"refusing every transmit",
(int)_cad_offset, (int)_cad_auto);
(int)off, (int)_cad_auto,
_has_radio_override ? ", temp preset" : "");
return true;
}
@@ -1887,6 +1925,21 @@ void LoRaRadioBase::cadMaintenance()
return;
}
/* Nothing to learn on a preset the node is only visiting: the statistics
* would describe that preset, and every mover of _cad_offset below is
* persisted by Dispatcher::maintenanceLoop() -> onCadOffsetChanged() ->
* prefs, so letting the staircase run inside a `tempradio` window would
* write a visitor's offset to flash and leave it there after the revert.
* The chip runs at the visited preset's own base (setRadioOverride).
*
* The pending sample is dropped rather than carried: it was taken under
* the other preset, and consuming it after the revert would admit a
* probe on a quiet verdict that is a whole interval stale. */
if (_has_radio_override) {
_sample_fresh = false;
return;
}
int64_t now = k_uptime_get();
/* Periodic decay keeps the stats fresh (and counters bounded). */
@@ -2150,6 +2203,9 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap)
* Header: a:on o:1 pk:22(b21/4s) sp:0.9/84%(312) bc:25%
* a auto on/off o offset pk operating peak
* b family base 4s symbols bc busy cap
* a:tmp a temporary radio override is running a preset the node is
* only visiting: adaptation is suspended and o/pk describe that
* visit, not the configured preset (see setRadioOverride).
* sp RSSI burst quality: mean spread in dB across the median-of-N
* reads, the share of bursts whose spread was 0, and the burst
* count. The count is not decoration: a share without its
@@ -2190,10 +2246,12 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap)
* still gets the mean and the share, which is the actual verdict. */
bool room_for_count = (cap >= 200);
const int eff_off = cadEffectiveOffset();
n += snprintf(buf + n, cap > n ? cap - n : 0,
"a:%s o:%d pk:%d(b%u/4s) sp:%u.%u/%u%%",
_cad_auto ? "on" : "off", (int)_cad_offset,
(int)base + _cad_offset, base,
_has_radio_override ? "tmp" : (_cad_auto ? "on" : "off"),
eff_off, (int)base + eff_off, base,
spread_mean10 / 10U, spread_mean10 % 10U, degen_pct);
if (room_for_count) {
/* bursts(ok reads/busy reads/abandoned bursts/dc-blocked) — the
@@ -2232,7 +2290,7 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap)
* every rung printed is a real one and `pk` below is what the chip got. */
const int lmin = cadLevelMinEff();
const int lmax = cadLevelMaxEff();
int cur = _cad_offset;
int cur = eff_off;
if (cur < lmin) cur = lmin;
if (cur > lmax) cur = lmax;
int lo = cur - 1, hi = cur + 1;
+27 -1
View File
@@ -41,7 +41,17 @@ public:
* _prefs. Used by tempradio so the saved prefs survive intact and
* concurrent savePrefs() calls don't poison flash. clearRadioOverride()
* reverts to whatever _prefs holds at that moment. */
void setRadioOverride(float freq, float bw, uint8_t sf, uint8_t cr);
/* visiting_new_preset = the override moves the radio onto a preset it
* was not already running (`tempradio`), so the learned detPeak offset
* does not apply there and the visit starts at that preset's own base.
* Pass false when the override exists only to HOLD the radio on the
* preset it is already running while _prefs move ahead of it
* (freezeRadioParams, i.e. `set radio` / `set freq` + reboot): nothing
* changed on air, so the offset that was learned for it keeps running
* until the reboot re-reads prefs. Either way CAD adaptation is
* suspended while the override is up — see cadMaintenance(). */
void setRadioOverride(float freq, float bw, uint8_t sf, uint8_t cr,
bool visiting_new_preset = true);
void clearRadioOverride();
bool hasRadioOverride() const { return _has_radio_override; }
int recvRaw(uint8_t *bytes, int sz) override;
@@ -388,6 +398,22 @@ protected:
CadLevelStats _cad_stats[CAD_NUM_LEVELS];
bool _cad_auto; /* staircase acts on the stats */
int8_t _cad_offset; /* operating detPeak offset (levels) */
/* The offset in force while a temporary radio override is active, i.e.
* on a preset the node is only visiting (`tempradio`, and the frozen
* old preset a `set radio` leaves running until reboot). detPeak's base
* table is per-SF and per-bandwidth, so the offset learned for the
* configured preset addresses a different ladder there — on the LR11xx
* the SF7->SF12 base step alone is 16 counts. Kept separate rather than
* saved-and-restored so _cad_offset never moves during the visit, which
* is what keeps it out of prefs: Dispatcher::maintenanceLoop() persists
* any change it sees in getCadOffset(). */
int8_t _cad_temp_offset;
/* The offset the chip is actually programmed with. Every hwCadSetPeakOffset()
* goes through this; getCadOffset() deliberately does not, because that is
* the value the app persists. */
int8_t cadEffectiveOffset() const {
return _has_radio_override ? _cad_temp_offset : _cad_offset;
}
uint16_t _probe_interval_s; /* 0 = CAD probing disabled; drives _measure_interval_ms */
uint8_t _cad_busycap_pct; /* airtime cap: max % TX deferred (0 = off) */
/* A CAD_RX probe awaiting its terminal event. _cad_pending_level is the
+10 -2
View File
@@ -2809,13 +2809,21 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len)
bw >= 7000 && bw <= 500000 &&
sf >= 5 && sf <= 12 &&
cr >= 5 && cr <= 8) {
/* Coding rate is deliberately not in this test:
* it changes airtime, not the channel or the
* per-SF/per-bandwidth detPeak base, so it does
* not invalidate the learned CAD offset. */
bool preset_changed =
prefs.freq != (float)freq / 1000.0f ||
prefs.bw != (float)bw / 1000.0f ||
prefs.sf != sf;
prefs.freq = (float)freq / 1000.0f;
prefs.bw = (float)bw / 1000.0f;
prefs.sf = sf;
prefs.cr = cr;
prefs.client_repeat = repeat;
_store->savePrefs(prefs);
if (_radio_reconfig_cb) _radio_reconfig_cb();
if (_radio_reconfig_cb) _radio_reconfig_cb(preset_changed);
LOG_INF("SET_RADIO_PARAMS: client_repeat=%d", repeat);
sendPacketOk();
} else {
@@ -2832,7 +2840,7 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len)
if (power >= -9 && power <= MAX_LORA_TX_POWER) {
prefs.tx_power_dbm = power;
_store->savePrefs(prefs);
if (_radio_reconfig_cb) _radio_reconfig_cb();
if (_radio_reconfig_cb) _radio_reconfig_cb(false);
sendPacketOk();
} else {
sendPacketError(ERR_ILLEGAL_ARG);
+4 -2
View File
@@ -85,8 +85,10 @@ typedef size_t (*WriteFrameCallback)(const uint8_t *data, size_t len);
/* Battery millivolt read callback */
typedef uint16_t (*GetBatteryCallback)(void);
/* Radio reconfigure callback */
typedef void (*RadioReconfigureCallback)(void);
/* Radio reconfigure callback. preset_changed = the channel or the modem
* config moved (freq/bw/sf), not just TX power — the adaptive-CAD state is
* tied to those and has to be reset with them (see main_companion). */
typedef void (*RadioReconfigureCallback)(bool preset_changed);
/* BLE PIN change callback */
typedef void (*PinChangeCallback)(uint32_t new_pin);
+4 -1
View File
@@ -1199,7 +1199,10 @@ void RepeaterMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_
void RepeaterMesh::freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) {
auto& radio = getRadioDriver(_radio);
if (!radio.hasRadioOverride()) {
radio.setRadioOverride(freq, bw, sf, cr);
/* Holds the radio on the preset it is ALREADY running while _prefs
* move ahead of it, so the learned CAD offset still applies — see
* setRadioOverride(). */
radio.setRadioOverride(freq, bw, sf, cr, /*visiting_new_preset=*/false);
}
}
+4 -1
View File
@@ -903,7 +903,10 @@ void RoomServerMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint
void RoomServerMesh::freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) {
auto& radio = getRadioDriver(_radio);
if (!radio.hasRadioOverride()) {
radio.setRadioOverride(freq, bw, sf, cr);
/* Holds the radio on the preset it is ALREADY running while _prefs
* move ahead of it, so the learned CAD offset still applies — see
* setRadioOverride(). */
radio.setRadioOverride(freq, bw, sf, cr, /*visiting_new_preset=*/false);
}
}
+60 -15
View File
@@ -182,6 +182,41 @@ void CommonCLI::savePrefs() {
_callbacks->savePrefs();
}
/* Drop everything the adaptive-CAD controller has learned.
*
* Called both by `set cad.reset` and by every command that moves the radio to a
* different preset, because such a change invalidates all three pieces at once:
* detPeak's base table is per-SF and per-bandwidth, so the ladder the offset
* addresses is re-based under it (SF7->SF12 is 5 counts on SX126x and 16 on the
* LR11xx), and the per-level statistics that justified the offset describe a
* preset that is no longer on air. Re-converging from the new preset's own
* base costs an hour or two; carrying a stale offset across can leave the node
* either transmitting over live receptions or unable to transmit at all.
*
* The operating offset lives in two places — _prefs->cad_offset, and _cad_offset
* inside the radio, which applyCadPrefs() reloads through setCadParams().
*
* preset_pending = the caller has already written the NEW freq/bw/sf/cr to
* _prefs but the radio is still running the OLD preset (frozen by
* freezeRadioParams until the reboot the caller is about to ask for).
* applyCadPrefs() stamps cad_base from the RUNNING radio, i.e. the preset being
* left, so that stamp has to be cleared again afterwards: a stale anchor in
* flash is precisely what makes setCadParams() re-anchor a freshly reset offset
* by (old_base - new_base) on the next boot, which is the bug this reset
* exists to avoid. 0 means "no anchor recorded", which that re-anchor
* deliberately treats as a no-op, and the next boot re-stamps it.
*/
void CommonCLI::resetCadState(bool preset_pending) {
_prefs->cad_offset = cliDefaults()->cad_offset;
_prefs->cad_base = 0;
_callbacks->resetCadStats();
_callbacks->applyCadPrefs();
if (preset_pending) {
_prefs->cad_base = 0;
}
savePrefs();
}
uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
if (_prefs->advert_loc_policy == ADVERT_LOC_NONE) {
AdvertDataBuilder builder(node_type, _prefs->node_name);
@@ -851,19 +886,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
* FROM wherever the staircase had already walked detPeak, with no
* evidence left to justify sitting there — the opposite of a reset,
* and useless for the one job this command has (recovering after a
* base-table change). The operating offset lives in two places:
* _prefs->cad_offset, and _cad_offset inside the radio, which
* applyCadPrefs() reloads through setCadParams(). */
_prefs->cad_offset = cliDefaults()->cad_offset;
/* Clear the recorded base too. applyCadPrefs() below re-stamps it
* from the radio, so a reset always leaves offset and base
* describing the same configuration -- leaving a stale base here
* would make the NEXT base-table change re-anchor a freshly reset
* offset away from zero. */
_prefs->cad_base = 0;
_callbacks->resetCadStats();
_callbacks->applyCadPrefs();
savePrefs();
* base-table change).
*
* preset_pending = false: the radio is running the preset this
* reset is for, so the base applyCadPrefs() stamps is the right
* one to keep. */
resetCadState(false);
snprintf(reply, CLI_REPLY_SIZE,
"OK - CAD probe stats cleared, detPeak offset reset to %d",
(int)_prefs->cad_offset);
@@ -1006,7 +1034,17 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
_prefs->cr = cr;
_callbacks->savePrefs();
_callbacks->freezeRadioParams(old_freq, old_bw, old_sf, old_cr);
strcpy(reply, "OK - reboot to apply");
/* Anything that moves the channel or the detPeak base table
* invalidates the learned CAD offset and every statistic
* behind it — see resetCadState(). Coding rate does neither
* (it changes airtime, not the threshold or the band), so a
* cr-only edit keeps a converged offset. */
if (freq != old_freq || bw != old_bw || sf != old_sf) {
resetCadState(true);
strcpy(reply, "OK - reboot to apply (CAD reset)");
} else {
strcpy(reply, "OK - reboot to apply");
}
} else {
strcpy(reply, "Error: freq 150-2500, bw 7-500, sf 5-12, cr 5-8, or default");
}
@@ -1163,7 +1201,14 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
* back; freeze the running radio on the old freq until reboot,
* mirroring the "set radio" handler above. */
_callbacks->freezeRadioParams(old_freq, _prefs->bw, _prefs->sf, _prefs->cr);
strcpy(reply, "OK - reboot to apply");
/* New channel, new interference: the offset and the statistics
* behind it were learned somewhere else. See resetCadState(). */
if (f != old_freq) {
resetCadState(true);
strcpy(reply, "OK - reboot to apply (CAD reset)");
} else {
strcpy(reply, "OK - reboot to apply");
}
} else {
strcpy(reply, "Error: range 150-2500 MHz, or default");
}
+7
View File
@@ -149,4 +149,11 @@ public:
void handleCommand(uint32_t sender_timestamp, const char* command, char* reply);
uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data);
/* Full adaptive-CAD reset: the learned detPeak offset, the probe statistics
* that justified it, and the recorded base anchor, all dropped together.
* Public so the companion's app-driven radio change (CMD_SET_RADIO_PARAMS,
* which applies live rather than at reboot) can reach the same code path
* instead of open-coding it. See the definition for `preset_pending`. */
void resetCadState(bool preset_pending);
};
+18 -5
View File
@@ -673,11 +673,6 @@ static uint16_t get_battery_mv(void)
return zephyr_board.getBattMilliVolts();
}
static void radio_reconfigure(void)
{
lora_radio.reconfigure();
}
static mesh::ZephyrMillisecondClock ms_clock;
static mesh::ZephyrRNG zephyr_rng;
static mesh::SimpleMeshTables mesh_tables;
@@ -886,6 +881,24 @@ static ClientACL companion_acl; /* unused by CommonCLI but required by construc
static CommonCLI companion_cli(zephyr_board, rtc_clock, companion_acl,
&companion_mesh.prefs, &companion_cli_cbs);
/* Radio reconfigure, reached from the app protocol (CMD_SET_RADIO_PARAMS /
* CMD_SET_RADIO_TX_POWER). Defined here rather than up with the other radio
* callbacks because it needs companion_cli, which needs the mesh and the
* datastore — same reason save_prefs_to_flash() sits below them.
*
* Unlike the CLI's `set radio`, this path applies LIVE: there is no reboot and
* no frozen old preset, so by the time the CAD reset runs the radio is already
* on the new one and the base it stamps is the right one to keep — hence
* preset_pending = false. */
static void radio_reconfigure(bool preset_changed)
{
lora_radio.reconfigure();
if (preset_changed) {
companion_cli.resetCadState(false);
}
}
#if defined(CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS) && \
CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS > 0
#define ZEPHCORE_HAS_AUTO_SHUTDOWN 1