vcontact and cad fixes

This commit is contained in:
liquidraver
2026-08-12 15:36:51 +02:00
parent ee90693c89
commit aac97f7ade
12 changed files with 291 additions and 47 deletions
+48 -14
View File
@@ -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
1540, LR 4890) is a guardrail; AN1200.48 recommends 2129 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 1540, LR 4890), 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 SF57 (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 2129
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 152154):
- `set/get v.contact on|off` — default on.
**Settings** (companion `v.*` CLI namespace, prefs offsets 152154 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 <mV>|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
@@ -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'",
+15
View File
@@ -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:
+2
View File
@@ -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;
};
+56 -13
View File
@@ -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];
+21
View File
@@ -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
+76 -10
View File
@@ -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();
+11 -1
View File
@@ -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
+6
View File
@@ -140,6 +140,12 @@ struct NodePrefs {
uint16_t auto_shutdown_mv; // low-batt auto-shutdown threshold; 0 = off, else 29004200
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. */
@@ -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
@@ -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
*
+3
View File
@@ -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;