cad, meshamerica flasher and other polishes

This commit is contained in:
liquidraver
2026-07-13 09:00:14 +02:00
parent 0c6e2a7058
commit 9eb33e27dc
6 changed files with 277 additions and 97 deletions
+58 -5
View File
@@ -36,10 +36,60 @@ import sys
# Provider identity
# ---------------------------------------------------------------------------
MAKER_KEY = "zephcore"
MAKER_NAME = "ZephCore"
DEVICE_CLASS = "zephcore"
# `maker` is the DEVICE MANUFACTURER (not the firmware provider — the ZephCore
# provider badge comes from Mesh America's registration, independent of this).
# Folded devices use MeshCore's exact maker key so they group under the same
# brand; new tiles use the hardware maker. Display names for the maker map:
MAKERS = {
"rak": "RAK Wireless",
"seeed": "Seeed Studio",
"elecrow": "Elecrow",
"lilygo": "LilyGo",
"heltec": "Heltec",
"promicro": "ProMicro",
"gat-iot": "GAT-IoT",
"uniteng": "UnitEng",
"Ikoka": "Ikoka",
"femtofox": "Femtofox",
}
# Manufacturer per device name.
MAKER_BY_DEVICE = {
"RAK WisBlock / WisMesh (RAK 4631)": "rak",
"RAK WisMesh 1W Booster (3401 + 13302)": "rak",
"RAK WisMesh Tag": "rak",
"Seeed Studio Wio Tracker L1 Pro": "seeed",
"Seeed Studio SenseCAP T1000-E": "seeed",
"Seeed Studio SenseCAP Solar": "seeed",
"Seeed Studio Xiao nRF52 WIO": "seeed",
"Seeed Studio Xiao C3": "seeed",
"Seeed Studio Xiao S3 WIO": "seeed",
"Seeed Studio Xiao ESP32-C6": "seeed",
"Elecrow ThinkNode M1": "elecrow",
"Elecrow ThinkNode M3": "elecrow",
"Elecrow ThinkNode M6": "elecrow",
"Ikoka Nano": "Ikoka",
"LilyGo T-Echo": "lilygo",
"LilyGo T-Beam (SX1262)": "lilygo",
"LilyGo T-Impulse Plus": "lilygo",
"LilyGo T-Lora C6": "lilygo",
"ProMicro nrf52 (faketec)": "promicro",
"Heltec T114": "heltec",
"Heltec T096": "heltec",
"Heltec v3": "heltec",
"Heltec v4": "heltec",
"Heltec v4.3": "heltec",
"Heltec Wireless Tracker": "heltec",
"Heltec Wireless Tracker v2": "heltec",
"GAT-IoT GAT562 30s": "gat-iot",
"UnitEng Station G2": "uniteng",
"Femtofox (Luckfox Pico Mini)": "femtofox",
"RAK6421 WisMesh (Raspberry Pi)": "rak",
"RAK6421 WisMesh (Raspberry Pi 5)": "rak",
}
DESCRIPTION = (
"ZephCore is an independent, ground-up implementation of the MeshCore "
"protocol on the Zephyr RTOS. It is wire-compatible with stock MeshCore "
@@ -99,7 +149,7 @@ BOARDS = [
# --- nRF52: new ZephCore-only hardware (own tile) ---------------------
dict(stem="lilygo_timpulse_plus", kind="nrf", device="LilyGo T-Impulse Plus", new=True, img="lora.svg"),
dict(stem="heltec_t096", kind="nrf", device="Heltec T96", new=True, img="lora.svg"),
dict(stem="heltec_t096", kind="nrf", device="Heltec T096", new=True, img="lora.svg"),
# --- ESP32 (sysbuild/MCUboot, -merged.bin): fold ---------------------
dict(stem="xiao_esp32c3", kind="esp32", device="Seeed Studio Xiao C3"),
@@ -182,6 +232,7 @@ def build(assets, url_base, version):
devices = {} # name -> device object (first-seen order preserved)
order = []
used_roles = set()
used_makers = set()
stats = {"fold": 0, "new": 0, "skipped": []}
def spec_files(triples):
@@ -208,8 +259,10 @@ def build(assets, url_base, version):
continue
if name not in devices:
maker = MAKER_BY_DEVICE[name] # KeyError if a device is unmapped
used_makers.add(maker)
dev = {
"maker": MAKER_KEY,
"maker": maker,
"class": DEVICE_CLASS,
"name": name,
"type": DEVICE_TYPE[board["kind"]],
@@ -230,7 +283,7 @@ def build(assets, url_base, version):
catalog = {
"description": DESCRIPTION,
"maker": {MAKER_KEY: {"name": MAKER_NAME}},
"maker": {k: {"name": MAKERS[k]} for k in sorted(used_makers)},
"device": [devices[n] for n in order],
}
role_map = {k: v for k, v in CUSTOM_ROLES.items() if k in used_roles}
+28 -12
View File
@@ -66,19 +66,35 @@ reset completely whenever the radio parameters change (frequency, SF, BW
`cad.auto` ships **on**, on repeaters and companions alike. A one-sided
staircase controller acts on the probe stats:
- Probes concentrate on the **frontier** — one level more sensitive than
the current operating point (3 of every 4 probes), with the remainder
self-checking the operating level.
- **Step down** (more sensitive) when the frontier has ≥300 samples and
its false-positive rate is ≤1%.
- **Step up** (less sensitive) quickly when the operating level itself
shows a false-positive rate above 2% over ≥50 samples — false positives
at the operating point cost real transmissions.
- The offset is persisted to flash whenever it steps.
The controller is **knee-seeking**. The false-positive-vs-detPeak curve
falls as detPeak rises (less sensitive → fewer false detects) and flattens
past a knee; the sweet spot is that knee — the most sensitive detPeak whose
FP has already bottomed out. Probes sample the operating level and **both
neighbours** (op1 more sensitive, op+1 less sensitive; op weighted half, each
neighbour a quarter) so the staircase can read the local curve *slope*:
At the default 15-second probe interval a step decision lands roughly
every **12 hours**, so the node tracks a changing RF environment within
that window without thrashing.
- **Step up** (less sensitive) when the level above is markedly cleaner —
FP drops ≥5%/level (`CAD_KNEE_SLOPE_PERMILLE`). That means we're on the
steep part below the knee; climb toward it.
- **Step down** (more sensitive) only on a flat plateau (frontier no worse
than operating, slope <5%/level) that is **already clean** (FP ≤5%,
`CAD_PLATEAU_CLEAN_PERMILLE`) — reclaim sensitivity that costs nothing.
- Otherwise **hold** — either at the knee (steep below, flat above) or on a
noisy flat plateau.
Using slopes rather than an absolute FP target makes convergence independent
of a site's FP *floor* (which varies with traffic and with the classifier's
residual false-positive rate). The clean-plateau guard is what stops a
flat-but-noisy curve from walking to the sensitive rail: there, holding is the
least-bad move, while a genuinely quiet flat-low site correctly descends to
the floor. Each involved rung needs ≥120 samples (`CAD_STEP_MIN_PROBES`)
before a step, so a decision lands roughly every 12 hours at the default
interval.
A step decision requires the operating rung (and, for the direction chosen,
its neighbour) to be warm; `get cad`'s three-rung window shows exactly those
levels, so the slope the controller is acting on is visible directly. The
offset is persisted to flash whenever it steps.
The offset is clamped to **8…+12** levels around the family base — wide
enough that a dense hilltop can settle much less sensitive and a quiet
+120 -55
View File
@@ -990,48 +990,77 @@ int8_t LoRaRadioBase::pickCadProbeLevel()
return (int8_t)(CAD_SWEEP_MIN + (_cad_probe_rr % span));
}
/* Auto: concentrate samples on the frontier (one step more sensitive
* than the operating point); every 4th probe self-checks the
* operating level. */
int8_t frontier = _cad_offset > CAD_LEVEL_MIN ? (int8_t)(_cad_offset - 1)
: _cad_offset;
return ((_cad_probe_rr & 3) == 0) ? _cad_offset : frontier;
/* Auto: sample the operating level AND both neighbours so the staircase
* can read the local FP curvature (slope below vs. above) and seek the
* knee. op is the shared term of both slopes → weight it half; each
* neighbour a quarter. Out-of-range neighbours fall back to op. */
int8_t lvl;
switch (_cad_probe_rr & 3) {
case 1: lvl = (int8_t)(_cad_offset - 1); break; /* more sensitive */
case 3: lvl = (int8_t)(_cad_offset + 1); break; /* less sensitive */
default: lvl = _cad_offset; break; /* operating (0, 2) */
}
if (lvl < CAD_LEVEL_MIN || lvl > CAD_LEVEL_MAX) {
lvl = _cad_offset;
}
return lvl;
}
void LoRaRadioBase::cadStaircaseStep()
{
/* Step down (more sensitive) when the frontier level has enough
* samples and its suspected-FP rate is at or under target. */
if (_cad_offset > CAD_LEVEL_MIN) {
CadLevelStats &f = _cad_stats[(_cad_offset - 1) - CAD_LEVEL_MIN];
/* Knee-seeking controller — see the CAD_KNEE_SLOPE / CAD_PLATEAU_CLEAN
* notes in radio_common.h. Reads local curvature from three rungs and
* steps toward the knee (the most sensitive detPeak whose FP has already
* bottomed out), using slopes so the decision is site-floor-independent. */
int oi = _cad_offset - CAD_LEVEL_MIN;
if (f.probes >= CAD_STEP_DOWN_MIN_PROBES &&
(uint32_t)f.fp * 1000U <=
(uint32_t)f.probes * CAD_FP_TARGET_PERMILLE) {
_cad_offset--;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: step down -> offset %d (frontier %up/%ufp)",
(int)_cad_offset, f.probes, f.fp);
return;
/* Per-level FP rate in permille, or -1 when too few samples to trust. */
auto rate = [&](int idx) -> int {
if (idx < 0 || idx >= CAD_NUM_LEVELS) {
return -1;
}
CadLevelStats &s = _cad_stats[idx];
if (s.probes < CAD_STEP_MIN_PROBES) {
return -1;
}
return (int)(((uint32_t)s.fp * 1000U) / s.probes);
};
int r_op = rate(oi);
if (r_op < 0) {
return; /* operating level not warm yet — no basis to step */
}
int r_up = rate(oi + 1); /* one step less sensitive */
int r_dn = rate(oi - 1); /* frontier, one step more sensitive */
/* Step UP (less sensitive) when the level above is markedly cleaner —
* we're on the steep part of the curve, below the knee. */
if (_cad_offset < CAD_LEVEL_MAX && r_up >= 0 &&
r_op - r_up >= CAD_KNEE_SLOPE_PERMILLE) {
_cad_offset++;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: step up -> offset %d (op %d dn->up %d)",
(int)_cad_offset, r_op, r_up);
return;
}
/* Step up (less sensitive) when the operating level itself shows
* FPs well above target. Lower sample bar: FPs at the operating
* point cost real TX opportunities, react quickly. */
if (_cad_offset < CAD_LEVEL_MAX) {
CadLevelStats &o = _cad_stats[_cad_offset - CAD_LEVEL_MIN];
if (o.probes >= CAD_STEP_UP_MIN_PROBES &&
(uint32_t)o.fp * 1000U >
(uint32_t)o.probes * 2U * CAD_FP_TARGET_PERMILLE) {
_cad_offset++;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: step up -> offset %d (operating %up/%ufp)",
(int)_cad_offset, o.probes, o.fp);
}
/* Step DOWN (more sensitive) only on a flat plateau that is already
* clean: the frontier is no worse than operating (nothing to lose) AND
* FP here is low enough that reclaiming sensitivity is cheap. The clean
* guard is what keeps a flat-but-noisy curve from descending to the
* sensitive rail — there, holding position is the least-bad move. */
if (_cad_offset > CAD_LEVEL_MIN && r_dn >= 0 &&
r_dn - r_op < CAD_KNEE_SLOPE_PERMILLE &&
r_op <= CAD_PLATEAU_CLEAN_PERMILLE) {
_cad_offset--;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: step down -> offset %d (op %d dn %d)",
(int)_cad_offset, r_op, r_dn);
return;
}
/* Otherwise: at the knee (steep below, flat above) or a noisy flat
* plateau — hold. */
}
void LoRaRadioBase::cadMaintenance()
@@ -1107,22 +1136,48 @@ void LoRaRadioBase::cadMaintenance()
if (ret > 0) {
s.busy++;
/* Ground-truth post-check: a real LoRa signal that tripped
* CAD keeps transmitting — after RX restart its preamble or
* header trips the receive path within a few symbols. Wait
* ~8 symbols, then classify. RX is already armed, so the
* packet itself is not at risk during this sleep. */
/* Ground-truth post-check: was the CAD hit a REAL signal or a
* correlator false positive? A real transmitter that tripped
* CAD keeps radiating, so over the next preamble+header window
* one of two things shows up:
* (a) the restarted RX syncs on it -> isReceiving(), or
* (b) instantaneous RSSI climbs above the noise floor.
* (b) is the important addition: the probe tears RX down to run
* CAD, and the STANDBY->RX restart routinely eats the preamble of
* a real packet, so RX never re-syncs — the old isReceiving()-only
* snapshot booked those strong-but-missed packets as false
* positives, a ~detPeak-independent floor that flattened the FP
* curve and drove the staircase to the ceiling. Channel energy
* doesn't depend on winning the preamble race, so it recovers
* them. Neither signal over the whole window => genuine FP. A
* below-floor packet we can neither sync nor see stays ambiguous
* and counts as FP — bias toward higher detPeak (the safe side).
* The prefilter above guaranteed RSSI <= floor+guard pre-probe,
* so a rise past that threshold now is a newly-arrived signal. */
uint8_t sf = getActiveSpreadingFactor();
uint16_t bw_x10 = getActiveBandwidthKHzX10();
uint32_t tsym_us = bw_x10 ? (uint32_t)(((1UL << sf) * 10000UL)
/ bw_x10) : 1024;
uint32_t wait_ms = (8U * tsym_us) / 1000U;
uint32_t step_ms = (3U * tsym_us) / 1000U; /* ~3 symbols/sample */
if (wait_ms < 20) wait_ms = 20;
if (wait_ms > 400) wait_ms = 400;
k_sleep(K_MSEC(wait_ms));
if (step_ms < 5) step_ms = 5;
if (step_ms > 100) step_ms = 100;
if (isReceiving()) {
bool floor_valid = (_noise_floor != DEFAULT_NOISE_FLOOR);
int16_t rssi_thresh = _noise_floor + CAD_PROBE_RSSI_GUARD;
bool real = false;
for (int k = 0; k < 4 && !real; k++) { /* ~12 symbols total */
k_sleep(K_MSEC(step_ms));
if (isReceiving()) {
real = true;
} else if (floor_valid &&
hwGetCurrentRSSI() > rssi_thresh) {
real = true;
}
}
if (real) {
s.tp++;
} else {
s.fp++;
@@ -1147,27 +1202,37 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap)
* Header: a:on o:1 pk:22(b21/4s) iv:15s
* a auto on/off o offset pk operating peak
* b family base 4s symbols iv probe interval
* Level: -3(18) 22p 18b 16f 2t 72%
* level(peak) probes busy fp tp fp-rate%% (integer). */
* Level: *+1(22) 22p 18b 16f 2t 72%
* '*' = operating rung level(peak) probes busy fp tp fp-rate%%. */
n += snprintf(buf + n, cap > n ? cap - n : 0,
"a:%s o:%d pk:%d(b%u/4s) iv:%us",
_cad_auto ? "on" : "off", (int)_cad_offset,
(int)base + _cad_offset, base,
(unsigned)_cad_probe_interval_s);
for (int i = 0; i < CAD_NUM_LEVELS; i++) {
CadLevelStats &s = _cad_stats[i];
/* Only the 3 rungs around the operating offset — the far rungs are mildly
* irrelevant; what matters is where we sit on the ladder. The window is
* clamped to stay inside [CAD_LEVEL_MIN, CAD_LEVEL_MAX] while still showing
* 3 rungs, so at either end it slides inward rather than dropping a line. */
int cur = _cad_offset;
if (cur < CAD_LEVEL_MIN) cur = CAD_LEVEL_MIN;
if (cur > CAD_LEVEL_MAX) cur = CAD_LEVEL_MAX;
int lo = cur - 1, hi = cur + 1;
if (lo < CAD_LEVEL_MIN) { lo = CAD_LEVEL_MIN; hi = lo + 2; }
if (hi > CAD_LEVEL_MAX) { hi = CAD_LEVEL_MAX; lo = hi - 2; }
if (s.probes == 0) {
continue;
}
/* Integer FP rate, rounded to nearest percent. */
unsigned fp_pct = (unsigned)(((uint32_t)s.fp * 100U + s.probes / 2)
/ s.probes);
for (int lvl = lo; lvl <= hi; lvl++) {
CadLevelStats &s = _cad_stats[lvl - CAD_LEVEL_MIN];
/* Integer FP rate, rounded to nearest percent (0 when unprobed). */
unsigned fp_pct = s.probes
? (unsigned)(((uint32_t)s.fp * 100U + s.probes / 2) / s.probes)
: 0;
n += snprintf(buf + n, cap > n ? cap - n : 0,
"\n%+d(%d) %up %ub %uf %ut %u%%",
i + CAD_LEVEL_MIN, (int)base + i + CAD_LEVEL_MIN,
"\n%c%+d(%d) %up %ub %uf %ut %u%%",
lvl == cur ? '*' : ' ',
lvl, (int)base + lvl,
s.probes, s.busy, s.fp, s.tp, fp_pct);
}
+17 -3
View File
@@ -37,9 +37,23 @@
#define CAD_NUM_LEVELS (CAD_LEVEL_MAX - CAD_LEVEL_MIN + 1)
#define CAD_SWEEP_MIN (-4) /* dry-run sweep window (get cad with auto off) */
#define CAD_SWEEP_MAX 4
#define CAD_FP_TARGET_PERMILLE 10 /* step-down needs FP rate <= 1% */
#define CAD_STEP_DOWN_MIN_PROBES 300 /* samples before a down-step call */
#define CAD_STEP_UP_MIN_PROBES 50 /* samples before an up-step call */
/* Knee-seeking staircase (replaces the earlier absolute-FP-target band). The
* FP-vs-detPeak curve falls as detPeak rises (less sensitive → fewer false
* detects) and flattens past a knee; the sweet spot is the knee — the most
* sensitive detPeak whose FP has already bottomed out. The controller reads
* the local curve SLOPE from three rungs (frontier op-1, operating op, op+1)
* rather than an absolute FP level, so it converges the same way regardless of
* a site's FP floor (which varies with traffic and classifier residual).
* - KNEE_SLOPE: the per-level FP change (permille) that counts as "steep".
* Below the knee the curve drops fast (step up toward the knee); at/above it
* the curve is flat (slope < KNEE_SLOPE).
* - PLATEAU_CLEAN: on a flat plateau, only reclaim sensitivity (step down) if
* FP is already this low — the guard that stops a flat-but-noisy curve from
* walking to the sensitive rail (there, holding is the least-bad move; a
* genuinely quiet flat-low site descends to the floor, which is correct). */
#define CAD_KNEE_SLOPE_PERMILLE 50 /* >=5%/level FP change = steep */
#define CAD_PLATEAU_CLEAN_PERMILLE 50 /* <=5% FP = clean enough to descend */
#define CAD_STEP_MIN_PROBES 120 /* per-level samples before a step call */
#define CAD_PROBE_RSSI_GUARD 7 /* dB above floor = channel visibly busy, skip probe */
#define CAD_STATS_DECAY_MS (6UL * 3600UL * 1000UL) /* halve counters every 6 h */
+39 -7
View File
@@ -1022,6 +1022,17 @@ bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len)
bool dup = (msg_timestamp != 0 && msg_timestamp == _vcontact_last_ts);
_vcontact_last_ts = msg_timestamp;
/* Emit the SENT response FIRST. It is the synchronous reply the
* app's send request blocks on; the CLI command below runs on this
* thread and can take a second or more (e.g. `get cad`, `advert`).
* Deferring SENT until after the command ran let the app's response
* timer fire and auto-retry the send, producing the duplicate
* replies (and the lingering "sending" state) users reported. */
uint32_t ack = 0;
getRNG()->random((uint8_t *)&ack, 4);
if (ack == 0) ack = 1;
sendPacketSent(MSG_SEND_SENT_DIRECT, ack, 3000);
char reply[VCONTACT_CLI_REPLY_SIZE];
reply[0] = '\0';
if (!dup) {
@@ -1041,12 +1052,7 @@ bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len)
LOG_DBG("vcontact CLI: dup ts=%u, re-ack only", msg_timestamp);
}
/* Synthesize the normal send/ack choreography: SENT response,
* then an immediate delivery confirmation (loopback, 0 ms). */
uint32_t ack = 0;
getRNG()->random((uint8_t *)&ack, 4);
if (ack == 0) ack = 1;
sendPacketSent(MSG_SEND_SENT_DIRECT, ack, 3000);
/* Delivery confirmation (loopback, 0 ms trip), then the reply. */
uint8_t ack_push[8];
memcpy(ack_push, &ack, 4);
memset(&ack_push[4], 0, 4); /* trip time: 0 ms */
@@ -1092,8 +1098,34 @@ bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len)
}
return false;
case CMD_SEND_TELEMETRY_REQ:
/* Contact telemetry request: [cmd][3 reserved][32-byte pubkey].
* The v-contact represents THIS node, so its telemetry is our own
* self-telemetry. Synthesize the SENT ack the app blocks on, then push
* a TELEMETRY_RESPONSE tagged with the v-contact key so the app matches
* it to the loopback contact. (Real contacts fall through to the async
* RF path below; the loopback key just isn't in the contacts table.) */
if (len >= 4 + PUB_KEY_SIZE && isVContactKey(&data[4], PUB_KEY_SIZE)) {
uint32_t tag = 0;
getRNG()->random((uint8_t *)&tag, 4);
if (tag == 0) tag = 1;
sendPacketSent(MSG_SEND_SENT_DIRECT, tag, 3000);
uint8_t rsp[8 + 4 + 11 + 11 + (12 * POWER_MAX_CHANNELS) + 8];
int i = 0;
rsp[i++] = PUSH_CODE_TELEMETRY_RESPONSE;
rsp[i++] = 0; /* reserved */
memcpy(&rsp[i], _vcontact_pubkey, 6);
i += 6;
i += appendSelfTelemetry(&rsp[i],
TELEM_PERM_BASE | TELEM_PERM_LOCATION | TELEM_PERM_ENVIRONMENT);
sendPush(rsp[0], &rsp[1], i - 1);
return true;
}
return false;
default:
/* Every other pubkey-addressed opcode (login, telemetry, binary req,
/* Every other pubkey-addressed opcode (login, binary req,
* path discovery, export, ...) resolves the contact via
* lookupContactByPubKey(); the v-contact is never in the table, so
* they fail with ERR_NOT_FOUND before any packet exists. */
+15 -15
View File
@@ -687,9 +687,13 @@ public:
const char* getRole() override { return "companion"; }
bool formatFileSystem() override { return data_store.formatFileSystem(); }
/* Advert / timer controls — mesh-internal; stub for now. */
/* Advert the companion can originate its own self-advert. delay_millis is
* unused (companion sends flood at 0 ms / zero-hop immediately, matching the
* app-triggered path); previously a no-op stub, so `advert` from the CLI
* reported success but transmitted nothing. */
void sendSelfAdvertisement(int delay_millis, bool flood) override {
(void)delay_millis; (void)flood;
(void)delay_millis;
companion_mesh.sendSelfAdvert(flood);
}
void updateAdvertTimer() override {}
void updateFloodAdvertTimer() override {}
@@ -1378,19 +1382,15 @@ int main(void)
}
#ifdef ZEPHCORE_LORA
/* Initialize prefs with defaults */
memset(&companion_mesh.prefs, 0, sizeof(companion_mesh.prefs));
companion_mesh.prefs.freq = 869.618f;
companion_mesh.prefs.bw = 62.5f;
companion_mesh.prefs.sf = 8;
companion_mesh.prefs.cr = 8;
companion_mesh.prefs.tx_power_dbm = 22;
companion_mesh.prefs.rx_delay_base = 0.0f; /* Disabled for companion */
companion_mesh.prefs.airtime_factor = 9.0f; /* Arduino formula: 100/(af+1) → 10% (EU 868 default) */
companion_mesh.prefs.rx_duty_cycle = 0; /* Default OFF: continuous RX */
companion_mesh.prefs.rx_boost = 1; /* Default: boosted RX (+3dB sensitivity, +2mA) */
companion_mesh.prefs.apc_enabled = 0; /* Default: APC off */
companion_mesh.prefs.apc_margin = 20; /* Companions: more conservative margin (mobile) */
/* Initialize prefs with defaults via the single source of truth
* (initNodePrefs) so every field including new ones added later gets
* its proper default. A hand-maintained subset here silently drifts: any
* field not listed defaults to 0, and on upgrade the past-EOF read in
* loadPrefs then keeps that 0 instead of the real default (this is what
* zeroed cad_probe_interval / cad_auto and, earlier, the GPS settings). */
initNodePrefs(&companion_mesh.prefs);
/* Companion-specific overrides vs. initNodePrefs defaults: */
companion_mesh.prefs.apc_margin = 20; /* mobile: more conservative than the 16 default */
companion_mesh.prefs.auto_shutdown_mv = CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS; /* low-batt cutoff (0=off) */
companion_mesh.prefs.gps_interval = CONFIG_ZEPHCORE_GPS_POLL_INTERVAL_SEC; /* 5-min duty cycle (0=always-on) */