From 5bf3c577c903de2f8832a4163db732ba0c8c1eb0 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:31:39 +0200 Subject: [PATCH] tune cad detection across all chips --- docs/ADAPTIVE_CAD.md | 48 +- docs/ARCHITECTURE.md | 2503 +++++++++-------- docs/Repeater_CLI_commands.md | 700 ++--- releasenotes/RELEASE_NOTES_1.17.4-zephcore.md | 231 +- zephcore/adapters/radio/LR1110Radio.cpp | 16 + zephcore/adapters/radio/LR1110Radio.h | 2 + zephcore/adapters/radio/SX126xRadio.cpp | 14 + zephcore/adapters/radio/SX126xRadio.h | 2 + .../drivers/lora/lr11xx/lr11xx_lora.c | 139 +- .../drivers/lora/lr11xx/lr11xx_lora.h | 20 +- .../drivers/lora/native/sx126x/sx126x_ext.h | 21 +- .../zephyr/0003-lora-sx126x-native.patch | 320 ++- 12 files changed, 2203 insertions(+), 1813 deletions(-) diff --git a/docs/ADAPTIVE_CAD.md b/docs/ADAPTIVE_CAD.md index 11be314..a732eec 100644 --- a/docs/ADAPTIVE_CAD.md +++ b/docs/ADAPTIVE_CAD.md @@ -42,8 +42,8 @@ lowering it means "hear even the faint stuff."** There is no knob that separates *near* from *far* — only *strong* from *faint* — because the radio only ever knew signal strength, never distance. -**Semtech's recommended values (AN1200.48: 21–29 across all SF/BW for the -SX126x; our base is `SF+13`, which is 21 at SF8) are tuned for a +**Semtech's recommended values (21–29 across all SF/BW for the SX126x; +our base is their table, ~20–24 at BW125) are tuned for a receiver** that wants to hear everything down to its sensitivity limit — i.e. to *catch the faint*. Listen-before-talk on a busy backbone often wants the opposite: deliberately sit *above* that band so it ignores faint @@ -51,13 +51,30 @@ contention it would win on capture anyway. So "operating above 29" is not a misconfiguration here; it is the point. **Scale sanity, because the register is deceptive.** `cadDetPeak` is a -full 8-bit field (0–255), but the *useful* range is only ~18–32. The -driver's 40 ceiling is ~11 above the highest value Semtech recommends -anywhere — a near-blind guardrail, not an operating point. And the -LR11xx/LR2021 family's 56–68 numbers are a **different chip's correlation -scale**; porting them onto an SX126x makes CAD deaf. If a value feels like -it should be "mid-scale," that instinct is the trap: this scale is -compressed, not linear over 0–255. +full 8-bit field (0–255), but the *useful* range is only ~18–35 on the +SX126x. The driver's 48 ceiling is well above the highest value Semtech +recommends anywhere — a near-blind guardrail, not an operating point. And +the LR11xx/LR2021 family's ~50–85 numbers are a **different chip's +correlation scale**; porting them onto an SX126x makes CAD deaf. If a +value feels like it should be "mid-scale," that instinct is the trap: this +scale is compressed, not linear over 0–255. + +**Bandwidth matters, and it matters unequally.** Semtech's tables are +bracketed by bandwidth, not just SF. On the SX126x the effect is mild — +1–3 counts per octave — but on the LR11xx it is roughly 12 counts per +octave (at SF7: 52 at BW125, 64 at BW250, 77 at BW500). A bandwidth-blind +base table is therefore a small error on one family and a large one on the +other. This is not theoretical: the LR11xx base table used to be +bandwidth-blind *and* taken from the wrong chip (it was the LR20xx +2-symbol row), reading 56 at SF7 where Semtech says 52. Field T1000-E +companions at SF7/BW62.5 walked eight rungs down to the offset rail +because of it, while an SX1262 in the same room settled at −1. + +Below BW125 Semtech declines to give any value, and MeshCore's default +preset is BW62.5 — so our normal operating point is off the end of the +published tables. Both drivers reuse the BW125 row there rather than +extrapolating the trend downward: the curve is noisy empirical PER data, +and the staircase's whole job is to find the local value anyway. **Why the probes only ever measure the faint side.** A calibration probe is *skipped* whenever RSSI is more than 7 dB above the noise floor (a @@ -99,8 +116,10 @@ duty cycle is briefly interrupted and re-armed, within the same preamble-catch budget philosophy the sniff-mode math already accepts. Each probe tests one **level**: a signed offset from the chip family's -per-SF base detPeak (SX126x: `SF+13`; LR11xx/LR2021: the 56–68 table). -Results accumulate per level: +base detPeak for the current SF, bandwidth and CAD symbol count (all three +from Semtech's LoRa Basics Modem reference tables — SX126x ~18–34, LR11xx +~50–85, LR2021 its own symbol-indexed table). Results accumulate per +level: - `probes` — how many CADs ran at this level - `busy` — raw "activity detected" verdicts @@ -180,7 +199,12 @@ 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 valley node much more sensitive. The driver additionally clamps the -absolute detPeak (SX126x 15–40, LR11xx/LR20xx 48–90). That absolute clamp +absolute detPeak (SX126x 12–48, LR11xx 40–100, LR20xx 48–90), and now +*reports* that clamp to the controller, which narrows the offset window to +match. That reporting matters: where base+offset falls outside the clamp, +several offsets collapse onto the same peak, and the staircase reads the +sampling noise between identical configurations as curvature — which is +how a node can random-walk to a rail. That absolute clamp is a *firmware guardrail*, not a chip limit — `cadDetPeak` is a full 8-bit register (0–255) — it simply stops the staircase from wandering into "CAD never fires" (detPeak too high → LBT effectively off) or "CAD diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c476914..6526ade 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,1251 +1,1252 @@ -# ZephCore Architecture Guide - -> Comprehensive developer reference for the ZephCore codebase — a Zephyr RTOS port of the Arduino MeshCore LoRa mesh networking firmware. - ---- - -## Table of Contents - -1. [Project Overview](#1-project-overview) -2. [Directory Structure](#2-directory-structure) -3. [Layer Architecture](#3-layer-architecture) -4. [Core Mesh Engine](#4-core-mesh-engine) -5. [Radio Subsystem](#5-radio-subsystem) -6. [Application Layer](#6-application-layer) -7. [Hardware Adapters](#7-hardware-adapters) -8. [UI Subsystem](#8-ui-subsystem) -9. [Build System](#9-build-system) -10. [Board Matrix](#10-board-matrix) -11. [Packet Format Reference](#11-packet-format-reference) -12. [BLE Protocol Reference](#12-ble-protocol-reference) -13. [Data Storage](#13-data-storage) -14. [Key Call Flows](#14-key-call-flows) -15. [Watchdogs and Recovery Mechanisms](#15-watchdogs-and-recovery-mechanisms) - ---- - -## 1. Project Overview - -ZephCore is a LoRa mesh networking firmware running on Zephyr RTOS. It supports four device roles: - -- **Companion**: BLE-connected device paired with a phone app. Full contact/channel/message management. -- **Repeater**: Autonomous headless relay node. CLI administration via authenticated mesh connections or serial UART. -- **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 (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 - -ZephCore is a port of [Arduino MeshCore](https://github.com/meshcore-dev/MeshCore). The core mesh protocol (Mesh.cpp, Dispatcher.cpp, Packet.cpp, Identity.cpp, Utils.cpp) is shared code. Adapters (`adapters/`) bridge MeshCore's HAL interfaces to Zephyr APIs. Binary file formats (prefs, contacts, channels) are byte-compatible with Arduino MeshCore. - ---- - -## 2. Directory Structure - -``` -zephcore/ -├── src/ # Core mesh engine (shared with Arduino MeshCore) -│ ├── Mesh.cpp # Routing protocol: flood, direct, dedup, adverts -│ ├── Dispatcher.cpp # Packet queue, radio scheduling, CAD, duty cycle -│ ├── Packet.cpp # Packet serialization, hash, wire format -│ ├── Identity.cpp # Ed25519 key management, ECDH shared secrets -│ ├── Utils.cpp # AES-ECB encrypt, HMAC-SHA256, MAC -│ ├── ContentionTracker.cpp # Adaptive contention window (EMA, backoff) -│ ├── StaticPoolPacketManager.cpp # Fixed-size packet pool (32 slots) -│ ├── main_companion.cpp # Companion mode entry point + event loop -│ ├── main_repeater.cpp # Repeater mode entry point + event loop -│ └── main_room_server.cpp # Room server mode entry point + event loop -│ -├── include/mesh/ # Core interfaces (shared with Arduino MeshCore) -│ ├── Mesh.h, Dispatcher.h, Packet.h, Identity.h, Utils.h -│ ├── MeshCore.h # Constants: key sizes, packet limits -│ ├── Radio.h # Abstract radio interface -│ ├── Board.h, Clock.h, RNG.h, RTC.h # HAL interfaces -│ ├── ContentionTracker.h # Adaptive contention window state -│ ├── LoRaConfig.h # Default radio parameters -│ ├── RadioIncludes.h # Compile-time radio driver selection -│ ├── SimpleMeshTables.h # Hash-based packet deduplication -│ └── StaticPoolPacketManager.h # Fixed pool allocator -│ -├── adapters/ # Zephyr HAL implementations -│ ├── radio/ # LoRa radio drivers -│ │ ├── LoRaRadioBase.cpp/h # Shared TX/RX state machine, noise floor, AGC -│ │ ├── 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) -│ │ ├── radio_common.h # Shared radio types and constants -│ │ ├── lr11xx/ # LR11xx low-level HAL (SPI, GPIO, Semtech SDK) -│ │ └── lr20xx/ # LR20xx low-level HAL (Semtech SDK) -│ ├── ble/ZephyrBLE.cpp/h # BLE NUS service, pairing, TX congestion -│ ├── board/ZephyrBoard.cpp/h # Battery ADC, LEDs, reboot, bootloader -│ ├── clock/ # Millisecond uptime + software RTC + I2C RTC discovery -│ ├── datastore/ZephyrDataStore.cpp/h # LittleFS persistence -│ ├── gps/ZephyrGPSManager.cpp/h # GNSS state machine, power mgmt -│ ├── mqtt/ZephyrMQTTPublisher.c/h # MQTT packet publisher (observer / uplink) -│ ├── ota/wifi_ota.c/h # WiFi SoftAP + HTTP firmware upload -│ ├── rng/ZephyrRNG.cpp/h # Hardware CSPRNG with PRNG fallback -│ ├── sensors/ # I2C env sensors + power monitors -│ ├── transport/ # TCP companion (native Linux) + serial companion (STM32WL) -│ ├── usb/ # USB CDC for companion + repeater -│ └── wifi/ZephyrWiFiStation.c/h # WiFi station client (ESP32) -│ -├── app/ # Application layer -│ ├── CompanionMesh.cpp/h # Phone-connected companion logic -│ ├── RepeaterMesh.cpp/h # Autonomous repeater logic -│ ├── RepeaterRegionCLI.cpp # Repeater `region` CLI commands -│ ├── RepeaterUplink.cpp # Repeater WiFi+MQTT uplink (ESP32) -│ ├── RepeaterDataStore.cpp/h # Repeater-specific persistence paths -│ ├── RoomServerMesh.cpp/h # Store-and-forward room server (BBS) -│ ├── RoomServerRegionCLI.cpp # Room server `region` CLI commands -│ ├── ObserverMesh.cpp/h # Listen-only WiFi+MQTT observer (ESP32) -│ └── main_observer.cpp, observer_creds.cpp/h -│ -├── helpers/ # Shared utilities -│ ├── BaseChatMesh.cpp/h # Contact/channel/message base class -│ ├── CommonCLI.cpp/h # Serial/mesh CLI command processor -│ ├── MeshTimeSync.cpp/h # Mesh clock-consensus estimator (§4.9) -│ ├── AdvertDataHelpers.cpp/h # Advertisement wire format encoder/decoder -│ ├── ClientACL.cpp/h # Authenticated client management -│ ├── TransportKeyStore.cpp/h # Region transport key cache -│ ├── RegionMap.cpp/h # Region-based flood filtering -│ ├── ContactInfo.h, ChannelDetails.h, NodePrefs.h # Data structures -│ ├── RateLimiter.h, IdentityStore.h, StatsFormatHelper.h -│ ├── battery_curve.c/h, fatal_reboot.c, oled_power.c/h -│ ├── ui/ # Shared UI plumbing: display, buzzer, multi-tap input, Doom -│ ├── ui-button/ # Single-button page UI (pages, task) -│ └── ui-joystick/ # 5-way joystick UI (Wio Tracker L1) -│ -├── boards/ # Board definitions -│ ├── common/ # Shared configs, DTS includes, partition layouts -│ ├── nrf52840/ # RAK4631, T1000-E, ThinkNode M1/M3/M6, T-Echo, T114, ... -│ ├── nrf54l/ # XIAO nRF54L15 -│ ├── esp32/ # XIAO C3/C6/S3, Heltec V3/V4.x, Station G2, T-Beam, ... -│ ├── mg24/ # XIAO MG24 -│ ├── stm32wl/ # Seeed LoRa-E5 mini -│ └── linux_native/ # native_sim presets (Femtofox, RAK6421) — see LINUX_NATIVE.md -│ -├── patches/ # Zephyr tree modifications -│ ├── zephyr/ # Unified diffs (SX126x extensions, GNSS, native Linux, ...) -│ └── zephyr-new/ # New files (LR11xx/LR20xx drivers, native Linux SPI/GPIO, DTS bindings) -│ -├── lib/monocypher/ # Vendored crypto library (Ed25519/X25519) -├── tools/ # Formatter (flash erase) + LR1110 firmware updater -├── CMakeLists.txt # Build orchestration -├── Kconfig # All ZephCore configuration options -├── Kconfig.psram # ESP32 PSRAM auto-enable from devicetree -├── prj.conf # Base project config -├── sysbuild.conf # Forces MCUboot when --sysbuild is used -└── west.yml # West manifest (Zephyr version pin) -``` - ---- - -## 3. Layer Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ Phone App (BLE NUS / USB CDC / TCP / UART) │ External -│ or Serial CLI (USB CDC / PTY) │ -├─────────────────────────────────────────────────┤ -│ CompanionMesh / RepeaterMesh / │ App Layer -│ RoomServerMesh / ObserverMesh │ -│ ├── BaseChatMesh (contacts, channels, msgs) │ -│ ├── CommonCLI (command processor) │ -│ ├── ClientACL, RegionMap, TransportKeyStore │ -│ └── UI (display, buzzer, buttons) │ -├─────────────────────────────────────────────────┤ -│ mesh::Mesh │ Routing -│ ├── Flood routing (path hash accumulation) │ -│ ├── Direct routing (source-routed paths) │ -│ ├── Packet dedup (SimpleMeshTables) │ -│ └── Advert / ACK / Trace / Group dispatch │ -├─────────────────────────────────────────────────┤ -│ mesh::Dispatcher │ Scheduling -│ ├── TX/RX queue management │ -│ ├── CAD (channel activity detection) │ -│ ├── Duty cycle enforcement (EU ETSI) │ -│ ├── RX delay (score-based prioritization) │ -│ └── Maintenance (noise floor, image cal) │ -├─────────────────────────────────────────────────┤ -│ LoRaRadioBase │ Radio HAL -│ ├── SX126xRadio ──► Zephyr SX126x driver │ -│ ├── SX127xRadio ──► loramac-node backend │ -│ ├── LR1110Radio ──► Custom LR11xx driver │ -│ └── LR2021Radio ──► Custom LR20xx driver │ -├─────────────────────────────────────────────────┤ -│ Zephyr RTOS (kernel, drivers, BLE, FS, USB) │ Platform -└─────────────────────────────────────────────────┘ -``` - ---- - -## 4. Core Mesh Engine - -### 4.1 Packet Lifecycle - -1. **Allocation**: `StaticPoolPacketManager::allocNew()` — fixed pool of 32 `Packet` objects (no heap) -2. **Creation**: `Mesh::createDatagram()`, `createAdvert()`, `createAck()`, etc. -3. **Queuing**: `Dispatcher::sendPacket()` → `PacketManager::queueOutbound()` with priority + scheduled time -4. **Transmission**: `Dispatcher::checkSend()` → CAD check → serialize → `radio->startSendRaw()` -5. **Release**: `PacketManager::free()` after TX complete or processing done - -### 4.2 Packet Structure - -``` -Wire format: - [header: 1B] [transport_codes: 0 or 4B] [path_len: 1B] [path: variable] [payload: variable] - -Header byte: - Bits 0-1: Route type (0=transport_flood, 1=flood, 2=direct, 3=transport_direct) - Bits 2-5: Payload type (0=REQ .. 15=RAW_CUSTOM) - Bits 6-7: Version (0=v1) - -Path_len byte: - Bits 0-5: Hash count (0-63 hops) - Bits 6-7: Hash size mode (0=1B, 1=2B, 2=3B, 3=reserved) -``` - -### 4.3 Payload Types - -| Type | Value | Description | -|------|-------|-------------| -| REQ | 0x00 | Encrypted request to peer | -| RESPONSE | 0x01 | Encrypted response from peer | -| TXT_MSG | 0x02 | Encrypted text message | -| ACK | 0x03 | 4-byte CRC acknowledgment | -| ADVERT | 0x04 | Signed identity advertisement | -| GRP_TXT | 0x05 | Group channel text message | -| GRP_DATA | 0x06 | Group channel data | -| ANON_REQ | 0x07 | Anonymous request (includes full pubkey) | -| PATH | 0x08 | Path return (source route exchange) | -| TRACE | 0x09 | Trace route | -| MULTIPART | 0x0A | Multi-ACK container | -| CONTROL | 0x0B | Control data (zero-hop) | -| RAW_CUSTOM | 0x0F | Raw custom data | - -### 4.4 Routing - -**Flood routing**: Packet has no destination path. Each relay node appends its identity hash to `path[]` and retransmits. Priority decreases with hop count. `allowPacketForward()` is the gatekeeper. - -**Direct routing**: Packet carries a source-routed `path[]`. Each relay node checks if the first path hash matches its own identity, removes itself, and forwards. Path is built from previous flood packets' accumulated hashes. - -**Deduplication**: `SimpleMeshTables` maintains a circular buffer of 160 packet hashes (8 bytes each, SHA-256 truncated); ACKs are deduped through the same packet-hash path. `wasSeen()` is a pure query; call sites insert explicitly via `markSeen()` to prevent duplicate processing and retransmission. - -### 4.5 Dispatcher Scheduling - -The Dispatcher runs a tight loop: - -``` -loop(): - 1. Check if current TX is complete → release packet, record airtime - 2. Process next inbound packet from queue (if scheduled time has passed) - 3. checkRecv(): Drain radio RX ring buffer - - Parse raw bytes into Packet - - Flood packets: compute RX delay based on score → defer or process immediately - - Direct packets: process immediately - 4. checkSend(): Check outbound queue - - CAD: if channel busy (`isReceiving()` returns true or radio not ready), - retry every 100-200ms (jittered) up to 4s total. On 4s timeout, - call `_radio->recoverRxState()` (cancel + restart, clears IRQ + - latch + grace timestamp) and re-wake the loop instead of falling - through to TX. - - Duty cycle: if exceeded, defer 5 seconds (admin packets exempt) - - Final `isReceiving()` check right before TX (closes timing gap) - - Serialize and transmit -``` - -**RX Delay**: Flood packets are delayed based on signal quality. High-quality signals (high SNR, short packets) get shorter delays, allowing closer/better relays to retransmit first. Uses a lookup table approximation of `10^(0.85 - score*0.1) - 1` multiplied by airtime. - -**Duty Cycle**: Fixed 1-hour sliding window. Default 10%. Admin packets (REQ, RESPONSE, ANON_REQ, CONTROL) are exempt. - -### 4.6 Maintenance Loop - -Called every ~5 seconds from the main event loop: - -1. **Noise floor calibration**: EMA with alpha=1/8, jitter, threshold filtering, warmup -2. **RX mode watchdog**: Flags error if radio stuck outside RX for >8 seconds -3. **AGC reset** (`agcIdleMaintenance()`): warm sleep + recalibration, **SX126x only** — gated on `hwNeedsAgcReset()`, which only that family declares. Semtech prescribe it for a jammed AGC on the SX126x; neither the LR11xx UM nor the LR2021 DS describes such a fault, and running it there cost packets (T1000-E, 2026-08-24: 7.4% miss rate in the 60 s after a fire vs 0.6% elsewhere). Triggered by long silence **and** corroborating evidence — a frozen noise-floor reading — never by silence alone, which is a normal condition rather than a fault. -4. **Image-calibration drift** (`imageCalMaintenance()`): unrelated to the AGC despite the shared hook — LR11xx/LR2021 only, where the datasheets give a temperature threshold. Temperature is read from the **board** (`Board::getMCUTemperature()`), never from the radio; only the delta matters and the die sensor tracks the same ambient without costing the radio an SPI command. Polled hourly, deferred after TX so PA self-heating is not read as ambient drift, and confirmed by a second reading before recalibrating. - -### 4.7 Adaptive Contention Window - -Replaces Arduino MeshCore's static `txdelay`/`rxdelay` with three complementary mechanisms. - -**EMA Delay Factor (proactive)** - -`ContentionTracker` measures observed duplicates per retransmitted packet using a **24-entry ring buffer** (sized for ~50-neighbor hilltop topologies with multiple concurrent in-flight floods). Each entry tracks a packet (identified by FNV-1a hash) and records how many dupes arrive within a 10-second observation window. When the window closes, the entry is finalized and an EMA is updated with alpha = 1/8. The resulting estimate feeds the delay factor formula: - -``` -factor = 0.05 + 0.170 * sqrt(est) -``` - -Capped at 2.0. During warmup (fewer than 4 finalized entries), factor defaults to 0.5. Sparse nodes converge toward near-zero delay; dense nodes get proportionally higher delay. - -The flood retransmit jitter window is `5·airtime·factor` clamped by **two ceilings**: -- Airtime-scaled: `6·airtime` — keeps SF7/narrow-BW configs from wasting time in oversized windows. -- Absolute: `2000ms` — bounds per-hop latency in dense areas even when airtime is large. - -**Per-Dupe Reactive Backoff** - -When a duplicate of a pending outbound packet is heard, TX is rescheduled to `now + backoff_multiplier * airtime`. Each dupe triggers a full delay (not diminishing). Cumulative reactive extension is capped at `min(2000ms, 12·airtime)` per packet; after the cap, CAD handles remaining channel activity. `backoff_multiplier` is configurable via `set backoff.multiplier X` (range 0.0–2.0). - -**Initial-Flood Jitter (companion-only)** - -Companions don't retransmit floods, but they observe mesh contention and need to spread their *originated* transmissions to avoid colliding with repeaters still busy in TX/RX. `Mesh::passivelyTrackFloods()` (overridden to `true` on `CompanionMesh`) registers every first-hearing of a flood with the ContentionTracker, so the EMA warms up even without forwarding. `Mesh::getInitialFloodJitter(packet)` is added to the caller-supplied delay in both `sendFlood` overloads; on companion this is `rand(0, min(1000ms, 3·airtime, 5·airtime·factor))` — half the repeater's ceilings. Repeaters keep the default 0 (no double-jitter on forwards). - -**Direct Packets** - -Direct (source-routed) packets bypass adaptive scaling entirely. They use minimal fixed jitter: `20 + rand(0, airtime / 10)` ms. - -**CLI** - -- `get txdelay` — shows current adaptive state (EMA estimate, delay factor, backoff multiplier). -- `set backoff.multiplier X` — controls per-dupe reactive delay (0.0–2.0). -- `txdelay`, `rxdelay`, `direct.txdelay` — accepted for prefs compatibility but ignored at runtime. - -**ContentionTracker Resource Usage** - -~260 bytes RAM (24-entry ring buffer × ~16B/entry + state). FNV-1a packet hash, 10-second observation window, EMA with alpha = 1/8. - -### 4.8 Encryption - -- **Peer-to-peer**: ECDH shared secret (Curve25519) → AES-128-ECB encrypt → 2-byte HMAC-SHA256 MAC -- **Group channels**: SHA-256 of channel name → AES key -- **Advertisements**: Ed25519 signature over (pubkey + timestamp + app_data) -- **ACKs**: SHA-256(shared_secret + packet_hash) truncated to 4 bytes - -### 4.9 Mesh Time Sync (Clock Consensus) - -ZephCore-only divergence from Arduino MeshCore (like the Adaptive Contention Window). A node senses its own clock error from the Ed25519-signed timestamps in other nodes' adverts and — **opt-in, default off** (`set meshtimesync on`) — corrects it automatically. There is no trusted reference clock on a mesh, so this is a *consensus estimation* problem: the node assumes the majority of tenured advert senders within 3 flood hops is right. User-facing doc: `MESHTIMESYNC.md` at the repo root. - -**Module**: `helpers/MeshTimeSync.{h,cpp}` — role-agnostic estimator, owns no clock. Each role feeds it verified adverts (`onAdvertHeard`), calls `tick()` periodically (15-min pacing internal), and applies STEP verdicts under its own policy. - -**Sample table** (per-sender, `CONFIG_ZEPHCORE_TIMESYNC_TABLE_SIZE` slots: 32 default, 16 on RAM-bound companions; 24 B/slot): -- 8-byte pubkey prefix — a security floor, not a tuning knob (shorter prefixes are grindable: an attacker could collide a tenured voter's prefix and reset its tenure with validly-signed adverts). -- Latest advert timestamp (= the vote, per-sender monotonic — replays and flood dupes are inert) + arrival **uptime**. Skew is recomputed at evaluate time from the uptime anchor, so the node's own steps never stale stored samples. -- Tenure tracking: first-heard uptime, advert count. Eligibility = heard ≥ 1 h, ≥ 2 adverts, latest sample ≤ 5 days old (bridges the 47 h flood-advert cadence). -- Self-consistency: consecutive samples must satisfy `|Δadvert_ts − Δuptime| ≤ 45 s + 150 ppm × Δuptime`; violation (sender rebooted/corrected/lying) resets that sender's tenure. -- **Hop-priority admission** (hop cap 3): a new sender may only displace a young entry farther (higher hop) than it; mature entries are protected unless silent > 24 h. Naive LRU churned hub nodes to zero eligible voters in simulation. - -**Consensus**: Marzullo interval intersection over eligible votes, each `[skew − r, skew + r]` with `r = 150 s + 15 s × hop` (the 150 s base covers the real fleet's good-clock scatter, not just RF delay). No absolute outlier thresholds against the local clock — clustering does the rejection, so an epoch-reset clock still finds the true cluster. Stepping requires `CONFIG_ZEPHCORE_TIMESYNC_QUORUM` (default 6, floor 3, build-time security knob) eligible senders AND a strict majority inside the intersection; otherwise abstain. - -**Correction policy** (priority: GPS / manual set > mesh consensus): -- Any clock set — GPS fix **or** manual set (`time`, `clock sync`, app time set) — arms the same **7-day suppression** of all stepping, bootstrap included, plus drift-envelope pedigree (`noteGPSSync` and `noteManualSync` are identical). A live GPS re-arms it on every fix (so a repeater's 48 h duty cycle keeps GPS owning the clock); a GPS that cannot fix (indoors, dead antenna) becomes mesh-correctable once 7 days pass without a fix. Sensing always continues; a suppressed node shows `hold (suppressed)` in the dry-run. -- Step trigger 10 min, dead band 5 min, step capped **±1 h**, one step per **6 h**, logged loudly. Production contains coherent wrong-time islands (+28 h × 63 repeaters at analysis time); the cap bounds capture drag to 4 h/day. -- **Drift-envelope gate**: with a trusted sync + continuous uptime since (pedigree, RAM-only), corrections beyond `elapsed × 300 ppm + 10 min` are physically impossible for a crystal — refused regardless of quorum. -- **Bootstrap**: local time < firmware build epoch (`FIRMWARE_BUILD_EPOCH`, CMake-injected) is provably wrong → any 3 agreeing senders, step to the cluster's **low edge** (midpoint − 150 s; undershoot so later refinement is always forward = monotonicity-safe). - -**Per-role step policy** (policy lives in the role, not the estimator): -| Role | Policy | Why | -|---|---|---| -| Repeater | bidirectional | clock not load-bearing: forwarding/dedup/remote-admin run on `millis()`/hashes; a backward step only mutes own adverts at peers for a window equal to the step | -| Observer | bidirectional | clock only stamps observations — exactly what this fixes | -| Room server | forward-only | post timestamps feed client `sync_since` ordering | -| Companion | forward-only | own clock stamps outgoing DMs; peers hold per-sender replay high-water marks | - -**Step application**: the shared policy (suppression/pedigree checks inside `evaluateNow`, forward-only skip, uint32-overflow guard, set clock, one `zephcore_rtc_save` per step — never per evaluation) lives in `MeshTimeSync::runTick()`; when it returns true, the role shifts its wall-clock-anchored bookkeeping by `lastStepDelta()` — repeater: neighbor `heard_timestamp`s, ACL `last_activity`, login/anon/discover rate-limiter resets; room server: ACL + login limiter. - -All policy timers (6 h rate limit, 7-day suppression, tenure, sample age) anchor on **uptime, never wall clock** — otherwise the very steps they govern would distort them. - -**CLI**: `set meshtimesync {on|off}`, `get meshtimesync` → state + live dry-run (eligible count, votes for/against, skew/radius, would-be verdict) + per-sender evidence table (full table over local USB; remote admin replies are summary-truncated to fit the packet). Sensing always runs, so the dry-run works before enabling. - -**Accepted limits**: a coordinated same-offset majority around a node captures it (no consensus survives that — Bitcoin timejacking lesson; mitigations: default-off, manual override, caps); sub-quorum islands abstain forever (bootstrap still heals dead clocks with 3 senders). - ---- - -## 5. Radio Subsystem - -### 5.1 Class Hierarchy - -``` -mesh::Radio (abstract interface) - └── LoRaRadioBase (shared state machine, ring buffer, noise floor) - ├── 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 -``` - -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. - -### 5.2 LoRaRadioBase State Machine - -**TX Flow** (LBT — current default; `cad.mode == LORA_CAD_MODE_LBT` is set unconditionally in `buildModemConfig`): -1. `startSendRaw()` → `isReceiving()` final gate → `_tx_active = 1` → **skip** `hwCancelReceive()` and leave `_in_recv_mode = 1` so the driver sees state == RX → `configureTx()` → async send. -2. SX126x `send_async` entry CAS accepts both `REST_STATE → TX` and `RX → TX`, recording `was_rx`. LBT branch issues `set_standby(RC)` then SetCAD. On CAD-busy: in-driver `sx126x_restart_rx` puts the chip back in RX before `-EBUSY` returns. C++ failure path calls `startReceive()`, which the driver's `lora_recv_async` short-circuits when state is already RX. -3. On TX success: `_in_recv_mode = 0`, TX wait thread blocks on semaphore (5 s timeout). -4. On DIO1 `TX_DONE` interrupt → signal raised → restart RX → update stats. - -**RX Flow**: -1. `lora_recv_async()` with callback. SX126x `recv_async` clears `IRQ_ALL` and resets the RX-busy signals on every fresh entry. -2. ISR writes to 8-slot SPSC ring buffer (drops NEW packet on overflow). -3. Main thread drains via `recvRaw()`. - -**Config Caching**: Avoids redundant `lora_config()` calls. Fast-path for TX↔RX transitions when only direction differs. `recoverRxState()` clears the cache (`_config_cached = false`) so post-recovery RX goes through the full path. - -### 5.2.1 RX-Busy Gate (TX-during-RX prevention) - -`LoRaRadioBase::isReceiving()` is the single software source of truth for "currently receiving" and is consulted at three sites: dispatcher initial gate, dispatcher final gate, and `startSendRaw`'s last-moment gate. Logic: - -``` -isReceiving() - ├─ false if !_in_recv_mode || _tx_active - ├─ true if hwIsReceiving() ← per-adapter; never clears IRQ - └─ isChannelActive() RSSI fallback ← sub-preamble-threshold energy -``` - -For SX126x, `hwIsReceiving()` → `sx126x_is_receiving()` reads in this order: -1. **`data->rx_packet_active`** latch (no SPI). Set by the work handler on `HEADER_VALID`; cleared on every terminal event and RX (re)start. Covers the full payload phase. Bounded by a payload deadline: `header_seen_at_ms` is stamped when the latch is promoted, and once `sx126x_max_payload_ms()` (255-byte airtime at the current SF/BW, CR 4/8, LDRO on, +25% +100 ms) has elapsed the latch is released and the sticky PREAMBLE/SYNC/HEADER bits cleared. Continuous RX has no symbol timer, so without this a `HEADER_VALID` whose packet never completes would hold the TX gate closed until reboot; the DC parked-RX watchdog does not cover it (DC-only, and it treats the latch as a legitimate in-flight packet). -2. **Mutex-busy conservative** — if the SPI mutex is contended and `state == RX`, return true (the work handler is likely mid-`RxDone`). -3. **`HEADER_VALID` raw bit** — covers the microseconds between DIO1 firing and the work handler running. -4. **`PREAMBLE_DETECTED` raw bit with SF-aware grace** — `PREAMBLE_DETECTED` is masked off DIO1 (fires on noise), but visible in the IRQ register. On first observation, `is_receiving` records `data->preamble_seen_at_ms`; subsequent calls return true until either `HEADER_VALID` promotes the latch (timestamp reset) or `(preamble_len + 8) × 2^SF / BW` ms elapses — at which point the bit is explicitly cleared and TX is allowed. Grace scales with SF: ~82 ms at SF8, ~786 ms at SF12. - -The poll path is otherwise non-destructive — IRQ bits are cleared only by the work-handler bulk clear (on any DIO1 event), explicit `clear_irq_status(IRQ_ALL)` at every RX (re)start, the grace-expiry one-bit clear for foreign preambles, and the payload-deadline clear in step 1. - -### 5.2.2 CAD-Timeout Recovery - -`Dispatcher::checkSend()` tracks `cad_busy_start` while `isReceiving()` keeps the TX gate closed. If 4 s elapse (`getCADFailMaxDuration()`), the dispatcher calls `_radio->recoverRxState()` and returns. `LoRaRadioBase::recoverRxState()` does: - -```cpp -hwCancelReceive(); // RX → IDLE → STANDBY → SLEEP (REST_STATE) -atomic_set(&_in_recv_mode, 0); // resync C++ side -_config_cached = false; // force full lora_config on the way back -startReceive(); // CAS(REST → RX) clears latch + IRQ -``` - -This walks the chip through REST so the driver's `lora_recv_async` entry CAS (`REST_STATE → RX`) actually succeeds — a bare `startReceive()` from `state == RX` would fail with `-EBUSY` and set `_in_recv_mode = 0` while the driver still thinks it's in RX. After recovery, the dispatcher fires `_tx_queued_cb(1, ...)` to re-wake the loop promptly. - -### 5.3 Noise Floor EMA - -Algorithm in `triggerNoiseFloorCalibrate()`: -- 8 RSSI samples per tick, take median (insertion-sort midpoint) -- Threshold filter: reject samples ≥ floor + 14dB (after 8-tick warmup) -- Periodic bypass: every 16th tick accepts unconditionally -- EMA: `floor += round_nearest((sample - floor) / 8)`, clamped to [-120, -50] dBm - -### 5.3.1 Adaptive CAD (LBT detPeak calibration) - -`cadDetPeak` is a correlation peak-to-noise threshold in the despreader (not -dBm): it gates on signal *strength* ≈ link budget, blind to distance, so -raising it means "react to strong signals only, ignore faint/echo". The right -LBT sensitivity is site-dependent and cannot be derived from the RSSI floor. -`LoRaRadioBase::cadMaintenance()` (housekeeping tick) runs one calibration CAD -probe per `probe.interval` (default **15 s**) at a signed **level** relative -to the family's per-SF base detPeak, restarts RX, and classifies busy verdicts -with a ground-truth filter. **Key property:** the probe is *skipped* when RSSI > -floor+7 dB, so probes only ever sample the quiet/faint regime — the whole loop -is a faint-rejection tuner and `busy%` is faint-regime, not total occupancy. -Post-busy classification watches a ~12-symbol window for RX re-sync **or** an -RSSI climb above floor+guard (the energy path recovers real packets whose -preamble the probe's RX-restart ate — the fix for the FP over-count that used to -drive the staircase to the ceiling) → `tp`, else `fp`. Counters decay 6-hourly, -reset on any RF param change. - -With `cad.auto on` the staircase is **knee-seeking**: probes sample op / op−1 / -op+1 (½/¼/¼); it steps **up** when the level above is ≥`CAD_KNEE_SLOPE_PERMILLE` -(5%) cleaner (steep side, below knee), **down** only on a clean flat plateau -(`≤CAD_PLATEAU_CLEAN_PERMILLE`), else holds — slope-based so convergence is -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 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.stats` 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 -`nSym·Tsym + margin`. CLI: `get cad.stats` (3-rung window, `*`=operating, `bc:`=cap), -`set cad.auto/offset/probe.interval/busycap/reset`. SX127x: unsupported (no HW -CAD). Full mental model + tuning: `ADAPTIVE_CAD.md`. - -### 5.4 LR1110 Driver Errata Workarounds - -The custom `lr11xx_lora.c` driver handles several LR1110 firmware bugs: -- **CMD_ERROR IRQ**: Benign error flag on several write commands — cleared silently -- **RX buffer drift**: Buffer base shifts 4 bytes per packet → `clear_rxbuffer()` after every RX -- **Header error**: Can shift buffer pointer → standby before RX restart -- **DIO1 stuck HIGH**: 5-cycle detection → full hardware reset + recovery -- **BUSY-high wedge**: a command racing the autonomous `SetRxDutyCycle` sleep phase can leave the chip BUSY-high with DIO1 low. No IRQ ever fires, so the event-driven driver never re-arms and the node goes permanently deaf. A wedge-recovery watchdog on its own work queue (`lr11xx_wedge`, `K_PRIO_COOP(7)`) checks every 3 s: after 12 s of DIO1 silence it polls the BUSY GPIO continuously for 250 ms, and a dwell with no low edge means a genuine wedge → hardware reset + RX restart. False-positive free by construction — a healthy chip, continuous or duty-cycled, always drops BUSY low within one cycle. Reads the GPIO only (no SPI), so it cannot itself disturb the chip or race the autonomous DC state machine. -- **Duty-cycle ownership (BUSY is not a mode flag)**: UM §7.2.6 ends the RxDutyCycle loop on exactly three events — a packet (chip returns to the configured fallback, `STDBY_RC`), a host `SetStandby`, or **an NSS falling edge waking the chip from the sleep phase**, for which the manual adds "the user should send the `SetStandby(...)` command". Every host command is an NSS edge, so a command landing in the sleep phase silently ends the cycle: no IRQ, no flag, and every re-arm site hangs off RX_DONE or an error, which cannot fire on a receiver that is no longer listening. The node goes deaf until something independently calls `startReceive()` — which is precisely what the periodic housekeeping tick removed in `fe6e585` (2026-07-28, three days after the 1.16.7 tag) had been doing, and why the duty cycle "worked" in 1.16.7. Measured on a T1000-E 2026-08-24: **10 packets against an SX1262's 94** over 85 minutes with the cycle armed; parity with it off. - - The guard this replaces was a BUSY read before each command, and it cannot be made correct here. BUSY is high in the sleep phase (command fatal) **and** through ordinary Rx (command harmless), so the pin does not distinguish them — 406 of 407 sampler bursts refused with the cycle armed, 76 of 76 with it off — and it is check-then-act regardless, since the chip can enter sleep between the read and the NSS assert. The driver takes ownership instead: `lr11xx_dc_suspend()` / `lr11xx_dc_resume()` bracket any work that must touch the chip, ending the cycle with the `SetStandby` the manual asks for and re-arming explicitly. `is_receiving()` is deliberately *not* bracketed — it runs on the TX gate, where standing the cycle down would end the reception being asked about — and answers from the DIO1-stamped latch with no bus access. Restored parity to 10/10, the sampler to zero refusals, and adaptive CAD from 2 probes in 9 h to ~20 per 5 min. Identical treatment in the LR2021 driver, whose DS §6.3.8 states the same three rules word for word (**untested on hardware** — no X1 available). - - Two consequences worth keeping straight: the per-packet re-arm skips the standby after RX_DONE (`restart_rx(data, in_standby=true)`) because the chip has already performed it to spec, and the Rx-boost re-apply was dropped from the duty-cycle paths — §7.2.6 saves and restores the device configuration across each wake, so it was a redundant write inherited by analogy from the SX126x, which genuinely does need one (DS §9.6 retention list). - -- **Stale SPI reply read as data**: a read is two NSS windows (command, then answer) with a BUSY wait between them. `wait_on_busy()` returns immediately on a BUSY that reads low and cannot distinguish "command finished" from "BUSY has not risen yet", so the answer window can clock out the chip's default status / IRQ stream instead of the payload — silently, since the caller parses IRQ bits as a plausible short integer. `lr11xx_hal_read()` therefore checks the stat1 command-status byte it used to discard and re-issues the command (3 attempts) unless it reports `CMD_DATA`. Same guard in the LR2021 HAL; upstream MeshCore hit this as [PR #3261](https://github.com/meshcore-dev/MeshCore/pull/3261). -- **RX duty cycle**: wired via `SetRxDutyCycle` MODE_RX, sized by the shared adapter math (same as SX126x). The earlier "broken, 23-40% loss" verdict was a window-sizing bug (over-sleep + no header budget), not a chip defect — default-off, HW-verify before production use. - -### 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). **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 the temperature-drift recalibration — never on the Tx/Rx path (DS §6.4.2 keeps the values on chip across retention sleep). (It used to also ride the AGC-reset path; that path no longer exists on this family, and the caller it had explicitly skipped CalibFE anyway, so nothing was lost when it went.) 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. - - **Reads are status-checked.** The two-window read (command, BUSY wait, answer) can clock its second window before BUSY rises, in which case the chip streams status / IRQ instead of the payload — `GetRxPacketLength` then returns `irq[31:16]`, exactly 4 with `RX_DONE` set, and a real frame is read out of the FIFO at the wrong length. `lr20xx_spi_read_frame()` accepts an answer only when the stat1 header reports `CMD_DATA`, re-issuing the command otherwise (3 attempts). Safe to retry because the Rx FIFO pop is not on this path (`lr20xx_hal_direct_read_fifo()`, single window, structurally immune). - -#### 5.5.1 LR2021 driver design notes - -Why the driver is shaped the way it is. Kept here rather than in comments; the -code carries only units, datasheet references, and the constraints that would -break something if violated. - -**PA power.** `pa_lf_table[]` is Semtech's `LR20XX_PA_LF_CFG_TABLE` -(`examples/radio_hal/lr20xx_pa_pwr_cfg.h`, Clear BSD), indexed −10…+22 dBm, and -`lr20xx_get_pa_cfg_for_power()` mirrors `lr20xx_get_tx_cfg()` from -`ral_lr20xx_bsp.c`. `half_power`, `pa_duty_cycle` and `pa_lf_slices` are a -**matched triple per target power** — not independent knobs, which is why the -board-level `pa-hp-sel`/`pa-duty-cycle` devicetree properties were removed. The -register is half-dBm (DS Table 7-20, the SDK's `power_half_dbm` parameter name, -DS Table 7-16, and the BSP field name all agree); an earlier table modelled it as -an opaque calibration value and transmitted +22 dBm requests at 17.5 dBm. Values -are chip-level for Semtech's reference design: Semtech applies a per-board -matching-network correction separately via -`radio_utilities_get_tx_power_offset()`, which ZephCore does not yet have, so -absolute radiated power is uncalibrated. - -**Front-end calibration.** DS §6.4.2 stores calibration on chip, and it survives -every sleep this driver issues (all with retention), so it does **not** belong on -the Tx/Rx path — Semtech's `ral_lr20xx_init()` calibrates once at init and never -during operation. It runs only at `lora_config()`, after `lr20xx_hardware_reset()` -(a chip reset discards it) and in `reset_agc()`. `CalibFE` takes up to three -**point** frequencies in 4 MHz steps, unlike the SX126x/LR11xx `CalibrateImage` -band pair with datasheet-prescribed edges, so the operating frequency can be used -directly. The argument is quantised and the SDK rounds **up** where the chip's -own no-argument default truncates **down**, so both 4 MHz neighbours are -calibrated, nearest first — sidestepping an undocumented reuse rule. (The -"±20 MHz" tolerance comes from a BSP comment, not the datasheet.) - -**LBT and CAD_LBT.** `lr20xx_do_cad()` uses **LoRa CAD** (`SetLoraCadParams` / -`SetLoraCAD`) with the per-SF `det_peak` of DS Table 6-19 — not the generic -RSSI-threshold CAD, which cannot see a LoRa signal below the noise floor. The two -commands have **different exit-mode encodings**; always use -`lr20xx_radio_lora_cad_exit_mode_t`. With `CadExitMode = 0x10` the chip performs -CAD→Tx itself, removing ~3.9 ms of host round-trip (measured). Its `cad_timeout` -doubles as the Tx timeout and is 24 bits of 32 MHz periods = **524 ms maximum**, -so transmits whose airtime exceeds that take the classic host path instead — at -SF7/BW62.5 the crossover is roughly a 96-byte payload. Without that guard the -timeout wraps and truncates the packet on air. - -**RX duty cycle.** An NSS falling edge terminates the cycle (DS §6.3.8), so -incidental pollers must not issue SPI into a sleep window, and TX stands the -cycle down deliberately via `lr20xx_dc_takeover()`. `restart_rx()` issues -`SetStandby` before re-arming, because a header or CRC error does **not** -terminate the loop (§6.3.8 ends it on packet *reception*) and re-arming a live -cycle is refused — a refusal that latches CMD_ERROR, holds DIO1 high and used to -drive the safety path into a five-strike hardware reset. - -**Wake budget.** `hwWakeupTimeUs()` is per-device because the TCXO dominates: -DS Table 3-23 gives 1 ms warm start plus 115 µs STDBY_RC→Rx, and duty-cycle sleep -powers the VTCXO regulator down so the oscillator restarts on every wake. A board -declaring `tcxo-startup-delay-ms` that inherits the base class's flat 1500 µs -oversizes its sleep window and drops window-edge preambles regardless of signal -strength. - -**Firmware Patch RAM.** Loaded from both reset paths and verified by the magic -word at `0x800FF8`, so the `PRAM loaded:` line is proof the chip took the patch -rather than that the writes were accepted. Lost on reset, preserved by retention -sleep. - -### 5.6 Default Radio Parameters - -| Parameter | Default | Notes | -|-----------|---------|-------| -| Frequency | 869.618 MHz | EU 869.4-869.65 MHz band (500mW ERP allowed) | -| Bandwidth | 62 kHz | | -| Spreading Factor | 8 | | -| Coding Rate | 4/8 | | -| Preamble | 16 symbols | | -| TX Power | 22 dBm | Clamped by `CONFIG_ZEPHCORE_MAX_TX_POWER_DBM` | - ---- - -## 6. Application Layer - -### 6.1 Class Hierarchy - -``` -mesh::Mesh -├── BaseChatMesh (contacts, channels, messages, connections) -│ └── CompanionMesh (BLE protocol, phone sync, offline queue, ACK tracking) -├── RepeaterMesh (ClientACL, RegionMap, CLI, rate limiting, neighbor tracking) -├── RoomServerMesh (store-and-forward BBS; reuses repeater ACL/region/CLI) -└── ObserverMesh (listen-only; publishes packets to MQTT over WiFi — ESP32) -``` - -### 6.2 CompanionMesh - -Handles the binary BLE protocol with ~50 command opcodes. Key features: -- **Offline queue**: circular buffer with peek/confirm pattern (survives BLE drops); `CONFIG_ZEPHCORE_OFFLINE_QUEUE_SIZE`, default 256 frames (lowered on RAM-bound boards) -- **ACK tracking**: 8-slot table, computes expected ACK = SHA256(secret + hash)[0:4] -- **Contact iteration**: Streaming protocol with `lastmod` filtering for incremental sync -- **Lazy write batching**: Dirty contacts/channels flush after 5-second delay -- **Protocol versioning**: V2/V3 frame format negotiation with phone app -- **Ed25519 signing**: 3-phase flow (start→data→finish) for signing up to 8KB -- **Flood scope**: Transport key filtering for region-scoped sends - -### 6.2.1 V-Contact (Loopback Admin Contact) - -ZephCore-only feature (no Arduino equivalent). The companion synthesizes a CHAT -contact named `v` that exists only toward the connected BLE/USB app. -Chatting with it runs the same text CLI as the USB serial sideband; the reply -comes back as normal chat messages. The firmware also uses it to emit -unsolicited notices: a one-shot low-battery alert and a restart-reason message -(all causes: PIN/SOFTWARE/BROWNOUT/POR/WATCHDOG/LOCKUP — offline-queue only, -so routine power-on "noise" costs nothing over the air). - -**Identity**: pubkey = `SHA256("zc-vcontact" || self_pubkey)` — stable per -node, unique per device, and deliberately **not a real keypair**: no private -key exists anywhere. - -**No-RF invariants** (all enforced in `CompanionMesh`): -1. `vcontactHandleFrame()` intercepts `CMD_SEND_TXT_MSG` (and the handful of - other opcodes that must succeed) *before* any contact lookup — the CLI runs - 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 — 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. -3. Even a hand-crafted over-the-air packet addressed to the derived pubkey is - inert: unknown dest, undecryptable by everyone including this node. - -**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. 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 -(`_vcontact_pending`) instead of queued with an epoch-0 timestamp. -`vcontactClockSynced()` activates it and flushes the buffer; hooked at -`CMD_APP_START` (covers hardware-RTC boards, already valid), successful -`CMD_SET_DEVICE_TIME` (typical app connect flow), and GPS time sync. - -**Resend dedupe**: app retry attempts reuse the message timestamp (only the -attempt byte changes); `_vcontact_last_ts` suppresses re-execution — a dupe -gets the full ack choreography but the CLI does not run twice. Side effect: -sending the identical command twice within the same wall-clock second only -executes once (same app-side timestamp). Synthesized `est_timeout` is 3 s so -the app's retry timer doesn't race the loopback confirmation. - -**Stats**: `CompanionCLICallbacks` overrides -`formatStatsReply`/`formatRadioStatsReply`/`formatPacketStatsReply` with the -repeater's `StatsFormatHelper` JSON, so `stats-core`/`stats-radio`/ -`stats-packets` return real data over USB and the v-contact. - -**Notices ride the offline queue** — emitted while nothing is connected, they -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 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 - once per discharge cycle; re-arms on external power, recovery above - threshold + 150 mV, or threshold change. Sampling mirrors - `ui_auto_shutdown_check()` (30 s gate, 3-strike confirm) but lives in - `main_companion.cpp` so headless builds alert too. - -### 6.3 RepeaterMesh - -Autonomous operation features: -- **Authentication**: Password-based login with timestamp replay protection (120s window) -- **Permission levels**: GUEST(0), READ_ONLY(1), READ_WRITE(2), ADMIN(3) -- **Region filtering**: `RegionMap` with transport key matching per flood packet -- **Rate limiting**: 4 requests per 120s (discovery), 4 per 180s (anonymous), 4 failed logins per 180s -- **Neighbor tracking**: RSSI/SNR/name/timestamp table (`CONFIG_ZEPHCORE_MAX_NEIGHBOURS`, default 50 slots) -- **Temporary radio params**: `tempradio` command applies freq/bw/sf/cr via `LoRaRadioBase::setRadioOverride()` (does not mutate `_prefs`); auto-revert timer calls `clearRadioOverride()` to fall back to saved prefs -- **WiFi+MQTT uplink** (ESP32, `CONFIG_ZEPHCORE_REPEATER_UPLINK`): `RepeaterUplink.cpp` reports packets observer-style while still repeating; configured via `set uplink.*` CLI - -### 6.4 RoomServerMesh - -Headless store-and-forward shared message room (BBS). Clients log in with the admin or guest password and post messages; the server pushes each new post to every other logged-in client (per-client sync cursor + ACK). Reuses the repeater's ACL, region filtering, and USB CLI. Entry point `main_room_server.cpp`; build with `boards/common/room_server.conf`. - -### 6.5 ObserverMesh - -Listen-only node (ESP32 only): receives LoRa packets and publishes them to an MQTT broker over WiFi STA (`adapters/mqtt/`, `adapters/wifi/`). Never transmits. Configured at runtime via serial CLI (credentials in `observer_creds.cpp`); build with `boards/common/observer.conf`. - -### 6.6 CommonCLI Commands - -System: `ver`, `board`, `reboot`, `start dfu`, `start ota`, `erase` -Config: `set name/freq/radio/tx/flood.max/password/...`, corresponding getters -GPS: `gps on/off/setloc/advert`, `set gps duty ` -Sensors: `sensor get/set/list` -Stats: `stats-core/stats-radio/stats-packets`, `clear stats` -Time: `clock`, `clock sync`, `time `, `set meshtimesync on/off` - -Full command reference with constraints and remote-admin restrictions: `Repeater_CLI_commands.md`. - ---- - -## 7. Hardware Adapters - -### 7.1 BLE (`adapters/ble/`) - -- Nordic UART Service (NUS) with AUTHEN permissions on CCC + RX (forces pairing) -- Passkey-based MITM pairing (SC + MITM + Bonding), runtime configurable PIN via `app_passkey` callback -- DisplayOnly IO capability — phone enters passkey displayed on device / known to user -- Advertising always uses `BT_LE_ADV_OPT_USE_IDENTITY` — exposes the stable identity address even when privacy is enabled, preserving Android connect-from-app -- `CONFIG_BT_PRIVACY` **disabled** on nRF52840 / MG24: identity address is advertised directly; both iOS and Android work without RPA. Android's Flutter BLE plugin fails `connectGatt()` to RPA-advertised devices from app context. -- `CONFIG_BT_PRIVACY` **enabled** on ESP32-S3 (`boards/common/esp32_common.conf`): the Espressif controller's privacy-OFF Secure-Connections path produces a MIC failure against iOS (HCI disconnect `0x3d` at encryption start). Privacy ON keeps the controller on its working SC path. `USE_IDENTITY` advertising preserves Android compatibility. Do **not** remove `USE_IDENTITY` while ESP32 privacy is on. -- Pairing triggered reactively: phone hits ATT error 0x05 on secured attribute → initiates SMP pairing (Apple Accessory Design Guidelines §55 compliant — no proactive Security Request) -- **Unpaired-connection timeout (15 s)**: a connection that never reaches security L2 is disconnected. With `CONFIG_BT_MAX_CONN=1` the stack stops advertising while the slot is held, and the companion advertising watchdog skips any state where a connection exists — so a client that connects and never pairs (a scanner app left open, iOS routinely) otherwise makes the node invisible until it is power cycled. Every characteristic on both services is `*_AUTHEN`, so an unsecured connection can do nothing and the drop costs a legitimate client nothing. Armed in `connected()`, cancelled by `security_changed()` at L2+ and by `disconnected()`; the expiry handler reaches the connection via `bt_conn_foreach()` rather than `current_conn`, which belongs to the Bluetooth callback thread. -- TX congestion control: queue (12 frames) + overflow buffer + retry + timeout watchdog -- Fast/slow advertising switching with post-disconnect flap prevention -- DLE (Data Length Extension) to 251 bytes -- Interface coexistence: BLE vs USB, one active at a time -- Debug: build with `debug.conf` plus `-DCONFIG_ZEPHCORE_BLE_LOG_LEVEL_DBG=y` for adapter-level DBG logging - -### 7.2 DataStore (`adapters/datastore/`) - -- **Internal**: LittleFS on flash (`/lfs`), 256-byte cache for reduced flash I/O -- **External**: Optional LittleFS on QSPI (`/ext`) with auto-migration -- **BLE bonds**: NVS (`storage_partition`, 0xD0000 on nRF52) via Zephyr settings backend (≥1.16.2) -- **Prefs**: 152-byte binary (companion `new_prefs`), Arduino-compatible base + ZephCore extension fields, field-by-field I/O (see §13) -- **Contacts**: 152-byte records, stored on external flash if available -- **Channels**: 68-byte records (4 pad + 32 name + 32 secret) -- **Blobs**: Fixed-size records with LRU eviction by timestamp - -**First-boot migration (3-way FS self-heal)** - -A marker file `/lfs/_zc_init` is written after the first clean ZephCore boot. On every subsequent boot it is present and the logic below is skipped. On first boot (marker absent), `main_companion.cpp` picks one of three paths before `bt_enable()` runs: - -1. **No prefs, or Arduino MeshCore prefs** → full LFS + NVS format. Arduino's `new_prefs` omits `node_lat`/`node_lon`, shifting `freq`/`sf`/`bw` by 16 bytes; `prefsLookLikeArduino()` detects this by range-checking those fields. Covers fresh installs and Arduino → ZephCore migrations. -2. **Valid ZephCore prefs + `/lfs/settings` present** → NVS-only erase (`formatNVSOnly()`). ZephCore ≤1.16.1 stored BLE bonds in `/lfs/settings` (file backend); ≤1.16.1 used 0xD0000 as app code, so bytes there may pass NVS sector validation and hang `settings_load()`. Identity/prefs/contacts are preserved; re-pairing is required. -3. **Valid ZephCore prefs + no `/lfs/settings`** → skip format entirely. NVS was already initialised by ZephCore ≥1.16.2; bonds survive the upgrade. - -`loadPrefs()` also range-checks `freq`/`sf`/`bw` after deserialisation and reverts to compile-time defaults on out-of-range values, so a misread Arduino prefs file never corrupts the radio config. - -### 7.3 GPS (`adapters/gps/`) - -- State machine: OFF → ACQUIRING → STANDBY (with warm standby on supported hardware) -- 3 consecutive good fixes (≥4 satellites) required before reporting -- Multi-constellation: GPS+GLONASS+Galileo+BeiDou with fallback -- T1000-E: Complex 6-GPIO power sequencing with VRTC preservation -- GPS time blocks phone time sync for 2 hours after last fix - -**Duty cycle vs always-on** - -`gps_wake_interval_ms` (initialised from `prefs.gps_interval`) controls the mode: - -- **Duty cycling** (`gps_wake_interval_ms > 0`): after acquiring 3 good fixes the GPS powers down; the state machine wakes it again after the configured standby interval. The fix callback fires and then the GPS sleeps. -- **Always-on** (`gps_wake_interval_ms == 0`): the GPS never powers down. `consecutive_good_fixes` is reset after each promotion so the 3-fix gate cycles continuously, streaming fresh positions. Flash writes and fix callbacks are rate-limited to once per `gps_acquire_timeout_ms` to avoid hammering storage. - -`gps_set_poll_interval_sec(0)` switches to always-on live; persisted via `prefs.gps_interval` (set by `set gps duty 0`). - -**Timeout split** - -Two separate timeouts apply to acquisition: - -- `CONFIG_ZEPHCORE_GPS_FIRST_FIX_TIMEOUT_SEC` (default 300s): the cold-start window used for the very first acquisition after `gps_enable()`. Longer to allow almanac download. -- `CONFIG_ZEPHCORE_GPS_FIX_TIMEOUT_SEC` (default 120s): the normal per-wake timeout for all subsequent acquisitions (warm start). - -**Repeater mode** - -Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC` (48 h) for GPS duty — GPS wakes only for a periodic time-sync fix (5-minute acquire window). The interval is now unified with companion via `prefs.gps_interval` and is configurable at runtime via `set gps duty `; persists across reboots. - -### 7.4 USB (`adapters/usb/`) - -- **CompanionUSB**: V3-framed CDC (little-endian 16-bit length prefix + payload) -- **RepeaterUSB**: Minimal CDC with 1200-baud DFU touch detection -- Both share message queues with BLE adapter (transport-agnostic mesh layer) - -### 7.5 Board (`adapters/board/`) - -- Battery ADC with optional regulator-gated voltage divider, 8-sample average (boards with `zephyr,user` ADC node; MG24 has no battery divider, ADC disabled) -- UF2 bootloader entry via GPREGRET magic (0x57 = UF2, 0xA8 = BLE DFU) -- TX LED bracketing for LoRa transmissions (gated by the LED master switch below) -- Bootloader version detection via flash memory scan - -**LED master switch** (`helpers/led_gate.{c,h}`, `set leds on|off`, all roles): one process-wide -flag every LED driver consults — heartbeat and unread-message LEDs in `helpers/ui/ui_common.c`, the -`lora-tx-led` in `ZephyrBoard::onBeforeTransmit()`, and the message/shutdown flashes. It lives -outside the UI layer because `ui_common.c` is only compiled when a UI is enabled, while a headless -repeater still blinks on every transmit. `ui_common.c` overrides the weak `zephcore_leds_ui_sync()` -hook so a CLI change also stops a lit heartbeat and refreshes the UI's LEDs page. Persisted in -`NodePrefs.leds_disabled` (companion offset 93; repeater offset 120, magic-encoded — see §13). -Does not cover the display backlight, which has its own UI brightness setting (`display_brightness`). - -### 7.6 WiFi / MQTT / TCP Transports - -- **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by observer and repeater uplink -- **`adapters/mqtt/ZephyrMQTTPublisher.c`**: MQTT publisher for observed/uplinked packets -- **`adapters/ota/wifi_ota.c`**: WiFi SoftAP + HTTP firmware upload to MCUboot slot1 (ESP32, requires `--sysbuild`) -- **`adapters/transport/LinuxTCPTransport.c`**: TCP companion transport on native Linux (port 5000, MeshCore `SerialWifiInterface` framing) -- **`adapters/transport/SerialCompanionTransport.c`**: UART companion transport (STM32WL — drop-in `zephcore_ble_*` provider, auto-selected when `CONFIG_BT=n`) - ---- - -## 8. UI Subsystem - -### 8.1 Architecture - -Event-driven, no dedicated thread. All UI work on Zephyr work queues. - -Two UI frontends share the same plumbing (`helpers/ui/`: display, buzzer, multi-tap input filter, mesh action queue): - -- **Button UI** (`helpers/ui-button/`): single-button page cycler — most boards -- **Joystick UI** (`helpers/ui-joystick/`): 5-way joystick menu UI (Wio Tracker L1) - -``` -Hardware buttons → Zephyr input subsystem → Longpress filter → Multi-tap filter - → ui_input_cb() → page navigation / action dispatch → schedule_render() - → render_work (50ms OLED / 200ms EPD debounce) → CFB framebuffer → display -``` - -Color TFT panels (T114, T096, Wireless Tracker) are wrapped as 1bpp displays for CFB via the `zephcore,mono-tft` shim (`display_mono_tft.c`). - -### 8.2 Pages (Button UI) - -**Companion** (up to 12 pages): Messages, Recent, Radio, Bluetooth, Advert, GPS, Buzzer (if buzzer present), LEDs, Sensors, Offgrid, DFU, Shutdown - -**Repeater** (3 pages): Status, Radio, Shutdown - -### 8.2.1 Renderer Split (mono / color) - -Pages whose color layout genuinely diverges from the mono layout are split into -dedicated renderers behind a compile-time seam, instead of branching on -capability inline (and never into per-board renderer files): - -``` -render__mono() — mono / tiny / e-ink layout (always compiled) -render__color() — RGB565 layout, wrapped in - #if MC_DISPLAY_COLOR_PANEL -render_() — thin dispatcher: - #if MC_DISPLAY_COLOR_PANEL - if (mc_display_has_color()) { _color(); return; } - #endif - _mono(); -``` - -`MC_DISPLAY_COLOR_PANEL` is defined (in `display.h`) only when a `tft` node -exists in devicetree. On a mono/e-ink board the color bodies — and every -color-only helper they reference (`draw_activity_graph`, `use_compact_color_home`, -the `activity_*` buffers, …) — are dropped at compile time, so color rendering -costs zero flash/RAM there. Adding a new color board reuses `_color`; it must -never fork a board-specific renderer. - -Pages with a **shared** flow that only tints per-row (Recent, GPS, Sensors, -Status) stay as single functions with inline `if (mc_display_has_color())` — -that already is the "one layout, colored" ideal, and the color branch -dead-code-eliminates on mono via the constant-false `mc_display_has_color()`. -Split pages: Messages, Radio, Traffic, Bluetooth, Advert, LEDs, Offgrid, DFU, -Shutdown. - -### 8.3 Multi-Tap Input - -Single button; tap-count → key-code mapping comes from the board's devicetree `tap-codes` (up to 5). Typical mapping: -- 1 tap → Page next -- 2 taps → LED heartbeat toggle -- 3 taps → Notification mode (sound+vibrate → vibrate → silent → sound → …; boards with no motor fall back to a plain on/off toggle) -- 4 taps → GPS toggle -- 5 taps → Flood advert (immediate, no delay) - -### 8.4 Buzzer and vibration - -Non-blocking RTTTL parser on dedicated work queue. Predefined melodies for startup, shutdown, messages, ACKs. 2-second safety watchdog auto-silences on work queue stall. - -Boards with a DRV2605 haptic driver (`ti,drv2605` in DT) also vibrate on every notification — `buzzer_play()` pulses the motor. The two outputs share one setting, the notification mode, which lives in `helpers/buzzer_gate.c` (always linked, same pattern as `led_gate.c`, so the CLI resolves its symbols on boards that compile no buzzer). `set buzzer 0|1|2|3` and the 3-tap button action both drive it: - -| Mode | Name | Buzzer | Motor | -|------|------|--------|-------| -| 0 | silent | - | - | -| 1 | sound+vib | yes | yes | -| 2 | vibrate | - | yes | -| 3 | sound | yes | - | - -Modes 2 and 3 are rejected on boards with no motor, where they would be indistinguishable from 0 and 1. The setting persists in the existing `buzzer_quiet` prefs byte — 0 and 1 keep their original meaning, 2 and 3 are new and read as "quiet" by older firmware, so a downgrade silences a node left on sound-only. - -### 8.5 Doom Easter Egg - -Wolf3D-style raycaster on OLED: textured walls, 2 enemy types, shooting, HUD. Bypasses CFB, writes directly to display. ~1.7KB RAM, ~5KB flash. Enabled via `CONFIG_ZEPHCORE_EASTER_EGG_DOOM`. Button UI: triple-press ENTER on Messages page. Joystick UI: Tools menu → "Doom". - ---- - -## 9. Build System - -### 9.1 Config Layering - -``` -prj.conf (base: console; production defaults — LOG=n, ASSERT=n) - → boards/common/zephcore_common.conf (ALL boards: BLE, crypto, FS, LoRa, sensors) - → boards/common/_common.conf (nrf52/esp32/nrf54l/mg24 specifics) - → boards///board.conf (board-specific pins, features) - → [optional] repeater.conf, debug.conf (user extras, LAST = highest priority) -``` - -### 9.2 Key Kconfig Choices - -- **Role**: `ZEPHCORE_ROLE_COMPANION` (default) vs `ZEPHCORE_ROLE_REPEATER` vs `ZEPHCORE_ROLE_ROOM_SERVER` vs `ZEPHCORE_ROLE_OBSERVER` (selected via `repeater.conf` / `room_server.conf` / `observer.conf`) -- **Radio**: `ZEPHCORE_RADIO_NATIVE` (SX126x, default) vs `ZEPHCORE_RADIO_LR1110` vs `ZEPHCORE_RADIO_LR2021` vs `ZEPHCORE_RADIO_SX127X` -- **Features**: Display, buzzer, buttons, multi-tap, Doom (auto-enabled from DT); PSRAM auto-enable from DT (`Kconfig.psram`) - -### 9.3 Platform Notes - -- **nRF52840**: Zephyr open-source BLE controller, UF2 bootloader, partial flash erase for BLE coexistence -- **nRF54L15**: Same BLE controller as nRF52, CMSIS-DAP via SAMD11 bridge, no native USB -- **ESP32-C3/C6/S3**: Espressif proprietary BLE blob, 32KB heap, asserts disabled (blob IRQ false positives); simple-boot by default, MCUboot only with `--sysbuild` (WiFi OTA) -- **ESP32 classic (PICO-D4)**: much smaller DRAM — contact/queue caps shrunk in `board.conf`; console/CLI on `uart0` (no native USB); DIO flash mode required (QIO bootloops) -- **EFR32MG24**: SiLabs proprietary BLE blob, 32KB heap, SEMAILBOX enabled for hardware TRNG/crypto entropy, ADC disabled (no battery divider), CMSIS-DAP via onboard SAMD11 -- **STM32WL (LoRa-E5)**: no BLE, no USB device — companion protocol and CLI run over USART1; 64KB SRAM caps contacts/queues hard; TRNG entropy; single app partition, flash via SWD -- **Native Linux (`native_sim`)**: real SPI/GPIO via spidev + GPIO chardev; TCP companion transport; file-backed flash — see `LINUX_NATIVE.md` - -### 9.4 Patches - -Applied automatically at CMake configure time; a failed patch aborts the configure with the offending patch named. - -| Patch | Risk | Purpose | -|-------|------|---------| -| 0001-lora-lr11xx-lr20xx-build | LOW | Registers the LR11xx and LR20xx drivers in the Zephyr LoRa build | -| 0003-lora-sx126x-native | **HIGH** | DIO1 work queue, duty cycle, CAD, RX-busy gating, band RSSI/AGC calibration, PA/OCP tuning, extension API | -| 0004-lora-sx127x-62k5-bandwidth | LOW | Adds 62.5 kHz bandwidth to the loramac-node backend | -| 0005-gnss-config-and-version-query | MEDIUM | Air530Z nav-rate config + `$PCAS06` version query; NMEA generic dump | -| 0006-blobs-py | LOW | Fix `west blobs fetch` KeyError | -| 0007-spi-gpio-native-linux | LOW | Wires native-Linux SPI/GPIO drivers into the Zephyr build | -| 0008-flash-sim-per-node-file | LOW | Flash simulator defaults to per-node settings file (native Linux) | -| 0009-display-ssd16xx-fill-ram-white | LOW | E-paper full-refresh-to-white anti-ghosting helper | -| 0010-uarte-pm-suspend-bounded-rxto-wait | MEDIUM | Bounds the nRF UARTE STOPRX/RXTO spin on PM suspend; unbounded upstream, wedges the mesh thread | - -**One patch per file.** No upstream file is touched by more than one patch, so -apply order is irrelevant and no patch can be anchored inside another's added -lines. Consolidated 2026-08-20 (15 → 9): `0002` folded into `0001`, and -`0011`–`0015` folded into `0003`, each keeping its rationale as a `== section ==` -in that patch's preamble. `0002` is a deliberate numbering gap. Add a new -sx126x fix by regenerating `0003`, never by stacking an `0016` on it — see -`WEST_UPDATE.md`. - -New drivers in `patches/zephyr-new/` (LR11xx, LR20xx, native-Linux SPI/GPIO, DTS bindings) are copied — not patched — into the Zephyr tree at configure time. - -### 9.5 Flash Partition Layouts - -**nRF52840 SD v6**: SoftDevice 152KB → App 680KB → NVS 16KB → LFS 128KB → UF2 48KB -**nRF52840 SD v7**: SoftDevice 156KB → App 676KB → NVS 16KB → LFS 128KB → UF2 48KB -**ESP32 (4MB)**: Boot + App → LFS 192KB + NVS 16KB -**ESP32-S3 (8/16MB)**: Boot + App → LFS 384KB + NVS 16KB -**nRF54L15**: MCUboot 64KB → App 1272KB → LFS 92KB -**EFR32MG24**: MCUboot 48KB (reserved) → App 1344KB → LFS 144KB -**STM32WL**: App at flash origin → LFS (no bootloader) - ---- - -## 10. Board Matrix - -Build strings and flash methods: `boards/supported_boards.md` and `boards/example_board/README.md`. - -| Board | SoC | Radio | GPS | Display | Notable extras | -|-------|-----|-------|-----|---------|----------------| -| RAK4631 / WisMesh Pocket | nRF52840 | SX1262 | u-blox MAX-7Q (opt) | WisBlock OLED (opt) | I2C sensors | -| RAK3401 1W | nRF52840 | SX1262+SKY66122 (30dBm) | u-blox MAX-7Q (opt) | - | I2C sensors | -| RAK WisMesh Tag | nRF52840 | SX1262 | AT6558R | - | Accelerometer, buzzer, multitap | -| T1000-E | nRF52840 | **LR1110** | AG3335 | - | Buzzer, button, multitap | -| SenseCAP MeshTracker X1 | nRF52840 | **LR2021** | AG3335M (L1+L5) | - | SPA06 barometer, DRV2605L vibration, YSN8900 RTC, QSPI 8MB, RGB LEDs, buzzer | -| ThinkNode M1 | nRF52840 | SX1262 | Air530Z | EPD 200x200 (SSD1681) | Buzzer, 2 buttons, QSPI 2MB, RGB LEDs | -| ThinkNode M3 | nRF52840 | **LR1110** | Yes | - | Buzzer, 2 buttons, RGB LEDs | -| ThinkNode M6 | nRF52840 | SX1262 | L76K | - | QSPI, RGB LEDs | -| Wio Tracker L1 | nRF52840 | SX1262 | L76K | OLED 128x64 (SH1106) | 5-way joystick UI, buzzer, QSPI 2MB | -| LilyGo T-Echo | nRF52840 | SX1262 (TCXO 1.8V) | L76K | EPD 1.54" (SSD1681) | BME280, QSPI, touch-button backlight | -| Heltec T114 | nRF52840 | SX1262 | - | TFT 240x135 (ST7789V) | Screenless build via `no_display.conf` | -| Heltec Mesh Node T096 | nRF52840 | SX1262+KCT8103L PA | UC6580 | TFT 160x80 (ST7735S) | Button, LED, battery ADC | -| Ikoka Nano 30dBm | nRF52840 | SX1262+PA (30dBm) | - | - | RGB LEDs | -| GAT562 30S Mesh Kit | nRF52840 | SX1262+PA (1W) | Yes | OLED (SSD1306) | 5-way joystick, buzzer, solar | -| SenseCAP Solar | nRF52840 | SX1262 | L76K | - | QSPI, battery monitor | -| XIAO nRF52840 + Wio-SX1262 | nRF52840 | SX1262 | - | - | - | -| ProMicro SX1262 | nRF52840 | SX1262 (E22-900M30S) | Yes | - | Button, LED, battery ADC | -| muzi works R1 Neo | nRF52840 | SX1262 | Yes | - | Buzzer, button, RX8130CE RTC, latched-rail power-off | -| XIAO nRF54L15 | nRF54L15 | SX1262 | - | - | Contacts capped at 450 | -| XIAO ESP32-C3 | ESP32-C3 | SX1262 | - | - | Contacts capped at 300 | -| XIAO ESP32-C6 | ESP32-C6 | SX1262 | - | - | - | -| LilyGo TLoRa C6 | ESP32-C6 | SX1262 | - | - | - | -| XIAO ESP32-S3 | ESP32-S3 | SX1262 | - | - | 8MB flash, 8MB PSRAM | -| Station G2 | ESP32-S3 | SX1262+PA | UART GNSS | OLED (SH1106) | 16MB flash, 8MB PSRAM | -| Heltec V3 | ESP32-S3 | SX1262 | - | OLED (SSD1306) | Console on `uart0` | -| Heltec V4.2 / V4.3 | ESP32-S3 | SX1262+PA (GC1109 / KCT8103L) | - | OLED (SSD1306) | 16MB flash, 2MB PSRAM | -| Heltec Wireless Tracker | ESP32-S3 | SX1262 | UC6580 | TFT 160x80 (ST7735R) | - | -| LilyGo T-Beam v1.2 | ESP32 (PICO-D4) | SX1262 | Yes | - | AXP2101 PMU; contacts capped at 160 | -| TTGO LoRa32 | ESP32 (PICO-D4) | **SX1276** (loramac-node) | - | - | SX127x reference board | -| XIAO MG24 | EFR32MG24 | SX1262 | - | - | - | -| Seeed LoRa-E5 mini | STM32WL | STM32WL sub-GHz (SX1262-class) | - | - | UART companion/CLI; contacts capped at 24 | - -Contact capacity is `CONFIG_ZEPHCORE_MAX_CONTACTS` (default 350) unless capped per-board as noted. Native-Linux presets (Femtofox, RAK6421) are `EXTRA_CONF_FILE` presets, not boards — see `LINUX_NATIVE.md`. - ---- - -## 11. Packet Format Reference - -### Wire Format - -``` -Byte 0: Header - [1:0] Route type: 0=transport_flood, 1=flood, 2=direct, 3=transport_direct - [5:2] Payload type (see table in §4.3) - [7:6] Version (0=v1) - -If transport route (bit 0 or both bits set): - Bytes 1-4: transport_codes[2] (2x uint16_t LE) - -Next byte: path_len - [5:0] Hash count (number of hops) - [7:6] Hash size mode (0→1B, 1→2B, 2→3B) - -Next N bytes: path[] (hash_count × hash_size bytes) - -Remaining bytes: payload (type-specific) -``` - -### Advert Payload - -``` -[32B pubkey] [4B timestamp LE] [64B Ed25519 signature] [0-32B app_data] - -app_data format (AdvertDataHelpers): - Byte 0: type(3:0) | flags(7:4) - flags: bit4=lat/lon, bit5=feat1, bit6=feat2, bit7=name - [optional 8B: lat(float) + lon(float)] - [optional 2B: features1] - [optional 2B: features2] - [remaining: name string] -``` - -### Encrypted Datagram (REQ/RESPONSE/TXT_MSG) - -``` -[1B dest_hash] [1B src_hash] [encrypted_payload + 2B MAC] - -encrypted_payload (after AES-128-ECB decrypt): - For TXT_MSG: [4B timestamp] [1B txt_type] [text...] - txt_type: 0=plain, 1=cli_data, 2=signed_plain -``` - ---- - -## 12. BLE Protocol Reference - -### Frame Format - -Raw binary over BLE NUS. Each frame: `[1B opcode] [payload...]` -Over USB CDC (and native-Linux TCP): framed with a length prefix — `[2B LE length] [1B opcode] [payload...]` (TCP additionally prefixes a `<`/`>` direction byte). - -### Key Command Opcodes (phone → device) - -The full set (~50 opcodes, `0x01`–`0x41`) is defined at the top of `app/CompanionMesh.cpp`; values match the Arduino MeshCore companion protocol. A sample: - -| Opcode | Name | Payload | -|--------|------|---------| -| 0x01 | CMD_APP_START | app version + name (session start) | -| 0x02 | CMD_SEND_TXT_MSG | txt_type + attempt + timestamp + pubkey_prefix + text | -| 0x04 | CMD_GET_CONTACTS | [optional 4B `since` lastmod filter] | -| 0x05 / 0x06 | CMD_GET/SET_DEVICE_TIME | (none) / 4B epoch (forward-only) | -| 0x07 | CMD_SEND_SELF_ADVERT | [optional type byte: flood/zero-hop] | -| 0x08 | CMD_SET_ADVERT_NAME | name string | -| 0x0A | CMD_SYNC_NEXT_MESSAGE | (none) — offline queue peek/confirm | -| 0x0B | CMD_SET_RADIO_PARAMS | freq + bw + sf + cr | -| 0x16 | CMD_DEVICE_QUERY | app target version | -| 0x21–0x23 | CMD_SIGN_START / DATA / FINISH | 3-phase Ed25519 signing (up to 8KB) | - -### Push Notifications (device → phone, async) - -Codes `0x80`–`0x90` (`PUSH_CODE_*` in `app/CompanionMesh.h`). Most used: - -| Code | Name | -|------|------| -| 0x80 | PUSH_CODE_ADVERT | -| 0x81 | PUSH_CODE_PATH_UPDATED | -| 0x82 | PUSH_CODE_SEND_CONFIRMED | -| 0x83 | PUSH_CODE_MSG_WAITING | -| 0x8A | PUSH_CODE_NEW_ADVERT | - ---- - -## 13. Data Storage - -### File Paths - -| Path | Content | Format | -|------|---------|--------| -| `/lfs/_main.id` | Node identity | 64B private key + 32B public key | -| `/lfs/new_prefs` | Companion preferences | 152B binary, field-by-field (Arduino-compatible superset) | -| `/lfs/contacts3` or `/ext/contacts3` | Contacts | 152B × N records | -| `/lfs/channels2` or `/ext/channels2` | Channels | 68B × N records | -| `/lfs/adv_blobs` or `/ext/adv_blobs` | Advert cache | Fixed-size blob records | -| `/lfs/repeater/*` | Repeater/room-server identity + prefs | 297B prefs; atomic-replace writes | -| `/lfs/repeater/acl` | Client ACL | 136B × N records | -| `/lfs/repeater/regions2` | Region map | Header + 164B × N entries | -| `storage_partition` (NVS, 0xD0000 nRF52) | BLE bonds + Zephyr settings | NVS settings backend (≥1.16.2; old `/lfs/settings` file detected by self-heal) | - -> **Roles are not interchangeable.** Each role formats the whole volume on its first boot if the -> volume holds no data for that role: the companion checks `/lfs/new_prefs` -> (`ZephyrDataStore::hasPrefs()`), the repeater/room-server/observer check `/lfs/repeater/prefs` -> and `/lfs/repeater/_main.id` (`RepeaterDataStore::hasRoleData()`). So flashing a repeater over -> a companion — or the reverse — erases the previous role's identity, prefs and contacts, plus -> `storage_partition` and QSPI. Export your identity before switching roles. The roles' files -> never overlap physically (one LittleFS volume, one allocator); the reason for the wipe is that -> they share 128 KB and the other role's data crowds out writes. Repeater, room server and -> observer share `/lfs/repeater/` and the same prefs layout, so switching among *those three* -> preserves the identity. - -### Preferences Binary Layouts - -Two distinct field-by-field serializations (NOT raw struct dumps), both Arduino-compatible -in their shared base fields: - -**Companion `/lfs/new_prefs` (168 bytes)** — `adapters/datastore/ZephyrDataStore.cpp` -`loadPrefs()`/`savePrefs()` (offset comments inline). Arduino companion layout (name, lat/lon, -radio params, telemetry modes, BLE pin, GPS, autoadd) plus ZephCore extensions from offset 92: -rx_boost(92), leds_disabled(93), reserved(94-95, was APC), default flood scope name/key(96-142), -ble_disabled(143), display/wake/screen-off/auto-shutdown(144-149), rx_duty_cycle(150), -meshtimesync(151). - -**Repeater/room-server `/lfs/repeater/prefs` (305 bytes)** — `app/RepeaterDataStore.cpp` -`loadPrefs()`/`savePrefs()` (offset comments inline). This is the only serializer for the -repeater layout; `helpers/CommonCLI.cpp` carried a second, unreachable copy of it until it was -removed — do not add prefs fields anywhere but the two files named in this section. -Key ranges: name(4-36), radio(72-119), adaptive-delay(80-111, ignored at runtime), -leds_disabled(120, magic-encoded `0xA0`/`0xA1` — the byte formerly held `agc_reset_interval`, which -stored seconds/4, so any other value is a legacy interval and decodes to "LEDs on"), -Arduino-bridge(127-151, read+discarded), GPS(156-161), owner_info(170-290), rx_boost/duty(290-291), -reserved(292-293, was APC), flood_max_unscoped/advert(294-295), meshtimesync(296). Older shorter files -load cleanly — reads past EOF are no-ops, so newer fields keep their defaults and a one-time -upgrade block migrates them. - ---- - -## 14. Key Call Flows - -### 14.1 Receiving a LoRa Packet → Application - -``` -DIO1 interrupt → Zephyr lora driver → async RX callback - → LoRaRadioBase::rxCallbackStatic() → SPSC ring buffer write → _rx_cb() - → k_event_post(MESH_EVENT_LORA_RX) → main thread wakes - → Dispatcher::loop() → checkRecv() → drain ring buffer - → tryParsePacket() → score + airtime calc - → flood: dedup + adaptive contention delay → queue for retransmit - → direct: process immediately - → Mesh::onRecvPacket() → decrypt → dispatch by type - → BaseChatMesh::onPeerDataRecv() → onMessageRecv() - → CompanionMesh: writeFrame() to phone or queueOfflineMessage() -``` - -### 14.2 Sending a Text Message - -``` -Phone sends CMD_SEND_TXT_MSG via BLE NUS - → CompanionMesh::handleProtocolFrame() - → BaseChatMesh::sendMessage(contact, text) - → composeMsgPacket(): ECDH secret → AES encrypt → MAC - → if contact has path: trySendDirect() - → else: sendFlood() - → Mesh::sendFlood() → mark seen → queue outbound - → Dispatcher::checkSend() → CAD check → duty cycle check → LBT → startSendRaw() -``` - -### 14.3 Repeater Forwarding a Packet - -``` -Dispatcher::checkRecv() → Mesh::onRecvPacket() - → flood packet, not for us - → routeRecvPacket() → allowPacketForward() - → RepeaterMesh checks: disable_fwd? flood_max? region filter? - → if allowed: append self hash to path, ACTION_RETRANSMIT_DELAYED - → re-queued outbound with priority = hop count -``` - -### 14.4 Noise Floor Calibration Cycle - -``` -main event loop (every 5s) → Dispatcher::maintenanceLoop() - → radio->triggerNoiseFloorCalibrate(threshold) - → guards: in RX? TX active? duty cycle? mid-receive? - → read 8 RSSI samples, take median - → first sample: seed directly - → warmup (<8 ticks): accept unconditionally - → periodic bypass (every 16th): accept unconditionally - → otherwise: reject if sample ≥ floor + 14dB - → EMA: floor += round((sample - floor) / 8) - → clamp [-120, -50] dBm -``` - ---- - -## 15. Watchdogs and Recovery Mechanisms - -**There is no hardware watchdog.** No `CONFIG_WATCHDOG`, no `task_wdt`, no `wdt` -node enabled on any board — the `wdt` nodes visible in board `.dts` files are -inherited SoC definitions, and the `RTCWDT` references in the TTGO board configs -concern the ESP32 ROM bootloader's own watchdog, not something ZephCore arms. -The only consumer of the concept is the boot breadcrumb in `main_companion.cpp`, -which reads `RESET_WATCHDOG` out of `hwinfo_get_reset_cause()` and reports it in -the "Restarted:" v-contact message (see [6.2.1](#621-v-contact-loopback-admin-contact)). - -Everything below is software: bounded stall detection in the layer that owns the -state machine. Each entry names what it recovers, because several are -deliberately diagnostic-only and recover nothing. - -### 15.1 Named watchdogs - -| Watchdog | Location | Period | Trigger → action | -|----------|----------|--------|------------------| -| SX126x parked-RX | `patches/zephyr/0003-lora-sx126x-native.patch` (`sx126x_dc_watchdog_handler`) | `2×(preamble+8)` symbols, floor 250 ms | Duty-cycle only. Two consecutive samples showing the chip parked in full RX after a false preamble detect → re-arm the DC cycle. Two-strike so a sighting can never fall inside one real packet's preamble→header gap and abort a live reception. Counted by `get dc.restarts`. | -| LR11xx wedge-recovery | `lr11xx_lora.c` (`lr11xx_wedge_watchdog_handler`, own `lr11xx_wedge` queue) | 3 s | DIO1 silent >12 s **and** BUSY continuously high for a 250 ms confirm poll → hardware reset + RX restart. See [5.4](#54-lr1110-driver-errata-workarounds). | -| Radio stall | `Dispatcher::maintenanceLoop()` | `RADIO_STALL_THRESHOLD_MS` (8 s) | Radio neither in RX nor mid-TX for the whole window → latch `ERR_EVENT_STARTRX_TIMEOUT`. **Diagnostic only.** The bit is surfaced everywhere: repeater/room-server `stats`, binary telemetry, MQTT uplink, and the companion's BLE device-status response. | -| Contact-dump stall | `main_companion.cpp` housekeeping | housekeeping tick | Dump active and the iterator cursor unmoved across a whole tick → re-post `MESH_EVENT_CONTACT_ITER`. The dump is pumped solely by the BLE/USB tx-idle callback, so one lost kick would strand it silently. | -| BLE advertising | `main_companion.cpp` housekeeping | housekeeping tick | Enabled, not connected, not advertising → `zephcore_ble_set_enabled(true)`. Covers transient `bt_le_adv_start` failure, which would otherwise leave the node undiscoverable until reboot. | -| BLE TX timeout | `ZephyrBLE.cpp` (`BLE_TX_TIMEOUT_MS`) | 2 s | `ble_tx_in_progress` set with no completion callback → clear the flag and proceed to the next TX. Sits 3 s inside the 5 s supervision timeout. | -| USB partial-input | `ZephyrCompanionUSB.cpp` (`USB_FRAME_TIMEOUT_MS`) | byte-driven | Mid-frame or mid-text-line too long → reset parser to `USB_RX_IDLE`. **Not a timer** — it only runs when bytes arrive, so it never wakes a sleeping node. | -| Buzzer safety | `helpers/ui/buzzer.c` (`BUZZER_TONE_MAX_MS`) | 2 s | Note handler stalls → silence PWM + amp off. The PWM block is autonomous and would otherwise drive the pin forever after a crash or work-queue stall. | - -### 15.2 Unnamed, same job - -Timeouts and deadlines that are watchdogs in everything but name: - -| Mechanism | Location | Bounds | -|-----------|----------|--------| -| RX-latch payload deadline | all three custom radio paths: SX126x `patch 0003` ("Bound the lifetime of the RX-busy latch"), `lr11xx_lora.c`, `lr20xx_lora.c` (`header_seen_at_ms` + `*_max_payload_ms()`) | A `HEADER_VALID` whose packet never completes would pin the TX gate closed and silently mute the node — continuous RX has no symbol timer. Released at 255-byte airtime +25% +100 ms. See [5.2.1](#521-rx-busy-gate-tx-during-rx-prevention). | -| Stuck-DIO1 counter | `lr11xx_lora.c` and `lr20xx_lora.c` | 5 empty DIO1 cycles → hardware reset. Counting rather than timing; on the LR11xx it complements the wedge watchdog rather than replacing it. | -| CAD timeout | `Dispatcher::checkSend()` | 4 s (~20 retry attempts) → `ERR_EVENT_CAD_TIMEOUT` + `recoverRxState()`, rather than falling through to TX. See [5.2.2](#522-cad-timeout-recovery). | -| Chip-side TX timeout | SX126x `SetTx` deadline (`patch 0003`, "Scale the chip-side Tx timeout from airtime instead of a fixed 10 s"; airtime +25% +500 ms, floored at 10 s, clamped 262143 ms); LR2021 `TIMEOUT` IRQ handler | The chip stops the transmission when this fires, so a fixed value is a truncation, not a safeguard — at SF12/BW62.5 the old flat 10 s cut every packet from 76 bytes up. | -| Serial partial-frame resync | `SerialCompanionTransport.c` (`FRAME_PARTIAL_TIMEOUT_MS`) | 2 s. Parser-level only — deliberately **not** a session or idle timeout; an idle-but-connected companion sits in `RX_IDLE` indefinitely. | -| TCP send timeout | `LinuxTCPTransport.c` | Native sim only. A peer that can't accept a frame in the window is wedged → close it, rather than hang the whole queue. | -| Bounded RXTO wait | `patches/zephyr/0010-uarte-pm-suspend-bounded-rxto-wait.patch` | `uarte_pm_suspend()` busy-waits for RXTO with no timeout upstream. Landing in the STOPRX race with bytes in flight spins forever on the main thread and wedges the entire mesh (observed: RAK3401 1W repeater on 1.16.6, CLI answering only `-> busy`). Backstop for the GPS UART PM path in [7.3](#73-gps-adaptersgps). | +# ZephCore Architecture Guide + +> Comprehensive developer reference for the ZephCore codebase — a Zephyr RTOS port of the Arduino MeshCore LoRa mesh networking firmware. + +--- + +## Table of Contents + +1. [Project Overview](#1-project-overview) +2. [Directory Structure](#2-directory-structure) +3. [Layer Architecture](#3-layer-architecture) +4. [Core Mesh Engine](#4-core-mesh-engine) +5. [Radio Subsystem](#5-radio-subsystem) +6. [Application Layer](#6-application-layer) +7. [Hardware Adapters](#7-hardware-adapters) +8. [UI Subsystem](#8-ui-subsystem) +9. [Build System](#9-build-system) +10. [Board Matrix](#10-board-matrix) +11. [Packet Format Reference](#11-packet-format-reference) +12. [BLE Protocol Reference](#12-ble-protocol-reference) +13. [Data Storage](#13-data-storage) +14. [Key Call Flows](#14-key-call-flows) +15. [Watchdogs and Recovery Mechanisms](#15-watchdogs-and-recovery-mechanisms) + +--- + +## 1. Project Overview + +ZephCore is a LoRa mesh networking firmware running on Zephyr RTOS. It supports four device roles: + +- **Companion**: BLE-connected device paired with a phone app. Full contact/channel/message management. +- **Repeater**: Autonomous headless relay node. CLI administration via authenticated mesh connections or serial UART. +- **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 (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 + +ZephCore is a port of [Arduino MeshCore](https://github.com/meshcore-dev/MeshCore). The core mesh protocol (Mesh.cpp, Dispatcher.cpp, Packet.cpp, Identity.cpp, Utils.cpp) is shared code. Adapters (`adapters/`) bridge MeshCore's HAL interfaces to Zephyr APIs. Binary file formats (prefs, contacts, channels) are byte-compatible with Arduino MeshCore. + +--- + +## 2. Directory Structure + +``` +zephcore/ +├── src/ # Core mesh engine (shared with Arduino MeshCore) +│ ├── Mesh.cpp # Routing protocol: flood, direct, dedup, adverts +│ ├── Dispatcher.cpp # Packet queue, radio scheduling, CAD, duty cycle +│ ├── Packet.cpp # Packet serialization, hash, wire format +│ ├── Identity.cpp # Ed25519 key management, ECDH shared secrets +│ ├── Utils.cpp # AES-ECB encrypt, HMAC-SHA256, MAC +│ ├── ContentionTracker.cpp # Adaptive contention window (EMA, backoff) +│ ├── StaticPoolPacketManager.cpp # Fixed-size packet pool (32 slots) +│ ├── main_companion.cpp # Companion mode entry point + event loop +│ ├── main_repeater.cpp # Repeater mode entry point + event loop +│ └── main_room_server.cpp # Room server mode entry point + event loop +│ +├── include/mesh/ # Core interfaces (shared with Arduino MeshCore) +│ ├── Mesh.h, Dispatcher.h, Packet.h, Identity.h, Utils.h +│ ├── MeshCore.h # Constants: key sizes, packet limits +│ ├── Radio.h # Abstract radio interface +│ ├── Board.h, Clock.h, RNG.h, RTC.h # HAL interfaces +│ ├── ContentionTracker.h # Adaptive contention window state +│ ├── LoRaConfig.h # Default radio parameters +│ ├── RadioIncludes.h # Compile-time radio driver selection +│ ├── SimpleMeshTables.h # Hash-based packet deduplication +│ └── StaticPoolPacketManager.h # Fixed pool allocator +│ +├── adapters/ # Zephyr HAL implementations +│ ├── radio/ # LoRa radio drivers +│ │ ├── LoRaRadioBase.cpp/h # Shared TX/RX state machine, noise floor, AGC +│ │ ├── 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) +│ │ ├── radio_common.h # Shared radio types and constants +│ │ ├── lr11xx/ # LR11xx low-level HAL (SPI, GPIO, Semtech SDK) +│ │ └── lr20xx/ # LR20xx low-level HAL (Semtech SDK) +│ ├── ble/ZephyrBLE.cpp/h # BLE NUS service, pairing, TX congestion +│ ├── board/ZephyrBoard.cpp/h # Battery ADC, LEDs, reboot, bootloader +│ ├── clock/ # Millisecond uptime + software RTC + I2C RTC discovery +│ ├── datastore/ZephyrDataStore.cpp/h # LittleFS persistence +│ ├── gps/ZephyrGPSManager.cpp/h # GNSS state machine, power mgmt +│ ├── mqtt/ZephyrMQTTPublisher.c/h # MQTT packet publisher (observer / uplink) +│ ├── ota/wifi_ota.c/h # WiFi SoftAP + HTTP firmware upload +│ ├── rng/ZephyrRNG.cpp/h # Hardware CSPRNG with PRNG fallback +│ ├── sensors/ # I2C env sensors + power monitors +│ ├── transport/ # TCP companion (native Linux) + serial companion (STM32WL) +│ ├── usb/ # USB CDC for companion + repeater +│ └── wifi/ZephyrWiFiStation.c/h # WiFi station client (ESP32) +│ +├── app/ # Application layer +│ ├── CompanionMesh.cpp/h # Phone-connected companion logic +│ ├── RepeaterMesh.cpp/h # Autonomous repeater logic +│ ├── RepeaterRegionCLI.cpp # Repeater `region` CLI commands +│ ├── RepeaterUplink.cpp # Repeater WiFi+MQTT uplink (ESP32) +│ ├── RepeaterDataStore.cpp/h # Repeater-specific persistence paths +│ ├── RoomServerMesh.cpp/h # Store-and-forward room server (BBS) +│ ├── RoomServerRegionCLI.cpp # Room server `region` CLI commands +│ ├── ObserverMesh.cpp/h # Listen-only WiFi+MQTT observer (ESP32) +│ └── main_observer.cpp, observer_creds.cpp/h +│ +├── helpers/ # Shared utilities +│ ├── BaseChatMesh.cpp/h # Contact/channel/message base class +│ ├── CommonCLI.cpp/h # Serial/mesh CLI command processor +│ ├── MeshTimeSync.cpp/h # Mesh clock-consensus estimator (§4.9) +│ ├── AdvertDataHelpers.cpp/h # Advertisement wire format encoder/decoder +│ ├── ClientACL.cpp/h # Authenticated client management +│ ├── TransportKeyStore.cpp/h # Region transport key cache +│ ├── RegionMap.cpp/h # Region-based flood filtering +│ ├── ContactInfo.h, ChannelDetails.h, NodePrefs.h # Data structures +│ ├── RateLimiter.h, IdentityStore.h, StatsFormatHelper.h +│ ├── battery_curve.c/h, fatal_reboot.c, oled_power.c/h +│ ├── ui/ # Shared UI plumbing: display, buzzer, multi-tap input, Doom +│ ├── ui-button/ # Single-button page UI (pages, task) +│ └── ui-joystick/ # 5-way joystick UI (Wio Tracker L1) +│ +├── boards/ # Board definitions +│ ├── common/ # Shared configs, DTS includes, partition layouts +│ ├── nrf52840/ # RAK4631, T1000-E, ThinkNode M1/M3/M6, T-Echo, T114, ... +│ ├── nrf54l/ # XIAO nRF54L15 +│ ├── esp32/ # XIAO C3/C6/S3, Heltec V3/V4.x, Station G2, T-Beam, ... +│ ├── mg24/ # XIAO MG24 +│ ├── stm32wl/ # Seeed LoRa-E5 mini +│ └── linux_native/ # native_sim presets (Femtofox, RAK6421) — see LINUX_NATIVE.md +│ +├── patches/ # Zephyr tree modifications +│ ├── zephyr/ # Unified diffs (SX126x extensions, GNSS, native Linux, ...) +│ └── zephyr-new/ # New files (LR11xx/LR20xx drivers, native Linux SPI/GPIO, DTS bindings) +│ +├── lib/monocypher/ # Vendored crypto library (Ed25519/X25519) +├── tools/ # Formatter (flash erase) + LR1110 firmware updater +├── CMakeLists.txt # Build orchestration +├── Kconfig # All ZephCore configuration options +├── Kconfig.psram # ESP32 PSRAM auto-enable from devicetree +├── prj.conf # Base project config +├── sysbuild.conf # Forces MCUboot when --sysbuild is used +└── west.yml # West manifest (Zephyr version pin) +``` + +--- + +## 3. Layer Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Phone App (BLE NUS / USB CDC / TCP / UART) │ External +│ or Serial CLI (USB CDC / PTY) │ +├─────────────────────────────────────────────────┤ +│ CompanionMesh / RepeaterMesh / │ App Layer +│ RoomServerMesh / ObserverMesh │ +│ ├── BaseChatMesh (contacts, channels, msgs) │ +│ ├── CommonCLI (command processor) │ +│ ├── ClientACL, RegionMap, TransportKeyStore │ +│ └── UI (display, buzzer, buttons) │ +├─────────────────────────────────────────────────┤ +│ mesh::Mesh │ Routing +│ ├── Flood routing (path hash accumulation) │ +│ ├── Direct routing (source-routed paths) │ +│ ├── Packet dedup (SimpleMeshTables) │ +│ └── Advert / ACK / Trace / Group dispatch │ +├─────────────────────────────────────────────────┤ +│ mesh::Dispatcher │ Scheduling +│ ├── TX/RX queue management │ +│ ├── CAD (channel activity detection) │ +│ ├── Duty cycle enforcement (EU ETSI) │ +│ ├── RX delay (score-based prioritization) │ +│ └── Maintenance (noise floor, image cal) │ +├─────────────────────────────────────────────────┤ +│ LoRaRadioBase │ Radio HAL +│ ├── SX126xRadio ──► Zephyr SX126x driver │ +│ ├── SX127xRadio ──► loramac-node backend │ +│ ├── LR1110Radio ──► Custom LR11xx driver │ +│ └── LR2021Radio ──► Custom LR20xx driver │ +├─────────────────────────────────────────────────┤ +│ Zephyr RTOS (kernel, drivers, BLE, FS, USB) │ Platform +└─────────────────────────────────────────────────┘ +``` + +--- + +## 4. Core Mesh Engine + +### 4.1 Packet Lifecycle + +1. **Allocation**: `StaticPoolPacketManager::allocNew()` — fixed pool of 32 `Packet` objects (no heap) +2. **Creation**: `Mesh::createDatagram()`, `createAdvert()`, `createAck()`, etc. +3. **Queuing**: `Dispatcher::sendPacket()` → `PacketManager::queueOutbound()` with priority + scheduled time +4. **Transmission**: `Dispatcher::checkSend()` → CAD check → serialize → `radio->startSendRaw()` +5. **Release**: `PacketManager::free()` after TX complete or processing done + +### 4.2 Packet Structure + +``` +Wire format: + [header: 1B] [transport_codes: 0 or 4B] [path_len: 1B] [path: variable] [payload: variable] + +Header byte: + Bits 0-1: Route type (0=transport_flood, 1=flood, 2=direct, 3=transport_direct) + Bits 2-5: Payload type (0=REQ .. 15=RAW_CUSTOM) + Bits 6-7: Version (0=v1) + +Path_len byte: + Bits 0-5: Hash count (0-63 hops) + Bits 6-7: Hash size mode (0=1B, 1=2B, 2=3B, 3=reserved) +``` + +### 4.3 Payload Types + +| Type | Value | Description | +|------|-------|-------------| +| REQ | 0x00 | Encrypted request to peer | +| RESPONSE | 0x01 | Encrypted response from peer | +| TXT_MSG | 0x02 | Encrypted text message | +| ACK | 0x03 | 4-byte CRC acknowledgment | +| ADVERT | 0x04 | Signed identity advertisement | +| GRP_TXT | 0x05 | Group channel text message | +| GRP_DATA | 0x06 | Group channel data | +| ANON_REQ | 0x07 | Anonymous request (includes full pubkey) | +| PATH | 0x08 | Path return (source route exchange) | +| TRACE | 0x09 | Trace route | +| MULTIPART | 0x0A | Multi-ACK container | +| CONTROL | 0x0B | Control data (zero-hop) | +| RAW_CUSTOM | 0x0F | Raw custom data | + +### 4.4 Routing + +**Flood routing**: Packet has no destination path. Each relay node appends its identity hash to `path[]` and retransmits. Priority decreases with hop count. `allowPacketForward()` is the gatekeeper. + +**Direct routing**: Packet carries a source-routed `path[]`. Each relay node checks if the first path hash matches its own identity, removes itself, and forwards. Path is built from previous flood packets' accumulated hashes. + +**Deduplication**: `SimpleMeshTables` maintains a circular buffer of 160 packet hashes (8 bytes each, SHA-256 truncated); ACKs are deduped through the same packet-hash path. `wasSeen()` is a pure query; call sites insert explicitly via `markSeen()` to prevent duplicate processing and retransmission. + +### 4.5 Dispatcher Scheduling + +The Dispatcher runs a tight loop: + +``` +loop(): + 1. Check if current TX is complete → release packet, record airtime + 2. Process next inbound packet from queue (if scheduled time has passed) + 3. checkRecv(): Drain radio RX ring buffer + - Parse raw bytes into Packet + - Flood packets: compute RX delay based on score → defer or process immediately + - Direct packets: process immediately + 4. checkSend(): Check outbound queue + - CAD: if channel busy (`isReceiving()` returns true or radio not ready), + retry every 100-200ms (jittered) up to 4s total. On 4s timeout, + call `_radio->recoverRxState()` (cancel + restart, clears IRQ + + latch + grace timestamp) and re-wake the loop instead of falling + through to TX. + - Duty cycle: if exceeded, defer 5 seconds (admin packets exempt) + - Final `isReceiving()` check right before TX (closes timing gap) + - Serialize and transmit +``` + +**RX Delay**: Flood packets are delayed based on signal quality. High-quality signals (high SNR, short packets) get shorter delays, allowing closer/better relays to retransmit first. Uses a lookup table approximation of `10^(0.85 - score*0.1) - 1` multiplied by airtime. + +**Duty Cycle**: Fixed 1-hour sliding window. Default 10%. Admin packets (REQ, RESPONSE, ANON_REQ, CONTROL) are exempt. + +### 4.6 Maintenance Loop + +Called every ~5 seconds from the main event loop: + +1. **Noise floor calibration**: EMA with alpha=1/8, jitter, threshold filtering, warmup +2. **RX mode watchdog**: Flags error if radio stuck outside RX for >8 seconds +3. **AGC reset** (`agcIdleMaintenance()`): warm sleep + recalibration, **SX126x only** — gated on `hwNeedsAgcReset()`, which only that family declares. Semtech prescribe it for a jammed AGC on the SX126x; neither the LR11xx UM nor the LR2021 DS describes such a fault, and running it there cost packets (T1000-E, 2026-08-24: 7.4% miss rate in the 60 s after a fire vs 0.6% elsewhere). Triggered by long silence **and** corroborating evidence — a frozen noise-floor reading — never by silence alone, which is a normal condition rather than a fault. +4. **Image-calibration drift** (`imageCalMaintenance()`): unrelated to the AGC despite the shared hook — LR11xx/LR2021 only, where the datasheets give a temperature threshold. Temperature is read from the **board** (`Board::getMCUTemperature()`), never from the radio; only the delta matters and the die sensor tracks the same ambient without costing the radio an SPI command. Polled hourly, deferred after TX so PA self-heating is not read as ambient drift, and confirmed by a second reading before recalibrating. + +### 4.7 Adaptive Contention Window + +Replaces Arduino MeshCore's static `txdelay`/`rxdelay` with three complementary mechanisms. + +**EMA Delay Factor (proactive)** + +`ContentionTracker` measures observed duplicates per retransmitted packet using a **24-entry ring buffer** (sized for ~50-neighbor hilltop topologies with multiple concurrent in-flight floods). Each entry tracks a packet (identified by FNV-1a hash) and records how many dupes arrive within a 10-second observation window. When the window closes, the entry is finalized and an EMA is updated with alpha = 1/8. The resulting estimate feeds the delay factor formula: + +``` +factor = 0.05 + 0.170 * sqrt(est) +``` + +Capped at 2.0. During warmup (fewer than 4 finalized entries), factor defaults to 0.5. Sparse nodes converge toward near-zero delay; dense nodes get proportionally higher delay. + +The flood retransmit jitter window is `5·airtime·factor` clamped by **two ceilings**: +- Airtime-scaled: `6·airtime` — keeps SF7/narrow-BW configs from wasting time in oversized windows. +- Absolute: `2000ms` — bounds per-hop latency in dense areas even when airtime is large. + +**Per-Dupe Reactive Backoff** + +When a duplicate of a pending outbound packet is heard, TX is rescheduled to `now + backoff_multiplier * airtime`. Each dupe triggers a full delay (not diminishing). Cumulative reactive extension is capped at `min(2000ms, 12·airtime)` per packet; after the cap, CAD handles remaining channel activity. `backoff_multiplier` is configurable via `set backoff.multiplier X` (range 0.0–2.0). + +**Initial-Flood Jitter (companion-only)** + +Companions don't retransmit floods, but they observe mesh contention and need to spread their *originated* transmissions to avoid colliding with repeaters still busy in TX/RX. `Mesh::passivelyTrackFloods()` (overridden to `true` on `CompanionMesh`) registers every first-hearing of a flood with the ContentionTracker, so the EMA warms up even without forwarding. `Mesh::getInitialFloodJitter(packet)` is added to the caller-supplied delay in both `sendFlood` overloads; on companion this is `rand(0, min(1000ms, 3·airtime, 5·airtime·factor))` — half the repeater's ceilings. Repeaters keep the default 0 (no double-jitter on forwards). + +**Direct Packets** + +Direct (source-routed) packets bypass adaptive scaling entirely. They use minimal fixed jitter: `20 + rand(0, airtime / 10)` ms. + +**CLI** + +- `get txdelay` — shows current adaptive state (EMA estimate, delay factor, backoff multiplier). +- `set backoff.multiplier X` — controls per-dupe reactive delay (0.0–2.0). +- `txdelay`, `rxdelay`, `direct.txdelay` — accepted for prefs compatibility but ignored at runtime. + +**ContentionTracker Resource Usage** + +~260 bytes RAM (24-entry ring buffer × ~16B/entry + state). FNV-1a packet hash, 10-second observation window, EMA with alpha = 1/8. + +### 4.8 Encryption + +- **Peer-to-peer**: ECDH shared secret (Curve25519) → AES-128-ECB encrypt → 2-byte HMAC-SHA256 MAC +- **Group channels**: SHA-256 of channel name → AES key +- **Advertisements**: Ed25519 signature over (pubkey + timestamp + app_data) +- **ACKs**: SHA-256(shared_secret + packet_hash) truncated to 4 bytes + +### 4.9 Mesh Time Sync (Clock Consensus) + +ZephCore-only divergence from Arduino MeshCore (like the Adaptive Contention Window). A node senses its own clock error from the Ed25519-signed timestamps in other nodes' adverts and — **opt-in, default off** (`set meshtimesync on`) — corrects it automatically. There is no trusted reference clock on a mesh, so this is a *consensus estimation* problem: the node assumes the majority of tenured advert senders within 3 flood hops is right. User-facing doc: `MESHTIMESYNC.md` at the repo root. + +**Module**: `helpers/MeshTimeSync.{h,cpp}` — role-agnostic estimator, owns no clock. Each role feeds it verified adverts (`onAdvertHeard`), calls `tick()` periodically (15-min pacing internal), and applies STEP verdicts under its own policy. + +**Sample table** (per-sender, `CONFIG_ZEPHCORE_TIMESYNC_TABLE_SIZE` slots: 32 default, 16 on RAM-bound companions; 24 B/slot): +- 8-byte pubkey prefix — a security floor, not a tuning knob (shorter prefixes are grindable: an attacker could collide a tenured voter's prefix and reset its tenure with validly-signed adverts). +- Latest advert timestamp (= the vote, per-sender monotonic — replays and flood dupes are inert) + arrival **uptime**. Skew is recomputed at evaluate time from the uptime anchor, so the node's own steps never stale stored samples. +- Tenure tracking: first-heard uptime, advert count. Eligibility = heard ≥ 1 h, ≥ 2 adverts, latest sample ≤ 5 days old (bridges the 47 h flood-advert cadence). +- Self-consistency: consecutive samples must satisfy `|Δadvert_ts − Δuptime| ≤ 45 s + 150 ppm × Δuptime`; violation (sender rebooted/corrected/lying) resets that sender's tenure. +- **Hop-priority admission** (hop cap 3): a new sender may only displace a young entry farther (higher hop) than it; mature entries are protected unless silent > 24 h. Naive LRU churned hub nodes to zero eligible voters in simulation. + +**Consensus**: Marzullo interval intersection over eligible votes, each `[skew − r, skew + r]` with `r = 150 s + 15 s × hop` (the 150 s base covers the real fleet's good-clock scatter, not just RF delay). No absolute outlier thresholds against the local clock — clustering does the rejection, so an epoch-reset clock still finds the true cluster. Stepping requires `CONFIG_ZEPHCORE_TIMESYNC_QUORUM` (default 6, floor 3, build-time security knob) eligible senders AND a strict majority inside the intersection; otherwise abstain. + +**Correction policy** (priority: GPS / manual set > mesh consensus): +- Any clock set — GPS fix **or** manual set (`time`, `clock sync`, app time set) — arms the same **7-day suppression** of all stepping, bootstrap included, plus drift-envelope pedigree (`noteGPSSync` and `noteManualSync` are identical). A live GPS re-arms it on every fix (so a repeater's 48 h duty cycle keeps GPS owning the clock); a GPS that cannot fix (indoors, dead antenna) becomes mesh-correctable once 7 days pass without a fix. Sensing always continues; a suppressed node shows `hold (suppressed)` in the dry-run. +- Step trigger 10 min, dead band 5 min, step capped **±1 h**, one step per **6 h**, logged loudly. Production contains coherent wrong-time islands (+28 h × 63 repeaters at analysis time); the cap bounds capture drag to 4 h/day. +- **Drift-envelope gate**: with a trusted sync + continuous uptime since (pedigree, RAM-only), corrections beyond `elapsed × 300 ppm + 10 min` are physically impossible for a crystal — refused regardless of quorum. +- **Bootstrap**: local time < firmware build epoch (`FIRMWARE_BUILD_EPOCH`, CMake-injected) is provably wrong → any 3 agreeing senders, step to the cluster's **low edge** (midpoint − 150 s; undershoot so later refinement is always forward = monotonicity-safe). + +**Per-role step policy** (policy lives in the role, not the estimator): +| Role | Policy | Why | +|---|---|---| +| Repeater | bidirectional | clock not load-bearing: forwarding/dedup/remote-admin run on `millis()`/hashes; a backward step only mutes own adverts at peers for a window equal to the step | +| Observer | bidirectional | clock only stamps observations — exactly what this fixes | +| Room server | forward-only | post timestamps feed client `sync_since` ordering | +| Companion | forward-only | own clock stamps outgoing DMs; peers hold per-sender replay high-water marks | + +**Step application**: the shared policy (suppression/pedigree checks inside `evaluateNow`, forward-only skip, uint32-overflow guard, set clock, one `zephcore_rtc_save` per step — never per evaluation) lives in `MeshTimeSync::runTick()`; when it returns true, the role shifts its wall-clock-anchored bookkeeping by `lastStepDelta()` — repeater: neighbor `heard_timestamp`s, ACL `last_activity`, login/anon/discover rate-limiter resets; room server: ACL + login limiter. + +All policy timers (6 h rate limit, 7-day suppression, tenure, sample age) anchor on **uptime, never wall clock** — otherwise the very steps they govern would distort them. + +**CLI**: `set meshtimesync {on|off}`, `get meshtimesync` → state + live dry-run (eligible count, votes for/against, skew/radius, would-be verdict) + per-sender evidence table (full table over local USB; remote admin replies are summary-truncated to fit the packet). Sensing always runs, so the dry-run works before enabling. + +**Accepted limits**: a coordinated same-offset majority around a node captures it (no consensus survives that — Bitcoin timejacking lesson; mitigations: default-off, manual override, caps); sub-quorum islands abstain forever (bootstrap still heals dead clocks with 3 senders). + +--- + +## 5. Radio Subsystem + +### 5.1 Class Hierarchy + +``` +mesh::Radio (abstract interface) + └── LoRaRadioBase (shared state machine, ring buffer, noise floor) + ├── 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 +``` + +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. + +### 5.2 LoRaRadioBase State Machine + +**TX Flow** (LBT — current default; `cad.mode == LORA_CAD_MODE_LBT` is set unconditionally in `buildModemConfig`): +1. `startSendRaw()` → `isReceiving()` final gate → `_tx_active = 1` → **skip** `hwCancelReceive()` and leave `_in_recv_mode = 1` so the driver sees state == RX → `configureTx()` → async send. +2. SX126x `send_async` entry CAS accepts both `REST_STATE → TX` and `RX → TX`, recording `was_rx`. LBT branch issues `set_standby(RC)` then SetCAD. On CAD-busy: in-driver `sx126x_restart_rx` puts the chip back in RX before `-EBUSY` returns. C++ failure path calls `startReceive()`, which the driver's `lora_recv_async` short-circuits when state is already RX. +3. On TX success: `_in_recv_mode = 0`, TX wait thread blocks on semaphore (5 s timeout). +4. On DIO1 `TX_DONE` interrupt → signal raised → restart RX → update stats. + +**RX Flow**: +1. `lora_recv_async()` with callback. SX126x `recv_async` clears `IRQ_ALL` and resets the RX-busy signals on every fresh entry. +2. ISR writes to 8-slot SPSC ring buffer (drops NEW packet on overflow). +3. Main thread drains via `recvRaw()`. + +**Config Caching**: Avoids redundant `lora_config()` calls. Fast-path for TX↔RX transitions when only direction differs. `recoverRxState()` clears the cache (`_config_cached = false`) so post-recovery RX goes through the full path. + +### 5.2.1 RX-Busy Gate (TX-during-RX prevention) + +`LoRaRadioBase::isReceiving()` is the single software source of truth for "currently receiving" and is consulted at three sites: dispatcher initial gate, dispatcher final gate, and `startSendRaw`'s last-moment gate. Logic: + +``` +isReceiving() + ├─ false if !_in_recv_mode || _tx_active + ├─ true if hwIsReceiving() ← per-adapter; never clears IRQ + └─ isChannelActive() RSSI fallback ← sub-preamble-threshold energy +``` + +For SX126x, `hwIsReceiving()` → `sx126x_is_receiving()` reads in this order: +1. **`data->rx_packet_active`** latch (no SPI). Set by the work handler on `HEADER_VALID`; cleared on every terminal event and RX (re)start. Covers the full payload phase. Bounded by a payload deadline: `header_seen_at_ms` is stamped when the latch is promoted, and once `sx126x_max_payload_ms()` (255-byte airtime at the current SF/BW, CR 4/8, LDRO on, +25% +100 ms) has elapsed the latch is released and the sticky PREAMBLE/SYNC/HEADER bits cleared. Continuous RX has no symbol timer, so without this a `HEADER_VALID` whose packet never completes would hold the TX gate closed until reboot; the DC parked-RX watchdog does not cover it (DC-only, and it treats the latch as a legitimate in-flight packet). +2. **Mutex-busy conservative** — if the SPI mutex is contended and `state == RX`, return true (the work handler is likely mid-`RxDone`). +3. **`HEADER_VALID` raw bit** — covers the microseconds between DIO1 firing and the work handler running. +4. **`PREAMBLE_DETECTED` raw bit with SF-aware grace** — `PREAMBLE_DETECTED` is masked off DIO1 (fires on noise), but visible in the IRQ register. On first observation, `is_receiving` records `data->preamble_seen_at_ms`; subsequent calls return true until either `HEADER_VALID` promotes the latch (timestamp reset) or `(preamble_len + 8) × 2^SF / BW` ms elapses — at which point the bit is explicitly cleared and TX is allowed. Grace scales with SF: ~82 ms at SF8, ~786 ms at SF12. + +The poll path is otherwise non-destructive — IRQ bits are cleared only by the work-handler bulk clear (on any DIO1 event), explicit `clear_irq_status(IRQ_ALL)` at every RX (re)start, the grace-expiry one-bit clear for foreign preambles, and the payload-deadline clear in step 1. + +### 5.2.2 CAD-Timeout Recovery + +`Dispatcher::checkSend()` tracks `cad_busy_start` while `isReceiving()` keeps the TX gate closed. If 4 s elapse (`getCADFailMaxDuration()`), the dispatcher calls `_radio->recoverRxState()` and returns. `LoRaRadioBase::recoverRxState()` does: + +```cpp +hwCancelReceive(); // RX → IDLE → STANDBY → SLEEP (REST_STATE) +atomic_set(&_in_recv_mode, 0); // resync C++ side +_config_cached = false; // force full lora_config on the way back +startReceive(); // CAS(REST → RX) clears latch + IRQ +``` + +This walks the chip through REST so the driver's `lora_recv_async` entry CAS (`REST_STATE → RX`) actually succeeds — a bare `startReceive()` from `state == RX` would fail with `-EBUSY` and set `_in_recv_mode = 0` while the driver still thinks it's in RX. After recovery, the dispatcher fires `_tx_queued_cb(1, ...)` to re-wake the loop promptly. + +### 5.3 Noise Floor EMA + +Algorithm in `triggerNoiseFloorCalibrate()`: +- 8 RSSI samples per tick, take median (insertion-sort midpoint) +- Threshold filter: reject samples ≥ floor + 14dB (after 8-tick warmup) +- Periodic bypass: every 16th tick accepts unconditionally +- EMA: `floor += round_nearest((sample - floor) / 8)`, clamped to [-120, -50] dBm + +### 5.3.1 Adaptive CAD (LBT detPeak calibration) + +`cadDetPeak` is a correlation peak-to-noise threshold in the despreader (not +dBm): it gates on signal *strength* ≈ link budget, blind to distance, so +raising it means "react to strong signals only, ignore faint/echo". The right +LBT sensitivity is site-dependent and cannot be derived from the RSSI floor. +`LoRaRadioBase::cadMaintenance()` (housekeeping tick) runs one calibration CAD +probe per `probe.interval` (default **15 s**) at a signed **level** relative +to the family's per-SF base detPeak, restarts RX, and classifies busy verdicts +with a ground-truth filter. **Key property:** the probe is *skipped* when RSSI > +floor+7 dB, so probes only ever sample the quiet/faint regime — the whole loop +is a faint-rejection tuner and `busy%` is faint-regime, not total occupancy. +Post-busy classification watches a ~12-symbol window for RX re-sync **or** an +RSSI climb above floor+guard (the energy path recovers real packets whose +preamble the probe's RX-restart ate — the fix for the FP over-count that used to +drive the staircase to the ceiling) → `tp`, else `fp`. Counters decay 6-hourly, +reset on any RF param change. + +With `cad.auto on` the staircase is **knee-seeking**: probes sample op / op−1 / +op+1 (½/¼/¼); it steps **up** when the level above is ≥`CAD_KNEE_SLOPE_PERMILLE` +(5%) cleaner (steep side, below knee), **down** only on a clean flat plateau +(`≤CAD_PLATEAU_CLEAN_PERMILLE`), else holds — slope-based so convergence is +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 to **−8…+12** +*narrowed by the driver's own detPeak clamp* (SX126x 12–48, LR11xx 40–100, +LR20xx 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.stats` 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 from Semtech's LBM table, bandwidth-aware), 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 +`nSym·Tsym + margin`. CLI: `get cad.stats` (3-rung window, `*`=operating, `bc:`=cap), +`set cad.auto/offset/probe.interval/busycap/reset`. SX127x: unsupported (no HW +CAD). Full mental model + tuning: `ADAPTIVE_CAD.md`. + +### 5.4 LR1110 Driver Errata Workarounds + +The custom `lr11xx_lora.c` driver handles several LR1110 firmware bugs: +- **CMD_ERROR IRQ**: Benign error flag on several write commands — cleared silently +- **RX buffer drift**: Buffer base shifts 4 bytes per packet → `clear_rxbuffer()` after every RX +- **Header error**: Can shift buffer pointer → standby before RX restart +- **DIO1 stuck HIGH**: 5-cycle detection → full hardware reset + recovery +- **BUSY-high wedge**: a command racing the autonomous `SetRxDutyCycle` sleep phase can leave the chip BUSY-high with DIO1 low. No IRQ ever fires, so the event-driven driver never re-arms and the node goes permanently deaf. A wedge-recovery watchdog on its own work queue (`lr11xx_wedge`, `K_PRIO_COOP(7)`) checks every 3 s: after 12 s of DIO1 silence it polls the BUSY GPIO continuously for 250 ms, and a dwell with no low edge means a genuine wedge → hardware reset + RX restart. False-positive free by construction — a healthy chip, continuous or duty-cycled, always drops BUSY low within one cycle. Reads the GPIO only (no SPI), so it cannot itself disturb the chip or race the autonomous DC state machine. +- **Duty-cycle ownership (BUSY is not a mode flag)**: UM §7.2.6 ends the RxDutyCycle loop on exactly three events — a packet (chip returns to the configured fallback, `STDBY_RC`), a host `SetStandby`, or **an NSS falling edge waking the chip from the sleep phase**, for which the manual adds "the user should send the `SetStandby(...)` command". Every host command is an NSS edge, so a command landing in the sleep phase silently ends the cycle: no IRQ, no flag, and every re-arm site hangs off RX_DONE or an error, which cannot fire on a receiver that is no longer listening. The node goes deaf until something independently calls `startReceive()` — which is precisely what the periodic housekeeping tick removed in `fe6e585` (2026-07-28, three days after the 1.16.7 tag) had been doing, and why the duty cycle "worked" in 1.16.7. Measured on a T1000-E 2026-08-24: **10 packets against an SX1262's 94** over 85 minutes with the cycle armed; parity with it off. + + The guard this replaces was a BUSY read before each command, and it cannot be made correct here. BUSY is high in the sleep phase (command fatal) **and** through ordinary Rx (command harmless), so the pin does not distinguish them — 406 of 407 sampler bursts refused with the cycle armed, 76 of 76 with it off — and it is check-then-act regardless, since the chip can enter sleep between the read and the NSS assert. The driver takes ownership instead: `lr11xx_dc_suspend()` / `lr11xx_dc_resume()` bracket any work that must touch the chip, ending the cycle with the `SetStandby` the manual asks for and re-arming explicitly. `is_receiving()` is deliberately *not* bracketed — it runs on the TX gate, where standing the cycle down would end the reception being asked about — and answers from the DIO1-stamped latch with no bus access. Restored parity to 10/10, the sampler to zero refusals, and adaptive CAD from 2 probes in 9 h to ~20 per 5 min. Identical treatment in the LR2021 driver, whose DS §6.3.8 states the same three rules word for word (**untested on hardware** — no X1 available). + + Two consequences worth keeping straight: the per-packet re-arm skips the standby after RX_DONE (`restart_rx(data, in_standby=true)`) because the chip has already performed it to spec, and the Rx-boost re-apply was dropped from the duty-cycle paths — §7.2.6 saves and restores the device configuration across each wake, so it was a redundant write inherited by analogy from the SX126x, which genuinely does need one (DS §9.6 retention list). + +- **Stale SPI reply read as data**: a read is two NSS windows (command, then answer) with a BUSY wait between them. `wait_on_busy()` returns immediately on a BUSY that reads low and cannot distinguish "command finished" from "BUSY has not risen yet", so the answer window can clock out the chip's default status / IRQ stream instead of the payload — silently, since the caller parses IRQ bits as a plausible short integer. `lr11xx_hal_read()` therefore checks the stat1 command-status byte it used to discard and re-issues the command (3 attempts) unless it reports `CMD_DATA`. Same guard in the LR2021 HAL; upstream MeshCore hit this as [PR #3261](https://github.com/meshcore-dev/MeshCore/pull/3261). +- **RX duty cycle**: wired via `SetRxDutyCycle` MODE_RX, sized by the shared adapter math (same as SX126x). The earlier "broken, 23-40% loss" verdict was a window-sizing bug (over-sleep + no header budget), not a chip defect — default-off, HW-verify before production use. + +### 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). **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 the temperature-drift recalibration — never on the Tx/Rx path (DS §6.4.2 keeps the values on chip across retention sleep). (It used to also ride the AGC-reset path; that path no longer exists on this family, and the caller it had explicitly skipped CalibFE anyway, so nothing was lost when it went.) 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. + - **Reads are status-checked.** The two-window read (command, BUSY wait, answer) can clock its second window before BUSY rises, in which case the chip streams status / IRQ instead of the payload — `GetRxPacketLength` then returns `irq[31:16]`, exactly 4 with `RX_DONE` set, and a real frame is read out of the FIFO at the wrong length. `lr20xx_spi_read_frame()` accepts an answer only when the stat1 header reports `CMD_DATA`, re-issuing the command otherwise (3 attempts). Safe to retry because the Rx FIFO pop is not on this path (`lr20xx_hal_direct_read_fifo()`, single window, structurally immune). + +#### 5.5.1 LR2021 driver design notes + +Why the driver is shaped the way it is. Kept here rather than in comments; the +code carries only units, datasheet references, and the constraints that would +break something if violated. + +**PA power.** `pa_lf_table[]` is Semtech's `LR20XX_PA_LF_CFG_TABLE` +(`examples/radio_hal/lr20xx_pa_pwr_cfg.h`, Clear BSD), indexed −10…+22 dBm, and +`lr20xx_get_pa_cfg_for_power()` mirrors `lr20xx_get_tx_cfg()` from +`ral_lr20xx_bsp.c`. `half_power`, `pa_duty_cycle` and `pa_lf_slices` are a +**matched triple per target power** — not independent knobs, which is why the +board-level `pa-hp-sel`/`pa-duty-cycle` devicetree properties were removed. The +register is half-dBm (DS Table 7-20, the SDK's `power_half_dbm` parameter name, +DS Table 7-16, and the BSP field name all agree); an earlier table modelled it as +an opaque calibration value and transmitted +22 dBm requests at 17.5 dBm. Values +are chip-level for Semtech's reference design: Semtech applies a per-board +matching-network correction separately via +`radio_utilities_get_tx_power_offset()`, which ZephCore does not yet have, so +absolute radiated power is uncalibrated. + +**Front-end calibration.** DS §6.4.2 stores calibration on chip, and it survives +every sleep this driver issues (all with retention), so it does **not** belong on +the Tx/Rx path — Semtech's `ral_lr20xx_init()` calibrates once at init and never +during operation. It runs only at `lora_config()`, after `lr20xx_hardware_reset()` +(a chip reset discards it) and in `reset_agc()`. `CalibFE` takes up to three +**point** frequencies in 4 MHz steps, unlike the SX126x/LR11xx `CalibrateImage` +band pair with datasheet-prescribed edges, so the operating frequency can be used +directly. The argument is quantised and the SDK rounds **up** where the chip's +own no-argument default truncates **down**, so both 4 MHz neighbours are +calibrated, nearest first — sidestepping an undocumented reuse rule. (The +"±20 MHz" tolerance comes from a BSP comment, not the datasheet.) + +**LBT and CAD_LBT.** `lr20xx_do_cad()` uses **LoRa CAD** (`SetLoraCadParams` / +`SetLoraCAD`) with the per-SF `det_peak` of DS Table 6-19 — not the generic +RSSI-threshold CAD, which cannot see a LoRa signal below the noise floor. The two +commands have **different exit-mode encodings**; always use +`lr20xx_radio_lora_cad_exit_mode_t`. With `CadExitMode = 0x10` the chip performs +CAD→Tx itself, removing ~3.9 ms of host round-trip (measured). Its `cad_timeout` +doubles as the Tx timeout and is 24 bits of 32 MHz periods = **524 ms maximum**, +so transmits whose airtime exceeds that take the classic host path instead — at +SF7/BW62.5 the crossover is roughly a 96-byte payload. Without that guard the +timeout wraps and truncates the packet on air. + +**RX duty cycle.** An NSS falling edge terminates the cycle (DS §6.3.8), so +incidental pollers must not issue SPI into a sleep window, and TX stands the +cycle down deliberately via `lr20xx_dc_takeover()`. `restart_rx()` issues +`SetStandby` before re-arming, because a header or CRC error does **not** +terminate the loop (§6.3.8 ends it on packet *reception*) and re-arming a live +cycle is refused — a refusal that latches CMD_ERROR, holds DIO1 high and used to +drive the safety path into a five-strike hardware reset. + +**Wake budget.** `hwWakeupTimeUs()` is per-device because the TCXO dominates: +DS Table 3-23 gives 1 ms warm start plus 115 µs STDBY_RC→Rx, and duty-cycle sleep +powers the VTCXO regulator down so the oscillator restarts on every wake. A board +declaring `tcxo-startup-delay-ms` that inherits the base class's flat 1500 µs +oversizes its sleep window and drops window-edge preambles regardless of signal +strength. + +**Firmware Patch RAM.** Loaded from both reset paths and verified by the magic +word at `0x800FF8`, so the `PRAM loaded:` line is proof the chip took the patch +rather than that the writes were accepted. Lost on reset, preserved by retention +sleep. + +### 5.6 Default Radio Parameters + +| Parameter | Default | Notes | +|-----------|---------|-------| +| Frequency | 869.618 MHz | EU 869.4-869.65 MHz band (500mW ERP allowed) | +| Bandwidth | 62 kHz | | +| Spreading Factor | 8 | | +| Coding Rate | 4/8 | | +| Preamble | 16 symbols | | +| TX Power | 22 dBm | Clamped by `CONFIG_ZEPHCORE_MAX_TX_POWER_DBM` | + +--- + +## 6. Application Layer + +### 6.1 Class Hierarchy + +``` +mesh::Mesh +├── BaseChatMesh (contacts, channels, messages, connections) +│ └── CompanionMesh (BLE protocol, phone sync, offline queue, ACK tracking) +├── RepeaterMesh (ClientACL, RegionMap, CLI, rate limiting, neighbor tracking) +├── RoomServerMesh (store-and-forward BBS; reuses repeater ACL/region/CLI) +└── ObserverMesh (listen-only; publishes packets to MQTT over WiFi — ESP32) +``` + +### 6.2 CompanionMesh + +Handles the binary BLE protocol with ~50 command opcodes. Key features: +- **Offline queue**: circular buffer with peek/confirm pattern (survives BLE drops); `CONFIG_ZEPHCORE_OFFLINE_QUEUE_SIZE`, default 256 frames (lowered on RAM-bound boards) +- **ACK tracking**: 8-slot table, computes expected ACK = SHA256(secret + hash)[0:4] +- **Contact iteration**: Streaming protocol with `lastmod` filtering for incremental sync +- **Lazy write batching**: Dirty contacts/channels flush after 5-second delay +- **Protocol versioning**: V2/V3 frame format negotiation with phone app +- **Ed25519 signing**: 3-phase flow (start→data→finish) for signing up to 8KB +- **Flood scope**: Transport key filtering for region-scoped sends + +### 6.2.1 V-Contact (Loopback Admin Contact) + +ZephCore-only feature (no Arduino equivalent). The companion synthesizes a CHAT +contact named `v` that exists only toward the connected BLE/USB app. +Chatting with it runs the same text CLI as the USB serial sideband; the reply +comes back as normal chat messages. The firmware also uses it to emit +unsolicited notices: a one-shot low-battery alert and a restart-reason message +(all causes: PIN/SOFTWARE/BROWNOUT/POR/WATCHDOG/LOCKUP — offline-queue only, +so routine power-on "noise" costs nothing over the air). + +**Identity**: pubkey = `SHA256("zc-vcontact" || self_pubkey)` — stable per +node, unique per device, and deliberately **not a real keypair**: no private +key exists anywhere. + +**No-RF invariants** (all enforced in `CompanionMesh`): +1. `vcontactHandleFrame()` intercepts `CMD_SEND_TXT_MSG` (and the handful of + other opcodes that must succeed) *before* any contact lookup — the CLI runs + 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 — 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. +3. Even a hand-crafted over-the-air packet addressed to the derived pubkey is + inert: unknown dest, undecryptable by everyone including this node. + +**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. 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 +(`_vcontact_pending`) instead of queued with an epoch-0 timestamp. +`vcontactClockSynced()` activates it and flushes the buffer; hooked at +`CMD_APP_START` (covers hardware-RTC boards, already valid), successful +`CMD_SET_DEVICE_TIME` (typical app connect flow), and GPS time sync. + +**Resend dedupe**: app retry attempts reuse the message timestamp (only the +attempt byte changes); `_vcontact_last_ts` suppresses re-execution — a dupe +gets the full ack choreography but the CLI does not run twice. Side effect: +sending the identical command twice within the same wall-clock second only +executes once (same app-side timestamp). Synthesized `est_timeout` is 3 s so +the app's retry timer doesn't race the loopback confirmation. + +**Stats**: `CompanionCLICallbacks` overrides +`formatStatsReply`/`formatRadioStatsReply`/`formatPacketStatsReply` with the +repeater's `StatsFormatHelper` JSON, so `stats-core`/`stats-radio`/ +`stats-packets` return real data over USB and the v-contact. + +**Notices ride the offline queue** — emitted while nothing is connected, they +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 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 + once per discharge cycle; re-arms on external power, recovery above + threshold + 150 mV, or threshold change. Sampling mirrors + `ui_auto_shutdown_check()` (30 s gate, 3-strike confirm) but lives in + `main_companion.cpp` so headless builds alert too. + +### 6.3 RepeaterMesh + +Autonomous operation features: +- **Authentication**: Password-based login with timestamp replay protection (120s window) +- **Permission levels**: GUEST(0), READ_ONLY(1), READ_WRITE(2), ADMIN(3) +- **Region filtering**: `RegionMap` with transport key matching per flood packet +- **Rate limiting**: 4 requests per 120s (discovery), 4 per 180s (anonymous), 4 failed logins per 180s +- **Neighbor tracking**: RSSI/SNR/name/timestamp table (`CONFIG_ZEPHCORE_MAX_NEIGHBOURS`, default 50 slots) +- **Temporary radio params**: `tempradio` command applies freq/bw/sf/cr via `LoRaRadioBase::setRadioOverride()` (does not mutate `_prefs`); auto-revert timer calls `clearRadioOverride()` to fall back to saved prefs +- **WiFi+MQTT uplink** (ESP32, `CONFIG_ZEPHCORE_REPEATER_UPLINK`): `RepeaterUplink.cpp` reports packets observer-style while still repeating; configured via `set uplink.*` CLI + +### 6.4 RoomServerMesh + +Headless store-and-forward shared message room (BBS). Clients log in with the admin or guest password and post messages; the server pushes each new post to every other logged-in client (per-client sync cursor + ACK). Reuses the repeater's ACL, region filtering, and USB CLI. Entry point `main_room_server.cpp`; build with `boards/common/room_server.conf`. + +### 6.5 ObserverMesh + +Listen-only node (ESP32 only): receives LoRa packets and publishes them to an MQTT broker over WiFi STA (`adapters/mqtt/`, `adapters/wifi/`). Never transmits. Configured at runtime via serial CLI (credentials in `observer_creds.cpp`); build with `boards/common/observer.conf`. + +### 6.6 CommonCLI Commands + +System: `ver`, `board`, `reboot`, `start dfu`, `start ota`, `erase` +Config: `set name/freq/radio/tx/flood.max/password/...`, corresponding getters +GPS: `gps on/off/setloc/advert`, `set gps duty ` +Sensors: `sensor get/set/list` +Stats: `stats-core/stats-radio/stats-packets`, `clear stats` +Time: `clock`, `clock sync`, `time `, `set meshtimesync on/off` + +Full command reference with constraints and remote-admin restrictions: `Repeater_CLI_commands.md`. + +--- + +## 7. Hardware Adapters + +### 7.1 BLE (`adapters/ble/`) + +- Nordic UART Service (NUS) with AUTHEN permissions on CCC + RX (forces pairing) +- Passkey-based MITM pairing (SC + MITM + Bonding), runtime configurable PIN via `app_passkey` callback +- DisplayOnly IO capability — phone enters passkey displayed on device / known to user +- Advertising always uses `BT_LE_ADV_OPT_USE_IDENTITY` — exposes the stable identity address even when privacy is enabled, preserving Android connect-from-app +- `CONFIG_BT_PRIVACY` **disabled** on nRF52840 / MG24: identity address is advertised directly; both iOS and Android work without RPA. Android's Flutter BLE plugin fails `connectGatt()` to RPA-advertised devices from app context. +- `CONFIG_BT_PRIVACY` **enabled** on ESP32-S3 (`boards/common/esp32_common.conf`): the Espressif controller's privacy-OFF Secure-Connections path produces a MIC failure against iOS (HCI disconnect `0x3d` at encryption start). Privacy ON keeps the controller on its working SC path. `USE_IDENTITY` advertising preserves Android compatibility. Do **not** remove `USE_IDENTITY` while ESP32 privacy is on. +- Pairing triggered reactively: phone hits ATT error 0x05 on secured attribute → initiates SMP pairing (Apple Accessory Design Guidelines §55 compliant — no proactive Security Request) +- **Unpaired-connection timeout (15 s)**: a connection that never reaches security L2 is disconnected. With `CONFIG_BT_MAX_CONN=1` the stack stops advertising while the slot is held, and the companion advertising watchdog skips any state where a connection exists — so a client that connects and never pairs (a scanner app left open, iOS routinely) otherwise makes the node invisible until it is power cycled. Every characteristic on both services is `*_AUTHEN`, so an unsecured connection can do nothing and the drop costs a legitimate client nothing. Armed in `connected()`, cancelled by `security_changed()` at L2+ and by `disconnected()`; the expiry handler reaches the connection via `bt_conn_foreach()` rather than `current_conn`, which belongs to the Bluetooth callback thread. +- TX congestion control: queue (12 frames) + overflow buffer + retry + timeout watchdog +- Fast/slow advertising switching with post-disconnect flap prevention +- DLE (Data Length Extension) to 251 bytes +- Interface coexistence: BLE vs USB, one active at a time +- Debug: build with `debug.conf` plus `-DCONFIG_ZEPHCORE_BLE_LOG_LEVEL_DBG=y` for adapter-level DBG logging + +### 7.2 DataStore (`adapters/datastore/`) + +- **Internal**: LittleFS on flash (`/lfs`), 256-byte cache for reduced flash I/O +- **External**: Optional LittleFS on QSPI (`/ext`) with auto-migration +- **BLE bonds**: NVS (`storage_partition`, 0xD0000 on nRF52) via Zephyr settings backend (≥1.16.2) +- **Prefs**: 152-byte binary (companion `new_prefs`), Arduino-compatible base + ZephCore extension fields, field-by-field I/O (see §13) +- **Contacts**: 152-byte records, stored on external flash if available +- **Channels**: 68-byte records (4 pad + 32 name + 32 secret) +- **Blobs**: Fixed-size records with LRU eviction by timestamp + +**First-boot migration (3-way FS self-heal)** + +A marker file `/lfs/_zc_init` is written after the first clean ZephCore boot. On every subsequent boot it is present and the logic below is skipped. On first boot (marker absent), `main_companion.cpp` picks one of three paths before `bt_enable()` runs: + +1. **No prefs, or Arduino MeshCore prefs** → full LFS + NVS format. Arduino's `new_prefs` omits `node_lat`/`node_lon`, shifting `freq`/`sf`/`bw` by 16 bytes; `prefsLookLikeArduino()` detects this by range-checking those fields. Covers fresh installs and Arduino → ZephCore migrations. +2. **Valid ZephCore prefs + `/lfs/settings` present** → NVS-only erase (`formatNVSOnly()`). ZephCore ≤1.16.1 stored BLE bonds in `/lfs/settings` (file backend); ≤1.16.1 used 0xD0000 as app code, so bytes there may pass NVS sector validation and hang `settings_load()`. Identity/prefs/contacts are preserved; re-pairing is required. +3. **Valid ZephCore prefs + no `/lfs/settings`** → skip format entirely. NVS was already initialised by ZephCore ≥1.16.2; bonds survive the upgrade. + +`loadPrefs()` also range-checks `freq`/`sf`/`bw` after deserialisation and reverts to compile-time defaults on out-of-range values, so a misread Arduino prefs file never corrupts the radio config. + +### 7.3 GPS (`adapters/gps/`) + +- State machine: OFF → ACQUIRING → STANDBY (with warm standby on supported hardware) +- 3 consecutive good fixes (≥4 satellites) required before reporting +- Multi-constellation: GPS+GLONASS+Galileo+BeiDou with fallback +- T1000-E: Complex 6-GPIO power sequencing with VRTC preservation +- GPS time blocks phone time sync for 2 hours after last fix + +**Duty cycle vs always-on** + +`gps_wake_interval_ms` (initialised from `prefs.gps_interval`) controls the mode: + +- **Duty cycling** (`gps_wake_interval_ms > 0`): after acquiring 3 good fixes the GPS powers down; the state machine wakes it again after the configured standby interval. The fix callback fires and then the GPS sleeps. +- **Always-on** (`gps_wake_interval_ms == 0`): the GPS never powers down. `consecutive_good_fixes` is reset after each promotion so the 3-fix gate cycles continuously, streaming fresh positions. Flash writes and fix callbacks are rate-limited to once per `gps_acquire_timeout_ms` to avoid hammering storage. + +`gps_set_poll_interval_sec(0)` switches to always-on live; persisted via `prefs.gps_interval` (set by `set gps duty 0`). + +**Timeout split** + +Two separate timeouts apply to acquisition: + +- `CONFIG_ZEPHCORE_GPS_FIRST_FIX_TIMEOUT_SEC` (default 300s): the cold-start window used for the very first acquisition after `gps_enable()`. Longer to allow almanac download. +- `CONFIG_ZEPHCORE_GPS_FIX_TIMEOUT_SEC` (default 120s): the normal per-wake timeout for all subsequent acquisitions (warm start). + +**Repeater mode** + +Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC` (48 h) for GPS duty — GPS wakes only for a periodic time-sync fix (5-minute acquire window). The interval is now unified with companion via `prefs.gps_interval` and is configurable at runtime via `set gps duty `; persists across reboots. + +### 7.4 USB (`adapters/usb/`) + +- **CompanionUSB**: V3-framed CDC (little-endian 16-bit length prefix + payload) +- **RepeaterUSB**: Minimal CDC with 1200-baud DFU touch detection +- Both share message queues with BLE adapter (transport-agnostic mesh layer) + +### 7.5 Board (`adapters/board/`) + +- Battery ADC with optional regulator-gated voltage divider, 8-sample average (boards with `zephyr,user` ADC node; MG24 has no battery divider, ADC disabled) +- UF2 bootloader entry via GPREGRET magic (0x57 = UF2, 0xA8 = BLE DFU) +- TX LED bracketing for LoRa transmissions (gated by the LED master switch below) +- Bootloader version detection via flash memory scan + +**LED master switch** (`helpers/led_gate.{c,h}`, `set leds on|off`, all roles): one process-wide +flag every LED driver consults — heartbeat and unread-message LEDs in `helpers/ui/ui_common.c`, the +`lora-tx-led` in `ZephyrBoard::onBeforeTransmit()`, and the message/shutdown flashes. It lives +outside the UI layer because `ui_common.c` is only compiled when a UI is enabled, while a headless +repeater still blinks on every transmit. `ui_common.c` overrides the weak `zephcore_leds_ui_sync()` +hook so a CLI change also stops a lit heartbeat and refreshes the UI's LEDs page. Persisted in +`NodePrefs.leds_disabled` (companion offset 93; repeater offset 120, magic-encoded — see §13). +Does not cover the display backlight, which has its own UI brightness setting (`display_brightness`). + +### 7.6 WiFi / MQTT / TCP Transports + +- **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by observer and repeater uplink +- **`adapters/mqtt/ZephyrMQTTPublisher.c`**: MQTT publisher for observed/uplinked packets +- **`adapters/ota/wifi_ota.c`**: WiFi SoftAP + HTTP firmware upload to MCUboot slot1 (ESP32, requires `--sysbuild`) +- **`adapters/transport/LinuxTCPTransport.c`**: TCP companion transport on native Linux (port 5000, MeshCore `SerialWifiInterface` framing) +- **`adapters/transport/SerialCompanionTransport.c`**: UART companion transport (STM32WL — drop-in `zephcore_ble_*` provider, auto-selected when `CONFIG_BT=n`) + +--- + +## 8. UI Subsystem + +### 8.1 Architecture + +Event-driven, no dedicated thread. All UI work on Zephyr work queues. + +Two UI frontends share the same plumbing (`helpers/ui/`: display, buzzer, multi-tap input filter, mesh action queue): + +- **Button UI** (`helpers/ui-button/`): single-button page cycler — most boards +- **Joystick UI** (`helpers/ui-joystick/`): 5-way joystick menu UI (Wio Tracker L1) + +``` +Hardware buttons → Zephyr input subsystem → Longpress filter → Multi-tap filter + → ui_input_cb() → page navigation / action dispatch → schedule_render() + → render_work (50ms OLED / 200ms EPD debounce) → CFB framebuffer → display +``` + +Color TFT panels (T114, T096, Wireless Tracker) are wrapped as 1bpp displays for CFB via the `zephcore,mono-tft` shim (`display_mono_tft.c`). + +### 8.2 Pages (Button UI) + +**Companion** (up to 12 pages): Messages, Recent, Radio, Bluetooth, Advert, GPS, Buzzer (if buzzer present), LEDs, Sensors, Offgrid, DFU, Shutdown + +**Repeater** (3 pages): Status, Radio, Shutdown + +### 8.2.1 Renderer Split (mono / color) + +Pages whose color layout genuinely diverges from the mono layout are split into +dedicated renderers behind a compile-time seam, instead of branching on +capability inline (and never into per-board renderer files): + +``` +render__mono() — mono / tiny / e-ink layout (always compiled) +render__color() — RGB565 layout, wrapped in + #if MC_DISPLAY_COLOR_PANEL +render_() — thin dispatcher: + #if MC_DISPLAY_COLOR_PANEL + if (mc_display_has_color()) { _color(); return; } + #endif + _mono(); +``` + +`MC_DISPLAY_COLOR_PANEL` is defined (in `display.h`) only when a `tft` node +exists in devicetree. On a mono/e-ink board the color bodies — and every +color-only helper they reference (`draw_activity_graph`, `use_compact_color_home`, +the `activity_*` buffers, …) — are dropped at compile time, so color rendering +costs zero flash/RAM there. Adding a new color board reuses `_color`; it must +never fork a board-specific renderer. + +Pages with a **shared** flow that only tints per-row (Recent, GPS, Sensors, +Status) stay as single functions with inline `if (mc_display_has_color())` — +that already is the "one layout, colored" ideal, and the color branch +dead-code-eliminates on mono via the constant-false `mc_display_has_color()`. +Split pages: Messages, Radio, Traffic, Bluetooth, Advert, LEDs, Offgrid, DFU, +Shutdown. + +### 8.3 Multi-Tap Input + +Single button; tap-count → key-code mapping comes from the board's devicetree `tap-codes` (up to 5). Typical mapping: +- 1 tap → Page next +- 2 taps → LED heartbeat toggle +- 3 taps → Notification mode (sound+vibrate → vibrate → silent → sound → …; boards with no motor fall back to a plain on/off toggle) +- 4 taps → GPS toggle +- 5 taps → Flood advert (immediate, no delay) + +### 8.4 Buzzer and vibration + +Non-blocking RTTTL parser on dedicated work queue. Predefined melodies for startup, shutdown, messages, ACKs. 2-second safety watchdog auto-silences on work queue stall. + +Boards with a DRV2605 haptic driver (`ti,drv2605` in DT) also vibrate on every notification — `buzzer_play()` pulses the motor. The two outputs share one setting, the notification mode, which lives in `helpers/buzzer_gate.c` (always linked, same pattern as `led_gate.c`, so the CLI resolves its symbols on boards that compile no buzzer). `set buzzer 0|1|2|3` and the 3-tap button action both drive it: + +| Mode | Name | Buzzer | Motor | +|------|------|--------|-------| +| 0 | silent | - | - | +| 1 | sound+vib | yes | yes | +| 2 | vibrate | - | yes | +| 3 | sound | yes | - | + +Modes 2 and 3 are rejected on boards with no motor, where they would be indistinguishable from 0 and 1. The setting persists in the existing `buzzer_quiet` prefs byte — 0 and 1 keep their original meaning, 2 and 3 are new and read as "quiet" by older firmware, so a downgrade silences a node left on sound-only. + +### 8.5 Doom Easter Egg + +Wolf3D-style raycaster on OLED: textured walls, 2 enemy types, shooting, HUD. Bypasses CFB, writes directly to display. ~1.7KB RAM, ~5KB flash. Enabled via `CONFIG_ZEPHCORE_EASTER_EGG_DOOM`. Button UI: triple-press ENTER on Messages page. Joystick UI: Tools menu → "Doom". + +--- + +## 9. Build System + +### 9.1 Config Layering + +``` +prj.conf (base: console; production defaults — LOG=n, ASSERT=n) + → boards/common/zephcore_common.conf (ALL boards: BLE, crypto, FS, LoRa, sensors) + → boards/common/_common.conf (nrf52/esp32/nrf54l/mg24 specifics) + → boards///board.conf (board-specific pins, features) + → [optional] repeater.conf, debug.conf (user extras, LAST = highest priority) +``` + +### 9.2 Key Kconfig Choices + +- **Role**: `ZEPHCORE_ROLE_COMPANION` (default) vs `ZEPHCORE_ROLE_REPEATER` vs `ZEPHCORE_ROLE_ROOM_SERVER` vs `ZEPHCORE_ROLE_OBSERVER` (selected via `repeater.conf` / `room_server.conf` / `observer.conf`) +- **Radio**: `ZEPHCORE_RADIO_NATIVE` (SX126x, default) vs `ZEPHCORE_RADIO_LR1110` vs `ZEPHCORE_RADIO_LR2021` vs `ZEPHCORE_RADIO_SX127X` +- **Features**: Display, buzzer, buttons, multi-tap, Doom (auto-enabled from DT); PSRAM auto-enable from DT (`Kconfig.psram`) + +### 9.3 Platform Notes + +- **nRF52840**: Zephyr open-source BLE controller, UF2 bootloader, partial flash erase for BLE coexistence +- **nRF54L15**: Same BLE controller as nRF52, CMSIS-DAP via SAMD11 bridge, no native USB +- **ESP32-C3/C6/S3**: Espressif proprietary BLE blob, 32KB heap, asserts disabled (blob IRQ false positives); simple-boot by default, MCUboot only with `--sysbuild` (WiFi OTA) +- **ESP32 classic (PICO-D4)**: much smaller DRAM — contact/queue caps shrunk in `board.conf`; console/CLI on `uart0` (no native USB); DIO flash mode required (QIO bootloops) +- **EFR32MG24**: SiLabs proprietary BLE blob, 32KB heap, SEMAILBOX enabled for hardware TRNG/crypto entropy, ADC disabled (no battery divider), CMSIS-DAP via onboard SAMD11 +- **STM32WL (LoRa-E5)**: no BLE, no USB device — companion protocol and CLI run over USART1; 64KB SRAM caps contacts/queues hard; TRNG entropy; single app partition, flash via SWD +- **Native Linux (`native_sim`)**: real SPI/GPIO via spidev + GPIO chardev; TCP companion transport; file-backed flash — see `LINUX_NATIVE.md` + +### 9.4 Patches + +Applied automatically at CMake configure time; a failed patch aborts the configure with the offending patch named. + +| Patch | Risk | Purpose | +|-------|------|---------| +| 0001-lora-lr11xx-lr20xx-build | LOW | Registers the LR11xx and LR20xx drivers in the Zephyr LoRa build | +| 0003-lora-sx126x-native | **HIGH** | DIO1 work queue, duty cycle, CAD, RX-busy gating, band RSSI/AGC calibration, PA/OCP tuning, extension API | +| 0004-lora-sx127x-62k5-bandwidth | LOW | Adds 62.5 kHz bandwidth to the loramac-node backend | +| 0005-gnss-config-and-version-query | MEDIUM | Air530Z nav-rate config + `$PCAS06` version query; NMEA generic dump | +| 0006-blobs-py | LOW | Fix `west blobs fetch` KeyError | +| 0007-spi-gpio-native-linux | LOW | Wires native-Linux SPI/GPIO drivers into the Zephyr build | +| 0008-flash-sim-per-node-file | LOW | Flash simulator defaults to per-node settings file (native Linux) | +| 0009-display-ssd16xx-fill-ram-white | LOW | E-paper full-refresh-to-white anti-ghosting helper | +| 0010-uarte-pm-suspend-bounded-rxto-wait | MEDIUM | Bounds the nRF UARTE STOPRX/RXTO spin on PM suspend; unbounded upstream, wedges the mesh thread | + +**One patch per file.** No upstream file is touched by more than one patch, so +apply order is irrelevant and no patch can be anchored inside another's added +lines. Consolidated 2026-08-20 (15 → 9): `0002` folded into `0001`, and +`0011`–`0015` folded into `0003`, each keeping its rationale as a `== section ==` +in that patch's preamble. `0002` is a deliberate numbering gap. Add a new +sx126x fix by regenerating `0003`, never by stacking an `0016` on it — see +`WEST_UPDATE.md`. + +New drivers in `patches/zephyr-new/` (LR11xx, LR20xx, native-Linux SPI/GPIO, DTS bindings) are copied — not patched — into the Zephyr tree at configure time. + +### 9.5 Flash Partition Layouts + +**nRF52840 SD v6**: SoftDevice 152KB → App 680KB → NVS 16KB → LFS 128KB → UF2 48KB +**nRF52840 SD v7**: SoftDevice 156KB → App 676KB → NVS 16KB → LFS 128KB → UF2 48KB +**ESP32 (4MB)**: Boot + App → LFS 192KB + NVS 16KB +**ESP32-S3 (8/16MB)**: Boot + App → LFS 384KB + NVS 16KB +**nRF54L15**: MCUboot 64KB → App 1272KB → LFS 92KB +**EFR32MG24**: MCUboot 48KB (reserved) → App 1344KB → LFS 144KB +**STM32WL**: App at flash origin → LFS (no bootloader) + +--- + +## 10. Board Matrix + +Build strings and flash methods: `boards/supported_boards.md` and `boards/example_board/README.md`. + +| Board | SoC | Radio | GPS | Display | Notable extras | +|-------|-----|-------|-----|---------|----------------| +| RAK4631 / WisMesh Pocket | nRF52840 | SX1262 | u-blox MAX-7Q (opt) | WisBlock OLED (opt) | I2C sensors | +| RAK3401 1W | nRF52840 | SX1262+SKY66122 (30dBm) | u-blox MAX-7Q (opt) | - | I2C sensors | +| RAK WisMesh Tag | nRF52840 | SX1262 | AT6558R | - | Accelerometer, buzzer, multitap | +| T1000-E | nRF52840 | **LR1110** | AG3335 | - | Buzzer, button, multitap | +| SenseCAP MeshTracker X1 | nRF52840 | **LR2021** | AG3335M (L1+L5) | - | SPA06 barometer, DRV2605L vibration, YSN8900 RTC, QSPI 8MB, RGB LEDs, buzzer | +| ThinkNode M1 | nRF52840 | SX1262 | Air530Z | EPD 200x200 (SSD1681) | Buzzer, 2 buttons, QSPI 2MB, RGB LEDs | +| ThinkNode M3 | nRF52840 | **LR1110** | Yes | - | Buzzer, 2 buttons, RGB LEDs | +| ThinkNode M6 | nRF52840 | SX1262 | L76K | - | QSPI, RGB LEDs | +| Wio Tracker L1 | nRF52840 | SX1262 | L76K | OLED 128x64 (SH1106) | 5-way joystick UI, buzzer, QSPI 2MB | +| LilyGo T-Echo | nRF52840 | SX1262 (TCXO 1.8V) | L76K | EPD 1.54" (SSD1681) | BME280, QSPI, touch-button backlight | +| Heltec T114 | nRF52840 | SX1262 | - | TFT 240x135 (ST7789V) | Screenless build via `no_display.conf` | +| Heltec Mesh Node T096 | nRF52840 | SX1262+KCT8103L PA | UC6580 | TFT 160x80 (ST7735S) | Button, LED, battery ADC | +| Ikoka Nano 30dBm | nRF52840 | SX1262+PA (30dBm) | - | - | RGB LEDs | +| GAT562 30S Mesh Kit | nRF52840 | SX1262+PA (1W) | Yes | OLED (SSD1306) | 5-way joystick, buzzer, solar | +| SenseCAP Solar | nRF52840 | SX1262 | L76K | - | QSPI, battery monitor | +| XIAO nRF52840 + Wio-SX1262 | nRF52840 | SX1262 | - | - | - | +| ProMicro SX1262 | nRF52840 | SX1262 (E22-900M30S) | Yes | - | Button, LED, battery ADC | +| muzi works R1 Neo | nRF52840 | SX1262 | Yes | - | Buzzer, button, RX8130CE RTC, latched-rail power-off | +| XIAO nRF54L15 | nRF54L15 | SX1262 | - | - | Contacts capped at 450 | +| XIAO ESP32-C3 | ESP32-C3 | SX1262 | - | - | Contacts capped at 300 | +| XIAO ESP32-C6 | ESP32-C6 | SX1262 | - | - | - | +| LilyGo TLoRa C6 | ESP32-C6 | SX1262 | - | - | - | +| XIAO ESP32-S3 | ESP32-S3 | SX1262 | - | - | 8MB flash, 8MB PSRAM | +| Station G2 | ESP32-S3 | SX1262+PA | UART GNSS | OLED (SH1106) | 16MB flash, 8MB PSRAM | +| Heltec V3 | ESP32-S3 | SX1262 | - | OLED (SSD1306) | Console on `uart0` | +| Heltec V4.2 / V4.3 | ESP32-S3 | SX1262+PA (GC1109 / KCT8103L) | - | OLED (SSD1306) | 16MB flash, 2MB PSRAM | +| Heltec Wireless Tracker | ESP32-S3 | SX1262 | UC6580 | TFT 160x80 (ST7735R) | - | +| LilyGo T-Beam v1.2 | ESP32 (PICO-D4) | SX1262 | Yes | - | AXP2101 PMU; contacts capped at 160 | +| TTGO LoRa32 | ESP32 (PICO-D4) | **SX1276** (loramac-node) | - | - | SX127x reference board | +| XIAO MG24 | EFR32MG24 | SX1262 | - | - | - | +| Seeed LoRa-E5 mini | STM32WL | STM32WL sub-GHz (SX1262-class) | - | - | UART companion/CLI; contacts capped at 24 | + +Contact capacity is `CONFIG_ZEPHCORE_MAX_CONTACTS` (default 350) unless capped per-board as noted. Native-Linux presets (Femtofox, RAK6421) are `EXTRA_CONF_FILE` presets, not boards — see `LINUX_NATIVE.md`. + +--- + +## 11. Packet Format Reference + +### Wire Format + +``` +Byte 0: Header + [1:0] Route type: 0=transport_flood, 1=flood, 2=direct, 3=transport_direct + [5:2] Payload type (see table in §4.3) + [7:6] Version (0=v1) + +If transport route (bit 0 or both bits set): + Bytes 1-4: transport_codes[2] (2x uint16_t LE) + +Next byte: path_len + [5:0] Hash count (number of hops) + [7:6] Hash size mode (0→1B, 1→2B, 2→3B) + +Next N bytes: path[] (hash_count × hash_size bytes) + +Remaining bytes: payload (type-specific) +``` + +### Advert Payload + +``` +[32B pubkey] [4B timestamp LE] [64B Ed25519 signature] [0-32B app_data] + +app_data format (AdvertDataHelpers): + Byte 0: type(3:0) | flags(7:4) + flags: bit4=lat/lon, bit5=feat1, bit6=feat2, bit7=name + [optional 8B: lat(float) + lon(float)] + [optional 2B: features1] + [optional 2B: features2] + [remaining: name string] +``` + +### Encrypted Datagram (REQ/RESPONSE/TXT_MSG) + +``` +[1B dest_hash] [1B src_hash] [encrypted_payload + 2B MAC] + +encrypted_payload (after AES-128-ECB decrypt): + For TXT_MSG: [4B timestamp] [1B txt_type] [text...] + txt_type: 0=plain, 1=cli_data, 2=signed_plain +``` + +--- + +## 12. BLE Protocol Reference + +### Frame Format + +Raw binary over BLE NUS. Each frame: `[1B opcode] [payload...]` +Over USB CDC (and native-Linux TCP): framed with a length prefix — `[2B LE length] [1B opcode] [payload...]` (TCP additionally prefixes a `<`/`>` direction byte). + +### Key Command Opcodes (phone → device) + +The full set (~50 opcodes, `0x01`–`0x41`) is defined at the top of `app/CompanionMesh.cpp`; values match the Arduino MeshCore companion protocol. A sample: + +| Opcode | Name | Payload | +|--------|------|---------| +| 0x01 | CMD_APP_START | app version + name (session start) | +| 0x02 | CMD_SEND_TXT_MSG | txt_type + attempt + timestamp + pubkey_prefix + text | +| 0x04 | CMD_GET_CONTACTS | [optional 4B `since` lastmod filter] | +| 0x05 / 0x06 | CMD_GET/SET_DEVICE_TIME | (none) / 4B epoch (forward-only) | +| 0x07 | CMD_SEND_SELF_ADVERT | [optional type byte: flood/zero-hop] | +| 0x08 | CMD_SET_ADVERT_NAME | name string | +| 0x0A | CMD_SYNC_NEXT_MESSAGE | (none) — offline queue peek/confirm | +| 0x0B | CMD_SET_RADIO_PARAMS | freq + bw + sf + cr | +| 0x16 | CMD_DEVICE_QUERY | app target version | +| 0x21–0x23 | CMD_SIGN_START / DATA / FINISH | 3-phase Ed25519 signing (up to 8KB) | + +### Push Notifications (device → phone, async) + +Codes `0x80`–`0x90` (`PUSH_CODE_*` in `app/CompanionMesh.h`). Most used: + +| Code | Name | +|------|------| +| 0x80 | PUSH_CODE_ADVERT | +| 0x81 | PUSH_CODE_PATH_UPDATED | +| 0x82 | PUSH_CODE_SEND_CONFIRMED | +| 0x83 | PUSH_CODE_MSG_WAITING | +| 0x8A | PUSH_CODE_NEW_ADVERT | + +--- + +## 13. Data Storage + +### File Paths + +| Path | Content | Format | +|------|---------|--------| +| `/lfs/_main.id` | Node identity | 64B private key + 32B public key | +| `/lfs/new_prefs` | Companion preferences | 152B binary, field-by-field (Arduino-compatible superset) | +| `/lfs/contacts3` or `/ext/contacts3` | Contacts | 152B × N records | +| `/lfs/channels2` or `/ext/channels2` | Channels | 68B × N records | +| `/lfs/adv_blobs` or `/ext/adv_blobs` | Advert cache | Fixed-size blob records | +| `/lfs/repeater/*` | Repeater/room-server identity + prefs | 297B prefs; atomic-replace writes | +| `/lfs/repeater/acl` | Client ACL | 136B × N records | +| `/lfs/repeater/regions2` | Region map | Header + 164B × N entries | +| `storage_partition` (NVS, 0xD0000 nRF52) | BLE bonds + Zephyr settings | NVS settings backend (≥1.16.2; old `/lfs/settings` file detected by self-heal) | + +> **Roles are not interchangeable.** Each role formats the whole volume on its first boot if the +> volume holds no data for that role: the companion checks `/lfs/new_prefs` +> (`ZephyrDataStore::hasPrefs()`), the repeater/room-server/observer check `/lfs/repeater/prefs` +> and `/lfs/repeater/_main.id` (`RepeaterDataStore::hasRoleData()`). So flashing a repeater over +> a companion — or the reverse — erases the previous role's identity, prefs and contacts, plus +> `storage_partition` and QSPI. Export your identity before switching roles. The roles' files +> never overlap physically (one LittleFS volume, one allocator); the reason for the wipe is that +> they share 128 KB and the other role's data crowds out writes. Repeater, room server and +> observer share `/lfs/repeater/` and the same prefs layout, so switching among *those three* +> preserves the identity. + +### Preferences Binary Layouts + +Two distinct field-by-field serializations (NOT raw struct dumps), both Arduino-compatible +in their shared base fields: + +**Companion `/lfs/new_prefs` (168 bytes)** — `adapters/datastore/ZephyrDataStore.cpp` +`loadPrefs()`/`savePrefs()` (offset comments inline). Arduino companion layout (name, lat/lon, +radio params, telemetry modes, BLE pin, GPS, autoadd) plus ZephCore extensions from offset 92: +rx_boost(92), leds_disabled(93), reserved(94-95, was APC), default flood scope name/key(96-142), +ble_disabled(143), display/wake/screen-off/auto-shutdown(144-149), rx_duty_cycle(150), +meshtimesync(151). + +**Repeater/room-server `/lfs/repeater/prefs` (305 bytes)** — `app/RepeaterDataStore.cpp` +`loadPrefs()`/`savePrefs()` (offset comments inline). This is the only serializer for the +repeater layout; `helpers/CommonCLI.cpp` carried a second, unreachable copy of it until it was +removed — do not add prefs fields anywhere but the two files named in this section. +Key ranges: name(4-36), radio(72-119), adaptive-delay(80-111, ignored at runtime), +leds_disabled(120, magic-encoded `0xA0`/`0xA1` — the byte formerly held `agc_reset_interval`, which +stored seconds/4, so any other value is a legacy interval and decodes to "LEDs on"), +Arduino-bridge(127-151, read+discarded), GPS(156-161), owner_info(170-290), rx_boost/duty(290-291), +reserved(292-293, was APC), flood_max_unscoped/advert(294-295), meshtimesync(296). Older shorter files +load cleanly — reads past EOF are no-ops, so newer fields keep their defaults and a one-time +upgrade block migrates them. + +--- + +## 14. Key Call Flows + +### 14.1 Receiving a LoRa Packet → Application + +``` +DIO1 interrupt → Zephyr lora driver → async RX callback + → LoRaRadioBase::rxCallbackStatic() → SPSC ring buffer write → _rx_cb() + → k_event_post(MESH_EVENT_LORA_RX) → main thread wakes + → Dispatcher::loop() → checkRecv() → drain ring buffer + → tryParsePacket() → score + airtime calc + → flood: dedup + adaptive contention delay → queue for retransmit + → direct: process immediately + → Mesh::onRecvPacket() → decrypt → dispatch by type + → BaseChatMesh::onPeerDataRecv() → onMessageRecv() + → CompanionMesh: writeFrame() to phone or queueOfflineMessage() +``` + +### 14.2 Sending a Text Message + +``` +Phone sends CMD_SEND_TXT_MSG via BLE NUS + → CompanionMesh::handleProtocolFrame() + → BaseChatMesh::sendMessage(contact, text) + → composeMsgPacket(): ECDH secret → AES encrypt → MAC + → if contact has path: trySendDirect() + → else: sendFlood() + → Mesh::sendFlood() → mark seen → queue outbound + → Dispatcher::checkSend() → CAD check → duty cycle check → LBT → startSendRaw() +``` + +### 14.3 Repeater Forwarding a Packet + +``` +Dispatcher::checkRecv() → Mesh::onRecvPacket() + → flood packet, not for us + → routeRecvPacket() → allowPacketForward() + → RepeaterMesh checks: disable_fwd? flood_max? region filter? + → if allowed: append self hash to path, ACTION_RETRANSMIT_DELAYED + → re-queued outbound with priority = hop count +``` + +### 14.4 Noise Floor Calibration Cycle + +``` +main event loop (every 5s) → Dispatcher::maintenanceLoop() + → radio->triggerNoiseFloorCalibrate(threshold) + → guards: in RX? TX active? duty cycle? mid-receive? + → read 8 RSSI samples, take median + → first sample: seed directly + → warmup (<8 ticks): accept unconditionally + → periodic bypass (every 16th): accept unconditionally + → otherwise: reject if sample ≥ floor + 14dB + → EMA: floor += round((sample - floor) / 8) + → clamp [-120, -50] dBm +``` + +--- + +## 15. Watchdogs and Recovery Mechanisms + +**There is no hardware watchdog.** No `CONFIG_WATCHDOG`, no `task_wdt`, no `wdt` +node enabled on any board — the `wdt` nodes visible in board `.dts` files are +inherited SoC definitions, and the `RTCWDT` references in the TTGO board configs +concern the ESP32 ROM bootloader's own watchdog, not something ZephCore arms. +The only consumer of the concept is the boot breadcrumb in `main_companion.cpp`, +which reads `RESET_WATCHDOG` out of `hwinfo_get_reset_cause()` and reports it in +the "Restarted:" v-contact message (see [6.2.1](#621-v-contact-loopback-admin-contact)). + +Everything below is software: bounded stall detection in the layer that owns the +state machine. Each entry names what it recovers, because several are +deliberately diagnostic-only and recover nothing. + +### 15.1 Named watchdogs + +| Watchdog | Location | Period | Trigger → action | +|----------|----------|--------|------------------| +| SX126x parked-RX | `patches/zephyr/0003-lora-sx126x-native.patch` (`sx126x_dc_watchdog_handler`) | `2×(preamble+8)` symbols, floor 250 ms | Duty-cycle only. Two consecutive samples showing the chip parked in full RX after a false preamble detect → re-arm the DC cycle. Two-strike so a sighting can never fall inside one real packet's preamble→header gap and abort a live reception. Counted by `get dc.restarts`. | +| LR11xx wedge-recovery | `lr11xx_lora.c` (`lr11xx_wedge_watchdog_handler`, own `lr11xx_wedge` queue) | 3 s | DIO1 silent >12 s **and** BUSY continuously high for a 250 ms confirm poll → hardware reset + RX restart. See [5.4](#54-lr1110-driver-errata-workarounds). | +| Radio stall | `Dispatcher::maintenanceLoop()` | `RADIO_STALL_THRESHOLD_MS` (8 s) | Radio neither in RX nor mid-TX for the whole window → latch `ERR_EVENT_STARTRX_TIMEOUT`. **Diagnostic only.** The bit is surfaced everywhere: repeater/room-server `stats`, binary telemetry, MQTT uplink, and the companion's BLE device-status response. | +| Contact-dump stall | `main_companion.cpp` housekeeping | housekeeping tick | Dump active and the iterator cursor unmoved across a whole tick → re-post `MESH_EVENT_CONTACT_ITER`. The dump is pumped solely by the BLE/USB tx-idle callback, so one lost kick would strand it silently. | +| BLE advertising | `main_companion.cpp` housekeeping | housekeeping tick | Enabled, not connected, not advertising → `zephcore_ble_set_enabled(true)`. Covers transient `bt_le_adv_start` failure, which would otherwise leave the node undiscoverable until reboot. | +| BLE TX timeout | `ZephyrBLE.cpp` (`BLE_TX_TIMEOUT_MS`) | 2 s | `ble_tx_in_progress` set with no completion callback → clear the flag and proceed to the next TX. Sits 3 s inside the 5 s supervision timeout. | +| USB partial-input | `ZephyrCompanionUSB.cpp` (`USB_FRAME_TIMEOUT_MS`) | byte-driven | Mid-frame or mid-text-line too long → reset parser to `USB_RX_IDLE`. **Not a timer** — it only runs when bytes arrive, so it never wakes a sleeping node. | +| Buzzer safety | `helpers/ui/buzzer.c` (`BUZZER_TONE_MAX_MS`) | 2 s | Note handler stalls → silence PWM + amp off. The PWM block is autonomous and would otherwise drive the pin forever after a crash or work-queue stall. | + +### 15.2 Unnamed, same job + +Timeouts and deadlines that are watchdogs in everything but name: + +| Mechanism | Location | Bounds | +|-----------|----------|--------| +| RX-latch payload deadline | all three custom radio paths: SX126x `patch 0003` ("Bound the lifetime of the RX-busy latch"), `lr11xx_lora.c`, `lr20xx_lora.c` (`header_seen_at_ms` + `*_max_payload_ms()`) | A `HEADER_VALID` whose packet never completes would pin the TX gate closed and silently mute the node — continuous RX has no symbol timer. Released at 255-byte airtime +25% +100 ms. See [5.2.1](#521-rx-busy-gate-tx-during-rx-prevention). | +| Stuck-DIO1 counter | `lr11xx_lora.c` and `lr20xx_lora.c` | 5 empty DIO1 cycles → hardware reset. Counting rather than timing; on the LR11xx it complements the wedge watchdog rather than replacing it. | +| CAD timeout | `Dispatcher::checkSend()` | 4 s (~20 retry attempts) → `ERR_EVENT_CAD_TIMEOUT` + `recoverRxState()`, rather than falling through to TX. See [5.2.2](#522-cad-timeout-recovery). | +| Chip-side TX timeout | SX126x `SetTx` deadline (`patch 0003`, "Scale the chip-side Tx timeout from airtime instead of a fixed 10 s"; airtime +25% +500 ms, floored at 10 s, clamped 262143 ms); LR2021 `TIMEOUT` IRQ handler | The chip stops the transmission when this fires, so a fixed value is a truncation, not a safeguard — at SF12/BW62.5 the old flat 10 s cut every packet from 76 bytes up. | +| Serial partial-frame resync | `SerialCompanionTransport.c` (`FRAME_PARTIAL_TIMEOUT_MS`) | 2 s. Parser-level only — deliberately **not** a session or idle timeout; an idle-but-connected companion sits in `RX_IDLE` indefinitely. | +| TCP send timeout | `LinuxTCPTransport.c` | Native sim only. A peer that can't accept a frame in the window is wedged → close it, rather than hang the whole queue. | +| Bounded RXTO wait | `patches/zephyr/0010-uarte-pm-suspend-bounded-rxto-wait.patch` | `uarte_pm_suspend()` busy-waits for RXTO with no timeout upstream. Landing in the STOPRX race with bytes in flight spins forever on the main thread and wedges the entire mesh (observed: RAK3401 1W repeater on 1.16.6, CLI answering only `-> busy`). Backstop for the GPS UART PM path in [7.3](#73-gps-adaptersgps). | diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 8dcdb80..8bba877 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -1,350 +1,350 @@ -# Repeater CLI Commands - -All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the mesh (non-zero `sender_timestamp`) cannot access USB-only commands. - -> The **Room Server** role shares this CLI — the common commands (radio, region, password, advert, gps, etc.) plus `setperm` / `get acl` all apply. - -**Sources:** -- `helpers/CommonCLI.cpp` — common commands shared by all roles -- `app/RepeaterMesh.cpp` — repeater-specific commands (`setperm`, `get acl`, `region`, `discover.neighbors`) -- `app/RepeaterRegionCLI.cpp` / `app/RoomServerRegionCLI.cpp` — the `region` sub-CLI -- `app/RepeaterUplink.cpp` — `get`/`set uplink.*` (ESP32 uplink builds only) -- `app/RoomServerMesh.cpp` — room-server-specific commands (`room.post`) - -> **Commands are case-sensitive**, matching Arduino MeshCore. Nothing is lower-cased before matching. - -> **Request-tag prefix.** If a command is longer than 4 characters and its **third** character is `|` -> (e.g. `a7|reboot`), the first three characters are stripped before dispatch and echoed back at the -> start of the reply. This is how the phone app correlates replies with requests. It means a command -> whose third character is a literal `|` cannot be sent as-is. - ---- - -## System - -| Command | Description | -|---------|-------------| -| `ver` | Firmware version and build date | -| `board` | Board manufacturer name | -| `reboot` | Reboot immediately | -| `start dfu` | nRF52: reboot into the UF2 bootloader for drag-and-drop update. ESP32-S3: reboot into the ROM download mode so esptool can reach the chip | -| `start ota` | ESP32: start WiFi AP + HTTP OTA server. nRF52: reboot into BLE OTA DFU mode | -| `stop ota` | Stop WiFi OTA server (ESP32 only) | -| `clkreboot` | Set clock to a fixed reference time (15 May 2024 8:50pm UTC) then reboot | -| `powersaving` | Not implemented | - ---- - -## Clock - -| Command | Description | -|---------|-------------| -| `clock` | Display current UTC time | -| `clock sync` | Sync clock from the sender's timestamp (only advances, cannot go backwards). Arms the 7-day mesh-time-sync suppression window. | -| `time ` | Set RTC to a specific Unix timestamp (cannot go backwards). Arms the 7-day mesh-time-sync suppression window. | - ---- - -## Advertisement - -| Command | Description | -|---------|-------------| -| `advert` | Send a flood-routed self-advertisement (1500 ms delay) | -| `advert.zerohop` | Send a 0-hop (direct only) self-advertisement | - ---- - -## Neighbors - -| Command | Description | -|---------|-------------| -| `neighbors` | Display current neighbor list | -| `neighbor.remove ` | Remove a neighbor entry by its public key. A prefix is accepted — the hex is truncated to at most 32 bytes and matched at whatever length you give. **Repeater only in effect:** `RoomServerMesh` does not override `removeNeighbor`, so on a room server this replies `OK` and does nothing. | -| `discover.neighbors` | *(repeater only)* Broadcast a node discovery request to find nearby nodes. Takes no arguments — anything after it replies `Err - discover.neighbors has no options`. Not implemented on room servers. | - ---- - -## Security & Access Control - -| Command | Description | -|---------|-------------| -| `password ` | Set the admin password (**max 15 characters**) | -| `setperm ` | Set ACL permissions for a node (app format: 2-char hex perms first) | -| `setperm ` | Set ACL permissions for a node (Arduino format: pubkey first, decimal perms) | -| `get acl` | *(USB only)* List all ACL entries with permissions and public keys | - -> **Password length:** admin and guest passwords are capped at **15 characters** (16-byte storage incl. NUL; same limit as Arduino MeshCore). The login-send path silently truncates anything longer, so a password >15 chars will never authenticate. Applies to `set guest.password` as well. - -> **`allow.read.only` is room-server only.** `RoomServerMesh.cpp` is its sole consumer; `RepeaterMesh.cpp` never reads it, so on a repeater the setting silently did nothing. The CLI now only exposes it on `CONFIG_ZEPHCORE_ROLE_ROOM_SERVER` builds — a deliberate divergence from Arduino MeshCore, whose shared CommonCLI offers the knob on every role. The pref itself is unchanged: it stays byte 114 of the on-flash prefs layout, identical to Arduino's, so existing prefs files are unaffected. - -> **Guest access differs by role, matching Arduino MeshCore.** On a **repeater**, an empty `guest.password` (the default) means *open* guest access — a blank submitted password logs in as `PERM_ACL_GUEST`, which cannot run CLI commands or read the access list, so it gets login plus status/telemetry only. On a **room server**, an empty `guest.password` *disables* guest login, so a room is never accidentally left open; to run an open room use `set allow.read.only on`, which grants read-only (`PERM_ACL_GUEST`), not post rights. Set a non-empty `guest.password` to require one on either role. - ---- - -## Room Server - -| Command | Description | -|---------|-------------| -| `room.post ` | Post a message to the shared room as the server itself (system post). Pushed to clients like any other post. | - ---- - -## Region Filtering - -Regions control which flood packets the repeater forwards. The region tree is hierarchical; the wildcard `*` region is the root. - -| Command | Description | -|---------|-------------| -| `region` | Export the current region map (indented text tree) | -| `region load` | Enter interactive region load mode. Paste indented region lines; send a blank line to commit. Any unindented command (e.g. `reboot`) aborts the load without committing, and then runs | -| `region save` | Save the current region map to persistent storage | -| `region def [...]` | Cursor-walk bulk region builder — define a hierarchy in one line (see below) | -| `region put []` | Create a region; default parent is the wildcard root. Flood is **allowed** by default (use `region denyf` to deny) | -| `region remove ` | Remove a region (must have no children) | -| `region get ` | Show a region's parent and flood-allow flag | -| `region home []` | Get (no arg) or set the home region | -| `region default [\|]` | Get (no arg), set, or clear (``) the default flood scope. Originated floods (self-adverts, etc.) are scoped with this region's TransportKey. Auto-creates the region if it doesn't exist and persists immediately | -| `region allowf ` | Allow flood packets in a region (clears deny-flood flag) | -| `region denyf ` | Deny flood packets in a region (sets deny-flood flag) | -| `region list allowed` | List all regions that allow floods | -| `region list denied` | List all regions that deny floods | - -**Region load format:** one region per line, indented with spaces to indicate depth. Append `F` after the name to mark flood-allowed (otherwise flood is denied by default). - -**`region def` format:** space-separated tokens; a cursor starts at `*`. Each token is `name` (create child of cursor, advance cursor to it) or `name|jump` / `name,jump` (create child of cursor, then move cursor to the existing region `jump`). Does **not** auto-save — follow with `region save`. Reply is the updated region tree. Example — branched tree: `region def west pnw or pdx|pnw wa sw-wa`. Example — flat list: `region def west|* pnw|* or|* pdx|*`. - ---- - -## Statistics & Logging - -| Command | Description | -|---------|-------------| -| `clear stats` | Reset all statistics counters | -| `stats-core` | *(USB only)* Display core mesh statistics | -| `stats-radio` | *(USB only)* Display radio statistics | -| `stats-packets` | *(USB only)* Display packet statistics | -| `log start` | Enable packet logging to file | -| `log stop` | Disable packet logging | -| `log erase` | Erase the log file | -| `log` | *(USB only)* Dump the full log file to USB serial | -| `erase` | *(USB only)* Factory reset: erase the entire LittleFS volume, the BLE-bond NVS, and external QSPI flash, then reboot | - -> **`erase` is a true factory reset.** It flattens `lfs_partition` (identity, prefs, ACL, -> region map, logs), `storage_partition` (BLE bonds) and `qspi_storage_partition` where -> present — not just the files under `/lfs/repeater/`. The node comes back with a new -> identity and default prefs. Erasing the volume rather than unlinking files is what makes -> it able to recover a volume another firmware has written into: on nRF52840 the Adafruit -> core's filesystem (used by Arduino MeshCore and Meshtastic) sits at 0xED000, inside our -> `lfs_partition`, and its format scribbles the top 7 blocks of our volume. Switching -> between Arduino-core firmware and ZephCore on nRF52840 needs an erase in **both** -> directions — `tools/formatter` or a full chip erase. - ---- - -## GPS - -| Command | Description | -|---------|-------------| -| `gps` | Show GPS status (`on` or `off`) | -| `gps on` | Enable GPS module | -| `gps off` | Disable GPS module | -| `gps setloc` | Update stored latitude/longitude from current GPS fix | -| `gps advert` | Show current location advertising policy | -| `gps advert none` | Do not include location in advertisements | -| `gps advert share` | Include live GPS location in advertisements | -| `gps advert prefs` | Include stored lat/lon from prefs in advertisements | -| `set gps duty ` | GPS duty interval (standby seconds between fixes). `0` = always-on (continuous; streams fresh fixes, can download a full almanac). Floor 10s, cap 604800 (1 week). Persists to flash, applied live. | -| `set gps duty default` | Reset GPS duty to the role default (repeater/room 48h, companion 300s) | -| `set gps diag <0\|1\|on\|off>` | Arm GPS module-configuration diagnostics (see below). Not persisted — clears on reboot | - -**GPS configuration diagnostics.** At boot the firmware configures the GNSS module — constellations, AssistNow/EASY, minimum elevation, fix rate — and on modules driven over raw NMEA those commands are sent **blind**: nothing reads the module's reply, so a silently rejected configuration is indistinguishable from a working one. These two commands make that visible. - -``` -set gps diag 1 # arm it -gps off # power-cycle the module... -gps on # ...which re-runs configuration and records the result -get gps diag # read it back -``` - -Sample reply: - -``` -> diag=on cfg=uart age=910s rx=120 mod=URANUS5 sent=12/336B sys=G3/R4/E0/B3/?0 -``` - -- `rx=` NMEA sentences the driver has parsed. **Check this first** — it is the only field that cannot be misread. Non-zero means the module is alive, at the right baud, and talking, so anything still wrong is signal or antenna. Zero means nothing is arriving at all, and no antenna work will help -- `cfg=` which path ran — `uart` (raw PMTK+PCAS+UBX), `api` (driver GNSS API), `blind` (neither available), or `never-run` -- `mod=` module identification, from a CASIC `$GPTXT` version reply or a u-blox `$PUBX` poll response, or `no-reply`. Only an explicit software-version token is accepted as an identity — TXT sentences also carry warnings, and a warning reported as an identity is worse than no answer -- `sent=` commands/bytes written to the module (UART path), or `sys_ret=`/`rate_ret=` return codes (API path) -- `sys=` tracked satellites per constellation from GSV talker IDs: **G**PS / GLONASS (**R**) / Galileo (**E**) / **B**eiDou / other. A constellation that stops reporting for 30 s decays to zero rather than showing a stale count - -`sys=` totalling more than `sats=` in `get gps` is expected, not a discrepancy: GSV counts satellites **tracked**, GGA counts satellites **used in the fix solution**. - -**`rx=` first, then `mod=`.** `rx=` is the only field that cannot be misread: non-zero means the module is alive, at the right baud and talking, so anything still wrong is signal or antenna; zero means nothing is arriving at all. `mod=` then tells you whether the module *heard* us — everything on this transport is written blind, so a module that hears nothing looks exactly like one that hears everything and ignores it. `mod=no-reply` with `rx=` climbing means the receive direction works but our transmit does not reach it: wiring or pin assignment, not configuration. - -**`sent=` proves transmission, not acceptance.** Only `sys=` shows what the module actually did. A module still running its factory or previously saved configuration reports `G` non-zero with the rest at `0`. Note `B0` is expected on u-blox M8 (BeiDou is deliberately disabled — only three major constellations can run concurrently), and `?0` is normal outside Japan (QZSS is regional). - -The generic-NMEA path sends three protocols — PMTK (MediaTek), PCAS (CASIC: Quectel L76K/L76KB, Air530Z) and UBX (u-blox) — because a WisBlock-style GPS slot can hold any of them and each family ignores what it does not understand. Related build option: `CONFIG_ZEPHCORE_GPS_NAV_MODE` sets the CASIC navigation dynamic model (`$PCAS11`), defaulting to stationary for repeaters and room servers and automotive otherwise. It is worth setting because that model is stored *in the module* and survives reflashing the host — a slot module that previously lived in another device can arrive stuck in an airborne model that quietly degrades fixes on a fixed site. - -Caveats: the `sys=` tally needs `CONFIG_ZEPHCORE_GPS_SAT_DIAG` (default on for repeaters, off for companions to save RAM) — the reply says so when built without it. Only the raw-UART path is re-run on `gps on`; boards with a real GNSS driver (Air530Z, LC76G) keep reporting their boot-time result, because that path goes through `modem_chat_run_script()`, which is safe only at boot. On those boards `E0` is also expected — the Air530Z driver supports GPS/GLONASS/BeiDou but not Galileo, and the firmware falls back automatically. - ---- - -## Sensor Settings - -| Command | Description | -|---------|-------------| -| `sensor list []` | List custom sensor settings (paginated at 134 chars) | -| `sensor get ` | Get a custom sensor setting value by key | -| `sensor set ` | Set a custom sensor setting value | - ---- - -## Radio (Temporary Override) - -| Command | Description | -|---------|-------------| -| `tempradio ,,,,` | Apply temporary radio parameters; automatically reverts after `timeout_mins`. Constraints: freq 150–2500 MHz, bw 7–500 kHz, sf 5–12, cr 5–8. Saved prefs are never mutated — concurrent `set` commands and reboots both restore the real saved values. | - ---- - -## Repeater Uplink (ESP32 + `CONFIG_ZEPHCORE_REPEATER_UPLINK`) - -These commands configure observer-style WiFi+MQTT packet reporting from repeater role. -All `set uplink.*` changes are saved immediately and only applied after reboot. - -| Command | Description | -|---------|-------------| -| `get uplink.status` | Uplink runtime state: enabled flag, WiFi state, MQTT state, reboot-required flag | -| `get uplink.enable` | Uplink enable flag (`on`/`off`) | -| `get uplink.wifi.ssid` | Configured WiFi SSID | -| `get uplink.mqtt.host` | Configured MQTT broker host | -| `get uplink.mqtt.port` | Configured MQTT broker port | -| `get uplink.mqtt.tls` | MQTT TLS mode (`0`/`1`) | -| `get uplink.mqtt.user` | Configured MQTT username | -| `get uplink.mqtt.iata` | Configured IATA/site code used in MQTT topic | -| `set uplink.enable ` | Enable or disable repeater uplink *(reboot required)* | -| `set uplink.wifi.ssid ` | Set WiFi SSID *(reboot required)* | -| `set uplink.wifi.psk ` | Set WiFi password *(reboot required)* | -| `set uplink.mqtt.host ` | Set MQTT host *(reboot required)* | -| `set uplink.mqtt.port ` | Set MQTT port 1–65535 *(reboot required)* | -| `set uplink.mqtt.tls <0\|1>` | Set MQTT TLS mode *(reboot required)* | -| `set uplink.mqtt.user ` | Set MQTT username *(reboot required)* | -| `set uplink.mqtt.password ` | Set MQTT password *(reboot required)* | -| `set uplink.mqtt.iata ` | Set MQTT site code *(reboot required)* | - ---- - -## `get` — Read Configuration - -| Command | Returns | -|---------|---------| -| `get name` | Node name | -| `get role` | Firmware role: `repeater` or `room_server` (companion builds report `companion`) | -| `get repeat` | Forwarding enabled: `on` or `off` | -| `get radio` | Radio params as `freq,bw,sf,cr` — the same comma-separated form `set radio` takes, so a reply can be edited and sent straight back | -| `get freq` | Frequency in MHz | -| `get freqerr` | Carrier frequency error measured on received packets: `mean N Hz, min A, max B, K pkts`. **LR2021 only** — other radios answer `not available`. Purely diagnostic; nothing acts on it. **The mean only approximates *this* node's reference error once it is averaged over many different peers** — their individual errors cancel, ours does not — so read `K` and the min/max spread before believing it: a tight spread over a handful of packets is one chatty neighbour, not a population. Small values are the expected answer and mean there is nothing to do; LoRa tolerates carrier error up to roughly a quarter of the bandwidth before sensitivity suffers, so at BW 62.5 kHz a few hundred Hz is noise. If it is kHz-scale the correction is board-dependent: XTAL parts have `SetXoscCpTrim`, but **TCXO parts have no chip-side trim at all** (DS §6.11.4: "If a TCXO is configured, this command has no effect"), leaving only a software offset to the programmed frequency. Values beyond ±200 kHz are discarded by the driver and warn once — the field is decoded from three `GetLoraPacketStatus` bytes that DS rev 2.1 does not document, so implausible readings are evidence the field is not real on that firmware rather than a genuine measurement. Reset by `clear stats`. | -| `get tx` | TX power in dBm | -| `get lat` | Stored latitude | -| `get lon` | Stored longitude | -| `get dutycycle` | Duty cycle as percentage (e.g. "50.0%") | -| `get af` | Raw airtime factor value | -| `get txdelay` | Adaptive TX delay status: contention estimate and flood delay factor | -| `get rxdelay` | *(deprecated)* Always returns "adaptive (rxdelay deprecated)" | -| `get direct.txdelay` | *(deprecated)* Always returns "adaptive (direct.txdelay deprecated)" | -| `get backoff.multiplier` | Per-dupe reactive backoff multiplier | -| `get flood.max` | Max flood retransmit hops | -| `get flood.max.unscoped` | Max retransmit hops for un-scoped floods | -| `get flood.max.advert` | Max retransmit hops for ADVERT floods | -| `get flood.advert.interval` | Flood advertisement interval in hours | -| `get advert.interval` | Local advertisement interval in minutes | -| `get allow.read.only` | *(room server only)* Whether read-only clients are allowed | -| `get guest.password` | Guest access password | -| `get owner.info` | Owner/contact info (pipes `\|` display as newlines) | -| `get int.thresh` | Interference threshold | -| `get leds` | LED master switch: `on` or `off` | -| `get buzzer` | *(room server only)* Buzzer/vibration mode as ` ()`: `0 (silent)`, `1 (sound+vib)`, `2 (vibrate)`, `3 (sound)`. Compiled out on repeater builds (`#ifndef ZEPHCORE_REPEATER`) — a repeater answers `unknown config: buzzer`. | -| `get agc.reset.interval` | Removed — replies `Removed - Automatic AGC reset is on`. Periodic AGC recalibration was deleted (it reset the noise floor to its unseeded sentinel on every fire). Use `set rxduty` to cut RX current. | -| `get multi.acks` | Extra ACK transmit count (`0` or `1`) | -| `get path.hash.mode` | Path hashing algorithm: `0`, `1`, or `2` | -| `get loop.detect` | Loop detection level: `off`, `minimal`, `moderate`, or `strict` | -| `get radio.rxgain` | RX gain boost: `on` or `off` | -| `get radio.fem.rxgain` | External FEM's LNA in the RX path: `on` (through the LNA) or `off` (bypassed). Default `on` | -| `get rxduty` | RX duty cycle mode: `0` or `1` | -| `get display.rotate` | Panel 180-degree rotation: `0` or `1`. Reports the **live panel state**, not the stored byte — the two differ only when a rotation was refused, which is the case worth seeing. Boards whose panel cannot rotate reply `unsupported (panel cannot rotate)` | -| `get input.rotate` | Joystick/D-pad axis swap: `0` or `1` | -| `get gps duty` | Now-effective GPS duty interval in seconds (`always on (0)` when continuous) | -| `get gps diag` | What the last GPS module-configuration attempt did — which path ran, bytes sent, and tracked satellites per constellation. See **GPS configuration diagnostics** in the GPS section for the field reference | -| `get meshtimesync` | Mesh time-sync state + live dry-run: on/off, eligible voter count, votes for/against, consensus skew and radius, would-be verdict (`ok`/`in-band`/`step±N`/`abstain (reason)`/`hold (reason)`; a recent clock set — manual or GPS — shows as `hold (suppressed)`, and a backward step a forward-only role would refuse is annotated `(skipped: forward-only)`), step counters, suppression countdown, and a per-sender evidence table (`prefix hops count skew E`, `E` = counted toward the verdict above). Entries that count print first, so a size-capped reply never hides the ones that explain the summary; if the table doesn't fully fit, a trailing `+N more` shows how many were left out. Sensing runs even while off, so this works as a dry-run before enabling. Over remote admin the reply is truncated to the packet size (summary always fits); the full table needs the USB CLI. | -| `get probe.interval` | Seconds between periodic radio measurements (noise-floor sample + CAD probe). 0 = CAD probing off | -| `get dc.restarts` | Duty-cycle re-arm counter — RxTimeout re-arms **plus** parked-RX watchdog recoveries, sharing one total. **Read it as a rate: divide by uptime.** A bare count is not interpretable, and the two sources it merges cost very differently. An RxTimeout re-arm is ~7 ms of deaf time (the `Calibrate(ALL)` gap in the driver's `restart_rx`) after which the chip returns to duty cycle immediately — packets, not power. A watchdog recovery means the chip sat parked in *full RX* for one to two watchdog periods (`2·(preamble+8)` symbols, floored at 250 ms) — power, not packets, since parked RX still receives. The counter cannot tell you which, so read the worst case. **Measured normal: ~250/hr on a high site at SF8/BW 62.5** (one every ~14 s), where the worst case — every event a park — costs about 3.5% of the duty cycle's savings. Nothing to act on below roughly **2000/hr**; above that the parked-RX share starts eating a meaningful fraction of the saving and it becomes worth splitting the counter to find out. A high rate means the preamble detector is tripping without a decodable packet following, which on an elevated site is usually distant marginal traffic rather than interference — cross-check `get cad.stats`, whose adaptive detPeak offset rises independently in a genuinely busy RF environment. Reset by `clear stats`. | -| `get cad` | Always `on` — ZephCore performs CAD/LBT unconditionally and has no enable knob. Kept as a boolean reply for Arduino MeshCore app compatibility; the real status lives in `get cad.stats`. | -| `get cad.stats` | Adaptive-CAD status: header (`a` auto on/off, `o` operating detPeak offset, `pk` absolute peak with family base, `sp` noise-floor RSSI burst quality as `mean-spread-dB/zero-spread-%` (plus `(burst-count rN/bN/aN)` on the local USB console, omitted over the air to protect the 161 B reply budget, where `r` is completed RSSI reads, `b` reads the chip refused as busy, and `a` bursts abandoned because of one — on a healthy radio `b`/`a` stay at 0, and a large `a` against a near-zero burst count is the signature of a sampler being refused rather than one losing the odd read) — a non-zero mean proves the 8 reads are independent however high the share climbs; only mean `0.0` with a high share indicts the sampler. See `ADAPTIVE_CAD.md`. `bc` busy cap), then a 3-rung window around the operating offset (`*` marks it) with probe/busy/fp/tp counts and false-positive rate — the three levels the knee controller reads. Probing runs even while `cad.auto` is off (dry-run), so this is the observation tool for picking a site-appropriate detPeak. See `ADAPTIVE_CAD.md`. Not available on SX127x boards (no hardware CAD). | -| `get extra.sf` | LR2021 side detectors: the extra spreading factors currently received alongside `sf`, comma-separated (bare, no `> ` prefix), or `No extra SF configured`. Reflects the saved prefs, not what the chip accepted — if the set became invalid after an `sf`/`bw` change it is reported here but was refused at boot (a `WRN` line says so). | -| `get adc.multiplier` | Battery voltage ADC calibration multiplier | -| `get bootloader.ver` | Bootloader version string | -| `get public.key` | Node's public key as hex. **Not** USB-only — it is answerable over remote admin, matching Arduino MeshCore. A public key is broadcast in every advert, so there is nothing to gate. | -| `get prv.key` | *(USB only)* Node's private key as hex — the 128-char expanded form, the same one `set prv.key` takes | - ---- - -## `set` — Write Configuration - -Changes are persisted immediately unless noted. Some require a reboot. - -| Command | Constraints | Description | -|---------|-------------|-------------| -| `set name ` | No `[ ] \ : , ? *` | Set node name | -| `set repeat ` | | Enable or disable packet forwarding | -| `set radio ,,,` | freq 150–2500, bw 7–500, sf 5–12, cr 5–8 | **Comma-separated**, not space-separated — spaces parse as a single argument and the command is rejected. Set radio params *(reboot required)* | -| `set freq ` | 150–2500 *(USB only)* | Set frequency alone *(reboot required)* | -| `set tx ` | −9 to board max (default 30) | Set TX power | -| `set lat ` | | Set stored latitude | -| `set lon ` | | Set stored longitude | -| `set dutycycle ` | 1–100 | Set duty cycle percentage (converted to airtime factor internally) | -| `set af ` | float | Set raw airtime factor directly | -| `set txdelay ` | | Accepted for prefs compatibility — **ignored** (txdelay is adaptive) | -| `set rxdelay ` | | Accepted for prefs compatibility — **ignored** (rxdelay is adaptive) | -| `set direct.txdelay ` | | Accepted for prefs compatibility — **ignored** (direct.txdelay is adaptive) | -| `set backoff.multiplier ` | 0.0–2.0 | Per-dupe reactive backoff multiplier (0 = disable reactive backoff) | -| `set flood.max ` | 0–64 | Maximum flood retransmit hops | -| `set flood.max.unscoped ` | 0–64 | Hop limit for un-scoped floods only (default 64 = same as flood.max); scoped/transport floods still use flood.max | -| `set flood.max.advert ` | 0–64 | Hop limit for ADVERT floods only (default 8); curbs advert churn independent of flood.max | -| `set flood.advert.interval ` | `0` (off) or 3–168 | How often the repeater floods its own advertisement. `0` disables periodic flood adverts. | -| `set advert.interval ` | `0` (off) or min–240 | How often the repeater sends local (zero-hop) advertisements. `0` — the default — disables them. Stored halved (the pref holds minutes/2), so odd values round down. | -| `set allow.read.only ` | | *(room server only)* Allow or deny read-only client connections | -| `set guest.password ` | | Set guest access password | -| `set owner.info ` | Use `\|` for newlines | Owner/contact information | -| `set int.thresh ` | | Interference detection threshold | -| `set buzzer <0\|1\|2\|3>` | or `off` / `on` / `vibrate` / `sound` | *(room server only)* `0`/`off` silent, `1`/`on` sound + vibration, `2`/`vibrate` vibration only, `3`/`sound` sound only. Modes 2 and 3 need a vibration motor; without one the node replies `Error: no vibration motor on this board - use 0 or 1`. Applied live and persisted. Compiled out on repeater builds. | -| `set leds ` | default **on** | Master switch for every LED on the node, applied live and persisted: heartbeat, unread-message and LoRa TX-activity LEDs, plus the message and shutdown flashes. Works on every role, including headless repeaters where the TX LED is the only one that ever lights. Does **not** cover the display backlight, which is a separate UI brightness setting. | -| `set agc.reset.interval ` | Accepted, ignored | Removed — replies `Removed - Automatic AGC reset is on`. The prefs byte is still read and written so the on-flash layout stays byte-exact, but nothing acts on it. | -| `set multi.acks <0\|1>` | | Enable extra ACK transmits | -| `set path.hash.mode ` | 0, 1, or 2 | Path hashing algorithm | -| `set loop.detect ` | `off`, `minimal`, `moderate`, `strict` | Loop detection sensitivity | -| `set radio.rxgain <0\|1\|on\|off>` | | RX gain boost, applied live. Replies `Error: unsupported` on radios without RX boost (SX127x); the pref is still saved. | -| `set radio.fem.rxgain <0\|1\|on\|off>` | default **1** | Routes receive through the external FEM's LNA (`1`) or around it via the FEM's bypass path (`0`), applied live. Sensitivity for battery life — `0` costs roughly 17 dB and saves the LNA's supply current. Transmit, and the driver's idle/sleep gating of the FEM, are unaffected either way. Supported only where the FEM's receive path is software-selectable and that select line is wired to the radio node as `lna-bypass-gpios` — today the three KCT8103L boards, `heltec_t096`, `heltec_wireless_tracker_v2` and `heltec_wifi_lora32_v43`. Every other board reports `Error: unsupported`: `heltec_wifi_lora32_v4`'s GC1109 has no receive-path select (its CPS is don't-care in RX, same as MeshCore); `station_g2`, `gat562_30s`, `ikoka_nano_30dbm` and `promicro_sx1262` have only the DIO2/TXEN/RXEN transmit-receive switch; `rak3401_1watt`'s SKY66122 is enabled by a standalone always-on regulator outside the radio node; and non-SX126x radios (LR1110, LR2021, SX127x) never implement it. The pref is still saved when unsupported. **Do not expect the FEM's chip-enable to be the knob** — deasserting `antenna-enable-gpios` in RX shuts the part down and takes the through path with it (~69 dB measured on a V4.3), which is what 1.17.2 did before this moved to `lna-bypass-gpios`. | -| `set rxduty <0\|1\|on\|off>` | | RX duty cycle mode *(reboot required)*. Window timing auto-sized per SF/BW/preamble from the SX126x datasheet constraints (boot log line `rxduty:` shows the result). Zero-loss guarantee assumes senders on preamble-32 firmware (current MeshCore at SF≤8); legacy preamble-16 senders are only caught ~50% worst-phase — keep off until the local mesh has converted. Presets with 16-symbol preambles (SF≥9) fall back to continuous RX automatically. | -| `set display.rotate <0\|1\|on\|off>` | default **0** | Rotate the display 180 degrees, for cases and upgrade kits that mount the screen upside down (e.g. the Meshnology N37E for the Wio Tracker L1). Applied live — the driver flips the panel's `SEGMENT_MAP` and `COM_OUTPUT_SCAN`, two bytes on the wire, and the next frame comes out rotated with no redraw and no per-frame cost. **Only full-height SSD1306 and SH1106 panels support this** (`rak4631`, `gat562_30s`, `heltec_wifi_lora32_v4`/`v43`, `lilygo_t3s3`, `station_g2`, `wio_tracker_l1`); every other panel replies `Error: this panel cannot rotate` and the pref is **not** saved, so a stored value can never disagree with what the screen shows. `lilygo_timpulse_plus` is excluded despite being an SSD1306: its 64x32 glass is windowed into a 128x64 controller at `page-offset 4`, and the COM-scan reversal flips the controller's whole range, which would move the image off the bonded region. E-paper (SSD16xx) is excluded on purpose: its driver accepts a 180-degree orientation but implements it by flipping the RAM entry mode only, which reverses byte order without reversing bit order inside each byte — it would report success and render wrong. | -| `set input.rotate <0\|1\|on\|off>` | default **0** | Swap the joystick/D-pad axes — up/down and left/right — to match an upside-down mount. Applied live. Deliberately **separate** from `display.rotate`: a case can flip the screen without moving the stick, and boards whose panel cannot rotate can still need the axis swap. Works on every board with directional input, in both the joystick UI and the button UI (where it swaps page-prev/page-next). Non-directional keys, tap codes and long-press gestures are unaffected. | -| `set adc.multiplier ` | `0` (use board default) or 100–30000 | Battery voltage ADC calibration multiplier, set directly. Rejects non-numeric input, NaN/inf and negatives. | -| `set adc.multiplier target ` | 3000–4400 mV | Calibrate against a voltage you measured with a multimeter: rescales the current multiplier so the ADC reads ``. Replies with the old and new multiplier plus the before/after reading. `Error: no ADC reading on this board` if the board has no battery ADC. | -| `set adc.multiplier full` | board must be fully charged | Same calibration, but against the board's battery-curve 100% point instead of a hand-measured value. Only meaningful on a full charge. | -| `set meshtimesync ` | default **off** | Mesh time sync: automatically correct this node's clock from the consensus of Ed25519-signed advert timestamps heard on the mesh. Steps at most ±1 h per step, one step per 6 h; abstains without a quorum (default 6) of tenured agreeing senders; never overrides a clock set in the last 7 days, whether from GPS (re-armed on every fix) or a manual set. See `MESHTIMESYNC.md`. | -| `set cad.auto ` | default **on** | Adaptive CAD: let the staircase controller move the operating detPeak offset based on probe statistics. On by default (repeaters and companions); at the default 15 s probe interval it responds to environment change in ~1–2 h. Turn off to observe/hand-tune via `get cad.stats` + `set cad.offset`. See `ADAPTIVE_CAD.md`. | -| `set cad.offset ` | −8 to 12, default 0 | Operating detPeak offset from the chip family's per-SF base (SX126x: SF+13; LR11xx/LR20xx: 56–68 table). Negative = more sensitive LBT (catches weaker signals, risks false busy), positive = less sensitive. Wide range so dense hilltops / quiet valleys can settle far from base. The per-family absolute clamp in the driver (SX126x 15–40, LR 48–90) is a firmware guardrail against a CAD that never/always fires, not a chip limit (`cadDetPeak` is a full `uint8_t`). Applied live; the auto staircase may move it later if `cad.auto` is on. | -| `set probe.interval ` | 0 (off) or 10–255, default **15** | Seconds between periodic radio measurements. ONE reading serves both: the noise-floor RSSI sample (median of 8) and the CAD calibration probe, which consumes that same reading rather than measuring separately — so this is also the noise-floor sampling rate, and it sets how often an idle repeater wakes. Default 15 s → ~1–2 h CAD staircase response; the floor EMA warms up over 8 samples (~2 min) and its unguarded bypass runs every 16th (~4 min). Longer = fewer wakes, slower to track a changing RF environment. 0 disables CAD probing entirely (also freezes auto adaptation); the floor sampler then falls back to its build-time default. | -| `set cad.busycap ` | 0 (off) or 10–90, default **25** | Airtime-protection cap: the max percentage of TX attempts the node will let CAD defer before the staircase backs off to a less sensitive detPeak — counting **real** traffic, not just false positives. On a congested hilltop most busy verdicts are distant traffic won on capture anyway, so deferring for all of it starves the node's own airtime. Self-targeting: a quiet node's busy rate never reaches the cap. Shown as `bc:` in `get cad.stats`. 0 disables the cap (pure knee-seeking). | -| `set cad.reset` | | Clear the accumulated per-level CAD probe statistics (RAM only; also cleared automatically on any radio parameter change). | -| `set extra.sf [sf] [sf]` | up to 3 SFs, `0`/`off` clears | **LR2021 only** (`Error: unsupported` elsewhere) — LoRa *side detectors*: demodulate up to three extra spreading factors concurrently with `sf`, on the same bandwidth, so one repeater can serve several SF communities. Which SF a packet arrived on is a chip-side readout, not a guess. Chip constraints, enforced in the driver and reported as `Error: unsupported or invalid extra SF config`: every extra SF must be **greater** than `sf`, all distinct, highest−lowest ≤ 4, and at BW ≥ 500 kHz at most 2 (only 1 when `sf` ≥ 10). **Receive only, and the bridge it creates is one-way.** TX always uses the single configured `sf`, and all detectors share one bandwidth, so this is multi-SF, not multi-channel. A node with `sf 7` + `extra.sf 8` hears SF8 traffic and *does* forward it — but the forward goes out at SF7, so traffic moves SF8 -> SF7 only and nothing comes back. An SF8 node's direct messages are delivered while its ACKs never arrive, so it retries to its limit every time; adverts and one-way flood traffic propagate fine. Because every extra SF must be **greater** than `sf`, the main SF is always the lowest in the set and TX always uses it — so the bridge direction is fixed at high-SF-in / low-SF-out and **cannot be reversed**. Two nodes back to back both point the same way; there is no configuration that carries SF7 -> SF8. Treat it as a collector for slower-SF stragglers, not as a link between two SF islands. Applied live and restored on every RX entry. **Interaction with CAD:** the chip's SF constraint for CAD is the inverse of the one for RX, so the driver switches side detectors off for each LBT CAD and back on when RX re-arms — two extra SPI commands per TX, no configuration required. Persisted; a set that no longer fits after an `sf`/`bw` change is refused at boot and logged. | -| `set prv.key ` | **128-char hex** (64-byte expanded Ed25519 key) | Replace private key; derive new identity *(reboot to apply)*. The length must be exact — `fromHex` rejects anything else with `Error, bad key`. `get prv.key` returns the same 128-char form. Not USB-gated. | - ---- - -## Notes - -- **USB-only commands** — `get acl`, `get prv.key`, `set freq`, `log` (dump), `stats-packets`, `stats-radio`, `stats-core`, `erase` — are blocked when the command arrives over the mesh (remote admin). These are the only ones gated on `sender_timestamp == 0`; `get public.key` and `set prv.key` are **not** among them. -- **Adaptive contention window** — `txdelay`, `rxdelay`, and `direct.txdelay` are accepted and stored for Arduino prefs compatibility but have no effect. Use `get txdelay` to inspect the current adaptive state and `set backoff.multiplier` to tune reactive backoff. -- **Region load mode** — after `region load`, every line received is parsed as a region entry until a blank line is sent. The loaded map is only committed to the live region tree at that point; use `region save` to persist it. Region rows must be indented by at least one space, so an **unindented line that starts with a name character aborts the mode and is executed as a normal command** — the escape hatch if a `region load` is started by accident or a client dies mid-transfer. An abort discards the partial map, leaving the live region tree untouched. The exported wildcard header line `*` stays unindented and is ignored as before, so pasting the output of `region` still loads cleanly. -- **Reboot delay** — `start dfu`, `start ota` (nRF52 BLE-DFU path only), `reboot`, `clkreboot` and `erase` defer the reset by **2 seconds** so the reply can be transmitted over LoRa first. On a companion the handler then keeps deferring in 20 ms steps until the BLE/USB transport has drained, up to a further 3 s grace. On ESP32 `start ota` starts a WiFi AP + HTTP server and does **not** reboot. +# Repeater CLI Commands + +All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the mesh (non-zero `sender_timestamp`) cannot access USB-only commands. + +> The **Room Server** role shares this CLI — the common commands (radio, region, password, advert, gps, etc.) plus `setperm` / `get acl` all apply. + +**Sources:** +- `helpers/CommonCLI.cpp` — common commands shared by all roles +- `app/RepeaterMesh.cpp` — repeater-specific commands (`setperm`, `get acl`, `region`, `discover.neighbors`) +- `app/RepeaterRegionCLI.cpp` / `app/RoomServerRegionCLI.cpp` — the `region` sub-CLI +- `app/RepeaterUplink.cpp` — `get`/`set uplink.*` (ESP32 uplink builds only) +- `app/RoomServerMesh.cpp` — room-server-specific commands (`room.post`) + +> **Commands are case-sensitive**, matching Arduino MeshCore. Nothing is lower-cased before matching. + +> **Request-tag prefix.** If a command is longer than 4 characters and its **third** character is `|` +> (e.g. `a7|reboot`), the first three characters are stripped before dispatch and echoed back at the +> start of the reply. This is how the phone app correlates replies with requests. It means a command +> whose third character is a literal `|` cannot be sent as-is. + +--- + +## System + +| Command | Description | +|---------|-------------| +| `ver` | Firmware version and build date | +| `board` | Board manufacturer name | +| `reboot` | Reboot immediately | +| `start dfu` | nRF52: reboot into the UF2 bootloader for drag-and-drop update. ESP32-S3: reboot into the ROM download mode so esptool can reach the chip | +| `start ota` | ESP32: start WiFi AP + HTTP OTA server. nRF52: reboot into BLE OTA DFU mode | +| `stop ota` | Stop WiFi OTA server (ESP32 only) | +| `clkreboot` | Set clock to a fixed reference time (15 May 2024 8:50pm UTC) then reboot | +| `powersaving` | Not implemented | + +--- + +## Clock + +| Command | Description | +|---------|-------------| +| `clock` | Display current UTC time | +| `clock sync` | Sync clock from the sender's timestamp (only advances, cannot go backwards). Arms the 7-day mesh-time-sync suppression window. | +| `time ` | Set RTC to a specific Unix timestamp (cannot go backwards). Arms the 7-day mesh-time-sync suppression window. | + +--- + +## Advertisement + +| Command | Description | +|---------|-------------| +| `advert` | Send a flood-routed self-advertisement (1500 ms delay) | +| `advert.zerohop` | Send a 0-hop (direct only) self-advertisement | + +--- + +## Neighbors + +| Command | Description | +|---------|-------------| +| `neighbors` | Display current neighbor list | +| `neighbor.remove ` | Remove a neighbor entry by its public key. A prefix is accepted — the hex is truncated to at most 32 bytes and matched at whatever length you give. **Repeater only in effect:** `RoomServerMesh` does not override `removeNeighbor`, so on a room server this replies `OK` and does nothing. | +| `discover.neighbors` | *(repeater only)* Broadcast a node discovery request to find nearby nodes. Takes no arguments — anything after it replies `Err - discover.neighbors has no options`. Not implemented on room servers. | + +--- + +## Security & Access Control + +| Command | Description | +|---------|-------------| +| `password ` | Set the admin password (**max 15 characters**) | +| `setperm ` | Set ACL permissions for a node (app format: 2-char hex perms first) | +| `setperm ` | Set ACL permissions for a node (Arduino format: pubkey first, decimal perms) | +| `get acl` | *(USB only)* List all ACL entries with permissions and public keys | + +> **Password length:** admin and guest passwords are capped at **15 characters** (16-byte storage incl. NUL; same limit as Arduino MeshCore). The login-send path silently truncates anything longer, so a password >15 chars will never authenticate. Applies to `set guest.password` as well. + +> **`allow.read.only` is room-server only.** `RoomServerMesh.cpp` is its sole consumer; `RepeaterMesh.cpp` never reads it, so on a repeater the setting silently did nothing. The CLI now only exposes it on `CONFIG_ZEPHCORE_ROLE_ROOM_SERVER` builds — a deliberate divergence from Arduino MeshCore, whose shared CommonCLI offers the knob on every role. The pref itself is unchanged: it stays byte 114 of the on-flash prefs layout, identical to Arduino's, so existing prefs files are unaffected. + +> **Guest access differs by role, matching Arduino MeshCore.** On a **repeater**, an empty `guest.password` (the default) means *open* guest access — a blank submitted password logs in as `PERM_ACL_GUEST`, which cannot run CLI commands or read the access list, so it gets login plus status/telemetry only. On a **room server**, an empty `guest.password` *disables* guest login, so a room is never accidentally left open; to run an open room use `set allow.read.only on`, which grants read-only (`PERM_ACL_GUEST`), not post rights. Set a non-empty `guest.password` to require one on either role. + +--- + +## Room Server + +| Command | Description | +|---------|-------------| +| `room.post ` | Post a message to the shared room as the server itself (system post). Pushed to clients like any other post. | + +--- + +## Region Filtering + +Regions control which flood packets the repeater forwards. The region tree is hierarchical; the wildcard `*` region is the root. + +| Command | Description | +|---------|-------------| +| `region` | Export the current region map (indented text tree) | +| `region load` | Enter interactive region load mode. Paste indented region lines; send a blank line to commit. Any unindented command (e.g. `reboot`) aborts the load without committing, and then runs | +| `region save` | Save the current region map to persistent storage | +| `region def [...]` | Cursor-walk bulk region builder — define a hierarchy in one line (see below) | +| `region put []` | Create a region; default parent is the wildcard root. Flood is **allowed** by default (use `region denyf` to deny) | +| `region remove ` | Remove a region (must have no children) | +| `region get ` | Show a region's parent and flood-allow flag | +| `region home []` | Get (no arg) or set the home region | +| `region default [\|]` | Get (no arg), set, or clear (``) the default flood scope. Originated floods (self-adverts, etc.) are scoped with this region's TransportKey. Auto-creates the region if it doesn't exist and persists immediately | +| `region allowf ` | Allow flood packets in a region (clears deny-flood flag) | +| `region denyf ` | Deny flood packets in a region (sets deny-flood flag) | +| `region list allowed` | List all regions that allow floods | +| `region list denied` | List all regions that deny floods | + +**Region load format:** one region per line, indented with spaces to indicate depth. Append `F` after the name to mark flood-allowed (otherwise flood is denied by default). + +**`region def` format:** space-separated tokens; a cursor starts at `*`. Each token is `name` (create child of cursor, advance cursor to it) or `name|jump` / `name,jump` (create child of cursor, then move cursor to the existing region `jump`). Does **not** auto-save — follow with `region save`. Reply is the updated region tree. Example — branched tree: `region def west pnw or pdx|pnw wa sw-wa`. Example — flat list: `region def west|* pnw|* or|* pdx|*`. + +--- + +## Statistics & Logging + +| Command | Description | +|---------|-------------| +| `clear stats` | Reset all statistics counters | +| `stats-core` | *(USB only)* Display core mesh statistics | +| `stats-radio` | *(USB only)* Display radio statistics | +| `stats-packets` | *(USB only)* Display packet statistics | +| `log start` | Enable packet logging to file | +| `log stop` | Disable packet logging | +| `log erase` | Erase the log file | +| `log` | *(USB only)* Dump the full log file to USB serial | +| `erase` | *(USB only)* Factory reset: erase the entire LittleFS volume, the BLE-bond NVS, and external QSPI flash, then reboot | + +> **`erase` is a true factory reset.** It flattens `lfs_partition` (identity, prefs, ACL, +> region map, logs), `storage_partition` (BLE bonds) and `qspi_storage_partition` where +> present — not just the files under `/lfs/repeater/`. The node comes back with a new +> identity and default prefs. Erasing the volume rather than unlinking files is what makes +> it able to recover a volume another firmware has written into: on nRF52840 the Adafruit +> core's filesystem (used by Arduino MeshCore and Meshtastic) sits at 0xED000, inside our +> `lfs_partition`, and its format scribbles the top 7 blocks of our volume. Switching +> between Arduino-core firmware and ZephCore on nRF52840 needs an erase in **both** +> directions — `tools/formatter` or a full chip erase. + +--- + +## GPS + +| Command | Description | +|---------|-------------| +| `gps` | Show GPS status (`on` or `off`) | +| `gps on` | Enable GPS module | +| `gps off` | Disable GPS module | +| `gps setloc` | Update stored latitude/longitude from current GPS fix | +| `gps advert` | Show current location advertising policy | +| `gps advert none` | Do not include location in advertisements | +| `gps advert share` | Include live GPS location in advertisements | +| `gps advert prefs` | Include stored lat/lon from prefs in advertisements | +| `set gps duty ` | GPS duty interval (standby seconds between fixes). `0` = always-on (continuous; streams fresh fixes, can download a full almanac). Floor 10s, cap 604800 (1 week). Persists to flash, applied live. | +| `set gps duty default` | Reset GPS duty to the role default (repeater/room 48h, companion 300s) | +| `set gps diag <0\|1\|on\|off>` | Arm GPS module-configuration diagnostics (see below). Not persisted — clears on reboot | + +**GPS configuration diagnostics.** At boot the firmware configures the GNSS module — constellations, AssistNow/EASY, minimum elevation, fix rate — and on modules driven over raw NMEA those commands are sent **blind**: nothing reads the module's reply, so a silently rejected configuration is indistinguishable from a working one. These two commands make that visible. + +``` +set gps diag 1 # arm it +gps off # power-cycle the module... +gps on # ...which re-runs configuration and records the result +get gps diag # read it back +``` + +Sample reply: + +``` +> diag=on cfg=uart age=910s rx=120 mod=URANUS5 sent=12/336B sys=G3/R4/E0/B3/?0 +``` + +- `rx=` NMEA sentences the driver has parsed. **Check this first** — it is the only field that cannot be misread. Non-zero means the module is alive, at the right baud, and talking, so anything still wrong is signal or antenna. Zero means nothing is arriving at all, and no antenna work will help +- `cfg=` which path ran — `uart` (raw PMTK+PCAS+UBX), `api` (driver GNSS API), `blind` (neither available), or `never-run` +- `mod=` module identification, from a CASIC `$GPTXT` version reply or a u-blox `$PUBX` poll response, or `no-reply`. Only an explicit software-version token is accepted as an identity — TXT sentences also carry warnings, and a warning reported as an identity is worse than no answer +- `sent=` commands/bytes written to the module (UART path), or `sys_ret=`/`rate_ret=` return codes (API path) +- `sys=` tracked satellites per constellation from GSV talker IDs: **G**PS / GLONASS (**R**) / Galileo (**E**) / **B**eiDou / other. A constellation that stops reporting for 30 s decays to zero rather than showing a stale count + +`sys=` totalling more than `sats=` in `get gps` is expected, not a discrepancy: GSV counts satellites **tracked**, GGA counts satellites **used in the fix solution**. + +**`rx=` first, then `mod=`.** `rx=` is the only field that cannot be misread: non-zero means the module is alive, at the right baud and talking, so anything still wrong is signal or antenna; zero means nothing is arriving at all. `mod=` then tells you whether the module *heard* us — everything on this transport is written blind, so a module that hears nothing looks exactly like one that hears everything and ignores it. `mod=no-reply` with `rx=` climbing means the receive direction works but our transmit does not reach it: wiring or pin assignment, not configuration. + +**`sent=` proves transmission, not acceptance.** Only `sys=` shows what the module actually did. A module still running its factory or previously saved configuration reports `G` non-zero with the rest at `0`. Note `B0` is expected on u-blox M8 (BeiDou is deliberately disabled — only three major constellations can run concurrently), and `?0` is normal outside Japan (QZSS is regional). + +The generic-NMEA path sends three protocols — PMTK (MediaTek), PCAS (CASIC: Quectel L76K/L76KB, Air530Z) and UBX (u-blox) — because a WisBlock-style GPS slot can hold any of them and each family ignores what it does not understand. Related build option: `CONFIG_ZEPHCORE_GPS_NAV_MODE` sets the CASIC navigation dynamic model (`$PCAS11`), defaulting to stationary for repeaters and room servers and automotive otherwise. It is worth setting because that model is stored *in the module* and survives reflashing the host — a slot module that previously lived in another device can arrive stuck in an airborne model that quietly degrades fixes on a fixed site. + +Caveats: the `sys=` tally needs `CONFIG_ZEPHCORE_GPS_SAT_DIAG` (default on for repeaters, off for companions to save RAM) — the reply says so when built without it. Only the raw-UART path is re-run on `gps on`; boards with a real GNSS driver (Air530Z, LC76G) keep reporting their boot-time result, because that path goes through `modem_chat_run_script()`, which is safe only at boot. On those boards `E0` is also expected — the Air530Z driver supports GPS/GLONASS/BeiDou but not Galileo, and the firmware falls back automatically. + +--- + +## Sensor Settings + +| Command | Description | +|---------|-------------| +| `sensor list []` | List custom sensor settings (paginated at 134 chars) | +| `sensor get ` | Get a custom sensor setting value by key | +| `sensor set ` | Set a custom sensor setting value | + +--- + +## Radio (Temporary Override) + +| Command | Description | +|---------|-------------| +| `tempradio ,,,,` | Apply temporary radio parameters; automatically reverts after `timeout_mins`. Constraints: freq 150–2500 MHz, bw 7–500 kHz, sf 5–12, cr 5–8. Saved prefs are never mutated — concurrent `set` commands and reboots both restore the real saved values. | + +--- + +## Repeater Uplink (ESP32 + `CONFIG_ZEPHCORE_REPEATER_UPLINK`) + +These commands configure observer-style WiFi+MQTT packet reporting from repeater role. +All `set uplink.*` changes are saved immediately and only applied after reboot. + +| Command | Description | +|---------|-------------| +| `get uplink.status` | Uplink runtime state: enabled flag, WiFi state, MQTT state, reboot-required flag | +| `get uplink.enable` | Uplink enable flag (`on`/`off`) | +| `get uplink.wifi.ssid` | Configured WiFi SSID | +| `get uplink.mqtt.host` | Configured MQTT broker host | +| `get uplink.mqtt.port` | Configured MQTT broker port | +| `get uplink.mqtt.tls` | MQTT TLS mode (`0`/`1`) | +| `get uplink.mqtt.user` | Configured MQTT username | +| `get uplink.mqtt.iata` | Configured IATA/site code used in MQTT topic | +| `set uplink.enable ` | Enable or disable repeater uplink *(reboot required)* | +| `set uplink.wifi.ssid ` | Set WiFi SSID *(reboot required)* | +| `set uplink.wifi.psk ` | Set WiFi password *(reboot required)* | +| `set uplink.mqtt.host ` | Set MQTT host *(reboot required)* | +| `set uplink.mqtt.port ` | Set MQTT port 1–65535 *(reboot required)* | +| `set uplink.mqtt.tls <0\|1>` | Set MQTT TLS mode *(reboot required)* | +| `set uplink.mqtt.user ` | Set MQTT username *(reboot required)* | +| `set uplink.mqtt.password ` | Set MQTT password *(reboot required)* | +| `set uplink.mqtt.iata ` | Set MQTT site code *(reboot required)* | + +--- + +## `get` — Read Configuration + +| Command | Returns | +|---------|---------| +| `get name` | Node name | +| `get role` | Firmware role: `repeater` or `room_server` (companion builds report `companion`) | +| `get repeat` | Forwarding enabled: `on` or `off` | +| `get radio` | Radio params as `freq,bw,sf,cr` — the same comma-separated form `set radio` takes, so a reply can be edited and sent straight back | +| `get freq` | Frequency in MHz | +| `get freqerr` | Carrier frequency error measured on received packets: `mean N Hz, min A, max B, K pkts`. **LR2021 only** — other radios answer `not available`. Purely diagnostic; nothing acts on it. **The mean only approximates *this* node's reference error once it is averaged over many different peers** — their individual errors cancel, ours does not — so read `K` and the min/max spread before believing it: a tight spread over a handful of packets is one chatty neighbour, not a population. Small values are the expected answer and mean there is nothing to do; LoRa tolerates carrier error up to roughly a quarter of the bandwidth before sensitivity suffers, so at BW 62.5 kHz a few hundred Hz is noise. If it is kHz-scale the correction is board-dependent: XTAL parts have `SetXoscCpTrim`, but **TCXO parts have no chip-side trim at all** (DS §6.11.4: "If a TCXO is configured, this command has no effect"), leaving only a software offset to the programmed frequency. Values beyond ±200 kHz are discarded by the driver and warn once — the field is decoded from three `GetLoraPacketStatus` bytes that DS rev 2.1 does not document, so implausible readings are evidence the field is not real on that firmware rather than a genuine measurement. Reset by `clear stats`. | +| `get tx` | TX power in dBm | +| `get lat` | Stored latitude | +| `get lon` | Stored longitude | +| `get dutycycle` | Duty cycle as percentage (e.g. "50.0%") | +| `get af` | Raw airtime factor value | +| `get txdelay` | Adaptive TX delay status: contention estimate and flood delay factor | +| `get rxdelay` | *(deprecated)* Always returns "adaptive (rxdelay deprecated)" | +| `get direct.txdelay` | *(deprecated)* Always returns "adaptive (direct.txdelay deprecated)" | +| `get backoff.multiplier` | Per-dupe reactive backoff multiplier | +| `get flood.max` | Max flood retransmit hops | +| `get flood.max.unscoped` | Max retransmit hops for un-scoped floods | +| `get flood.max.advert` | Max retransmit hops for ADVERT floods | +| `get flood.advert.interval` | Flood advertisement interval in hours | +| `get advert.interval` | Local advertisement interval in minutes | +| `get allow.read.only` | *(room server only)* Whether read-only clients are allowed | +| `get guest.password` | Guest access password | +| `get owner.info` | Owner/contact info (pipes `\|` display as newlines) | +| `get int.thresh` | Interference threshold | +| `get leds` | LED master switch: `on` or `off` | +| `get buzzer` | *(room server only)* Buzzer/vibration mode as ` ()`: `0 (silent)`, `1 (sound+vib)`, `2 (vibrate)`, `3 (sound)`. Compiled out on repeater builds (`#ifndef ZEPHCORE_REPEATER`) — a repeater answers `unknown config: buzzer`. | +| `get agc.reset.interval` | Removed — replies `Removed - Automatic AGC reset is on`. Periodic AGC recalibration was deleted (it reset the noise floor to its unseeded sentinel on every fire). Use `set rxduty` to cut RX current. | +| `get multi.acks` | Extra ACK transmit count (`0` or `1`) | +| `get path.hash.mode` | Path hashing algorithm: `0`, `1`, or `2` | +| `get loop.detect` | Loop detection level: `off`, `minimal`, `moderate`, or `strict` | +| `get radio.rxgain` | RX gain boost: `on` or `off` | +| `get radio.fem.rxgain` | External FEM's LNA in the RX path: `on` (through the LNA) or `off` (bypassed). Default `on` | +| `get rxduty` | RX duty cycle mode: `0` or `1` | +| `get display.rotate` | Panel 180-degree rotation: `0` or `1`. Reports the **live panel state**, not the stored byte — the two differ only when a rotation was refused, which is the case worth seeing. Boards whose panel cannot rotate reply `unsupported (panel cannot rotate)` | +| `get input.rotate` | Joystick/D-pad axis swap: `0` or `1` | +| `get gps duty` | Now-effective GPS duty interval in seconds (`always on (0)` when continuous) | +| `get gps diag` | What the last GPS module-configuration attempt did — which path ran, bytes sent, and tracked satellites per constellation. See **GPS configuration diagnostics** in the GPS section for the field reference | +| `get meshtimesync` | Mesh time-sync state + live dry-run: on/off, eligible voter count, votes for/against, consensus skew and radius, would-be verdict (`ok`/`in-band`/`step±N`/`abstain (reason)`/`hold (reason)`; a recent clock set — manual or GPS — shows as `hold (suppressed)`, and a backward step a forward-only role would refuse is annotated `(skipped: forward-only)`), step counters, suppression countdown, and a per-sender evidence table (`prefix hops count skew E`, `E` = counted toward the verdict above). Entries that count print first, so a size-capped reply never hides the ones that explain the summary; if the table doesn't fully fit, a trailing `+N more` shows how many were left out. Sensing runs even while off, so this works as a dry-run before enabling. Over remote admin the reply is truncated to the packet size (summary always fits); the full table needs the USB CLI. | +| `get probe.interval` | Seconds between periodic radio measurements (noise-floor sample + CAD probe). 0 = CAD probing off | +| `get dc.restarts` | Duty-cycle re-arm counter — RxTimeout re-arms **plus** parked-RX watchdog recoveries, sharing one total. **Read it as a rate: divide by uptime.** A bare count is not interpretable, and the two sources it merges cost very differently. An RxTimeout re-arm is ~7 ms of deaf time (the `Calibrate(ALL)` gap in the driver's `restart_rx`) after which the chip returns to duty cycle immediately — packets, not power. A watchdog recovery means the chip sat parked in *full RX* for one to two watchdog periods (`2·(preamble+8)` symbols, floored at 250 ms) — power, not packets, since parked RX still receives. The counter cannot tell you which, so read the worst case. **Measured normal: ~250/hr on a high site at SF8/BW 62.5** (one every ~14 s), where the worst case — every event a park — costs about 3.5% of the duty cycle's savings. Nothing to act on below roughly **2000/hr**; above that the parked-RX share starts eating a meaningful fraction of the saving and it becomes worth splitting the counter to find out. A high rate means the preamble detector is tripping without a decodable packet following, which on an elevated site is usually distant marginal traffic rather than interference — cross-check `get cad.stats`, whose adaptive detPeak offset rises independently in a genuinely busy RF environment. Reset by `clear stats`. | +| `get cad` | Always `on` — ZephCore performs CAD/LBT unconditionally and has no enable knob. Kept as a boolean reply for Arduino MeshCore app compatibility; the real status lives in `get cad.stats`. | +| `get cad.stats` | Adaptive-CAD status: header (`a` auto on/off, `o` operating detPeak offset, `pk` absolute peak with family base, `sp` noise-floor RSSI burst quality as `mean-spread-dB/zero-spread-%` (plus `(burst-count rN/bN/aN)` on the local USB console, omitted over the air to protect the 161 B reply budget, where `r` is completed RSSI reads, `b` reads the chip refused as busy, and `a` bursts abandoned because of one — on a healthy radio `b`/`a` stay at 0, and a large `a` against a near-zero burst count is the signature of a sampler being refused rather than one losing the odd read) — a non-zero mean proves the 8 reads are independent however high the share climbs; only mean `0.0` with a high share indicts the sampler. See `ADAPTIVE_CAD.md`. `bc` busy cap), then a 3-rung window around the operating offset (`*` marks it) with probe/busy/fp/tp counts and false-positive rate — the three levels the knee controller reads. Probing runs even while `cad.auto` is off (dry-run), so this is the observation tool for picking a site-appropriate detPeak. See `ADAPTIVE_CAD.md`. Not available on SX127x boards (no hardware CAD). | +| `get extra.sf` | LR2021 side detectors: the extra spreading factors currently received alongside `sf`, comma-separated (bare, no `> ` prefix), or `No extra SF configured`. Reflects the saved prefs, not what the chip accepted — if the set became invalid after an `sf`/`bw` change it is reported here but was refused at boot (a `WRN` line says so). | +| `get adc.multiplier` | Battery voltage ADC calibration multiplier | +| `get bootloader.ver` | Bootloader version string | +| `get public.key` | Node's public key as hex. **Not** USB-only — it is answerable over remote admin, matching Arduino MeshCore. A public key is broadcast in every advert, so there is nothing to gate. | +| `get prv.key` | *(USB only)* Node's private key as hex — the 128-char expanded form, the same one `set prv.key` takes | + +--- + +## `set` — Write Configuration + +Changes are persisted immediately unless noted. Some require a reboot. + +| Command | Constraints | Description | +|---------|-------------|-------------| +| `set name ` | No `[ ] \ : , ? *` | Set node name | +| `set repeat ` | | Enable or disable packet forwarding | +| `set radio ,,,` | freq 150–2500, bw 7–500, sf 5–12, cr 5–8 | **Comma-separated**, not space-separated — spaces parse as a single argument and the command is rejected. Set radio params *(reboot required)* | +| `set freq ` | 150–2500 *(USB only)* | Set frequency alone *(reboot required)* | +| `set tx ` | −9 to board max (default 30) | Set TX power | +| `set lat ` | | Set stored latitude | +| `set lon ` | | Set stored longitude | +| `set dutycycle ` | 1–100 | Set duty cycle percentage (converted to airtime factor internally) | +| `set af ` | float | Set raw airtime factor directly | +| `set txdelay ` | | Accepted for prefs compatibility — **ignored** (txdelay is adaptive) | +| `set rxdelay ` | | Accepted for prefs compatibility — **ignored** (rxdelay is adaptive) | +| `set direct.txdelay ` | | Accepted for prefs compatibility — **ignored** (direct.txdelay is adaptive) | +| `set backoff.multiplier ` | 0.0–2.0 | Per-dupe reactive backoff multiplier (0 = disable reactive backoff) | +| `set flood.max ` | 0–64 | Maximum flood retransmit hops | +| `set flood.max.unscoped ` | 0–64 | Hop limit for un-scoped floods only (default 64 = same as flood.max); scoped/transport floods still use flood.max | +| `set flood.max.advert ` | 0–64 | Hop limit for ADVERT floods only (default 8); curbs advert churn independent of flood.max | +| `set flood.advert.interval ` | `0` (off) or 3–168 | How often the repeater floods its own advertisement. `0` disables periodic flood adverts. | +| `set advert.interval ` | `0` (off) or min–240 | How often the repeater sends local (zero-hop) advertisements. `0` — the default — disables them. Stored halved (the pref holds minutes/2), so odd values round down. | +| `set allow.read.only ` | | *(room server only)* Allow or deny read-only client connections | +| `set guest.password ` | | Set guest access password | +| `set owner.info ` | Use `\|` for newlines | Owner/contact information | +| `set int.thresh ` | | Interference detection threshold | +| `set buzzer <0\|1\|2\|3>` | or `off` / `on` / `vibrate` / `sound` | *(room server only)* `0`/`off` silent, `1`/`on` sound + vibration, `2`/`vibrate` vibration only, `3`/`sound` sound only. Modes 2 and 3 need a vibration motor; without one the node replies `Error: no vibration motor on this board - use 0 or 1`. Applied live and persisted. Compiled out on repeater builds. | +| `set leds ` | default **on** | Master switch for every LED on the node, applied live and persisted: heartbeat, unread-message and LoRa TX-activity LEDs, plus the message and shutdown flashes. Works on every role, including headless repeaters where the TX LED is the only one that ever lights. Does **not** cover the display backlight, which is a separate UI brightness setting. | +| `set agc.reset.interval ` | Accepted, ignored | Removed — replies `Removed - Automatic AGC reset is on`. The prefs byte is still read and written so the on-flash layout stays byte-exact, but nothing acts on it. | +| `set multi.acks <0\|1>` | | Enable extra ACK transmits | +| `set path.hash.mode ` | 0, 1, or 2 | Path hashing algorithm | +| `set loop.detect ` | `off`, `minimal`, `moderate`, `strict` | Loop detection sensitivity | +| `set radio.rxgain <0\|1\|on\|off>` | | RX gain boost, applied live. Replies `Error: unsupported` on radios without RX boost (SX127x); the pref is still saved. | +| `set radio.fem.rxgain <0\|1\|on\|off>` | default **1** | Routes receive through the external FEM's LNA (`1`) or around it via the FEM's bypass path (`0`), applied live. Sensitivity for battery life — `0` costs roughly 17 dB and saves the LNA's supply current. Transmit, and the driver's idle/sleep gating of the FEM, are unaffected either way. Supported only where the FEM's receive path is software-selectable and that select line is wired to the radio node as `lna-bypass-gpios` — today the three KCT8103L boards, `heltec_t096`, `heltec_wireless_tracker_v2` and `heltec_wifi_lora32_v43`. Every other board reports `Error: unsupported`: `heltec_wifi_lora32_v4`'s GC1109 has no receive-path select (its CPS is don't-care in RX, same as MeshCore); `station_g2`, `gat562_30s`, `ikoka_nano_30dbm` and `promicro_sx1262` have only the DIO2/TXEN/RXEN transmit-receive switch; `rak3401_1watt`'s SKY66122 is enabled by a standalone always-on regulator outside the radio node; and non-SX126x radios (LR1110, LR2021, SX127x) never implement it. The pref is still saved when unsupported. **Do not expect the FEM's chip-enable to be the knob** — deasserting `antenna-enable-gpios` in RX shuts the part down and takes the through path with it (~69 dB measured on a V4.3), which is what 1.17.2 did before this moved to `lna-bypass-gpios`. | +| `set rxduty <0\|1\|on\|off>` | | RX duty cycle mode *(reboot required)*. Window timing auto-sized per SF/BW/preamble from the SX126x datasheet constraints (boot log line `rxduty:` shows the result). Zero-loss guarantee assumes senders on preamble-32 firmware (current MeshCore at SF≤8); legacy preamble-16 senders are only caught ~50% worst-phase — keep off until the local mesh has converted. Presets with 16-symbol preambles (SF≥9) fall back to continuous RX automatically. | +| `set display.rotate <0\|1\|on\|off>` | default **0** | Rotate the display 180 degrees, for cases and upgrade kits that mount the screen upside down (e.g. the Meshnology N37E for the Wio Tracker L1). Applied live — the driver flips the panel's `SEGMENT_MAP` and `COM_OUTPUT_SCAN`, two bytes on the wire, and the next frame comes out rotated with no redraw and no per-frame cost. **Only full-height SSD1306 and SH1106 panels support this** (`rak4631`, `gat562_30s`, `heltec_wifi_lora32_v4`/`v43`, `lilygo_t3s3`, `station_g2`, `wio_tracker_l1`); every other panel replies `Error: this panel cannot rotate` and the pref is **not** saved, so a stored value can never disagree with what the screen shows. `lilygo_timpulse_plus` is excluded despite being an SSD1306: its 64x32 glass is windowed into a 128x64 controller at `page-offset 4`, and the COM-scan reversal flips the controller's whole range, which would move the image off the bonded region. E-paper (SSD16xx) is excluded on purpose: its driver accepts a 180-degree orientation but implements it by flipping the RAM entry mode only, which reverses byte order without reversing bit order inside each byte — it would report success and render wrong. | +| `set input.rotate <0\|1\|on\|off>` | default **0** | Swap the joystick/D-pad axes — up/down and left/right — to match an upside-down mount. Applied live. Deliberately **separate** from `display.rotate`: a case can flip the screen without moving the stick, and boards whose panel cannot rotate can still need the axis swap. Works on every board with directional input, in both the joystick UI and the button UI (where it swaps page-prev/page-next). Non-directional keys, tap codes and long-press gestures are unaffected. | +| `set adc.multiplier ` | `0` (use board default) or 100–30000 | Battery voltage ADC calibration multiplier, set directly. Rejects non-numeric input, NaN/inf and negatives. | +| `set adc.multiplier target ` | 3000–4400 mV | Calibrate against a voltage you measured with a multimeter: rescales the current multiplier so the ADC reads ``. Replies with the old and new multiplier plus the before/after reading. `Error: no ADC reading on this board` if the board has no battery ADC. | +| `set adc.multiplier full` | board must be fully charged | Same calibration, but against the board's battery-curve 100% point instead of a hand-measured value. Only meaningful on a full charge. | +| `set meshtimesync ` | default **off** | Mesh time sync: automatically correct this node's clock from the consensus of Ed25519-signed advert timestamps heard on the mesh. Steps at most ±1 h per step, one step per 6 h; abstains without a quorum (default 6) of tenured agreeing senders; never overrides a clock set in the last 7 days, whether from GPS (re-armed on every fix) or a manual set. See `MESHTIMESYNC.md`. | +| `set cad.auto ` | default **on** | Adaptive CAD: let the staircase controller move the operating detPeak offset based on probe statistics. On by default (repeaters and companions); at the default 15 s probe interval it responds to environment change in ~1–2 h. Turn off to observe/hand-tune via `get cad.stats` + `set cad.offset`. See `ADAPTIVE_CAD.md`. | +| `set cad.offset ` | −8 to 12, default 0 | Operating detPeak offset from the chip family's base for the current SF, bandwidth and CAD symbol count (Semtech LoRa Basics Modem reference tables; SX126x ~18–34, LR11xx ~50–85, LR20xx its own symbol-indexed table). Negative = more sensitive LBT (catches weaker signals, risks false busy), positive = less sensitive. Wide range so dense hilltops / quiet valleys can settle far from base. The per-family absolute clamp in the driver (SX126x 12–48, LR11xx 40–100, LR20xx 48–90) is a firmware guardrail against a CAD that never/always fires, not a chip limit (`cadDetPeak` is a full `uint8_t`); the driver reports it so the controller narrows this range to match rather than exploring offsets that collapse onto one peak. Applied live; the auto staircase may move it later if `cad.auto` is on. | +| `set probe.interval ` | 0 (off) or 10–255, default **15** | Seconds between periodic radio measurements. ONE reading serves both: the noise-floor RSSI sample (median of 8) and the CAD calibration probe, which consumes that same reading rather than measuring separately — so this is also the noise-floor sampling rate, and it sets how often an idle repeater wakes. Default 15 s → ~1–2 h CAD staircase response; the floor EMA warms up over 8 samples (~2 min) and its unguarded bypass runs every 16th (~4 min). Longer = fewer wakes, slower to track a changing RF environment. 0 disables CAD probing entirely (also freezes auto adaptation); the floor sampler then falls back to its build-time default. | +| `set cad.busycap ` | 0 (off) or 10–90, default **25** | Airtime-protection cap: the max percentage of TX attempts the node will let CAD defer before the staircase backs off to a less sensitive detPeak — counting **real** traffic, not just false positives. On a congested hilltop most busy verdicts are distant traffic won on capture anyway, so deferring for all of it starves the node's own airtime. Self-targeting: a quiet node's busy rate never reaches the cap. Shown as `bc:` in `get cad.stats`. 0 disables the cap (pure knee-seeking). | +| `set cad.reset` | | Clear the accumulated per-level CAD probe statistics (RAM only; also cleared automatically on any radio parameter change). | +| `set extra.sf [sf] [sf]` | up to 3 SFs, `0`/`off` clears | **LR2021 only** (`Error: unsupported` elsewhere) — LoRa *side detectors*: demodulate up to three extra spreading factors concurrently with `sf`, on the same bandwidth, so one repeater can serve several SF communities. Which SF a packet arrived on is a chip-side readout, not a guess. Chip constraints, enforced in the driver and reported as `Error: unsupported or invalid extra SF config`: every extra SF must be **greater** than `sf`, all distinct, highest−lowest ≤ 4, and at BW ≥ 500 kHz at most 2 (only 1 when `sf` ≥ 10). **Receive only, and the bridge it creates is one-way.** TX always uses the single configured `sf`, and all detectors share one bandwidth, so this is multi-SF, not multi-channel. A node with `sf 7` + `extra.sf 8` hears SF8 traffic and *does* forward it — but the forward goes out at SF7, so traffic moves SF8 -> SF7 only and nothing comes back. An SF8 node's direct messages are delivered while its ACKs never arrive, so it retries to its limit every time; adverts and one-way flood traffic propagate fine. Because every extra SF must be **greater** than `sf`, the main SF is always the lowest in the set and TX always uses it — so the bridge direction is fixed at high-SF-in / low-SF-out and **cannot be reversed**. Two nodes back to back both point the same way; there is no configuration that carries SF7 -> SF8. Treat it as a collector for slower-SF stragglers, not as a link between two SF islands. Applied live and restored on every RX entry. **Interaction with CAD:** the chip's SF constraint for CAD is the inverse of the one for RX, so the driver switches side detectors off for each LBT CAD and back on when RX re-arms — two extra SPI commands per TX, no configuration required. Persisted; a set that no longer fits after an `sf`/`bw` change is refused at boot and logged. | +| `set prv.key ` | **128-char hex** (64-byte expanded Ed25519 key) | Replace private key; derive new identity *(reboot to apply)*. The length must be exact — `fromHex` rejects anything else with `Error, bad key`. `get prv.key` returns the same 128-char form. Not USB-gated. | + +--- + +## Notes + +- **USB-only commands** — `get acl`, `get prv.key`, `set freq`, `log` (dump), `stats-packets`, `stats-radio`, `stats-core`, `erase` — are blocked when the command arrives over the mesh (remote admin). These are the only ones gated on `sender_timestamp == 0`; `get public.key` and `set prv.key` are **not** among them. +- **Adaptive contention window** — `txdelay`, `rxdelay`, and `direct.txdelay` are accepted and stored for Arduino prefs compatibility but have no effect. Use `get txdelay` to inspect the current adaptive state and `set backoff.multiplier` to tune reactive backoff. +- **Region load mode** — after `region load`, every line received is parsed as a region entry until a blank line is sent. The loaded map is only committed to the live region tree at that point; use `region save` to persist it. Region rows must be indented by at least one space, so an **unindented line that starts with a name character aborts the mode and is executed as a normal command** — the escape hatch if a `region load` is started by accident or a client dies mid-transfer. An abort discards the partial map, leaving the live region tree untouched. The exported wildcard header line `*` stays unindented and is ignored as before, so pasting the output of `region` still loads cleanly. +- **Reboot delay** — `start dfu`, `start ota` (nRF52 BLE-DFU path only), `reboot`, `clkreboot` and `erase` defer the reset by **2 seconds** so the reply can be transmitted over LoRa first. On a companion the handler then keeps deferring in 20 ms steps until the BLE/USB transport has drained, up to a further 3 s grace. On ESP32 `start ota` starts a WiFi AP + HTTP server and does **not** reboot. diff --git a/releasenotes/RELEASE_NOTES_1.17.4-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.4-zephcore.md index 1b21f91..3f2d9a4 100644 --- a/releasenotes/RELEASE_NOTES_1.17.4-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.4-zephcore.md @@ -1,107 +1,124 @@ -# ZephCore 1.17.4-zephcore - -Storage housekeeping. The repeater's `erase` command never actually erased anything, a node flashed -from another firmware could start out with somebody else's leftovers underneath it, and switching a -node between companion and repeater firmware quietly let the two share the same 128 KB. All three are -fixed, and the last one is now deliberate and loud rather than quiet. - -> [!IMPORTANT] -> **Read the role-switching section before you flash a different role onto an existing node.** A -> companion that gets repeater firmware — or the reverse — now erases itself on first boot. That is -> intentional, but it is new. Export your identity first if you want to keep it. - -> [!NOTE] -> A normal upgrade is unaffected. Repeater to repeater, or companion to companion, keeps your -> identity, settings, contacts and phone pairing exactly as before. - ---- - -## `erase` now erases - -On a repeater or room server, `erase` promised to format the entire filesystem. It did not. It deleted -the handful of files it had put in its own folder and cleared the phone pairings, and left everything -else exactly where it was. - -Most of the time nobody noticed, because on a healthy node those files *are* everything that matters. -It mattered when the node was not healthy — which is precisely when somebody reaches for `erase`. If -anything had written into the storage area from outside, deleting our own files could not undo it, and -the command reported success while the problem stayed. - -`erase` now wipes the storage area itself, the phone-pairing store, and external flash where a board has -it, then reboots. The node comes back with a new identity and default settings, exactly like a node out -of the box. - -> [!IMPORTANT] -> **This is a genuine factory reset now, and it takes the identity with it.** Anyone who had your node -> in their contacts will need to add it again, and an admin password, ACL and region map all go too. -> That was always what the command claimed to do; it is now what it does. - -The companion's `erase` already worked this way. Repeater, room server and observer share one -implementation with it now, so there is one behaviour to remember instead of two. - ---- - -## Switching a node between roles now wipes it - -Companion firmware and repeater firmware kept their files in separate folders, and until now each left -the other's alone. Flashing back and forth preserved both sets. - -That sounds generous and was not. The two roles share a single 128 KB storage area. A companion that has -collected a few hundred contacts and a full advert cache leaves noticeably less room for a repeater's -region map and access list, and a write that no longer fits simply fails. The node is not corrupted — -the two roles' files never sit on top of each other — it just runs out of space for reasons its owner -cannot see, because half of what is stored belongs to firmware that is not running. - -They are also two quite different kinds of node, and treating one machine as quietly holding both was -never worth the space it cost. - -From 1.17.4, each role checks on first boot whether the storage belongs to it, and formats everything if -not. So a repeater flashed onto a former companion starts empty, and a companion flashed onto a former -repeater starts empty. - -> [!IMPORTANT] -> **Export your identity before switching roles.** The node's identity, settings, contacts, channels, -> access list, region map and phone pairings all go. There is no undo and no warning prompt — the first -> boot on the new firmware has already done it by the time you see anything. - -> [!NOTE] -> **Repeater, room server and observer still share.** Those three keep their files in the same place and -> use the same settings layout, so moving between them keeps the node's identity and configuration. It -> is the companion that is now separate. - ---- - -## A node coming from other firmware starts clean - -Flashing ZephCore onto hardware that was running something else is a normal thing to do, and it used to -leave more behind than anyone expected. - -Nothing about installing firmware erases storage. Dragging a UF2 file writes the program and nothing -else, and each firmware only ever clears the piece of flash it believes is its own. On the nRF52840 -boards, Arduino MeshCore's storage sits inside the same region ZephCore uses, so the two overlap — and -whichever one boots first tidies up its own corner and leaves the rest of the other's files sitting -there. The result on one Seeed Solar Node was a repeater that came up looking perfectly healthy and -silently refused to forward anything, because a single setting deep inside its configuration had been -overwritten by bytes that belonged to a different firmware. - -Each role now checks on first boot whether the storage is its own and, if not, clears the whole lot — -the storage area, the phone-pairing store, and external flash — before writing anything. A node arriving -from another firmware, or from a factory-fresh chip, starts from a known state instead of an inherited -one. - -> [!IMPORTANT] -> **Moving between Arduino MeshCore and ZephCore still needs an erase in both directions.** ZephCore now -> cleans up on the way in, but it cannot clean up on the way out — going back to Arduino MeshCore leaves -> ZephCore's files inside the area Arduino will use. Run the formatter UF2, or a full chip erase, when -> you switch either way. This is not new advice; it is now written down. - ---- - -## Also in this release - -Nothing here changes how a node behaves. - -- **A wasted erase on the companion.** Running `erase` from the companion's USB console formatted the - storage, then formatted it a second time on the reboot that followed, because the marker saying "this - node has been set up" went out with everything else. The pairing-based factory reset never had this - problem. Both paths behave the same way now. +# ZephCore 1.17.4-zephcore + +Storage housekeeping, plus a listen-before-talk fix. The repeater's `erase` never actually erased, +a node flashed from another firmware could start out with somebody else's leftovers underneath it, +and switching a node between companion and repeater firmware quietly let the two share the same +128 KB. Separately, the channel-activity detector was using the wrong reference table on LR1110 +boards. + +> [!IMPORTANT] +> **Read the role-switching section before you flash a different role onto an existing node.** A +> companion that gets repeater firmware — or the reverse — now erases itself on first boot. That is +> intentional, but it is new. Export your identity first if you want to keep it. + +> [!NOTE] +> A normal upgrade is unaffected. Repeater to repeater, or companion to companion, keeps your +> identity, settings, contacts and phone pairing exactly as before. + +--- + +## `erase` now erases + +On a repeater or room server, `erase` promised to format the entire filesystem. It deleted the files +in its own folder and cleared the phone pairings, and left everything else where it was. + +That only mattered when a node was unhealthy — which is precisely when somebody reaches for `erase`. +If anything had written into the storage area from outside, deleting our own files could not undo it, +and the command reported success while the problem stayed. + +`erase` now wipes the storage area, the phone-pairing store, and external flash where a board has it, +then reboots. The node comes back exactly like one out of the box. + +> [!IMPORTANT] +> **This is a genuine factory reset now, and it takes the identity with it.** Anyone who had your node +> in their contacts will need to add it again, and the admin password, ACL and region map go too. + +The companion's `erase` already worked this way. Repeater, room server and observer now share its +implementation, so there is one behaviour to remember instead of two. + +--- + +## Switching a node between roles now wipes it + +Companion and repeater firmware keep their files in separate folders, and until now each left the +other's alone. Flashing back and forth preserved both sets. + +That sounds generous and was not. The two roles share a single 128 KB storage area, so a companion's +few hundred contacts and full advert cache leave noticeably less room for a repeater's region map and +access list — and a write that no longer fits simply fails. Nothing is corrupted; the node just runs +out of space for reasons its owner cannot see, because half of what is stored belongs to firmware that +is not running. + +From 1.17.4 each role checks on first boot whether the storage belongs to it, and formats everything +if not. + +> [!IMPORTANT] +> **Export your identity before switching roles.** Identity, settings, contacts, channels, access list, +> region map and phone pairings all go. There is no undo and no prompt — the first boot on the new +> firmware has already done it by the time you see anything. + +> [!NOTE] +> **Repeater, room server and observer still share.** Those three keep their files in the same place +> and use the same settings layout, so moving between them keeps identity and configuration. It is the +> companion that is now separate. + +--- + +## A node coming from other firmware starts clean + +Nothing about installing firmware erases storage. Dragging a UF2 writes the program and nothing else, +and each firmware only clears the piece of flash it believes is its own. On the nRF52840 boards, +Arduino MeshCore's storage sits inside the region ZephCore uses, so whichever boots first tidies its +own corner and leaves the rest. On one Seeed Solar Node that produced a repeater which came up looking +perfectly healthy and silently refused to forward anything, because a single setting deep inside its +configuration had been overwritten by another firmware's bytes. + +Each role now checks on first boot whether the storage is its own and, if not, clears the whole lot +before writing anything. + +> [!IMPORTANT] +> **Moving between Arduino MeshCore and ZephCore still needs an erase in both directions.** ZephCore +> cleans up on the way in but cannot clean up on the way out. Run the formatter UF2, or a full chip +> erase, when you switch either way. + +--- + +## Listen-before-talk was too cautious on LR1110 boards + +Before transmitting, a node listens for a LoRa signal already on the air. How faint a signal counts is +set by a per-chip threshold, and ZephCore tunes it automatically from what each node measures. + +The starting values for that tuning came from a reference table — and on the LR1110 it was the wrong +table, copied from a different Semtech chip and read at the wrong setting. It started roughly five +steps too insensitive, so those nodes spent weeks walking the threshold down and still hit the limit of +how far they were allowed to adjust. Two nodes sitting in one room made it visible: an SX1262 settled +one step from its starting point while the LR1110s next to it were pinned at the end of their range. + +The tables now come from Semtech's own reference code, and they take **bandwidth** into account, which +nothing did before. That barely moves the SX1262 — a count or two, and nothing at all at the default +preset — but on the LR1110 bandwidth is worth around twelve counts per doubling, which is most of the +error. The range each node may adjust within is wider, and the radio now tells the tuner where its own +limits are, so a node can no longer sit against a wall it cannot see. + +Affected boards: **T1000-E**, **ThinkNode M3** and **ThinkNode M9**. On SX1262 boards nothing changes +unless you run a 250 or 500 kHz bandwidth, where the old value was up to ten counts too sensitive. + +> [!NOTE] +> **Run `set cad.reset` after upgrading.** The tuning statistics your node collected are measured +> against the old starting point and are not comparable to the new one. Clearing them lets the tuner +> re-converge cleanly; left alone it blends two sets of readings. Everything else is automatic. + +> [!IMPORTANT] +> This is a first release of the corrected tables. They are verified against Semtech's reference and +> against on-air measurements from three nodes, but not yet across a season or a busy site. If a node +> starts deferring noticeably more or less than it used to, `get cad.stats` shows what it is measuring. + +--- + +## Also in this release + +Nothing here changes how a node behaves. + +- **A wasted erase on the companion.** Running `erase` from the companion's USB console formatted the + storage, then formatted it again on the reboot that followed, because the marker saying "this node + has been set up" went out with everything else. Both paths behave the same way now. diff --git a/zephcore/adapters/radio/LR1110Radio.cpp b/zephcore/adapters/radio/LR1110Radio.cpp index ebd633f..1fab63c 100644 --- a/zephcore/adapters/radio/LR1110Radio.cpp +++ b/zephcore/adapters/radio/LR1110Radio.cpp @@ -123,4 +123,20 @@ uint8_t LR1110Radio::hwCadBasePeak() return lr11xx_cad_base_peak(_dev); } +/* The detPeak range lr11xx_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. Same reasoning as LR2021Radio::hwCadPeakMin — + * and the same symptom was observed here on T1000-E companions, which sat at + * o:-8 with the driver's old floor of 48 already reached. */ +uint8_t LR1110Radio::hwCadPeakMin() +{ + return lr11xx_cad_peak_min(); +} + +uint8_t LR1110Radio::hwCadPeakMax() +{ + return lr11xx_cad_peak_max(); +} + } /* namespace mesh */ diff --git a/zephcore/adapters/radio/LR1110Radio.h b/zephcore/adapters/radio/LR1110Radio.h index 3031e8d..4a8ffb4 100644 --- a/zephcore/adapters/radio/LR1110Radio.h +++ b/zephcore/adapters/radio/LR1110Radio.h @@ -40,6 +40,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; }; } /* namespace mesh */ diff --git a/zephcore/adapters/radio/SX126xRadio.cpp b/zephcore/adapters/radio/SX126xRadio.cpp index ce26647..3117ec2 100644 --- a/zephcore/adapters/radio/SX126xRadio.cpp +++ b/zephcore/adapters/radio/SX126xRadio.cpp @@ -151,6 +151,20 @@ uint8_t SX126xRadio::hwCadBasePeak() return sx126x_cad_base_peak(_dev); } +/* The detPeak range sx126x_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. Same reasoning as LR2021Radio::hwCadPeakMin. */ +uint8_t SX126xRadio::hwCadPeakMin() +{ + return sx126x_cad_peak_min(); +} + +uint8_t SX126xRadio::hwCadPeakMax() +{ + return sx126x_cad_peak_max(); +} + uint32_t SX126xRadio::getDutyCycleTimeoutRestarts() const { return sx126x_get_dc_timeout_restarts(_dev); diff --git a/zephcore/adapters/radio/SX126xRadio.h b/zephcore/adapters/radio/SX126xRadio.h index 18d71ab..d05a140 100644 --- a/zephcore/adapters/radio/SX126xRadio.h +++ b/zephcore/adapters/radio/SX126xRadio.h @@ -43,6 +43,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; }; } /* namespace mesh */ diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c index a45add7..0acc4c4 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c @@ -1698,20 +1698,97 @@ int16_t lr11xx_get_chip_temp_c(const struct device *dev) /* ── Driver API: CAD ────────────────────────────────────────────────── */ -/* Recommended cad_detect_peak values per SF for 2-symbol CAD. - * From Semtech SX1261/62/68 / LR1110 reference (same silicon IP). */ -static uint8_t lr11xx_cad_detect_peak(uint8_t sf) +/* Recommended cad_detect_peak, from Semtech's own reference stack: + * LoRa Basics Modem v4.9.0, ral_lr11xx.c ral_lr11xx_get_lora_cad_det_peak(). + * + * Provenance matters here, because the table this replaces was wrong twice + * over. It was `{56,56,56,58,58,60,64,68}`, labelled "from SX1261/62/68 / + * LR1110 reference (same silicon IP)" — but that is byte-for-byte the *LR20xx* + * 2-symbol row (ral_lr20xx.c), i.e. the wrong chip family, sampled at the wrong + * symbol count. The SX126x scale is ~20-35 and shares nothing with this one. + * The error was worst at SF6/SF7, where it read 56 against Semtech's 52, and it + * is what drove field units eight rungs down to the offset rail: measured on + * two T1000-E companions at SF7/BW62.5, both pinned at o:-8 with a flat, clean + * FP curve, one of them also sitting on the driver's own peak clamp. + * + * Unlike the LR20xx, this family's detPeak is strongly bandwidth-dependent — + * at SF7 Semtech spans 52/64/77 across BW125/250/500, ~12 counts per octave, + * against ~1-3 per octave on the SX126x. A bandwidth-blind base table is + * therefore a much larger error here than it is there, which is precisely why + * the SX1262 in the same room settled at offset -1 while these walked to -8. + * + * Below BW125 Semtech returns RAL_STATUS_UNKNOWN_VALUE and offers nothing. We + * run BW62.5 by default, so that gap is our normal operating point. We reuse + * the BW125 row there rather than extrapolating the trend downward: the curve + * is empirical PER-test data with visible noise (SF9 breaks monotonicity in all + * three brackets), a one-octave extrapolation at SF7 would invent ~40, and we + * already run a closed-loop controller whose entire job is to find the local + * value. Reusing BW125 leaves the adaptive offset a short, well-centred walk + * instead of substituting a guess for a measurement we take anyway. */ +static uint8_t lr11xx_cad_detect_peak(uint8_t sf, uint16_t bw_khz, uint8_t symb_nb) { - switch (sf) { - case 5: case 6: return 56; - case 7: return 56; - case 8: return 58; - case 9: return 58; - case 10: return 60; - case 11: return 64; - case 12: return 68; - default: return 60; + /* SF5 SF6 SF7 SF8 SF9 SF10 SF11 SF12 */ + static const uint8_t bw500[8] = { 65, 70, 77, 85, 78, 80, 79, 82 }; + static const uint8_t bw250[8] = { 60, 61, 64, 72, 63, 71, 73, 75 }; + static const uint8_t bw125[8] = { 56, 52, 52, 58, 58, 62, 66, 68 }; + const uint8_t *row; + int peak; + + if (sf < 5 || sf > 12) { + sf = 9; /* mid-range fallback */ } + + if (bw_khz >= 500) { + row = bw500; + } else if (bw_khz >= 250) { + row = bw250; + } else { + /* BW125 and everything narrower — see the note above. */ + row = bw125; + } + peak = (int)row[sf - 5]; + + /* More symbols means more looks at the same correlation, so the same + * detection quality is reached at a lower threshold. Semtech applies + * this correction after the table lookup; we run 4 symbols everywhere + * (LORA_CAD_SYMB_4 in LoRaRadioBase::buildModemConfig), so it always + * bites, and omitting it was one further count of the SF7 error. */ + if (symb_nb >= 8) { + peak -= 2; + } else if (symb_nb >= 4) { + peak -= 1; + } + + return (uint8_t)peak; +} + +/* The detPeak range this driver will actually program. Exported through + * lr11xx_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. That is not hypothetical — it is the + * documented failure mode on the LR2021 (see LR2021Radio::hwCadPeakMin), and + * the old 48 floor here reproduced it on the LR1110 at SF7. + * + * The bounds are chosen so the clamp never truncates the offset window itself: + * the lowest base this table yields is 51 (SF6/SF7, BW<=125, 4 symbols), and + * CAD_LEVEL_MIN is -8, so anything above 43 would silently collapse the bottom + * rungs; the highest base is 85 (SF8, BW500) and CAD_LEVEL_MAX is +12. Within + * those, CAD_LEVEL_MIN/MAX remain the real limit and this is only a guardrail + * against "CAD never fires" / "CAD always busy". 40 is also roughly where + * Semtech's own BW trend extrapolates for the sub-125 bandwidths it declines to + * tabulate, which is where our default preset lives. */ +#define LR11XX_CAD_PEAK_MIN 40 +#define LR11XX_CAD_PEAK_MAX 100 + +uint8_t lr11xx_cad_peak_min(void) +{ + return LR11XX_CAD_PEAK_MIN; +} + +uint8_t lr11xx_cad_peak_max(void) +{ + return LR11XX_CAD_PEAK_MAX; } static int lr11xx_do_cad(struct lr11xx_data *data) @@ -1720,23 +1797,23 @@ static int lr11xx_do_cad(struct lr11xx_data *data) struct lora_modem_config *mc = &data->modem_cfg; uint8_t sf = (uint8_t)mc->datarate; - uint8_t symb_nb = 2; - uint8_t detect_peak = lr11xx_cad_detect_peak(sf); + /* Both the table lookup and the timeout need the symbol count, so it is + * resolved before the base peak rather than after it. */ + uint8_t symb_nb = mc->cad.symbol_num ? (uint8_t)mc->cad.symbol_num : 2; + uint16_t bw_khz = (uint16_t)bw_enum_to_khz(mc->bandwidth); + uint8_t detect_peak = lr11xx_cad_detect_peak(sf, bw_khz, symb_nb); - if (mc->cad.symbol_num != 0) { - symb_nb = (uint8_t)mc->cad.symbol_num; - } if (mc->cad.detection_peak != 0) { detect_peak = mc->cad.detection_peak; } else if (data->cad_peak_offset != 0) { /* Adaptive-CAD operating offset (base +/- learned delta). - * LR11xx detPeak scale is ~48-90 — never mix with SX126x. */ + * LR11xx detPeak scale is ~50-85 — never mix with SX126x. */ int peak = (int)detect_peak + data->cad_peak_offset; - if (peak < 48) { - peak = 48; - } else if (peak > 90) { - peak = 90; + if (peak < LR11XX_CAD_PEAK_MIN) { + peak = LR11XX_CAD_PEAK_MIN; + } else if (peak > LR11XX_CAD_PEAK_MAX) { + peak = LR11XX_CAD_PEAK_MAX; } detect_peak = (uint8_t)peak; } @@ -1845,7 +1922,15 @@ uint8_t lr11xx_cad_base_peak(const struct device *dev) { struct lr11xx_data *data = dev->data; - return lr11xx_cad_detect_peak((uint8_t)data->modem_cfg.datarate); + /* Must mirror lr11xx_do_cad()'s lookup exactly — bandwidth and symbol + * count included. This is what `get cad` prints as the base and what + * the C++ staircase offsets from, so a base that disagreed with the + * peak actually programmed would make every rung a lie. */ + return lr11xx_cad_detect_peak( + (uint8_t)data->modem_cfg.datarate, + (uint16_t)bw_enum_to_khz(data->modem_cfg.bandwidth), + data->modem_cfg.cad.symbol_num + ? (uint8_t)data->modem_cfg.cad.symbol_num : 2); } int lr11xx_cad_probe(const struct device *dev, int8_t peak_offset) @@ -1855,10 +1940,10 @@ int lr11xx_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 < LR11XX_CAD_PEAK_MIN) { + peak = LR11XX_CAD_PEAK_MIN; + } else if (peak > LR11XX_CAD_PEAK_MAX) { + peak = LR11XX_CAD_PEAK_MAX; } /* One-shot absolute override consumed by lr11xx_do_cad(). Probes and diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h index 12011bc..f24985f 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h +++ b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h @@ -132,10 +132,28 @@ void lr11xx_cad_set_peak_offset(const struct device *dev, int8_t offset); * @brief Per-SF base cadDetPeak for the currently configured SF * * @param dev LoRa device - * @return Base detPeak (56-68 on this family) + * @return Base detPeak for the current SF, bandwidth and CAD symbol count + * (roughly 50-85 on this family; strongly bandwidth-dependent) */ uint8_t lr11xx_cad_base_peak(const struct device *dev); +/** + * @brief Lowest detPeak this driver will program. + * + * The C++ adaptive-CAD controller narrows its offset window to this range so it + * never explores offsets that collapse onto one peak. + * + * @return Minimum absolute detPeak + */ +uint8_t lr11xx_cad_peak_min(void); + +/** + * @brief Highest detPeak this driver will program. + * + * @return Maximum absolute detPeak + */ +uint8_t lr11xx_cad_peak_max(void); + /** * @brief Run one blocking calibration CAD at base detPeak + peak_offset * diff --git a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h index 29e42a6..ee19567 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h +++ b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h @@ -162,13 +162,30 @@ void sx126x_reset_dc_timeout_restarts(const struct device *dev); void sx126x_cad_set_peak_offset(const struct device *dev, int8_t offset); /** - * @brief Per-SF base cadDetPeak for the currently configured SF + * @brief Base cadDetPeak for the current SF, bandwidth and CAD symbol count * * @param dev LoRa device - * @return Base detPeak (SF + 13 on this family) + * @return Base detPeak (roughly 18-34 on this family) */ uint8_t sx126x_cad_base_peak(const struct device *dev); +/** + * @brief Lowest detPeak this driver will program. + * + * The C++ adaptive-CAD controller narrows its offset window to this range so it + * never explores offsets that collapse onto one peak. + * + * @return Minimum absolute detPeak + */ +uint8_t sx126x_cad_peak_min(void); + +/** + * @brief Highest detPeak this driver will program. + * + * @return Maximum absolute detPeak + */ +uint8_t sx126x_cad_peak_max(void); + /** * @brief Run one blocking calibration CAD at base detPeak + peak_offset * diff --git a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch index e30dada..37513ef 100644 --- a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch +++ b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch @@ -461,6 +461,56 @@ TX. It is a bare GPIO with no SPI and no chip state behind it, and a node parked in continuous RX may not make an RX/TX/sleep transition for minutes -- long enough for the CLI to acknowledge a change the radio had not made. +== Bandwidth-aware CAD detPeak base table == + +cadDetPeak was SF+13 for every configuration. That is RadioLib's default and +it is a reasonable number, but it ignores bandwidth, and the datasheet does not +justify it: rev 2.2 Sec 13.4.7 gives no table at all, saying only that the +values "must be carefully tested", and defers to AN1200.48 (paywalled). + +Semtech's own reference stack does have one. LoRa Basics Modem v4.9.0, +ral_sx126x.c ral_sx126x_get_lora_cad_det_peak(), brackets on bandwidth: + + BW >= 500 kHz 22 23 25 26 30 31 33 35 (SF5..SF12) + BW >= 125 kHz 20 21 22 24 24 25 27 27 + below BW125 RAL_STATUS_UNKNOWN_VALUE + +followed by a symbol-count correction: -1 at 4 symbols, -2 at 8 or 16, since +more looks at the same correlation reach the same detection quality at a lower +threshold. We run 4 symbols everywhere and applied no such correction. + +The error this leaves is mild on this family -- 1-3 counts per octave of +bandwidth -- which is why SF+13 survived so long. It is not nil: against the +BW500 column SF+13 is ten counts too sensitive at SF12, and `set bw` accepts +250 and 500. Contrast the LR11xx, where the same omission costs ~12 counts per +octave and drove field units onto the offset rail. + +Below BW125 Semtech declines to tabulate, and BW62.5 is our default preset, so +that gap is where we normally operate. SF+13 is kept there, and it is not a +guess: it sits ~1 count below Semtech's BW125 column at every SF, which is the +correct direction for a narrower bandwidth, and a field SX1262 at SF7/BW62.5 +converged to detPeak 19 against this table's 20 -- one rung of adaptive +correction. SF+13 is also already a 4-symbol figure (RadioLib programs it with +CAD_ON_4_SYMB citing DS rev 1.1 p.92), so the symbol correction must not be +applied to it a second time. Net effect: no behaviour change at the default +preset, and a real table where there was none at BW250/500. + +The clamp around the adaptive offset moves from 15-40 to SX126X_CAD_PEAK_MIN/ +MAX (12-48) and is now exported through sx126x_cad_peak_min/max(). Both halves +matter. The ceiling has to cover the widest-band base (34 at SF12/BW500 with 4 +symbols) plus the full +12 offset excursion, which 40 did not. The floor stops +two counts above cadDetMin, pinned at 10 everywhere -- a detPeak at or below the +correlator's own noise-estimate floor expresses no threshold at all. Exporting +it is what stops the C++ staircase exploring offsets that collapse onto one +peak and reading the sampling noise between identical configurations as +curvature; that failure mode is documented on the LR2021 and was observed on +the LR1110. + +sx126x_cad_base_peak_cfg() is the single source of truth so sx126x_do_cad(), +the exported sx126x_cad_base_peak() and sx126x_cad_probe() cannot drift: the +base is what `get cad` prints and what the staircase offsets from, so a base +disagreeing with the peak actually programmed would make every rung a lie. + == Why this is one patch == Five of the sections above shipped as separate files (0011 band-rssi-cal, 0012 @@ -503,10 +553,10 @@ Regenerate with: appended to this preamble. diff --git a/drivers/lora/native/sx126x/sx126x.c b/drivers/lora/native/sx126x/sx126x.c -index 30243ba5dc7..0e870e37468 100644 +index 30243ba5dc7..aa0732b1de9 100644 --- a/drivers/lora/native/sx126x/sx126x.c +++ b/drivers/lora/native/sx126x/sx126x.c -@@ -6,72 +6,218 @@ +@@ -6,72 +6,220 @@ #include #include #include @@ -667,15 +717,17 @@ index 30243ba5dc7..0e870e37468 100644 +} + +/* Reset all software state that indicates "we are currently receiving": -+ * the rx_packet_active latch (and its deadline timestamp) and the -+ * preamble-grace timestamp. Paired write so the fields never drift out of -+ * sync. Called from every RX (re)start site, every terminal-event handler -+ * (RX_DONE / CRC_ERR / RX_TX_TIMEOUT), and on TX-state entry. */ ++ * the rx_packet_active latch (and its deadline timestamp), the ++ * preamble-grace timestamp and the raw-HEADER_VALID deadline. Paired write ++ * so the fields never drift out of sync. Called from every RX (re)start ++ * site, every terminal-event handler (RX_DONE / CRC_ERR / RX_TX_TIMEOUT), ++ * and on TX-state entry. */ +static inline void sx126x_reset_rx_busy_signals(struct sx126x_data *data) +{ + data->rx_packet_active = false; + atomic_set(&data->preamble_seen_at_ms, 0); + atomic_set(&data->header_seen_at_ms, 0); ++ atomic_set(&data->raw_header_seen_at_ms, 0); +} + +/* Grace period for the PREAMBLE_DETECTED -> HEADER_VALID gap, SF/BW-aware. @@ -749,7 +801,7 @@ index 30243ba5dc7..0e870e37468 100644 } static bool should_enable_ldro(enum lora_datarate sf, enum lora_signal_bandwidth bw, -@@ -81,7 +227,11 @@ static bool should_enable_ldro(enum lora_datarate sf, enum lora_signal_bandwidth +@@ -81,7 +229,11 @@ static bool should_enable_ldro(enum lora_datarate sf, enum lora_signal_bandwidth return true; } @@ -762,7 +814,7 @@ index 30243ba5dc7..0e870e37468 100644 /* Symbol time = 2^SF / BW (in seconds) */ /* 16.38 ms = 16380 us */ /* 2^SF / BW > 0.01638 => 2^SF * 1000000 / BW > 16380 */ -@@ -216,12 +366,43 @@ static int sx126x_set_modulation_params(const struct device *dev, +@@ -216,12 +368,43 @@ static int sx126x_set_modulation_params(const struct device *dev, return sx126x_hal_write_cmd(dev, SX126X_CMD_SET_MODULATION_PARAMS, buf, 4); } @@ -806,7 +858,7 @@ index 30243ba5dc7..0e870e37468 100644 sys_put_be16(preamble_len, &buf[0]); buf[2] = header_type; -@@ -229,7 +410,31 @@ static int sx126x_set_packet_params(const struct device *dev, +@@ -229,7 +412,31 @@ static int sx126x_set_packet_params(const struct device *dev, buf[4] = crc_mode; buf[5] = invert_iq; @@ -839,7 +891,7 @@ index 30243ba5dc7..0e870e37468 100644 } static int sx126x_set_sync_word(const struct device *dev, bool public_network) -@@ -243,10 +448,128 @@ static int sx126x_set_sync_word(const struct device *dev, bool public_network) +@@ -243,10 +450,128 @@ static int sx126x_set_sync_word(const struct device *dev, bool public_network) return sx126x_hal_write_regs(dev, SX126X_REG_LORA_SYNC_WORD_MSB, buf, 2); } @@ -970,7 +1022,7 @@ index 30243ba5dc7..0e870e37468 100644 return sx126x_hal_write_regs(dev, SX126X_REG_RX_GAIN, &val, 1); } -@@ -296,7 +619,7 @@ static int sx126x_get_packet_status(const struct device *dev, +@@ -296,7 +621,7 @@ static int sx126x_get_packet_status(const struct device *dev, uint8_t buf[3]; int ret; @@ -979,7 +1031,7 @@ index 30243ba5dc7..0e870e37468 100644 if (ret == 0) { /* RSSI is -value/2 dBm */ *rssi = -((int16_t)buf[0] >> 1); -@@ -307,6 +630,140 @@ static int sx126x_get_packet_status(const struct device *dev, +@@ -307,6 +632,140 @@ static int sx126x_get_packet_status(const struct device *dev, return ret; } @@ -1120,7 +1172,7 @@ index 30243ba5dc7..0e870e37468 100644 static int sx126x_chip_init(const struct device *dev) { const struct sx126x_hal_config *config = dev->config; -@@ -367,6 +824,26 @@ static int sx126x_chip_init(const struct device *dev) +@@ -367,6 +826,26 @@ static int sx126x_chip_init(const struct device *dev) return ret; } @@ -1147,7 +1199,7 @@ index 30243ba5dc7..0e870e37468 100644 /* Set packet type to LoRa */ ret = sx126x_set_packet_type(dev, SX126X_PACKET_TYPE_LORA); if (ret < 0) { -@@ -374,10 +851,23 @@ static int sx126x_chip_init(const struct device *dev) +@@ -374,10 +853,23 @@ static int sx126x_chip_init(const struct device *dev) return ret; } @@ -1174,7 +1226,7 @@ index 30243ba5dc7..0e870e37468 100644 if (ret < 0) { LOG_ERR("Set IRQ params failed: %d", ret); return ret; -@@ -390,6 +880,10 @@ static int sx126x_chip_init(const struct device *dev) +@@ -390,6 +882,10 @@ static int sx126x_chip_init(const struct device *dev) return ret; } @@ -1185,7 +1237,7 @@ index 30243ba5dc7..0e870e37468 100644 LOG_INF("SX126x initialized"); return 0; } -@@ -398,15 +892,44 @@ static void sx126x_dio1_callback(const struct device *dev) +@@ -398,15 +894,44 @@ static void sx126x_dio1_callback(const struct device *dev) { struct sx126x_data *data = dev->data; @@ -1232,7 +1284,7 @@ index 30243ba5dc7..0e870e37468 100644 sx126x_hal_set_rf_switch(dev, enable, tx); } } -@@ -426,6 +949,7 @@ static void sx126x_disconnect_rf_gpios(const struct device *dev) +@@ -426,6 +951,7 @@ static void sx126x_disconnect_rf_gpios(const struct device *dev) sx126x_disconnect_gpio(&config->antenna_enable); sx126x_disconnect_gpio(&config->tx_enable); sx126x_disconnect_gpio(&config->rx_enable); @@ -1240,7 +1292,7 @@ index 30243ba5dc7..0e870e37468 100644 } static int sx126x_reconnect_rf_gpios(const struct device *dev) -@@ -451,10 +975,238 @@ static int sx126x_reconnect_rf_gpios(const struct device *dev) +@@ -451,10 +977,238 @@ static int sx126x_reconnect_rf_gpios(const struct device *dev) return ret; } @@ -1479,7 +1531,7 @@ index 30243ba5dc7..0e870e37468 100644 static int sx126x_set_sleep(const struct device *dev) { struct sx126x_data *data = dev->data; -@@ -533,8 +1285,16 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -533,8 +1287,16 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta struct sx126x_data *data = dev->data; struct sx126x_rx_result result = { 0 }; uint8_t payload_len = 0, offset = 0; @@ -1496,7 +1548,7 @@ index 30243ba5dc7..0e870e37468 100644 /* Get received packet info */ ret = sx126x_get_rx_buffer_status(dev, &payload_len, &offset); if (ret < 0) { -@@ -544,9 +1304,26 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -544,9 +1306,26 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta /* Get signal quality */ sx126x_get_packet_status(dev, &result.rssi, &result.snr); @@ -1526,7 +1578,7 @@ index 30243ba5dc7..0e870e37468 100644 result.status = -EIO; } else { /* Read payload into shared buffer */ -@@ -564,23 +1341,55 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -564,23 +1343,55 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta } } @@ -1595,7 +1647,7 @@ index 30243ba5dc7..0e870e37468 100644 sx126x_set_sleep(dev); k_msgq_put(&data->rx_msgq, &result, K_NO_WAIT); } -@@ -589,8 +1398,83 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -589,8 +1400,83 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta static void sx126x_handle_irq_timeout(const struct device *dev) { struct sx126x_data *data = dev->data; @@ -1679,7 +1731,7 @@ index 30243ba5dc7..0e870e37468 100644 sx126x_set_sleep(dev); if (data->tx_async_signal != NULL) { -@@ -633,10 +1517,54 @@ static void sx126x_irq_work_handler(struct k_work *work) +@@ -633,10 +1519,54 @@ static void sx126x_irq_work_handler(struct k_work *work) sx126x_handle_irq_rx_done(dev, irq_status); } @@ -1734,7 +1786,7 @@ index 30243ba5dc7..0e870e37468 100644 /* Re-enable the DIO1 interrupt for the next event (unless sleeping) */ if (atomic_get(&data->state) != SX126X_REST_STATE) { sx126x_hal_dio1_irq_enable(dev); -@@ -648,6 +1576,7 @@ static int sx126x_lora_config(const struct device *dev, +@@ -648,6 +1578,7 @@ static int sx126x_lora_config(const struct device *dev, { struct sx126x_data *data = dev->data; const struct sx126x_hal_config *hal_config = dev->config; @@ -1742,7 +1794,7 @@ index 30243ba5dc7..0e870e37468 100644 bool ldro; int ret; -@@ -679,25 +1608,45 @@ static int sx126x_lora_config(const struct device *dev, +@@ -679,25 +1610,45 @@ static int sx126x_lora_config(const struct device *dev, goto out; } @@ -1790,7 +1842,7 @@ index 30243ba5dc7..0e870e37468 100644 /* Set sync word */ ret = sx126x_set_sync_word(dev, config->public_network); if (ret < 0) { -@@ -721,6 +1670,10 @@ out: +@@ -721,6 +1672,10 @@ out: return ret; } @@ -1801,7 +1853,7 @@ index 30243ba5dc7..0e870e37468 100644 static int sx126x_lora_send_async(const struct device *dev, uint8_t *data_buf, uint32_t data_len, struct k_poll_signal *async) -@@ -738,11 +1691,27 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -738,11 +1693,27 @@ static int sx126x_lora_send_async(const struct device *dev, return -EINVAL; } @@ -1831,7 +1883,7 @@ index 30243ba5dc7..0e870e37468 100644 k_mutex_lock(&data->lock, K_FOREVER); ret = sx126x_ensure_ready(dev); -@@ -752,6 +1721,59 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -752,6 +1723,59 @@ static int sx126x_lora_send_async(const struct device *dev, return ret; } @@ -1891,7 +1943,7 @@ index 30243ba5dc7..0e870e37468 100644 data->tx_async_signal = async; k_msgq_purge(&data->tx_msgq); -@@ -777,8 +1799,99 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -777,8 +1801,99 @@ static int sx126x_lora_send_async(const struct device *dev, /* Enable antenna and set TX path */ sx126x_set_rf_path(dev, true, true); @@ -1993,7 +2045,7 @@ index 30243ba5dc7..0e870e37468 100644 if (ret < 0) { goto out_error; } -@@ -847,6 +1960,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf, +@@ -847,6 +1962,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf, } data->rx_cb = NULL; @@ -2002,7 +2054,7 @@ index 30243ba5dc7..0e870e37468 100644 k_msgq_purge(&data->rx_msgq); /* Set packet parameters for variable length reception */ -@@ -918,6 +2033,8 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -918,6 +2035,8 @@ static int sx126x_lora_recv_async(const struct device *dev, /* Stop async reception */ data->rx_cb = NULL; data->rx_cb_user_data = NULL; @@ -2011,7 +2063,7 @@ index 30243ba5dc7..0e870e37468 100644 if (atomic_cas(&data->state, SX126X_STATE_RX, SX126X_STATE_IDLE)) { sx126x_set_standby(dev, SX126X_STANDBY_RC); sx126x_set_sleep(dev); -@@ -932,6 +2049,19 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -932,6 +2051,19 @@ static int sx126x_lora_recv_async(const struct device *dev, return -EINVAL; } @@ -2031,7 +2083,7 @@ index 30243ba5dc7..0e870e37468 100644 if (!atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_RX)) { LOG_ERR("Busy"); k_mutex_unlock(&data->lock); -@@ -945,8 +2075,18 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -945,8 +2077,18 @@ static int sx126x_lora_recv_async(const struct device *dev, return ret; } @@ -2050,7 +2102,7 @@ index 30243ba5dc7..0e870e37468 100644 /* Set packet parameters */ ret = sx126x_set_packet_params(dev, -@@ -959,6 +2099,7 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -959,6 +2101,7 @@ static int sx126x_lora_recv_async(const struct device *dev, SX126X_LORA_IQ_INVERTED : SX126X_LORA_IQ_STANDARD); if (ret < 0) { data->rx_cb = NULL; @@ -2058,7 +2110,7 @@ index 30243ba5dc7..0e870e37468 100644 sx126x_set_sleep(dev); k_mutex_unlock(&data->lock); return ret; -@@ -971,11 +2112,21 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -971,11 +2114,21 @@ static int sx126x_lora_recv_async(const struct device *dev, ret = sx126x_set_rx(dev, 0); if (ret < 0) { data->rx_cb = NULL; @@ -2080,7 +2132,7 @@ index 30243ba5dc7..0e870e37468 100644 k_mutex_unlock(&data->lock); return 0; } -@@ -993,7 +2144,9 @@ static uint32_t sx126x_lora_airtime(const struct device *dev, uint32_t data_len) +@@ -993,7 +2146,9 @@ static uint32_t sx126x_lora_airtime(const struct device *dev, uint32_t data_len) } /* Calculate symbol time in microseconds */ @@ -2091,7 +2143,7 @@ index 30243ba5dc7..0e870e37468 100644 sf = data->config.datarate; /* Symbol time = 2^SF / BW (seconds) */ -@@ -1051,7 +2204,7 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, +@@ -1051,7 +2206,7 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, /* Set PA config and TX power */ ret = sx126x_hal_configure_tx_params(dev, tx_power, frequency, @@ -2100,7 +2152,7 @@ index 30243ba5dc7..0e870e37468 100644 if (ret < 0) { sx126x_set_sleep(dev); k_mutex_unlock(&data->lock); -@@ -1083,14 +2236,855 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, +@@ -1083,14 +2238,983 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, return 0; } @@ -2189,11 +2241,50 @@ index 30243ba5dc7..0e870e37468 100644 + + sx126x_get_irq_status(dev, &irq_status); + -+ /* HEADER_VALID raw bit: narrow window between DIO1 firing and the work -+ * handler running. Latch will take over within microseconds. */ ++ /* HEADER_VALID raw bit, bounded. Normally this is just the narrow ++ * window between DIO1 firing and the work handler running, and the ++ * latch above takes over within microseconds -- but that holds only ++ * while DIO1 interrupts are actually arriving. If they stop (a ++ * stalled work queue, or an SoC returning from light sleep with the ++ * pad no longer routed to the GPIO matrix) the handler never runs, the ++ * chip's sticky IRQ bits are never cleared, and rx_packet_active is ++ * never set -- so the bounded latch path above is never reached and ++ * every poll lands here instead. An unbounded "return true" then ++ * mutes TX until something else intervenes, which in practice means ++ * the Dispatcher's 4 s CAD-timeout recovery: every single transmit ++ * costs 4 s and the node looks like a dying radio rather than a ++ * driver that stopped being interrupted. ++ * ++ * Same deadline as the latch, for the same reason -- releasing early ++ * would let TX start on top of a packet that is still arriving. This ++ * is a stuck-state safety net, not a timing mechanism. */ + if (irq_status & SX126X_IRQ_HEADER_VALID) { ++ uint32_t now = k_uptime_get_32(); ++ uint32_t seen = (uint32_t)atomic_get(&data->raw_header_seen_at_ms); ++ ++ if (seen == 0) { ++ /* First observation in this RX cycle. Use 1 as the ++ * "I am set" sentinel if k_uptime returns 0 at boot. */ ++ atomic_set(&data->raw_header_seen_at_ms, ++ (atomic_val_t)(now == 0 ? 1U : now)); ++ k_mutex_unlock(&data->lock); ++ return true; ++ } ++ if ((now - seen) < sx126x_max_payload_ms(data)) { ++ k_mutex_unlock(&data->lock); ++ return true; ++ } ++ LOG_WRN("HEADER_VALID stuck %u ms with no work handler " ++ "(DIO1 not arriving?), releasing TX gate", ++ now - seen); ++ /* Drop the sticky reception bits so the next poll starts clean. */ ++ sx126x_clear_irq_status(dev, SX126X_IRQ_PREAMBLE_DETECTED | ++ SX126X_IRQ_SYNC_WORD_VALID | ++ SX126X_IRQ_HEADER_VALID | ++ SX126X_IRQ_HEADER_ERR); ++ sx126x_reset_rx_busy_signals(data); + k_mutex_unlock(&data->lock); -+ return true; ++ return false; + } + + /* PREAMBLE_DETECTED with SF-aware grace. PREAMBLE_DETECTED is masked @@ -2238,6 +2329,7 @@ index 30243ba5dc7..0e870e37468 100644 + /* No preamble bit, no header bit: nothing in flight. Defensive + * reset in case a stale timestamp survived a mode change. */ + atomic_set(&data->preamble_seen_at_ms, 0); ++ atomic_set(&data->raw_header_seen_at_ms, 0); + k_mutex_unlock(&data->lock); + return false; +} @@ -2457,14 +2549,103 @@ index 30243ba5dc7..0e870e37468 100644 + * chip-family-specific: LR11xx wants ~48-68 for the same job -- never + * copy values across families (56-68 here once made CAD near-blind and + * the LBT gate inert). */ -+static uint8_t sx126x_cad_detect_peak(uint8_t sf) ++/* Recommended cadDetPeak. The datasheet gives no table (rev 2.2 §13.4.7 only ++ * says the values "must be carefully tested" and defers to AN1200.48, which is ++ * paywalled), so the numbers come from Semtech's own reference stack: LoRa ++ * Basics Modem v4.9.0, ral_sx126x.c ral_sx126x_get_lora_cad_det_peak(). ++ * ++ * Bandwidth matters, and it did not used to be considered here at all. It is a ++ * mild effect on this family — 1-3 counts per octave, against ~12 on the LR11xx ++ * — which is why a flat SF+13 survived so long. But Semtech's BW500 column ++ * runs up to 35 at SF12 where SF+13 gives 25: ten counts too sensitive on a ++ * wide-band preset, and `set bw` accepts 250 and 500. ++ * ++ * Below BW125 Semtech returns RAL_STATUS_UNKNOWN_VALUE. Our default preset is ++ * BW62.5, so that gap is where we normally live, and there we keep SF+13 — it ++ * is not a guess: it sits ~1 count below Semtech's BW125 column at every SF, ++ * which is the correct direction for a narrower bandwidth, and it is confirmed ++ * on hardware. A field SX1262 at SF7/BW62.5 converged to detPeak 19 against ++ * this table's 20, i.e. the adaptive controller moved it a single rung. Note ++ * SF+13 is already a 4-symbol figure (RadioLib programs it with CAD_ON_4_SYMB, ++ * citing DS rev 1.1 p.92), so the symbol correction below must NOT be applied ++ * to it a second time. */ ++static uint8_t sx126x_cad_detect_peak(uint8_t sf, uint32_t bw_hz, uint8_t symb_nb) +{ ++ /* SF5 SF6 SF7 SF8 SF9 SF10 SF11 SF12 */ ++ static const uint8_t bw500[8] = { 22, 23, 25, 26, 30, 31, 33, 35 }; ++ static const uint8_t bw125[8] = { 20, 21, 22, 24, 24, 25, 27, 27 }; ++ int peak; ++ + if (sf < 5) { + sf = 5; + } else if (sf > 12) { + sf = 12; + } -+ return sf + 13; ++ ++ /* Narrower than BW125: Semtech offers nothing, SF+13 is measured. */ ++ if (bw_hz < 125000U) { ++ return sf + 13; ++ } ++ ++ /* Semtech brackets only >=500 and >=125 on this family — BW250 shares ++ * the BW125 column, unlike the LR11xx which gives it its own. */ ++ peak = (int)(bw_hz >= 500000U ? bw500[sf - 5] : bw125[sf - 5]); ++ ++ /* More symbols means more looks at the same correlation, so the same ++ * detection quality is reached at a lower threshold. We run 4 symbols ++ * everywhere (LORA_CAD_SYMB_4 in LoRaRadioBase::buildModemConfig). */ ++ if (symb_nb >= 8) { ++ peak -= 2; ++ } else if (symb_nb >= 4) { ++ peak -= 1; ++ } ++ ++ return (uint8_t)peak; ++} ++ ++/* The detPeak range this driver will actually program. Exported through ++ * sx126x_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. ++ * ++ * The ceiling covers the widest-band base (34 at SF12/BW500 with 4 symbols) ++ * plus the full CAD_LEVEL_MAX excursion of +12. The floor stops two counts ++ * above cadDetMin, which is pinned at 10 everywhere: a detPeak at or below the ++ * correlator's own noise-estimate floor does not express a threshold at all. ++ * At the most sensitive base (18, SF5 narrow-band) that does clip the bottom ++ * rungs of the offset window — which is exactly why it is exported rather than ++ * applied silently. */ ++#define SX126X_CAD_PEAK_MIN 12 ++#define SX126X_CAD_PEAK_MAX 48 ++ ++uint8_t sx126x_cad_peak_min(void) ++{ ++ return SX126X_CAD_PEAK_MIN; ++} ++ ++uint8_t sx126x_cad_peak_max(void) ++{ ++ return SX126X_CAD_PEAK_MAX; ++} ++ ++/* Single source of truth for the base peak, so sx126x_do_cad(), the exported ++ * sx126x_cad_base_peak() and sx126x_cad_probe() cannot drift apart. A base ++ * that disagreed with the peak actually programmed would make every rung the ++ * C++ staircase reasons about a lie. ++ * ++ * An unresolvable bandwidth falls through to the sub-125 branch, i.e. SF+13, ++ * which is what this driver did unconditionally before the table existed. */ ++static uint8_t sx126x_cad_base_peak_cfg(struct lora_modem_config *mc) ++{ ++ uint32_t bw_hz; ++ uint8_t symb_nb = mc->cad.symbol_num ? (uint8_t)mc->cad.symbol_num : 2; ++ ++ if (bandwidth_to_hz(mc->bandwidth, &bw_hz) < 0) { ++ bw_hz = 0; ++ } ++ ++ return sx126x_cad_detect_peak((uint8_t)mc->datarate, bw_hz, symb_nb); +} + +/* Blocking-CAD wait budget scaled to the actual CAD duration: @@ -2494,13 +2675,12 @@ index 30243ba5dc7..0e870e37468 100644 +{ + struct lora_modem_config *mc = &data->config; + uint8_t sf = (uint8_t)mc->datarate; -+ uint8_t symb_nb = 2; -+ uint8_t detect_peak = sx126x_cad_detect_peak(sf); ++ /* Both the table lookup and the timeout need the symbol count, so it is ++ * resolved before the base peak rather than after it. */ ++ uint8_t symb_nb = mc->cad.symbol_num ? (uint8_t)mc->cad.symbol_num : 2; ++ uint8_t detect_peak = sx126x_cad_base_peak_cfg(mc); + int ret; + -+ if (mc->cad.symbol_num != 0) { -+ symb_nb = (uint8_t)mc->cad.symbol_num; -+ } + if (mc->cad.detection_peak != 0) { + detect_peak = mc->cad.detection_peak; + } else if (data->cad_peak_offset != 0) { @@ -2508,10 +2688,10 @@ index 30243ba5dc7..0e870e37468 100644 + * Clamped to a sane absolute window around the family scale. */ + int peak = (int)detect_peak + data->cad_peak_offset; + -+ if (peak < 15) { -+ peak = 15; -+ } else if (peak > 40) { -+ peak = 40; ++ if (peak < SX126X_CAD_PEAK_MIN) { ++ peak = SX126X_CAD_PEAK_MIN; ++ } else if (peak > SX126X_CAD_PEAK_MAX) { ++ peak = SX126X_CAD_PEAK_MAX; + } + detect_peak = (uint8_t)peak; + } @@ -2677,7 +2857,7 @@ index 30243ba5dc7..0e870e37468 100644 +{ + struct sx126x_data *data = dev->data; + -+ return sx126x_cad_detect_peak((uint8_t)data->config.datarate); ++ return sx126x_cad_base_peak_cfg(&data->config); +} + +int sx126x_cad_probe(const struct device *dev, int8_t peak_offset) @@ -2687,10 +2867,10 @@ index 30243ba5dc7..0e870e37468 100644 + int peak = base + peak_offset; + int ret; + -+ if (peak < 15) { -+ peak = 15; -+ } else if (peak > 40) { -+ peak = 40; ++ if (peak < SX126X_CAD_PEAK_MIN) { ++ peak = SX126X_CAD_PEAK_MIN; ++ } else if (peak > SX126X_CAD_PEAK_MAX) { ++ peak = SX126X_CAD_PEAK_MAX; + } + + /* One-shot absolute override consumed by sx126x_do_cad(). Probes and @@ -2963,7 +3143,7 @@ index 30243ba5dc7..0e870e37468 100644 }; #ifdef CONFIG_PM_DEVICE -@@ -1112,6 +3106,7 @@ static int sx126x_pm_action(const struct device *dev, +@@ -1112,6 +3236,7 @@ static int sx126x_pm_action(const struct device *dev, static int sx126x_init(const struct device *dev) { struct sx126x_data *data = dev->data; @@ -2971,7 +3151,7 @@ index 30243ba5dc7..0e870e37468 100644 int ret; /* Initialize data structures */ -@@ -1121,9 +3116,33 @@ static int sx126x_init(const struct device *dev) +@@ -1121,9 +3246,33 @@ static int sx126x_init(const struct device *dev) k_msgq_init(&data->rx_msgq, (char *)&data->rx_result, sizeof(struct sx126x_rx_result), 1); k_work_init(&data->irq_work, sx126x_irq_work_handler); @@ -3005,7 +3185,7 @@ index 30243ba5dc7..0e870e37468 100644 /* Initialize HAL */ ret = sx126x_hal_init(dev); -@@ -1185,6 +3204,8 @@ static int sx126x_init(const struct device *dev) +@@ -1185,6 +3334,8 @@ static int sx126x_init(const struct device *dev) {0}), \ .rx_enable = GPIO_DT_SPEC_INST_GET_OR(inst, rx_enable_gpios, \ {0}), \ @@ -3014,7 +3194,7 @@ index 30243ba5dc7..0e870e37468 100644 .dio2_tx_enable = DT_INST_PROP(inst, dio2_tx_enable), \ .dio3_tcxo_enable = DT_INST_NODE_HAS_PROP(inst, dio3_tcxo_voltage), \ .dio3_tcxo_voltage = DT_INST_PROP_OR(inst, dio3_tcxo_voltage, 0), \ -@@ -1239,6 +3260,8 @@ DT_INST_FOREACH_STATUS_OKAY_VARGS(SX126X_INIT, true) +@@ -1239,6 +3390,8 @@ DT_INST_FOREACH_STATUS_OKAY_VARGS(SX126X_INIT, true) {0}), \ .rx_enable = GPIO_DT_SPEC_INST_GET_OR(inst, rx_enable_gpios, \ {0}), \ @@ -3024,10 +3204,10 @@ index 30243ba5dc7..0e870e37468 100644 .dio3_tcxo_enable = DT_INST_NODE_HAS_PROP(inst, dio3_tcxo_voltage), \ .dio3_tcxo_voltage = DT_INST_PROP_OR(inst, dio3_tcxo_voltage, 0), \ diff --git a/drivers/lora/native/sx126x/sx126x.h b/drivers/lora/native/sx126x/sx126x.h -index 9dbf3f26586..675f3eca8a7 100644 +index 9dbf3f26586..51fb808bb46 100644 --- a/drivers/lora/native/sx126x/sx126x.h +++ b/drivers/lora/native/sx126x/sx126x.h -@@ -56,13 +56,107 @@ struct sx126x_data { +@@ -56,13 +56,121 @@ struct sx126x_data { /* Async RX callback */ lora_recv_cb rx_cb; void *rx_cb_user_data; @@ -3092,6 +3272,20 @@ index 9dbf3f26586..675f3eca8a7 100644 + * the latch once sx126x_max_payload_ms() has elapsed. */ + atomic_t header_seen_at_ms; + ++ /* Timestamp (k_uptime_get_32() units, ms) of the first observation of a ++ * raw HEADER_VALID bit by sx126x_is_receiving() while rx_packet_active ++ * is still false -- i.e. DIO1 fired but the work handler has not yet ++ * promoted the latch. Zero means "not tracking". ++ * ++ * That handoff normally takes microseconds, so this exists only to bound ++ * the case where it never happens because DIO1 interrupts have stopped ++ * arriving at all. Nothing then clears the chip's sticky IRQ bits, and ++ * rx_packet_active is never set, so header_seen_at_ms above cannot bound ++ * anything -- without this field the TX gate stays true until the ++ * Dispatcher's 4 s CAD-timeout recovery clears it, once per packet. ++ * Reset together with the other RX-busy signals. */ ++ atomic_t raw_header_seen_at_ms; ++ + uint32_t dc_rx_time; /* stored duty cycle rx period (15.625us steps) */ + uint32_t dc_sleep_time; /* stored duty cycle sleep period (15.625us steps) */ +