server roles, event loops, companion transports, storage and prefs.json

Server roles (slice 7)
- CommonCLI split into upstream's shape; region CLI moved into CommonCLI;
  ClientACL, RegionMap, TransportKeyStore and both server meshes in
  upstream shape over an Arduino compat shim (helpers/compat: Arduino.h,
  Stream/Print, Serial, SHA256, FILESYSTEM/File over Zephyr fs)
- room server processAck port; `get acl` prints as upstream
- room: default name Room-XXXX, display/input rotation, live radio UI refresh

Event loops (slice 7b)
- repeater and room server share server_main_common with a ServerRole
  descriptor; shared event bits in src/mesh_events.h
- room server deadline-driven (idle wakes 2.2/s -> 0.08/s); companion
  loop() runs on housekeeping (closes the lazy contact-flush gap)
- `get loop.wakes` in debug builds (CONFIG_ZEPHCORE_LOOP_WAKE_STATS)

Companion transports and WiFi (slice 8)
- upstream MultiSerialInterface: BLE, USB/UART and TCP are each a
  BaseSerialInterface, every connected transport gets every frame;
  CompanionSerial holds a lossless reply; shared frame_txq for BLE/TCP
- SerialCompanionTransport and LinuxTCPTransport replaced by
  TcpCompanionTransport; ZephyrBLE split (gatt layout, DFU)
- WiFi companion (upstream wifi.ssid/pwd/enabled CLI, TCP port 5000) on
  boards declaring `capabilities: wifi` in zephcore.yml: the PSRAM
  ESP32-S3 boards, plus Heltec V3 (140 contacts), Wireless Tracker (130)
  and both C6 boards (280); ESP32-S3 without PSRAM gets bounded WiFi
  buffers and a measured 72 KB heap; PSRAM boards' internal heap 72 KB
- TCP queues: own depth (ZEPHCORE_TCP_QUEUE_SIZE=8), RX waits instead
  of dropping
- RAM: companion ClientACL dropped; main/BT-TX/TCP stacks sized from
  measured peaks (-7 KB nRF companions, -9 KB ESP32 companions)
- ESP32 WiFi stopped working under ~200 frames/s unicast. Fixed by:
  patches/zephyr/0017 never block the WiFi task in the RX callback;
  patches/zephyr/0018 lock shared_multi_heap (heap corruption with the
  WiFi heap in PSRAM); patches/modules/hal_espressif/0001 backport of
  hal_espressif af97fb02df (PSRAM realloc)

Storage and prefs (slice 9)
- prefs are now prefs.json through upstream's ConfigSerializer (ported
  verbatim), with upstream's key names for shared fields and ZephCore's
  own under "zc"; on first boot the binary new_prefs / repeater prefs is
  migrated and kept, so a downgrade still boots with the old settings
- helpers/PrefsCodec: both legacy binary layouts (companion 272 B,
  server 311 B) as Zephyr-free codecs; sanitizeNodePrefs is the only
  validator
- fix: server prefs could be replaced by a truncated file when a write
  failed; identity, prefs and channel writes go through one checked
  atomic replace (ZephyrFsUtil)
- fix: a corrupt cad_auto byte turned adaptive CAD off instead of the
  default on
- fix: a deliberate 300 s GPS duty on a repeater was rewritten to 48 h
  on every boot (that migration now runs on legacy files only)
- one identity loader for all roles (IdentityFile); an unusable key
  file is always parked as .bad before regenerating
- server store no longer stamps repeater defaults on room server and
  observer; companion first-boot volume policy moved into the store
  (adoptVolume)
- ZephyrDataStore 1435 -> ~850 lines
This commit is contained in:
liquidraver
2026-09-25 08:34:21 +02:00
parent b361c33901
commit 38f815a0a1
114 changed files with 11280 additions and 10308 deletions
+62 -378
View File
@@ -5,6 +5,10 @@
#include "RepeaterDataStore.h"
#include "../adapters/datastore/ZephyrFsFormat.h"
#include "../adapters/datastore/ZephyrFsUtil.h"
#include "../adapters/datastore/IdentityFile.h"
#include "../adapters/datastore/PrefsFile.h"
#include <helpers/PrefsCodec.h>
#include <helpers/LoRaConfig.h>
#include <zephyr/fs/fs.h>
#include <zephyr/sys/util.h>
@@ -39,11 +43,6 @@ bool RepeaterDataStore::begin() {
const char* RepeaterDataStore::getBasePath() const { return BASE_PATH; }
static bool fileExists(const char* path) {
struct fs_dirent entry;
return fs_stat(path, &entry) == 0;
}
bool RepeaterDataStore::hasRoleData() const {
char path[64];
@@ -61,304 +60,101 @@ bool RepeaterDataStore::hasRoleData() const {
* Self-limiting: loadPrefs() persists defaults on boot 1 and main_*.cpp
* saves a generated identity on the same boot, so after one successful
* boot at least one of these exists and the check never fires again. */
static const char* const ours[] = { "prefs", "_main.id" };
static const char* const ours[] = { "prefs.json", "prefs", "_main.id" };
for (size_t i = 0; i < ARRAY_SIZE(ours); i++) {
snprintf(path, sizeof(path), "%s/%s", BASE_PATH, ours[i]);
if (fileExists(path)) return true;
if (zephcore_fs_exists(path)) return true;
}
return false;
}
const char* RepeaterDataStore::getAclPath() const {
static char buf[48];
snprintf(buf, sizeof(buf), "%s/acl", BASE_PATH);
return buf;
}
const char* RepeaterDataStore::getRegionsPath() const {
static char buf[48];
snprintf(buf, sizeof(buf), "%s/regions2", BASE_PATH);
return buf;
}
bool RepeaterDataStore::loadIdentity(mesh::LocalIdentity& id) {
char path[48];
snprintf(path, sizeof(path), "%s/_main.id", BASE_PATH);
struct fs_file_t file;
fs_file_t_init(&file);
int ret = fs_open(&file, path, FS_O_READ);
if (ret < 0) {
LOG_DBG("No identity file at %s", path);
return false;
}
uint8_t buf[PRV_KEY_SIZE + PUB_KEY_SIZE];
ssize_t n = fs_read(&file, buf, sizeof(buf));
fs_close(&file);
LOG_DBG("loadIdentity: read %d bytes from %s", (int)n, path);
if (n >= PRV_KEY_SIZE) {
if (id.readFromStorage(buf, n)) {
LOG_INF("Loaded identity from %s", path);
return true;
}
if (id.recoverFromStorage(buf, n)) {
/* Not re-persisted on purpose — see ZephyrDataStore::loadMainIdentity. */
LOG_WRN("identity pub/prv mismatch - advertising the pub its private key owns");
return true;
}
LOG_ERR("loadIdentity: no coherent key layout in %d bytes", (int)n);
}
/* Unusable pair — the caller regenerates, so park the bytes instead of
* letting a fresh identity overwrite them (same as the companion). */
char bad_path[56];
if (snprintf(bad_path, sizeof(bad_path), "%s.bad", path) < (int)sizeof(bad_path)) {
fs_unlink(bad_path);
if (fs_rename(path, bad_path) == 0) {
LOG_ERR("Identity file corrupt - kept at %s", bad_path);
return false;
}
}
LOG_ERR("Identity file corrupt");
return false;
return zephcore_identity_load(path, id);
}
bool RepeaterDataStore::saveIdentity(const mesh::LocalIdentity& id) {
if (!_initialized) begin();
char path[48];
char tmp_path[56];
snprintf(path, sizeof(path), "%s/_main.id", BASE_PATH);
if (snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path) >= (int)sizeof(tmp_path)) {
return false;
}
/* Guarded by fileExists() rather than unlinking blind: on the normal path
* the temp is absent, fs_unlink() returns -ENOENT, and Zephyr's FS layer
* logs that at ERR level regardless of us ignoring the return -- putting an
* <err> line on the happy path of every save, which is exactly the noise
* that makes a real filesystem error invisible. Same guard as
* ZephyrDataStore::atomicWrite(). */
if (fileExists(tmp_path)) {
fs_unlink(tmp_path);
}
struct fs_file_t file;
fs_file_t_init(&file);
int ret = fs_open(&file, tmp_path, FS_O_CREATE | FS_O_WRITE);
if (ret < 0) {
LOG_ERR("Failed to open %s for write: %d", tmp_path, ret);
return false;
}
/* pub || prv, same as the companion and Arduino MeshCore. Older builds
* wrote prv alone (64 bytes); readFromStorage() still accepts those. */
uint8_t buf[PUB_KEY_SIZE + PRV_KEY_SIZE];
int len = id.writeToStorage(buf, sizeof(buf));
ssize_t n = fs_write(&file, buf, len);
ret = fs_sync(&file);
fs_close(&file);
if (n != len || ret < 0) {
LOG_ERR("Failed to write identity: wrote %d of %d sync=%d", (int)n, len, ret);
fs_unlink(tmp_path);
return false;
}
if (fs_rename(tmp_path, path) < 0) {
LOG_ERR("saveIdentity: rename failed");
fs_unlink(tmp_path);
return false;
}
LOG_INF("Saved identity to %s", path);
return true;
return zephcore_identity_save(path, id);
}
bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) {
char path[48];
snprintf(path, sizeof(path), "%s/prefs", BASE_PATH);
snprintf(path, sizeof(path), "%s/prefs.json", BASE_PATH);
struct fs_file_t file;
fs_file_t_init(&file);
int ret = fs_open(&file, path, FS_O_READ);
if (ret < 0) {
LOG_DBG("No prefs file at %s, using defaults", path);
initNodePrefs(&prefs);
strcpy(prefs.node_name, "Repeater");
prefs.advert_loc_policy = ADVERT_LOC_PREFS;
prefs.loop_detect = LOOP_DETECT_MODERATE;
/* path_hash_mode = 1 comes from initNodePrefs() now. */
prefs.gps_interval = CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC; // repeater default (48h)
/* Persist defaults so flash always has a prefs file from boot 1.
* Lets later code (e.g. tempradio revert) trust that flash is
* authoritative without a "first run" special case. */
savePrefs(prefs);
if (zephcore_prefs_json_load(path, prefs, serverPrefsFromJson)) {
LOG_INF("Loaded prefs from %s", path);
return true;
}
bool had_json = zephcore_fs_exists(path);
struct fs_dirent entry;
ret = fs_stat(path, &entry);
LOG_DBG("loadPrefs: file size = %d bytes", ret < 0 ? 0 : (int)entry.size);
/* No usable prefs.json: the legacy binary file, read once and migrated.
* It is kept, so firmware from before prefs.json still boots with the
* settings as they were at the upgrade. With neither, the caller's prefs
* are the role's defaults (set in its constructor). Either way they are
* saved, so flash always holds a prefs file from boot 1 and later code
* (e.g. tempradio revert) can trust it without a "first run" case. */
char legacy[48];
snprintf(legacy, sizeof(legacy), "%s/prefs", BASE_PATH);
if (zephcore_fs_exists(legacy)) {
if (loadLegacyPrefs(legacy, prefs)) {
LOG_INF("loadPrefs: %s %s from %s", had_json ? "recovered" : "migrated",
path, legacy);
}
} else {
LOG_DBG("No prefs file at %s, saving the role defaults", path);
}
savePrefs(prefs);
return true;
}
uint8_t pad[25];
bool RepeaterDataStore::loadLegacyPrefs(const char* path, NodePrefs& prefs) {
uint8_t buf[SERVER_PREFS_SIZE + 32];
size_t len = 0;
if (!zephcore_fs_read_file(path, buf, sizeof(buf), &len)) {
LOG_ERR("loadPrefs: read of %s failed, using defaults", path);
return false;
}
LOG_DBG("loadPrefs: file size = %d bytes", (int)len);
/* Read prefs in same format as Arduino CommonCLI for compatibility */
fs_read(&file, &prefs.airtime_factor, sizeof(prefs.airtime_factor));
fs_read(&file, &prefs.node_name, sizeof(prefs.node_name));
fs_read(&file, pad, 4);
fs_read(&file, &prefs.node_lat, sizeof(prefs.node_lat));
fs_read(&file, &prefs.node_lon, sizeof(prefs.node_lon));
fs_read(&file, &prefs.password, sizeof(prefs.password));
fs_read(&file, &prefs.freq, sizeof(prefs.freq));
fs_read(&file, &prefs.tx_power_dbm, sizeof(prefs.tx_power_dbm));
fs_read(&file, &prefs.disable_fwd, sizeof(prefs.disable_fwd));
fs_read(&file, &prefs.advert_interval, sizeof(prefs.advert_interval));
fs_read(&file, pad, 1);
fs_read(&file, &prefs.rx_delay_base, sizeof(prefs.rx_delay_base));
fs_read(&file, &prefs.tx_delay_factor, sizeof(prefs.tx_delay_factor));
fs_read(&file, &prefs.guest_password, sizeof(prefs.guest_password));
fs_read(&file, &prefs.direct_tx_delay_factor, sizeof(prefs.direct_tx_delay_factor));
fs_read(&file, &prefs.backoff_multiplier, sizeof(prefs.backoff_multiplier));
fs_read(&file, &prefs.sf, sizeof(prefs.sf));
fs_read(&file, &prefs.cr, sizeof(prefs.cr));
fs_read(&file, &prefs.allow_read_only, sizeof(prefs.allow_read_only));
fs_read(&file, &prefs.multi_acks, sizeof(prefs.multi_acks));
fs_read(&file, &prefs.bw, sizeof(prefs.bw));
/* 120: leds_disabled, magic-encoded. Formerly agc_reset_interval — see the
* LEDS_PREF_* comment in NodePrefs.h for why this is not a bare 0/1.
* leds_byte stays 0 (→ LEDs on) if the file is short. */
uint8_t leds_byte = 0;
fs_read(&file, &leds_byte, sizeof(leds_byte));
fs_read(&file, &prefs.path_hash_mode, sizeof(prefs.path_hash_mode));
fs_read(&file, &prefs.loop_detect, sizeof(prefs.loop_detect));
fs_read(&file, pad, 1);
fs_read(&file, &prefs.flood_max, sizeof(prefs.flood_max));
fs_read(&file, &prefs.flood_advert_interval, sizeof(prefs.flood_advert_interval));
fs_read(&file, &prefs.interference_threshold, sizeof(prefs.interference_threshold));
fs_read(&file, pad, 25); // skip bridge settings
fs_read(&file, &prefs.powersaving_enabled, sizeof(prefs.powersaving_enabled));
fs_read(&file, pad, 3);
fs_read(&file, &prefs.gps_enabled, sizeof(prefs.gps_enabled));
fs_read(&file, &prefs.gps_interval, sizeof(prefs.gps_interval));
fs_read(&file, &prefs.advert_loc_policy, sizeof(prefs.advert_loc_policy));
fs_read(&file, &prefs.discovery_mod_timestamp, sizeof(prefs.discovery_mod_timestamp));
fs_read(&file, &prefs.adc_multiplier, sizeof(prefs.adc_multiplier));
fs_read(&file, prefs.owner_info, sizeof(prefs.owner_info));
/* ZephCore extensions — absent in old 290-byte files; fs_read past EOF is a
* no-op so these fields keep the initNodePrefs() defaults the caller passed
* in (rx_boost=1, rx_duty_cycle=0). The upgrade block below forces
* repeater-specific values for old files. */
fs_read(&file, &prefs.rx_boost, sizeof(prefs.rx_boost));
fs_read(&file, &prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle));
/* RESERVED — formerly apc_enabled / apc_margin (APC, removed in 1.16.6).
* Still consumed so the fields after them stay at their stored offsets. */
fs_read(&file, &prefs._reserved_apc_enabled, sizeof(prefs._reserved_apc_enabled));
fs_read(&file, &prefs._reserved_apc_margin, sizeof(prefs._reserved_apc_margin));
/* Flood hop-ceiling extensions (absent in <296-byte files; the no-op EOF
* read leaves the constructor defaults flood_max_unscoped=64, flood_max_advert=8). */
fs_read(&file, &prefs.flood_max_unscoped, sizeof(prefs.flood_max_unscoped));
fs_read(&file, &prefs.flood_max_advert, sizeof(prefs.flood_max_advert));
/* Mesh time sync (absent in <297-byte files; no-op EOF read keeps default 0 = off) */
fs_read(&file, &prefs.meshtimesync, sizeof(prefs.meshtimesync));
/* Adaptive CAD (absent in <300-byte files; no-op EOF reads keep defaults
* auto=0, offset=0, probe_interval=60) */
fs_read(&file, &prefs.cad_auto, sizeof(prefs.cad_auto));
fs_read(&file, &prefs.cad_offset, sizeof(prefs.cad_offset));
fs_read(&file, &prefs.probe_interval, sizeof(prefs.probe_interval));
/* cad_busycap absent in <301-byte files; EOF read keeps default 25 */
fs_read(&file, &prefs.cad_busycap, sizeof(prefs.cad_busycap));
/* LR2021 side-detector SFs, offsets 301-303. Absent in <304-byte files;
* the no-op EOF read leaves the zeroed default = feature off. */
fs_read(&file, prefs.extra_sf, sizeof(prefs.extra_sf));
/* External FEM RX gain, offset 304. Absent in <305-byte files; the no-op
* EOF read keeps the initNodePrefs() default fem_rxgain=1, which is what
* every already-deployed node has been running. */
fs_read(&file, &prefs.fem_rxgain, sizeof(prefs.fem_rxgain));
/* Mounting orientation, offsets 305-306. Absent in <307-byte files; the
* no-op EOF reads keep the initNodePrefs() defaults of 0/0, which is the
* stock orientation every already-deployed node runs. */
fs_read(&file, &prefs.display_rotate, sizeof(prefs.display_rotate));
fs_read(&file, &prefs.input_rotate, sizeof(prefs.input_rotate));
/* Family base detPeak cad_offset was learned against, offset 307. Absent
* in <308-byte files; the no-op EOF read leaves 0, which setCadParams()
* reads as "no base recorded" and acts on by leaving the stored offset
* alone — correct, since a node upgrading across a table change cannot
* know which base its offset came from. */
fs_read(&file, &prefs.cad_base, sizeof(prefs.cad_base));
/* Display timezone offset, offset 308. Absent in <309-byte files; the
* no-op EOF read leaves 0 = UTC, which is what every already-deployed
* node shows today. Range is re-checked by sanitizeNodePrefs(). */
fs_read(&file, &prefs.tz_offset, sizeof(prefs.tz_offset));
/* LED activity/heartbeat modes, offsets 309-310. Absent in <311-byte
* files; the no-op EOF read leaves the initNodePrefs() defaults of 0/0,
* which are deliberately the behaviour every already-deployed node has
* (activity LED on transmit, heartbeat with unread indication). */
fs_read(&file, &prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode));
fs_read(&file, &prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode));
if (len >= 120) {
float freq, bw;
memcpy(&freq, &buf[72], sizeof(freq));
memcpy(&bw, &buf[116], sizeof(bw));
if (freq < ZC_RADIO_FREQ_MIN_MHZ || freq > ZC_RADIO_FREQ_MAX_MHZ ||
buf[112] < 5 || buf[112] > 12 ||
bw < ZC_RADIO_BW_MIN_KHZ || bw > ZC_RADIO_BW_MAX_KHZ) {
LOG_WRN("Invalid radio params in prefs, using defaults: freq=%.3f sf=%u bw=%.1f",
(double)freq, buf[112], (double)bw);
}
}
/* Radio params outside ZC_RADIO_* fall back to the LoRaConfig defaults,
* everything else is sanitized; fields past the end keep the caller's. */
serverPrefsDecode(prefs, buf, len);
fs_close(&file);
/* Only the explicit "off" magic disables LEDs; a legacy AGC interval or an
* unwritten byte both mean "on". */
prefs.leds_disabled = (leds_byte == LEDS_PREF_OFF) ? 1 : 0;
/* The 0.0-or-NaN -> 0.2 coercion that used to live here is gone. It could
* not tell "field absent from an old file" from "the user set 0.0 to turn
* reactive backoff off", so the documented off switch never survived a
* reboot. Both cases are now handled properly: initNodePrefs() supplies
* 0.2 and a short-file fs_read() is a no-op that keeps it, while NaN and
* out-of-range values are caught by sanitizeNodePrefs(). */
LOG_INF("Loaded prefs from %s", path);
LOG_DBG(" name='%s' freq=%.3f sf=%u bw=%.1f tx_pwr=%d",
prefs.node_name, (double)prefs.freq, prefs.sf, (double)prefs.bw, prefs.tx_power_dbm);
/* Validate radio params - use defaults if garbage */
if (prefs.freq < ZC_RADIO_FREQ_MIN_MHZ || prefs.freq > ZC_RADIO_FREQ_MAX_MHZ ||
prefs.sf < 5 || prefs.sf > 12 ||
prefs.bw < ZC_RADIO_BW_MIN_KHZ || prefs.bw > ZC_RADIO_BW_MAX_KHZ) {
LOG_WRN("Invalid radio params in prefs, using defaults: freq=%.3f sf=%u bw=%.1f",
(double)prefs.freq, prefs.sf, (double)prefs.bw);
prefs.freq = mesh::LoRaConfig::FREQ_MHZ;
prefs.bw = mesh::LoRaConfig::BANDWIDTH_KHZ;
prefs.sf = mesh::LoRaConfig::SPREADING_FACTOR;
prefs.cr = mesh::LoRaConfig::CODING_RATE;
prefs.tx_power_dbm = mesh::LoRaConfig::TX_POWER_DBM;
}
/* Everything else that came off flash — bounds, NaNs, and the char fields,
* which the file format stores without terminators. */
sanitizeNodePrefs(&prefs);
/* One-time format upgrade: old files (< 294 bytes) never saved the ZephCore
* extension fields, and stored path_hash_mode/loop_detect as zero padding.
* Apply repeater defaults and re-save so values survive subsequent reboots. */
if (ret >= 0 && entry.size < 294) {
/* Old files (< 294 bytes) never saved the ZephCore extension fields, and
* stored path_hash_mode/loop_detect as zero padding: the repeater defaults. */
if (len < 294) {
prefs.rx_boost = 1;
prefs.path_hash_mode = 1;
prefs.loop_detect = LOOP_DETECT_MODERATE;
savePrefs(prefs);
LOG_INF("loadPrefs: upgraded prefs format (%d -> 297 bytes)", (int)entry.size);
LOG_INF("loadPrefs: upgraded an old %d-byte prefs file", (int)len);
}
/* Repeater GPS-interval unification migration: before this firmware the
* repeater ignored gps_interval (hardcoded 48h), so a stored companion
* default (300) was never a deliberate choice. Bump it to the repeater
* default once, so now-honoring the field doesn't silently switch existing
* units to 5-min GPS polling. (Triggers only on exactly 300; after the
* one-time rewrite it won't re-fire. A deliberate 300 on a repeater isn't
* reachable via the CLI — use 299/301 if you really want ~5 min.) */
/* Before the repeater honoured gps_interval it ran a fixed 48 h, so a
* stored companion default (300) was never a choice: the repeater
* default instead. Legacy files only; prefs.json is past this. */
if (prefs.gps_interval == CONFIG_ZEPHCORE_GPS_POLL_INTERVAL_SEC) {
prefs.gps_interval = CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC;
savePrefs(prefs);
}
return true;
}
@@ -366,120 +162,8 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) {
if (!_initialized) begin();
char path[48];
char tmp_path[56];
snprintf(path, sizeof(path), "%s/prefs", BASE_PATH);
if (snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path) >= (int)sizeof(tmp_path)) {
return false;
}
/* Guarded by fileExists() rather than unlinking blind: on the normal path
* the temp is absent, fs_unlink() returns -ENOENT, and Zephyr's FS layer
* logs that at ERR level regardless of us ignoring the return -- putting an
* <err> line on the happy path of every save, which is exactly the noise
* that makes a real filesystem error invisible. Same guard as
* ZephyrDataStore::atomicWrite(). */
if (fileExists(tmp_path)) {
fs_unlink(tmp_path);
}
struct fs_file_t file;
fs_file_t_init(&file);
int ret = fs_open(&file, tmp_path, FS_O_CREATE | FS_O_WRITE);
if (ret < 0) {
LOG_ERR("Failed to open %s for write: %d", tmp_path, ret);
return false;
}
uint8_t pad[25];
memset(pad, 0, sizeof(pad));
/* Write prefs in same format as Arduino CommonCLI for compatibility */
fs_write(&file, &prefs.airtime_factor, sizeof(prefs.airtime_factor));
fs_write(&file, &prefs.node_name, sizeof(prefs.node_name));
fs_write(&file, pad, 4);
fs_write(&file, &prefs.node_lat, sizeof(prefs.node_lat));
fs_write(&file, &prefs.node_lon, sizeof(prefs.node_lon));
fs_write(&file, &prefs.password, sizeof(prefs.password));
fs_write(&file, &prefs.freq, sizeof(prefs.freq));
fs_write(&file, &prefs.tx_power_dbm, sizeof(prefs.tx_power_dbm));
fs_write(&file, &prefs.disable_fwd, sizeof(prefs.disable_fwd));
fs_write(&file, &prefs.advert_interval, sizeof(prefs.advert_interval));
fs_write(&file, pad, 1);
fs_write(&file, &prefs.rx_delay_base, sizeof(prefs.rx_delay_base));
fs_write(&file, &prefs.tx_delay_factor, sizeof(prefs.tx_delay_factor));
fs_write(&file, &prefs.guest_password, sizeof(prefs.guest_password));
fs_write(&file, &prefs.direct_tx_delay_factor, sizeof(prefs.direct_tx_delay_factor));
fs_write(&file, &prefs.backoff_multiplier, sizeof(prefs.backoff_multiplier));
fs_write(&file, &prefs.sf, sizeof(prefs.sf));
fs_write(&file, &prefs.cr, sizeof(prefs.cr));
fs_write(&file, &prefs.allow_read_only, sizeof(prefs.allow_read_only));
fs_write(&file, &prefs.multi_acks, sizeof(prefs.multi_acks));
fs_write(&file, &prefs.bw, sizeof(prefs.bw));
/* 120: leds_disabled, magic-encoded (was agc_reset_interval). */
{
uint8_t leds_byte = prefs.leds_disabled ? LEDS_PREF_OFF : LEDS_PREF_ON;
fs_write(&file, &leds_byte, sizeof(leds_byte));
}
fs_write(&file, &prefs.path_hash_mode, sizeof(prefs.path_hash_mode));
fs_write(&file, &prefs.loop_detect, sizeof(prefs.loop_detect));
fs_write(&file, pad, 1);
fs_write(&file, &prefs.flood_max, sizeof(prefs.flood_max));
fs_write(&file, &prefs.flood_advert_interval, sizeof(prefs.flood_advert_interval));
fs_write(&file, &prefs.interference_threshold, sizeof(prefs.interference_threshold));
fs_write(&file, pad, 25); // skip bridge settings
fs_write(&file, &prefs.powersaving_enabled, sizeof(prefs.powersaving_enabled));
fs_write(&file, pad, 3);
fs_write(&file, &prefs.gps_enabled, sizeof(prefs.gps_enabled));
fs_write(&file, &prefs.gps_interval, sizeof(prefs.gps_interval));
fs_write(&file, &prefs.advert_loc_policy, sizeof(prefs.advert_loc_policy));
fs_write(&file, &prefs.discovery_mod_timestamp, sizeof(prefs.discovery_mod_timestamp));
fs_write(&file, &prefs.adc_multiplier, sizeof(prefs.adc_multiplier));
fs_write(&file, prefs.owner_info, sizeof(prefs.owner_info));
/* ZephCore extensions */
fs_write(&file, &prefs.rx_boost, sizeof(prefs.rx_boost));
fs_write(&file, &prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle));
/* RESERVED — formerly apc_enabled / apc_margin (removed in 1.16.6).
* Written back unchanged to hold the layout. */
fs_write(&file, &prefs._reserved_apc_enabled, sizeof(prefs._reserved_apc_enabled));
fs_write(&file, &prefs._reserved_apc_margin, sizeof(prefs._reserved_apc_margin));
/* Flood hop-ceiling extensions (extend the format past 294 bytes) */
fs_write(&file, &prefs.flood_max_unscoped, sizeof(prefs.flood_max_unscoped));
fs_write(&file, &prefs.flood_max_advert, sizeof(prefs.flood_max_advert));
/* Mesh time sync on/off (offset 296) */
fs_write(&file, &prefs.meshtimesync, sizeof(prefs.meshtimesync));
/* Adaptive CAD (offsets 297-300) */
fs_write(&file, &prefs.cad_auto, sizeof(prefs.cad_auto));
fs_write(&file, &prefs.cad_offset, sizeof(prefs.cad_offset));
fs_write(&file, &prefs.probe_interval, sizeof(prefs.probe_interval));
fs_write(&file, &prefs.cad_busycap, sizeof(prefs.cad_busycap));
/* LR2021 side-detector SFs (offsets 301-303) */
fs_write(&file, prefs.extra_sf, sizeof(prefs.extra_sf));
/* External FEM RX gain (offset 304) */
fs_write(&file, &prefs.fem_rxgain, sizeof(prefs.fem_rxgain));
/* Mounting orientation (offsets 305-306) */
fs_write(&file, &prefs.display_rotate, sizeof(prefs.display_rotate));
fs_write(&file, &prefs.input_rotate, sizeof(prefs.input_rotate));
/* Family base detPeak cad_offset was learned against (offset 307) */
fs_write(&file, &prefs.cad_base, sizeof(prefs.cad_base));
/* Display timezone offset (offset 308) — signed whole hours from UTC,
* applied only when formatting the on-device clock */
fs_write(&file, &prefs.tz_offset, sizeof(prefs.tz_offset));
/* LED activity/heartbeat modes (offsets 309-310) */
fs_write(&file, &prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode));
fs_write(&file, &prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode));
ret = fs_sync(&file);
fs_close(&file);
if (ret < 0) {
LOG_ERR("savePrefs: sync failed: %d", ret);
fs_unlink(tmp_path);
return false;
}
if (fs_rename(tmp_path, path) < 0) {
LOG_ERR("savePrefs: rename failed");
fs_unlink(tmp_path);
snprintf(path, sizeof(path), "%s/prefs.json", BASE_PATH);
if (!zephcore_prefs_json_save(path, prefs, serverPrefsToJson)) {
return false;
}
LOG_INF("Saved prefs to %s", path);