From 9eb33e27dcc1734a5ce09366eb400185fd879867 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:00:14 +0200 Subject: [PATCH] cad, meshamerica flasher and other polishes --- gen_provider_catalog.py | 63 +++++++- zephcore/ADAPTIVE_CAD.md | 40 +++-- zephcore/adapters/radio/LoRaRadioBase.cpp | 175 +++++++++++++++------- zephcore/adapters/radio/radio_common.h | 20 ++- zephcore/app/CompanionMesh.cpp | 46 +++++- zephcore/src/main_companion.cpp | 30 ++-- 6 files changed, 277 insertions(+), 97 deletions(-) diff --git a/gen_provider_catalog.py b/gen_provider_catalog.py index 16e3d69..027e635 100644 --- a/gen_provider_catalog.py +++ b/gen_provider_catalog.py @@ -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} diff --git a/zephcore/ADAPTIVE_CAD.md b/zephcore/ADAPTIVE_CAD.md index c9c0963..f4ed7f4 100644 --- a/zephcore/ADAPTIVE_CAD.md +++ b/zephcore/ADAPTIVE_CAD.md @@ -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** (op−1 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 **1–2 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 1–2 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 diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index 6b98d06..e5a9ef7 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -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); } diff --git a/zephcore/adapters/radio/radio_common.h b/zephcore/adapters/radio/radio_common.h index f1ea3e8..c1ffea1 100644 --- a/zephcore/adapters/radio/radio_common.h +++ b/zephcore/adapters/radio/radio_common.h @@ -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 */ diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index e42510d..5364719 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -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. */ diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 30fee23..0369cc7 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -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) */