From aac97f7adee90de1f1b918d0f59077049df60647 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:19:14 +0200 Subject: [PATCH] vcontact and cad fixes --- zephcore/ARCHITECTURE.md | 62 ++++++++++--- .../adapters/datastore/ZephyrDataStore.cpp | 11 ++- zephcore/adapters/radio/LR2021Radio.cpp | 15 ++++ zephcore/adapters/radio/LR2021Radio.h | 2 + zephcore/adapters/radio/LoRaRadioBase.cpp | 69 ++++++++++++--- zephcore/adapters/radio/LoRaRadioBase.h | 21 +++++ zephcore/app/CompanionMesh.cpp | 86 ++++++++++++++++--- zephcore/app/CompanionMesh.h | 12 ++- zephcore/helpers/NodePrefs.h | 6 ++ .../drivers/lora/lr20xx/lr20xx_lora.c | 38 ++++++-- .../drivers/lora/lr20xx/lr20xx_lora.h | 13 +++ zephcore/src/main_companion.cpp | 3 + 12 files changed, 291 insertions(+), 47 deletions(-) diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index 9b61ec0..0d94bae 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -32,7 +32,7 @@ ZephCore is a LoRa mesh networking firmware running on Zephyr RTOS. It supports - **Room Server**: Headless store-and-forward shared message room (BBS). Reuses the repeater's ACL/region/CLI; pushes new posts to logged-in clients (per-client sync cursor + ACK). - **Observer** (ESP32): Listen-only node that publishes received LoRa packets to MQTT over WiFi. -Supported hardware: nRF52840, nRF54L15, ESP32 (classic PICO-D4 and C3/C6/S3), EFR32MG24, and STM32WL (LoRa-E5). Radios: SX126x family (SX1261/62/68, LLCC68, STM32WL sub-GHz), LR1110, SX127x (SX1272/76/78, loramac-node backend), and LR2021 (experimental bring-up). A native Linux port runs the full stack on SBCs (Femtofox, Raspberry Pi) via Zephyr `native_sim` — see `LINUX_NATIVE.md`. +Supported hardware: nRF52840, nRF54L15, ESP32 (classic PICO-D4 and C3/C6/S3), EFR32MG24, and STM32WL (LoRa-E5). Radios: SX126x family (SX1261/62/68, LLCC68, STM32WL sub-GHz), LR1110, SX127x (SX1272/76/78, loramac-node backend), and LR2021 (validated on the MeshTracker X1). A native Linux port runs the full stack on SBCs (Femtofox, Raspberry Pi) via Zephyr `native_sim` — see `LINUX_NATIVE.md`. ### Upstream Relationship @@ -73,7 +73,7 @@ zephcore/ │ │ ├── SX126xRadio.cpp/h # SX126x adapter (native Zephyr driver, patched) │ │ ├── SX127xRadio.cpp/h # SX127x adapter (loramac-node backend) │ │ ├── LR1110Radio.cpp/h # LR1110 adapter (custom Zephyr driver) -│ │ ├── LR2021Radio.cpp/h # LR2021 adapter (custom driver, experimental) +│ │ ├── LR2021Radio.cpp/h # LR2021 adapter (custom driver) │ │ ├── radio_common.h # Shared radio types and constants │ │ ├── lr11xx/ # LR11xx low-level HAL (SPI, GPIO, Semtech SDK) │ │ └── lr20xx/ # LR20xx low-level HAL (Semtech SDK) @@ -363,7 +363,7 @@ mesh::Radio (abstract interface) ├── SX126xRadio → Zephyr native SX126x driver + sx126x_ext.h ├── SX127xRadio → Zephyr loramac-node backend (SX1272/76/78) ├── LR1110Radio → Custom lr11xx_lora.c driver + Semtech HAL - └── LR2021Radio → Custom lr20xx_lora.c driver + Semtech HAL (experimental) + └── LR2021Radio → Custom lr20xx_lora.c driver + Semtech HAL ``` Compile-time selection via the `CONFIG_ZEPHCORE_RADIO_NATIVE` / `_LR1110` / `_LR2021` / `_SX127X` Kconfig options, resolved in `RadioIncludes.h`. The native SX126x path is the default and covers SX1261/62/68, LLCC68, and the STM32WL integrated sub-GHz radio. @@ -449,10 +449,20 @@ independent of a site's FP floor. Highest-priority override: **airtime / faint cap** — step up when the operating busy rate exceeds `cad_busycap` (percent, `set cad.busycap`, default 25, 0=off); self-targeting since only busy nodes reach it, and effectively a faint-tolerance dial (lower = reject faint harder). -Each step needs ≥`CAD_STEP_MIN_PROBES` (120); offset clamped **−8…+12**, -persisted via `Dispatcher::onCadOffsetChanged()`. Driver absolute clamp (SX126x -15–40, LR 48–90) is a guardrail; AN1200.48 recommends 21–29 for SX126x (base -`SF+13`), tuned to catch faint — LBT may deliberately sit above it. Probe + +Each step needs ≥`CAD_STEP_MIN_PROBES` (120); offset clamped to **−8…+12** +*narrowed by the driver's own detPeak clamp* (SX126x 15–40, LR 48–90), persisted +via `Dispatcher::onCadOffsetChanged()`. The narrowing is not cosmetic: where +`base + offset` falls outside the hardware clamp, several offsets program the +**same** peak, and the staircase then compares rungs that are physically +identical and reads sampling noise as curvature. `hwCadPeakMin/Max()` report the +driver clamp and `cadLevelMinEff()/MaxEff()` derive the usable window, so every +level the controller can reach is a distinct configuration and the `pk` shown by +`get cad` is what the chip was actually given. It binds on the LR2021, whose +4-symbol base is 51 at SF5–7 (effective −3…+12) and 54 at SF8 (−6…+12); the +LR11xx's lowest base of 56 already lands exactly on the 48 floor at −8, so its +full window is usable and it keeps the static range. AN1200.48 recommends 21–29 +for SX126x (base `SF+13`), tuned to catch faint — LBT may deliberately sit above +it. Probe + offset plumbing is per-driver extension API (`*_cad_probe`, `*_cad_set_peak_offset`, `*_cad_base_peak`); LBT CAD runs 4 symbols (set in `buildModemConfig`), drivers scale their blocking-CAD timeout to @@ -472,7 +482,12 @@ The custom `lr11xx_lora.c` driver handles several LR1110 firmware bugs: ### 5.5 SX127x and LR2021 Paths - **SX127x** (`CONFIG_ZEPHCORE_RADIO_SX127X`): uses Zephyr's loramac-node LoRa backend instead of the native driver (`CONFIG_LORA_MODULE_BACKEND_LORAMAC_NODE`). Patch `0004-lora-sx127x-62k5-bandwidth` adds the 62.5 kHz bandwidth MeshCore defaults to. No RX duty cycle and no RX gain boost on this path. Reference board: TTGO LoRa32 (SX1276). -- **LR2021** (`CONFIG_ZEPHCORE_RADIO_LR2021`): custom driver in `patches/zephyr-new/drivers/lora/lr20xx/` (copied into the Zephyr tree at configure time, like LR11xx). Experimental — bring-up on the ProMicro LR2021 is still in progress; not listed as a supported board. +- **LR2021** (`CONFIG_ZEPHCORE_RADIO_LR2021`): custom driver in `patches/zephyr-new/drivers/lora/lr20xx/` (copied into the Zephyr tree at configure time, like LR11xx). **Validated on the SenseCAP MeshTracker X1** — RX, TX, LBT and RX duty cycle all confirmed on hardware after a full driver audit (2026-08-12). `promicro_lr2021` builds but is untested; its module was destroyed by overvoltage during bring-up. Notable properties that differ from the SX126x/LR11xx paths: + - **Firmware Patch RAM.** DS §22.3 calls the PRAM "highly recommended"; without it the chip runs unpatched. `lr20xx_load_pram()` writes the 560-word image from `0x801000`, activates it with opcode `0x012D`, and verifies the magic word at `0x800FF8` — so the `PRAM loaded:` log line is proof the chip took it, not merely that the writes were accepted. Volatile: reloaded from both reset paths, survives every sleep this driver issues (all with retention). + - **Hardware CAD→TX (`CadExitMode = 0x10`).** The chip runs the LBT CAD and, on a clear channel, transmits itself with no host round-trip. Payload and packet params are staged *before* `SetLoraCAD` and DIO1 stays enabled across it. Bounded by `cad_timeout`, which is 24 bits of 32 MHz periods = **524 ms max Tx timeout** — transmits whose airtime exceeds that take the classic CAD→host→`SetTx` route rather than being truncated (at SF7/BW62.5 the crossover is ~96 bytes). + - **Front-end calibration is a point calibration, not a band.** `CalibFE` takes up to three individual frequencies (4 MHz steps, bit 15 = LF/HF), unlike the SX126x/LR11xx `CalibrateImage` freq1/freq2 band with datasheet-prescribed edges. It is issued only at config, after a hardware reset, and on AGC reset — never on the Tx/Rx path (DS §6.4.2 keeps the values on chip across retention sleep). Both 4 MHz neighbours of the operating frequency are calibrated, nearest first, because the SDK rounds the argument up where the chip's own default truncates down. + - **Side detectors** (multi-SF receive) are LR2021-only; see `lr20xx_configure_side_detectors()`. Mutually exclusive with CAD, whose SF ordering constraint is the inverse. + - **Per-packet frequency error** is decoded and accumulated (`get freqerr`) — diagnostic only, nothing acts on it. ### 5.6 Default Radio Parameters @@ -531,7 +546,8 @@ key exists anywhere. and the reply is written straight into the offline queue. **No packet object is ever created**, so nothing can reach the dispatcher or radio. 2. The v-contact never enters the real contacts table (`CMD_ADD_UPDATE_CONTACT` - for its key is intercepted to a no-op OK), so it is never in the RF RX + for its key is intercepted — it keeps only the app-owned `flags` byte, in + `prefs.v_contact_flags`, and replies OK), so it is never in the RF RX matching path. Every other pubkey-addressed opcode (login, telemetry, binary req, path discovery…) misses `lookupContactByPubKey()` and fails `ERR_NOT_FOUND` before a packet exists. @@ -540,11 +556,27 @@ key exists anywhere. **App plumbing**: appears as a virtual tail entry in the `CMD_GET_CONTACTS` iteration (and `+1` in the CONTACT_START total); pushed as `NEW_ADVERT` on -runtime enable and rename, `CONTACT_DELETED` on disable. App-side contact -delete (`CMD_REMOVE_CONTACT`) turns the feature off. Send/ack choreography is -synthesized (SENT + immediate SEND_CONFIRMED, trip time 0). CLI replies are +runtime enable and rename, `CONTACT_DELETED` on disable. Send/ack choreography +is synthesized (SENT + immediate SEND_CONFIRMED, trip time 0). CLI replies are chunked at ≤150 chars on line breaks (offline-queue frames cap at 172 bytes). +`_vcontact_lastmod` is re-stamped once per app session at `CMD_APP_START`, +before the `CMD_GET_CONTACTS` that follows it. Without that the timestamp only +moved on boot/rename/identity-import, so the app showed an ever-growing "last +seen" age *and* — because the sync gate is `_vcontact_lastmod > +_contact_iter_since` — the v-contact was streamed exactly once ever, leaving +the app holding a contact the node no longer mentioned. + +**App-side delete is session-scoped** (`_vcontact_app_hidden`): the v-contact is +withheld from sync and adverts for the rest of that session, and returns at the +next `CMD_APP_START`. It deliberately does **not** touch +`prefs.v_contact_enabled` — the v-contact is an ordinary entry in the app's +contact list, so a "purge all contacts" walks it like any other, and the old +behaviour (delete ⇒ pref off) let a routine purge silently disable a firmware +feature with no way back except the USB CLI. Durable disable is node-side only: +`set v.contact off`. Notices queued while hidden stay in the offline queue and +drain on the next connect; only their `MSG_WAITING` prompt is suppressed. + **Clock gating (no 1970 timestamps)**: while the RTC has never been synced (time < firmware build epoch) the v-contact is *deferred* — withheld from contact sync and adverts, and notices are buffered in a small RAM slot @@ -570,8 +602,10 @@ are delivered on the first app connect/sync. RAM-backed: lost on reboot (the restart-reason message partially compensates) and bounded by `CONFIG_ZEPHCORE_OFFLINE_QUEUE_SIZE`. -**Settings** (companion `v.*` CLI namespace, prefs offsets 152–154): -- `set/get v.contact on|off` — default on. +**Settings** (companion `v.*` CLI namespace, prefs offsets 152–154 plus +`v_contact_flags` at 166): +- `set/get v.contact on|off` — default on. The only durable disable; turning it + off also clears `v_contact_flags`, since the app drops the contact. - `set/get v.batteryalert |0|default` — default = board auto-shutdown threshold + 200 mV (so the alert wins the race against the 90 s shutdown confirm window), 3500 mV on boards without auto-shutdown. Alert latches diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 618ddf1..ba812a0 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -834,6 +834,13 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) for (int i = 0; i < EXTRA_SF_MAX && off < len; i++) { prefs.extra_sf[i] = buf[off++]; } + + /* Offset 166: v_contact_flags (ZephCore extension). Absent in pre-existing + * files → stays 0, which is exactly the old behaviour (no favourite, no + * telemetry permissions). */ + if (off < len) { + prefs.v_contact_flags = buf[off++]; + } } void ZephyrDataStore::savePrefs(const NodePrefs &prefs) @@ -931,7 +938,9 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs) /* Offset 163-165: extra_sf (ZephCore extension, LR2021 side detectors) */ memcpy(&buf[off], prefs.extra_sf, EXTRA_SF_MAX); off += EXTRA_SF_MAX; - /* Total: 166 bytes */ + /* Offset 166: v_contact_flags (ZephCore extension) */ + buf[off++] = prefs.v_contact_flags; + /* Total: 167 bytes */ bool ok = atomicReplaceFile(PREFS_FILE, buf, off); LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'", diff --git a/zephcore/adapters/radio/LR2021Radio.cpp b/zephcore/adapters/radio/LR2021Radio.cpp index c1cbf02..47b9f64 100644 --- a/zephcore/adapters/radio/LR2021Radio.cpp +++ b/zephcore/adapters/radio/LR2021Radio.cpp @@ -142,6 +142,21 @@ uint8_t LR2021Radio::hwCadBasePeak() return lr20xx_cad_base_peak(_dev); } +/* The detPeak range lr20xx_do_cad() will actually program. Must match the + * driver's clamp exactly: if the adapter thinks the range is wider, the + * staircase explores offsets that collapse onto one peak and reads the noise + * between them as curvature — which is how it random-walked to -8 at SF7, + * where base 51 puts everything from -3 down onto peak 48. */ +uint8_t LR2021Radio::hwCadPeakMin() +{ + return lr20xx_cad_peak_min(); +} + +uint8_t LR2021Radio::hwCadPeakMax() +{ + return lr20xx_cad_peak_max(); +} + uint32_t LR2021Radio::hwWakeupTimeUs() { /* Per-device, because the TCXO term dominates and is board-specific: diff --git a/zephcore/adapters/radio/LR2021Radio.h b/zephcore/adapters/radio/LR2021Radio.h index b11d242..ed2a6d6 100644 --- a/zephcore/adapters/radio/LR2021Radio.h +++ b/zephcore/adapters/radio/LR2021Radio.h @@ -42,6 +42,8 @@ protected: int hwCadProbe(int8_t level) override; void hwCadSetPeakOffset(int8_t offset) override; uint8_t hwCadBasePeak() override; + uint8_t hwCadPeakMin() override; + uint8_t hwCadPeakMax() override; uint32_t hwWakeupTimeUs() override; }; diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index 77d6ee4..fdd16eb 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -1078,11 +1078,44 @@ bool LoRaRadioBase::isChannelActive(int threshold) /* ── Adaptive CAD (LBT detPeak calibration) ───────────────────────────── */ +/* The offset window is [CAD_LEVEL_MIN, CAD_LEVEL_MAX], but an offset is only + * meaningful while base+offset still lands somewhere the driver will actually + * program. Past the hardware clamp several offsets collapse onto one peak, and + * the staircase cannot tell them apart — it reads sampling noise as curvature. + * Narrow the window so every level it can reach is a distinct configuration. + * + * Radios that report no clamp (hwCadPeakMin/Max == 0) keep the static window, + * which is also what a radio with no adaptive CAD at all gets. */ +int8_t LoRaRadioBase::cadLevelMinEff() +{ + uint8_t base = hwCadBasePeak(); + uint8_t pmin = hwCadPeakMin(); + + if (base == 0 || pmin == 0 || pmin <= base - CAD_LEVEL_MIN) { + return CAD_LEVEL_MIN; + } + return (int8_t)((int)pmin - (int)base); +} + +int8_t LoRaRadioBase::cadLevelMaxEff() +{ + uint8_t base = hwCadBasePeak(); + uint8_t pmax = hwCadPeakMax(); + + if (base == 0 || pmax == 0 || (int)pmax - (int)base >= CAD_LEVEL_MAX) { + return CAD_LEVEL_MAX; + } + return (int8_t)((int)pmax - (int)base); +} + void LoRaRadioBase::setCadParams(bool auto_enabled, int8_t offset, uint16_t probe_interval_s, uint8_t busycap_pct) { - if (offset < CAD_LEVEL_MIN) offset = CAD_LEVEL_MIN; - if (offset > CAD_LEVEL_MAX) offset = CAD_LEVEL_MAX; + const int8_t lo = cadLevelMinEff(); + const int8_t hi = cadLevelMaxEff(); + + if (offset < lo) offset = lo; + if (offset > hi) offset = hi; _cad_auto = auto_enabled; _cad_offset = offset; @@ -1146,7 +1179,7 @@ int8_t LoRaRadioBase::pickCadProbeLevel() 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) { + if (lvl < cadLevelMinEff() || lvl > cadLevelMaxEff()) { lvl = _cad_offset; } return lvl; @@ -1196,7 +1229,7 @@ void LoRaRadioBase::cadStaircaseStep() * just starves our own airtime. Cap is `set cad.busycap` percent (0 = * off); only binds on genuinely busy channels. */ int cap_permille = (int)_cad_busycap_pct * 10; - if (cap_permille && _cad_offset < CAD_LEVEL_MAX && b_op > cap_permille) { + if (cap_permille && _cad_offset < cadLevelMaxEff() && b_op > cap_permille) { _cad_offset++; hwCadSetPeakOffset(_cad_offset); LOG_INF("cad: step up -> offset %d (airtime, busy %d cap %d)", @@ -1206,7 +1239,7 @@ void LoRaRadioBase::cadStaircaseStep() /* 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 && + if (_cad_offset < cadLevelMaxEff() && r_up >= 0 && r_op - r_up >= CAD_KNEE_SLOPE_PERMILLE) { _cad_offset++; hwCadSetPeakOffset(_cad_offset); @@ -1224,7 +1257,7 @@ void LoRaRadioBase::cadStaircaseStep() int b_dn = busy_rate(oi - 1); bool busy_ok = (cap_permille == 0) || (b_dn <= cap_permille - CAD_BUSY_DEFER_HYST_PERMILLE); - if (_cad_offset > CAD_LEVEL_MIN && r_dn >= 0 && + if (_cad_offset > cadLevelMinEff() && r_dn >= 0 && r_dn - r_op < CAD_KNEE_SLOPE_PERMILLE && r_op <= CAD_PLATEAU_CLEAN_PERMILLE && busy_ok) { _cad_offset--; @@ -1477,15 +1510,25 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap) (unsigned)_cad_busycap_pct); /* 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. */ + * irrelevant; what matters is where we sit on the ladder. The window is + * clamped to the EFFECTIVE range while still showing 3 rungs, so at either + * end it slides inward rather than dropping a line. + * + * Effective, not the static constants: past the hardware detPeak clamp + * several offsets program the same peak, and showing them as separate + * rungs invited exactly the wrong reading — three lines of distinct + * statistics for one physical configuration. With the range narrowed, + * every rung printed is a real one and `pk` below is what the chip got. */ + const int lmin = cadLevelMinEff(); + const int lmax = cadLevelMaxEff(); int cur = _cad_offset; - if (cur < CAD_LEVEL_MIN) cur = CAD_LEVEL_MIN; - if (cur > CAD_LEVEL_MAX) cur = CAD_LEVEL_MAX; + if (cur < lmin) cur = lmin; + if (cur > lmax) cur = lmax; 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 (lo < lmin) { lo = lmin; hi = lo + 2; } + if (hi > lmax) { hi = lmax; lo = hi - 2; } + if (lo < CAD_LEVEL_MIN) lo = CAD_LEVEL_MIN; + if (hi > CAD_LEVEL_MAX) hi = CAD_LEVEL_MAX; for (int lvl = lo; lvl <= hi; lvl++) { CadLevelStats &s = _cad_stats[lvl - CAD_LEVEL_MIN]; diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index e226e78..8819ef7 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -123,6 +123,14 @@ public: void cadMaintenance() override; uint32_t msUntilNextMaintenance() override; int8_t getCadOffset() const override { return _cad_offset; } + + /* Offset bounds the controller may actually use: the static + * [CAD_LEVEL_MIN, CAD_LEVEL_MAX] window narrowed to whatever the + * hardware clamp leaves distinguishable at the current base. Every + * range decision goes through these; the raw constants stay in use only + * for indexing _cad_stats[], which is sized to the static window. */ + int8_t cadLevelMinEff(); + int8_t cadLevelMaxEff(); void resetCadStats() override; int formatCadStatus(char *buf, int cap) override; @@ -154,6 +162,19 @@ protected: /** Per-SF base detPeak for the current config (0 = unsupported). */ virtual uint8_t hwCadBasePeak() { return 0; } + /** Absolute detPeak range the driver will actually program, inclusive. + * 0/0 means "no known limit" and the offset range stays as-is. + * + * This exists because the offset window and the hardware clamp are two + * different things, and when they disagree the controller explores + * levels that are physically identical: on the LR2021 at SF7 the base is + * 51 and the driver clamps to 48, so offsets -3 through -8 all programmed + * the same peak. The staircase then compared three rungs of the same + * configuration, found only sampling noise between them, and random-walked + * into the floor with nothing to climb back out on. */ + virtual uint8_t hwCadPeakMin() { return 0; } + virtual uint8_t hwCadPeakMax() { return 0; } + /** Radio deaf time per duty-cycle wake transition (context restore + * PLL lock + TCXO startup where fitted), in microseconds. Counts * against the duty-cycle preamble-catch budget: per SX126x DS rev 2.2 diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index fbc8629..2f37e7c 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -218,6 +218,7 @@ CompanionMesh::CompanionMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mes memset(_vcontact_pending, 0, sizeof(_vcontact_pending)); _vcontact_pending_count = 0; _vcontact_hold_msgwait = false; + _vcontact_app_hidden = false; memset(&prefs, 0, sizeof(prefs)); prefs.node_lat = 0; prefs.node_lon = 0; @@ -864,7 +865,12 @@ void CompanionMesh::buildVContact(ContactInfo &c) const { memcpy(c.id.pub_key, _vcontact_pubkey, PUB_KEY_SIZE); c.type = ADV_TYPE_CHAT; - c.flags = 0; + /* App-owned flags (bit 0 = favourite, upper bits = telemetry permissions). + * The v-contact has no contacts-table record to hold them, so they live in + * prefs — see the CMD_ADD_UPDATE_CONTACT interception below. Echoing a + * hardcoded 0 here is what used to clear the favourite star on every + * contact sync. */ + c.flags = prefs.v_contact_flags; c.out_path_len = 0; /* zero-hop direct — renders as "0 hops" in the app */ c.shared_secret_valid = false; memset(c.out_path, 0, sizeof(c.out_path)); @@ -950,8 +956,13 @@ void CompanionMesh::vcontactQueueText(const char *text) * makes the app interleave message-sync into the contact stream and * truncate it. The message is already safe in the offline queue and the * app's own initial message-sync drains it — so no prompt is needed inside - * the window. Outside it, the prompt goes out immediately. */ - if (!_vcontact_hold_msgwait) { + * the window. Outside it, the prompt goes out immediately. + * + * Also suppressed while the app has deleted the v-contact this session: + * prompting for messages from a contact the app just dropped is noise. The + * messages stay in the offline queue and drain on the next connect, when + * the contact is back. */ + if (!_vcontact_hold_msgwait && !_vcontact_app_hidden) { sendPush(PUSH_CODE_MSG_WAITING); } } @@ -993,6 +1004,9 @@ void CompanionMesh::deriveVContactKey() void CompanionMesh::vcontactPushAdvert() { if (!isVContactEnabled()) return; + /* The app deleted it this session — don't push it straight back at them. + * It returns on its own at the next CMD_APP_START. */ + if (_vcontact_app_hidden) return; if (!vcontactClockValid()) { /* Defer — an advert stamped now would carry a 1970 timestamp. * vcontactClockSynced() re-runs this once a time source arrives. */ @@ -1148,10 +1162,29 @@ bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len) return false; case CMD_ADD_UPDATE_CONTACT: + /* Never let the v-contact into the real contacts table (it must stay out + * of the RF RX matching path) — but do keep the one field the app owns + * and expects back: the flags byte (bit 0 = favourite, upper bits = + * telemetry permissions). Everything else in the frame (name, path, + * lat/lon, advert timestamp) is ours to generate in buildVContact(). + * Frame layout matches the real handler: [cmd][32-byte pubkey][type] + * [flags][...]. Reply OK so app-side flows don't surface errors. */ + if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) { + if (len >= 1 + PUB_KEY_SIZE + 2) { + uint8_t flags = data[1 + PUB_KEY_SIZE + 1]; + if (flags != prefs.v_contact_flags) { + prefs.v_contact_flags = flags; + _store->savePrefs(prefs); + } + } + sendPacketOk(); + return true; + } + return false; + case CMD_RESET_PATH: - /* Never let the v-contact into the real contacts table (it must stay - * out of the RF RX matching path); path resets are meaningless for a - * loopback contact. Reply OK so app-side flows don't surface errors. */ + /* Path resets are meaningless for a loopback contact — accept and drop + * so app-side flows don't surface errors. */ if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) { sendPacketOk(); return true; @@ -1159,11 +1192,19 @@ bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len) return false; case CMD_REMOVE_CONTACT: - /* App-side delete turns the feature off (mirrors user intent); - * `set v.contact on` (USB CLI) brings it back. */ + /* App-side delete hides the v-contact for the rest of this session and + * nothing more — see _vcontact_app_hidden. It used to set + * prefs.v_contact_enabled = 0, which made a routine "purge all + * contacts" in the app silently disable the feature for good: the + * v-contact is an ordinary list entry, so a purge removes it like any + * other, and only the USB CLI could turn it back on. Durable disable + * stays with the node-side pref (`set v.contact off`). + * + * Flags are deliberately kept: the same contact returns on the next + * connect, so its favourite star should return with it. */ if (len >= 1 + PUB_KEY_SIZE && isVContactKey(&data[1], PUB_KEY_SIZE)) { - prefs.v_contact_enabled = 0; - _store->savePrefs(prefs); + _vcontact_app_hidden = true; + LOG_INF("vcontact: hidden by app delete (this session only)"); sendPacketOk(); return true; } @@ -2075,11 +2116,29 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) /* New session: suppress v-contact notice MSG_WAITING until the initial * sync (contacts + messages) completes at PACKET_NO_MORE_MSGS. */ _vcontact_hold_msgwait = true; + /* An app-side delete only hides the v-contact for the session it + * happened in — this is that session boundary, so it comes back. */ + _vcontact_app_hidden = false; /* If a time source already ran (hardware RTC, GPS), activate the * deferred v-contact and flush buffered notices for this session. */ vcontactClockSynced(); + /* Re-stamp the v-contact once per app session so it stays "fresh". + * _vcontact_lastmod feeds both lastmod and last_advert_timestamp, and + * it used to move only on boot / rename / identity import: the app + * showed an ever-growing "last seen" age, and — worse — the contact + * sync gate is `_vcontact_lastmod > _contact_iter_since`, so after the + * first sync the v-contact was never streamed again and app-side state + * (flags, name) could never be corrected. Bumping here, before the + * CMD_GET_CONTACTS that follows APP_START, means every session's sync + * carries a current timestamp; deferring it to the end of sync would + * always land one session late. Silent on purpose — no NEW_ADVERT push + * mid-handshake; the sync itself delivers it. */ + if (vcontactReady() && vcontactClockValid()) { + _vcontact_lastmod = (uint32_t)getRTCClock()->getCurrentTime(); + } + // Return SELF_INFO uint8_t rsp[90]; // 58 fixed + up to 32 bytes name size_t i = 0; @@ -2950,6 +3009,13 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) * and vcontactClockSynced() (the lastmod==0 path) emits at the * next time-sync instead of only after a reboot. */ _vcontact_lastmod = 0; + /* Different key → different contact in the app; the old + * favourite/telemetry flags don't carry over. Persist + * before the push so a reboot can't resurrect them. */ + if (prefs.v_contact_flags != 0) { + prefs.v_contact_flags = 0; + _store->savePrefs(prefs); + } if (vc_was_enabled) vcontactPushAdvert(); /* Reload contacts to invalidate ECDH shared secrets */ resetContacts(); diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 949be0a..0f4947d 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -506,8 +506,18 @@ private: * guard and truncates the sync. Held from CMD_APP_START until the first * PACKET_NO_MORE_MSGS (end of the contacts+messages initial sync). */ bool _vcontact_hold_msgwait; + /* App-side delete (CMD_REMOVE_CONTACT for the loopback key) hides the + * v-contact for the rest of the session only — it deliberately does NOT + * touch prefs.v_contact_enabled. The v-contact is an ordinary entry in the + * app's contact list, so a "purge all contacts" walks it like any other and + * used to permanently disable a firmware feature with no way back except + * the USB CLI. The pref is node-side state: `set v.contact off` is the only + * durable disable. Cleared at CMD_APP_START (the session reset). */ + bool _vcontact_app_hidden; bool vcontactClockValid(); - bool vcontactReady() { return isVContactEnabled() && _vcontact_lastmod != 0; } + bool vcontactReady() { + return isVContactEnabled() && !_vcontact_app_hidden && _vcontact_lastmod != 0; + } void buildVContact(ContactInfo &c) const; bool isVContactKey(const uint8_t *key, int prefix_len) const; /** (Re)derive _vcontact_pubkey from the current identity. Call on boot and diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 14c79bc..4c26a82 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -140,6 +140,12 @@ struct NodePrefs { uint16_t auto_shutdown_mv; // low-batt auto-shutdown threshold; 0 = off, else 2900–4200 uint8_t v_contact_enabled; // v-contact (loopback admin chat via BLE/USB); 1 = on (default) uint16_t v_battery_alert_mv; // 0 = alert off; 0xFFFF = board default (auto_shutdown+200); else mV + /* App-owned ContactInfo.flags byte for the v-contact. The v-contact never + * enters the contacts table, so it has no record to hold the flags the app + * sets via CMD_ADD_UPDATE_CONTACT -- bit 0 is the 'favourite' star, the + * upper bits are telemetry permissions. Kept here so a toggle survives + * reconnects and reboots instead of being echoed back as 0. */ + uint8_t v_contact_flags; }; /* Default prefs -- must match LoRaConfig.h defaults for radio interop. */ diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c index 04409dd..35ebcc6 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c @@ -2557,6 +2557,28 @@ static const uint8_t lr20xx_cad_peak_table[4][8] = { /* 4 */ { 51, 51, 51, 54, 56, 60, 60, 64 }, }; +/* The detPeak range this driver will actually program. Exported through + * lr20xx_cad_peak_min/max() so the C++ adaptive-CAD controller can narrow its + * offset window to match: where base+offset falls outside this, several offsets + * collapse onto one peak and the staircase reads sampling noise between + * identical configurations as curvature. + * + * 48 sits three steps below the lowest value DS Table 6-19 documents anywhere + * (51, at SF5-7 with 3-4 symbols), so the range already allows more sensitivity + * than the datasheet tabulates; the datasheet gives no absolute bound. */ +#define LR20XX_CAD_PEAK_MIN 48 +#define LR20XX_CAD_PEAK_MAX 90 + +uint8_t lr20xx_cad_peak_min(void) +{ + return LR20XX_CAD_PEAK_MIN; +} + +uint8_t lr20xx_cad_peak_max(void) +{ + return LR20XX_CAD_PEAK_MAX; +} + static uint8_t lr20xx_cad_detect_peak(uint8_t sf, uint8_t symb_nb) { if (sf < 5 || sf > 12) { @@ -2624,10 +2646,10 @@ static int lr20xx_do_cad(struct lr20xx_data *data) * LR20xx detPeak scale matches LR11xx (~48-90). */ int peak = (int)cad.cad_detect_peak + data->cad_peak_offset; - if (peak < 48) { - peak = 48; - } else if (peak > 90) { - peak = 90; + if (peak < LR20XX_CAD_PEAK_MIN) { + peak = LR20XX_CAD_PEAK_MIN; + } else if (peak > LR20XX_CAD_PEAK_MAX) { + peak = LR20XX_CAD_PEAK_MAX; } cad.cad_detect_peak = (uint8_t)peak; } @@ -2761,10 +2783,10 @@ int lr20xx_cad_probe(const struct device *dev, int8_t peak_offset) int peak = base + peak_offset; int ret; - if (peak < 48) { - peak = 48; - } else if (peak > 90) { - peak = 90; + if (peak < LR20XX_CAD_PEAK_MIN) { + peak = LR20XX_CAD_PEAK_MIN; + } else if (peak > LR20XX_CAD_PEAK_MAX) { + peak = LR20XX_CAD_PEAK_MAX; } /* One-shot absolute override consumed by lr20xx_do_cad(). Probes and diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h index 7c5343f..c08ddcd 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h +++ b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h @@ -173,6 +173,19 @@ void lr20xx_cad_set_peak_offset(const struct device *dev, int8_t offset); */ uint8_t lr20xx_cad_base_peak(const struct device *dev); +/** + * @brief Absolute detPeak range this driver will program, inclusive + * + * The adaptive-CAD controller needs these to keep its offset window inside the + * range where every level is a distinct configuration. Outside it the clamp + * folds several offsets onto one peak, and the staircase compares rungs that + * are physically identical. + * + * @return lowest / highest detPeak the driver will ever send + */ +uint8_t lr20xx_cad_peak_min(void); +uint8_t lr20xx_cad_peak_max(void); + /** * @brief Run one blocking calibration CAD at base detPeak + peak_offset * diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index b89afa2..ae33c53 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -957,6 +957,9 @@ static bool handle_vcontact_cli(const char *line, char *reply) companion_mesh.vcontactPushAdvert(); } else if (!en && was) { companion_mesh.vcontactPushDeleted(); + /* The app drops the contact, so its flags (favourite / telemetry + * permissions) go with it — same as the app-side delete path. */ + companion_mesh.prefs.v_contact_flags = 0; } snprintf(reply, CLI_REPLY_SIZE, "OK - v.contact %s", en ? "on" : "off"); return true;