feat(radio): restore CAD support + radio_fem_rxgain persistence (upstream parity)

Continues restoring features dropped by the 22eb9b87 revert. Both were
upstream-tested code, not intentional fork removals.

CAD (hardware Channel Activity Detection / listen-before-talk before TX),
fully restored and functional:
- NodePrefs.cad_enabled + `set cad on/off` / `get cad` CLI (default off)
- RadioLibWrapper: _cad_enabled + setCADEnabled() + the scanChannel()/CAD
  branch in isChannelActive() (Phase 1 already restored the Dispatcher hook)
- getCADEnabled() overrides in the repeater/room/sensor apps (return the
  pref) and companion (always on, matching upstream)

radio_fem_rxgain: the NodePrefs field + /com_prefs persistence are restored
here at upstream's exact offsets (293 fem, 294 cad), which makes /com_prefs
byte-identical to upstream through the tail. The field is persisted and
defaults on (upstream default), but the per-board LNA *driving* + the
`radio.fem.rxgain` CLI are deferred to the FEM-hardware change (they depend
on board methods and want per-board bench testing).

The new-format /com_prefs tail grows from 3 to 5 bytes; the old-format
detection threshold and the host migration harness are updated accordingly
(all scenarios pass, incl. the non-MQTT-build variant).

Builds: Heltec_v3 repeater, repeater-observer, room-observer. (sensor /
plain-room / companion fail only on the pre-existing Timezone.h include
issue, unrelated to these changes.)
This commit is contained in:
agessaman committed 2026-07-09 11:12:57 -07:00
1 parent 7076ff5b31
commit df90673d18
12 files changed
+80 -7

No files matched your search

+4
View File
@@ -258,6 +258,10 @@ float MyMesh::getAirtimeBudgetFactor() const {
return _prefs.airtime_factor;
}
bool MyMesh::getCADEnabled() const {
return true; // hardware CAD before TX (no CLI toggle on companion; enabled by default)
}
int MyMesh::getInterferenceThreshold() const {
return 0; // disabled for now, until currentRSSI() problem is resolved
}
+1
View File
@@ -105,6 +105,7 @@ public:
protected:
float getAirtimeBudgetFactor() const override;
int getInterferenceThreshold() const override;
bool getCADEnabled() const override;
int calcRxDelay(float score, uint32_t air_time) const override;
uint32_t getRetransmitDelay(const mesh::Packet *packet) override;
uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override;
+2
View File
@@ -979,6 +979,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.rx_boosted_gain = 1; // enabled by default;
#endif
#endif
_prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards)
_prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on')
pending_discover_tag = 0;
pending_discover_until = 0;
+4
View File
@@ -152,6 +152,10 @@ protected:
return _prefs.airtime_factor;
}
bool getCADEnabled() const override {
return _prefs.cad_enabled;
}
bool allowPacketForward(const mesh::Packet* packet) override;
const char* getLogDateTime() override;
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
+2
View File
@@ -672,6 +672,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
_prefs.flood_max_unscoped = 64;
_prefs.flood_max_advert = 8;
_prefs.interference_threshold = 0; // disabled
_prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards)
_prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on')
#ifdef ROOM_PASSWORD
StrHelper::strncpy(_prefs.guest_password, ROOM_PASSWORD, sizeof(_prefs.guest_password));
#endif
+4
View File
@@ -143,6 +143,10 @@ protected:
return _prefs.airtime_factor;
}
bool getCADEnabled() const override {
return _prefs.cad_enabled;
}
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
void logRx(mesh::Packet* pkt, int len, float score) override;
void logTx(mesh::Packet* pkt, int len) override;
+6
View File
@@ -301,6 +301,10 @@ float SensorMesh::getAirtimeBudgetFactor() const {
return _prefs.airtime_factor;
}
bool SensorMesh::getCADEnabled() const {
return _prefs.cad_enabled;
}
bool SensorMesh::allowPacketForward(const mesh::Packet* packet) {
if (_prefs.disable_fwd) return false;
if (packet->isRouteFlood() && packet->getPathHashCount() >= _prefs.flood_max) return false;
@@ -726,6 +730,8 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise
_prefs.disable_fwd = true;
_prefs.flood_max = 64;
_prefs.interference_threshold = 0; // disabled
_prefs.radio_fem_rxgain = 1; // LoRa FEM RX gain on by default (FEM boards)
_prefs.cad_enabled = 0; // hardware CAD before TX (off by default; 'set cad on')
// GPS defaults
_prefs.gps_enabled = 0;
+1
View File
@@ -120,6 +120,7 @@ protected:
uint32_t getRetransmitDelay(const mesh::Packet* packet) override;
uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override;
int getInterferenceThreshold() const override;
bool getCADEnabled() const override;
int getAGCResetInterval() const override;
void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override;
int searchPeersByHash(const uint8_t* hash) override;
+27 -4
View File
@@ -150,8 +150,15 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
// MQTT gap here followed by a trailing observer block; detect that layout by the
// extra length, skip the gap, and recover the tail so those settings survive
// the upgrade (the file is rewritten in the new layout by loadPrefs afterwards).
// Defaults for the trailing fields that older/shorter files may not contain.
// (upstream defaults: FEM RX gain on, CAD off) — overwritten below if present.
_prefs->radio_fem_rxgain = 1;
_prefs->cad_enabled = 0;
// New-format tail is 5 bytes (rx_boosted_gain, flood_max_unscoped/advert,
// radio_fem_rxgain, cad_enabled). A larger `extra` means an old fork file with
// the legacy MQTT gap; detect and recover it below.
size_t extra = file.available();
if (extra > 3) {
if (extra > 5) {
_com_prefs_needs_upgrade = true;
size_t gap = 0;
if (extra > LEGACY_MQTT_GAP_6SLOT && extra <= LEGACY_MQTT_GAP_6SLOT + LEGACY_OBS_TAIL_MAX) {
@@ -256,6 +263,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
if (file.available() >= (int)sizeof(_prefs->flood_max_advert)) {
file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert));
}
if (file.available() >= (int)sizeof(_prefs->radio_fem_rxgain)) { // 293
file.read((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain));
}
if (file.available() >= (int)sizeof(_prefs->cad_enabled)) { // 294
file.read((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled));
}
}
// sanitise bad pref values
@@ -272,6 +285,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->adc_multiplier = constrain(_prefs->adc_multiplier, 0.0f, 10.0f);
_prefs->path_hash_mode = constrain(_prefs->path_hash_mode, 0, 2); // NOTE: mode 3 reserved for future
_prefs->loop_detect = constrain(_prefs->loop_detect, 0, 3); // LOOP_DETECT_OFF..LOOP_DETECT_STRICT
_prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean
_prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean
// sanitise bad bridge pref values
_prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1);
@@ -348,9 +363,11 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170
// MQTT/observer settings are stored in /mqtt_prefs, not here. No zero-gap is
// written anymore — /com_prefs holds only the (non-observer) fields below.
file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain));
file.write((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped));
file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert));
file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain)); // 290
file.write((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 291
file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292
file.write((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293
file.write((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294
file.close();
}
@@ -963,6 +980,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
_prefs->interference_threshold = atoi(&config[11]);
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "cad ", 4) == 0) {
_prefs->cad_enabled = memcmp(&config[4], "on", 2) == 0;
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "agc.reset.interval ", 19) == 0) {
_prefs->agc_reset_interval = atoi(&config[19]) / 4;
savePrefs();
@@ -1249,6 +1270,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->airtime_factor));
} else if (memcmp(config, "int.thresh", 10) == 0) {
sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold);
} else if (memcmp(config, "cad", 3) == 0) {
sprintf(reply, "> %s", _prefs->cad_enabled ? "on" : "off");
} else if (memcmp(config, "agc.reset.interval", 18) == 0) {
sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4);
} else if (memcmp(config, "multi.acks", 10) == 0) {
+6
View File
@@ -66,6 +66,12 @@ struct NodePrefs { // persisted to file
uint8_t loop_detect;
// Restored from upstream (dropped by the 22eb9b87 revert). Persisted at the same
// /com_prefs offsets upstream uses (293, 294) so the file stays upstream-aligned.
uint8_t radio_fem_rxgain; // LoRa FEM RX-gain (LNA); default on. Hardware driving is
// wired per-board in the FEM-restore change; persisted here.
uint8_t cad_enabled; // hardware Channel Activity Detection before TX; default off
// NOTE: observer settings (MQTT/WiFi/timezone/SNMP/alert) were moved out of
// NodePrefs into MQTTPrefs (persisted to /mqtt_prefs) so this struct stays
// aligned with upstream. See struct MQTTPrefs below.
+19 -3
View File
@@ -180,10 +180,26 @@ void RadioLibWrapper::onSendFinished() {
state = STATE_IDLE;
}
int16_t RadioLibWrapper::performChannelScan() {
return _radio->scanChannel();
}
bool RadioLibWrapper::isChannelActive() {
return _threshold == 0
? false // interference check is disabled
: getCurrentRSSI() > _noise_floor + _threshold;
// int.thresh: RSSI-based interference detection (relative to noise floor)
if (_threshold != 0 && getCurrentRSSI() > _noise_floor + _threshold) return true;
// cad: hardware channel activity detection
if (_cad_enabled) {
int16_t result = performChannelScan();
// scanChannel() triggers DIO interrupt (CAD done) which sets STATE_INT_READY
// via setFlag() ISR. Clear it before restarting RX so recvRaw() doesn't
// try to read a non-existent packet and count a spurious recv error.
state = STATE_IDLE;
startRecv();
if (result != RADIOLIB_CHANNEL_FREE) return true;
}
return false;
}
uint8_t RadioLibWrapper::getRadioState() const {
+4
View File
@@ -14,9 +14,11 @@ protected:
unsigned long last_recv_millis;
unsigned long last_radio_interrupt_millis; // updated on any ISR event, even CRC errors
uint8_t _preamble_sf;
bool _cad_enabled;
void idle() override;
void startRecv() override;
int16_t performChannelScan();
float packetScoreInt(float snr, int sf, int packet_len);
virtual bool isReceivingPacket() =0;
virtual void doResetAGC();
@@ -26,6 +28,7 @@ public:
n_recv = n_sent = n_recv_errors = 0;
last_recv_millis = 0;
last_radio_interrupt_millis = 0;
_cad_enabled = false;
}
void begin() override;
@@ -55,6 +58,7 @@ public:
int getNoiseFloor() const override { return _noise_floor; }
void triggerNoiseFloorCalibrate(int threshold) override;
void setCADEnabled(bool enable) override { _cad_enabled = enable; }
void resetAGC() override;
void loop() override;