Files
MeshCore/src/helpers/CommonCLI.cpp
T

5661 lines
243 KiB
C++

#include <Arduino.h>
#include "CommonCLI.h"
#include "PrefsSaveReplyGuard.h"
#include "UsbLoggingWatchdog.h"
#include "HilStartupTrace.h"
#include <helpers/ui/DisplayPowerSettings.h>
#include "CLICommandUtils.h"
#include "GpsPowerPolicy.h"
#include "FloodAdvertCLI.h"
#include "StorageLayout.h"
#include "radiolib/RadioPowerLimits.h"
#include "radiolib/LR2021SideDetectorConfig.h"
#include "TxtDataHelpers.h"
#include "AdvertDataHelpers.h"
#include "AlertReporter.h" // for alertReporterBannedChannelMatch()
#include "sensors/EnvironmentI2CConfig.h"
#if defined(NRF52_POWER_MANAGEMENT)
#include "NRF52VoltagePolicy.h"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include "AtomicFileWriter.h"
#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
#include "ContactFileTransaction.h"
#endif
#include <RTClib.h>
#include <ctype.h>
#include <Utils.h>
#include <math.h>
#include <new>
#include <stddef.h>
#define STR_HELPER(x) #x
#define STR(x) STR_HELPER(x)
#if defined(ENABLE_OTA)
#include "ota/OtaCli.h"
#include "ota/OtaContext.h" // persist/sync OTA policy + signer allowlist with NodePrefs
#include "ota/OtaConfigState.h"
#include "ota/OtaSpeedConfig.h"
#endif
#ifndef BRIDGE_MAX_BAUD
#define BRIDGE_MAX_BAUD 115200
#endif
#ifdef ESP_PLATFORM
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <esp_wifi.h>
#include <esp_heap_caps.h>
#else
#include <malloc.h> // mallinfo() for the `memory` command on nRF52/RP2040
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
#include <helpers/esp32/WiFiRadioPolicy.h>
#endif
#ifdef WITH_MQTT_BRIDGE
#include "bridges/MQTTBridge.h"
#include "CommonPrefsRecovery.h"
#include "FilePresence.h"
#include "MQTTDefaults.h"
#include "MQTTPrefsAtomicStore.h"
#include "MQTTPrefsCodec.h"
#include "MQTTPrefsJsonImport.h"
#include "MQTTPrefsRecovery.h"
#endif
#define RECENT_REPEATER_PREFIX_MAX_BYTES 3
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
static const uint32_t SDCARD_ACTION_COOLDOWN_MILLIS = 5UL * 60UL * 1000UL;
static void formatSdCardElapsed(char* out, size_t out_len, bool recorded,
uint32_t ran_at, uint32_t now) {
if (!recorded) {
snprintf(out, out_len, "never");
return;
}
const uint32_t elapsed_secs = (now - ran_at) / 1000UL;
if (elapsed_secs < 60UL) {
snprintf(out, out_len, "%lus ago", (unsigned long)elapsed_secs);
} else if (elapsed_secs < 60UL * 60UL) {
snprintf(out, out_len, "%lum %lus ago",
(unsigned long)(elapsed_secs / 60UL),
(unsigned long)(elapsed_secs % 60UL));
} else if (elapsed_secs < 24UL * 60UL * 60UL) {
snprintf(out, out_len, "%luh %lum ago",
(unsigned long)(elapsed_secs / 3600UL),
(unsigned long)((elapsed_secs / 60UL) % 60UL));
} else {
snprintf(out, out_len, "%lud %luh ago",
(unsigned long)(elapsed_secs / 86400UL),
(unsigned long)((elapsed_secs / 3600UL) % 24UL));
}
}
#endif
#include <helpers/radiolib/RXPowerSaving.h>
// Believe it or not, this std C function is busted on some platforms!
static uint32_t _atoi(const char* sp) {
uint32_t n = 0;
while (*sp && *sp >= '0' && *sp <= '9') {
n *= 10;
n += (*sp++ - '0');
}
return n;
}
#if defined(ENABLE_OTA)
static bool commandTokenMatches(const char* command, const char* token) {
const size_t len = strlen(token);
return strncmp(command, token, len) == 0 && (command[len] == 0 || command[len] == ' ');
}
static bool otaCommandNeedsTempRadio(const char* command) {
const char* action = command + 3;
while (*action == ' ') action++;
return commandTokenMatches(action, "neighbors")
|| commandTokenMatches(action, "nbrs")
|| commandTokenMatches(action, "updates")
|| commandTokenMatches(action, "ls")
|| commandTokenMatches(action, "n")
|| commandTokenMatches(action, "pull")
|| commandTokenMatches(action, "get")
|| commandTokenMatches(action, "download")
|| commandTokenMatches(action, "announce")
|| commandTokenMatches(action, "adv")
|| commandTokenMatches(action, "folder on")
|| commandTokenMatches(action, "folder off")
|| commandTokenMatches(action, "fold on")
|| commandTokenMatches(action, "fold off")
|| commandTokenMatches(action, "dev announce");
}
#endif
static bool isValidName(const char *n) {
while (*n) {
if (*n == '[' || *n == ']' || *n == '/' || *n == '\\' || *n == ':' || *n == ',' || *n == '?' || *n == '*') return false;
n++;
}
return true;
}
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
static bool isGpioConfig(const char* config) {
static const char expected[] = "gpio";
for (size_t i = 0; i < sizeof(expected) - 1; i++) {
if (config[i] == '\0' || tolower((unsigned char)config[i]) != expected[i]) return false;
}
return config[sizeof(expected) - 1] == '\0' || config[sizeof(expected) - 1] == ' ';
}
#endif
void CommonCLI::loop() {
_radio_profiles.loop();
loopManagement();
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
_user_gpio.loop();
UserGpio::Completion completion;
while (_user_gpio.takeCompletion(completion)) {
_callbacks->onUserGpioTimerCompleted(completion.pin, (uint8_t)completion.state,
completion.request_id);
}
#endif
}
bool CommonCLI::hasActiveUserGpioTimer() const {
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
return _user_gpio.hasActiveTimer();
#else
return false;
#endif
}
// Old fork firmware persisted the (since removed) NodePrefs MQTT fields to /com_prefs
// as a zero-filled gap between owner_info (which ends at offset 290) and a trailing
// observer block (rx_boosted_gain, flood_max_*, snmp/watchdog/alert settings).
// The gap size depended on MAX_MQTT_SLOTS at the time: 306 bytes of non-slot fields
// plus 186 bytes per slot (preset 24 + host 64 + port 2 + username 32 + password 64).
// loadPrefsInt() uses the file size to tell the eras apart and recover the tail.
static const size_t LEGACY_MQTT_GAP_6SLOT = 306 + 6 * 186; // 1422
static const size_t LEGACY_MQTT_GAP_3SLOT = 306 + 3 * 186; // 864
static const size_t LEGACY_OBS_TAIL_MAX = 124; // rx_boosted(1) + flood(2) + snmp(25) + watchdog(1) + alert block(95)
static bool looksNumeric(const char* s) {
if (s == NULL) return false;
while (*s == ' ') s++;
if (*s == '-' || *s == '+') s++;
bool saw_digit = false;
bool saw_dot = false;
while (*s) {
if (*s >= '0' && *s <= '9') {
saw_digit = true;
} else if (*s == '.' && !saw_dot) {
saw_dot = true;
} else if (*s == ' ') {
while (*s == ' ') s++;
return saw_digit && *s == 0;
} else {
break;
}
s++;
}
return saw_digit && *s == 0;
}
static bool looksUnsignedInteger(const char* s) {
if (s == NULL) return false;
while (*s == ' ') s++;
bool saw_digit = false;
while (*s) {
if (*s >= '0' && *s <= '9') {
saw_digit = true;
} else if (*s == ' ') {
while (*s == ' ') s++;
return saw_digit && *s == 0;
} else {
return false;
}
s++;
}
return saw_digit;
}
static bool configKeyMatches(const char* config, const char* key) {
if (config == NULL || key == NULL) return false;
size_t key_len = strlen(key);
return strncmp(config, key, key_len) == 0
&& (config[key_len] == 0 || config[key_len] == ' ' || config[key_len] == '.');
}
static bool configKeyEquals(const char* config, const char* key) {
return mesh::cli::matchNoArgCommand(config, key)
== mesh::cli::NoArgCommandMatch::Exact;
}
static bool isAdvancedRetryConfig(const char* config) {
return configKeyMatches(config, "direct.retry.heard")
|| configKeyMatches(config, "direct.retry.margin")
|| configKeyMatches(config, "flood.retry.prefixes")
|| configKeyMatches(config, "flood.retry.ignore")
|| configKeyMatches(config, "flood.retry.bridge")
|| configKeyMatches(config, "flood.retry.bucket")
|| configKeyMatches(config, "recent.repeater")
|| configKeyMatches(config, "recent.repeaters");
}
static bool isBasicRetryConfig(const char* config) {
return configKeyMatches(config, "retry.preset")
|| configKeyMatches(config, "direct.retry")
|| configKeyMatches(config, "flood.retry.count")
|| configKeyMatches(config, "flood.retry.path")
|| configKeyMatches(config, "flood.retry.group.path")
|| configKeyMatches(config, "flood.retry.advert");
}
static bool parseUint8Strict(const char* value, uint8_t min_value, uint8_t max_value, uint8_t& result) {
if (value == NULL || *value == 0) {
return false;
}
uint16_t parsed = 0;
const char* sp = value;
while (*sp) {
if (*sp < '0' || *sp > '9') {
return false;
}
parsed = (uint16_t)((parsed * 10) + (*sp - '0'));
if (parsed > max_value) {
return false;
}
sp++;
}
if (parsed < min_value) {
return false;
}
result = (uint8_t)parsed;
return true;
}
static bool parseOnOffStrict(const char* value, bool& enabled) {
if (strcmp(value, "on") == 0) {
enabled = true;
return true;
}
if (strcmp(value, "off") == 0) {
enabled = false;
return true;
}
return false;
}
static bool bwMatches(float bw, float allowed) {
float diff = bw - allowed;
if (diff < 0.0f) diff = -diff;
return diff <= 0.001f;
}
static bool isValidLoRaBandwidth(float bw) {
#if defined(USE_LR1110)
return bwMatches(bw, 62.5f)
|| bwMatches(bw, 125.0f)
|| bwMatches(bw, 250.0f)
|| bwMatches(bw, 500.0f);
#elif defined(USE_LLCC68) || defined(USE_SX1272)
return bwMatches(bw, 125.0f)
|| bwMatches(bw, 250.0f)
|| bwMatches(bw, 500.0f);
#else
return bwMatches(bw, 7.8f)
|| bwMatches(bw, 10.4f)
|| bwMatches(bw, 15.6f)
|| bwMatches(bw, 20.8f)
|| bwMatches(bw, 31.25f)
|| bwMatches(bw, 41.7f)
|| bwMatches(bw, 62.5f)
|| bwMatches(bw, 125.0f)
|| bwMatches(bw, 250.0f)
|| bwMatches(bw, 500.0f);
#endif
}
static float defaultLoRaBandwidth() {
#ifdef LORA_BW
if (isValidLoRaBandwidth((float)LORA_BW)) {
return (float)LORA_BW;
}
#endif
return 125.0f;
}
static const char* skipSpacesConst(const char* s) {
while (s != NULL && *s == ' ') s++;
return s;
}
static bool parseUint32Strict(const char* s, uint32_t& out) {
if (!looksUnsignedInteger(s)) {
return false;
}
uint64_t n = 0;
s = skipSpacesConst(s);
while (*s >= '0' && *s <= '9') {
n = (n * 10) + (uint32_t)(*s - '0');
if (n > 0xFFFFFFFFULL) {
return false;
}
s++;
}
out = (uint32_t)n;
return true;
}
static int countSeparatedParts(const char* s, char separator) {
if (s == NULL || *s == 0) {
return 0;
}
int count = 1;
while (*s) {
if (*s++ == separator) {
count++;
}
}
return count;
}
static bool parseScheduledRadioArgs(const char* args, bool temporary, float& freq, float& bw,
uint8_t& sf, uint8_t& cr, uint32_t& start_time,
uint32_t& end_time, uint32_t now) {
const int expected_parts = temporary ? 6 : 5;
args = skipSpacesConst(args);
if (countSeparatedParts(args, ',') != expected_parts) {
return false;
}
char local[96];
if (strlen(args) >= sizeof(local)) {
return false;
}
StrHelper::strncpy(local, args, sizeof(local));
const char* parts[6];
int num = mesh::Utils::parseTextParts(local, parts, expected_parts, ',');
if (num != expected_parts) {
return false;
}
uint32_t sf_u32 = 0;
uint32_t cr_u32 = 0;
if (!mesh::cli::parseDecimalStrict(parts[0], freq)
|| !mesh::cli::parseDecimalStrict(parts[1], bw)
|| !parseUint32Strict(parts[2], sf_u32)
|| !parseUint32Strict(parts[3], cr_u32)
|| !mesh::cli::parseRadioScheduleTime(parts[4], now, start_time)) {
return false;
}
if (sf_u32 > 255 || cr_u32 > 255) {
return false;
}
sf = (uint8_t)sf_u32;
cr = (uint8_t)cr_u32;
if (temporary && !mesh::cli::parseRadioScheduleTime(parts[5], now, end_time)) {
return false;
}
if (!temporary) {
end_time = 0;
}
return true;
}
static int16_t parseSnrDbX4(const char* s) {
float db = 0.0f;
mesh::cli::parseDecimalStrict(s, db);
return (int16_t)(db * 4.0f + (db >= 0.0f ? 0.5f : -0.5f));
}
static void formatSnrDbX4(char* dest, size_t dest_len, int16_t snr_x4) {
int16_t v = snr_x4;
const char* sign = "";
if (v < 0) {
sign = "-";
v = -v;
}
snprintf(dest, dest_len, "%s%d.%02d", sign, v / 4, (v % 4) * 25);
}
static const char* retryPresetName(uint8_t preset) {
switch (preset) {
case RETRY_PRESET_INFRA: return "infra";
case RETRY_PRESET_ROOFTOP: return "rooftop";
case RETRY_PRESET_MOBILE: return "mobile";
default: return "custom";
}
}
bool CommonCLI::calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble,
uint32_t* rx_us, uint32_t* sleep_us) {
if (level < 1 || level > 10 || preamble > 0xFF
|| !isRxPowerSavingPreamble((uint8_t)preamble)) {
return false;
}
return calcRxPowerSavingLevelAtOrAbove(
(uint8_t)level, sf, bw, (uint8_t)preamble,
rxPowerSavingDefaultTransitionUs(), rx_us, sleep_us);
}
static void appendRxPowerSavingAdjustmentNote(char* reply, const NodePrefs* prefs,
uint8_t sf, float bw) {
if (!reply || !prefs || !prefs->rx_powersaving_enabled
|| prefs->rx_ps_level < 1 || prefs->rx_ps_level > 10) {
return;
}
uint32_t rx_us = 0;
uint32_t sleep_us = 0;
uint8_t effective_level = 0;
uint8_t effective_preamble = 0;
if (!recalcRxPowerSavingFromLevel(
prefs->rx_ps_level, sf, bw, prefs->rx_ps_preamble,
&rx_us, &sleep_us, &effective_level, &effective_preamble)) {
return;
}
char note[96] = {};
if (rxPowerSavingUsesContinuousFallback(rx_us, sleep_us)) {
snprintf(note, sizeof(note),
"; RXPS continuous-fast (requested level %u)",
(unsigned)prefs->rx_ps_level);
} else {
const uint8_t requested_preamble = prefs->rx_ps_preamble == 0
? rxPowerSavingPreambleForParams(sf, bw) : prefs->rx_ps_preamble;
if (effective_level == prefs->rx_ps_level
&& effective_preamble == requested_preamble) {
return;
}
snprintf(note, sizeof(note),
"; RXPS effective level %u, preamble %u (saved minimum %u)",
(unsigned)effective_level, (unsigned)effective_preamble,
(unsigned)prefs->rx_ps_level);
}
const size_t used = strlen(reply);
if (used < 159) {
snprintf(reply + used, 160 - used, "%s", note);
}
}
// Recomputes rx_ps_rx_us/rx_ps_sleep_us from the stored level and the current
// radio SF/BW. No-op (returns false) for manual timings (rx_ps_level == 0).
// Lets level-based RX powersaving auto-retune when SF/BW change.
bool CommonCLI::recalculateRxPowerSavingFromLevel(NodePrefs* prefs) {
return recalcRxPowerSavingFromLevel(prefs->rx_ps_level, prefs->sf, prefs->bw,
prefs->rx_ps_preamble, &prefs->rx_ps_rx_us,
&prefs->rx_ps_sleep_us);
}
static void markDirectRetryPrefsValid(NodePrefs* prefs) {
prefs->direct_retry_prefs_magic[0] = DIRECT_RETRY_PREFS_MAGIC_0;
prefs->direct_retry_prefs_magic[1] = DIRECT_RETRY_PREFS_MAGIC_1;
}
static void applyFloodRetryPreset(NodePrefs* prefs, uint8_t preset) {
if (preset == RETRY_PRESET_INFRA) {
prefs->flood_retry_attempts = FLOOD_RETRY_INFRA_COUNT;
prefs->flood_retry_max_path = FLOOD_RETRY_INFRA_MAX_PATH;
} else if (preset == RETRY_PRESET_MOBILE) {
prefs->flood_retry_attempts = FLOOD_RETRY_MOBILE_COUNT;
prefs->flood_retry_max_path = FLOOD_RETRY_MOBILE_MAX_PATH;
} else {
prefs->flood_retry_attempts = FLOOD_RETRY_ROOFTOP_COUNT;
prefs->flood_retry_max_path = FLOOD_RETRY_ROOFTOP_MAX_PATH;
}
prefs->flood_retry_group_max_path = prefs->flood_retry_max_path == 0
? FLOOD_RETRY_PATH_GATE_DISABLED
: FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT;
}
static bool parseFloodRetryPathGate(const char* value, uint8_t& path_gate) {
if (value == NULL) {
return false;
}
if (strcmp(value, "off") == 0 || strcmp(value, "disabled") == 0 || strcmp(value, "disable") == 0) {
path_gate = FLOOD_RETRY_PATH_GATE_DISABLED;
return true;
}
return parseUint8Strict(value, 0, 63, path_gate);
}
static void formatFloodRetryPathGate(char* dest, uint8_t path_gate) {
if (path_gate == FLOOD_RETRY_PATH_GATE_DISABLED) {
strcpy(dest, "off");
} else {
sprintf(dest, "%u", (unsigned int)path_gate);
}
}
static bool parseFloodChannelHops(const char* value, uint8_t& max_hops) {
if (value == NULL) {
return false;
}
value = skipSpacesConst(value);
if (strcmp(value, "all") == 0) {
max_hops = FLOOD_CHANNEL_HOPS_ALL;
return true;
}
return parseUint8Strict(value, 1, 7, max_hops);
}
static void formatFloodChannelHops(char* dest, uint8_t max_hops) {
if (max_hops == FLOOD_CHANNEL_HOPS_ALL) {
strcpy(dest, "h=all");
} else {
sprintf(dest, "h>%u", (unsigned int)max_hops);
}
}
static void formatFloodRetryPrefixList(char* dest, const uint8_t prefixes[][FLOOD_RETRY_PREFIX_LEN],
uint8_t max_prefixes) {
char* out = dest;
bool first = true;
for (int i = 0; i < max_prefixes; i++) {
const uint8_t* prefix = prefixes[i];
if (prefix[0] == 0 && prefix[1] == 0 && prefix[2] == 0) {
continue;
}
if (!first) {
*out++ = ',';
}
mesh::Utils::toHex(out, prefix, FLOOD_RETRY_PREFIX_LEN);
out += FLOOD_RETRY_PREFIX_LEN * 2;
first = false;
}
*out = 0;
}
static bool parseFloodRetryPrefixList(uint8_t dest[][FLOOD_RETRY_PREFIX_LEN], uint8_t max_prefixes, const char* value) {
if (max_prefixes > FLOOD_RETRY_LIST_PREFIXES) {
return false;
}
uint8_t parsed[FLOOD_RETRY_LIST_PREFIXES][FLOOD_RETRY_PREFIX_LEN];
memset(parsed, 0, sizeof(parsed));
if (value == NULL || value[0] == 0 || strcmp(value, "none") == 0 || strcmp(value, "off") == 0) {
memcpy(dest, parsed, max_prefixes * FLOOD_RETRY_PREFIX_LEN);
return true;
}
char local[FLOOD_RETRY_LIST_TEXT_MAX];
StrHelper::strncpy(local, value, sizeof(local));
const char* parts[FLOOD_RETRY_LIST_PREFIXES + 1];
int num = mesh::Utils::parseTextParts(local, parts, FLOOD_RETRY_LIST_PREFIXES + 1);
if (num > max_prefixes) {
return false;
}
for (int i = 0; i < num; i++) {
if (strlen(parts[i]) != FLOOD_RETRY_PREFIX_LEN * 2) {
return false;
}
for (int j = 0; j < FLOOD_RETRY_PREFIX_LEN * 2; j++) {
if (!mesh::Utils::isHexChar(parts[i][j])) {
return false;
}
}
if (!mesh::Utils::fromHex(parsed[i], FLOOD_RETRY_PREFIX_LEN, parts[i])
|| (parsed[i][0] == 0 && parsed[i][1] == 0 && parsed[i][2] == 0)) {
return false;
}
}
memcpy(dest, parsed, max_prefixes * FLOOD_RETRY_PREFIX_LEN);
return true;
}
static const uint8_t* floodRetryBucketPrefixAt(const NodePrefs* prefs, uint8_t bucket, uint8_t index) {
if (bucket < FLOOD_RETRY_BRIDGE_BUCKETS && index < FLOOD_RETRY_BUCKET_PREFIXES) {
return prefs->flood_retry_bridge_buckets[bucket][index];
}
if (bucket == FLOOD_RETRY_BRIDGE_BUCKETS && index < FLOOD_RETRY_PREFIX_SLOTS) {
return prefs->flood_retry_prefixes[index];
}
return NULL;
}
static uint8_t floodRetryBucketPrefixCount(uint8_t bucket) {
return bucket < FLOOD_RETRY_BRIDGE_BUCKETS ? FLOOD_RETRY_BUCKET_PREFIXES : FLOOD_RETRY_PREFIX_SLOTS;
}
static bool findFloodRetryFirstByteCollision(const NodePrefs* prefs, uint8_t& first_byte,
uint8_t& first_bucket, uint8_t& second_bucket) {
for (uint8_t bucket_a = 0; bucket_a <= FLOOD_RETRY_BRIDGE_BUCKETS; bucket_a++) {
for (uint8_t bucket_b = bucket_a + 1; bucket_b <= FLOOD_RETRY_BRIDGE_BUCKETS; bucket_b++) {
for (uint8_t a = 0; a < floodRetryBucketPrefixCount(bucket_a); a++) {
const uint8_t* prefix_a = floodRetryBucketPrefixAt(prefs, bucket_a, a);
if (prefix_a == NULL || (prefix_a[0] == 0 && prefix_a[1] == 0 && prefix_a[2] == 0)) {
continue;
}
for (uint8_t b = 0; b < floodRetryBucketPrefixCount(bucket_b); b++) {
const uint8_t* prefix_b = floodRetryBucketPrefixAt(prefs, bucket_b, b);
if (prefix_b != NULL && (prefix_b[0] != 0 || prefix_b[1] != 0 || prefix_b[2] != 0)
&& prefix_a[0] == prefix_b[0]) {
first_byte = prefix_a[0];
first_bucket = bucket_a;
second_bucket = bucket_b;
return true;
}
}
}
}
}
return false;
}
static bool formatFloodRetryBucketCollisionWarning(char* reply, const NodePrefs* prefs, const char* prefix) {
uint8_t first_byte, first_bucket, second_bucket;
if (!findFloodRetryFirstByteCollision(prefs, first_byte, first_bucket, second_bucket)) {
return false;
}
if (second_bucket == FLOOD_RETRY_BRIDGE_BUCKETS) {
snprintf(reply, 160, "%sWARNING: 1-byte %02X matches buckets %u and 7 (other)", prefix,
(unsigned int)first_byte, (unsigned int)first_bucket + 1U);
} else {
snprintf(reply, 160, "%sWARNING: 1-byte %02X matches buckets %u and %u", prefix,
(unsigned int)first_byte, (unsigned int)first_bucket + 1U,
(unsigned int)second_bucket + 1U);
}
return true;
}
static void applyDirectRetryPreset(NodePrefs* prefs, uint8_t preset) {
prefs->retry_preset = preset;
if (preset == RETRY_PRESET_INFRA) {
prefs->direct_retry_attempts = DIRECT_RETRY_INFRA_COUNT;
prefs->direct_retry_base_ms = DIRECT_RETRY_INFRA_BASE_MS;
prefs->direct_retry_step_ms = DIRECT_RETRY_INFRA_STEP_MS;
prefs->direct_retry_snr_margin_x4 = DIRECT_RETRY_INFRA_MARGIN_X4;
} else if (preset == RETRY_PRESET_MOBILE) {
prefs->direct_retry_attempts = DIRECT_RETRY_MOBILE_COUNT;
prefs->direct_retry_base_ms = DIRECT_RETRY_MOBILE_BASE_MS;
prefs->direct_retry_step_ms = DIRECT_RETRY_MOBILE_STEP_MS;
prefs->direct_retry_snr_margin_x4 = DIRECT_RETRY_MOBILE_MARGIN_X4;
} else {
prefs->retry_preset = RETRY_PRESET_ROOFTOP;
prefs->direct_retry_attempts = DIRECT_RETRY_ROOFTOP_COUNT;
prefs->direct_retry_base_ms = DIRECT_RETRY_ROOFTOP_BASE_MS;
prefs->direct_retry_step_ms = DIRECT_RETRY_ROOFTOP_STEP_MS;
prefs->direct_retry_snr_margin_x4 = DIRECT_RETRY_ROOFTOP_MARGIN_X4;
}
applyFloodRetryPreset(prefs, prefs->retry_preset);
markDirectRetryPrefsValid(prefs);
}
static void setDefaultDirectRetryPrefs(NodePrefs* prefs) {
applyDirectRetryPreset(prefs, RETRY_PRESET_ROOFTOP);
prefs->direct_retry_cr_enabled = 1;
prefs->direct_retry_cr4_snr_x4 = DIRECT_RETRY_CR4_MIN_SNR_X4_DEFAULT;
prefs->direct_retry_cr5_snr_x4 = DIRECT_RETRY_CR5_MIN_SNR_X4_DEFAULT;
prefs->direct_retry_cr7_snr_x4 = DIRECT_RETRY_CR7_MIN_SNR_X4_DEFAULT;
prefs->direct_retry_cr8_snr_x4 = DIRECT_RETRY_CR8_MAX_SNR_X4_DEFAULT;
prefs->direct_retry_enabled = 1;
prefs->direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT;
markDirectRetryPrefsValid(prefs);
}
static bool directRetryPrefsValid(const NodePrefs* prefs) {
return prefs->direct_retry_prefs_magic[0] == DIRECT_RETRY_PREFS_MAGIC_0
&& prefs->direct_retry_prefs_magic[1] == DIRECT_RETRY_PREFS_MAGIC_1;
}
static bool parseRetryPreset(const char* s, uint8_t& preset) {
if (strcmp(s, "infra") == 0 || strcmp(s, "0") == 0) {
preset = RETRY_PRESET_INFRA;
return true;
}
if (strcmp(s, "rooftop") == 0 || strcmp(s, "1") == 0) {
preset = RETRY_PRESET_ROOFTOP;
return true;
}
if (strcmp(s, "mobile") == 0 || strcmp(s, "2") == 0) {
preset = RETRY_PRESET_MOBILE;
return true;
}
return false;
}
static bool parseHashPrefix(const char* text, uint8_t* prefix, uint8_t& prefix_len) {
size_t hex_len = strlen(text);
if (hex_len == 0 || (hex_len & 1) || hex_len > RECENT_REPEATER_PREFIX_MAX_BYTES * 2) {
return false;
}
for (size_t i = 0; i < hex_len; i++) {
if (!mesh::Utils::isHexChar(text[i])) {
return false;
}
}
prefix_len = hex_len / 2;
return mesh::Utils::fromHex(prefix, prefix_len, text);
}
static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) {
formatSnrDbX4(dest, dest_len, snr_x4);
size_t len = strlen(dest);
if (len > 3 && dest[len - 1] == '0') {
dest[len - 1] = 0;
}
}
void CommonCLI::loadPrefs(FILESYSTEM* fs) {
mesh::hilStartupTrace("prefs_profiles_begin");
_radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true);
mesh::hilStartupTrace("prefs_profiles_ready");
_prefs->primary_radio_preamble = _radio_profiles.primaryPreamble();
#if !defined(WITH_MQTT_BRIDGE) && (defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM))
mesh::hilStartupTrace("prefs_recovery_begin");
mesh::ContactFileTransaction::recover(fs, "/com_prefs");
mesh::hilStartupTrace("prefs_recovery_ready");
#endif
#if defined(ENABLE_OTA)
mesh::hilStartupTrace("prefs_ota_speed_begin");
mesh::ota::beginSpeedConfig(fs);
mesh::hilStartupTrace("prefs_ota_speed_ready");
#endif
mesh::hilStartupTrace("prefs_display_begin");
const bool display_settings_loaded = mesh::ui::loadDisplayPowerSettings(fs, false);
mesh::hilStartupTrace("prefs_display_ready");
(void)display_settings_loaded;
bool is_fresh_install = false;
bool is_upgrade = false;
bool loaded = false;
// The hardware main-loop watchdog is opt-out. Older preference files do not
// contain its appended byte, so they safely inherit the enabled default.
_prefs->system_watchdog_enabled = 1;
memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf));
_prefs->usb_logging_enabled = 1;
_prefs->usb_debug_enabled = 0;
_prefs->trace_when_repeat_off = 0;
_prefs->ota_channel = 0;
#ifdef WITH_RS232_BRIDGE
_prefs->bridge_uart = WITH_RS232_BRIDGE_UART;
#else
_prefs->bridge_uart = 0;
#endif
_prefs->bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
_prefs->gps_sync_interval_hours = 0;
#ifdef WITH_MQTT_BRIDGE
bool node_prefs_needs_migration = false;
mesh::hilStartupTrace("prefs_recovery_begin");
if (!recoverCommonPrefsFiles(fs)) {
MESH_DEBUG_PRINTLN("Prefs: common preference recovery is incomplete");
}
mesh::hilStartupTrace("prefs_recovery_ready");
#endif
mesh::hilStartupTrace("prefs_common_begin");
if (fs->exists("/com_prefs")) {
loadPrefsInt(fs, "/com_prefs"); loaded = true; // new filename
} else if (fs->exists("/node_prefs")) {
loadPrefsInt(fs, "/node_prefs");
loaded = true;
is_upgrade = true; // Migrating from old filename
#ifdef WITH_MQTT_BRIDGE
// Wait for loadMQTTPrefs() to persist any observer tail captured from this
// old file before replacing or removing its only on-flash copy.
node_prefs_needs_migration = true;
#else
savePrefs(fs);
if (fs->exists("/com_prefs")) {
fs->remove("/node_prefs");
} else {
MESH_DEBUG_PRINTLN("Prefs: preserving legacy /node_prefs because /com_prefs was not created");
}
_com_prefs_needs_upgrade = false;
#endif
} else if (fs->exists("/prefs.json")) {
// Stock 1.17.1 stored NodePrefs as JSON. A partition migration preserves
// that file byte-for-byte; import it before writing the current binary
// image so node name, passwords and primary radio survive the Full handoff.
#if defined(NRF52_PLATFORM)
File legacy(*fs);
legacy.open("/prefs.json", FILE_O_READ);
#elif defined(STM32_PLATFORM)
File legacy = fs->open("/prefs.json", FILE_O_READ);
#else
File legacy = fs->open("/prefs.json", "r");
#endif
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \
&& !defined(RS232_BRIDGE_DEFAULT_ON)
const uint8_t previous_bridge_uart = _prefs->bridge_uart;
const uint8_t previous_bridge_enabled = _prefs->bridge_enabled;
// JSON profiles can contain the unused enabled flag without any UART.
// Require an explicit nonzero UART before treating it as bridge intent.
_prefs->bridge_uart = 0;
#endif
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)
const uint8_t previous_espnow_enabled = _prefs->espnow_bridge_enabled;
#if !defined(WITH_MQTT_BRIDGE) && !defined(WITH_RS232_BRIDGE)
const uint8_t previous_bridge_enabled = _prefs->bridge_enabled;
#endif
#endif
if (legacy && _prefs->loadSerial(legacy)) {
loaded = true;
is_upgrade = true;
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \
&& !defined(RS232_BRIDGE_DEFAULT_ON)
if (_prefs->bridge_uart == 0) _prefs->bridge_enabled = 0;
if (_prefs->bridge_uart != WITH_RS232_BRIDGE_UART
#ifdef WITH_RS232_BRIDGE_ALT
&& _prefs->bridge_uart != WITH_RS232_BRIDGE_ALT_UART
#endif
) {
_prefs->bridge_uart = WITH_RS232_BRIDGE_UART;
}
#endif
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)
// The legacy JSON schema has no independent ESP-NOW intent marker.
_prefs->espnow_bridge_enabled = 0;
#if !defined(WITH_MQTT_BRIDGE) && !defined(WITH_RS232_BRIDGE)
_prefs->bridge_enabled = 0;
#endif
#elif defined(WITH_ESPNOW_BRIDGE) && defined(ESPNOW_BRIDGE_MERGED) \
&& !defined(WITH_MQTT_BRIDGE) && !defined(WITH_RS232_BRIDGE)
// An explicitly default-on compatibility profile imports its legacy
// single-bridge intent into the new independent ESP-NOW setting.
_prefs->espnow_bridge_enabled = _prefs->bridge_enabled;
#endif
#ifdef WITH_MQTT_BRIDGE
node_prefs_needs_migration = true;
#else
savePrefs(fs);
#endif
} else {
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \
&& !defined(RS232_BRIDGE_DEFAULT_ON)
_prefs->bridge_uart = previous_bridge_uart;
_prefs->bridge_enabled = previous_bridge_enabled;
#endif
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)
_prefs->espnow_bridge_enabled = previous_espnow_enabled;
#if !defined(WITH_MQTT_BRIDGE) && !defined(WITH_RS232_BRIDGE)
_prefs->bridge_enabled = previous_bridge_enabled;
#endif
#endif
}
if (legacy) legacy.close();
} else {
// File doesn't exist - set defaults for a fresh install. Dual R1/R2
// scanning keeps the node awake, so only that configuration starts with
// device power saving off. A saved preference is never rewritten here.
is_fresh_install = true;
mesh::Radio* profile_radio = _callbacks->getProfileRadio();
const bool dual_radio_active = profile_radio != NULL
&& profile_radio->profiles() != NULL
&& profile_radio->profiles()->enabled();
_prefs->powersaving_enabled = DEFAULT_POWERSAVING_ENABLED && !dual_radio_active ? 1 : 0;
_prefs->bridge_pkt_src = 1; // Default to RX (logRx) for new installs
}
mesh::hilStartupTrace("prefs_common_ready");
#ifdef WITH_MQTT_BRIDGE
// Load observer preferences (MQTT/WiFi/timezone/SNMP/alert) from /mqtt_prefs.
// Readers (MQTTBridge, AlertReporter, observer CLI) use _mqtt_prefs directly -
// these fields no longer exist in NodePrefs, so there is nothing to sync.
MQTTPrefsAtomicStore::LegacyUpgradeGate legacy_upgrade(
_com_prefs_needs_upgrade || node_prefs_needs_migration);
mesh::hilStartupTrace("prefs_mqtt_begin");
loadMQTTPrefs(fs, &legacy_upgrade);
mesh::hilStartupTrace("prefs_mqtt_ready");
if (!display_settings_loaded && _mqtt_prefs.display_timeout_secs != DISPLAY_TIMEOUT_DEFAULT_SECS)
mesh::ui::migrateLegacyDisplayTimeout(_mqtt_prefs.display_timeout_secs);
if (_mqtt_prefs_hold) legacy_upgrade.holdMqttSource();
// For MQTT bridge, migrate bridge.source to RX (logRx) only on fresh installs or upgrades
// so legacy "tx" is not the default. mqtt.rx / mqtt.tx are separate (fresh default: advert for TX)
if ((is_fresh_install || is_upgrade) && _prefs->bridge_pkt_src == 0) {
if (legacy_upgrade.blocksComPrefsRewrite()) {
MESH_DEBUG_PRINTLN("MQTT Bridge: deferring bridge.source migration until legacy prefs are preserved");
} else {
MESH_DEBUG_PRINTLN("MQTT Bridge: Migrating bridge.source from tx to rx (MQTT bridge default)");
_prefs->bridge_pkt_src = 1; // Set to RX (logRx)
if (node_prefs_needs_migration) {
// The atomic /node_prefs -> /com_prefs handoff below persists this
// in-memory change. Do not publish /com_prefs before that transaction.
MESH_DEBUG_PRINTLN("MQTT Bridge: bridge.source will be saved with node prefs migration");
} else {
savePrefs(fs); // Save the updated preference
}
}
}
// mqtt_rx_enabled: new field appended to end of MQTTPrefs. On upgrade from older firmware,
// the shorter /mqtt_prefs file won't contain it, so it keeps the default value (1 = on)
// set by setMQTTPrefsDefaults(). No explicit migration needed.
#endif
mesh::hilStartupTrace("prefs_migration_begin");
#ifdef WITH_MQTT_BRIDGE
if (node_prefs_needs_migration) {
if (legacy_upgrade.mayRewriteComPrefs()) {
// The MQTT image (and any tail from /node_prefs) is committed, so it is
// now safe to publish the replacement name. Keep /node_prefs until the
// complete /com_prefs image is closed and atomically renamed into place.
if (saveCommonPrefsImageAtomically(fs)) {
fs->remove("/node_prefs");
legacy_upgrade.recordComPrefsRewrite();
_com_prefs_needs_upgrade = false;
} else {
MESH_DEBUG_PRINTLN("MQTT: preserving legacy /node_prefs until /com_prefs migration commits");
}
} else {
MESH_DEBUG_PRINTLN("MQTT: preserving legacy /node_prefs until /mqtt_prefs migration commits");
}
} else if (_com_prefs_needs_upgrade) {
// Old-format /com_prefs (legacy MQTT gap + trailing observer block) was detected:
// rewrite the prefs files in the current layout, one time. This persists the
// recovered rx_boosted_gain/flood_max_* values and (on MQTT builds) the observer
// settings that loadMQTTPrefs carried over into /mqtt_prefs.
if (legacy_upgrade.mayRewriteComPrefs()) {
// loadMQTTPrefs has already committed the full MQTT payload (including
// any recovered observer tail), so compact only /com_prefs now.
savePrefs(fs, PrefsSaveRouting::Scope::Common);
legacy_upgrade.recordComPrefsRewrite();
_com_prefs_needs_upgrade = false;
} else {
MESH_DEBUG_PRINTLN("MQTT: preserving legacy /com_prefs until /mqtt_prefs migration commits");
}
}
#else
if (_com_prefs_needs_upgrade) {
savePrefs(fs);
_com_prefs_needs_upgrade = false;
}
#endif
mesh::hilStartupTrace("prefs_migration_ready");
#if defined(ENABLE_OTA)
if (loaded) syncOtaConfigFromPrefs(); // persisted OTA policy/keys -> OtaContext (else keep safe defaults)
#endif
mesh::hilStartupTrace("prefs_apply_begin");
#if MESH_USB_LOGGING_AVAILABLE
mesh::setUsbDebugEnabled(_prefs->usb_debug_enabled != 0);
mesh::setUsbLoggingEnabled(_prefs->usb_logging_enabled != 0);
#endif
_radio_profiles.adoptPrimaryPreamble(_prefs->primary_radio_preamble);
_radio_profiles.stagePrimary(_prefs->primary_radio_preamble, false);
mesh::hilStartupTrace("prefs_apply_ready");
}
#if defined(ENABLE_OTA)
// Register the existing preferences as the source of OTA policy. Registration
// does not need a workspace; a later allocation retry must still restore keys.
void CommonCLI::syncOtaConfigFromPrefs() {
// Mesh owns one process-wide OTA context. Its role's NodePrefs outlives that
// context and already holds the durable policy, so retain only its pointer.
static const NodePrefs* ota_prefs = nullptr;
ota_prefs = _prefs;
mesh::ota::ota_set_context_config_loader([](mesh::ota::OtaConfigState& state) {
state.autofetch = ota_prefs->ota_autofetch;
state.checkpoint = ota_prefs->ota_checkpoint_blocks;
state.advert = ota_prefs->ota_advert_interval;
state.hops = ota_prefs->ota_max_hops;
state.autoinstall = ota_prefs->ota_autoinstall;
state.allow.clear();
for (uint8_t i = 0; i < ota_prefs->ota_signer_count && i < MAX_OTA_SIGNERS; i++)
state.allow.add(ota_prefs->ota_signers[i]);
return true;
});
}
#endif
void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
mesh::hilStartupTrace("prefs_image_open_begin");
// Older or truncated preference images must never inherit these opt-ins
// from a previous load of a newer image.
_prefs->usb_debug_enabled = 0;
_prefs->trace_when_repeat_off = 0;
_prefs->gps_sync_interval_hours = 0;
_prefs->ota_channel = 0;
#if defined(RP2040_PLATFORM)
File file = fs->open(filename, "r");
#else
File file = fs->open(filename);
#endif
mesh::hilStartupTrace("prefs_image_open_ready");
if (file) {
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)
bool has_runtime_espnow_intent = false;
#endif
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \
&& !defined(RS232_BRIDGE_DEFAULT_ON)
bool has_runtime_bridge_uart = false;
#endif
// Every supported layout contains the fixed 290-byte common core. Reject
// a truncated in-place write before it can leave strings unterminated or
// feed partial radio values into startup. loadPrefs() rewrites the image.
if (file.size() < 290) {
MESH_DEBUG_PRINTLN("Prefs: %s is truncated (%u bytes); using defaults",
filename, (unsigned)file.size());
file.close();
_com_prefs_needs_upgrade = true;
return;
}
uint8_t pad[8];
mesh::hilStartupTrace("prefs_image_core_begin");
file.read((uint8_t *)&_prefs->airtime_factor, sizeof(_prefs->airtime_factor)); // 0
file.read((uint8_t *)&_prefs->node_name, sizeof(_prefs->node_name)); // 4
file.read(pad, 4); // 36
file.read((uint8_t *)&_prefs->node_lat, sizeof(_prefs->node_lat)); // 40
file.read((uint8_t *)&_prefs->node_lon, sizeof(_prefs->node_lon)); // 48
file.read((uint8_t *)&_prefs->password[0], sizeof(_prefs->password)); // 56
file.read((uint8_t *)&_prefs->freq, sizeof(_prefs->freq)); // 72
file.read((uint8_t *)&_prefs->tx_power_dbm, sizeof(_prefs->tx_power_dbm)); // 76
file.read((uint8_t *)&_prefs->disable_fwd, sizeof(_prefs->disable_fwd)); // 77
file.read((uint8_t *)&_prefs->advert_interval, sizeof(_prefs->advert_interval)); // 78
file.read(pad, 1); // 79 : 1 byte unused (was rx_boosted_gain in v1.14.1, moved to end for upgrade compat)
file.read((uint8_t *)&_prefs->rx_delay_base, sizeof(_prefs->rx_delay_base)); // 80
file.read((uint8_t *)&_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor)); // 84
file.read((uint8_t *)&_prefs->guest_password[0], sizeof(_prefs->guest_password)); // 88
file.read((uint8_t *)&_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor)); // 104
file.read(pad, 4); // 108
file.read((uint8_t *)&_prefs->sf, sizeof(_prefs->sf)); // 112
file.read((uint8_t *)&_prefs->cr, sizeof(_prefs->cr)); // 113
file.read((uint8_t *)&_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114
file.read((uint8_t *)&_prefs->multi_acks, sizeof(_prefs->multi_acks)); // 115
file.read((uint8_t *)&_prefs->bw, sizeof(_prefs->bw)); // 116
file.read((uint8_t *)&_prefs->agc_reset_interval, sizeof(_prefs->agc_reset_interval)); // 120
file.read((uint8_t *)&_prefs->path_hash_mode, sizeof(_prefs->path_hash_mode)); // 121
file.read((uint8_t *)&_prefs->loop_detect, sizeof(_prefs->loop_detect)); // 122
file.read(pad, 1); // 123
file.read((uint8_t *)&_prefs->flood_max, sizeof(_prefs->flood_max)); // 124
file.read((uint8_t *)&_prefs->flood_advert_interval, sizeof(_prefs->flood_advert_interval)); // 125
file.read((uint8_t *)&_prefs->interference_threshold, sizeof(_prefs->interference_threshold)); // 126
file.read((uint8_t *)&_prefs->bridge_enabled, sizeof(_prefs->bridge_enabled)); // 127
file.read((uint8_t *)&_prefs->bridge_delay, sizeof(_prefs->bridge_delay)); // 128
file.read((uint8_t *)&_prefs->bridge_pkt_src, sizeof(_prefs->bridge_pkt_src)); // 130
file.read((uint8_t *)&_prefs->bridge_baud, sizeof(_prefs->bridge_baud)); // 131
file.read((uint8_t *)&_prefs->bridge_channel, sizeof(_prefs->bridge_channel)); // 135
file.read((uint8_t *)&_prefs->bridge_secret, sizeof(_prefs->bridge_secret)); // 136
file.read((uint8_t *)&_prefs->powersaving_enabled, sizeof(_prefs->powersaving_enabled)); // 152
file.read((uint8_t *)&_prefs->reboot_interval, sizeof(_prefs->reboot_interval)); // 153
file.read(pad, 2); // 154
file.read((uint8_t *)&_prefs->gps_enabled, sizeof(_prefs->gps_enabled)); // 156
file.read((uint8_t *)&_prefs->gps_interval, sizeof(_prefs->gps_interval)); // 157
file.read((uint8_t *)&_prefs->advert_loc_policy, sizeof (_prefs->advert_loc_policy)); // 161
file.read((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162
file.read((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166
file.read((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170
mesh::hilStartupTrace("prefs_image_core_ready");
_prefs->node_name[sizeof(_prefs->node_name) - 1] = '\0';
_prefs->password[sizeof(_prefs->password) - 1] = '\0';
_prefs->guest_password[sizeof(_prefs->guest_password) - 1] = '\0';
_prefs->bridge_secret[sizeof(_prefs->bridge_secret) - 1] = '\0';
_prefs->owner_info[sizeof(_prefs->owner_info) - 1] = '\0';
// MQTT/observer settings are no longer stored in /com_prefs - they live in
// /mqtt_prefs (loaded by loadMQTTPrefs). Old fork firmware wrote a zero-filled
// 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.
// Build-profile defaults - overwritten below when the saved field is present.
_prefs->radio_fem_rxgain = 1;
_prefs->radio_fem_txgain = 0;
_prefs->cad_enabled = DEFAULT_CAD_ENABLED;
memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf));
_prefs->rx_powersaving_enabled = 0;
_prefs->rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US;
_prefs->rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
_prefs->rx_ps_level = 0;
_prefs->rx_ps_preamble = 0;
#if defined(ENABLE_OTA)
// OTA settings were appended after Keymind's retry/flood tail. Initialize them
// before reading so older and legacy preference files remain conservative.
_prefs->ota_autofetch = 0;
_prefs->ota_autoinstall = 0;
_prefs->ota_signer_count = 0;
memset(_prefs->ota_signers, 0, sizeof(_prefs->ota_signers));
_prefs->ota_checkpoint_blocks = 4;
_prefs->ota_advert_interval = 0;
_prefs->ota_max_hops = 3;
#endif
_prefs->telemetry_access = TELEMETRY_ACCESS_ALL;
_prefs->flood_retry_group_max_path = FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT;
_prefs->rx_watchdog_enabled = mesh::RepeaterRadioTiming::DEFAULT_RX_WATCHDOG_ENABLED;
_prefs->system_watchdog_enabled = 1;
// A remainder larger than the smallest legacy MQTT gap (864) means an old fork
// file with the zero-filled gap; detect and recover it below. Anything smaller
// (upstream/flex 5-byte tails, or the ~384-byte keymind retry tail) takes the
// normal path: guarded 290-294 reads, then the keymind retry tail whose absence
// in shorter files is detected via direct_retry_prefs_magic.
size_t extra = file.available();
bool has_flood_retry_prefs = false; // set when the keymind flood-retry tail is read below
if (extra > LEGACY_MQTT_GAP_3SLOT) {
_com_prefs_needs_upgrade = true;
size_t gap = 0;
if (extra > LEGACY_MQTT_GAP_6SLOT && extra <= LEGACY_MQTT_GAP_6SLOT + LEGACY_OBS_TAIL_MAX) {
gap = LEGACY_MQTT_GAP_6SLOT;
} else if (extra > LEGACY_MQTT_GAP_3SLOT && extra <= LEGACY_MQTT_GAP_3SLOT + LEGACY_OBS_TAIL_MAX) {
gap = LEGACY_MQTT_GAP_3SLOT;
}
// Unrecognized legacy sizes (e.g. pre-slot-era files) leave gap == 0: the tail
// is not read and everything past owner_info degrades to defaults.
if (gap > 0) {
uint8_t skip_buf[64];
size_t remaining = gap;
while (remaining > 0) {
size_t n = remaining > sizeof(skip_buf) ? sizeof(skip_buf) : remaining;
file.read(skip_buf, n);
remaining -= n;
}
file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain));
// Tail layout: flood_max_unscoped, flood_max_advert, then the snmp fields -
// except legacy flex-branch files where snmp starts right after
// rx_boosted_gain (no flood_max_*). Same heuristic the old firmware used:
// snmp_enabled is 0/1 and the first community char is printable (> 64).
uint8_t b1 = 0, b2 = 0;
bool have_flood_bytes = file.available() >= 2;
if (have_flood_bytes) {
file.read(&b1, 1);
file.read(&b2, 1);
}
#ifdef WITH_MQTT_BRIDGE
// Pre-fill with the same defaults applyMQTTDefaults() uses, so fields a
// shorter (older) tail doesn't contain degrade to defaults when applied.
memset(&_legacy_tail, 0, sizeof(_legacy_tail));
strncpy(_legacy_tail.snmp_community, "public", sizeof(_legacy_tail.snmp_community) - 1);
_legacy_tail.radio_watchdog_minutes = 5;
_legacy_tail.alert_wifi_minutes = 30;
_legacy_tail.alert_mqtt_minutes = 240;
_legacy_tail.alert_min_interval_min = 60;
#endif
if (have_flood_bytes && b1 <= 1 && b2 > 64) {
// Legacy variant: no flood_max_* - b1/b2 are snmp_enabled + community[0]
#ifdef WITH_MQTT_BRIDGE
_legacy_tail.snmp_enabled = b1;
_legacy_tail.snmp_community[0] = (char) b2;
if (file.available() >= (int)(sizeof(_legacy_tail.snmp_community) - 1)) {
file.read((uint8_t *)&_legacy_tail.snmp_community[1], sizeof(_legacy_tail.snmp_community) - 1);
}
#endif
} else if (have_flood_bytes) {
_prefs->flood_max_unscoped = b1;
_prefs->flood_max_advert = b2;
#ifdef WITH_MQTT_BRIDGE
if (file.available() >= (int)sizeof(_legacy_tail.snmp_enabled)) {
file.read((uint8_t *)&_legacy_tail.snmp_enabled, sizeof(_legacy_tail.snmp_enabled));
}
if (file.available() >= (int)sizeof(_legacy_tail.snmp_community)) {
file.read((uint8_t *)&_legacy_tail.snmp_community, sizeof(_legacy_tail.snmp_community));
}
#endif
}
#ifdef WITH_MQTT_BRIDGE
if (file.available() >= (int)sizeof(_legacy_tail.radio_watchdog_minutes)) {
file.read((uint8_t *)&_legacy_tail.radio_watchdog_minutes, sizeof(_legacy_tail.radio_watchdog_minutes));
}
if (file.available() >= (int)sizeof(_legacy_tail.alert_enabled)) {
file.read((uint8_t *)&_legacy_tail.alert_enabled, sizeof(_legacy_tail.alert_enabled));
}
if (file.available() >= (int)sizeof(_legacy_tail.alert_psk_hex)) {
file.read((uint8_t *)&_legacy_tail.alert_psk_hex, sizeof(_legacy_tail.alert_psk_hex));
}
if (file.available() >= (int)sizeof(_legacy_tail.alert_wifi_minutes)) {
file.read((uint8_t *)&_legacy_tail.alert_wifi_minutes, sizeof(_legacy_tail.alert_wifi_minutes));
}
if (file.available() >= (int)sizeof(_legacy_tail.alert_mqtt_minutes)) {
file.read((uint8_t *)&_legacy_tail.alert_mqtt_minutes, sizeof(_legacy_tail.alert_mqtt_minutes));
}
if (file.available() >= (int)sizeof(_legacy_tail.alert_min_interval_min)) {
file.read((uint8_t *)&_legacy_tail.alert_min_interval_min, sizeof(_legacy_tail.alert_min_interval_min));
}
if (file.available() >= (int)sizeof(_legacy_tail.alert_hashtag)) {
file.read((uint8_t *)&_legacy_tail.alert_hashtag, sizeof(_legacy_tail.alert_hashtag));
}
if (file.available() >= (int)sizeof(_legacy_tail.alert_region)) {
file.read((uint8_t *)&_legacy_tail.alert_region, sizeof(_legacy_tail.alert_region));
}
_legacy_tail.snmp_enabled = constrain(_legacy_tail.snmp_enabled, 0, 1);
_legacy_tail.radio_watchdog_minutes = constrain(_legacy_tail.radio_watchdog_minutes, 0, 120);
_legacy_tail.alert_enabled = constrain(_legacy_tail.alert_enabled, 0, 1);
_legacy_tail.snmp_community[sizeof(_legacy_tail.snmp_community) - 1] = '\0';
_legacy_tail.alert_psk_hex[sizeof(_legacy_tail.alert_psk_hex) - 1] = '\0';
_legacy_tail.alert_hashtag[sizeof(_legacy_tail.alert_hashtag) - 1] = '\0';
_legacy_tail.alert_region[sizeof(_legacy_tail.alert_region) - 1] = '\0';
_legacy_tail.valid = true;
#endif
}
} else {
if (file.available() >= (int)sizeof(_prefs->rx_boosted_gain)) {
file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain));
}
if (file.available() >= (int)sizeof(_prefs->flood_max_unscoped)) {
file.read((uint8_t *)&_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped));
}
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));
}
// next: 295
file.read((uint8_t *)&_prefs->retry_preset, sizeof(_prefs->retry_preset)); // 295
file.read((uint8_t *)&_prefs->direct_retry_attempts, sizeof(_prefs->direct_retry_attempts)); // 296
file.read((uint8_t *)&_prefs->direct_retry_base_ms, sizeof(_prefs->direct_retry_base_ms)); // 297
file.read((uint8_t *)&_prefs->direct_retry_step_ms, sizeof(_prefs->direct_retry_step_ms)); // 299
file.read((uint8_t *)&_prefs->direct_retry_snr_margin_x4, sizeof(_prefs->direct_retry_snr_margin_x4)); // 301
file.read((uint8_t *)&_prefs->direct_retry_cr4_snr_x4, sizeof(_prefs->direct_retry_cr4_snr_x4)); // 303
file.read((uint8_t *)&_prefs->direct_retry_cr5_snr_x4, sizeof(_prefs->direct_retry_cr5_snr_x4)); // 304
file.read((uint8_t *)&_prefs->direct_retry_cr7_snr_x4, sizeof(_prefs->direct_retry_cr7_snr_x4)); // 305
file.read((uint8_t *)&_prefs->direct_retry_cr8_snr_x4, sizeof(_prefs->direct_retry_cr8_snr_x4)); // 306
file.read((uint8_t *)&_prefs->direct_retry_enabled, sizeof(_prefs->direct_retry_enabled)); // 307
file.read((uint8_t *)&_prefs->direct_retry_cr_enabled, sizeof(_prefs->direct_retry_cr_enabled)); // 308
file.read((uint8_t *)&_prefs->direct_retry_prefs_magic, sizeof(_prefs->direct_retry_prefs_magic)); // 309
memset(_prefs->flood_retry_prefixes, 0, sizeof(_prefs->flood_retry_prefixes));
_prefs->flood_retry_bridge_enabled = 0;
memset(_prefs->flood_retry_bridge_buckets, 0, sizeof(_prefs->flood_retry_bridge_buckets));
memset(_prefs->flood_retry_ignore_prefixes, 0, sizeof(_prefs->flood_retry_ignore_prefixes));
_prefs->flood_retry_advert_enabled = FLOOD_RETRY_ADVERT_DEFAULT;
_prefs->battery_alert_enabled = 0;
_prefs->battery_alert_low_percent = BATTERY_ALERT_LOW_PERCENT_DEFAULT;
_prefs->battery_alert_critical_percent = BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT;
memset(_prefs->battery_alert_region, 0, sizeof(_prefs->battery_alert_region));
_prefs->direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT;
_prefs->flood_channel_data_enabled = 1;
_prefs->legacy_flood_channel_block_max_hops = FLOOD_CHANNEL_HOPS_ALL;
_prefs->flood_channel_data_max_hops = FLOOD_CHANNEL_HOPS_ALL;
has_flood_retry_prefs = file.available() >= 2;
if (has_flood_retry_prefs) {
file.read((uint8_t *)&_prefs->flood_retry_attempts, sizeof(_prefs->flood_retry_attempts)); // 311
file.read((uint8_t *)&_prefs->flood_retry_max_path, sizeof(_prefs->flood_retry_max_path)); // 312
if (file.available() >= (int)sizeof(_prefs->flood_retry_prefixes)) {
file.read((uint8_t *)&_prefs->flood_retry_prefixes[0][0], sizeof(_prefs->flood_retry_prefixes));
}
if (file.available() >= (int)sizeof(_prefs->flood_retry_bridge_enabled)) {
file.read((uint8_t *)&_prefs->flood_retry_bridge_enabled, sizeof(_prefs->flood_retry_bridge_enabled));
}
if (file.available() >= (int)sizeof(_prefs->flood_retry_bridge_buckets)) {
file.read((uint8_t *)&_prefs->flood_retry_bridge_buckets[0][0][0], sizeof(_prefs->flood_retry_bridge_buckets));
}
if (file.available() >= (int)sizeof(_prefs->flood_retry_ignore_prefixes)) {
file.read((uint8_t *)&_prefs->flood_retry_ignore_prefixes[0][0], sizeof(_prefs->flood_retry_ignore_prefixes));
}
if (file.available() >= (int)sizeof(_prefs->flood_retry_advert_enabled)) {
file.read((uint8_t *)&_prefs->flood_retry_advert_enabled, sizeof(_prefs->flood_retry_advert_enabled));
}
if (file.available() >= (int)sizeof(_prefs->battery_alert_enabled)) {
file.read((uint8_t *)&_prefs->battery_alert_enabled, sizeof(_prefs->battery_alert_enabled));
}
if (file.available() >= (int)sizeof(_prefs->battery_alert_low_percent)) {
file.read((uint8_t *)&_prefs->battery_alert_low_percent, sizeof(_prefs->battery_alert_low_percent));
}
if (file.available() >= (int)sizeof(_prefs->battery_alert_critical_percent)) {
file.read((uint8_t *)&_prefs->battery_alert_critical_percent, sizeof(_prefs->battery_alert_critical_percent));
}
if (file.available() >= (int)sizeof(_prefs->direct_retry_recent_enabled)) {
file.read((uint8_t *)&_prefs->direct_retry_recent_enabled, sizeof(_prefs->direct_retry_recent_enabled));
}
if (file.available() >= (int)sizeof(_prefs->flood_channel_data_enabled)) {
file.read((uint8_t *)&_prefs->flood_channel_data_enabled, sizeof(_prefs->flood_channel_data_enabled));
}
if (file.available() >= (int)sizeof(_prefs->legacy_flood_channel_block_max_hops)) {
file.read((uint8_t *)&_prefs->legacy_flood_channel_block_max_hops,
sizeof(_prefs->legacy_flood_channel_block_max_hops));
}
if (file.available() >= (int)sizeof(_prefs->flood_channel_data_max_hops)) {
file.read((uint8_t *)&_prefs->flood_channel_data_max_hops, sizeof(_prefs->flood_channel_data_max_hops));
}
if (file.available() >= (int)sizeof(_prefs->telemetry_access)) {
file.read((uint8_t *)&_prefs->telemetry_access, sizeof(_prefs->telemetry_access));
}
}
#if defined(ENABLE_OTA)
// OTA config starts at 675, after telemetry_access. Guard every appended field so older files keep defaults.
if (file.available() >= (int)sizeof(_prefs->ota_autofetch)) {
file.read((uint8_t *)&_prefs->ota_autofetch, sizeof(_prefs->ota_autofetch));
}
if (file.available() >= (int)sizeof(_prefs->ota_autoinstall)) {
file.read((uint8_t *)&_prefs->ota_autoinstall, sizeof(_prefs->ota_autoinstall));
}
if (file.available() >= (int)sizeof(_prefs->ota_signer_count)) {
file.read((uint8_t *)&_prefs->ota_signer_count, sizeof(_prefs->ota_signer_count));
}
if (file.available() >= (int)sizeof(_prefs->ota_signers)) {
file.read((uint8_t *)_prefs->ota_signers, sizeof(_prefs->ota_signers));
}
if (file.available() >= (int)sizeof(_prefs->ota_checkpoint_blocks)) {
file.read((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks));
}
if (file.available() >= (int)sizeof(_prefs->ota_advert_interval)) {
file.read((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval));
}
if (file.available() >= (int)sizeof(_prefs->ota_max_hops)) {
file.read((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops));
}
// next: 811
#endif
// RXPS is stored at a fixed offset after the reserved OTA tail. This keeps
// both old OTA and non-OTA /com_prefs files unambiguous.
bool has_rxps_tail = file.available() >= 11;
#if !defined(ENABLE_OTA)
const size_t ota_tail_size = 136;
const size_t padded_rxps_tail_size = sizeof(_prefs->rx_powersaving_enabled)
+ sizeof(_prefs->rx_ps_rx_us) + sizeof(_prefs->rx_ps_sleep_us)
+ sizeof(_prefs->rx_ps_level) + sizeof(_prefs->rx_ps_preamble);
if (file.available() >= (int)(ota_tail_size + padded_rxps_tail_size)) {
size_t remaining = ota_tail_size;
while (remaining > 0) {
size_t n = remaining > sizeof(pad) ? sizeof(pad) : remaining;
file.read(pad, n);
remaining -= n;
}
has_rxps_tail = true;
} else {
has_rxps_tail = false;
}
#endif
const size_t rxps_tail_size = sizeof(_prefs->rx_powersaving_enabled)
+ sizeof(_prefs->rx_ps_rx_us) + sizeof(_prefs->rx_ps_sleep_us)
+ sizeof(_prefs->rx_ps_level) + sizeof(_prefs->rx_ps_preamble);
if (has_rxps_tail && file.available() >= (int)rxps_tail_size) {
file.read((uint8_t *)&_prefs->rx_powersaving_enabled, sizeof(_prefs->rx_powersaving_enabled));
file.read((uint8_t *)&_prefs->rx_ps_rx_us, sizeof(_prefs->rx_ps_rx_us));
file.read((uint8_t *)&_prefs->rx_ps_sleep_us, sizeof(_prefs->rx_ps_sleep_us));
file.read((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level));
file.read((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble));
}
if (file.available() >= (int)sizeof(_prefs->battery_alert_region)) {
file.read((uint8_t *)_prefs->battery_alert_region, sizeof(_prefs->battery_alert_region));
_prefs->battery_alert_region[sizeof(_prefs->battery_alert_region) - 1] = '\0';
}
if (file.available() >= (int)sizeof(_prefs->flood_retry_group_max_path)) {
file.read((uint8_t *)&_prefs->flood_retry_group_max_path,
sizeof(_prefs->flood_retry_group_max_path));
}
if (file.available() >= (int)sizeof(_prefs->rx_watchdog_enabled)) {
file.read((uint8_t *)&_prefs->rx_watchdog_enabled,
sizeof(_prefs->rx_watchdog_enabled));
}
if (file.available() >= (int)sizeof(_prefs->system_watchdog_enabled)) {
file.read((uint8_t *)&_prefs->system_watchdog_enabled,
sizeof(_prefs->system_watchdog_enabled));
}
if (file.available() >= (int)sizeof(_prefs->extra_sf)) {
file.read((uint8_t *)_prefs->extra_sf, sizeof(_prefs->extra_sf));
if (file.available() >= (int)sizeof(_prefs->radio_fem_txgain)) {
file.read((uint8_t *)&_prefs->radio_fem_txgain,
sizeof(_prefs->radio_fem_txgain));
if (file.available() >= (int)sizeof(_prefs->usb_logging_enabled)) {
file.read((uint8_t *)&_prefs->usb_logging_enabled,
sizeof(_prefs->usb_logging_enabled));
if (file.available() >= (int)sizeof(_prefs->bridge_uart)) {
file.read((uint8_t *)&_prefs->bridge_uart,
sizeof(_prefs->bridge_uart));
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \
&& !defined(RS232_BRIDGE_DEFAULT_ON)
has_runtime_bridge_uart = true;
#endif
if (file.available() >= (int)sizeof(_prefs->bridge_format)) {
file.read((uint8_t *)&_prefs->bridge_format,
sizeof(_prefs->bridge_format));
if (file.available() >= (int)sizeof(_prefs->primary_radio_preamble)) {
uint16_t preamble = 0;
if (file.read((uint8_t *)&preamble, sizeof(preamble)) == sizeof(preamble)
&& (preamble == 0 || (preamble >= 8
&& preamble <= mesh::RadioProfiles::MaxPreamble))) {
_prefs->primary_radio_preamble = preamble;
}
if (file.available() >= (int)sizeof(_prefs->espnow_bridge_enabled)) {
file.read((uint8_t *)&_prefs->espnow_bridge_enabled,
sizeof(_prefs->espnow_bridge_enabled));
if (file.available() >= (int)sizeof(_prefs->gps_sync_interval_hours)) {
uint16_t hours = 0;
if (file.read((uint8_t *)&hours, sizeof(hours)) == sizeof(hours)) {
_prefs->gps_sync_interval_hours = hours;
if (file.available() >= (int)sizeof(_prefs->usb_debug_enabled)) {
file.read((uint8_t *)&_prefs->usb_debug_enabled,
sizeof(_prefs->usb_debug_enabled));
if (file.available() >= (int)sizeof(_prefs->trace_when_repeat_off)) {
file.read((uint8_t *)&_prefs->trace_when_repeat_off,
sizeof(_prefs->trace_when_repeat_off));
if (file.available() >= (int)sizeof(_prefs->ota_channel)) {
file.read((uint8_t *)&_prefs->ota_channel,
sizeof(_prefs->ota_channel));
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)
uint8_t bridge_profile = 0;
if (file.read(&bridge_profile, sizeof(bridge_profile)) == sizeof(bridge_profile)) {
has_runtime_espnow_intent = bridge_profile == 0xA1;
}
#endif
}
}
}
}
}
}
}
}
}
}
}
} else if (file.available() > 0) {
// Never accept a torn append as a partial detector list.
_com_prefs_needs_upgrade = true;
}
}
// Sanitize non-finite values before constrain(), whose comparisons leave
// NaN unchanged. Safe defaults keep a damaged image from reaching radio
// initialization or advert coordinate encoding.
if (!isfinite(_prefs->airtime_factor)) _prefs->airtime_factor = 1.0f;
#ifdef DEFAULT_RX_DELAY_BASE
if (!isfinite(_prefs->rx_delay_base)) _prefs->rx_delay_base = DEFAULT_RX_DELAY_BASE;
#else
if (!isfinite(_prefs->rx_delay_base)) _prefs->rx_delay_base = 0.0f;
#endif
if (!isfinite(_prefs->tx_delay_factor)) _prefs->tx_delay_factor = 0.5f;
if (!isfinite(_prefs->direct_tx_delay_factor)) _prefs->direct_tx_delay_factor = 0.3f;
#ifdef LORA_FREQ
if (!isfinite(_prefs->freq)) _prefs->freq = (float)LORA_FREQ;
#else
if (!isfinite(_prefs->freq)) _prefs->freq = 915.0f;
#endif
if (!isfinite(_prefs->bw)) _prefs->bw = defaultLoRaBandwidth();
if (!isfinite(_prefs->adc_multiplier)) _prefs->adc_multiplier = 0.0f;
if (!isfinite(_prefs->node_lat) || _prefs->node_lat < -90.0 || _prefs->node_lat > 90.0) {
_prefs->node_lat = 0.0;
}
if (!isfinite(_prefs->node_lon) || _prefs->node_lon < -180.0 || _prefs->node_lon > 180.0) {
_prefs->node_lon = 0.0;
}
// sanitise bad pref values
_prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f);
_prefs->tx_delay_factor = constrain(_prefs->tx_delay_factor, 0, 2.0f);
_prefs->direct_tx_delay_factor = constrain(_prefs->direct_tx_delay_factor, 0, 2.0f);
// Duty cycles down to 1% are accepted and persist factor 99, even though
// the legacy `set af` command retains its narrower input range.
_prefs->airtime_factor = constrain(_prefs->airtime_factor, 0, 99.0f);
_prefs->freq = constrain(_prefs->freq, 150.0f, 2500.0f);
_prefs->bw = isValidLoRaBandwidth(_prefs->bw) ? _prefs->bw : defaultLoRaBandwidth();
_prefs->sf = constrain(_prefs->sf, 5, 12);
_prefs->cr = constrain(_prefs->cr, 5, 8);
uint8_t extra_sf_count = 0;
if (!mesh::lr2021::storedSideDetectorCount(_prefs->extra_sf, extra_sf_count)
|| !mesh::lr2021::validateSideDetectorSFs(
_prefs->extra_sf, extra_sf_count, _prefs->sf, _prefs->bw)) {
memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf));
_com_prefs_needs_upgrade = true;
}
_prefs->tx_power_dbm = mesh::clampLoRaTxPower(
_prefs->tx_power_dbm, _prefs->freq);
_prefs->multi_acks = constrain(_prefs->multi_acks, 0, 1);
_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
// sanitise bad bridge pref values
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)
if (!has_runtime_espnow_intent) {
// Older profiles saved an unused ESP-NOW flag as on. Only the appended
// combined-profile marker proves that this was an explicit setting.
_prefs->espnow_bridge_enabled = 0;
_com_prefs_needs_upgrade = true;
}
#endif
#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \
&& !defined(RS232_BRIDGE_DEFAULT_ON)
if (!has_runtime_bridge_uart || _prefs->bridge_uart == 0) {
// Pre-merge normal repeaters persisted bridge_enabled=1 even though no
// bridge was compiled, with either no UART tail or the no-bridge zero
// sentinel. Fail safe before normalizing the UART instead of claiming
// it unexpectedly; the user can explicitly enable the bridge.
_prefs->bridge_enabled = 0;
_com_prefs_needs_upgrade = true;
}
#endif
_prefs->bridge_enabled = constrain(_prefs->bridge_enabled, 0, 1);
_prefs->espnow_bridge_enabled = constrain(_prefs->espnow_bridge_enabled, 0, 1);
#if defined(WITH_ESPNOW_BRIDGE) && defined(ESPNOW_BRIDGE_MERGED) \
&& !defined(WITH_MQTT_BRIDGE) && !defined(WITH_RS232_BRIDGE)
// An unrelated legacy primary bridge flag cannot enable this new mode.
// Only the separately persisted and marker-validated ESP-NOW intent wins.
_prefs->bridge_enabled = _prefs->espnow_bridge_enabled;
#endif
_prefs->bridge_delay = constrain(_prefs->bridge_delay, 0, 10000);
_prefs->bridge_pkt_src = constrain(_prefs->bridge_pkt_src, 0, 1);
_prefs->bridge_baud = constrain(_prefs->bridge_baud, 9600, BRIDGE_MAX_BAUD);
#ifdef WITH_RS232_BRIDGE
if (_prefs->bridge_uart != WITH_RS232_BRIDGE_UART
#ifdef WITH_RS232_BRIDGE_ALT
&& _prefs->bridge_uart != WITH_RS232_BRIDGE_ALT_UART
#endif
) {
_prefs->bridge_uart = WITH_RS232_BRIDGE_UART;
_com_prefs_needs_upgrade = true;
}
#else
_prefs->bridge_uart = 0;
#endif
if (!mesh::bridge::isValidEspNowFormat(_prefs->bridge_format)) {
_prefs->bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
_com_prefs_needs_upgrade = true;
}
#ifdef WITH_ESPNOW_BRIDGE
if (!mesh::bridge::isValidEspNowBridgeChannel(
_prefs->bridge_channel, _prefs->bridge_format)) {
_prefs->bridge_channel = 1;
_com_prefs_needs_upgrade = true;
}
#endif
_prefs->powersaving_enabled = constrain(_prefs->powersaving_enabled, 0, 1);
_prefs->reboot_interval = constrain(_prefs->reboot_interval, 0, 255);
_prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1);
_prefs->gps_sync_interval_hours = constrain(_prefs->gps_sync_interval_hours,
0, mesh::gps::MAX_SYNC_INTERVAL_HOURS);
_prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2);
_prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean
_prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean
_prefs->radio_fem_txgain = constrain(_prefs->radio_fem_txgain, 0, 1); // boolean
_prefs->usb_logging_enabled = constrain(_prefs->usb_logging_enabled, 0, 1); // boolean
// Only a deliberately saved 1 enables diagnostics; corrupted bytes are OFF.
_prefs->usb_debug_enabled = _prefs->usb_debug_enabled == 1 ? 1 : 0;
// Corrupt or unknown values must not enable a repeat-off exception.
_prefs->trace_when_repeat_off = _prefs->trace_when_repeat_off == 1 ? 1 : 0;
_prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean
if (!directRetryPrefsValid(_prefs)) {
setDefaultDirectRetryPrefs(_prefs);
memset(_prefs->flood_retry_prefixes, 0, sizeof(_prefs->flood_retry_prefixes));
_prefs->flood_retry_bridge_enabled = 0;
memset(_prefs->flood_retry_bridge_buckets, 0, sizeof(_prefs->flood_retry_bridge_buckets));
memset(_prefs->flood_retry_ignore_prefixes, 0, sizeof(_prefs->flood_retry_ignore_prefixes));
_prefs->flood_retry_advert_enabled = FLOOD_RETRY_ADVERT_DEFAULT;
} else if (!has_flood_retry_prefs) {
applyFloodRetryPreset(_prefs, _prefs->retry_preset);
}
if (_prefs->retry_preset > RETRY_PRESET_MOBILE && _prefs->retry_preset != RETRY_PRESET_CUSTOM) {
_prefs->retry_preset = RETRY_PRESET_CUSTOM;
}
_prefs->direct_retry_attempts = constrain(_prefs->direct_retry_attempts, 1, 15);
_prefs->direct_retry_base_ms = constrain(_prefs->direct_retry_base_ms, 10, 5000);
_prefs->direct_retry_step_ms = constrain(_prefs->direct_retry_step_ms, 0, 5000);
_prefs->direct_retry_snr_margin_x4 = constrain(_prefs->direct_retry_snr_margin_x4, 0, 160);
_prefs->direct_retry_enabled = constrain(_prefs->direct_retry_enabled, 0, 1);
_prefs->direct_retry_cr_enabled = constrain(_prefs->direct_retry_cr_enabled, 0, 1);
_prefs->flood_retry_attempts = constrain(_prefs->flood_retry_attempts, 0, 15);
if (_prefs->flood_retry_max_path != FLOOD_RETRY_PATH_GATE_DISABLED) {
_prefs->flood_retry_max_path = constrain(_prefs->flood_retry_max_path, 0, 63);
}
if (_prefs->flood_retry_group_max_path != FLOOD_RETRY_PATH_GATE_DISABLED) {
_prefs->flood_retry_group_max_path = constrain(_prefs->flood_retry_group_max_path, 0, 63);
}
if (_prefs->flood_retry_max_path == 0) {
_prefs->flood_retry_group_max_path = FLOOD_RETRY_PATH_GATE_DISABLED;
}
_prefs->flood_retry_bridge_enabled = constrain(_prefs->flood_retry_bridge_enabled, 0, 1);
_prefs->flood_retry_advert_enabled = constrain(_prefs->flood_retry_advert_enabled, 0, 1);
_prefs->battery_alert_enabled = constrain(_prefs->battery_alert_enabled, 0, 1);
_prefs->direct_retry_recent_enabled = constrain(_prefs->direct_retry_recent_enabled, 0, 1);
_prefs->flood_channel_data_enabled = constrain(_prefs->flood_channel_data_enabled, 0, 1);
_prefs->telemetry_access = constrain(_prefs->telemetry_access, 0, 1);
if (_prefs->ota_channel > 2) _prefs->ota_channel = 0;
if (_prefs->legacy_flood_channel_block_max_hops != FLOOD_CHANNEL_HOPS_ALL
&& (_prefs->legacy_flood_channel_block_max_hops < 1
|| _prefs->legacy_flood_channel_block_max_hops > 7)) {
_prefs->legacy_flood_channel_block_max_hops = FLOOD_CHANNEL_HOPS_ALL;
}
if (_prefs->flood_channel_data_max_hops != FLOOD_CHANNEL_HOPS_ALL
&& (_prefs->flood_channel_data_max_hops < 1 || _prefs->flood_channel_data_max_hops > 7)) {
_prefs->flood_channel_data_max_hops = FLOOD_CHANNEL_HOPS_ALL;
}
if (_prefs->battery_alert_low_percent < 1
|| _prefs->battery_alert_low_percent > 100
|| _prefs->battery_alert_critical_percent >= _prefs->battery_alert_low_percent) {
_prefs->battery_alert_low_percent = BATTERY_ALERT_LOW_PERCENT_DEFAULT;
_prefs->battery_alert_critical_percent = BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT;
}
#if defined(ENABLE_OTA)
_prefs->ota_autofetch = constrain(_prefs->ota_autofetch, 0, 2);
_prefs->ota_autoinstall = constrain(_prefs->ota_autoinstall, 0, 1);
if (_prefs->ota_checkpoint_blocks > 4096) _prefs->ota_checkpoint_blocks = 4; // 0=never; cap absurd
if (_prefs->ota_advert_interval > 10080) _prefs->ota_advert_interval = 1440; // 0=off; cap at 7 days
if (_prefs->ota_max_hops > 8) _prefs->ota_max_hops = 3; // 0=direct only; cap absurd reach
if (_prefs->ota_signer_count > 4) _prefs->ota_signer_count = 0; // corrupt count -> drop keys
#endif
_prefs->rx_powersaving_enabled = constrain(_prefs->rx_powersaving_enabled, 0, 1);
_prefs->rx_watchdog_enabled = constrain(_prefs->rx_watchdog_enabled, 0, 1);
_prefs->system_watchdog_enabled = constrain(_prefs->system_watchdog_enabled, 0, 1);
_prefs->rx_ps_level = constrain(_prefs->rx_ps_level, 0, 10);
if (_prefs->rx_ps_preamble != 16 && _prefs->rx_ps_preamble != 32) {
_prefs->rx_ps_preamble = 0; // 0 = auto (derive from SF)
}
ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us);
recalcRxPowerSavingFromLevel(_prefs->rx_ps_level, _prefs->sf, _prefs->bw, _prefs->rx_ps_preamble,
&_prefs->rx_ps_rx_us,
&_prefs->rx_ps_sleep_us); // retune level-based timings to the loaded SF/BW
file.close();
}
}
#ifdef WITH_MQTT_BRIDGE
// Keep the byte layout in one place for the observer's coordinated atomic
// legacy migration. Ordinary builds use savePrefs() directly, avoiding a
// duplicate writer on flash-constrained targets.
template <typename Writer>
static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) {
uint8_t pad[8];
memset(pad, 0, sizeof(pad));
#define WRITE_COMMON_PREFS(value) \
do { \
if (writer.write((const uint8_t *)(value), sizeof(*(value))) != sizeof(*(value))) return false; \
} while (0)
#define WRITE_COMMON_PREFS_BYTES(value, size) \
do { \
if (writer.write((const uint8_t *)(value), (size)) != (size)) return false; \
} while (0)
WRITE_COMMON_PREFS(&prefs->airtime_factor); // 0
WRITE_COMMON_PREFS(&prefs->node_name); // 4
WRITE_COMMON_PREFS_BYTES(pad, 4); // 36
WRITE_COMMON_PREFS(&prefs->node_lat); // 40
WRITE_COMMON_PREFS(&prefs->node_lon); // 48
WRITE_COMMON_PREFS_BYTES(prefs->password, sizeof(prefs->password)); // 56
WRITE_COMMON_PREFS(&prefs->freq); // 72
WRITE_COMMON_PREFS(&prefs->tx_power_dbm); // 76
WRITE_COMMON_PREFS(&prefs->disable_fwd); // 77
WRITE_COMMON_PREFS(&prefs->advert_interval); // 78
WRITE_COMMON_PREFS_BYTES(pad, 1); // 79
WRITE_COMMON_PREFS(&prefs->rx_delay_base); // 80
WRITE_COMMON_PREFS(&prefs->tx_delay_factor); // 84
WRITE_COMMON_PREFS_BYTES(prefs->guest_password, sizeof(prefs->guest_password)); // 88
WRITE_COMMON_PREFS(&prefs->direct_tx_delay_factor); // 104
WRITE_COMMON_PREFS_BYTES(pad, 4); // 108
WRITE_COMMON_PREFS(&prefs->sf); // 112
WRITE_COMMON_PREFS(&prefs->cr); // 113
WRITE_COMMON_PREFS(&prefs->allow_read_only); // 114
WRITE_COMMON_PREFS(&prefs->multi_acks); // 115
WRITE_COMMON_PREFS(&prefs->bw); // 116
WRITE_COMMON_PREFS(&prefs->agc_reset_interval); // 120
WRITE_COMMON_PREFS(&prefs->path_hash_mode); // 121
WRITE_COMMON_PREFS(&prefs->loop_detect); // 122
WRITE_COMMON_PREFS_BYTES(pad, 1); // 123
WRITE_COMMON_PREFS(&prefs->flood_max); // 124
WRITE_COMMON_PREFS(&prefs->flood_advert_interval); // 125
WRITE_COMMON_PREFS(&prefs->interference_threshold); // 126
WRITE_COMMON_PREFS(&prefs->bridge_enabled); // 127
WRITE_COMMON_PREFS(&prefs->bridge_delay); // 128
WRITE_COMMON_PREFS(&prefs->bridge_pkt_src); // 130
WRITE_COMMON_PREFS(&prefs->bridge_baud); // 131
WRITE_COMMON_PREFS(&prefs->bridge_channel); // 135
WRITE_COMMON_PREFS_BYTES(prefs->bridge_secret, sizeof(prefs->bridge_secret)); // 136
WRITE_COMMON_PREFS(&prefs->powersaving_enabled); // 152
WRITE_COMMON_PREFS(&prefs->reboot_interval); // 153
WRITE_COMMON_PREFS_BYTES(pad, 2); // 154
WRITE_COMMON_PREFS(&prefs->gps_enabled); // 156
WRITE_COMMON_PREFS(&prefs->gps_interval); // 157
WRITE_COMMON_PREFS(&prefs->advert_loc_policy); // 161
WRITE_COMMON_PREFS(&prefs->discovery_mod_timestamp); // 162
WRITE_COMMON_PREFS(&prefs->adc_multiplier); // 166
WRITE_COMMON_PREFS_BYTES(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.
// These trailing writes are COM_PREFS_TAIL_BYTES; keep the two in sync.
WRITE_COMMON_PREFS(&prefs->rx_boosted_gain); // 290
WRITE_COMMON_PREFS(&prefs->flood_max_unscoped); // 291
WRITE_COMMON_PREFS(&prefs->flood_max_advert); // 292
WRITE_COMMON_PREFS(&prefs->radio_fem_rxgain); // 293
WRITE_COMMON_PREFS(&prefs->cad_enabled); // 294
markDirectRetryPrefsValid(prefs);
WRITE_COMMON_PREFS(&prefs->retry_preset); // 295
WRITE_COMMON_PREFS(&prefs->direct_retry_attempts); // 296
WRITE_COMMON_PREFS(&prefs->direct_retry_base_ms); // 297
WRITE_COMMON_PREFS(&prefs->direct_retry_step_ms); // 299
WRITE_COMMON_PREFS(&prefs->direct_retry_snr_margin_x4); // 301
WRITE_COMMON_PREFS(&prefs->direct_retry_cr4_snr_x4); // 303
WRITE_COMMON_PREFS(&prefs->direct_retry_cr5_snr_x4); // 304
WRITE_COMMON_PREFS(&prefs->direct_retry_cr7_snr_x4); // 305
WRITE_COMMON_PREFS(&prefs->direct_retry_cr8_snr_x4); // 306
WRITE_COMMON_PREFS(&prefs->direct_retry_enabled); // 307
WRITE_COMMON_PREFS(&prefs->direct_retry_cr_enabled); // 308
WRITE_COMMON_PREFS(&prefs->direct_retry_prefs_magic); // 309
WRITE_COMMON_PREFS(&prefs->flood_retry_attempts); // 311
WRITE_COMMON_PREFS(&prefs->flood_retry_max_path); // 312
WRITE_COMMON_PREFS(&prefs->flood_retry_prefixes); // 313
WRITE_COMMON_PREFS(&prefs->flood_retry_bridge_enabled);
WRITE_COMMON_PREFS(&prefs->flood_retry_bridge_buckets);
WRITE_COMMON_PREFS(&prefs->flood_retry_ignore_prefixes);
WRITE_COMMON_PREFS(&prefs->flood_retry_advert_enabled);
WRITE_COMMON_PREFS(&prefs->battery_alert_enabled);
WRITE_COMMON_PREFS(&prefs->battery_alert_low_percent);
WRITE_COMMON_PREFS(&prefs->battery_alert_critical_percent);
WRITE_COMMON_PREFS(&prefs->direct_retry_recent_enabled);
WRITE_COMMON_PREFS(&prefs->flood_channel_data_enabled);
WRITE_COMMON_PREFS(&prefs->legacy_flood_channel_block_max_hops);
WRITE_COMMON_PREFS(&prefs->flood_channel_data_max_hops);
WRITE_COMMON_PREFS(&prefs->telemetry_access); // 674
#if defined(ENABLE_OTA)
WRITE_COMMON_PREFS(&prefs->ota_autofetch); // 675
WRITE_COMMON_PREFS(&prefs->ota_autoinstall); // 676
WRITE_COMMON_PREFS(&prefs->ota_signer_count); // 677
WRITE_COMMON_PREFS(&prefs->ota_signers); // 678
WRITE_COMMON_PREFS(&prefs->ota_checkpoint_blocks); // 806
WRITE_COMMON_PREFS(&prefs->ota_advert_interval); // 808
WRITE_COMMON_PREFS(&prefs->ota_max_hops); // 810
#else
// Reserve the OTA tail so RXPS has the same offset in every build.
WRITE_COMMON_PREFS_BYTES(pad, 3);
for (size_t remaining = 128; remaining > 0; ) {
const size_t n = remaining > sizeof(pad) ? sizeof(pad) : remaining;
WRITE_COMMON_PREFS_BYTES(pad, n);
remaining -= n;
}
const uint16_t ota_checkpoint_blocks = 4;
const uint16_t ota_advert_interval = 0;
const uint8_t ota_max_hops = 3;
WRITE_COMMON_PREFS(&ota_checkpoint_blocks);
WRITE_COMMON_PREFS(&ota_advert_interval);
WRITE_COMMON_PREFS(&ota_max_hops);
#endif
WRITE_COMMON_PREFS(&prefs->rx_powersaving_enabled); // 811
WRITE_COMMON_PREFS(&prefs->rx_ps_rx_us); // 812
WRITE_COMMON_PREFS(&prefs->rx_ps_sleep_us); // 816
WRITE_COMMON_PREFS(&prefs->rx_ps_level); // 820
WRITE_COMMON_PREFS(&prefs->rx_ps_preamble); // 821
WRITE_COMMON_PREFS(&prefs->battery_alert_region); // 822
WRITE_COMMON_PREFS(&prefs->flood_retry_group_max_path); // 853
WRITE_COMMON_PREFS(&prefs->rx_watchdog_enabled); // 854
WRITE_COMMON_PREFS(&prefs->system_watchdog_enabled); // 855
WRITE_COMMON_PREFS(&prefs->extra_sf); // 856
WRITE_COMMON_PREFS(&prefs->radio_fem_txgain); // 860
WRITE_COMMON_PREFS(&prefs->usb_logging_enabled); // 861
WRITE_COMMON_PREFS(&prefs->bridge_uart); // 862
WRITE_COMMON_PREFS(&prefs->bridge_format); // 863
WRITE_COMMON_PREFS(&prefs->primary_radio_preamble); // appended primary tuple field
WRITE_COMMON_PREFS(&prefs->espnow_bridge_enabled); // appended Full ESP-NOW intent
WRITE_COMMON_PREFS(&prefs->gps_sync_interval_hours); // published GPS sync cadence
WRITE_COMMON_PREFS(&prefs->usb_debug_enabled); // appended USB debug intent
WRITE_COMMON_PREFS(&prefs->trace_when_repeat_off); // appended repeater trace exception
WRITE_COMMON_PREFS(&prefs->ota_channel); // appended WiFi OTA channel; never shift older fields
#ifdef ESPNOW_BRIDGE_MERGED
const uint8_t bridge_profile = 0xA1;
WRITE_COMMON_PREFS(&bridge_profile); // independent ESP-NOW intent belongs to this profile
#endif
#undef WRITE_COMMON_PREFS_BYTES
#undef WRITE_COMMON_PREFS
return true;
}
#endif
void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) {
const PrefsSaveRouting::Plan plan = PrefsSaveRouting::planFor(scope);
if (plan.common) {
_common_save_result_known = true;
_common_save_succeeded = false;
_prefs->usb_debug_enabled = _prefs->usb_debug_enabled == 1 ? 1 : 0;
_prefs->trace_when_repeat_off = _prefs->trace_when_repeat_off == 1 ? 1 : 0;
}
#ifdef WITH_MQTT_BRIDGE
// Observer builds use a verified temp/backup transaction for common prefs.
// Radio and bridge changes must never leave a truncated boot-time image.
if (plan.common) _common_save_succeeded = saveCommonPrefsImageAtomically(fs);
if (plan.observer) {
_observer_save_result_known = true;
_observer_save_succeeded = saveMQTTPrefs(fs);
}
#else
// Observer-only saves are a no-op on roles with no observer preference image.
if (!plan.common) return;
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
mesh::AtomicFileWriter file(fs, "/com_prefs");
#else
mesh::ContactFileTransaction file(fs, "/com_prefs");
#endif
if (file) {
uint8_t pad[8];
memset(pad, 0, sizeof(pad));
file.write((uint8_t *)&_prefs->airtime_factor, sizeof(_prefs->airtime_factor)); // 0
file.write((uint8_t *)&_prefs->node_name, sizeof(_prefs->node_name)); // 4
file.write(pad, 4); // 36
file.write((uint8_t *)&_prefs->node_lat, sizeof(_prefs->node_lat)); // 40
file.write((uint8_t *)&_prefs->node_lon, sizeof(_prefs->node_lon)); // 48
file.write((uint8_t *)&_prefs->password[0], sizeof(_prefs->password)); // 56
file.write((uint8_t *)&_prefs->freq, sizeof(_prefs->freq)); // 72
file.write((uint8_t *)&_prefs->tx_power_dbm, sizeof(_prefs->tx_power_dbm)); // 76
file.write((uint8_t *)&_prefs->disable_fwd, sizeof(_prefs->disable_fwd)); // 77
file.write((uint8_t *)&_prefs->advert_interval, sizeof(_prefs->advert_interval)); // 78
file.write(pad, 1); // 79 : 1 byte unused (rx_boosted_gain moved to end)
file.write((uint8_t *)&_prefs->rx_delay_base, sizeof(_prefs->rx_delay_base)); // 80
file.write((uint8_t *)&_prefs->tx_delay_factor, sizeof(_prefs->tx_delay_factor)); // 84
file.write((uint8_t *)&_prefs->guest_password[0], sizeof(_prefs->guest_password)); // 88
file.write((uint8_t *)&_prefs->direct_tx_delay_factor, sizeof(_prefs->direct_tx_delay_factor)); // 104
file.write(pad, 4); // 108
file.write((uint8_t *)&_prefs->sf, sizeof(_prefs->sf)); // 112
file.write((uint8_t *)&_prefs->cr, sizeof(_prefs->cr)); // 113
file.write((uint8_t *)&_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114
file.write((uint8_t *)&_prefs->multi_acks, sizeof(_prefs->multi_acks)); // 115
file.write((uint8_t *)&_prefs->bw, sizeof(_prefs->bw)); // 116
file.write((uint8_t *)&_prefs->agc_reset_interval, sizeof(_prefs->agc_reset_interval)); // 120
file.write((uint8_t *)&_prefs->path_hash_mode, sizeof(_prefs->path_hash_mode)); // 121
file.write((uint8_t *)&_prefs->loop_detect, sizeof(_prefs->loop_detect)); // 122
file.write(pad, 1); // 123
file.write((uint8_t *)&_prefs->flood_max, sizeof(_prefs->flood_max)); // 124
file.write((uint8_t *)&_prefs->flood_advert_interval, sizeof(_prefs->flood_advert_interval)); // 125
file.write((uint8_t *)&_prefs->interference_threshold, sizeof(_prefs->interference_threshold)); // 126
file.write((uint8_t *)&_prefs->bridge_enabled, sizeof(_prefs->bridge_enabled)); // 127
file.write((uint8_t *)&_prefs->bridge_delay, sizeof(_prefs->bridge_delay)); // 128
file.write((uint8_t *)&_prefs->bridge_pkt_src, sizeof(_prefs->bridge_pkt_src)); // 130
file.write((uint8_t *)&_prefs->bridge_baud, sizeof(_prefs->bridge_baud)); // 131
file.write((uint8_t *)&_prefs->bridge_channel, sizeof(_prefs->bridge_channel)); // 135
file.write((uint8_t *)&_prefs->bridge_secret, sizeof(_prefs->bridge_secret)); // 136
file.write((uint8_t *)&_prefs->powersaving_enabled, sizeof(_prefs->powersaving_enabled)); // 152
file.write((uint8_t *)&_prefs->reboot_interval, sizeof(_prefs->reboot_interval)); // 153
file.write(pad, 2); // 154
file.write((uint8_t *)&_prefs->gps_enabled, sizeof(_prefs->gps_enabled)); // 156
file.write((uint8_t *)&_prefs->gps_interval, sizeof(_prefs->gps_interval)); // 157
file.write((uint8_t *)&_prefs->advert_loc_policy, sizeof(_prefs->advert_loc_policy)); // 161
file.write((uint8_t *)&_prefs->discovery_mod_timestamp, sizeof(_prefs->discovery_mod_timestamp)); // 162
file.write((uint8_t *)&_prefs->adc_multiplier, sizeof(_prefs->adc_multiplier)); // 166
file.write((uint8_t *)_prefs->owner_info, sizeof(_prefs->owner_info)); // 170
// MQTT/observer settings are stored in /mqtt_prefs, not here. /com_prefs
// holds the upstream fields plus the keymind retry tail below.
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
// next: 295
markDirectRetryPrefsValid(_prefs);
file.write((uint8_t *)&_prefs->retry_preset, sizeof(_prefs->retry_preset)); // 295
file.write((uint8_t *)&_prefs->direct_retry_attempts, sizeof(_prefs->direct_retry_attempts)); // 296
file.write((uint8_t *)&_prefs->direct_retry_base_ms, sizeof(_prefs->direct_retry_base_ms)); // 297
file.write((uint8_t *)&_prefs->direct_retry_step_ms, sizeof(_prefs->direct_retry_step_ms)); // 299
file.write((uint8_t *)&_prefs->direct_retry_snr_margin_x4, sizeof(_prefs->direct_retry_snr_margin_x4)); // 301
file.write((uint8_t *)&_prefs->direct_retry_cr4_snr_x4, sizeof(_prefs->direct_retry_cr4_snr_x4)); // 303
file.write((uint8_t *)&_prefs->direct_retry_cr5_snr_x4, sizeof(_prefs->direct_retry_cr5_snr_x4)); // 304
file.write((uint8_t *)&_prefs->direct_retry_cr7_snr_x4, sizeof(_prefs->direct_retry_cr7_snr_x4)); // 305
file.write((uint8_t *)&_prefs->direct_retry_cr8_snr_x4, sizeof(_prefs->direct_retry_cr8_snr_x4)); // 306
file.write((uint8_t *)&_prefs->direct_retry_enabled, sizeof(_prefs->direct_retry_enabled)); // 307
file.write((uint8_t *)&_prefs->direct_retry_cr_enabled, sizeof(_prefs->direct_retry_cr_enabled)); // 308
file.write((uint8_t *)&_prefs->direct_retry_prefs_magic, sizeof(_prefs->direct_retry_prefs_magic)); // 309
file.write((uint8_t *)&_prefs->flood_retry_attempts, sizeof(_prefs->flood_retry_attempts)); // 311
file.write((uint8_t *)&_prefs->flood_retry_max_path, sizeof(_prefs->flood_retry_max_path)); // 312
file.write((uint8_t *)&_prefs->flood_retry_prefixes[0][0], sizeof(_prefs->flood_retry_prefixes)); // 313
file.write((uint8_t *)&_prefs->flood_retry_bridge_enabled, sizeof(_prefs->flood_retry_bridge_enabled));
file.write((uint8_t *)&_prefs->flood_retry_bridge_buckets[0][0][0], sizeof(_prefs->flood_retry_bridge_buckets));
file.write((uint8_t *)&_prefs->flood_retry_ignore_prefixes[0][0], sizeof(_prefs->flood_retry_ignore_prefixes));
file.write((uint8_t *)&_prefs->flood_retry_advert_enabled, sizeof(_prefs->flood_retry_advert_enabled));
file.write((uint8_t *)&_prefs->battery_alert_enabled, sizeof(_prefs->battery_alert_enabled));
file.write((uint8_t *)&_prefs->battery_alert_low_percent, sizeof(_prefs->battery_alert_low_percent));
file.write((uint8_t *)&_prefs->battery_alert_critical_percent, sizeof(_prefs->battery_alert_critical_percent));
file.write((uint8_t *)&_prefs->direct_retry_recent_enabled, sizeof(_prefs->direct_retry_recent_enabled));
file.write((uint8_t *)&_prefs->flood_channel_data_enabled, sizeof(_prefs->flood_channel_data_enabled));
file.write((uint8_t *)&_prefs->legacy_flood_channel_block_max_hops,
sizeof(_prefs->legacy_flood_channel_block_max_hops));
file.write((uint8_t *)&_prefs->flood_channel_data_max_hops, sizeof(_prefs->flood_channel_data_max_hops));
file.write((uint8_t *)&_prefs->telemetry_access, sizeof(_prefs->telemetry_access)); // 674
#if defined(ENABLE_OTA)
file.write((uint8_t *)&_prefs->ota_autofetch, sizeof(_prefs->ota_autofetch)); // 675
file.write((uint8_t *)&_prefs->ota_autoinstall, sizeof(_prefs->ota_autoinstall)); // 676
file.write((uint8_t *)&_prefs->ota_signer_count, sizeof(_prefs->ota_signer_count)); // 677
file.write((uint8_t *)_prefs->ota_signers, sizeof(_prefs->ota_signers)); // 678
file.write((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks)); // 806
file.write((uint8_t *)&_prefs->ota_advert_interval, sizeof(_prefs->ota_advert_interval)); // 808
file.write((uint8_t *)&_prefs->ota_max_hops, sizeof(_prefs->ota_max_hops)); // 810
// next: 811
#else
// Reserve the OTA tail so RXPS has the same offset in every build. Write
// OTA's normal defaults so a later OTA-enabled firmware reads sane values.
file.write(pad, 3); // autofetch, autoinstall, signer_count
for (size_t remaining = 128; remaining > 0; ) {
size_t n = remaining > sizeof(pad) ? sizeof(pad) : remaining;
file.write(pad, n);
remaining -= n;
}
const uint16_t ota_checkpoint_blocks = 4;
const uint16_t ota_advert_interval = 0;
const uint8_t ota_max_hops = 3;
file.write((uint8_t *)&ota_checkpoint_blocks, sizeof(ota_checkpoint_blocks));
file.write((uint8_t *)&ota_advert_interval, sizeof(ota_advert_interval));
file.write((uint8_t *)&ota_max_hops, sizeof(ota_max_hops));
#endif
file.write((uint8_t *)&_prefs->rx_powersaving_enabled, sizeof(_prefs->rx_powersaving_enabled)); // 811
file.write((uint8_t *)&_prefs->rx_ps_rx_us, sizeof(_prefs->rx_ps_rx_us)); // 812
file.write((uint8_t *)&_prefs->rx_ps_sleep_us, sizeof(_prefs->rx_ps_sleep_us)); // 816
file.write((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level)); // 820
file.write((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble)); // 821
file.write((uint8_t *)_prefs->battery_alert_region, sizeof(_prefs->battery_alert_region)); // 822
file.write((uint8_t *)&_prefs->flood_retry_group_max_path,
sizeof(_prefs->flood_retry_group_max_path)); // 853
file.write((uint8_t *)&_prefs->rx_watchdog_enabled,
sizeof(_prefs->rx_watchdog_enabled)); // 854
file.write((uint8_t *)&_prefs->system_watchdog_enabled,
sizeof(_prefs->system_watchdog_enabled)); // 855
file.write((uint8_t *)_prefs->extra_sf, sizeof(_prefs->extra_sf)); // 856
file.write((uint8_t *)&_prefs->radio_fem_txgain, sizeof(_prefs->radio_fem_txgain)); // 860
file.write((uint8_t *)&_prefs->usb_logging_enabled, sizeof(_prefs->usb_logging_enabled)); // 861
file.write((uint8_t *)&_prefs->bridge_uart, sizeof(_prefs->bridge_uart)); // 862
file.write((uint8_t *)&_prefs->bridge_format, sizeof(_prefs->bridge_format)); // 863
file.write((uint8_t *)&_prefs->primary_radio_preamble, sizeof(_prefs->primary_radio_preamble)); // appended
file.write((uint8_t *)&_prefs->espnow_bridge_enabled, sizeof(_prefs->espnow_bridge_enabled)); // appended
file.write((uint8_t *)&_prefs->gps_sync_interval_hours, sizeof(_prefs->gps_sync_interval_hours)); // published GPS sync cadence
file.write((uint8_t *)&_prefs->usb_debug_enabled, sizeof(_prefs->usb_debug_enabled)); // appended
file.write((uint8_t *)&_prefs->trace_when_repeat_off, sizeof(_prefs->trace_when_repeat_off)); // appended
file.write((uint8_t *)&_prefs->ota_channel, sizeof(_prefs->ota_channel)); // appended WiFi OTA channel
#ifdef ESPNOW_BRIDGE_MERGED
const uint8_t bridge_profile = 0xA1;
file.write(&bridge_profile, sizeof(bridge_profile)); // independent ESP-NOW intent belongs to this profile
#endif
_common_save_succeeded = file.commit();
if (!_common_save_succeeded) {
MESH_DEBUG_PRINTLN("ERROR: savePrefs atomic commit failed");
}
}
#endif
#if MESH_USB_LOGGING_AVAILABLE
if (plan.common && _common_save_succeeded) {
// Dynamic config imports use this path too. Do not enable diagnostics
// until the corresponding common preference image is committed.
mesh::setUsbDebugEnabled(_prefs->usb_debug_enabled != 0);
mesh::setUsbLoggingEnabled(_prefs->usb_logging_enabled != 0);
}
#endif
}
#ifdef WITH_MQTT_BRIDGE
// Set default values for MQTT preferences (used when file doesn't exist or is corrupted)
static void setMQTTPrefsDefaults(MQTTPrefs* prefs) {
applyMQTTDefaults(prefs);
}
static File openMqttPrefsRead(FILESYSTEM* fs, const char* path = "/mqtt_prefs") {
#if defined(RP2040_PLATFORM)
return fs->open(path, "r");
#else
return fs->open(path);
#endif
}
static bool mqttJsonImportPresetValid(const char* preset) {
return findMQTTPreset(preset) != nullptr;
}
static MQTTPrefsJsonImport::Result loadMqttJsonImport(
FILESYSTEM* fs, const MQTTPrefs& defaults, MQTTPrefs* candidate) {
File file = openMqttPrefsRead(fs, "/mqtt.json");
if (!file) return MQTTPrefsJsonImport::Result::InvalidSyntax;
if (file.size() > MQTTPrefsJsonImport::kMaximumFileSize) {
file.close();
return MQTTPrefsJsonImport::Result::TooLarge;
}
const MQTTPrefsJsonImport::Result result =
MQTTPrefsJsonImport::decode(
file, defaults, candidate, mqttJsonImportPresetValid);
file.close();
return result;
}
static const char* mqttJsonImportResultName(MQTTPrefsJsonImport::Result result) {
switch (result) {
case MQTTPrefsJsonImport::Result::Loaded: return "loaded";
case MQTTPrefsJsonImport::Result::LoadedWithRepairs: return "loaded with repairs";
case MQTTPrefsJsonImport::Result::InvalidSyntax: return "invalid ConfigSerializer syntax";
case MQTTPrefsJsonImport::Result::InvalidSchema: return "invalid v1 schema";
case MQTTPrefsJsonImport::Result::UnsupportedVersion: return "unsupported version";
case MQTTPrefsJsonImport::Result::TooLarge: return "file too large";
}
return "unknown error";
}
static MQTTPrefsRecovery::FileState mqttPrefsFileState(FILESYSTEM* fs, const char* path) {
bool present = false;
if (!mesh::filePresence(fs, path, present)) return MQTTPrefsRecovery::FileState::Preserve;
if (!present) return MQTTPrefsRecovery::FileState::Missing;
File file = openMqttPrefsRead(fs, path);
if (!file) return MQTTPrefsRecovery::FileState::Preserve;
const size_t file_size = file.size();
uint8_t prefix[sizeof(MQTTPrefsHeader)] = {};
const size_t prefix_size = file_size < sizeof(prefix) ? file_size : sizeof(prefix);
const size_t prefix_read = file.read(prefix, prefix_size);
file.close();
return MQTTPrefsCodec::classify(prefix, prefix_read, file_size).preserve_file
? MQTTPrefsRecovery::FileState::Preserve
: MQTTPrefsRecovery::FileState::Usable;
}
// Restore the only usable image before the normal loader inspects /mqtt_prefs.
// SPIFFS cannot rename over an existing destination, so publishing moves the
// old primary to .bak before moving the verified temp into the empty name.
// The decision helper deliberately treats unsupported/corrupt files as opaque:
// no recovery path overwrites one with an older layout.
static bool recoverMqttPrefsFiles(FILESYSTEM* fs) {
const MQTTPrefsRecovery::FileState primary = mqttPrefsFileState(fs, "/mqtt_prefs");
const MQTTPrefsRecovery::FileState temp = mqttPrefsFileState(fs, "/mqtt_prefs.tmp");
const MQTTPrefsRecovery::FileState backup = mqttPrefsFileState(fs, "/mqtt_prefs.bak");
const MQTTPrefsRecovery::Action action = MQTTPrefsRecovery::select(primary, temp, backup);
if (action == MQTTPrefsRecovery::Action::KeepPrimary) {
// A current/known legacy primary has already published. Every transaction
// artifact is therefore unpublished or stale, including a partial temp
// left by a reset during write(), and can be discarded. Preserve artifacts
// only when the primary itself is opaque (the branch above still keeps it).
if (primary == MQTTPrefsRecovery::FileState::Usable) {
if (temp != MQTTPrefsRecovery::FileState::Missing) fs->remove("/mqtt_prefs.tmp");
if (backup != MQTTPrefsRecovery::FileState::Missing) fs->remove("/mqtt_prefs.bak");
}
return primary == MQTTPrefsRecovery::FileState::Preserve;
}
if (action == MQTTPrefsRecovery::Action::PromoteBackup) {
if (fs->rename("/mqtt_prefs.bak", "/mqtt_prefs")) {
// Symmetric case: a usable backup is now primary, so any interrupted
// temp is no longer authoritative and must not block a later save.
if (backup == MQTTPrefsRecovery::FileState::Usable &&
temp != MQTTPrefsRecovery::FileState::Missing) {
fs->remove("/mqtt_prefs.tmp");
}
MESH_DEBUG_PRINTLN("MQTT: recovered /mqtt_prefs from transaction backup");
return backup == MQTTPrefsRecovery::FileState::Preserve;
}
MESH_DEBUG_PRINTLN("MQTT: could not recover /mqtt_prefs backup; files preserved");
return true;
}
if (action == MQTTPrefsRecovery::Action::DiscardTemp) {
// No published image exists. A first-save candidate is still uncommitted,
// even when fully written, and must not become active after a rejected save.
return !fs->remove("/mqtt_prefs.tmp");
}
return false;
}
// Filesystem adapter for MQTTPrefsAtomicStore. It writes the new image to
// /mqtt_prefs.tmp and verifies its size. Publishing is a recoverable SPIFFS
// transaction: primary -> .bak, then tmp -> primary, then best-effort backup
// cleanup. A power loss at every boundary leaves at least one recoverable file.
class MQTTPrefsFileStore {
public:
explicit MQTTPrefsFileStore(FILESYSTEM* fs)
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
: _fs(fs), _file(*fs) {}
#else
: _fs(fs) {}
#endif
bool begin() {
_finished = false;
_open = false;
_owns_temp = false;
_bytes_written = 0;
// Retry interrupted cleanup/rollback without requiring a reboot. Recovery
// never publishes a rejected candidate or replaces an opaque primary.
if (recoverMqttPrefsFiles(_fs)) return false;
if (_fs->exists("/mqtt_prefs.tmp") || _fs->exists("/mqtt_prefs.bak")) return false;
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
_file = _fs->open("/mqtt_prefs.tmp", FILE_O_WRITE);
#elif defined(RP2040_PLATFORM)
_file = _fs->open("/mqtt_prefs.tmp", "w");
#else
_file = _fs->open("/mqtt_prefs.tmp", "w", true);
#endif
_open = _file;
_owns_temp = _open;
return _open;
}
size_t write(const uint8_t* bytes, size_t size) {
if (!_open) return 0;
const size_t written = _file.write(bytes, size);
_bytes_written += written;
return written;
}
bool finish() {
if (!_open) return false;
_file.close();
_open = false;
#if defined(RP2040_PLATFORM)
File verify = _fs->open("/mqtt_prefs.tmp", "r");
#else
File verify = _fs->open("/mqtt_prefs.tmp");
#endif
if (!verify) return false;
const bool complete = verify.size() == _bytes_written;
verify.close();
if (!complete) return false;
_finished = true;
return true;
}
bool commit() {
if (!_finished) return false;
// SPIFFS refuses rename(tmp, existing_dest). Move the existing image to a
// recoverable backup first, then publish temp into the now-empty primary.
// A rejected publication must keep the previous image authoritative.
if (_fs->exists("/mqtt_prefs.bak")) return false;
if (_fs->exists("/mqtt_prefs") && !_fs->rename("/mqtt_prefs", "/mqtt_prefs.bak")) {
return false;
}
if (!_fs->rename("/mqtt_prefs.tmp", "/mqtt_prefs")) {
if (_fs->exists("/mqtt_prefs.bak")) {
_fs->rename("/mqtt_prefs.bak", "/mqtt_prefs");
}
return false;
}
// Cleanup failure is non-fatal: the new primary is published and recovery
// will remove a known-good stale backup on a later boot.
if (_fs->exists("/mqtt_prefs.bak")) _fs->remove("/mqtt_prefs.bak");
return true;
}
void abort() {
if (_open) _file.close();
_open = false;
// Explicit rejection is not a successful commit. If cleanup or rollback
// also fails, boot recovery still chooses the old backup, never this temp.
if (_owns_temp && _fs->exists("/mqtt_prefs.tmp")) {
_fs->remove("/mqtt_prefs.tmp");
}
_finished = false;
_owns_temp = false;
}
private:
FILESYSTEM* _fs;
File _file;
bool _open = false;
bool _finished = false;
bool _owns_temp = false;
size_t _bytes_written = 0;
};
#endif // WITH_MQTT_BRIDGE
#ifdef WITH_MQTT_BRIDGE
// Common preferences use the same verified temp/backup transaction shape as
// MQTT preferences. This protects both old-name migration and normal runtime
// radio/bridge changes from power loss between truncate, write, and close.
class CommonPrefsFileStore {
public:
explicit CommonPrefsFileStore(FILESYSTEM* fs)
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
: _fs(fs), _file(*fs) {}
#else
: _fs(fs) {}
#endif
bool begin() {
_finished = false;
_open = false;
_owns_temp = false;
_bytes_written = 0;
if (_fs->exists("/com_prefs.bak")) return false;
if (_fs->exists("/com_prefs.tmp")) {
_fs->remove("/com_prefs.tmp");
if (_fs->exists("/com_prefs.tmp")) return false; // could not clear it
}
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
_file = _fs->open("/com_prefs.tmp", FILE_O_WRITE);
#elif defined(RP2040_PLATFORM)
_file = _fs->open("/com_prefs.tmp", "w");
#else
_file = _fs->open("/com_prefs.tmp", "w", true);
#endif
_open = _file;
_owns_temp = _open;
return _open;
}
size_t write(const uint8_t* bytes, size_t size) {
if (!_open) return 0;
const size_t written = _file.write(bytes, size);
_bytes_written += written;
return written;
}
bool finish() {
if (!_open) return false;
_file.close();
_open = false;
#if defined(RP2040_PLATFORM)
File verify = _fs->open("/com_prefs.tmp", "r");
#else
File verify = _fs->open("/com_prefs.tmp");
#endif
if (!verify) return false;
const bool complete = verify.size() == _bytes_written;
verify.close();
if (!complete) return false;
_finished = true;
return true;
}
bool commit() {
if (!_finished) return false;
if (_fs->exists("/com_prefs.bak")) return false;
if (_fs->exists("/com_prefs")
&& !_fs->rename("/com_prefs", "/com_prefs.bak")) {
return false;
}
if (!_fs->rename("/com_prefs.tmp", "/com_prefs")) {
// An explicit failure must retain the previous durable configuration.
// If restoration also fails, recovery keeps the backup authoritative.
if (_fs->exists("/com_prefs.bak")) {
_fs->rename("/com_prefs.bak", "/com_prefs");
}
return false;
}
if (_fs->exists("/com_prefs.bak")) _fs->remove("/com_prefs.bak");
return true;
}
void abort() {
if (_open) _file.close();
_open = false;
// abort() is an explicit rejected write, unlike a power loss. Never leave
// its candidate eligible to become the committed primary on a later boot.
if (_owns_temp && _fs->exists("/com_prefs.tmp")) {
_fs->remove("/com_prefs.tmp");
}
_finished = false;
_owns_temp = false;
}
private:
FILESYSTEM* _fs;
File _file;
bool _open = false;
bool _finished = false;
bool _owns_temp = false;
size_t _bytes_written = 0;
};
static const char* commonPrefsSaveResultName(MQTTPrefsAtomicStore::ImageResult result) {
switch (result) {
case MQTTPrefsAtomicStore::ImageResult::BeginFailed: return "begin";
case MQTTPrefsAtomicStore::ImageResult::WriteFailed: return "write";
case MQTTPrefsAtomicStore::ImageResult::FinishFailed: return "close";
case MQTTPrefsAtomicStore::ImageResult::CommitFailed: return "rename";
case MQTTPrefsAtomicStore::ImageResult::Committed: return "committed";
}
return "unknown";
}
bool CommonCLI::recoverCommonPrefsFiles(FILESYSTEM* fs) {
using CommonPrefsRecovery::Action;
const Action action = CommonPrefsRecovery::select(
fs->exists("/com_prefs"),
fs->exists("/com_prefs.tmp"),
fs->exists("/com_prefs.bak"));
switch (action) {
case Action::KeepPrimary:
if (fs->exists("/com_prefs.tmp")) fs->remove("/com_prefs.tmp");
if (fs->exists("/com_prefs.bak")) fs->remove("/com_prefs.bak");
return !fs->exists("/com_prefs.tmp") && !fs->exists("/com_prefs.bak");
case Action::PromoteBackup:
// A complete temp was not necessarily accepted by the caller. Roll back
// an interrupted or rejected commit to the last published image.
if (!fs->rename("/com_prefs.bak", "/com_prefs")) return false;
if (fs->exists("/com_prefs.tmp")) fs->remove("/com_prefs.tmp");
return !fs->exists("/com_prefs.tmp");
case Action::DiscardTemp:
// With no backup, a reset may have interrupted the very first write
// before finish(). Keep defaults or /node_prefs and retry the save.
fs->remove("/com_prefs.tmp");
return !fs->exists("/com_prefs.tmp");
case Action::None:
return true;
}
return false;
}
bool CommonCLI::saveCommonPrefsImageAtomically(FILESYSTEM* fs) {
if (!recoverCommonPrefsFiles(fs)) {
MESH_DEBUG_PRINTLN("Prefs: refusing save while recovery is incomplete");
return false;
}
CommonPrefsFileStore store(fs);
const MQTTPrefsAtomicStore::ImageResult result = MQTTPrefsAtomicStore::writeImage(
store, [this](CommonPrefsFileStore& target) {
return writeCommonPrefsImage(target, _prefs);
});
if (!MQTTPrefsAtomicStore::imageCommitted(result)) {
MESH_DEBUG_PRINTLN("Prefs: atomic /com_prefs save failed at %s; previous image preserved",
commonPrefsSaveResultName(result));
return false;
}
return true;
}
static const char* mqttPrefsSaveResultName(MQTTPrefsAtomicStore::Result result) {
switch (result) {
case MQTTPrefsAtomicStore::Result::BeginFailed: return "begin";
case MQTTPrefsAtomicStore::Result::HeaderWriteFailed: return "header write";
case MQTTPrefsAtomicStore::Result::PayloadWriteFailed: return "payload write";
case MQTTPrefsAtomicStore::Result::FinishFailed: return "close";
case MQTTPrefsAtomicStore::Result::CommitFailed: return "rename";
case MQTTPrefsAtomicStore::Result::Committed: return "committed";
}
return "unknown";
}
void CommonCLI::loadMQTTPrefs(
FILESYSTEM* fs, MQTTPrefsAtomicStore::LegacyUpgradeGate* legacy_upgrade) {
setMQTTPrefsDefaults(&_mqtt_prefs);
// Complete or preserve an interrupted SPIFFS transaction before decoding.
// A failed recovery leaves the artifacts untouched and blocks this boot from
// replacing them with defaults through a later CLI save.
_mqtt_prefs_hold = recoverMqttPrefsFiles(fs);
// `/mqtt.json` was the authoritative observer-firmware-dev format. Import it
// exactly once, but only when binary recovery found no primary or recovery
// artifact at all. A valid, default-valued, corrupt, unreadable, or future
// binary image always wins because neither format carries a generation that
// could prove the JSON is newer.
const MQTTPrefsJsonImport::Decision json_import =
MQTTPrefsJsonImport::select(
_mqtt_prefs_hold,
fs->exists("/mqtt_prefs"), fs->exists("/mqtt_prefs.tmp"),
fs->exists("/mqtt_prefs.bak"), fs->exists("/mqtt.json"),
fs->exists("/mqtt.json.tmp"), fs->exists("/mqtt.json.bak"));
if (json_import == MQTTPrefsJsonImport::Decision::HoldJsonArtifacts) {
_mqtt_prefs_hold = true;
_legacy_tail.valid = false;
MESH_DEBUG_PRINTLN(
"MQTT: unresolved /mqtt.json transaction files; defaults active and all sources preserved");
return;
}
if (json_import == MQTTPrefsJsonImport::Decision::ImportPrimary) {
MQTTPrefs* candidate = new (std::nothrow) MQTTPrefs;
if (candidate == nullptr) {
_mqtt_prefs_hold = true;
_legacy_tail.valid = false;
MESH_DEBUG_PRINTLN(
"MQTT: no memory to import /mqtt.json; defaults active and source preserved");
return;
}
setMQTTPrefsDefaults(candidate);
const MQTTPrefsJsonImport::Result result =
loadMqttJsonImport(fs, _mqtt_prefs, candidate);
if (!MQTTPrefsJsonImport::loaded(result)) {
delete candidate;
_mqtt_prefs_hold = true;
_legacy_tail.valid = false;
MESH_DEBUG_PRINTLN(
"MQTT: /mqtt.json import refused (%s); defaults active and source preserved",
mqttJsonImportResultName(result));
return;
}
_mqtt_prefs = *candidate;
delete candidate;
legacy_upgrade->requireMqttRewrite();
if (saveMQTTPrefs(fs)) {
legacy_upgrade->recordMqttSave(true);
// JSON contains the full observer group. Returning prevents a stale
// /com_prefs observer tail from being overlaid after the committed import.
_legacy_tail.valid = false;
MESH_DEBUG_PRINTLN(
"MQTT: imported /mqtt.json v1 into atomic /mqtt_prefs (%s); JSON preserved",
mqttJsonImportResultName(result));
return;
}
// Do not expose a partially imported runtime state after a failed publish.
// The JSON remains untouched, and binary recovery owns any completed temp.
setMQTTPrefsDefaults(&_mqtt_prefs);
_mqtt_prefs_hold = true;
_legacy_tail.valid = false;
legacy_upgrade->recordMqttSave(false);
MESH_DEBUG_PRINTLN(
"MQTT: /mqtt.json decoded but binary commit failed; defaults active and sources preserved");
return;
}
bool has_observer_fields = false;
bool mqtt_rewrite_pending = false;
bool migrated_legacy_mqtt = false;
if (fs->exists("/mqtt_prefs")) {
File file = openMqttPrefsRead(fs);
if (file) {
const size_t file_size = file.size();
uint8_t prefix[sizeof(MQTTPrefsHeader)] = {};
const size_t prefix_size = file_size < sizeof(prefix) ? file_size : sizeof(prefix);
const size_t prefix_read = file.read(prefix, prefix_size);
file.close();
const MQTTPrefsCodec::DecodePlan plan =
MQTTPrefsCodec::classify(prefix, prefix_read, file_size);
if (plan.preserve_file) {
_mqtt_prefs_hold = true;
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs is unsupported or corrupt, using defaults (file preserved)");
} else if (plan.source == MQTTPrefsCodec::Source::Current) {
file = openMqttPrefsRead(fs);
MQTTPrefsHeader header;
if (!file || file.read((uint8_t *)&header, sizeof(header)) != sizeof(header) ||
file.read((uint8_t *)&_mqtt_prefs, plan.payload_len) != plan.payload_len) {
setMQTTPrefsDefaults(&_mqtt_prefs);
_mqtt_prefs_hold = true;
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs read failed, using defaults (file preserved)");
} else {
has_observer_fields = plan.observer_fields_present;
// Written by a later build with appended fields. Everything this
// binary knows loaded normally; say so, because the next `set` will
// rewrite the file at this length and drop the newer settings.
if (file_size - sizeof(MQTTPrefsHeader) > plan.payload_len) {
MESH_DEBUG_PRINTLN(
"MQTT: /mqtt_prefs written by newer firmware (%u > %u bytes); "
"config loaded, newer settings ignored and dropped on next save",
(unsigned)(file_size - sizeof(MQTTPrefsHeader)),
(unsigned)plan.payload_len);
}
}
if (file) file.close();
} else if (plan.rewrite_legacy) {
bool migrated = false;
file = openMqttPrefsRead(fs);
if (file) {
switch (plan.source) {
case MQTTPrefsCodec::Source::LegacyPreSlot: {
union {
OldMQTTPrefs post_wifi_power;
PreWifiPowerOldMQTTPrefs pre_wifi_power;
} old_prefs = {};
if (file.read((uint8_t *)&old_prefs, sizeof(old_prefs)) == sizeof(old_prefs)) {
if (MQTTPrefsCodec::isPlausibleLegacy(plan.source,
(const uint8_t *)&old_prefs, sizeof(old_prefs))) {
if (MQTTPrefsCodec::looksLikePreWifiPower((uint8_t *)&old_prefs, sizeof(old_prefs))) {
MQTTPrefsCodec::migratePreWifiPower(old_prefs.pre_wifi_power, &_mqtt_prefs);
} else {
MQTTPrefsCodec::migratePreSlot(old_prefs.post_wifi_power, &_mqtt_prefs);
}
migrated = true;
} else {
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs legacy content failed plausibility checks");
}
}
break;
}
case MQTTPrefsCodec::Source::LegacyThreeSlotBase: {
ThreeSlotBaseMQTTPrefs old_prefs = {};
if (file.read((uint8_t *)&old_prefs, sizeof(old_prefs)) == sizeof(old_prefs)) {
if (MQTTPrefsCodec::isPlausibleLegacy(plan.source,
(const uint8_t *)&old_prefs, sizeof(old_prefs))) {
MQTTPrefsCodec::migrateThreeSlot(old_prefs, &_mqtt_prefs);
migrated = true;
} else {
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs legacy content failed plausibility checks");
}
}
break;
}
case MQTTPrefsCodec::Source::LegacyThreeSlot: {
ThreeSlotMQTTPrefs old_prefs = {};
if (file.read((uint8_t *)&old_prefs, sizeof(old_prefs)) == sizeof(old_prefs)) {
if (MQTTPrefsCodec::isPlausibleLegacy(plan.source,
(const uint8_t *)&old_prefs, sizeof(old_prefs))) {
MQTTPrefsCodec::migrateThreeSlot(old_prefs, &_mqtt_prefs);
migrated = true;
} else {
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs legacy content failed plausibility checks");
}
}
break;
}
case MQTTPrefsCodec::Source::LegacySixSlotBase:
case MQTTPrefsCodec::Source::LegacySixSlotAudience:
case MQTTPrefsCodec::Source::LegacySixSlotAudienceRx:
case MQTTPrefsCodec::Source::LegacySixSlot: {
Legacy6SlotMQTTPrefs old_prefs = {};
if (file.read((uint8_t *)&old_prefs, plan.payload_len) == plan.payload_len) {
if (MQTTPrefsCodec::isPlausibleLegacy(plan.source,
(const uint8_t *)&old_prefs, plan.payload_len)) {
MQTTPrefsCodec::migrateLegacySixSlot(old_prefs, plan.source, &_mqtt_prefs);
migrated = true;
} else {
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs legacy content failed plausibility checks");
}
}
break;
}
default:
break;
}
file.close();
}
if (migrated) {
// Do not save yet: a legacy /com_prefs observer tail may still need
// to be overlaid below. Publish the complete v1 image once, after it.
mqtt_rewrite_pending = true;
migrated_legacy_mqtt = true;
} else {
setMQTTPrefsDefaults(&_mqtt_prefs);
_mqtt_prefs_hold = true;
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs legacy read failed, using defaults (file preserved)");
}
}
} else {
_mqtt_prefs_hold = true;
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs could not be opened, using defaults (file preserved)");
}
} else {
// No /mqtt_prefs file - defaults already set. (MQTT slot/WiFi settings from
// pre-/mqtt_prefs-split fork firmware are NOT recovered from /com_prefs - that
// offset-based migration was fragile and was removed; those users re-enter
// their MQTT config. The observer trailing block IS recovered, below.)
}
if (_legacy_tail.valid && !has_observer_fields) {
_mqtt_prefs.snmp_enabled = _legacy_tail.snmp_enabled;
memcpy(_mqtt_prefs.snmp_community, _legacy_tail.snmp_community, sizeof(_mqtt_prefs.snmp_community));
_mqtt_prefs.radio_watchdog_minutes = _legacy_tail.radio_watchdog_minutes;
_mqtt_prefs.alert_enabled = _legacy_tail.alert_enabled;
memcpy(_mqtt_prefs.alert_psk_hex, _legacy_tail.alert_psk_hex, sizeof(_mqtt_prefs.alert_psk_hex));
_mqtt_prefs.alert_wifi_minutes = _legacy_tail.alert_wifi_minutes;
_mqtt_prefs.alert_mqtt_minutes = _legacy_tail.alert_mqtt_minutes;
_mqtt_prefs.alert_min_interval_min = _legacy_tail.alert_min_interval_min;
memcpy(_mqtt_prefs.alert_hashtag, _legacy_tail.alert_hashtag, sizeof(_mqtt_prefs.alert_hashtag));
memcpy(_mqtt_prefs.alert_region, _legacy_tail.alert_region, sizeof(_mqtt_prefs.alert_region));
mqtt_rewrite_pending = true;
MESH_DEBUG_PRINTLN("MQTT: Migrated observer settings from legacy /com_prefs trailing block");
}
// Keep persisted values inside the signed-delta millis() scheduling window.
// This also repairs any manually-written or experimental value from firmware
// that briefly accepted intervals longer than the supported two-week cap.
if (_mqtt_prefs.mqtt_neighbors_interval < MQTT_NEIGHBORS_MIN_INTERVAL_MS ||
_mqtt_prefs.mqtt_neighbors_interval > MQTT_NEIGHBORS_MAX_INTERVAL_MS) {
_mqtt_prefs.mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS;
// Persist the repair so a corrupt flash value is not re-clamped every boot.
// Skip when hold is set so we never overwrite a deliberately preserved file.
if (!_mqtt_prefs_hold) {
mqtt_rewrite_pending = true;
}
MESH_DEBUG_PRINTLN("MQTT: invalid neighbors interval reset to %u hours",
(unsigned)MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS);
}
if (MQTTPrefsCodec::repairDisplayPrefs(&_mqtt_prefs)) {
// Old payloads retain valid defaults because the object was defaulted before
// their shorter prefix was copied. This branch repairs only a full/newer
// payload carrying invalid display values.
if (!_mqtt_prefs_hold) {
mqtt_rewrite_pending = true;
}
MESH_DEBUG_PRINTLN("MQTT: invalid display preferences reset to defaults");
}
_legacy_tail.valid = false;
if (mqtt_rewrite_pending) {
legacy_upgrade->requireMqttRewrite();
if (migrated_legacy_mqtt) {
MESH_DEBUG_PRINTLN("MQTT: Migrating headerless /mqtt_prefs to versioned layout");
} else {
MESH_DEBUG_PRINTLN("MQTT: Persisting observer tail into /mqtt_prefs before /com_prefs compaction");
}
if (saveMQTTPrefs(fs)) {
legacy_upgrade->recordMqttSave(true);
} else {
// The legacy source(s) remain intact because the failed transaction never
// published its temp file. Hold this boot so loadPrefs leaves /com_prefs
// untouched; the next boot can recover the tail and retry the transaction.
_mqtt_prefs_hold = true;
legacy_upgrade->recordMqttSave(false);
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs migration save failed; legacy files preserved and held");
}
}
}
bool CommonCLI::saveMQTTPrefs(FILESYSTEM* fs) {
if (_mqtt_prefs_hold) {
// Loading deliberately preserved the source file. Do not replace it with this
// boot's defaults after an unsupported, corrupt, or temporarily failed read.
MESH_DEBUG_PRINTLN("MQTT: /mqtt_prefs held, not overwriting");
return false;
}
// Write header and payload sequentially so the transaction needs no second
// full-size (2.8 KiB) staging buffer on constrained targets. The length is
// the shortest that still round-trips this config, so a node with default
// packet filters keeps writing a payload older firmware can read.
const size_t payload_len = MQTTPrefsCodec::payloadLenFor(_mqtt_prefs);
const MQTTPrefsHeader header = MQTTPrefsCodec::makeHeader(payload_len);
MQTTPrefsFileStore store(fs);
const MQTTPrefsAtomicStore::Result result = MQTTPrefsAtomicStore::write(
store, (const uint8_t *)&header, sizeof(header),
(const uint8_t *)&_mqtt_prefs, payload_len);
if (!MQTTPrefsAtomicStore::committed(result)) {
MESH_DEBUG_PRINTLN("MQTT: atomic /mqtt_prefs save failed at %s; source preserved",
mqttPrefsSaveResultName(result));
return false;
}
return true;
}
#endif
#define MIN_LOCAL_ADVERT_INTERVAL 60
void CommonCLI::savePrefs(PrefsSaveRouting::Scope scope) {
if (!trySavePrefs(scope)) {
++_prefs_save_failures;
if (PrefsSaveRouting::planFor(scope).common
&& (!_common_save_result_known || !_common_save_succeeded)) {
_prefs->markUnsaved();
}
}
}
bool CommonCLI::trySavePrefs(PrefsSaveRouting::Scope scope) {
const PrefsSaveRouting::Plan plan = PrefsSaveRouting::planFor(scope);
const uint8_t old_advert_interval = _prefs->advert_interval;
if (plan.common && _prefs->advert_interval * 2 < MIN_LOCAL_ADVERT_INTERVAL) {
_prefs->advert_interval = 0; // turn it off, now that device has been manually configured
}
// A previous successful transaction must not mask a callback that did not
// save this time. Both images have independent commit results.
if (plan.common) {
_common_save_result_known = false;
_common_save_succeeded = false;
}
#ifdef WITH_MQTT_BRIDGE
if (plan.observer) {
_observer_save_result_known = false;
_observer_save_succeeded = false;
}
#endif
_callbacks->savePrefs(scope);
const bool common_saved = !plan.common
|| (_common_save_result_known && _common_save_succeeded);
if (plan.common) {
if (common_saved) {
_prefs->clearDirty();
if (old_advert_interval != _prefs->advert_interval) {
_callbacks->updateAdvertTimer();
}
} else {
_prefs->advert_interval = old_advert_interval;
}
}
#ifdef WITH_MQTT_BRIDGE
return common_saved && (!plan.observer
|| (_observer_save_result_known && _observer_save_succeeded));
#else
return common_saved;
#endif
}
bool CommonCLI::saveObserverPrefs() {
#ifdef WITH_MQTT_BRIDGE
_observer_save_result_known = false;
_observer_save_succeeded = false;
_callbacks->savePrefs(PrefsSaveRouting::Scope::Observer);
const bool saved = _observer_save_result_known && _observer_save_succeeded;
if (!saved) ++_prefs_save_failures;
return saved;
#else
return false;
#endif
}
bool CommonCLI::saveCommonPrefs() {
_common_save_result_known = false;
_common_save_succeeded = false;
_callbacks->savePrefs(PrefsSaveRouting::Scope::Common);
return _common_save_result_known && _common_save_succeeded;
}
bool CommonCLI::savePrimaryRadioParams(float freq, float bw, uint8_t sf,
uint8_t cr, uint16_t preamble) {
if (!isfinite(freq) || !isfinite(bw) || freq < 150.0f || freq > 2500.0f
|| sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw)
|| !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) return false;
const float old_freq = _prefs->freq, old_bw = _prefs->bw;
const uint8_t old_sf = _prefs->sf, old_cr = _prefs->cr;
const int8_t old_power = _prefs->tx_power_dbm;
const uint32_t old_rx = _prefs->rx_ps_rx_us, old_sleep = _prefs->rx_ps_sleep_us;
const uint16_t old_preamble = _prefs->primary_radio_preamble;
_prefs->freq = freq; _prefs->bw = bw; _prefs->sf = sf; _prefs->cr = cr;
_prefs->primary_radio_preamble = preamble;
_prefs->tx_power_dbm = mesh::clampLoRaTxPower(old_power, freq);
recalculateRxPowerSavingFromLevel(_prefs);
if (!saveCommonPrefs()) {
_prefs->freq = old_freq; _prefs->bw = old_bw;
_prefs->sf = old_sf; _prefs->cr = old_cr;
_prefs->tx_power_dbm = old_power;
_prefs->rx_ps_rx_us = old_rx; _prefs->rx_ps_sleep_us = old_sleep;
_prefs->primary_radio_preamble = old_preamble;
return false;
}
_radio_profiles.adoptPrimaryPreamble(preamble);
return true;
}
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);
return builder.encodeTo(app_data);
} else if (_prefs->advert_loc_policy == ADVERT_LOC_SHARE) {
AdvertDataBuilder builder(node_type, _prefs->node_name, _sensors->node_lat, _sensors->node_lon);
return builder.encodeTo(app_data);
} else {
AdvertDataBuilder builder(node_type, _prefs->node_name, _prefs->node_lat, _prefs->node_lon);
return builder.encodeTo(app_data);
}
}
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply);
mesh::cli::normalizeCommandVerb(command);
if (mesh::handleUsbLoggingWatchdogCommand(command, reply, 160)) return;
if (handleManagementCommand(command, reply)) return;
if (mesh::wireless::control().handle(command, reply, 160, millis(),
_callbacks->wirelessCommandSource(sender_timestamp))) return;
if (_radio_profiles.handle(command, reply, 160, sender_timestamp != 0)) return;
#if defined(ENABLE_OTA)
const auto* ota_context = mesh::ota::ota_context_if_active();
const float adaptive_ota_pace = ota_context
? ota_context->manager.adaptivePacketSpeed() : mesh::ota::OTA_SPEED_DEFAULT;
if (mesh::ota::handleSpeedCommand(command, reply, 160, adaptive_ota_pace)) return;
#endif
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)
// live in CommonCLI_Observer.cpp.
if (handleObserverCommand(sender_timestamp, command, reply)) return;
if (mesh::cli::handleFloodAdvertGet(_callbacks->getFloodAdvertLimiter(), command, reply, millis())) return;
if (mesh::cli::handleFloodAdvertClear(_callbacks->getFloodAdvertLimiter(), command, reply)) return;
if (memcmp(command, "poweroff", 8) == 0 || memcmp(command, "shutdown", 8) == 0) {
_board->powerOff(); // doesn't return
} else if (memcmp(command, "reboot", 6) == 0) {
_board->reboot(); // doesn't return
} else if (sender_timestamp == 0
&& mesh::cli::isUf2ResetCommand(command)) {
if (!_board->rebootToUf2Bootloader()) {
strcpy(reply, "ERR: unsupported");
}
} else if (memcmp(command, "clkreboot", 9) == 0) {
// Reset clock
getRTCClock()->setCurrentTime(1715770351); // 15 May 2024, 8:50pm
_board->reboot(); // doesn't return
} else if (memcmp(command, "advert.zerohop", 14) == 0 && (command[14] == 0 || command[14] == ' ')) {
// send zerohop advert
_callbacks->sendSelfAdvertisement(1500, false); // longer delay, give CLI response time to be sent first
strcpy(reply, "OK - zerohop advert sent");
} else if (memcmp(command, "advert", 6) == 0) {
// send flood advert
_callbacks->sendSelfAdvertisement(1500, true); // longer delay, give CLI response time to be sent first
strcpy(reply, "OK - Advert sent");
} else if (memcmp(command, "clock sync", 10) == 0) {
uint32_t curr = getRTCClock()->getCurrentTime();
if (sender_timestamp > curr) {
getRTCClock()->setCurrentTime(sender_timestamp + 1);
_callbacks->onManualClockSet();
uint32_t now = getRTCClock()->getCurrentTime();
DateTime dt = DateTime(now);
sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
} else {
strcpy(reply, "ERR: clock cannot go backwards");
}
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
} else if (memcmp(command, "start webconfig", 15) == 0
&& (command[15] == 0 || command[15] == ' ')) {
const bool force_ap = command[15] == ' ' && strcmp(&command[16], "ap") == 0;
if (command[15] == ' ' && !force_ap) {
strcpy(reply, "ERR: usage start webconfig [ap]");
} else if (!_callbacks->startWebConfig(force_ap, reply)) {
strcpy(reply, "ERR: webconfig not supported on this build");
}
} else if (strcmp(command, "stop webconfig") == 0) {
if (!_callbacks->stopWebConfig(reply)) {
strcpy(reply, "ERR: webconfig not supported on this build");
}
#endif
} else if (memcmp(command, "memory", 6) == 0) {
#ifdef ESP_PLATFORM
sprintf(reply, "Free: %d, Min: %d, Max: %d, Queue: %d, IntFree: %d, IntMax: %d, PSRAM: %d/%d",
ESP.getFreeHeap(), ESP.getMinFreeHeap(), ESP.getMaxAllocHeap(),
_callbacks->getQueueSize(),
(int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
(int)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
(int)heap_caps_get_free_size(MALLOC_CAP_SPIRAM),
(int)heap_caps_get_total_size(MALLOC_CAP_SPIRAM));
#else
struct mallinfo mi = mallinfo();
sprintf(reply, "Heap: free=%d, used=%d", (int)mi.fordblks, (int)mi.uordblks);
#endif
} else if (memcmp(command, "start ota", 9) == 0 && (command[9] == 0 || command[9] == ' ')) {
// Manual OTA: bring up the board's browser server for a hand-uploaded binary.
const bool force_ap = command[9] == ' ' && strcmp(&command[10], "ap") == 0;
if (command[9] == ' ' && !force_ap) {
strcpy(reply, "ERR: usage start ota [ap]");
return;
}
bool webconfig_stopped = false;
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
if (_callbacks->isWebConfigActive()) {
if (!_callbacks->stopWebConfigForOTA(reply)) {
if (!reply[0]) strcpy(reply, "ERR: could not stop WebConfig for OTA");
return;
}
webconfig_stopped = true;
}
#endif
#if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
// Browser OTA owns the shared WiFi driver. An independent UART bridge
// can keep running throughout the upload.
const bool espnow_paused = _callbacks->isEspNowBridgeRunning();
if (espnow_paused && !_callbacks->setEspNowBridgeState(false)) {
strcpy(reply, "ERR: could not pause ESP-NOW for OTA");
return;
}
#endif
reply[0] = 0;
if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap)) {
if (!reply[0]) strcpy(reply, "Error");
#if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
if (espnow_paused && !_callbacks->setEspNowBridgeState(true)) {
const size_t used = strlen(reply);
snprintf(reply + used, 160 - used, "; ESP-NOW resume failed");
}
#endif
}
#if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA)
else {
// Keep WiFi up, but release MQTT/TLS heap while the browser uploader runs.
_callbacks->setBridgeState(false);
}
#endif
#if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
else if (espnow_paused) {
const size_t used = strlen(reply);
snprintf(reply + used, 160 - used, "; ESP-NOW paused");
}
#endif
if (webconfig_stopped) {
const size_t used = strlen(reply);
snprintf(reply + used, 160 - used, "; WebConfig stopped");
}
} else if (memcmp(command, "stop ota", 8) == 0 && (command[8] == 0 || command[8] == ' ')) {
if (!_board->stopOTAUpdate(reply)) {
if (!reply[0]) strcpy(reply, "Error");
}
#if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA)
else if (_prefs->bridge_enabled
#ifdef WITH_ESPNOW_BRIDGE
|| _prefs->espnow_bridge_enabled
#endif
) {
_callbacks->setBridgeState(true);
}
#endif
#if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
else if (
#if defined(WITH_RS232_BRIDGE) || defined(ESPNOW_BRIDGE_MERGED)
_prefs->espnow_bridge_enabled
#else
_prefs->bridge_enabled
#endif
) {
const bool resumed = _callbacks->setEspNowBridgeState(true);
const size_t used = strlen(reply);
snprintf(reply + used, 160 - used, resumed
? "; ESP-NOW resumed" : "; ESP-NOW resume failed");
}
#endif
} else if (memcmp(command, "clock", 5) == 0) {
uint32_t now = getRTCClock()->getCurrentTime();
DateTime dt = DateTime(now);
sprintf(reply, "%02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
} else if (memcmp(command, "time ", 5) == 0) { // set time (to epoch seconds)
uint32_t secs = _atoi(&command[5]);
uint32_t curr = getRTCClock()->getCurrentTime();
if (secs > curr) {
getRTCClock()->setCurrentTime(secs);
_callbacks->onManualClockSet();
uint32_t now = getRTCClock()->getCurrentTime();
DateTime dt = DateTime(now);
sprintf(reply, "OK - clock set: %02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
} else {
strcpy(reply, "(ERR: clock cannot go backwards)");
}
} else if (memcmp(command, "neighbors", 9) == 0) {
_callbacks->formatNeighborsReply(reply);
} else if (strncmp(command, "neighbor.remove", 15) == 0
&& (command[15] == 0 || command[15] == ' ' || command[15] == '\t')) {
const char* hex = &command[15];
while (*hex == ' ' || *hex == '\t' || *hex == '\r' || *hex == '\n') hex++;
uint8_t pubkey[PUB_KEY_SIZE];
size_t hex_len = strlen(hex);
int pubkey_len = (int)(hex_len / 2);
if (hex_len == 0) {
// Mobile clients clear all neighbors with an empty prefix. The
// repeater's command normalization may already have trimmed its space.
_callbacks->removeNeighbor(NULL, 0);
strcpy(reply, "OK");
} else if (hex_len <= PUB_KEY_SIZE * 2 && (hex_len & 1) == 0
&& mesh::Utils::fromHex(pubkey, pubkey_len, hex)) {
_callbacks->removeNeighbor(pubkey, pubkey_len);
strcpy(reply, "OK");
} else {
strcpy(reply, "ERR: bad pubkey");
}
} else if (strcmp(command, "normalradio") == 0) {
if (_callbacks->scheduleNormalRadio()) {
strcpy(reply, "OK - normal radio restore scheduled");
} else {
strcpy(reply, "Error: unsupported");
}
} else if (memcmp(command, "tempradio ", 10) == 0) {
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;
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
+ (uint64_t)temp_timeout_mins * 60 <= UINT32_MAX
&& mesh::cli::parseDecimalStrict(parts[0], freq)
&& mesh::cli::parseDecimalStrict(parts[1], bw)
&& freq >= 150.0f && freq <= 2500.0f
&& sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8
&& 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);
snprintf(reply, 160, "OK - temp params for %s", duration);
_radio_profiles.appendPrimaryChirpWarning(reply, 160, sf, bw, preamble);
_callbacks->appendTempRadioTimingNote(reply, 160, seconds);
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
} else {
strcpy(reply, "Error, invalid params");
}
} else if (memcmp(command, "password ", 9) == 0) {
// change admin password
char previous[sizeof(_prefs->password)];
memcpy(previous, _prefs->password, sizeof(previous));
StrHelper::strncpy(_prefs->password, &command[9], sizeof(_prefs->password));
if (!trySavePrefs()) {
memcpy(_prefs->password, previous, sizeof(previous));
strcpy(reply, "Error: password not saved; unchanged");
return;
}
sprintf(reply, "password now: %s", _prefs->password); // echo back just to let admin know for sure!!
} else if (memcmp(command, "clear stats", 11) == 0) {
_callbacks->clearStats();
strcpy(reply, "(OK - stats reset)");
} else if (memcmp(command, "clear recent.repeater", 21) == 0 && (command[21] == 0 || command[21] == ' ')) {
if (_callbacks->supportsAdvancedRetryConfig()) {
_callbacks->clearRecentRepeaters();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, unsupported on this role");
}
} else if (memcmp(command, "get ", 4) == 0) {
handleGetCmd(sender_timestamp, command, reply);
} else if (memcmp(command, "set ", 4) == 0) {
handleSetCmd(sender_timestamp, command, reply);
} else if (memcmp(command, "del ", 4) == 0) {
handleDelCmd(command, reply);
} else if (sender_timestamp == 0 && strcmp(command, "erase") == 0) {
bool s = _callbacks->formatFileSystem();
sprintf(reply, "File system erase: %s", s ? "OK" : "Err");
} else if (memcmp(command, "ver", 3) == 0) {
sprintf(reply, "%s (Build: %s)", _callbacks->getFirmwareVer(), _callbacks->getBuildDate());
} else if (memcmp(command, "board", 5) == 0) {
sprintf(reply, "%s", _board->getManufacturerName());
#if defined(ENABLE_OTA)
} else if (memcmp(command, "ota", 3) == 0 && (command[3] == 0 || command[3] == ' ')) {
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 {
if (!mesh::ota::ota_acquire_context(reply, 160)) return;
const auto previous = mesh::ota::OtaConfigState::capture(mesh::ota::ota_ctx());
mesh::ota::handle_ota_command(command, reply, *_board);
if (mesh::ota::ota_context_if_active()
&& mesh::ota::ota_ctx().config_dirty) { // a policy/key changed via the CLI -> persist it
mesh::ota::OtaContext& c = mesh::ota::ota_ctx();
const auto copy_policy = [this, &c]() {
_prefs->ota_autofetch = c.manager.autofetch();
_prefs->ota_checkpoint_blocks = c.manager.checkpoint_blocks();
_prefs->ota_advert_interval = c.manager.advert_mins();
_prefs->ota_max_hops = c.manager.max_hops();
_prefs->ota_autoinstall = c.autoinstall;
_prefs->ota_signer_count = c.allow.count();
memset(_prefs->ota_signers, 0, sizeof(_prefs->ota_signers));
for (uint8_t i = 0; i < c.allow.count() && i < MAX_OTA_SIGNERS; i++)
memcpy(_prefs->ota_signers[i], c.allow.get(i), 32);
};
copy_policy();
if (saveCommonPrefs()) {
c.config_dirty = false;
} else {
previous.apply(c);
copy_policy();
strcpy(reply, "ERR OTA configuration could not be saved");
}
}
}
#else
} else if (memcmp(command, "ota", 3) == 0 && (command[3] == 0 || command[3] == ' ')) {
strcpy(reply, "LoRa OTA is not included in this build; tempradio cannot enable it. Use an OTA-enabled repeater firmware");
#endif
} else if (strcmp(command, "power") == 0) {
const char* usb_state = _board->hasUsbPowerDetect()
? (_board->isUsbPowered() ? "yes" : "no")
: "n/a";
const char* solar_state = _board->hasSolarChargerDetect()
? (_board->isSolarChargerActive() ? "yes" : "no")
: "n/a";
snprintf(reply, 160, "batt:%u mV usb:%s solar_chg:%s ext:%s charger:%s",
(unsigned)_board->getBattMilliVolts(),
usb_state,
solar_state,
_board->isExternalPowered() ? "yes" : "no",
_board->isChargerActive() ? "yes" : "no");
} else if (memcmp(command, "sensor get ", 11) == 0) {
const char* key = command + 11;
const char* val = _sensors->getSettingByKey(key);
if (val != NULL) {
sprintf(reply, "> %s", val);
} else {
strcpy(reply, "null");
}
} else if (memcmp(command, "sensor set ", 11) == 0) {
strcpy(tmp, &command[11]);
const char *parts[2];
int num = mesh::Utils::parseTextParts(tmp, parts, 2, ' ');
const char *key = (num > 0) ? parts[0] : "";
const char *value = (num > 1) ? parts[1] : "null";
uint32_t gps_interval = 0;
const bool is_gps_interval = strcmp(key, "gps_interval") == 0;
const bool is_gps_toggle = strcmp(key, "gps") == 0;
const bool valid_gps_interval = !is_gps_interval
|| (parseUint32Strict(value, gps_interval)
&& gps_interval <= mesh::gps::MAX_UPDATE_INTERVAL_SEC);
const bool valid_gps_toggle = !is_gps_toggle
|| strcmp(value, "0") == 0 || strcmp(value, "1") == 0;
if (!valid_gps_interval) {
strcpy(reply, "gps_interval must be 0..86400 seconds");
} else if (!valid_gps_toggle) {
strcpy(reply, "gps must be 0 or 1");
}
else if (_sensors->setSettingValue(key, value)) {
if (is_gps_interval) {
_prefs->gps_interval = gps_interval;
savePrefs();
} else if (is_gps_toggle) {
_prefs->gps_enabled = strcmp(value, "1") == 0 ? 1 : 0;
savePrefs();
}
#ifdef WITH_RS232_BRIDGE
if (is_gps_toggle && strcmp(value, "1") == 0
&& _callbacks->isBridgeRunning()
&& _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) {
strcpy(reply, "saved; UART GPS paused while bridge is enabled");
} else
#endif
{
strcpy(reply, "ok");
}
} else {
strcpy(reply, "can't find custom var");
}
} else if (memcmp(command, "sensor list", 11) == 0) {
char* dp = reply;
int start = 0;
int end = _sensors->getNumSettings();
if (strlen(command) > 11) {
start = _atoi(command+12);
}
if (start >= end) {
strcpy(reply, "no custom var");
} else {
sprintf(dp, "%d vars\n", end);
dp = strchr(dp, 0);
int i;
for (i = start; i < end && (dp-reply < 134); i++) {
sprintf(dp, "%s=%s\n",
_sensors->getSettingName(i),
_sensors->getSettingValue(i));
dp = strchr(dp, 0);
}
if (i < end) {
sprintf(dp, "... next:%d", i);
} else {
*(dp-1) = 0; // remove last CR
}
}
} else if (memcmp(command, "region", 6) == 0) {
handleRegionCmd(command, reply);
#if ENV_INCLUDE_GPS == 1
} else if (memcmp(command, "gps on", 6) == 0) {
if (_sensors->setSettingValue("gps", "1")) {
_prefs->gps_enabled = 1;
savePrefs();
if (_callbacks->isBridgeRunning()
#ifdef WITH_RS232_BRIDGE
&& _sensors->gpsUsesSerialUart(_prefs->bridge_uart)
#else
&& false
#endif
) {
strcpy(reply, "saved; UART GPS paused while bridge is enabled");
} else if (_prefs->powersaving_enabled) { // Power Saving
strcpy(reply, "on (powersaving)");
} else { // Normal mode
strcpy(reply, "ok");
}
} else {
strcpy(reply, "gps toggle not found");
}
} else if (memcmp(command, "gps off", 7) == 0) {
if (_sensors->setSettingValue("gps", "0")) {
_prefs->gps_enabled = 0;
savePrefs();
strcpy(reply, "ok");
} else {
strcpy(reply, "gps toggle not found");
}
} else if (memcmp(command, "gps sync", 8) == 0) {
LocationProvider * l = _sensors->getLocationProvider();
if (!_prefs->gps_enabled) {
strcpy(reply, "gps is off");
#ifdef WITH_RS232_BRIDGE
} else if (_callbacks->isBridgeRunning()
&& _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) {
strcpy(reply, "gps paused by RS232 bridge");
#endif
} else if (l != NULL) {
l->syncTime();
strcpy(reply, "scheduled");
} else {
strcpy(reply, "gps provider not found");
}
} else if (strcmp(command, "gps diag") == 0) {
LocationProvider * l = _sensors->getLocationProvider();
if (l != NULL) {
const char* setting = _sensors->getSettingByKey("gps");
bool requested = setting != NULL && strcmp(setting, "1") == 0;
int prefix_len = snprintf(reply, 160, "req:%u ", requested ? 1U : 0U);
if (prefix_len < 0 || prefix_len >= 160) {
reply[159] = 0;
} else {
l->formatDiagnostics(reply + prefix_len, 160 - prefix_len);
}
} else {
strcpy(reply, "gps provider not found");
}
} else if (memcmp(command, "gps setloc", 10) == 0) {
_prefs->node_lat = _sensors->node_lat;
_prefs->node_lon = _sensors->node_lon;
savePrefs();
strcpy(reply, "ok");
} else if (memcmp(command, "gps advert", 10) == 0) {
if (strlen(command) == 10) {
switch (_prefs->advert_loc_policy) {
case ADVERT_LOC_NONE:
strcpy(reply, "> none");
break;
case ADVERT_LOC_PREFS:
strcpy(reply, "> prefs");
break;
case ADVERT_LOC_SHARE:
strcpy(reply, "> share");
break;
default:
strcpy(reply, "error");
}
} else if (memcmp(command+11, "none", 4) == 0) {
_prefs->advert_loc_policy = ADVERT_LOC_NONE;
savePrefs();
strcpy(reply, "ok");
} else if (memcmp(command+11, "share", 5) == 0) {
_prefs->advert_loc_policy = ADVERT_LOC_SHARE;
savePrefs();
strcpy(reply, "ok");
} else if (memcmp(command+11, "prefs", 5) == 0) {
_prefs->advert_loc_policy = ADVERT_LOC_PREFS;
savePrefs();
strcpy(reply, "ok");
} else {
strcpy(reply, "error");
}
} else if (memcmp(command, "gps", 3) == 0) {
LocationProvider * l = _sensors->getLocationProvider();
if (l != NULL) {
bool enabled = l->isEnabled(); // is EN pin on ?
bool fix = l->isValid(); // has fix ?
int sats = l->satellitesCount();
const char* gps_setting = _sensors->getSettingByKey("gps");
bool active = gps_setting != NULL && strcmp(gps_setting, "1") == 0;
if (!active) {
snprintf(reply, 160, "off, %s", enabled ? "powered" : "unpowered");
} else if (_prefs->powersaving_enabled && l->getGPSPowerSaving()) { // GPS Power Saving
unsigned long now = millis();
unsigned long next_off = l->getNextGPSOff();
unsigned long deadline = next_off != 0 ? next_off : l->getNextGPSOn();
long remaining_ms = deadline == 0 ? 0 : (long)(deadline - now);
unsigned long mins = remaining_ms > 0 ? (unsigned long)remaining_ms / 60000UL : 0;
if (next_off != 0) {
snprintf(reply, 160, "on, %s (powersaving, sleep in %luh %lum), %s, %d sats",
enabled ? "powered" : "unpowered", mins / 60UL, mins % 60UL,
fix ? "fix" : "no fix", sats);
} else {
snprintf(reply, 160, "on, %s (powersaving, wake in %luh %lum)",
enabled ? "powered" : "unpowered", mins / 60UL, mins % 60UL);
}
// "last sync" from GPS
unsigned long last_sync = l->getLastValidTimeSync();
size_t used = strlen(reply);
if (last_sync == 0) {
snprintf(reply + used, 160 - used, ", last sync: none");
} else {
DateTime dt = DateTime(last_sync);
snprintf(reply + used, 160 - used, ", last sync: %02d:%02d - %d/%d/%d UTC",
dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
}
} else { // Normal mode
snprintf(reply, 160, "on, %s, %s, %d sats",
enabled ? "powered" : "unpowered",
fix ? "fix" : "no fix", sats);
}
} else {
strcpy(reply, "Can't find GPS");
}
#endif
} else if (strcmp(command, "powersaving on") == 0 || strcmp(command, "powersaving off") == 0) {
char canonical[24];
snprintf(canonical, sizeof(canonical), "set %s", command);
handleSetCmd(sender_timestamp, canonical, reply);
} else if (strcmp(command, "powersaving") == 0) {
char canonical[] = "get powersaving";
handleGetCmd(sender_timestamp, canonical, reply);
} else if (memcmp(command, "sensor", 6) == 0) {
// I2C
#if ENV_HAS_SECONDARY_I2C
sprintf(reply, "I2C Wire1: SDA=%s,SCL=%s\r\n", STR(ENV_PIN_SDA), STR(ENV_PIN_SCL));
#elif defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL)
sprintf(reply, "I2C Wire: SDA=%s, SCL=%s\r\n", STR(PIN_BOARD_SDA), STR(PIN_BOARD_SCL));
#elif defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL)
sprintf(reply, "I2C Wire: SDA=%s, SCL=%s\r\n", STR(PIN_WIRE_SDA), STR(PIN_WIRE_SCL));
#else
sprintf(reply, "I2C GPIOs not defined\r\n");
#endif
// GPS
#if defined(PIN_GPS_RX) && defined(PIN_GPS_TX)
sprintf(reply + strlen(reply), "GPS Serial: RX=%s, TX=%s", STR(PIN_GPS_RX), STR(PIN_GPS_TX));
#if defined(ENV_INCLUDE_GPS) && ENV_INCLUDE_GPS > 0
sprintf(reply + strlen(reply), ". Configured");
#else
sprintf(reply + strlen(reply), ". Not configured");
#endif
#else
sprintf(reply + strlen(reply), "GPS Serial not defined");
#endif
} else if (memcmp(command, "powerlog", 8) == 0) {
sprintf(reply, "Last reset reason: %s", _board->getResetReasonString(_board->getResetReason()));
#if defined(NRF52_PLATFORM)
sprintf(reply + strlen(reply), "\r\nLast shutdown reason: %s",
_board->getShutdownReasonString(_board->getShutdownReason()));
sprintf(reply + strlen(reply), "\r\nLast boot voltage: %u mV", _board->getBootVoltage());
#endif
} else if (memcmp(command, "log start", 9) == 0) {
_callbacks->setLoggingOn(true);
strcpy(reply, " logging on");
} else if (memcmp(command, "log stop", 8) == 0) {
_callbacks->setLoggingOn(false);
strcpy(reply, " logging off");
} else if (memcmp(command, "log erase", 9) == 0) {
_callbacks->eraseLogFile();
strcpy(reply, " log erased");
} else if (sender_timestamp == 0 && memcmp(command, "log", 3) == 0) {
_callbacks->dumpLogFile();
strcpy(reply, " EOF");
} else if (sender_timestamp == 0 && memcmp(command, "stats-packets", 13) == 0 && (command[13] == 0 || command[13] == ' ')) {
_callbacks->formatPacketStatsReply(reply);
} else if (sender_timestamp == 0 && memcmp(command, "stats-radio-diag", 16) == 0 && (command[16] == 0 || command[16] == ' ')) {
_callbacks->formatRadioDiagReply(reply);
} else if (sender_timestamp == 0 && memcmp(command, "stats-radio", 11) == 0 && (command[11] == 0 || command[11] == ' ')) {
_callbacks->formatRadioStatsReply(reply);
} else if (sender_timestamp == 0 && memcmp(command, "stats-core", 10) == 0 && (command[10] == 0 || command[10] == ' ')) {
_callbacks->formatStatsReply(reply);
} else if (_board->handleCommand(command, sender_timestamp, reply)) {
if (_prefs->isDirty()) savePrefs();
} else {
strcpy(reply, "Unknown command");
}
}
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
bool CommonCLI::handleSdCardSetCmd(const char* config, char* reply) {
if (strncmp(config, "sdcard", 6) != 0 ||
(config[6] != 0 && config[6] != ' ')) {
return false;
}
const char* action_args = skipSpacesConst(config + 6);
bool is_format;
bool force = false;
if (strcmp(action_args, "format") == 0) {
is_format = true;
} else if (strcmp(action_args, "format --force") == 0) {
is_format = true;
force = true;
} else if (strcmp(action_args, "erase") == 0) {
is_format = false;
} else if (strcmp(action_args, "erase --force") == 0) {
is_format = false;
force = true;
} else {
strcpy(reply, "Error: use set sdcard format|erase [--force]");
return true;
}
const bool recorded = is_format ? _sdcard_format_recorded : _sdcard_erase_recorded;
const uint32_t ran_at = is_format ? _sdcard_format_at : _sdcard_erase_at;
const char* action = is_format ? "format" : "erase";
const uint32_t now = millis();
if (!force && recorded && now - ran_at < SDCARD_ACTION_COOLDOWN_MILLIS) {
char age[32];
formatSdCardElapsed(age, sizeof(age), true, ran_at, now);
snprintf(reply, 160,
"Error: SD card %s ran %s; use set sdcard %s --force",
action, age, action);
return true;
}
mesh::ota::OtaContext& context = mesh::ota::ota_ctx();
if (context.apply_pending) {
strcpy(reply, "Error: OTA apply is pending");
return true;
}
#if defined(OTA_TOWER_AUTO_STORE)
// Card maintenance must never abandon a fallback update stored internally.
if (context.fetch_store.usesInternal() &&
(context.manager.fetchState() != mesh::ota::OtaManager::IDLE ||
context.fetch_store.staged_size() != 0)) {
strcpy(reply, "Error: internal OTA slot is occupied; use ota cancel first");
return true;
}
#endif
mesh::ota::OtaStoreSdNrf52& card = context.sdStagingStore();
// A card-wide destructive operation invalidates any staged fetch. First let
// an archive capture checkpoint and close its shared card handles.
context.prepareSdCardReset();
context.manager.reset_session();
context.manager.want(0);
context.manager.want_mid(nullptr);
context.session_started_ms = 0;
context.prev_fstate = mesh::ota::OtaManager::IDLE;
if (is_format) {
if (!card.formatCard(*_board)) {
char error[80];
strncpy(error, card.last_error(), sizeof(error) - 1);
error[sizeof(error) - 1] = 0;
context.finishSdCardReset(millis());
snprintf(reply, 160, "Error: SD card format failed: %s",
error);
return true;
}
context.finishSdCardReset(millis());
_sdcard_format_recorded = true;
_sdcard_format_at = millis();
strcpy(reply, "OK - SD card format complete");
return true;
}
if (!card.eraseCard(*_board)) {
char error[80];
strncpy(error, card.last_error(), sizeof(error) - 1);
error[sizeof(error) - 1] = 0;
context.finishSdCardReset(millis());
snprintf(reply, 160, "Error: SD card erase failed: %s",
error);
return true;
}
_sdcard_erase_recorded = true;
_sdcard_erase_at = millis();
if (!card.formatCard(*_board)) {
char error[80];
strncpy(error, card.last_error(), sizeof(error) - 1);
error[sizeof(error) - 1] = 0;
context.finishSdCardReset(millis());
snprintf(reply, 160, "Error: SD card erased but format failed: %s",
error);
return true;
}
_sdcard_format_recorded = true;
_sdcard_format_at = millis();
context.finishSdCardReset(millis());
strcpy(reply, "OK - SD card erase and format complete");
return true;
}
bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) {
if (strncmp(config, "sdcard", 6) != 0 ||
(config[6] != 0 && config[6] != ' ')) {
return false;
}
const char* query = skipSpacesConst(config + 6);
const uint32_t now = millis();
if (*query == 0 || strcmp(query, "*") == 0) {
char format_age[32];
char erase_age[32];
formatSdCardElapsed(format_age, sizeof(format_age),
_sdcard_format_recorded, _sdcard_format_at, now);
formatSdCardElapsed(erase_age, sizeof(erase_age),
_sdcard_erase_recorded, _sdcard_erase_at, now);
snprintf(reply, 160, "> format: %s, erase: %s", format_age, erase_age);
} else if (strcmp(query, "format") == 0) {
char age[32];
formatSdCardElapsed(age, sizeof(age), _sdcard_format_recorded,
_sdcard_format_at, now);
snprintf(reply, 160, "> format: %s", age);
} else if (strcmp(query, "erase") == 0) {
char age[32];
formatSdCardElapsed(age, sizeof(age), _sdcard_erase_recorded,
_sdcard_erase_at, now);
snprintf(reply, 160, "> erase: %s", age);
} else if (strcmp(query, "free") == 0) {
uint64_t used_bytes = 0;
uint64_t free_bytes = 0;
mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore();
if (!store.getSpace(*_board, used_bytes, free_bytes)) {
snprintf(reply, 160, "Error: SD card space query failed: %s",
store.last_error());
} else {
char used[24];
char free[24];
mesh::cli::formatStorageBytes(used, sizeof(used), used_bytes);
mesh::cli::formatStorageBytes(free, sizeof(free), free_bytes);
snprintf(reply, 160, "> used: %s, free: %s", used, free);
}
} else if (strncmp(query, "ls", 2) == 0 || strncmp(query, "dir", 3) == 0) {
const bool is_ls = strncmp(query, "ls", 2) == 0;
const size_t word_len = is_ls ? 2 : 3;
if (query[word_len] != 0 && query[word_len] != ' ') {
strcpy(reply, "Error: use get sdcard ls|dir [page]");
return true;
}
const char* page_arg = skipSpacesConst(query + word_len);
uint32_t page = 1;
if (*page_arg) {
char* end = nullptr;
unsigned long parsed = strtoul(page_arg, &end, 10);
if (parsed == 0 || parsed > 65535 || !end || *skipSpacesConst(end) != 0) {
strcpy(reply, "Error: use get sdcard ls|dir [page]");
return true;
}
page = parsed;
}
mesh::ota::OtaStoreSdNrf52& store = mesh::ota::ota_ctx().sdStagingStore();
if (!store.listFiles(*_board, (uint16_t)page, reply, 160)) {
snprintf(reply, 160, "Error: SD card list failed: %s", store.last_error());
}
} else {
strcpy(reply, "Error: use get sdcard [format|erase|free|ls|dir [page]|*]");
}
return true;
}
#endif
void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) {
const char* config = &command[4];
if (strncmp(config, "repeat.trace", 12) == 0
&& (config[12] == 0 || config[12] == ' ' || config[12] == '\t')) {
if (strcmp(_callbacks->getRole(), "repeater") != 0) {
strcpy(reply, "Error: repeat.trace is only supported by repeaters");
return;
}
const char* value = config + 12;
while (*value == ' ' || *value == '\t') ++value;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
strcpy(reply, "Error: usage set repeat.trace on|off");
return;
}
const uint8_t previous = _prefs->trace_when_repeat_off;
_prefs->trace_when_repeat_off = strcmp(value, "on") == 0 ? 1 : 0;
if (!trySavePrefs()) {
_prefs->trace_when_repeat_off = previous;
strcpy(reply, "Error: Repeat trace not saved; unchanged");
return;
}
_callbacks->onRetryConfigChanged();
snprintf(reply, 160, "OK - repeat.trace %s (saved)", value);
return;
}
#if defined(NRF52_POWER_MANAGEMENT)
if (mesh::power::handleVoltagePolicyCommand(command, reply, 160)) return;
#endif
// 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.sync.interval", 17) == 0
&& (config[17] == 0 || config[17] == ' ' || config[17] == '\t')) {
const char* value = config + 17;
while (*value == ' ' || *value == '\t') ++value;
uint16_t hours = 0;
if (!mesh::gps::parseSyncIntervalHours(value, hours)) {
strcpy(reply, "Error: GPS sync interval must be 1..336 hours");
} else if (_sensors->getLocationProvider() == nullptr) {
strcpy(reply, "Error: GPS unavailable");
} else {
const uint16_t previous = _prefs->gps_sync_interval_hours;
_prefs->gps_sync_interval_hours = hours;
if (!trySavePrefs()) {
_prefs->gps_sync_interval_hours = previous;
strcpy(reply, "Error: GPS sync interval could not be saved");
} else {
_sensors->applyGpsTimeSyncInterval(hours);
snprintf(reply, 160, "OK - GPS sync interval %u hours (saved)", (unsigned)hours);
}
}
return;
}
if (strncmp(config, "gps ", 4) == 0) {
if (strcmp(config + 4, "on") != 0 && strcmp(config + 4, "off") != 0) {
strcpy(reply, "Error: use set gps on|off");
} else {
char gps_command[8];
snprintf(gps_command, sizeof(gps_command), "%s", config);
handleCommand(sender_timestamp, gps_command, reply);
}
return;
}
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (strncmp(config, "espnow.channel", 14) == 0
&& (config[14] == 0 || config[14] == ' ' || config[14] == '\t')) {
const char* value = config + 14;
while (*value == ' ' || *value == '\t') value++;
uint8_t channel = 0;
if (!mesh::wifi::parseEspNowChannel(value, channel)) {
strcpy(reply, "Error: ESP-NOW channel must be 1-13");
} else {
// Snapshot the boot channel before writing NVS. The saved setting must
// never become the active value in this boot merely because this is the
// first policy helper reached by a CLI-only image.
const uint8_t active = mesh::wifi::activeEspNowChannel();
if (!mesh::wifi::saveConfiguredEspNowChannel(channel)) {
strcpy(reply, "Error: failed to save ESP-NOW channel");
} else if (channel == active) {
snprintf(reply, 160,
"OK - ESP-NOW channel %u saved and active",
(unsigned)channel);
} else {
snprintf(reply, 160,
"OK - ESP-NOW channel %u saved; active %u; reboot required",
(unsigned)channel, (unsigned)active);
}
}
return;
}
#endif
#if MESH_USB_LOGGING_AVAILABLE
if (strncmp(config, "usb.logging", 11) == 0
&& (config[11] == 0 || config[11] == ' ' || config[11] == '\t')) {
const char* value = &config[11];
while (*value == ' ' || *value == '\t') value++;
bool enabled = false, reboot_if_needed = false;
if (mesh::cli::parseLoggingToggle(value, enabled, reboot_if_needed)) {
const uint8_t previous = _prefs->usb_logging_enabled;
_prefs->usb_logging_enabled = enabled ? 1 : 0;
if (!trySavePrefs()) {
_prefs->usb_logging_enabled = previous;
strcpy(reply, "Error: USB logging not saved; unchanged");
return;
}
// Commit before changing the stream's live protocol ownership.
mesh::setUsbLoggingEnabled(enabled);
snprintf(reply, 160, "OK - USB logging %s (saved)", enabled ? "on" : "off");
} else {
strcpy(reply, "Error: usage set usb.logging on|off");
}
return;
}
if (strncmp(config, "usb.debug", 9) == 0
&& (config[9] == 0 || config[9] == ' ' || config[9] == '\t')) {
const char* value = &config[9];
while (*value == ' ' || *value == '\t') value++;
bool enabled = false, reboot_if_needed = false;
if (mesh::cli::parseLoggingToggle(value, enabled, reboot_if_needed)
&& !reboot_if_needed) {
const uint8_t previous = _prefs->usb_debug_enabled;
_prefs->usb_debug_enabled = enabled ? 1 : 0;
if (!trySavePrefs()) {
_prefs->usb_debug_enabled = previous;
strcpy(reply, "Error: USB debug not saved; unchanged");
return;
}
mesh::setUsbDebugEnabled(enabled);
snprintf(reply, 160, "OK - USB debug %s (saved)", enabled ? "on" : "off");
} else {
strcpy(reply, "Error: usage set usb.debug on|off");
}
return;
}
#endif
#if MESH_USB_LOGGING_AVAILABLE && defined(WITH_MQTT_BRIDGE)
if (strncmp(config, "logging.output", 14) == 0
&& (config[14] == 0 || config[14] == ' ' || config[14] == '\t')) {
const char* value = &config[14];
while (*value == ' ' || *value == '\t') value++;
bool usb_enabled;
bool wifi_enabled;
if (!mesh::cli::parseLoggingOutput(value, usb_enabled, wifi_enabled)) {
strcpy(reply, "Error: usage set logging.output off|usb|wifi|both");
return;
}
const uint8_t previous_usb = _prefs->usb_logging_enabled;
const uint8_t previous_bridge = _prefs->bridge_enabled;
_prefs->usb_logging_enabled = usb_enabled ? 1 : 0;
_prefs->bridge_enabled = wifi_enabled ? 1 : 0;
if (!trySavePrefs()) {
_prefs->usb_logging_enabled = previous_usb;
_prefs->bridge_enabled = previous_bridge;
strcpy(reply, "Error: logging output not saved; unchanged");
return;
}
mesh::setUsbLoggingEnabled(usb_enabled);
if (!_callbacks->setMqttBridgeState(wifi_enabled)) {
strcpy(reply, "Error: logging output saved, but MQTT runtime change failed");
return;
}
snprintf(reply, 160, "OK - logging.output %s (saved)", value);
return;
}
#endif
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
if (isGpioConfig(config)) {
const UserGpio::SetResult result = _user_gpio.handleSet(
config + 4, reply, 160, sender_timestamp,
_callbacks->getUserGpioRequestSource());
if (result.outcome == UserGpio::SetResult::TIMER_STARTED) {
_callbacks->onUserGpioTimerScheduled(result.pin, sender_timestamp);
} else if (result.outcome == UserGpio::SetResult::APPLIED &&
result.cancelled_timer) {
_callbacks->onUserGpioTimerCancelled(result.pin);
}
return;
}
#endif
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
if (memcmp(config, "webui ", 6) == 0) {
const char* value = &config[6];
bool enabled;
if (strcmp(value, "on") == 0) enabled = true;
else if (strcmp(value, "off") == 0) enabled = false;
else {
strcpy(reply, "Error: usage set webui on|off");
return;
}
if (!_callbacks->setWebUIEnabled(enabled, reply)) {
strcpy(reply, "Error: webui not supported on this build");
}
return;
}
#endif
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
const char* cli_value = nullptr;
if (mesh::cli::classifyStandaloneWiFiSet(config, &cli_value) ==
mesh::cli::StandaloneWiFiKey::CLI) {
if (!_callbacks->setWiFiCLI(cli_value, reply)) {
strcpy(reply, "Error: WiFi CLI unavailable on this build");
}
return;
}
#endif
#ifdef WITH_MQTT_BRIDGE
// bridge_enabled is stored with the common node preferences. Keep its
// MQTT-specific switch here rather than in the observer preferences
// handler, which must only write its own store.
if (strncmp(config, "mqtt.enabled ", 13) == 0) {
const char* value = config + 13;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
strcpy(reply, "Error: use set mqtt.enabled on|off");
} else {
const bool enable = strcmp(value, "on") == 0;
_prefs->bridge_enabled = enable ? 1 : 0;
const bool applied = _callbacks->setMqttBridgeState(enable);
savePrefs();
strcpy(reply, applied ? "OK"
: "Error: MQTT runtime change failed; setting saved");
}
return;
}
#endif
// Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp.
if (handleObserverSetCmd(sender_timestamp, config, reply)) return;
#if defined(WITH_ESPNOW_BRIDGE)
if (memcmp(config, "espnow.enabled ", 15) == 0) {
const char* value = config + 15;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
strcpy(reply, "Error: use set espnow.enabled on|off");
} else {
const bool enable = strcmp(value, "on") == 0;
#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) \
|| defined(ESPNOW_BRIDGE_MERGED)
_prefs->espnow_bridge_enabled = enable;
#endif
#if !defined(WITH_MQTT_BRIDGE) && !defined(WITH_RS232_BRIDGE)
_prefs->bridge_enabled = enable;
#endif
const bool applied = _callbacks->setEspNowBridgeState(enable);
savePrefs();
strcpy(reply, applied ? "OK"
: "Error: ESP-NOW runtime change failed; setting saved");
}
return;
}
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (handleSdCardSetCmd(config, reply)) return;
#endif
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && \
!defined(WEBCONFIG_DISABLED) && !defined(WITH_MQTT_BRIDGE)
const char* wifi_value = nullptr;
const mesh::cli::StandaloneWiFiKey wifi_key =
mesh::cli::classifyStandaloneWiFiSet(config, &wifi_value);
bool wifi_supported = true;
switch (wifi_key) {
case mesh::cli::StandaloneWiFiKey::SSID:
wifi_supported = _callbacks->setWiFiSSID(wifi_value, reply);
break;
case mesh::cli::StandaloneWiFiKey::Password:
wifi_supported = _callbacks->setWiFiPassword(wifi_value, reply);
break;
case mesh::cli::StandaloneWiFiKey::PowerSave:
wifi_supported = _callbacks->setWiFiPowerSave(wifi_value, reply);
break;
case mesh::cli::StandaloneWiFiKey::CLI:
wifi_supported = _callbacks->setWiFiCLI(wifi_value, reply);
break;
default:
wifi_supported = false;
break;
}
if (wifi_key != mesh::cli::StandaloneWiFiKey::None) {
if (!wifi_supported) {
strcpy(reply, "Error: standalone WiFi settings unavailable on this build");
}
return;
}
#endif
if (isAdvancedRetryConfig(config) && !_callbacks->supportsAdvancedRetryConfig()) {
strcpy(reply, "Error, unsupported on this role");
return;
}
if (isBasicRetryConfig(config) && !_callbacks->supportsBasicRetryConfig()) {
strcpy(reply, "Error, retry configuration unsupported on this role");
return;
}
if (memcmp(config, "dutycycle ", 10) == 0) {
float dc = 0.0f;
if (!mesh::cli::parseDecimalStrict(&config[10], dc)
|| dc < 1.0f || dc > 100.0f) {
strcpy(reply, "ERROR: dutycycle must be 1-100");
} else {
_prefs->airtime_factor = (100.0f / dc) - 1.0f;
savePrefs();
float actual = 100.0f / (_prefs->airtime_factor + 1.0f);
int a_int = (int)actual;
int a_frac = (int)((actual - a_int) * 10.0f + 0.5f);
sprintf(reply, "OK - %d.%d%%", a_int, a_frac);
}
} else if (memcmp(config, "system.watchdog ", 16) == 0) {
#if defined(NRF52_PLATFORM)
const char* value = &config[16];
if (strcmp(value, "on") == 0) {
_prefs->system_watchdog_enabled = 1;
savePrefs();
strcpy(reply, "OK - system watchdog enabled");
} else if (strcmp(value, "off") == 0) {
_prefs->system_watchdog_enabled = 0;
savePrefs();
strcpy(reply, "OK - disabled; restarting within 60s");
} else {
strcpy(reply, "Error: use set system.watchdog on|off");
}
#else
strcpy(reply, "Error: unsupported on this platform");
#endif
} else if (memcmp(config, "af ", 3) == 0) {
float factor = 0.0f;
if (mesh::cli::parseDecimalStrict(&config[3], factor)) {
_prefs->airtime_factor = factor;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, invalid airtime factor");
}
} else if (memcmp(config, "int.thresh ", 11) == 0) {
_prefs->interference_threshold = atoi(&config[11]);
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "cad ", 4) == 0) {
const char* value = &config[4];
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
strcpy(reply, "Error: use set cad on|off");
} else {
_prefs->cad_enabled = strcmp(value, "on") == 0;
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "agc.reset.interval ", 19) == 0) {
_prefs->agc_reset_interval = atoi(&config[19]) / 4;
savePrefs();
sprintf(reply, "OK - interval rounded to %d", ((uint32_t) _prefs->agc_reset_interval) * 4);
} else if (memcmp(config, "multi.acks ", 11) == 0) {
_prefs->multi_acks = atoi(&config[11]);
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "allow.read.only ", 16) == 0) {
_prefs->allow_read_only = memcmp(&config[16], "on", 2) == 0;
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "telemetry.access ", 17) == 0) {
if (strcmp(&config[17], "all") == 0) {
_prefs->telemetry_access = TELEMETRY_ACCESS_ALL;
savePrefs();
strcpy(reply, "OK");
} else if (strcmp(&config[17], "acl") == 0) {
_prefs->telemetry_access = TELEMETRY_ACCESS_ACL;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "ERROR: telemetry.access must be all or acl");
}
} else if (memcmp(config, "flood.advert.interval ", 22) == 0) {
uint32_t hours = 0;
if (!parseUint32Strict(&config[22], hours)
|| (hours != 0 && (hours < 3 || hours > 168))) {
strcpy(reply, "Error: interval must be 0 (off) or 3-168 hours");
} else {
const uint8_t previous = _prefs->flood_advert_interval;
_prefs->flood_advert_interval = (uint8_t)(hours);
if (!trySavePrefs()) {
_prefs->flood_advert_interval = previous;
strcpy(reply, "Error: interval not saved; unchanged");
return;
}
_callbacks->updateFloodAdvertTimer();
strcpy(reply, "OK");
}
} else if (memcmp(config, "advert.interval ", 16) == 0) {
uint32_t mins = 0;
if (!parseUint32Strict(&config[16], mins)
|| (mins != 0 && (mins < MIN_LOCAL_ADVERT_INTERVAL || mins > 240))) {
sprintf(reply, "Error: interval must be 0 (off) or %d-240 minutes", MIN_LOCAL_ADVERT_INTERVAL);
} else {
const uint8_t previous = _prefs->advert_interval;
_prefs->advert_interval = (uint8_t)(mins / 2);
if (!trySavePrefs()) {
_prefs->advert_interval = previous;
strcpy(reply, "Error: interval not saved; unchanged");
return;
}
_callbacks->updateAdvertTimer();
strcpy(reply, "OK");
}
} else if (memcmp(config, "guest.password ", 15) == 0) {
char previous[sizeof(_prefs->guest_password)];
memcpy(previous, _prefs->guest_password, sizeof(previous));
StrHelper::strncpy(_prefs->guest_password, &config[15], sizeof(_prefs->guest_password));
if (!trySavePrefs()) {
memcpy(_prefs->guest_password, previous, sizeof(previous));
strcpy(reply, "Error: guest password not saved; unchanged");
return;
}
strcpy(reply, "OK");
} else if (memcmp(config, "prv.key ", 8) == 0) {
uint8_t prv_key[PRV_KEY_SIZE];
bool success = mesh::Utils::fromHex(prv_key, PRV_KEY_SIZE, &config[8]);
// only allow rekey if key is valid
if (success && mesh::LocalIdentity::validatePrivateKey(prv_key)) {
mesh::LocalIdentity new_id;
new_id.readFrom(prv_key, PRV_KEY_SIZE);
if (!_callbacks->saveIdentity(new_id)) {
strcpy(reply, "Error: identity not saved; current key unchanged");
return;
}
strcpy(reply, "OK, reboot to apply! New pubkey: ");
mesh::Utils::toHex(&reply[33], new_id.pub_key, PUB_KEY_SIZE);
} else {
strcpy(reply, "Error, bad key");
}
} else if (memcmp(config, "name ", 5) == 0) {
if (isValidName(&config[5])) {
StrHelper::strncpy(_prefs->node_name, &config[5], sizeof(_prefs->node_name));
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, bad chars");
}
} else if (memcmp(config, "repeat ", 7) == 0) {
if (strcmp(&config[7], "on") == 0 || strcmp(&config[7], "off") == 0) {
_prefs->disable_fwd = strcmp(&config[7], "off") == 0;
savePrefs();
_callbacks->onRetryConfigChanged();
strcpy(reply, _prefs->disable_fwd ? "OK - repeat is now OFF" : "OK - repeat is now ON");
} else {
strcpy(reply, "Error, must be on or off");
}
} else if (memcmp(config, "radio.rxgain ", 13) == 0) {
bool enabled = memcmp(&config[13], "on", 2) == 0;
if (_callbacks->setRxBoostedGain(enabled)) {
_prefs->rx_boosted_gain = enabled;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error: unsupported");
}
} else if (memcmp(config, "radio.fem.rxgain ", 17) == 0) {
if (!_board->canControlLoRaFemLna()) {
strcpy(reply, "Error: unsupported");
} else if (memcmp(&config[17], "on", 2) == 0) {
bool changed = !_board->isLoRaFemLnaEnabled();
if (_board->setLoRaFemLnaEnabled(true)) {
if (changed) _callbacks->recalibrateNoiseFloor();
_prefs->radio_fem_rxgain = 1;
savePrefs();
strcpy(reply, "OK - LoRa FEM RX gain on");
} else {
strcpy(reply, "Error: failed to apply LoRa FEM RX gain");
}
} else if (memcmp(&config[17], "off", 3) == 0) {
bool changed = _board->isLoRaFemLnaEnabled();
if (_board->setLoRaFemLnaEnabled(false)) {
if (changed) _callbacks->recalibrateNoiseFloor();
_prefs->radio_fem_rxgain = 0;
savePrefs();
strcpy(reply, "OK - LoRa FEM RX gain off");
} else {
strcpy(reply, "Error: failed to apply LoRa FEM RX gain");
}
} else {
strcpy(reply, "Error: state must be on or off");
}
} else if (memcmp(config, "radio.fem.txgain ", 17) == 0) {
if (!_board->canControlLoRaFemPaGain()) {
strcpy(reply, "Error: unsupported");
} else if (memcmp(&config[17], "on", 2) == 0) {
if (_board->setLoRaFemPaGainEnabled(true)) {
_prefs->radio_fem_txgain = 1;
savePrefs();
strcpy(reply, "OK - LoRa FEM TX gain on");
} else {
strcpy(reply, "Error: failed to apply LoRa FEM TX gain");
}
} else if (memcmp(&config[17], "off", 3) == 0) {
if (_board->setLoRaFemPaGainEnabled(false)) {
_prefs->radio_fem_txgain = 0;
savePrefs();
strcpy(reply, "OK - LoRa FEM TX gain off");
} else {
strcpy(reply, "Error: failed to apply LoRa FEM TX gain");
}
} else {
strcpy(reply, "Error: state must be on or off");
}
} else if (strncmp(config, "radio.rxps.rfrx_disabled ", 25) == 0) {
const char* value = &config[25];
bool disabled;
if (strcmp(value, "on") == 0) {
disabled = true;
} else if (strcmp(value, "off") == 0) {
disabled = false;
} else {
strcpy(reply, "Error: state must be on or off");
return;
}
if (!_callbacks->setRxPowerSavingRfRxDisabled(disabled)) {
strcpy(reply, "Error: unsupported");
} else {
sprintf(reply, "OK - radio.rxps.rfrx_disabled %s", disabled ? "on" : "off");
}
} else if (memcmp(config, "radio.rxps ", 11) == 0) { // RX PowerSaving
const char* value = &config[11];
uint8_t enable = _prefs->rx_powersaving_enabled;
uint32_t rx_us = _prefs->rx_ps_rx_us;
uint32_t sleep_us = _prefs->rx_ps_sleep_us;
uint8_t level = 0;
uint8_t preamble = rxPowerSavingPreambleForParams(_prefs->sf, _prefs->bw);
bool level_requested = false;
bool preamble_overridden = false;
bool manual_timings_requested = false;
ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us);
rx_us = _prefs->rx_ps_rx_us;
sleep_us = _prefs->rx_ps_sleep_us;
mesh::cli::RxPowerSavingArguments parsed = {};
if (!mesh::cli::parseRxPowerSavingArgumentsStrict(value, parsed)) {
strcpy(reply, "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|level <1-10>|<rx_us> <sleep_us>");
return;
}
using mesh::cli::RxPowerSavingArgumentMode;
if (parsed.mode == RxPowerSavingArgumentMode::Off) {
enable = 0;
} else if (parsed.mode == RxPowerSavingArgumentMode::Manual) {
rx_us = parsed.rx_us;
sleep_us = parsed.sleep_us;
enable = 1;
manual_timings_requested = true;
} else {
uint32_t requested_level = parsed.level;
uint32_t requested_preamble = parsed.preamble;
if (parsed.mode == RxPowerSavingArgumentMode::Conservative
|| parsed.mode == RxPowerSavingArgumentMode::Balanced) {
requested_level = parsed.mode == RxPowerSavingArgumentMode::Conservative
? RX_POWERSAVING_CONSERVATIVE_LEVEL : RX_POWERSAVING_BALANCED_LEVEL;
requested_preamble = RX_POWERSAVING_PROFILE_PREAMBLE;
}
if (requested_level < 1 || requested_level > 10
|| (requested_preamble != 0 && requested_preamble != 16 && requested_preamble != 32)) {
strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32");
return;
}
level = requested_level;
if (requested_preamble != 0) preamble = requested_preamble;
preamble_overridden = requested_preamble != 0;
level_requested = true;
enable = 1;
}
if (level_requested && preamble_overridden
&& preamble != 16 && preamble != 32) {
strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32");
return;
}
if (level_requested && !calculateRxPowerSavingLevel(level, _prefs->sf, _prefs->bw, preamble, &rx_us, &sleep_us)) {
strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32");
return;
}
if (manual_timings_requested
&& (rx_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US
|| sleep_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US)) {
sprintf(reply, "ERROR: range is %lu-%lu us",
(unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US,
(unsigned long)RX_POWERSAVING_MAX_PERIOD_US);
return;
}
if (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us)) {
sprintf(reply, "ERROR: range is %lu-%lu us",
(unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US,
(unsigned long)RX_POWERSAVING_MAX_PERIOD_US);
return;
}
if (!_callbacks->setRxPowerSaving(enable, rx_us, sleep_us)) {
strcpy(reply, "ERROR: RX powersaving unsupported");
return;
}
_prefs->rx_powersaving_enabled = enable;
_prefs->rx_ps_rx_us = rx_us;
_prefs->rx_ps_sleep_us = sleep_us;
if (level_requested) {
// Remember the intent so the timings can auto-retune when SF/BW change.
_prefs->rx_ps_level = level;
_prefs->rx_ps_preamble = preamble_overridden ? preamble : 0; // 0 = auto (derive from SF)
} else if (parsed.mode != RxPowerSavingArgumentMode::Off) {
// manual <rx_us> <sleep_us> timings are fixed, not level-derived
// (the named profiles set level_requested and are handled above)
_prefs->rx_ps_level = 0;
_prefs->rx_ps_preamble = 0;
}
savePrefs();
if (level_requested) {
sprintf(reply, "OK - level %lu,%s,%lu,%lu,preamble=%lu",
(unsigned long)level,
enable ? "on" : "off",
(unsigned long)rx_us,
(unsigned long)sleep_us,
(unsigned long)preamble);
appendRxPowerSavingAdjustmentNote(
reply, _prefs, _prefs->sf, _prefs->bw);
} else {
sprintf(reply, "OK - %s,%lu,%lu", enable ? "on" : "off",
(unsigned long)rx_us, (unsigned long)sleep_us);
}
} else if (strcmp(config, "powersaving on") == 0
|| strcmp(config, "powersaving off") == 0) {
const bool enabled = strcmp(&config[12], "on") == 0;
_prefs->powersaving_enabled = enabled ? 1 : 0;
_sensors->setPowerSavingEnabled(enabled);
savePrefs();
sprintf(reply, "OK - powersaving %s", enabled ? "on" : "off");
} else if (memcmp(config, "radio ", 6) == 0) {
float freq = 0.0f;
float bw = 0.0f;
uint8_t sf = 0;
uint8_t cr = 0;
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)
&& _radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) {
const int8_t previous_power = _prefs->tx_power_dbm;
if (!savePrimaryRadioParams(freq, bw, sf, cr, preamble)) {
strcpy(reply, "Error: radio settings could not be saved"); return;
}
if (_prefs->tx_power_dbm != previous_power) {
sprintf(reply, "OK - reboot to apply; TX power limited to %d dBm",
(int)_prefs->tx_power_dbm);
} else {
strcpy(reply, "OK - reboot to apply");
}
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
_radio_profiles.appendPrimaryChirpWarning(reply, 160, sf, bw, preamble);
} else {
strcpy(reply, "Error, invalid radio params");
}
} else if (memcmp(config, "radioat ", 8) == 0) {
float freq, bw;
uint8_t sf, cr;
uint32_t 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, _rtc->getCurrentTime())) {
strcpy(reply, "Error, use: set radioat f,bw,sf,cr,start (UTC or +minutes)");
} 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, preamble);
if (strncmp(reply, "OK", 2) == 0) {
_radio_profiles.appendPrimaryChirpWarning(reply, 160, sf, bw, preamble);
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
}
}
} else if (memcmp(config, "tempradioat ", 12) == 0) {
float freq, bw;
uint8_t sf, cr;
uint32_t 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, _rtc->getCurrentTime())) {
strcpy(reply, "Error, use: set tempradioat f,bw,sf,cr,start,end (UTC or +minutes)");
} 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, preamble);
if (strncmp(reply, "OK", 2) == 0) {
_radio_profiles.appendPrimaryChirpWarning(reply, 160, sf, bw, preamble);
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
}
}
} else if (memcmp(config, "lat ", 4) == 0) {
float latitude = 0.0f;
if (mesh::cli::parseDecimalStrict(&config[4], latitude)) {
_prefs->node_lat = latitude;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, invalid latitude");
}
} else if (memcmp(config, "lon ", 4) == 0) {
float longitude = 0.0f;
if (mesh::cli::parseDecimalStrict(&config[4], longitude)) {
_prefs->node_lon = longitude;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, invalid longitude");
}
} else if (memcmp(config, "rxdelay ", 8) == 0) {
float db = 0.0f;
if (mesh::cli::parseDecimalStrict(&config[8], db)
&& db >= 0.0f && db <= 20.0f) {
_prefs->rx_delay_base = db;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 0-20");
}
} else if (memcmp(config, "txdelay ", 8) == 0) {
float f = 0.0f;
if (mesh::cli::parseDecimalStrict(&config[8], f)
&& f >= 0.0f && f <= 2.0f) {
_prefs->tx_delay_factor = f;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 0-2");
}
} else if (memcmp(config, "flood.max.unscoped ", 19) == 0) {
uint32_t m;
if (mesh::cli::parseUnsignedIntegerStrict(&config[19], m) && m <= 64) {
_prefs->flood_max_unscoped = m;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, max 64");
}
} else if (memcmp(config, "flood.max.advert ", 17) == 0) {
uint32_t m;
if (mesh::cli::parseUnsignedIntegerStrict(&config[17], m) && m <= 64) {
_prefs->flood_max_advert = m;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, max 64");
}
} else if (memcmp(config, "flood.max ", 10) == 0) {
uint32_t m;
if (mesh::cli::parseUnsignedIntegerStrict(&config[10], m) && m <= 64) {
_prefs->flood_max = m;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, max 64");
}
} else if (memcmp(config, "direct.txdelay ", 15) == 0) {
float f = 0.0f;
if (mesh::cli::parseDecimalStrict(&config[15], f)
&& f >= 0.0f && f <= 2.0f) {
_prefs->direct_tx_delay_factor = f;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 0-2");
}
} else if (memcmp(config, "retry.preset ", 13) == 0) {
uint8_t preset;
if (parseRetryPreset(&config[13], preset)) {
applyDirectRetryPreset(_prefs, preset);
savePrefs();
sprintf(reply, "OK - %s", retryPresetName(_prefs->retry_preset));
} else {
strcpy(reply, "Error, must be infra, rooftop, or mobile");
}
} else if (memcmp(config, "direct.retry ", 13) == 0) {
if (strcmp(&config[13], "on") == 0) {
_prefs->direct_retry_enabled = 1;
savePrefs();
_callbacks->onRetryConfigChanged();
strcpy(reply, "OK");
} else if (strcmp(&config[13], "off") == 0) {
_prefs->direct_retry_enabled = 0;
savePrefs();
_callbacks->onRetryConfigChanged();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be on or off");
}
} else if (memcmp(config, "direct.retry.heard ", 19) == 0) {
if (strcmp(&config[19], "on") == 0) {
_prefs->direct_retry_recent_enabled = 1;
savePrefs();
strcpy(reply, "OK");
} else if (strcmp(&config[19], "off") == 0) {
_prefs->direct_retry_recent_enabled = 0;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be on or off");
}
} else if (memcmp(config, "direct.retry.margin ", 20) == 0) {
if (!looksNumeric(&config[20])) {
strcpy(reply, "Error, must be 0-40 dB");
} else {
int16_t margin_x4 = parseSnrDbX4(&config[20]);
if (margin_x4 >= 0 && margin_x4 <= 160) {
_prefs->direct_retry_snr_margin_x4 = (uint16_t)margin_x4;
_prefs->retry_preset = RETRY_PRESET_CUSTOM;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 0-40 dB");
}
}
} else if (memcmp(config, "direct.retry.count ", 19) == 0) {
int attempts = looksUnsignedInteger(&config[19]) ? _atoi(&config[19]) : -1;
if (attempts >= 1 && attempts <= 15) {
_prefs->direct_retry_attempts = (uint8_t)attempts;
_prefs->retry_preset = RETRY_PRESET_CUSTOM;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 1-15");
}
} else if (memcmp(config, "direct.retry.base ", 18) == 0) {
int base_ms = looksUnsignedInteger(&config[18]) ? _atoi(&config[18]) : -1;
if (base_ms >= 10 && base_ms <= 5000) {
_prefs->direct_retry_base_ms = (uint16_t)base_ms;
_prefs->retry_preset = RETRY_PRESET_CUSTOM;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 10-5000 ms");
}
} else if (memcmp(config, "direct.retry.step ", 18) == 0) {
int step_ms = looksUnsignedInteger(&config[18]) ? _atoi(&config[18]) : -1;
if (step_ms >= 0 && step_ms <= 5000) {
_prefs->direct_retry_step_ms = (uint16_t)step_ms;
_prefs->retry_preset = RETRY_PRESET_CUSTOM;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 0-5000 ms");
}
} else if (memcmp(config, "flood.channel.data.hops ", 24) == 0) {
uint8_t max_hops;
if (parseFloodChannelHops(&config[24], max_hops)) {
_prefs->flood_channel_data_max_hops = max_hops;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be all or 1-7");
}
} else if (memcmp(config, "flood.channel.data ", 19) == 0) {
if (strcmp(&config[19], "on") == 0) {
_prefs->flood_channel_data_enabled = 1;
savePrefs();
strcpy(reply, "OK");
} else if (strcmp(&config[19], "off") == 0) {
_prefs->flood_channel_data_enabled = 0;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be on or off");
}
} else if (memcmp(config, "flood.retry.count ", 18) == 0) {
int attempts = looksUnsignedInteger(&config[18]) ? _atoi(&config[18]) : -1;
if (attempts >= 0 && attempts <= 15) {
_prefs->flood_retry_attempts = (uint8_t)attempts;
_prefs->retry_preset = RETRY_PRESET_CUSTOM;
savePrefs();
_callbacks->onRetryConfigChanged();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 0-15");
}
} else if (memcmp(config, "flood.retry.path ", 17) == 0) {
uint8_t path_gate;
if (parseFloodRetryPathGate(&config[17], path_gate)) {
_prefs->flood_retry_max_path = path_gate;
if (path_gate == 0) {
_prefs->flood_retry_group_max_path = FLOOD_RETRY_PATH_GATE_DISABLED;
}
_prefs->retry_preset = RETRY_PRESET_CUSTOM;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 0-63 or off");
}
} else if (memcmp(config, "flood.retry.group.path ", 23) == 0) {
uint8_t path_gate;
if (parseFloodRetryPathGate(&config[23], path_gate)) {
_prefs->flood_retry_group_max_path = _prefs->flood_retry_max_path == 0
? FLOOD_RETRY_PATH_GATE_DISABLED
: path_gate;
_prefs->retry_preset = RETRY_PRESET_CUSTOM;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be 0-63 or off");
}
} else if (memcmp(config, "flood.retry.prefixes ", 21) == 0) {
if (parseFloodRetryPrefixList(_prefs->flood_retry_prefixes, FLOOD_RETRY_PREFIX_SLOTS, &config[21])) {
savePrefs();
if (!formatFloodRetryBucketCollisionWarning(reply, _prefs, "OK - ")) {
strcpy(reply, "OK");
}
} else {
sprintf(reply, "Error, use up to %u comma-separated 3-byte hex prefixes",
(unsigned int)FLOOD_RETRY_PREFIX_SLOTS);
}
} else if (memcmp(config, "flood.retry.ignore ", 19) == 0) {
if (parseFloodRetryPrefixList(_prefs->flood_retry_ignore_prefixes,
FLOOD_RETRY_IGNORE_PREFIXES, &config[19])) {
savePrefs();
strcpy(reply, "OK");
} else {
sprintf(reply, "Error, use up to %u comma-separated 3-byte hex prefixes",
(unsigned int)FLOOD_RETRY_IGNORE_PREFIXES);
}
} else if (memcmp(config, "flood.retry.advert ", 19) == 0) {
if (strcmp(&config[19], "on") == 0) {
_prefs->flood_retry_advert_enabled = 1;
savePrefs();
strcpy(reply, "OK");
} else if (strcmp(&config[19], "off") == 0) {
_prefs->flood_retry_advert_enabled = 0;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be on or off");
}
} else if (memcmp(config, "flood.retry.bridge ", 19) == 0) {
if (strcmp(&config[19], "on") == 0) {
_prefs->flood_retry_bridge_enabled = 1;
savePrefs();
if (!formatFloodRetryBucketCollisionWarning(reply, _prefs, "OK - bucket 7=prefixes; ")) {
strcpy(reply, "OK - flood.retry.prefixes acts as bucket 7 (other); multi-try bridge routing enabled");
}
} else if (strcmp(&config[19], "off") == 0) {
_prefs->flood_retry_bridge_enabled = 0;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, must be on or off");
}
} else if (memcmp(config, "flood.retry.bucket ", 19) == 0) {
const char* params = &config[19];
uint8_t bucket = atoi(params);
const char* list = strchr(params, ' ');
if (bucket < 1 || bucket > FLOOD_RETRY_BRIDGE_BUCKETS || list == NULL || *(list + 1) == 0) {
sprintf(reply, "Error, usage: set flood.retry.bucket <1-%d> <prefixes|none>", FLOOD_RETRY_BRIDGE_BUCKETS);
} else if (parseFloodRetryPrefixList(_prefs->flood_retry_bridge_buckets[bucket - 1],
FLOOD_RETRY_BUCKET_PREFIXES, list + 1)) {
savePrefs();
if (!formatFloodRetryBucketCollisionWarning(reply, _prefs, "OK - ")) {
strcpy(reply, "OK");
}
} else {
sprintf(reply, "Error, use up to %u comma-separated 3-byte hex prefixes",
(unsigned int)FLOOD_RETRY_BUCKET_PREFIXES);
}
} else if (memcmp(config, "direct.retry.cr ", 16) == 0) {
if (strcmp(&config[16], "off") == 0) {
_prefs->direct_retry_cr_enabled = 0;
savePrefs();
strcpy(reply, "OK");
} else if (!_callbacks->supportsAdvancedRetryConfig()) {
if (strcmp(&config[16], "on") == 0) {
_prefs->direct_retry_cr_enabled = 1;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, use on or off on this role");
}
} else {
strcpy(tmp, &config[16]);
const char *parts[4];
int num = mesh::Utils::parseTextParts(tmp, parts, 4, ',');
if (num == 4 && looksNumeric(parts[0]) && looksNumeric(parts[1]) && looksNumeric(parts[2]) && looksNumeric(parts[3])) {
int16_t cr4 = parseSnrDbX4(parts[0]);
int16_t cr5 = parseSnrDbX4(parts[1]);
int16_t cr7 = parseSnrDbX4(parts[2]);
int16_t cr8 = parseSnrDbX4(parts[3]);
if (cr4 >= -128 && cr4 <= 127 && cr5 >= -128 && cr5 <= 127 && cr7 >= -128 && cr7 <= 127 && cr8 >= -128 && cr8 <= 127) {
_prefs->direct_retry_cr4_snr_x4 = (int8_t)cr4;
_prefs->direct_retry_cr5_snr_x4 = (int8_t)cr5;
_prefs->direct_retry_cr7_snr_x4 = (int8_t)cr7;
_prefs->direct_retry_cr8_snr_x4 = (int8_t)cr8;
_prefs->direct_retry_cr_enabled = 1;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error, SNR must fit -32.00..31.75 dB");
}
} else {
strcpy(reply, "Error, use CR4,CR5,CR7,CR8 SNRs or off");
}
}
} else if (memcmp(config, "recent.repeater ", 16) == 0) {
strcpy(tmp, &config[16]);
const char *parts[2];
int num = mesh::Utils::parseTextParts(tmp, parts, 2, ' ');
uint8_t prefix[MAX_HASH_SIZE];
uint8_t prefix_len = 0;
int16_t snr_x4 = 12; // default to +3.0 dB when omitted or invalid
if (num < 1 || !parseHashPrefix(parts[0], prefix, prefix_len)) {
strcpy(reply, "Error, use: set recent.repeater <hex> [snr_db]");
} else if (num > 1 && !looksNumeric(parts[1])) {
strcpy(reply, "Error, SNR must be numeric");
} else {
if (num > 1) {
snr_x4 = parseSnrDbX4(parts[1]);
}
if (snr_x4 < -128 || snr_x4 > 127) {
strcpy(reply, "Error, SNR must fit -32.00..31.75 dB");
} else if (_callbacks->setRecentRepeater(prefix, prefix_len, (int8_t)snr_x4)) {
char prefix_hex[RECENT_REPEATER_PREFIX_MAX_BYTES * 2 + 1];
char snr[12];
mesh::Utils::toHex(prefix_hex, prefix, prefix_len);
prefix_hex[prefix_len * 2] = 0;
formatSnrDbX4Short(snr, sizeof(snr), snr_x4);
sprintf(reply, "OK - set %s at %s SNR", prefix_hex, snr);
} else {
strcpy(reply, "Error, table rejected prefix");
}
}
} else if (memcmp(config, "owner.info ", 11) == 0) {
config += 11;
char *dp = _prefs->owner_info;
while (*config && dp - _prefs->owner_info < sizeof(_prefs->owner_info)-1) {
*dp++ = (*config == '|') ? '\n' : *config; // translate '|' to newline chars
config++;
}
*dp = 0;
savePrefs();
strcpy(reply, "OK");
} else if (memcmp(config, "loop.detect ", 12) == 0) {
config += 12;
uint8_t mode;
if (memcmp(config, "off", 3) == 0) {
mode = LOOP_DETECT_OFF;
} else if (memcmp(config, "minimal", 7) == 0) {
mode = LOOP_DETECT_MINIMAL;
} else if (memcmp(config, "moderate", 8) == 0) {
mode = LOOP_DETECT_MODERATE;
} else if (memcmp(config, "strict", 6) == 0) {
mode = LOOP_DETECT_STRICT;
} else {
mode = 0xFF;
strcpy(reply, "Error, must be: off, minimal, moderate, or strict");
}
if (mode != 0xFF) {
_prefs->loop_detect = mode;
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "tx ", 3) == 0) {
int32_t power = 0;
const int8_t minimum = mesh::minLoRaTxPowerForFrequency(_prefs->freq);
const int8_t maximum = mesh::maxLoRaTxPowerForFrequency(_prefs->freq);
if (!mesh::cli::parseIntegerStrict(&config[3], power)) {
strcpy(reply, "Error: invalid TX power");
} else if (!mesh::isLoRaTxPowerValid(power, _prefs->freq)) {
sprintf(reply, "Error: TX power range is %d to %d dBm",
(int)minimum, (int)maximum);
} else if (!_callbacks->setTxPower(static_cast<int8_t>(power))) {
strcpy(reply, "Error: radio busy or TX power rejected");
} else {
_prefs->tx_power_dbm = static_cast<int8_t>(power);
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "freq ", 5) == 0) {
float freq = 0.0f;
if (mesh::cli::parseDecimalStrict(&config[5], freq)
&& freq >= 150.0f && freq <= 2500.0f) {
const int8_t previous_power = _prefs->tx_power_dbm;
_prefs->freq = freq;
_prefs->tx_power_dbm = mesh::clampLoRaTxPower(
_prefs->tx_power_dbm, _prefs->freq);
savePrefs();
if (_prefs->tx_power_dbm != previous_power) {
sprintf(reply, "OK - reboot to apply; TX power limited to %d dBm",
(int)_prefs->tx_power_dbm);
} else {
strcpy(reply, "OK - reboot to apply");
}
} else {
strcpy(reply, "Error, invalid frequency");
}
#ifdef WITH_BRIDGE
} else if (memcmp(config, "bridge.enabled ", 15) == 0) {
bool enable = false;
if (!parseOnOffStrict(&config[15], enable)) {
strcpy(reply, "Error: usage set bridge.enabled on|off");
} else {
#if defined(WITH_ESPNOW_BRIDGE) \
&& (defined(WITH_MQTT_BRIDGE) \
|| (defined(ESPNOW_BRIDGE_MERGED) && !defined(WITH_RS232_BRIDGE)))
// bridge.enabled retains its ESP-NOW meaning; mqtt.enabled is the
// independent MQTT switch when the profile also has that transport.
_prefs->espnow_bridge_enabled = enable;
#if !defined(WITH_MQTT_BRIDGE)
_prefs->bridge_enabled = enable;
#endif
const bool applied = _callbacks->setEspNowBridgeState(enable);
savePrefs();
strcpy(reply, applied ? "OK"
: "Error: ESP-NOW runtime change failed; setting saved");
#else
#ifdef WITH_RS232_BRIDGE
if (enable
&& _sensors->gpsSerialTransportMayConflict(_prefs->bridge_uart)
&& (!_sensors->gpsUsesSerialUart(_prefs->bridge_uart)
|| !_sensors->gpsSerialTransportCanYield(_prefs->bridge_uart))) {
strcpy(reply, "Error: UART may be driven by GPS; use UART 2 or a no-GPS build");
return;
}
const uint8_t previous_enabled = _prefs->bridge_enabled;
const bool previous_running = _callbacks->isBridgeRunning();
_prefs->bridge_enabled = enable;
const bool applied = _callbacks->setBridgeState(enable);
if (applied) {
savePrefs();
#ifdef WITH_RS232_BRIDGE
if (enable && _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) {
strcpy(reply, "OK - UART GPS paused");
} else
#endif
{
strcpy(reply, "OK");
}
} else {
_prefs->bridge_enabled = previous_enabled;
bool restored = true;
if (_callbacks->isBridgeRunning() != previous_running
|| !previous_running) {
// The false/false case still calls disable to delete a failed,
// non-running heap-backed bridge instance.
restored = _callbacks->setBridgeState(previous_running);
}
strcpy(reply, restored
? "Error: bridge state change failed; setting unchanged"
: "Error: bridge state change failed; previous runtime state could not be restored");
}
#else
// MQTT and ESP-NOW preserve configured intent across missing WiFi
// credentials or transient initialization failure. Their runtime can be
// retried later, so do not apply the stricter RS-232 rollback policy.
_prefs->bridge_enabled = enable;
const bool applied = _callbacks->setBridgeState(enable);
savePrefs();
strcpy(reply, applied ? "OK"
: "Error: bridge runtime change failed; setting saved");
#endif
#endif
}
} else if (memcmp(config, "bridge.delay ", 13) == 0) {
int32_t bridge_delay = 0;
if (mesh::cli::parseIntegerStrict(&config[13], bridge_delay)
&& bridge_delay >= 0 && bridge_delay <= 10000) {
_prefs->bridge_delay = (uint16_t)bridge_delay;
savePrefs();
strcpy(reply, "OK");
} else {
strcpy(reply, "Error: delay must be between 0-10000 ms");
}
} else if (memcmp(config, "bridge.source ", 14) == 0) {
const char* source = &config[14];
if (strcmp(source, "rx") != 0 && strcmp(source, "tx") != 0) {
strcpy(reply, "Error: usage set bridge.source rx|tx");
return;
}
_prefs->bridge_pkt_src = strcmp(source, "rx") == 0;
#ifdef WITH_MQTT_BRIDGE
if (_prefs->bridge_pkt_src == 1) {
_mqtt_prefs.mqtt_rx_enabled = 1;
_mqtt_prefs.mqtt_tx_enabled = 0;
} else {
_mqtt_prefs.mqtt_rx_enabled = 0;
_mqtt_prefs.mqtt_tx_enabled = 1;
}
savePrefs(PrefsSaveRouting::Scope::Both);
#else
savePrefs();
#endif
strcpy(reply, "OK");
#endif
#ifdef WITH_RS232_BRIDGE
} else if (memcmp(config, "bridge.baud ", 12) == 0) {
uint32_t baud = 0;
if (mesh::cli::parseUnsignedIntegerStrict(&config[12], baud)
&& baud >= 9600 && baud <= BRIDGE_MAX_BAUD) {
const uint32_t previous_baud = _prefs->bridge_baud;
const bool previous_running = _callbacks->isBridgeRunning();
_prefs->bridge_baud = baud;
const bool applied =
#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE)
!_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge();
#else
!_prefs->bridge_enabled || _callbacks->restartBridge();
#endif
if (applied) {
savePrefs();
strcpy(reply, "OK");
} else {
_prefs->bridge_baud = previous_baud;
const bool restored = previous_running
? _callbacks->restartBridge()
: _callbacks->setBridgeState(false);
strcpy(reply, restored
? "Error: bridge failed to restart; baud unchanged"
: "Error: bridge failed to restart; previous baud could not be restored");
}
} else {
sprintf(reply, "Error: baud rate must be between 9600-%d",BRIDGE_MAX_BAUD);
}
} else if (memcmp(config, "bridge.uart ", 12) == 0) {
int32_t uart = 0;
const bool parsed = mesh::cli::parseIntegerStrict(&config[12], uart);
const bool supported = uart == WITH_RS232_BRIDGE_UART
#ifdef WITH_RS232_BRIDGE_ALT
|| uart == WITH_RS232_BRIDGE_ALT_UART
#endif
;
if (!parsed || !supported) {
#ifdef WITH_RS232_BRIDGE_ALT
sprintf(reply, "Error: UART must be %d or %d",
WITH_RS232_BRIDGE_UART, WITH_RS232_BRIDGE_ALT_UART);
#else
sprintf(reply, "Error: UART is fixed at %d", WITH_RS232_BRIDGE_UART);
#endif
}
else {
if (_prefs->bridge_enabled
&& _sensors->gpsSerialTransportMayConflict((uint8_t)uart)
&& (!_sensors->gpsUsesSerialUart((uint8_t)uart)
|| !_sensors->gpsSerialTransportCanYield((uint8_t)uart))) {
strcpy(reply, "Error: UART may be driven by GPS; keep UART 2 or use a no-GPS build");
return;
}
const uint8_t previous_uart = _prefs->bridge_uart;
const bool previous_running = _callbacks->isBridgeRunning();
_prefs->bridge_uart = (uint8_t)uart;
const bool applied =
#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE)
!_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge();
#else
!_prefs->bridge_enabled || _callbacks->restartBridge();
#endif
if (!applied) {
_prefs->bridge_uart = previous_uart;
const bool restored = previous_running
? _callbacks->restartBridge()
: _callbacks->setBridgeState(false);
strcpy(reply, restored
? "Error: bridge failed to restart; UART unchanged"
: "Error: bridge failed to restart; previous UART could not be restored");
} else if (_prefs->bridge_enabled
&& _sensors->gpsUsesSerialUart(_prefs->bridge_uart)) {
savePrefs();
strcpy(reply, "OK - UART GPS paused");
} else {
savePrefs();
strcpy(reply, "OK");
}
}
#endif
#ifdef WITH_ESPNOW_BRIDGE
} else if (memcmp(config, "bridge.channel ", 15) == 0) {
uint8_t channel = 0;
const uint8_t max_channel =
mesh::bridge::espNowMaxChannel(_prefs->bridge_format);
if (mesh::bridge::parseEspNowBridgeChannel(
&config[15], _prefs->bridge_format, channel)) {
_prefs->bridge_channel = channel;
const bool applied =
#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)
!_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge();
#else
!_prefs->bridge_enabled || _callbacks->restartBridge();
#endif
savePrefs();
strcpy(reply, applied ? "OK"
: "Error: setting saved; bridge failed to restart");
} else {
sprintf(reply, "Error: channel must be an integer between 1-%u for %s format",
(unsigned)max_channel,
mesh::bridge::espNowFormatName(_prefs->bridge_format));
}
} else if (memcmp(config, "bridge.secret ", 14) == 0) {
const char* secret = &config[14];
if (secret[0] == 0 || strlen(secret) >= sizeof(_prefs->bridge_secret)) {
sprintf(reply, "Error: secret must be 1-%u characters", (unsigned)(sizeof(_prefs->bridge_secret) - 1));
} else {
StrHelper::strncpy(_prefs->bridge_secret, secret, sizeof(_prefs->bridge_secret));
const bool applied =
#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)
!_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge();
#else
!_prefs->bridge_enabled || _callbacks->restartBridge();
#endif
savePrefs();
strcpy(reply, applied ? "OK"
: "Error: setting saved; bridge failed to restart");
}
} else if (memcmp(config, "bridge.format ", 14) == 0) {
uint8_t format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
if (!mesh::bridge::parseEspNowFormat(&config[14], format)) {
strcpy(reply, "Error: format must be wrapped or raw");
} else if (!mesh::bridge::isValidEspNowBridgeChannel(
_prefs->bridge_channel, format)) {
strcpy(reply, "Error: set bridge.channel to 1-13 before changing format");
} else {
_prefs->bridge_format = format;
const bool applied =
#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE)
!_prefs->espnow_bridge_enabled || _callbacks->restartEspNowBridge();
#else
!_prefs->bridge_enabled || _callbacks->restartBridge();
#endif
savePrefs();
if (!applied) {
strcpy(reply, "Error: setting saved; bridge failed to restart");
} else {
strcpy(reply, format == mesh::bridge::ESPNOW_FORMAT_RAW
? "OK - raw MeshCore frames; bridge.secret is ignored"
: "OK - wrapped bridge frames");
}
}
#endif
} else if (memcmp(config, "adc.multiplier ", 15) == 0) {
float multiplier = 0.0f;
if (!mesh::cli::parseDecimalStrict(&config[15], multiplier)) {
strcpy(reply, "Error: invalid multiplier");
} else if (_board->setAdcMultiplier(multiplier)) {
_prefs->adc_multiplier = multiplier;
savePrefs();
if (_prefs->adc_multiplier == 0.0f) {
strcpy(reply, "OK - using default board multiplier");
} else {
sprintf(reply, "OK - multiplier set to %.3f", _prefs->adc_multiplier);
}
} else {
strcpy(reply, "Error: unsupported");
};
} else if (memcmp(config, "reboot.interval ", 16) == 0) {
int hours = _atoi(&config[16]);
if (hours == 0) {
_prefs->reboot_interval = 0;
savePrefs();
strcpy(reply, "reboot.interval disabled");
} else if (hours < 1 || 255 < hours) {
strcpy(reply, "Error: interval range is 1-255 hours");
} else {
_prefs->reboot_interval = hours;
savePrefs();
sprintf(reply, "OK - reboot.interval set to %d", _prefs->reboot_interval);
}
} 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];
if (strcmp(value, "none") == 0 || strcmp(value, "off") == 0) value = "";
if (!mesh::lr2021::parseSideDetectorSFList(value, sideDetSFs, num)
|| !mesh::lr2021::validateSideDetectorSFs(
sideDetSFs, num, _prefs->sf, _prefs->bw)) {
strcpy(reply, "Invalid extra SF config");
} else {
if (_callbacks->configSideDetectors(sideDetSFs, num, _prefs->bw)) {
memcpy(_prefs->extra_sf, sideDetSFs, sizeof(_prefs->extra_sf));
savePrefs();
strcpy(reply, num == 0 ? "OK - extra SFs cleared" : "OK - extra SFs set");
} else {
strcpy(reply, "Invalid extra SF config");
}
}
#else
strcpy(reply, "Error: extra.sf requires an LR2021 radio");
#endif
} else {
sprintf(reply, "unknown config: %s", config);
}
}
void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) {
const char* config = &command[4];
if (strncmp(config, "repeat.trace", 12) == 0
&& (config[12] == 0 || config[12] == ' ' || config[12] == '\t')) {
if (strcmp(_callbacks->getRole(), "repeater") != 0) {
strcpy(reply, "Error: repeat.trace is only supported by repeaters");
} else if (strcmp(config, "repeat.trace") != 0) {
strcpy(reply, "Error: usage get repeat.trace");
} else {
snprintf(reply, 160, "> %s", _prefs->trace_when_repeat_off == 1 ? "on" : "off");
}
return;
}
#if defined(NRF52_POWER_MANAGEMENT)
if (mesh::power::handleVoltagePolicyCommand(command, reply, 160)) return;
#endif
if (strcmp(config, "password") == 0) {
if (sender_timestamp == 0) {
snprintf(reply, 160, "> %s", _prefs->password);
} else {
strcpy(reply, "Error: get password requires a local connection");
}
return;
}
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED) && !defined(WITH_MQTT_BRIDGE)
if (strcmp(config, "wifi.pwd") == 0) {
if (sender_timestamp != 0) {
strcpy(reply, "> ******** (local connection only)");
} else if (!_callbacks->getWiFiPassword(reply)) {
strcpy(reply, "Error: WiFi password unavailable on this build");
}
return;
}
#endif
if (strcmp(config, "powersaving") == 0) {
snprintf(reply, 160, "> %s", _prefs->powersaving_enabled ? "on" : "off");
return;
}
#if ENV_INCLUDE_GPS == 1
if (strcmp(config, "gps.sync.interval") == 0) {
auto* provider = _sensors->getLocationProvider();
if (provider == nullptr) {
strcpy(reply, "Error: GPS unavailable");
} else {
const uint16_t hours = provider->getTimeSyncIntervalHours();
if (hours == 0) strcpy(reply, "> default (board GPS sync policy)");
else snprintf(reply, 160, "> %u hours", (unsigned)hours);
}
return;
}
if (strcmp(config, "gps") == 0) {
char gps_command[] = "gps";
handleCommand(sender_timestamp, gps_command, reply);
return;
}
#endif
#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW
if (strcmp(config, "espnow.channel") == 0) {
const uint8_t saved = mesh::wifi::loadConfiguredEspNowChannel();
const uint8_t active = mesh::wifi::activeEspNowChannel();
if (saved == active) {
snprintf(reply, 160, "> %u (saved and active)", (unsigned)saved);
} else {
snprintf(reply, 160, "> saved %u, active %u; reboot required",
(unsigned)saved, (unsigned)active);
}
return;
}
#endif
#if MESH_USB_LOGGING_AVAILABLE
if (strcmp(config, "usb.logging") == 0) {
snprintf(reply, 160, "> %s",
mesh::isUsbLoggingEnabled() ? "on" : "off");
return;
}
if (strcmp(config, "usb.debug") == 0) {
snprintf(reply, 160, "> %s",
mesh::isUsbDebugEnabled() ? "on" : "off");
return;
}
#endif
#if MESH_USB_LOGGING_AVAILABLE && defined(WITH_MQTT_BRIDGE)
if (strcmp(config, "logging.output") == 0) {
const bool usb_enabled = mesh::isUsbLoggingEnabled();
const bool wifi_enabled = _prefs->bridge_enabled != 0;
const char* mode = mesh::cli::loggingOutputName(usb_enabled, wifi_enabled);
snprintf(reply, 160, "> %s", mode);
return;
}
#endif
#if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL)
if (isGpioConfig(config)) {
_user_gpio.handleGet(config + 4, reply, 160);
return;
}
#endif
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
if (strcmp(config, "webui") == 0) {
if (!_callbacks->getWebUIStatus(reply)) {
strcpy(reply, "Error: webui not supported on this build");
}
return;
}
#endif
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
if (mesh::cli::classifyStandaloneWiFiGet(config) ==
mesh::cli::StandaloneWiFiKey::CLI) {
if (!_callbacks->getWiFiCLI(reply)) {
strcpy(reply, "Error: WiFi CLI unavailable on this build");
}
return;
}
#endif
// Observer/MQTT/WiFi/timezone/alert/SNMP commands live in CommonCLI_Observer.cpp.
if (handleObserverGetCmd(sender_timestamp, config, reply)) return;
if (mesh::cli::handleStorageLayoutGet(config, *_board, reply, 160)) return;
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (handleSdCardGetCmd(config, reply)) return;
#endif
#if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED)
// Non-MQTT FULL repeater/room-server builds keep WiFi only for WebConfig.
// Their credentials live in the standalone WebConfig NVS namespace rather
// than MQTTPrefs, so expose the WiFi configuration through the role.
const mesh::cli::StandaloneWiFiKey wifi_key =
mesh::cli::classifyStandaloneWiFiGet(config);
bool wifi_supported = true;
switch (wifi_key) {
case mesh::cli::StandaloneWiFiKey::SSID:
wifi_supported = _callbacks->getWiFiSSID(reply);
break;
case mesh::cli::StandaloneWiFiKey::Status:
wifi_supported = _callbacks->getWiFiStatus(reply);
break;
case mesh::cli::StandaloneWiFiKey::PowerSave:
wifi_supported = _callbacks->getWiFiPowerSave(reply);
break;
case mesh::cli::StandaloneWiFiKey::CLI:
wifi_supported = _callbacks->getWiFiCLI(reply);
break;
default:
wifi_supported = false;
break;
}
if (wifi_key != mesh::cli::StandaloneWiFiKey::None) {
if (!wifi_supported) {
strcpy(reply, "Error: WiFi information unavailable on this build");
}
return;
}
#endif
if (isAdvancedRetryConfig(config) && !_callbacks->supportsAdvancedRetryConfig()) {
strcpy(reply, "Error, unsupported on this role");
return;
}
if (isBasicRetryConfig(config) && !_callbacks->supportsBasicRetryConfig()) {
strcpy(reply, "Error, retry configuration unsupported on this role");
return;
}
mesh::cli::RecentRepeaterGetQuery recent_query;
const mesh::cli::RecentRepeaterGetMatch recent_query_match =
mesh::cli::parseRecentRepeaterGet(config, recent_query);
if (configKeyEquals(config, "dutycycle")) {
float dc = 100.0f / (_prefs->airtime_factor + 1.0f);
int dc_int = (int)dc;
int dc_frac = (int)((dc - dc_int) * 10.0f + 0.5f);
sprintf(reply, "> %d.%d%%", dc_int, dc_frac);
} else if (strcmp(config, "system.watchdog") == 0) {
#if defined(NRF52_PLATFORM)
sprintf(reply, "> %s", _prefs->system_watchdog_enabled ? "on" : "off");
#else
strcpy(reply, "Error: unsupported on this platform");
#endif
} else if (configKeyEquals(config, "af")) {
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->airtime_factor));
} else if (configKeyEquals(config, "int.thresh")) {
sprintf(reply, "> %d", (uint32_t) _prefs->interference_threshold);
} else if (configKeyEquals(config, "cad")) {
sprintf(reply, "> %s. # channel busy: %u", _prefs->cad_enabled ? "on" : "off", _board->n_cad_busy);
} else if (configKeyEquals(config, "agc.reset.interval")) {
sprintf(reply, "> %d", ((uint32_t) _prefs->agc_reset_interval) * 4);
} else if (configKeyEquals(config, "multi.acks")) {
sprintf(reply, "> %d", (uint32_t) _prefs->multi_acks);
} else if (configKeyEquals(config, "allow.read.only")) {
sprintf(reply, "> %s", _prefs->allow_read_only ? "on" : "off");
} else if (configKeyEquals(config, "telemetry.access")) {
sprintf(reply, "> %s", _prefs->telemetry_access == TELEMETRY_ACCESS_ACL ? "acl" : "all");
} else if (configKeyEquals(config, "flood.advert.interval")) {
mesh::RepeaterRadioTiming timing;
timing.setTempDuration(_callbacks->getTempRadioDurationSeconds());
sprintf(reply, "> %lu", (unsigned long)timing.floodAdvertHours(_prefs->flood_advert_interval));
} else if (configKeyEquals(config, "advert.interval")) {
mesh::RepeaterRadioTiming timing;
timing.setTempDuration(_callbacks->getTempRadioDurationSeconds());
sprintf(reply, "> %lu", (unsigned long)timing.localAdvertMinutes((uint32_t)_prefs->advert_interval * 2));
} else if (configKeyEquals(config, "guest.password")) {
sprintf(reply, "> %s", _prefs->guest_password);
} else if (sender_timestamp == 0 && configKeyEquals(config, "prv.key")) { // local connections only
uint8_t prv_key[PRV_KEY_SIZE];
int len = _callbacks->getSelfId().writeTo(prv_key, PRV_KEY_SIZE);
mesh::Utils::toHex(tmp, prv_key, len);
sprintf(reply, "> %s", tmp);
} else if (configKeyEquals(config, "name")) {
sprintf(reply, "> %s", _prefs->node_name);
} else if (configKeyEquals(config, "repeat")) {
sprintf(reply, "> %s", _prefs->disable_fwd ? "off" : "on");
} else if (configKeyEquals(config, "lat")) {
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->node_lat));
} else if (configKeyEquals(config, "lon")) {
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->node_lon));
} else if (configKeyEquals(config, "radio.rxgain")) {
sprintf(reply, "> %s", _prefs->rx_boosted_gain ? "on" : "off");
} else if (configKeyEquals(config, "radio.fem.rxgain")) {
if (!_board->canControlLoRaFemLna()) {
strcpy(reply, "Error: unsupported");
} else {
sprintf(reply, "> %s", _board->isLoRaFemLnaEnabled() ? "on" : "off");
}
} else if (configKeyEquals(config, "radio.fem.txgain")) {
if (!_board->canControlLoRaFemPaGain()) {
strcpy(reply, "Error: unsupported");
} else {
sprintf(reply, "> %s", _board->isLoRaFemPaGainEnabled() ? "on" : "off");
}
} else if (memcmp(config, "tempradioat", 11) == 0 && (config[11] == 0 || config[11] == ' ')) {
_callbacks->formatScheduledRadioParams(true, skipSpacesConst(&config[11]), reply);
} else if (memcmp(config, "radioat", 7) == 0 && (config[7] == 0 || config[7] == ' ')) {
_callbacks->formatScheduledRadioParams(false, skipSpacesConst(&config[7]), reply);
} else if (strcmp(config, "radio.rxps.rfrx_disabled") == 0) {
if (!_callbacks->supportsRxPowerSavingRfRxDisable()) {
strcpy(reply, "Error: unsupported");
} else {
sprintf(reply, "> %s", _callbacks->isRxPowerSavingRfRxDisabled() ? "on" : "off");
}
} else if (strcmp(config, "radio.rxps.config") == 0) {
ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us);
sprintf(reply, "> %s,level=%u,preamble=%u,rx=%lu,sleep=%lu",
_prefs->rx_powersaving_enabled ? "on" : "off",
(unsigned)_prefs->rx_ps_level,
(unsigned)_prefs->rx_ps_preamble,
(unsigned long)_prefs->rx_ps_rx_us,
(unsigned long)_prefs->rx_ps_sleep_us);
} else if (configKeyEquals(config, "radio.rxps")) { // RX PowerSaving
ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us);
sprintf(reply, "> %s,%lu,%lu", _prefs->rx_powersaving_enabled ? "on" : "off",
(unsigned long)_prefs->rx_ps_rx_us, (unsigned long)_prefs->rx_ps_sleep_us);
} else if (configKeyEquals(config, "rxps.wd")) {
uint32_t wd_soft, wd_hard;
_callbacks->getRxPsWatchdogCounts(&wd_soft, &wd_hard);
sprintf(reply, "> soft=%lu,hard=%lu", (unsigned long)wd_soft, (unsigned long)wd_hard);
} else if (configKeyEquals(config, "radio")) {
char freq[16], bw[16];
strcpy(freq, StrHelper::ftoa(_prefs->freq));
strcpy(bw, StrHelper::ftoa3(_prefs->bw));
// The official app parses this reply as exactly four CSV fields.
sprintf(reply, "> %s,%s,%d,%d", freq, bw, (uint32_t)_prefs->sf, (uint32_t)_prefs->cr);
} else if (configKeyEquals(config, "rxdelay")) {
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->rx_delay_base));
} else if (configKeyEquals(config, "txdelay")) {
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->tx_delay_factor));
} else if (strcmp(config, "flood.channel.data.hops") == 0) {
char hops[8];
formatFloodChannelHops(hops, _prefs->flood_channel_data_max_hops);
sprintf(reply, "> %s", hops);
} else if (strcmp(config, "flood.channel.data") == 0) {
char hops[8];
formatFloodChannelHops(hops, _prefs->flood_channel_data_max_hops);
sprintf(reply, "> %s %s", _prefs->flood_channel_data_enabled ? "on" : "off", hops);
} else if (configKeyEquals(config, "flood.max.advert")) {
sprintf(reply, "> %d", (uint32_t)_prefs->flood_max_advert);
} else if (configKeyEquals(config, "flood.max.unscoped")) {
sprintf(reply, "> %d", (uint32_t)_prefs->flood_max_unscoped);
} else if (configKeyEquals(config, "flood.max")) {
sprintf(reply, "> %d", (uint32_t)_prefs->flood_max);
} else if (configKeyEquals(config, "direct.txdelay")) {
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->direct_tx_delay_factor));
} else if (configKeyEquals(config, "retry.preset")) {
sprintf(reply, "> %s", retryPresetName(_prefs->retry_preset));
} else if (configKeyEquals(config, "direct.retry")) {
sprintf(reply, "> %s", _prefs->direct_retry_enabled ? "on" : "off");
} else if (configKeyEquals(config, "direct.retry.heard")) {
sprintf(reply, "> %s", _prefs->direct_retry_recent_enabled ? "on" : "off");
} else if (configKeyEquals(config, "direct.retry.margin")) {
char margin[12];
formatSnrDbX4(margin, sizeof(margin), _prefs->direct_retry_snr_margin_x4);
sprintf(reply, "> %s", margin);
} else if (configKeyEquals(config, "direct.retry.count")) {
sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_attempts);
} else if (configKeyEquals(config, "direct.retry.base")) {
sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_base_ms);
} else if (configKeyEquals(config, "direct.retry.step")) {
sprintf(reply, "> %d", (uint32_t)_prefs->direct_retry_step_ms);
} else if (configKeyEquals(config, "flood.retry.count")) {
sprintf(reply, "> %d", (uint32_t)_prefs->flood_retry_attempts);
} else if (configKeyEquals(config, "flood.retry.path")) {
char path_gate[8];
formatFloodRetryPathGate(path_gate, _prefs->flood_retry_max_path);
sprintf(reply, "> %s", path_gate);
} else if (configKeyEquals(config, "flood.retry.group.path")) {
char path_gate[8];
formatFloodRetryPathGate(path_gate, _prefs->flood_retry_group_max_path);
sprintf(reply, "> %s", path_gate);
} else if (configKeyEquals(config, "flood.retry.prefixes")) {
formatFloodRetryPrefixList(tmp, _prefs->flood_retry_prefixes, FLOOD_RETRY_PREFIX_SLOTS);
sprintf(reply, "> %s", tmp[0] ? tmp : "none");
} else if (configKeyEquals(config, "flood.retry.ignore")) {
formatFloodRetryPrefixList(tmp, _prefs->flood_retry_ignore_prefixes, FLOOD_RETRY_IGNORE_PREFIXES);
sprintf(reply, "> %s", tmp[0] ? tmp : "none");
} else if (configKeyEquals(config, "flood.retry.advert")) {
sprintf(reply, "> %s", _prefs->flood_retry_advert_enabled ? "on" : "off");
} else if (configKeyEquals(config, "flood.retry.bridge")) {
sprintf(reply, "> %s", _prefs->flood_retry_bridge_enabled ? "on" : "off");
} else if (memcmp(config, "flood.retry.bucket.", 19) == 0) {
int32_t bucket = 0;
if (mesh::cli::parseIntegerStrict(&config[19], bucket)
&& bucket >= 1 && bucket <= FLOOD_RETRY_BRIDGE_BUCKETS) {
formatFloodRetryPrefixList(tmp, _prefs->flood_retry_bridge_buckets[bucket - 1], FLOOD_RETRY_BUCKET_PREFIXES);
sprintf(reply, "> %s", tmp[0] ? tmp : "none");
} else {
sprintf(reply, "Error, bucket 1-%d", FLOOD_RETRY_BRIDGE_BUCKETS);
}
} else if (configKeyEquals(config, "direct.retry.cr")) {
if (!_prefs->direct_retry_cr_enabled) {
strcpy(reply, "> off");
} else if (!_callbacks->supportsAdvancedRetryConfig()) {
strcpy(reply, "> on");
} else {
char cr4[12], cr5[12], cr7[12], cr8[12];
formatSnrDbX4(cr4, sizeof(cr4), _prefs->direct_retry_cr4_snr_x4);
formatSnrDbX4(cr5, sizeof(cr5), _prefs->direct_retry_cr5_snr_x4);
formatSnrDbX4(cr7, sizeof(cr7), _prefs->direct_retry_cr7_snr_x4);
formatSnrDbX4(cr8, sizeof(cr8), _prefs->direct_retry_cr8_snr_x4);
sprintf(reply, "> %s,%s,%s,%s", cr4, cr5, cr7, cr8);
}
} else if (recent_query_match == mesh::cli::RecentRepeaterGetMatch::Valid) {
_callbacks->formatRecentRepeatersReply(
reply, recent_query.page,
recent_query.search_prefix_len == 0 ? NULL : recent_query.search_prefix,
recent_query.search_prefix_len);
} else if (recent_query_match == mesh::cli::RecentRepeaterGetMatch::Invalid) {
strcpy(reply, "Error, use: get recent.repeaters [page] | search <2|4|6 hex> [page]");
} else if (configKeyEquals(config, "owner.info")) {
*reply++ = '>';
*reply++ = ' ';
const char* sp = _prefs->owner_info;
while (*sp) {
*reply++ = (*sp == '\n') ? '|' : *sp; // translate newline back to orig '|'
sp++;
}
*reply = 0; // set null terminator
} else if (configKeyEquals(config, "path.hash.mode")) {
sprintf(reply, "> %d", (uint32_t)_prefs->path_hash_mode);
} else if (configKeyEquals(config, "loop.detect")) {
if (_prefs->loop_detect == LOOP_DETECT_OFF) {
strcpy(reply, "> off");
} else if (_prefs->loop_detect == LOOP_DETECT_MINIMAL) {
strcpy(reply, "> minimal");
} else if (_prefs->loop_detect == LOOP_DETECT_MODERATE) {
strcpy(reply, "> moderate");
} else {
strcpy(reply, "> strict");
}
} else if (configKeyEquals(config, "tx")) {
sprintf(reply, "> %d", (int32_t) _prefs->tx_power_dbm);
} else if (configKeyEquals(config, "freq")) {
sprintf(reply, "> %s", StrHelper::ftoa(_prefs->freq));
} else if (configKeyEquals(config, "public.key")) {
strcpy(reply, "> ");
mesh::Utils::toHex(&reply[2], _callbacks->getSelfId().pub_key, PUB_KEY_SIZE);
} else if (configKeyEquals(config, "role")) {
sprintf(reply, "> %s", _callbacks->getRole());
} else if (configKeyEquals(config, "bridge.type")) {
sprintf(reply, "> %s",
#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE)
"mqtt+espnow"
#elif defined(WITH_RS232_BRIDGE) && defined(WITH_ESPNOW_BRIDGE)
"rs232+espnow"
#elif defined(WITH_RS232_BRIDGE)
"rs232"
#elif WITH_ESPNOW_BRIDGE
"espnow"
#elif WITH_MQTT_BRIDGE
"mqtt"
#else
"none"
#endif
);
#ifdef WITH_BRIDGE
} else if (configKeyEquals(config, "bridge.enabled")) {
sprintf(reply, "> %s",
#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE)
_prefs->espnow_bridge_enabled ? "on" : "off");
#else
_prefs->bridge_enabled ? "on" : "off");
#endif
} else if (configKeyEquals(config, "bridge.running")) {
sprintf(reply, "> %s",
#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE)
_callbacks->isEspNowBridgeRunning() ? "on" : "off");
#else
_callbacks->isBridgeRunning() ? "on" : "off");
#endif
#if defined(WITH_ESPNOW_BRIDGE)
} else if (configKeyEquals(config, "espnow.enabled")) {
sprintf(reply, "> %s",
#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) \
|| defined(ESPNOW_BRIDGE_MERGED)
_prefs->espnow_bridge_enabled ? "on" : "off");
#else
_prefs->bridge_enabled ? "on" : "off");
#endif
} else if (configKeyEquals(config, "espnow.running")) {
sprintf(reply, "> %s", _callbacks->isEspNowBridgeRunning() ? "on" : "off");
#endif
} else if (configKeyEquals(config, "bridge.delay")) {
sprintf(reply, "> %d", (uint32_t)_prefs->bridge_delay);
} else if (configKeyEquals(config, "bridge.source")) {
sprintf(reply, "> %s", _prefs->bridge_pkt_src ? "rx" : "tx");
#endif
#ifdef WITH_RS232_BRIDGE
} else if (configKeyEquals(config, "bridge.baud")) {
sprintf(reply, "> %d", (uint32_t)_prefs->bridge_baud);
} else if (configKeyEquals(config, "bridge.uart")) {
sprintf(reply, "> %d", (uint32_t)_prefs->bridge_uart);
#endif
#ifdef WITH_ESPNOW_BRIDGE
} else if (configKeyEquals(config, "bridge.channel")) {
sprintf(reply, "> %d", (uint32_t)_prefs->bridge_channel);
} else if (configKeyEquals(config, "bridge.secret")) {
sprintf(reply, "> %s", _prefs->bridge_secret);
} else if (configKeyEquals(config, "bridge.format")) {
sprintf(reply, "> %s",
mesh::bridge::espNowFormatName(_prefs->bridge_format));
#endif
} else if (configKeyEquals(config, "bootloader.ver")) {
#ifdef NRF52_PLATFORM
char ver[128]; // preserve preview/fork/build suffixes without truncation
if (_board->getBootloaderVersion(ver, sizeof(ver))) {
sprintf(reply, "> %s", ver);
} else {
strcpy(reply, "> unknown");
}
#else
strcpy(reply, "Error: unsupported");
#endif
} else if (configKeyEquals(config, "adc.multiplier")) {
float adc_mult = _board->getAdcMultiplier();
if (adc_mult == 0.0f) {
strcpy(reply, "Error: unsupported");
} else {
sprintf(reply, "> %.3f", adc_mult);
}
// Power management commands
} else if (configKeyEquals(config, "pwrmgt.support")) {
#ifdef NRF52_POWER_MANAGEMENT
strcpy(reply, "> supported");
#else
strcpy(reply, "> unsupported");
#endif
} else if (configKeyEquals(config, "pwrmgt.source")) {
#ifdef NRF52_POWER_MANAGEMENT
strcpy(reply, _board->isExternalPowered() ? "> external" : "> battery");
#else
strcpy(reply, "ERROR: Power management not supported");
#endif
} else if (configKeyEquals(config, "pwrmgt.bootreason")) {
sprintf(reply, "> Reset: %s; Shutdown: %s",
_board->getResetReasonString(_board->getResetReason()),
_board->getShutdownReasonString(_board->getShutdownReason()));
} else if (configKeyEquals(config, "pwrmgt.bootmv")) {
#ifdef NRF52_POWER_MANAGEMENT
sprintf(reply, "> %u mV", _board->getBootVoltage());
#else
strcpy(reply, "ERROR: Power management not supported");
#endif
} else if (configKeyEquals(config, "reboot.interval")) {
if (_prefs->reboot_interval == 0) {
strcpy(reply, "disabled");
} else {
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]);
}
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);
}
}
void CommonCLI::handleDelCmd(char* command, char* reply) {
const char* config = &command[4];
if (memcmp(config, "tempradioat", 11) == 0 && (config[11] == 0 || config[11] == ' ')) {
_callbacks->deleteScheduledRadioParams(true, skipSpacesConst(&config[11]), reply);
} else if (memcmp(config, "radioat", 7) == 0 && (config[7] == 0 || config[7] == ' ')) {
_callbacks->deleteScheduledRadioParams(false, skipSpacesConst(&config[7]), reply);
} else {
strcpy(reply, "unknown del: ");
StrHelper::strncpy(&reply[13], config, 160 - 14);
}
}
static char* skipSpaces(char* s) {
while (*s == ' ') s++;
return s;
}
static void rtrimSpaces(char* s) {
char* e = s + strlen(s);
while (e > s && e[-1] == ' ') *--e = '\0';
}
static char* takeToken(char** cursor) {
char* p = skipSpaces(*cursor);
if (*p == '\0') { *cursor = p; return nullptr; }
char* tok = p;
while (*p && *p != ' ') p++;
if (*p) *p++ = '\0';
*cursor = p;
return tok;
}
static char* splitNameJump(char* tok) {
for (char* q = tok; *q; q++) {
if (*q == '|' || *q == ',') {
*q = '\0';
char* jump = skipSpaces(q + 1);
rtrimSpaces(jump);
return jump;
}
}
return nullptr;
}
static bool processRegionDefSegment(RegionMap* map, char* tok, RegionEntry** cursor, char* reply) {
char* jump = splitNameJump(tok);
char* name = skipSpaces(tok);
if (*name == '\0') { snprintf(reply, 160, "Err - empty name"); return false; }
if (jump && *jump == '\0') { snprintf(reply, 160, "Err - empty jump"); return false; }
RegionEntry* r = map->putRegion(name, (*cursor)->id);
if (r == NULL) { snprintf(reply, 160, "Err - put failed: %s", name); return false; }
r->flags = 0;
if (jump) {
RegionEntry* j = map->findByNamePrefix(jump);
if (j == NULL) { snprintf(reply, 160, "Err - unknown or ambiguous jump: %s", jump); return false; }
*cursor = j;
} else {
*cursor = r;
}
return true;
}
void CommonCLI::handleRegionCmd(char* command, char* reply) {
reply[0] = 0;
// `region def`: must run before parseTextParts mutates the buffer
char* cmd = skipSpaces(command);
if (strncmp(cmd, "region def", 10) == 0 && (cmd[10] == ' ' || cmd[10] == '\0')) {
char* payload = skipSpaces(cmd + 10);
rtrimSpaces(payload);
if (*payload == '\0') { snprintf(reply, 160, "Err - empty def"); return; }
// Build the complete definition on a staged copy. A bad name, jump, table
// overflow, or cycle leaves the live hierarchy completely unchanged.
RegionMap staged(*_region_map);
RegionEntry* cursor = &staged.getWildcard();
for (char* tok; (tok = takeToken(&payload)) != nullptr; ) {
if (!processRegionDefSegment(&staged, tok, &cursor, reply)) return;
}
*_region_map = staged;
_region_map->exportTo(reply, 160);
return;
}
const char* parts[4];
int n = mesh::Utils::parseTextParts(command, parts, 4, ' ');
if (n == 1) {
_region_map->exportTo(reply, 160);
} else if (n >= 2 && strcmp(parts[1], "load") == 0) {
_callbacks->startRegionsLoad();
} else if (n >= 2 && strcmp(parts[1], "save") == 0) {
_prefs->discovery_mod_timestamp = getRTCClock()->getCurrentTime(); // this node is now 'modified' (for discovery info)
savePrefs();
bool success = _callbacks->saveRegions();
strcpy(reply, success ? "OK" : "Err - save failed");
} else if (n >= 3 && strcmp(parts[1], "allowf") == 0) {
auto region = _region_map->findByNamePrefix(parts[2]);
if (region) {
region->flags &= ~REGION_DENY_FLOOD;
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - unknown region");
}
} else if (n >= 3 && strcmp(parts[1], "denyf") == 0) {
auto region = _region_map->findByNamePrefix(parts[2]);
if (region) {
region->flags |= REGION_DENY_FLOOD;
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - unknown region");
}
} else if (n >= 3 && strcmp(parts[1], "get") == 0) {
auto region = _region_map->findByNamePrefix(parts[2]);
if (region) {
auto parent = _region_map->findById(region->parent);
if (parent && parent->id != 0) {
sprintf(reply, " %s (%s) %s", region->name, parent->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F");
} else {
sprintf(reply, " %s %s", region->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F");
}
} else {
strcpy(reply, "Err - unknown region");
}
} else if (n >= 3 && strcmp(parts[1], "home") == 0) {
auto home = _region_map->findByNamePrefix(parts[2]);
if (home) {
_region_map->setHomeRegion(home);
sprintf(reply, " home is now %s", home->name);
} else {
strcpy(reply, "Err - unknown region");
}
} else if (n == 2 && strcmp(parts[1], "home") == 0) {
auto home = _region_map->getHomeRegion();
sprintf(reply, " home is %s", home ? home->name : "*");
} else if (n >= 3 && strcmp(parts[1], "default") == 0) {
if (strcmp(parts[2], "<null>") == 0) {
_region_map->setDefaultRegion(NULL);
_callbacks->onDefaultRegionChanged(NULL);
_callbacks->saveRegions(); // persist in one atomic step
sprintf(reply, " default scope is now <null>");
} else {
auto def = _region_map->findByNamePrefix(parts[2]);
if (def == NULL) {
def = _region_map->putRegion(parts[2], 0); // auto-create the default region
}
if (def) {
def->flags = 0; // make sure allow flood enabled
_region_map->setDefaultRegion(def);
_callbacks->onDefaultRegionChanged(def);
_callbacks->saveRegions(); // persist in one atomic step
sprintf(reply, " default scope is now %s", def->name);
} else {
strcpy(reply, "Err - region table full");
}
}
} else if (n == 2 && strcmp(parts[1], "default") == 0) {
auto def = _region_map->getDefaultRegion();
sprintf(reply, " default scope is %s", def ? def->name : "<null>");
} else if (n >= 3 && strcmp(parts[1], "put") == 0) {
auto parent = n >= 4 ? _region_map->findByNamePrefix(parts[3]) : &(_region_map->getWildcard());
if (parent == NULL) {
strcpy(reply, "Err - unknown parent");
} else {
auto region = _region_map->putRegion(parts[2], parent->id);
if (region == NULL) {
strcpy(reply, "Err - unable to put");
} else {
region->flags = 0; // New default: enable flood
strcpy(reply, "OK - (flood allowed)");
}
}
} else if (n >= 3 && strcmp(parts[1], "remove") == 0) {
auto region = _region_map->findByName(parts[2]);
if (region) {
RegionEntry* current_default = _region_map->getDefaultRegion();
bool removed_default = current_default != NULL && current_default->id == region->id;
if (_region_map->removeRegion(*region)) {
if (removed_default) _callbacks->onDefaultRegionChanged(NULL);
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - not empty");
}
} else {
strcpy(reply, "Err - not found");
}
} else if (n >= 3 && strcmp(parts[1], "list") == 0) {
uint8_t mask = 0;
bool invert = false;
if (strcmp(parts[2], "allowed") == 0) {
mask = REGION_DENY_FLOOD;
invert = false; // list regions that DON'T have DENY flag
} else if (strcmp(parts[2], "denied") == 0) {
mask = REGION_DENY_FLOOD;
invert = true; // list regions that DO have DENY flag
} else {
strcpy(reply, "Err - use 'allowed' or 'denied'");
return;
}
int len = _region_map->exportNamesTo(reply, 160, mask, invert);
if (len == 0) {
strcpy(reply, "-none-");
}
} else {
strcpy(reply, "Err - ??");
}
}