Add two-profile LoRa operation and radio CLI regression coverage

Implement radio2, tempradio2, radioat2 and tempradioat2 across Mesh roles,
with RX-only/RX+TX operation, optional preambles and persistent cross-TX
policy. Keep temporary OTA traffic on its profile by default and maintain
independent retry ownership and backoff for each profile.

Use slow-first receive scanning with 4.8-symbol visits and automatic
preambles rounded up in steps of eight. Preserve pending RX, restore power
saving on exit, and discard work bound to changed or expired profiles.

Restore the infrastructure path.hash.mode setter and report unsupported
extra.sf settings consistently. Add CLI, scheduling, scan and retry tests,
setup documentation, and the V4/XIAO hardware validation results.

Validation: 1,422 native tests, eight KISS tests, 63 final focused profile
tests, 18 checks from the staged source, sanitizer-enabled OTA transfers,
and builds for V4 Mesh roles, Full XIAO Companion and nRF52 T1000-E.
Hardware checks cover reception, cross-TX policy, expiry, reboot and OTA
discovery while receiving main-channel adverts.
This commit is contained in:
mikecarper committed 2026-09-13 08:25:00 -07:00
1 parent a25adbde64
commit 088c88507a
46 files changed
+5187 -143

No files matched your search

+71 -15
View File
@@ -142,6 +142,7 @@ static bool isGpioConfig(const char* config) {
#endif
void CommonCLI::loop() {
_radio_profiles.loop();
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
_user_gpio.loop();
UserGpio::Completion completion;
@@ -732,6 +733,7 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) {
}
void CommonCLI::loadPrefs(FILESYSTEM* fs) {
_radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc);
const bool display_settings_loaded = mesh::ui::loadDisplayPowerSettings(fs, false);
(void)display_settings_loaded;
bool is_fresh_install = false;
@@ -2462,6 +2464,20 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
mesh::cli::normalizeCommandVerb(command);
if (_radio_profiles.handle(command, reply)) return;
if (strncmp(command, "set tempradio ", 14) == 0) {
handleCommand(sender_timestamp, command + 4, reply);
return;
}
if (!strcmp(command, "get tempradio") || !strcmp(command, "tempradio")) {
const auto* radio = _callbacks->getProfileRadio();
if (!radio || !radio->profiles() || !radio->profiles()->primary_temporary) strcpy(reply, "> off");
else {
const auto& p = radio->profiles()->primary;
snprintf(reply, 160, "> %.3f,%.3f,%u,%u,%u", p.freq, p.bw, p.sf, p.cr, radio->profilePreamble(0));
}
return;
}
if (mesh::ui::handleDisplayPowerCommand(command, reply, 160)) return;
// Observer-only top-level commands (ota check/update, tls.bundletest, alert test)
@@ -2601,15 +2617,18 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
strcpy(reply, "Error: unsupported");
}
} else if (memcmp(command, "tempradio ", 10) == 0) {
strcpy(tmp, &command[10]);
uint16_t preamble = 0;
if (!mesh::RadioProfileCLI::parseSuffix(command + 10, 5, tmp, sizeof(tmp), preamble)) {
strcpy(reply, "Error, use: tempradio f,bw,sf,cr,minutes[,preamble]"); return;
}
const char *parts[5];
int num = mesh::Utils::parseTextParts(tmp, parts, 5);
float freq = 0.0f;
float bw = 0.0f;
uint8_t sf = num > 2 ? atoi(parts[2]) : 0;
uint8_t cr = num > 3 ? atoi(parts[3]) : 0;
uint32_t sf = 0, cr = 0;
uint32_t temp_timeout_mins = 0;
if (num == 5
&& parseUint32Strict(parts[2], sf) && parseUint32Strict(parts[3], cr)
&& parseUint32Strict(parts[4], temp_timeout_mins)
&& temp_timeout_mins > 0
&& (uint64_t)getRTCClock()->getCurrentTime() + 2
@@ -2618,8 +2637,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
&& mesh::cli::parseDecimalStrict(parts[1], bw)
&& freq >= 150.0f && freq <= 2500.0f
&& sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8
&& isValidLoRaBandwidth(bw)) {
_callbacks->applyTempRadioParams(freq, bw, sf, cr, temp_timeout_mins);
&& isValidLoRaBandwidth(bw)
&& _radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
_callbacks->applyTempRadioParams(freq, bw, sf, cr, temp_timeout_mins, preamble);
char duration[64];
const uint32_t seconds = (uint32_t)temp_timeout_mins * 60UL;
mesh::RepeaterRadioTiming::formatDuration(duration, sizeof(duration), seconds);
@@ -2659,7 +2679,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
sprintf(reply, "%s", _board->getManufacturerName());
#if defined(ENABLE_OTA)
} else if (memcmp(command, "ota", 3) == 0 && (command[3] == 0 || command[3] == ' ')) {
if (!_callbacks->isTempRadioActive() && otaCommandNeedsTempRadio(command)) {
if (!_callbacks->isTempRadioActive() && !_radio_profiles.secondaryTemporary()
&& otaCommandNeedsTempRadio(command)) {
strcpy(reply, "LoRa OTA needs temp radio on every node. Run: tempradio 909.950,250,5,5,120");
} else {
mesh::ota::handle_ota_command(command, reply, *_board);
@@ -3118,6 +3139,19 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) {
void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) {
const char* config = &command[4];
// Infrastructure roles dispatch here rather than through CommonRadioPrefs.
if (strncmp(config, "path.hash.mode", 14) == 0
&& (config[14] == 0 || config[14] == ' ' || config[14] == '\t')) {
uint32_t mode = 0;
if (mesh::cli::parseUnsignedIntegerStrict(config + 14, mode) && mode <= 2) {
_prefs->path_hash_mode = static_cast<uint8_t>(mode);
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 0,1, or 2");
}
return;
}
#if ENV_INCLUDE_GPS == 1
if (strncmp(config, "gps ", 4) == 0) {
if (strcmp(config + 4, "on") != 0 && strcmp(config + 4, "off") != 0) {
@@ -3603,10 +3637,17 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
float bw = 0.0f;
uint8_t sf = 0;
uint8_t cr = 0;
if (mesh::cli::parseRadioTupleStrict(&config[6], freq, bw, sf, cr)
uint16_t preamble = 0;
char legacy[96];
if (mesh::RadioProfileCLI::parseSuffix(&config[6], 4, legacy, sizeof(legacy), preamble)
&& mesh::cli::parseRadioTupleStrict(legacy, freq, bw, sf, cr)
&& freq >= 150.0f && freq <= 2500.0f
&& sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8
&& isValidLoRaBandwidth(bw)) {
&& isValidLoRaBandwidth(bw)
&& _radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
if (!_radio_profiles.savePrimaryPreamble(preamble)) {
strcpy(reply, "Error: preamble could not be saved"); return;
}
_prefs->sf = sf;
_prefs->cr = cr;
_prefs->freq = freq;
@@ -3634,12 +3675,16 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
float freq, bw;
uint8_t sf, cr;
uint32_t start_time, end_time;
if (!parseScheduledRadioArgs(&config[8], false, freq, bw, sf, cr, start_time, end_time)) {
uint16_t preamble = 0;
char legacy[120];
if (!mesh::RadioProfileCLI::parseSuffix(&config[8], 5, legacy, sizeof(legacy), preamble)
|| !parseScheduledRadioArgs(legacy, false, freq, bw, sf, cr, start_time, end_time)) {
strcpy(reply, "Error, use: set radioat f,bw,sf,cr,start");
} else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)) {
} else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
strcpy(reply, "Error, invalid radio params");
} else {
_callbacks->addScheduledRadioParams(false, freq, bw, sf, cr, start_time, end_time, reply);
_callbacks->addScheduledRadioParams(false, freq, bw, sf, cr, start_time, end_time, reply, preamble);
if (strncmp(reply, "OK", 2) == 0) {
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
}
@@ -3648,12 +3693,16 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
float freq, bw;
uint8_t sf, cr;
uint32_t start_time, end_time;
if (!parseScheduledRadioArgs(&config[12], true, freq, bw, sf, cr, start_time, end_time)) {
uint16_t preamble = 0;
char legacy[120];
if (!mesh::RadioProfileCLI::parseSuffix(&config[12], 6, legacy, sizeof(legacy), preamble)
|| !parseScheduledRadioArgs(legacy, true, freq, bw, sf, cr, start_time, end_time)) {
strcpy(reply, "Error, use: set tempradioat f,bw,sf,cr,start,end");
} else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)) {
} else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
strcpy(reply, "Error, invalid radio params");
} else {
_callbacks->addScheduledRadioParams(true, freq, bw, sf, cr, start_time, end_time, reply);
_callbacks->addScheduledRadioParams(true, freq, bw, sf, cr, start_time, end_time, reply, preamble);
if (strncmp(reply, "OK", 2) == 0) {
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
}
@@ -4288,8 +4337,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
savePrefs();
sprintf(reply, "OK - reboot.interval set to %d", _prefs->reboot_interval);
}
#if defined(USE_LR2021)
} else if (strcmp(config, "extra.sf") == 0 || memcmp(config, "extra.sf ", 9) == 0) {
#if defined(USE_LR2021)
uint8_t sideDetSFs[mesh::lr2021::STORED_SIDE_DETECTOR_BYTES] = {};
uint8_t num = 0;
const char* value = config[8] == '\0' ? "" : &config[9];
@@ -4307,6 +4356,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(reply, "Invalid extra SF config");
}
}
#else
strcpy(reply, "Error: extra.sf requires an LR2021 radio");
#endif
} else {
sprintf(reply, "unknown config: %s", config);
@@ -4538,6 +4589,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(freq, StrHelper::ftoa(_prefs->freq));
strcpy(bw, StrHelper::ftoa3(_prefs->bw));
sprintf(reply, "> %s,%s,%d,%d", freq, bw, (uint32_t)_prefs->sf, (uint32_t)_prefs->cr);
_radio_profiles.appendSavedPreamble(reply, 160, _prefs->sf, _prefs->bw);
} else if (configKeyEquals(config, "rxdelay")) {
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->rx_delay_base));
} else if (configKeyEquals(config, "txdelay")) {
@@ -4738,6 +4790,7 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
sprintf(reply, "> %d", (uint8_t)_prefs->reboot_interval);
}
} else if (strcmp(config, "extra.sf") == 0) {
#if defined(USE_LR2021)
char* tmp = reply;
for (int i = 0; i < 3 && _prefs->extra_sf[i] != 0; i++) {
tmp += sprintf(tmp, "%s%d", (i == 0) ? "" : ",", _prefs->extra_sf[i]);
@@ -4745,6 +4798,9 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
if (tmp == reply) {
strcpy(reply, "No extra SF configured");
}
#else
strcpy(reply, "Error: extra.sf requires an LR2021 radio");
#endif
} else {
mesh::cli::formatUnknownSetting(reply, 160, config);
}
+8 -2
View File
@@ -13,6 +13,7 @@
#include <helpers/CommonRadioPrefs.h>
#include <helpers/DynamicConfigSerializer.h>
#include <helpers/RepeaterRadioTiming.h>
#include <helpers/RadioProfileCLI.h>
#ifndef DEFAULT_CAD_ENABLED
#define DEFAULT_CAD_ENABLED 0
@@ -447,6 +448,7 @@ struct LegacyObserverTail {
class CommonCLICallbacks {
public:
virtual mesh::Radio* getProfileRadio() { return nullptr; }
virtual mesh::FloodAdvertLimiter* getFloodAdvertLimiter() { return nullptr; }
// Ordinary CommonCLI setters mutate NodePrefs and therefore save only the
// common image. Observer setters explicitly request Observer; only
@@ -511,7 +513,7 @@ public:
virtual mesh::LocalIdentity& getSelfId() = 0;
virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0;
virtual void clearStats() = 0;
virtual void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) = 0;
virtual void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins, uint16_t preamble = 0) = 0;
virtual uint32_t getTempRadioDurationSeconds() const { return 0; }
virtual void appendTempRadioTimingNote(char* reply, size_t size, uint32_t seconds) const { }
// Cancel pending/active temporary-radio windows and restore the saved tuple
@@ -521,7 +523,8 @@ public:
virtual bool isTempRadioActive() const { return false; }
#endif
virtual void addScheduledRadioParams(bool temporary, float freq, float bw, uint8_t sf, uint8_t cr,
uint32_t start_time, uint32_t end_time, char* reply) {
uint32_t start_time, uint32_t end_time, char* reply, uint16_t preamble = 0) {
(void)preamble;
(void)temporary;
(void)freq;
(void)bw;
@@ -744,6 +747,7 @@ class CommonCLI {
bool _observer_save_succeeded = false;
#endif
bool _com_prefs_needs_upgrade = false; // old-format legacy prefs detected; rewrite once after load
mesh::RadioProfileCLI _radio_profiles;
mesh::RTCClock* getRTCClock() { return _rtc; }
void savePrefs(
@@ -780,6 +784,8 @@ class CommonCLI {
bool handleObserverCommand(uint32_t sender_timestamp, char* command, char* reply);
public:
mesh::RadioProfileCLI& radioProfiles() { return _radio_profiles; }
const mesh::RadioProfileCLI& radioProfiles() const { return _radio_profiles; }
static bool calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble,
uint32_t* rx_us, uint32_t* sleep_us);
static bool recalculateRxPowerSavingFromLevel(NodePrefs* prefs);
+8 -1
View File
@@ -18,6 +18,8 @@ struct DeferredCliCommand {
uint32_t sender_timestamp;
uint32_t request_id;
uint8_t path_hash_size;
uint8_t radio_profile = 0;
uint32_t radio_generation = 0;
uint8_t secret[PUB_KEY_SIZE];
char command[MAX_PACKET_PAYLOAD + 1];
@@ -31,7 +33,8 @@ struct DeferredCliCommand {
bool enqueue(int new_client_index, uint32_t new_sender_timestamp,
uint8_t new_path_hash_size, const uint8_t* new_secret,
const char* new_command, size_t command_len,
uint32_t new_request_id = 0) {
uint32_t new_request_id = 0, uint8_t new_radio_profile = 0,
uint32_t new_radio_generation = 0) {
if (pending || new_secret == NULL || new_command == NULL
|| command_len >= sizeof(command)) {
return false;
@@ -42,6 +45,8 @@ struct DeferredCliCommand {
request_id = new_request_id != 0
? new_request_id : new_sender_timestamp;
path_hash_size = new_path_hash_size;
radio_profile = new_radio_profile;
radio_generation = new_radio_generation;
memcpy(secret, new_secret, sizeof(secret));
memcpy(command, new_command, command_len);
command[command_len] = 0;
@@ -65,6 +70,8 @@ struct DeferredCliCommand {
sender_timestamp = 0;
request_id = 0;
path_hash_size = 1;
radio_profile = 0;
radio_generation = 0;
memset(secret, 0, sizeof(secret));
memset(command, 0, sizeof(command));
}
+421
View File
@@ -0,0 +1,421 @@
#include "RadioProfileCLI.h"
#include "RadioProfileCommandUtils.h"
#include "radiolib/RXPowerSaving.h"
#include <Arduino.h>
#include <stdio.h>
#include <string.h>
namespace mesh {
namespace {
constexpr size_t ImageSize = 24;
const char* const ImagePath = "/radio_profiles";
const char* const BackupPath = "/radio_profiles.bak";
const char* const TempPath = "/radio_profiles.tmp";
uint32_t checksum(const uint8_t* p, size_t n) {
uint32_t crc = 0xffffffffU;
while (n--) {
crc ^= *p++;
for (int i = 0; i < 8; ++i) crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320U : 0);
}
return crc;
}
const char* modeName(RadioProfileMode m) {
return m == RadioProfileMode::RxTx ? "rxtx" : m == RadioProfileMode::Rx ? "rx" : "off";
}
bool parseMode(const char* text, RadioProfileMode& mode) {
if (!strcmp(text, "rx")) mode = RadioProfileMode::Rx;
else if (!strcmp(text, "rxtx") || !strcmp(text, "rx&tx")) mode = RadioProfileMode::RxTx;
else return false;
return true;
}
unsigned split(char* s, char* parts[], unsigned maximum) {
unsigned count = 0;
do {
if (count == maximum || !*s) return 0;
parts[count++] = s;
char* comma = strchr(s, ',');
if (!comma) break;
*comma = 0;
s = comma + 1;
} while (true);
return count;
}
bool parsePreamble(const char* s, uint16_t& result) {
return cli::parseRadioPreamble(s, result);
}
}
bool RadioProfileCLI::readImage(const char* path, uint8_t* bytes, size_t size) {
#if defined(NRF52_PLATFORM)
File file(*fs_);
if (!file.open(path, FILE_O_READ)) return false;
#elif defined(STM32_PLATFORM)
File file = fs_->open(path, FILE_O_READ);
#else
File file = fs_->open(path, "r");
#endif
if (!file) return false;
bool ok = file.size() == size && file.read(bytes, size) == (int)size;
file.close();
if (!ok) return false;
uint32_t stored;
memcpy(&stored, bytes + size - 4, 4);
return bytes[0] == 'R' && bytes[1] == '2' && bytes[2] == 1
&& stored == checksum(bytes, size - 4);
}
bool RadioProfileCLI::writeImage(const char* path, const uint8_t* bytes, size_t size) {
if (fs_->exists(path)) fs_->remove(path);
#if defined(NRF52_PLATFORM)
File file(*fs_);
if (!file.open(path, FILE_O_WRITE)) return false;
#elif defined(STM32_PLATFORM)
File file = fs_->open(path, FILE_O_WRITE);
#else
File file = fs_->open(path, "w");
#endif
if (!file) return false;
bool ok = file.write(bytes, size) == size;
file.flush();
file.close();
uint8_t verify[ImageSize];
return ok && readImage(path, verify, size) && memcmp(bytes, verify, size) == 0;
}
bool RadioProfileCLI::save(const RadioProfileConfig& config, uint16_t preamble, RadioCrossMode cross) {
if (!fs_ || hold_) return false;
uint8_t image[ImageSize] = {'R', '2', 1, (uint8_t)config.mode};
memcpy(image + 4, &config.params.freq, 4);
memcpy(image + 8, &config.params.bw, 4);
image[12] = config.params.sf; image[13] = config.params.cr;
memcpy(image + 14, &config.params.preamble, 2);
memcpy(image + 16, &preamble, 2);
image[18] = (uint8_t)cross;
const uint32_t crc = checksum(image, ImageSize - 4);
memcpy(image + ImageSize - 4, &crc, 4);
if (!writeImage(TempPath, image, sizeof(image))) return false;
if (fs_->exists(ImagePath)) {
if (fs_->exists(BackupPath)) fs_->remove(BackupPath);
if (!fs_->rename(ImagePath, BackupPath)) return false;
}
if (!fs_->rename(TempPath, ImagePath)) {
if (fs_->exists(BackupPath)) fs_->rename(BackupPath, ImagePath);
return false;
}
if (fs_->exists(BackupPath)) fs_->remove(BackupPath);
return true;
}
void RadioProfileCLI::begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc) {
fs_ = fs; radio_ = radio; rtc_ = rtc; last_ms_ = millis();
if (!fs_ || !radio_ || !radio_->profiles()) return;
uint8_t bytes[ImageSize];
bool loaded = readImage(ImagePath, bytes, sizeof(bytes));
if (!loaded && readImage(BackupPath, bytes, sizeof(bytes))) {
if (fs_->exists(ImagePath)) fs_->remove(ImagePath);
loaded = fs_->rename(BackupPath, ImagePath);
}
if (!loaded) {
hold_ = fs_->exists(ImagePath) || fs_->exists(BackupPath);
return;
}
saved_.mode = (RadioProfileMode)bytes[3];
memcpy(&saved_.params.freq, bytes + 4, 4);
memcpy(&saved_.params.bw, bytes + 8, 4);
saved_.params.sf = bytes[12]; saved_.params.cr = bytes[13];
memcpy(&saved_.params.preamble, bytes + 14, 2);
memcpy(&primary_preamble_, bytes + 16, 2);
cross_ = (RadioCrossMode)bytes[18];
if ((uint8_t)saved_.mode > 2 || (uint8_t)cross_ > 2
|| (saved_.mode != RadioProfileMode::Off && !radio_->validateProfile(saved_.params))
|| (primary_preamble_ && (primary_preamble_ < 8 || primary_preamble_ > RadioProfiles::MaxPreamble))) {
hold_ = true; saved_ = {}; primary_preamble_ = 0; cross_ = RadioCrossMode::Auto;
return;
}
stagePrimary(primary_preamble_, false);
publish();
}
bool RadioProfileCLI::savePrimaryPreamble(uint16_t symbols) {
if (symbols && (symbols < 8 || symbols > RadioProfiles::MaxPreamble)) return false;
if (!radio_ || !radio_->profiles()) return symbols == 0;
if (symbols == primary_preamble_) return true;
if (!save(saved_, symbols, cross_)) return false;
primary_preamble_ = symbols;
return true;
}
bool RadioProfileCLI::acceptsPrimary(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble) const {
if (!radio_ || !radio_->profiles()) return preamble == 0;
auto preview = *radio_->profiles();
auto& p = preview.primary;
p.freq = freq; p.bw = bw; p.sf = sf; p.cr = cr; p.preamble = preamble;
return radio_->validateProfile(p) && preview.automaticPreambleFits();
}
void RadioProfileCLI::publish() {
if (!radio_ || !radio_->profiles()) return;
radio_->profiles()->cross = cross_;
if (publish_pending_ && (int32_t)(millis() - publish_after_ms_) < 0) return;
publish_pending_ = false;
radio_->profiles()->setSecondary(temp_active_ ? temporary_ : saved_, temp_active_);
}
void RadioProfileCLI::loop() {
if (!radio_ || !radio_->profiles()) return;
const uint32_t now = millis(), elapsed = now - last_ms_;
last_ms_ = now;
if (temp_pending_ && (int32_t)(now - temp_start_ms_) >= 0) {
temporary_ = pending_temporary_;
const uint32_t late = now - temp_start_ms_;
temp_remaining_ms_ = late < pending_duration_ms_ ? pending_duration_ms_ - late : 0;
temp_pending_ = false; temp_active_ = temp_remaining_ms_ != 0;
} else if (temp_active_) {
if (elapsed >= temp_remaining_ms_) { temp_remaining_ms_ = 0; temp_active_ = false; }
else temp_remaining_ms_ -= elapsed;
}
const uint32_t epoch = rtc_ ? rtc_->getCurrentTime() : 0;
for (auto& s : schedules_) {
if (!s.active) continue;
if (s.temporary) {
if (elapsed >= s.remaining_ms || epoch >= s.end) {
if (s.started) { temp_active_ = false; temp_remaining_ms_ = 0; }
s = {}; continue;
}
s.remaining_ms -= elapsed;
}
if (s.started || epoch < s.start) continue;
if (s.temporary) {
if (temp_active_ || temp_pending_) continue;
temporary_ = s.config;
temp_remaining_ms_ = s.remaining_ms;
const uint32_t epoch_left = (s.end - epoch) * 1000UL;
if (temp_remaining_ms_ > epoch_left) temp_remaining_ms_ = epoch_left;
temp_active_ = true; s.started = true;
} else if (save(s.config, primary_preamble_, cross_)) {
saved_ = s.config; s = {};
} else {
// Avoid a flash write on every main-loop iteration after a storage fault.
s.start = epoch <= UINT32_MAX - 60 ? epoch + 60 : UINT32_MAX;
}
}
publish();
}
bool RadioProfileCLI::parseSuffix(const char* input, unsigned fields, char* legacy,
size_t capacity, uint16_t& preamble) {
return cli::parseRadioPreambleSuffix(input, fields, legacy, capacity, preamble);
}
void RadioProfileCLI::appendPreamble(char* reply, size_t capacity, uint8_t profile) const {
if (!radio_ || !radio_->profiles()) return;
const size_t used = strlen(reply);
if (used < capacity) snprintf(reply + used, capacity - used, ",preamble=%u",
(unsigned)radio_->profilePreamble(profile));
}
void RadioProfileCLI::appendSavedPreamble(char* reply, size_t capacity, uint8_t sf, float bw) const {
if (!radio_ || !radio_->profiles()) return;
auto preview = *radio_->profiles();
preview.primary.sf = sf; preview.primary.bw = bw;
preview.primary.preamble = primary_preamble_;
const size_t used = strlen(reply);
if (used < capacity) snprintf(reply + used, capacity - used, ",preamble=%u%s",
preview.preamble(0, rxPowerSavingPreambleForParams(sf, bw)), primary_preamble_ ? "" : " (auto)");
}
void RadioProfileCLI::formatConfig(char* reply, size_t capacity, const RadioProfileConfig& config,
bool temporary, uint32_t remaining) const {
if (config.mode == RadioProfileMode::Off) { snprintf(reply, capacity, "> off"); return; }
RadioProfiles preview = *radio_->profiles();
preview.secondary = config;
uint16_t preamble = preview.preamble(1, 32);
snprintf(reply, capacity, "> %.3f,%.3f,%u,%u,%s,%u%s", config.params.freq, config.params.bw,
config.params.sf, config.params.cr, modeName(config.mode), preamble,
config.params.preamble ? "" : " (auto)");
if (temporary) {
uint32_t minutes = (remaining + 59999UL) / 60000UL;
const size_t used = strlen(reply);
if (used < capacity) snprintf(reply + used, capacity - used, "; %lud%luh%lum (%lu min) left",
(unsigned long)(minutes / 1440), (unsigned long)(minutes / 60 % 24),
(unsigned long)(minutes % 60), (unsigned long)minutes);
}
}
bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity) {
const char* text = command;
enum { Get, Set, Delete } verb = Get;
if (!strncmp(text, "get ", 4)) text += 4;
else if (!strncmp(text, "set ", 4)) { text += 4; verb = Set; }
else if (!strncmp(text, "del ", 4)) { text += 4; verb = Delete; }
char key[24];
size_t n = strcspn(text, " \t");
if (n >= sizeof(key)) return false;
memcpy(key, text, n); key[n] = 0;
const char* args = text + n;
while (*args == ' ' || *args == '\t') ++args;
const bool base = !strcmp(key, "radio2");
const bool temporary = !strcmp(key, "tempradio2");
const bool scheduled = !strcmp(key, "radioat2") || !strcmp(key, "tempradioat2");
const bool scheduled_temp = !strcmp(key, "tempradioat2");
const bool crossing = !strcmp(key, "radio2.cross");
const bool status = !strcmp(key, "radio2.status");
const bool scan = !strcmp(key, "radio2.scan");
if (!base && !temporary && !scheduled && !crossing && !status && !scan) return false;
if (!radio_ || !radio_->profiles()) { snprintf(reply, capacity, "Error: radio profiles unsupported"); return true; }
if (command == text && *args && verb == Get && (temporary || base)) verb = Set;
if (crossing) {
if (verb == Get && !*args) {
snprintf(reply, capacity, "> %s", cross_ == RadioCrossMode::Auto ? "auto" : cross_ == RadioCrossMode::On ? "on" : "off");
} else if (verb == Set && (!strcmp(args, "auto") || !strcmp(args, "on") || !strcmp(args, "off"))) {
auto mode = !strcmp(args, "auto") ? RadioCrossMode::Auto : !strcmp(args, "on") ? RadioCrossMode::On : RadioCrossMode::Off;
if (save(saved_, primary_preamble_, mode)) { cross_ = mode; publish(); snprintf(reply, capacity, "OK"); }
else snprintf(reply, capacity, "Error: settings could not be saved");
} else snprintf(reply, capacity, "Error: use get/set radio2.cross [auto|on|off]");
return true;
}
if (scan) {
if (verb != Get || *args) { snprintf(reply, capacity, "Error: radio2.scan is read-only"); return true; }
const auto& p = *radio_->profiles();
if (!p.enabled()) snprintf(reply, capacity, "> off");
else {
const uint8_t slow = p.slowerProfile();
const uint16_t preamble = radio_->profilePreamble(slow);
snprintf(reply, capacity, "> slow=%s; listen_us=%lu,%lu; preamble=%u,%u",
slow ? "radio2" : "radio", (unsigned long)p.listenUs(0, preamble),
(unsigned long)p.listenUs(1, preamble), radio_->profilePreamble(0), radio_->profilePreamble(1));
}
return true;
}
if (status) {
if (verb != Get || *args) { snprintf(reply, capacity, "Error: radio2.status is read-only"); return true; }
const auto& p = *radio_->profiles();
snprintf(reply, capacity, "> %s; RX=%lu,%lu TX=%lu,%lu switches=%lu errors=%lu max=%luus preamble=%u,%u",
modeName(p.secondary.mode), (unsigned long)p.rx_packets[0], (unsigned long)p.rx_packets[1],
(unsigned long)p.tx_packets[0], (unsigned long)p.tx_packets[1],
(unsigned long)p.switches, (unsigned long)p.switch_failures,
(unsigned long)p.longest_switch_us, radio_->profilePreamble(0), radio_->profilePreamble(1));
return true;
}
if ((base || temporary) && verb == Get && !*args) {
if (temporary && !temp_active_ && !temp_pending_) snprintf(reply, capacity, "> off");
else formatConfig(reply, capacity, temporary ? (temp_pending_ ? pending_temporary_ : temporary_) : saved_,
temporary, temp_pending_ ? pending_duration_ms_ : temp_remaining_ms_);
return true;
}
if ((base || temporary) && verb == Set && !strcmp(args, "off")) {
if (base && !save({}, primary_preamble_, cross_)) {
snprintf(reply, capacity, "Error: settings could not be saved"); return true;
}
if (base) saved_ = {};
temp_pending_ = temp_active_ = false; temp_remaining_ms_ = 0;
for (auto& s : schedules_) if (base || s.temporary) s = {};
publish_pending_ = true; publish_after_ms_ = millis() + 2000;
publish(); snprintf(reply, capacity, "OK - %s in 2s", saved_.mode != RadioProfileMode::Off ? "saved radio2 restored" : "single radio");
return true;
}
if (scheduled && (verb == Get || verb == Delete)) {
uint32_t index = 0;
if (*args && strcmp(args, "all") && (!cli::parseUnsignedIntegerStrict(args, index) || index < 1 || index > 4)) {
snprintf(reply, capacity, "Error: slot must be 1-4 or all"); return true;
}
const unsigned first = scheduled_temp ? 4 : 0;
if (verb == Delete) {
for (unsigned i = 0; i < 4; ++i) if (!index || index == i + 1) {
if (schedules_[first+i].started) {
temp_active_ = false; temp_remaining_ms_ = 0;
publish_pending_ = true; publish_after_ms_ = millis() + 2000;
}
schedules_[first+i] = {};
}
publish(); snprintf(reply, capacity, "OK"); return true;
}
if (!index) {
snprintf(reply, capacity, "> slots: ");
for (unsigned i = 0; i < 4; ++i) if (schedules_[first+i].active) {
const size_t used = strlen(reply);
if (used < capacity) snprintf(reply + used, capacity-used, "%u@%lu ", i+1, (unsigned long)schedules_[first+i].start);
}
return true;
}
const auto& s = schedules_[first+index-1];
if (!s.active) snprintf(reply, capacity, "> off");
else {
formatConfig(reply, capacity, s.config, s.temporary, s.remaining_ms);
const size_t used = strlen(reply);
if (used < capacity) snprintf(reply+used, capacity-used, "; @%lu-%lu", (unsigned long)s.start, (unsigned long)s.end);
}
return true;
}
if (verb != Set) { snprintf(reply, capacity, "Error: invalid radio2 command"); return true; }
char local[140];
if (strlen(args) >= sizeof(local)) { snprintf(reply, capacity, "Error: params too long"); return true; }
strcpy(local, args);
char* parts[8];
const unsigned count = split(local, parts, 8);
const unsigned expected = scheduled_temp ? 7 : scheduled || temporary ? 6 : 5;
RadioProfileConfig config;
uint32_t sf = 0, cr = 0, start = 0, end = 0, minutes = 0;
bool valid = (count == expected || count == expected + 1)
&& cli::parseDecimalStrict(parts[0], config.params.freq)
&& cli::parseDecimalStrict(parts[1], config.params.bw)
&& cli::parseUnsignedIntegerStrict(parts[2], sf) && sf <= 255
&& cli::parseUnsignedIntegerStrict(parts[3], cr) && cr <= 255
&& parseMode(parts[4], config.mode);
config.params.sf = sf; config.params.cr = cr;
if (valid && count > expected) valid = parsePreamble(parts[expected], config.params.preamble);
if (valid && temporary) valid = cli::parseUnsignedIntegerStrict(parts[5], minutes) && minutes >= 1 && minutes <= 10080;
if (valid && scheduled) valid = cli::parseUnsignedIntegerStrict(parts[5], start);
if (valid && scheduled_temp) valid = cli::parseUnsignedIntegerStrict(parts[6], end);
if (valid) valid = radio_->validateProfile(config.params);
RadioProfiles preview = *radio_->profiles(); preview.secondary = config;
if (valid) valid = preview.automaticPreambleFits();
if (!valid) { snprintf(reply, capacity, "Error: use %s f,bw,sf,cr,rx|rxtx%s[,preamble|auto]", key,
scheduled_temp ? ",start,end" : scheduled ? ",start" : temporary ? ",minutes" : ""); return true; }
if (scheduled) {
const uint32_t epoch = rtc_ ? rtc_->getCurrentTime() : 0;
if (!epoch || start <= epoch || (uint64_t)(start-epoch) * 1000 > 0x7fffffffULL
|| (scheduled_temp && (end <= start || (uint64_t)(end-epoch) * 1000 > 0x7fffffffULL))) {
snprintf(reply, capacity, "Error: future UTC start/end required, within 24 days"); return true;
}
if (scheduled_temp) {
const uint64_t pending_left = temp_pending_
? pending_duration_ms_ + (uint64_t)((int32_t)(temp_start_ms_ - millis()) > 0 ? temp_start_ms_ - millis() : 0)
: 0;
const uint64_t session_left = pending_left > temp_remaining_ms_ ? pending_left : temp_remaining_ms_;
if ((temp_active_ || temp_pending_) && (uint64_t)(start-epoch)*1000 < session_left) {
snprintf(reply, capacity, "Error: temporary schedule overlaps active session"); return true;
}
for (const auto& s : schedules_) if (s.active && s.temporary && start < s.end && end > s.start) {
snprintf(reply, capacity, "Error: temporary schedules overlap"); return true;
}
}
const unsigned first = scheduled_temp ? 4 : 0;
for (unsigned i = 0; i < 4; ++i) if (!schedules_[first+i].active) {
auto& s = schedules_[first+i]; s = {};
s.config = config; s.start = start; s.end = end; s.temporary = scheduled_temp;
s.remaining_ms = scheduled_temp ? (end-epoch)*1000UL : 0; s.active = true;
snprintf(reply, capacity, "OK - %s %u queued, preamble=%u", key, i+1, preview.preamble(1, 32)); return true;
}
snprintf(reply, capacity, "Error: all four schedule slots occupied"); return true;
}
if (temporary) {
for (const auto& s : schedules_) if (s.active && s.temporary) {
snprintf(reply, capacity, "Error: clear tempradioat2 schedules first"); return true;
}
pending_temporary_ = config; pending_duration_ms_ = minutes * 60000UL;
temp_start_ms_ = millis() + 2000; temp_pending_ = true;
// An existing session remains active until the new request can take over.
snprintf(reply, capacity, "OK - tempradio2 %lud%luh%lum (%lu min); preamble=%u",
(unsigned long)(minutes/1440), (unsigned long)(minutes/60%24),
(unsigned long)(minutes%60), (unsigned long)minutes, preview.preamble(1, 32));
} else if (save(config, primary_preamble_, cross_)) {
saved_ = config;
publish_pending_ = true; publish_after_ms_ = millis() + 2000;
publish();
snprintf(reply, capacity, "OK - radio2 %s; preamble=%u", modeName(config.mode), preview.preamble(1, 32));
} else snprintf(reply, capacity, "Error: settings could not be saved");
return true;
}
} // namespace mesh
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <Dispatcher.h>
#include <helpers/IdentityStore.h>
#include <helpers/CLICommandUtils.h>
namespace mesh {
// Shared by infrastructure, Companion and standalone roles. Settings use a
// separate versioned image so existing preference offsets stay compatible.
class RadioProfileCLI {
public:
struct Schedule {
RadioProfileConfig config;
uint32_t start = 0, end = 0;
uint32_t remaining_ms = 0; // monotonic upper bound, including start delay
bool active = false, temporary = false, started = false;
};
private:
FILESYSTEM* fs_ = nullptr;
Radio* radio_ = nullptr;
RTCClock* rtc_ = nullptr;
RadioProfileConfig saved_;
RadioProfileConfig temporary_;
RadioProfileConfig pending_temporary_;
uint16_t primary_preamble_ = 0;
RadioCrossMode cross_ = RadioCrossMode::Auto;
Schedule schedules_[8];
uint32_t temp_remaining_ms_ = 0;
uint32_t temp_start_ms_ = 0;
uint32_t pending_duration_ms_ = 0;
uint32_t publish_after_ms_ = 0;
uint32_t last_ms_ = 0;
bool temp_pending_ = false, temp_active_ = false;
bool hold_ = false;
bool publish_pending_ = false;
bool save(const RadioProfileConfig& config, uint16_t preamble, RadioCrossMode cross);
bool readImage(const char* path, uint8_t* bytes, size_t size);
bool writeImage(const char* path, const uint8_t* bytes, size_t size);
void publish();
void formatConfig(char* reply, size_t capacity, const RadioProfileConfig& config,
bool temporary, uint32_t remaining_ms) const;
public:
void begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc);
void loop();
bool handle(const char* command, char* reply, size_t capacity = 160);
uint16_t primaryPreamble() const { return primary_preamble_; }
bool savePrimaryPreamble(uint16_t symbols);
bool acceptsPrimary(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble) const;
RadioParamApplyResult applyPrimary(float freq, float bw, uint8_t sf, uint8_t cr,
bool temporary, uint16_t preamble, const uint32_t* timings = nullptr) {
if (!radio_) return RadioParamApplyResult::FAILED;
RadioProfileParams p;
p.freq = freq; p.bw = bw; p.sf = sf; p.cr = cr; p.preamble = preamble;
return radio_->trySetPrimaryParams(p, temporary, timings);
}
void stagePrimary(uint16_t preamble, bool temporary) {
if (!radio_ || !radio_->profiles()) return;
auto p = radio_->profiles()->primary;
p.preamble = preamble;
radio_->profiles()->setPrimary(p, temporary);
}
bool secondaryTemporary() const { return temp_active_; }
uint32_t secondaryRemainingSeconds() const { return (temp_remaining_ms_ + 999) / 1000; }
void appendPreamble(char* reply, size_t capacity, uint8_t profile = 0) const;
void appendSavedPreamble(char* reply, size_t capacity, uint8_t sf, float bw) const;
// Strip exactly one optional trailing preamble before a legacy primary
// parser runs. Missing means auto. Reject extra fields and malformed input.
static bool parseSuffix(const char* input, unsigned fields, char* legacy,
size_t capacity, uint16_t& preamble);
};
} // namespace mesh
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <RadioProfiles.h>
#include <helpers/CLICommandUtils.h>
namespace mesh { namespace cli {
inline bool parseRadioPreamble(const char* text, uint16_t& result) {
uint32_t value;
if (!strcmp(text, "auto")) { result = 0; return true; }
if (!parseUnsignedIntegerStrict(text, value) || value > RadioProfiles::MaxPreamble
|| (value != 0 && value < 8)) return false;
result = (uint16_t)value;
return true;
}
inline bool parseRadioPreambleSuffix(const char* input, unsigned fields, char* legacy,
size_t capacity, uint16_t& preamble) {
preamble = 0;
if (!input || strlen(input) >= capacity) return false;
strcpy(legacy, input);
unsigned count = 1;
char* last = nullptr;
for (char* p = legacy; *p; ++p) if (*p == ',') { ++count; last = p; }
if (count == fields) return true;
if (count != fields + 1 || !last || !parseRadioPreamble(last + 1, preamble)) return false;
*last = 0;
return true;
}
} }
+13 -1
View File
@@ -89,6 +89,16 @@ mesh::Packet* PacketQueue::removeByIdx(int i) {
return item;
}
bool PacketQueue::reschedule(mesh::Packet* packet, uint32_t scheduled_for) {
for (int i = 0; i < _num; ++i) {
if (_table[i] != packet) continue;
_schedule_table[i] = scheduled_for;
rebuildNextTime();
return true;
}
return false;
}
bool PacketQueue::add(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) {
if (_num == _size) {
return false;
@@ -120,7 +130,9 @@ void PacketQueue::applyBestFloodTransportScope(mesh::Packet* packet,
uint8_t best_preference = 0;
for (int i = 0; i < _num; i++) {
const mesh::Packet* candidate = _table[i];
if (candidate == NULL || candidate->getRouteType() != ROUTE_TYPE_TRANSPORT_FLOOD) continue;
if (candidate == NULL || candidate->getRouteType() != ROUTE_TYPE_TRANSPORT_FLOOD
|| candidate->radio_profile != packet->radio_profile
|| candidate->radio_generation != packet->radio_generation) continue;
uint8_t candidate_hash[MAX_HASH_SIZE];
candidate->calculatePacketHash(candidate_hash);
if (memcmp(packet_hash, candidate_hash, sizeof(packet_hash)) != 0) continue;
+4
View File
@@ -30,6 +30,7 @@ public:
bool getNextTime(uint32_t now, uint32_t& scheduled_for) const;
mesh::Packet* itemAt(int i) const { return _table[i]; }
mesh::Packet* removeByIdx(int i);
bool reschedule(mesh::Packet* packet, uint32_t scheduled_for);
};
class StaticPoolPacketManager : public mesh::PacketManager {
@@ -50,6 +51,9 @@ public:
bool queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) override;
mesh::Packet* getNextOutbound(uint32_t now) override;
mesh::Packet* peekNextOutbound(uint32_t now) override;
bool deferOutbound(mesh::Packet* packet, uint32_t scheduled_for) override {
return send_queue.reschedule(packet, scheduled_for);
}
int getOutboundCount(uint32_t now) const override;
int getOutboundTotal() const override;
bool getNextOutboundTime(uint32_t now, uint32_t& scheduled_for) const override;
+5 -1
View File
@@ -56,6 +56,7 @@ public:
}
bool configSideDetectors(const uint8_t* sideDetSFs, uint8_t num, float bw) override {
if (_profiles.enabled()) return false;
const uint8_t primary_sf = getSpreadingFactor();
float active_bw = _params_valid ? _cur_bw : ((CustomLR2021 *)_radio)->getBandwidthKhz();
if (active_bw <= 0.0f) active_bw = bw;
@@ -104,6 +105,9 @@ protected:
}
bool applySideDetectorConfig(uint8_t primary_sf, float bw) {
if (_profiles.enabled()) {
return ((CustomLR2021 *)_radio)->setSideDetector(nullptr, 0) == RADIOLIB_ERR_NONE;
}
if (!mesh::lr2021::validateSideDetectorSFs(_sideDetSFs, _numSideDet, primary_sf, bw)) {
return false;
}
@@ -114,7 +118,7 @@ protected:
}
int16_t performChannelScan() override {
if (_numSideDet == 0) return RadioLibWrapper::performChannelScan();
if (_numSideDet == 0 || _profiles.enabled()) return RadioLibWrapper::performChannelScan();
CustomLR2021* radio = (CustomLR2021 *)_radio;
const uint8_t rx_primary_sf = getSpreadingFactor();
+183 -5
View File
@@ -44,6 +44,10 @@ void RadioLibWrapper::begin() {
const uint8_t initial_cr = 5;
#endif
cacheParams(LORA_FREQ, LORA_BW, LORA_SF, initial_cr);
_profiles.primary.freq = LORA_FREQ;
_profiles.primary.bw = LORA_BW;
_profiles.primary.sf = LORA_SF;
_profiles.primary.cr = initial_cr;
}
#endif
#ifdef LORA_TX_POWER
@@ -162,18 +166,31 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw,
const float previous_bw = _cur_bw;
const uint8_t previous_sf = _cur_sf;
const uint8_t previous_cr = _cur_cr;
const uint16_t previous_preamble = _physical_preamble;
mesh::RadioProfileParams requested = _profiles.primary;
requested.freq = freq; requested.bw = bw; requested.sf = sf; requested.cr = cr;
const auto previous_primary = _profiles.primary;
_profiles.primary = requested;
_physical_preamble = profilePreamble(0);
bool success = applyParams(freq, bw, sf, cr);
if (success) {
cacheParams(freq, bw, sf, cr);
_profiles.primary = previous_primary;
_profiles.setPrimary(requested, _profiles.primary_temporary);
_active_profile = 0;
_profile_generation = _profiles.generation[0];
if (rx_ps_timings != NULL) {
_rx_ps_enabled = true;
if (_profile_rxps_suspended) _profile_saved_rxps = true;
else _rx_ps_enabled = true;
_rx_ps_rx_us = rx_ps_timings[0];
_rx_ps_sleep_us = rx_ps_timings[1];
_rx_ps_continuous_fallback = rxPowerSavingUsesContinuousFallback(
_rx_ps_rx_us, _rx_ps_sleep_us);
}
} else {
_profiles.primary = previous_primary;
_physical_preamble = previous_preamble;
bool restored = had_previous_params
&& applyParams(previous_freq, previous_bw, previous_sf, previous_cr);
@@ -185,14 +202,158 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw,
}
endReconfigure(resume_rx);
_profile_visit_us = micros();
return success ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED;
}
bool RadioLibWrapper::validateProfile(const mesh::RadioProfileParams& p) const {
if (!mesh::RadioProfiles::valid(p)) return false;
#if defined(USE_SX1276)
return p.freq >= 137 && p.freq <= 1020 && p.sf >= 7 && p.bw <= 500;
#elif defined(USE_SX1272)
return p.freq >= 860 && p.freq <= 1020 && p.sf >= 7 && p.bw >= 125 && p.bw <= 500;
#elif defined(USE_LLCC68)
return p.freq >= 150 && p.freq <= 960
&& ((p.bw == 125 && p.sf <= 9) || (p.bw == 250 && p.sf <= 10)
|| (p.bw == 500 && p.sf <= 11));
#elif defined(USE_LR2021)
return (p.freq <= 1090 || p.freq >= 1900) && p.bw >= 31.25 && p.bw <= 1000;
#elif defined(USE_LR1110)
return p.freq <= 960 && p.bw >= 62.5 && p.bw <= 500;
#elif defined(USE_SX1268)
return p.freq >= 410 && p.freq <= 810 && p.bw <= 500;
#else
return p.freq <= 960 && p.bw <= 500;
#endif
}
mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) {
if (profile > 1 || (profile == 1 && !_profiles.enabled())) return mesh::RadioParamApplyResult::FAILED;
const auto& p = _profiles.params(profile);
const uint16_t preamble = profilePreamble(profile);
if (!_profile_refresh_required && _active_profile == profile && _profile_generation == _profiles.generation[profile]
&& _physical_preamble == preamble) return mesh::RadioParamApplyResult::APPLIED;
if (!validateProfile(p)) return mesh::RadioParamApplyResult::FAILED;
const uint32_t started = micros();
const uint8_t resume = beginReconfigure();
if (resume > 1) return mesh::RadioParamApplyResult::BUSY;
const uint16_t old_preamble = _physical_preamble;
_physical_preamble = preamble;
const bool applied = applyParams(p.freq, p.bw, p.sf, p.cr);
if (applied) {
cacheParams(p.freq, p.bw, p.sf, p.cr);
_active_profile = profile;
_profile_generation = _profiles.generation[profile];
_profile_refresh_required = false;
++_profiles.switches;
} else {
++_profiles.switch_failures;
_physical_preamble = old_preamble;
if (!applyParams(_cur_freq, _cur_bw, _cur_sf, _cur_cr)) restoreAfterDeepInit();
}
endReconfigure(resume);
_profile_visit_us = micros();
const uint32_t elapsed = _profile_visit_us - started;
if (elapsed > _profiles.longest_switch_us) _profiles.longest_switch_us = elapsed;
return applied ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED;
}
mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile) {
if (!_profiles.canTransmit(profile)) return mesh::RadioParamApplyResult::FAILED;
if (_profiles.enabled() && (isChipBusy() || isPacketPendingOrReceiving())) {
return mesh::RadioParamApplyResult::BUSY;
}
return tuneProfile(profile);
}
void RadioLibWrapper::serviceProfileScan() {
if (!_params_valid || (_profile_retry_at && (int32_t)(millis() - _profile_retry_at) < 0)) return;
_profile_retry_at = 0;
if (_profiles.enabled() && !_profile_rxps_suspended) {
const uint8_t resume = beginReconfigure();
if (resume > 1) return;
_profile_saved_rxps = _rx_ps_enabled;
_rx_ps_enabled = false;
_profile_rxps_suspended = true;
_nf_calib_active = false;
_noise_floor_valid = false; // a single-channel floor cannot describe both channels
_profile_refresh_required = true; // refresh side detectors even if the tuple is unchanged
endReconfigure(resume);
}
uint8_t target = _active_profile;
const bool restart_scan = _profiles.enabled()
&& (_profile_scan_generation[0] != _profiles.generation[0]
|| _profile_scan_generation[1] != _profiles.generation[1]);
if (!_profiles.enabled()) target = 0;
else if (restart_scan) target = _profiles.slowerProfile();
else if ((uint32_t)(micros() - _profile_visit_us) >= _profiles.listenUs(
_active_profile, profilePreamble(_profiles.slowerProfile()))) {
target ^= 1;
}
const auto result = tuneProfile(target);
if (restart_scan && result == mesh::RadioParamApplyResult::APPLIED) {
_profile_scan_generation[0] = _profiles.generation[0];
_profile_scan_generation[1] = _profiles.generation[1];
_profile_visit_us = micros();
}
if (result == mesh::RadioParamApplyResult::FAILED) {
// Retry a rejected or failing profile at a bounded rate; retain primary RX.
_profile_retry_at = millis() + 1000;
tuneProfile(0);
}
if (!_profiles.enabled() && _profile_rxps_suspended && _active_profile == 0
&& result == mesh::RadioParamApplyResult::APPLIED) {
const uint8_t resume = beginReconfigure();
if (resume > 1) return;
_rx_ps_enabled = _profile_saved_rxps;
_profile_rxps_suspended = false;
_profile_refresh_required = true;
recalibrateNoiseFloor();
endReconfigure(resume);
}
}
uint32_t RadioLibWrapper::getProfileAirtime(uint8_t profile, int len, uint8_t cr) {
if (!_profiles.enabled() && profile == 0 && cr == 0) return getEstAirtimeFor(len);
const auto& p = _profiles.params(profile);
const double symbol = mesh::RadioProfiles::symbolUs(p);
if (symbol <= 0 || len < 0) return 0;
if (cr < 5 || cr > 8) cr = p.cr;
const int de = symbol >= 16000 ? 1 : 0;
const double coded = ceil((8.0 * len - 4 * p.sf + (p.sf <= 6 ? 20 : 28) + 16)
/ (4 * (p.sf - 2 * de)));
const double payload_symbols = 8 + (coded > 0 ? coded * cr : 0);
const double suffix = p.sf <= 6 ? 6.25 : 4.25;
return (uint32_t)ceil((profilePreamble(profile) + suffix + payload_symbols) * symbol / 1000.0);
}
bool RadioLibWrapper::setParams(float freq, float bw, uint8_t sf, uint8_t cr,
const uint32_t* rx_ps_timings) {
return trySetParams(freq, bw, sf, cr, rx_ps_timings) == mesh::RadioParamApplyResult::APPLIED;
}
mesh::RadioParamApplyResult RadioLibWrapper::trySetPrimaryParams(const mesh::RadioProfileParams& p,
bool temporary, const uint32_t* timings) {
auto preview = _profiles;
preview.primary = p;
if (!validateProfile(p) || !preview.automaticPreambleFits()) return mesh::RadioParamApplyResult::FAILED;
const auto previous = _profiles.primary;
const bool was_temp = _profiles.primary_temporary;
const uint32_t generation = _profiles.generation[0];
_profiles.primary.preamble = p.preamble;
_profiles.primary_temporary = temporary;
const auto result = trySetParams(p.freq, p.bw, p.sf, p.cr, timings);
if (result != mesh::RadioParamApplyResult::APPLIED) {
_profiles.primary = previous;
_profiles.primary_temporary = was_temp;
_profiles.generation[0] = generation;
} else {
_profiles.generation[0] = generation + (previous != p || was_temp != temporary);
_profile_generation = _profiles.generation[0];
}
return result;
}
bool RadioLibWrapper::setRxBoostedGainMode(bool enabled) {
uint8_t resume_rx = beginReconfigure();
if (resume_rx > 1) return false;
@@ -471,6 +632,13 @@ void RadioLibWrapper::checkReceiveMode(uint32_t now) {
}
void RadioLibWrapper::loop() {
serviceProfileScan();
// Calibration batches need one stable channel. Do not publish a noise floor
// assembled from different frequencies, or let a batch pin the scan on one.
if (_profiles.enabled()) {
checkReceiveMode(static_cast<uint32_t>(millis()));
return;
}
if (_rx_ps_enabled && !_rx_ps_continuous_fallback) {
rxPsWatchdogCheck();
}
@@ -552,6 +720,7 @@ void RadioLibWrapper::startRecv() {
int err = startReceiveMode();
if (err == RADIOLIB_ERR_NONE) {
// A very short frame may complete while startReceiveMode() returns.
_profile_visit_us = micros();
// Retain that RX interrupt instead of overwriting it with software state.
noInterrupts();
state = (state & STATE_INT_READY) | STATE_RX;
@@ -611,6 +780,12 @@ bool RadioLibWrapper::isInRecvMode() const {
// RX PowerSaving
bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) {
if (_profile_rxps_suspended) {
_profile_saved_rxps = enabled;
_rx_ps_rx_us = rx_us;
_rx_ps_sleep_us = sleep_us;
return !enabled || supportsRxPowerSaving();
}
if (enabled && !supportsRxPowerSaving()) {
return false;
}
@@ -791,7 +966,7 @@ bool RadioLibWrapper::isChannelActive() {
// int.thresh: RSSI-based interference detection (relative to noise floor).
// In RX duty-cycle mode only checked while the chip is in a listen window
// (during the sleep window the frontend is off and the read would stall).
if (_threshold != 0 && !(_rx_ps_armed && isChipBusy())
if (_threshold != 0 && !_profiles.enabled() && !(_rx_ps_armed && isChipBusy())
&& getCurrentRSSI() > _noise_floor + _threshold) return true;
// cad: hardware channel activity detection
@@ -823,7 +998,10 @@ bool RadioLibWrapper::isReceivingPassive(int interference_margin_db) {
// busy so the retry is deferred instead of transmitting blind; Dispatcher
// retains its bounded busy timeout as a last-resort escape.
if (isChipBusy()) return true;
if (isReceivingPacket()) return true;
if (isPacketPendingOrReceiving()) return true;
// Four-symbol visits are too short for the settled single-channel noise
// calibration. Keep retries in RX and use the preamble/header IRQs here.
if (_profiles.enabled()) return false;
unsigned long now = millis();
if ((!_noise_floor_valid && _nf_last_calib == 0)
@@ -866,13 +1044,13 @@ float RadioLibWrapper::packetScoreInt(float snr, int sf, int packet_len) {
return score < 1.0f ? score : 1.0f;
}
PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols) {
PacketMillis RadioLibWrapper::calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint16_t preambleSymbols) {
// based on RadioLib's calculateTimeOnAir()
uint32_t tsym_us = ((uint32_t)10000 << sf) / (bw * 10);
uint32_t sfCoeff1_x4 = (sf == 5 || sf == 6) ? 25 : 17; // 6.25 : 4.25, semtech magic numbers to account for sync word + sfd
// preamble + syncword + sfd + header
uint32_t preamble_us = (((preambleSymbols + 8) * 4 + sfCoeff1_x4) * tsym_us) / 4;
uint64_t preamble_us = (((uint64_t)(preambleSymbols + 8) * 4 + sfCoeff1_x4) * tsym_us) / 4;
// airtime for max packet at current radio settings
uint32_t total_us = _radio->getTimeOnAir(MAX_TRANS_UNIT);
+28 -3
View File
@@ -73,6 +73,18 @@ protected:
int8_t _cur_dbm;
bool _cur_rx_boosted_gain;
bool _params_valid, _dbm_valid, _rx_boosted_gain_valid;
mesh::RadioProfiles _profiles;
uint8_t _active_profile = 0;
uint16_t _physical_preamble = 0;
uint32_t _profile_generation = 0;
uint32_t _profile_scan_generation[2] = {};
uint32_t _profile_visit_us = 0;
uint32_t _profile_retry_at = 0;
bool _profile_rxps_suspended = false;
bool _profile_saved_rxps = false;
bool _profile_refresh_required = false;
mesh::RadioParamApplyResult tuneProfile(uint8_t profile);
void serviceProfileScan();
// On-demand noise-floor calibration while RX duty-cycle powersaving is
// armed. A duty-cycled receiver can't be sampled reliably, so a requested
@@ -169,6 +181,19 @@ public:
}
void begin() override;
mesh::RadioProfiles* profiles() override { return &_profiles; }
const mesh::RadioProfiles* profiles() const override { return &_profiles; }
bool validateProfile(const mesh::RadioProfileParams& params) const override;
uint8_t receiveProfile() const override { return _active_profile; }
uint32_t receiveProfileGeneration() const override { return _profile_generation; }
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t profile) override;
mesh::RadioParamApplyResult trySetPrimaryParams(const mesh::RadioProfileParams& params,
bool temporary, const uint32_t* timings = nullptr) override;
uint16_t profilePreamble(uint8_t profile) const override {
const auto& p = _profiles.params(profile);
return _profiles.preamble(profile, rxPowerSavingPreambleForParams(p.sf, p.bw));
}
uint32_t getProfileAirtime(uint8_t profile, int len_bytes, uint8_t cr = 0) override;
int recvRaw(uint8_t* bytes, int sz) override;
uint32_t getEstAirtimeFor(int len_bytes) override;
bool startSendRaw(const uint8_t* bytes, int len) override;
@@ -202,8 +227,8 @@ public:
virtual float getCurrentRSSI() =0;
virtual uint8_t getSpreadingFactor() const { return LORA_SF; }
static uint16_t preambleLengthForParams(uint8_t sf, float bw) {
return rxPowerSavingPreambleForParams(sf, bw);
uint16_t preambleLengthForParams(uint8_t sf, float bw) const {
return _physical_preamble ? _physical_preamble : rxPowerSavingPreambleForParams(sf, bw);
}
uint16_t currentPreambleLength() const {
return _params_valid
@@ -213,7 +238,7 @@ public:
bool updatePreamble(uint8_t sf, float bw) {
return _radio->setPreambleLength(preambleLengthForParams(sf, bw)) == RADIOLIB_ERR_NONE;
}
PacketMillis calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint8_t preambleSymbols);
PacketMillis calcMaxPacketMillis(uint8_t sf, float bw, uint8_t cr, uint16_t preambleSymbols);
virtual int16_t performChannelScan();
int getNoiseFloor() const override { return _noise_floor; }