From a705d5b5ce35e46a20cec629335f3d56e5e41661 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:30:47 +0200 Subject: [PATCH] refactor / unify probes --- zephcore/ADAPTIVE_CAD.md | 6 +- zephcore/ARCHITECTURE.md | 2110 ++++++++--------- zephcore/Kconfig | 22 +- zephcore/Repeater_CLI_commands.md | 3 +- .../adapters/datastore/ZephyrDataStore.cpp | 12 +- zephcore/adapters/radio/LoRaRadioBase.cpp | 144 +- zephcore/adapters/radio/LoRaRadioBase.h | 13 +- zephcore/adapters/radio/radio_common.h | 45 +- zephcore/app/RepeaterDataStore.cpp | 6 +- zephcore/app/RepeaterMesh.cpp | 7 + zephcore/app/RepeaterMesh.h | 2 +- zephcore/app/RoomServerMesh.h | 2 +- zephcore/helpers/CommonCLI.cpp | 23 +- zephcore/helpers/NodePrefs.h | 6 +- zephcore/include/mesh/Dispatcher.h | 17 + zephcore/src/Dispatcher.cpp | 2 + zephcore/src/main_companion.cpp | 6 +- zephcore/src/main_repeater.cpp | 68 +- zephcore/src/main_room_server.cpp | 2 +- 19 files changed, 1331 insertions(+), 1165 deletions(-) diff --git a/zephcore/ADAPTIVE_CAD.md b/zephcore/ADAPTIVE_CAD.md index 262bf16..127c44d 100644 --- a/zephcore/ADAPTIVE_CAD.md +++ b/zephcore/ADAPTIVE_CAD.md @@ -90,7 +90,7 @@ caveat noted at the end, viewed through the strong/faint lens.) ## How it works -Every `cad.probe.interval` seconds (default 15), when the radio is idle +Every `probe.interval` seconds (default 15), when the radio is idle in receive mode, the firmware runs one **calibration CAD probe** and immediately re-arms RX. A probe takes one CAD duration — about 4 ms at SF7/250 kHz, up to ~130 ms at SF12/125 kHz — so the added radio deaf time @@ -248,7 +248,7 @@ any time with `set cad.auto on`. Offset changes (manual or automatic) appear in the log as `cad: step down/up -> offset N`. To collect dry-run data faster, drop the interval (probing is temporary -then): `set cad.probe.interval 10`. At the default 15 s, allow ~a day for +then): `set probe.interval 10`. At the default 15 s, allow ~a day for a knee to resolve clearly, longer to capture day/night variation. ## Command reference @@ -258,7 +258,7 @@ a knee to resolve clearly, longer to capture day/night variation. | `get cad` | | Status + per-level statistics (see above). | | `set cad.auto ` | **on** | Staircase controller acts on the stats. Off = observe/hand-tune. | | `set cad.offset ` | 0 | Operating offset, −8…12. Negative = more sensitive. Applied live. | -| `set cad.probe.interval ` | 15 | Probe cadence; 0 disables probing (and freezes auto), 10–255 otherwise. | +| `set probe.interval ` | 15 | Shared cadence for the noise-floor sample and the CAD probe that consumes it; 0 disables probing (and freezes auto), 10–255 otherwise. | | `set cad.busycap ` | 25 | Faint-tolerance / airtime cap: raise detPeak once more than this % of (quiet-moment) probes trip on faint signals. Lower = reject faint/echo harder (busy backbones); 0 = off. 10–90 otherwise. | | `set cad.reset` | | Clear accumulated statistics (RAM only). | diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index 8f53d8b..6410ee7 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -1,1055 +1,1055 @@ -# 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) - ---- - -## 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 (experimental bring-up). 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, experimental) -│ │ ├── 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, AGC reset) │ -├─────────────────────────────────────────────────┤ -│ 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**: Periodic warm sleep + recalibration (configurable interval, default off) - -### 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 (experimental) -``` - -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. -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, and the grace-expiry one-bit clear for foreign preambles. - -### 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 `cad.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 **−8…+12**, -persisted via `Dispatcher::onCadOffsetChanged()`. Driver absolute clamp (SX126x -15–40, LR 48–90) is a guardrail; AN1200.48 recommends 21–29 for SX126x (base -`SF+13`), tuned to catch faint — LBT may deliberately sit above it. Probe + -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` (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 -- **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). Experimental — bring-up on the ProMicro LR2021 is still in progress; not listed as a supported board. - -### 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 to a no-op 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. App-side contact -delete (`CMD_REMOVE_CONTACT`) turns the feature off. Send/ack choreography is -synthesized (SENT + immediate SEND_CONFIRMED, trip time 0). CLI replies are -chunked at ≤150 chars on line breaks (offline-queue frames cap at 172 bytes). - -**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): -- `set/get v.contact on|off` — default on. -- `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) -- 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 -- Bootloader version detection via flash memory scan - -### 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 → Buzzer toggle -- 4 taps → GPS toggle -- 5 taps → Flood advert (immediate, no delay) - -### 8.4 Buzzer - -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. - -### 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-build | LOW | Integrates LR11xx driver into Zephyr LoRa build | -| 0002-lora-lr20xx-build | LOW | Integrates LR20xx driver into Zephyr LoRa build | -| 0003-lora-sx126x-native | **HIGH** | DIO1 work queue, duty cycle, RX-busy gating, extension API, errata workarounds | -| 0004-lora-sx127x-62k5-bandwidth | LOW | Adds 62.5 kHz bandwidth to the loramac-node backend | -| 0005-gnss-air530z-easy | MEDIUM | EASY ephemeris + removes PM (prevents deadlocks) | -| 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 | - -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 | -| 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) | - -### 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` (152 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` (297 bytes)** — `app/RepeaterDataStore.cpp` -`loadPrefs()`/`savePrefs()` (same field order as `helpers/CommonCLI.cpp`; offset comments inline). -Key ranges: name(4-36), radio(72-119), adaptive-delay(80-111, ignored at runtime), -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 -``` +# 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) + +--- + +## 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 (experimental bring-up). 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, experimental) +│ │ ├── 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, AGC reset) │ +├─────────────────────────────────────────────────┤ +│ 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**: Periodic warm sleep + recalibration (configurable interval, default off) + +### 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 (experimental) +``` + +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. +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, and the grace-expiry one-bit clear for foreign preambles. + +### 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 **−8…+12**, +persisted via `Dispatcher::onCadOffsetChanged()`. Driver absolute clamp (SX126x +15–40, LR 48–90) is a guardrail; AN1200.48 recommends 21–29 for SX126x (base +`SF+13`), tuned to catch faint — LBT may deliberately sit above it. Probe + +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` (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 +- **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). Experimental — bring-up on the ProMicro LR2021 is still in progress; not listed as a supported board. + +### 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 to a no-op 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. App-side contact +delete (`CMD_REMOVE_CONTACT`) turns the feature off. Send/ack choreography is +synthesized (SENT + immediate SEND_CONFIRMED, trip time 0). CLI replies are +chunked at ≤150 chars on line breaks (offline-queue frames cap at 172 bytes). + +**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): +- `set/get v.contact on|off` — default on. +- `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) +- 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 +- Bootloader version detection via flash memory scan + +### 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 → Buzzer toggle +- 4 taps → GPS toggle +- 5 taps → Flood advert (immediate, no delay) + +### 8.4 Buzzer + +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. + +### 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-build | LOW | Integrates LR11xx driver into Zephyr LoRa build | +| 0002-lora-lr20xx-build | LOW | Integrates LR20xx driver into Zephyr LoRa build | +| 0003-lora-sx126x-native | **HIGH** | DIO1 work queue, duty cycle, RX-busy gating, extension API, errata workarounds | +| 0004-lora-sx127x-62k5-bandwidth | LOW | Adds 62.5 kHz bandwidth to the loramac-node backend | +| 0005-gnss-air530z-easy | MEDIUM | EASY ephemeris + removes PM (prevents deadlocks) | +| 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 | + +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 | +| 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) | + +### 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` (152 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` (297 bytes)** — `app/RepeaterDataStore.cpp` +`loadPrefs()`/`savePrefs()` (same field order as `helpers/CommonCLI.cpp`; offset comments inline). +Key ranges: name(4-36), radio(72-119), adaptive-delay(80-111, ignored at runtime), +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 +``` diff --git a/zephcore/Kconfig b/zephcore/Kconfig index ed39b0e..fbb4a55 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -396,24 +396,28 @@ config ZEPHCORE_MAINTENANCE_BACKSTOP_MS UNCONDITIONAL, so this is a safety net against a deadline that is missed or mis-reported — not a value that should ever bind in normal operation. - MUST stay above ZEPHCORE_NOISE_FLOOR_INTERVAL_MS and the CAD probe - interval (prefs cad_probe_interval, default 15 s), which are the shortest - recurring deadlines on an idle repeater. Set at or below them and this - silently becomes the wake period, reproducing the old fixed 5 s tick and - making the deadline scheduling a no-op. That is exactly the bug the - original 5000 default shipped with. + MUST stay above the radio measurement interval ("set probe.interval", + default 15 s; ZEPHCORE_NOISE_FLOOR_INTERVAL_MS when probing is off) — + the shortest recurring deadline on an idle repeater. Set at or below it + and this silently becomes the wake period, reproducing the old fixed 5 s + tick and making the deadline scheduling a no-op. That is exactly the bug + the original 5000 default shipped with. Lowering it costs wakes without improving responsiveness — real work is already scheduled at its own deadline. Raising it widens the window in which a scheduling bug goes unnoticed. config ZEPHCORE_NOISE_FLOOR_INTERVAL_MS - int "Noise floor sampling interval (ms)" - default 5000 if ZEPHCORE_ROLE_COMPANION || ZEPHCORE_ROLE_ROOM_SERVER + int "Default radio measurement interval (ms)" default 15000 range 1000 120000 help - How often the radio samples ambient RSSI to update the noise floor EMA. + Fallback cadence for periodic radio measurements — the noise-floor RSSI + sample and the CAD probe that consumes it. + + This is only the DEFAULT. At runtime the "probe.interval" pref governs + both (see set probe.interval); this value applies when that pref is 0, + i.e. CAD probing is switched off but the floor sampler still has to run. This used to be implicit: the sampler ran once per housekeeping tick, so it inherited that 5 s period. The sampler lives in shared radio code, so diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index 8825705..cb96be5 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -206,6 +206,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `get rxduty` | RX duty cycle mode: `0` or `1` | | `get gps duty` | Now-effective GPS duty interval in seconds (`always on (0)` when continuous) | | `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 preamble false-positive re-arm counter (RxTimeout re-arms + parked-RX watchdog recoveries). High values mean the preamble detector is tripping on noise/interference without real packets arriving — inflates RX-on time and drains battery; packets are never lost to it. Reset by `clear stats`. | | `get cad` | Adaptive-CAD status: header (`a` auto on/off, `o` operating detPeak offset, `pk` absolute peak with family base, `iv` probe interval, `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 adc.multiplier` | Battery voltage ADC calibration multiplier | @@ -253,7 +254,7 @@ Changes are persisted immediately unless noted. Some require a reboot. | `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` + `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 cad.probe.interval ` | 0 (off) or 10–255, default **15** | Seconds between calibration CAD probes. Default 15 s → ~1–2 h staircase response. 0 disables probing entirely (also freezes auto adaptation). | +| `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`. 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 prv.key ` | 64-char hex (32-byte key) | Replace private key; derive new identity *(reboot to apply)* | diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index bd7f60c..8927c3d 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -777,12 +777,12 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) } } - /* Offset 157: cad_probe_interval (ZephCore extension, seconds; 0 = off). + /* Offset 157: probe_interval (ZephCore extension, seconds; 0 = off). * Absent in pre-existing files → keep the in-RAM default (60). */ if (off < len) { - prefs.cad_probe_interval = buf[off++]; - if (prefs.cad_probe_interval != 0 && prefs.cad_probe_interval < 10) { - prefs.cad_probe_interval = 10; + prefs.probe_interval = buf[off++]; + if (prefs.probe_interval != 0 && prefs.probe_interval < 10) { + prefs.probe_interval = 10; } } @@ -892,8 +892,8 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs) buf[off++] = prefs.cad_auto; /* Offset 156: cad_offset (ZephCore extension, signed) */ buf[off++] = (uint8_t)prefs.cad_offset; - /* Offset 157: cad_probe_interval (ZephCore extension, seconds) */ - buf[off++] = prefs.cad_probe_interval; + /* Offset 157: probe_interval (ZephCore extension, seconds) */ + buf[off++] = prefs.probe_interval; /* Offset 158: cad_busycap (ZephCore extension, percent) */ buf[off++] = prefs.cad_busycap; /* Offset 159: adc_multiplier (ZephCore extension, float LE, 0 = board diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index eb75364..b4f1564 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -47,10 +47,12 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board, _last_rssi(0), _last_snr(0), _rx_head(0), _rx_tail(0), _noise_floor(DEFAULT_NOISE_FLOOR), _calibration_threshold(0), _ema_unguarded(0), - _noise_floor_next_ms(0), - _cad_auto(false), _cad_offset(0), _cad_probe_interval_s(0), + _noise_floor_next_ms(0), _noise_floor_retries(0), + _measure_interval_ms(CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS), + _sample_rssi(0), _sample_channel_quiet(false), _sample_fresh(false), + _cad_auto(false), _cad_offset(0), _probe_interval_s(0), _cad_busycap_pct(0), - _cad_last_probe_ms(0), _cad_last_decay_ms(0), _cad_retry_ms(0), + _cad_last_probe_ms(0), _cad_last_decay_ms(0), _cad_probe_rr(0), _rx_duty_cycle_enabled(IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE)), _rx_boost_enabled(true), @@ -792,13 +794,28 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) * calls are a no-op, so this is safe to invoke from any wake. */ int64_t now = k_uptime_get(); + /* Invalidate first: "fresh" must mean a sample landed in THIS pass, not + * merely at some point in the past. cadMaintenance() runs immediately + * after us and treats the verdict as current-channel ground truth, so a + * carried-over sample would let it probe on a reading taken a full + * interval ago — on a different channel state entirely. */ + _sample_fresh = false; + if (_noise_floor_next_ms != 0 && now < _noise_floor_next_ms) { return; } /* Due. Any bail-out below is a blocked attempt, not a completed one — - * push the deadline out by the short retry so msUntilNextMaintenance() - * cannot report "due now" on a loop. */ + * push the deadline out by the retry so msUntilNextMaintenance() cannot + * report "due now" on a loop. Bounded for the same reason as the CAD + * probe: an unbounded retry grid makes the retry period the de-facto + * wake period whenever the radio is persistently busy. */ + if (_noise_floor_retries >= NOISE_FLOOR_MAX_RETRIES) { + _noise_floor_retries = 0; + _noise_floor_next_ms = now + _measure_interval_ms; + return; + } + _noise_floor_retries++; _noise_floor_next_ms = now + NOISE_FLOOR_RETRY_MS; if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) { @@ -831,7 +848,8 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) } /* A full sample landed: next one is a full interval away. */ - _noise_floor_next_ms = now + NOISE_FLOOR_INTERVAL_MS; + _noise_floor_next_ms = now + _measure_interval_ms; + _noise_floor_retries = 0; /* Insertion sort — tiny array, branch-friendly on Cortex-M */ for (int i = 1; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { @@ -846,10 +864,31 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) int16_t rssi = (samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2 - 1] + samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2]) / 2; - /* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly. */ + /* Publish this sample for cadMaintenance(). The CAD probe needs exactly + * the same fact we just established — "is the channel at its floor right + * now?" — and used to answer it with its own single hwGetCurrentRSSI() on + * its own deadline. That cost a second wake per interval (measured: two + * 15 s grids ~3 s apart) and made the worse decision, since one raw read + * is precisely what the median-of-8 exists to defend against. + * + * The verdict is taken against the floor BEFORE this sample is folded in, + * so it compares a new observation to the established floor rather than + * to one already dragged toward it. */ + _sample_rssi = rssi; + _sample_channel_quiet = (_noise_floor == DEFAULT_NOISE_FLOOR) || + (rssi <= _noise_floor + CAD_PROBE_RSSI_GUARD); + _sample_fresh = true; + + /* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly. + * The lower clamp tracks the active bandwidth — thermal noise is + * 10*log10(BW) so a fixed rail pins narrow-BW presets several dB high + * (BW 31.25 kHz sits ~3 dB below BW 62.5) and never engages at all on + * wide ones. */ + int16_t floor_min = noise_floor_min_dbm(getActiveBandwidthKHzX10() / 10); + if (_noise_floor == DEFAULT_NOISE_FLOOR) { _noise_floor = rssi; - if (_noise_floor < -120) _noise_floor = -120; + if (_noise_floor < floor_min) _noise_floor = floor_min; if (_noise_floor > -50) _noise_floor = -50; _ema_unguarded = 0; LOG_DBG("noise_floor_cal: seed=%d", _noise_floor); @@ -886,7 +925,7 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) int half = W / 2; /* 4 */ int step = (diff + (diff > 0 ? half : -half)) / W; _noise_floor += step; - if (_noise_floor < -120) _noise_floor = -120; + if (_noise_floor < floor_min) _noise_floor = floor_min; if (_noise_floor > -50) _noise_floor = -50; LOG_DBG("noise_floor_cal: rssi=%d, floor=%d, tick=%u", @@ -949,12 +988,25 @@ void LoRaRadioBase::setCadParams(bool auto_enabled, int8_t offset, _cad_auto = auto_enabled; _cad_offset = offset; - _cad_probe_interval_s = probe_interval_s; + _probe_interval_s = probe_interval_s; _cad_busycap_pct = busycap_pct; + + /* One interval governs every periodic radio measurement, because there + * is only one measurement: the noise-floor sampler takes a median-of-8 + * and the CAD probe consumes that same reading (see cadMaintenance). + * Splitting them into two knobs could only ever express a rate the + * hardware does not actually run at. + * + * 0 means "CAD probing off" — the floor sampler still has to run, so it + * falls back to the build-time default. */ + _measure_interval_ms = probe_interval_s + ? (uint32_t)probe_interval_s * 1000U + : (uint32_t)CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS; + hwCadSetPeakOffset(_cad_offset); - LOG_INF("cad: auto=%d offset=%d probe_interval=%us busycap=%u%%", - (int)auto_enabled, (int)offset, (unsigned)probe_interval_s, + LOG_INF("cad: auto=%d offset=%d measure_interval=%ums busycap=%u%%", + (int)auto_enabled, (int)offset, (unsigned)_measure_interval_ms, (unsigned)busycap_pct); } @@ -1090,7 +1142,7 @@ void LoRaRadioBase::cadStaircaseStep() void LoRaRadioBase::cadMaintenance() { - if (_cad_probe_interval_s == 0) { + if (_probe_interval_s == 0) { return; } @@ -1104,39 +1156,30 @@ void LoRaRadioBase::cadMaintenance() _cad_last_decay_ms = now; } - if (now - _cad_last_probe_ms < (int64_t)_cad_probe_interval_s * 1000) { - return; - } - if (now < _cad_retry_ms) { - return; - } + /* No separate probe-interval check: the probe interval IS the measurement + * interval (setCadParams derives _measure_interval_ms from it), so a + * fresh sample means a probe is due by construction. */ - /* Due. _cad_last_probe_ms only advances on a probe that actually runs, - * so hold a short retry deadline across the guards below — otherwise a - * blocked probe reports "due now" to msUntilNextMaintenance() forever. */ - _cad_retry_ms = now + CAD_PROBE_RETRY_MS; - - /* Same guards as the noise-floor calibrator: only probe from idle - * continuous/duty-cycle RX, never during TX or an active packet, - * never while the chip is in its duty-cycle sleep (BUSY) phase. */ - if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) { - return; - } - if (!isRadioReady() || isReceiving()) { + /* Ride on the noise-floor sampler rather than measuring independently. + * + * A fresh sample means the sampler ran THIS pass, which already proves + * everything the probe needs: the radio was idle in RX, not transmitting, + * not mid-packet, and out of its duty-cycle sleep window — the sampler + * applies exactly those guards before it reads. So there is nothing left + * to re-check, no separate deadline, and no retry budget: if no sample + * landed this pass, the probe simply waits for the next one. + * + * This is what makes the wake cost one per interval instead of two. It + * also upgrades the ground-truth prefilter from a single raw RSSI read to + * the sampler's median-of-8 — the probe is trying to establish that the + * channel is quiet, and a busy verdict taken over real traffic teaches + * nothing about false positives, so the outlier rejection matters here. */ + if (!_sample_fresh) { return; } + _sample_fresh = false; - /* Ground-truth prefilter: skip when the channel is visibly busy — - * a busy verdict against strong traffic teaches us nothing about - * false positives. (Below-noise-floor LoRa can't be excluded here; - * the post-probe RX check below handles that side.) */ - int16_t rssi = hwGetCurrentRSSI(); - - if (rssi == -128) { - return; - } - if (_noise_floor != DEFAULT_NOISE_FLOOR && - rssi > _noise_floor + CAD_PROBE_RSSI_GUARD) { + if (!_sample_channel_quiet) { return; } @@ -1251,18 +1294,15 @@ uint32_t LoRaRadioBase::msUntilNextMaintenance() } next = clampDeadline(_noise_floor_next_ms - now); - if (_cad_probe_interval_s == 0) { + if (_probe_interval_s == 0) { return next; } - /* CAD probe: whichever is later of the interval since the last probe - * that actually ran and any retry deadline left by a blocked one. */ - int64_t probe_at = _cad_last_probe_ms + (int64_t)_cad_probe_interval_s * 1000; - - if (probe_at < _cad_retry_ms) { - probe_at = _cad_retry_ms; - } - next = mesh::maintenanceSooner(next, clampDeadline(probe_at - now)); + /* The CAD probe deliberately contributes NO deadline of its own. It runs + * off the noise-floor sampler's measurement (see cadMaintenance), so its + * wake is already accounted for above. Giving it a second deadline is + * what produced two independent 15 s grids ~3 s apart — one extra wake + * per interval, forever, on every repeater. */ /* Stats decay. _cad_last_decay_ms == 0 means the first call latches it * rather than decaying, so treat that as due now. */ @@ -1292,7 +1332,7 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap) "a:%s o:%d pk:%d(b%u/4s) iv:%us bc:%u%%", _cad_auto ? "on" : "off", (int)_cad_offset, (int)base + _cad_offset, base, - (unsigned)_cad_probe_interval_s, + (unsigned)_probe_interval_s, (unsigned)_cad_busycap_pct); /* Only the 3 rungs around the operating offset — the far rungs are mildly diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index cab3766..99041fe 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -200,6 +200,16 @@ protected: * shorter retry when an attempt is turned away because the radio was * mid-packet / transmitting / in its duty-cycle sleep window. */ int64_t _noise_floor_next_ms; + uint8_t _noise_floor_retries; /* consecutive blocked attempts, capped */ + /* Shared cadence for every periodic radio measurement (floor sample + + * CAD probe). Runtime, from the probe.interval pref. */ + uint32_t _measure_interval_ms; + /* Latest floor sample, published for cadMaintenance() so the CAD probe + * shares this measurement instead of taking its own single RSSI read. + * _sample_fresh is true only within the pass that produced it. */ + int16_t _sample_rssi; + bool _sample_channel_quiet; + bool _sample_fresh; /* Adaptive CAD state */ struct CadLevelStats { @@ -211,7 +221,7 @@ protected: CadLevelStats _cad_stats[CAD_NUM_LEVELS]; bool _cad_auto; /* staircase acts on the stats */ int8_t _cad_offset; /* operating detPeak offset (levels) */ - uint16_t _cad_probe_interval_s; /* 0 = probing disabled */ + uint16_t _probe_interval_s; /* 0 = CAD probing disabled; drives _measure_interval_ms */ uint8_t _cad_busycap_pct; /* airtime cap: max % TX deferred (0 = off) */ int64_t _cad_last_probe_ms; int64_t _cad_last_decay_ms; @@ -219,7 +229,6 @@ protected: * interval check in cadMaintenance() is against _cad_last_probe_ms, * which only advances on a probe that actually ran — without this a * blocked probe would report "due now" forever and spin the wake. */ - int64_t _cad_retry_ms; uint8_t _cad_probe_rr; /* round-robin index (sweep) / frontier mix counter */ int8_t pickCadProbeLevel(); diff --git a/zephcore/adapters/radio/radio_common.h b/zephcore/adapters/radio/radio_common.h index ce2b85b..a735c12 100644 --- a/zephcore/adapters/radio/radio_common.h +++ b/zephcore/adapters/radio/radio_common.h @@ -37,6 +37,8 @@ * rather than the exception — a shorter retry there can push the wake rate * ABOVE the fixed tick this conversion replaced, inverting the whole point. */ #define NOISE_FLOOR_RETRY_MS 5000 +/* Blocked attempts allowed before standing down to the next full interval. */ +#define NOISE_FLOOR_MAX_RETRIES 2 /* --- Adaptive CAD (LBT detPeak calibration) --- * Housekeeping-tick CAD probes accumulate per-level busy/free statistics; @@ -85,11 +87,11 @@ #define CAD_BUSY_DEFER_HYST_PERMILLE 100 /* descend only if frontier busy <= cap-10% */ #define CAD_PROBE_RSSI_GUARD 7 /* dB above floor = channel visibly busy, skip probe */ #define CAD_STATS_DECAY_MS (6UL * 3600UL * 1000UL) /* halve counters every 6 h */ -/* Retry deadline for a due probe turned away by the idle-RX guards or the - * RSSI prefilter. Those paths leave _cad_last_probe_ms untouched (by design — - * a skipped probe is not a probe), so the deadline query needs its own marker - * to avoid reporting "due now" on every wake. */ -#define CAD_PROBE_RETRY_MS 2000 +/* NOTE: the probe has no retry deadline and no wake of its own. It runs off + * the noise-floor sampler's measurement (LoRaRadioBase::cadMaintenance), which + * already applies the idle-RX guards and yields a median-of-8. Consequently + * the effective probe rate is quantised to NOISE_FLOOR_INTERVAL_MS: setting + * probe_interval below that just gets one probe per floor sample. */ /* --- RX ring buffer --- */ #define RX_RING_SIZE 8 /* ~2 KB; buffers burst arrivals at SF7/BW500 */ @@ -146,6 +148,39 @@ static inline enum lora_signal_bandwidth bw_khz_to_enum(uint16_t bw_khz) } } +/* Lowest physically-possible noise floor for a given bandwidth, in dBm. + * + * This is raw thermal noise — kTB at 290 K, with NO noise-figure term: + * -174 dBm/Hz + 10*log10(BW_Hz) + * A passive receiver cannot read below it, so it is the one place a sanity + * clamp belongs: anything under this line is a bad RSSI read, not a quiet + * site. Adding a receiver noise figure here would clamp ABOVE what the + * hardware can legitimately report and manufacture a floor — which is exactly + * what the old fixed -120 rail did to BW 62.5 kHz, pinning it on every EMA + * update because -120 happens to be that preset's kTB+NF. + * + * Bandwidth is the only term that moves. SF changes the SNR the demodulator + * can decode at, not the noise power in the channel, so it must NOT appear. + * + * For reference, a typical SX126x (NF ~6 dB) reads about 6 dB above these: + * BW 62.5 kHz measures ~-120 dBm on a quiet site against a -126 kTB limit. */ +static inline int16_t noise_floor_min_dbm(uint16_t bw_khz) +{ + switch (bw_khz) { + case 7: return -135; /* 10*log10(7800) = 38.9 */ + case 10: return -134; /* 10*log10(10400) = 40.2 */ + case 15: return -132; /* 10*log10(15600) = 41.9 */ + case 20: return -131; /* 10*log10(20800) = 43.2 */ + case 31: return -129; /* 10*log10(31250) = 45.0 */ + case 41: return -128; /* 10*log10(41700) = 46.2 */ + case 62: return -126; /* 10*log10(62500) = 48.0 */ + case 125: return -123; /* 10*log10(125000) = 51.0 */ + case 250: return -120; /* 10*log10(250000) = 54.0 */ + case 500: return -117; /* 10*log10(500000) = 57.0 */ + default: return -123; /* matches bw_khz_to_enum's 125 kHz fallback */ + } +} + /* CR 5-8 → Zephyr coding_rate enum */ static inline enum lora_coding_rate cr_to_enum(uint8_t cr) { diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index ead2fa1..349675e 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -209,7 +209,7 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { * auto=0, offset=0, probe_interval=60) */ fs_read(&file, &prefs.cad_auto, sizeof(prefs.cad_auto)); fs_read(&file, &prefs.cad_offset, sizeof(prefs.cad_offset)); - fs_read(&file, &prefs.cad_probe_interval, sizeof(prefs.cad_probe_interval)); + fs_read(&file, &prefs.probe_interval, sizeof(prefs.probe_interval)); /* cad_busycap absent in <301-byte files; EOF read keeps default 25 */ fs_read(&file, &prefs.cad_busycap, sizeof(prefs.cad_busycap)); @@ -243,7 +243,7 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { if (prefs.meshtimesync > 1) prefs.meshtimesync = 0; if (prefs.cad_auto > 1) prefs.cad_auto = 0; if (prefs.cad_offset < CAD_OFFSET_MIN || prefs.cad_offset > CAD_OFFSET_MAX) prefs.cad_offset = 0; - if (prefs.cad_probe_interval != 0 && prefs.cad_probe_interval < 10) prefs.cad_probe_interval = 10; + if (prefs.probe_interval != 0 && prefs.probe_interval < 10) prefs.probe_interval = 10; if (prefs.cad_busycap > 90) prefs.cad_busycap = 90; /* One-time format upgrade: old files (< 294 bytes) never saved the ZephCore @@ -349,7 +349,7 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { /* Adaptive CAD (offsets 297-300) */ fs_write(&file, &prefs.cad_auto, sizeof(prefs.cad_auto)); fs_write(&file, &prefs.cad_offset, sizeof(prefs.cad_offset)); - fs_write(&file, &prefs.cad_probe_interval, sizeof(prefs.cad_probe_interval)); + fs_write(&file, &prefs.probe_interval, sizeof(prefs.probe_interval)); fs_write(&file, &prefs.cad_busycap, sizeof(prefs.cad_busycap)); ret = fs_sync(&file); diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index cf1e171..ab959c9 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -59,6 +59,8 @@ static void uplink_time_sync_cb(uint32_t unix_ts) if (s_uplink_mesh) { s_uplink_mesh->getRTCClock()->setCurrentTime(unix_ts); atomic_set(&s_uplink_sntp_pending, 1); + /* Wake the main loop rather than waiting for its next deadline. */ + s_uplink_mesh->notifyWake(); } } #endif @@ -1018,6 +1020,11 @@ void RepeaterMesh::begin(RepeaterDataStore* store) { * loop via an atomic flag drained in maintenanceLoop(). */ if (s_uplink_mesh) { atomic_set(&s_uplink_mesh->_uplink_connect_pending, 1); + /* Wake the main loop so "online" publishes immediately. + * Without this the flag waits for whatever deadline fires + * next — up to UPLINK_STATUS_INTERVAL_MS (5 min) on an idle + * repeater, since maintenance no longer ticks every 5 s. */ + s_uplink_mesh->notifyWake(); } }); _uplink_next_status_at = futureMillis(UPLINK_STATUS_INTERVAL_MS); diff --git a/zephcore/app/RepeaterMesh.h b/zephcore/app/RepeaterMesh.h index ca6fb29..c83856d 100644 --- a/zephcore/app/RepeaterMesh.h +++ b/zephcore/app/RepeaterMesh.h @@ -179,7 +179,7 @@ protected: } void applyCadPrefs() override { _radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset, - _prefs.cad_probe_interval, _prefs.cad_busycap); + _prefs.probe_interval, _prefs.cad_busycap); } void resetCadStats() override { _radio->resetCadStats(); diff --git a/zephcore/app/RoomServerMesh.h b/zephcore/app/RoomServerMesh.h index 5bdee41..adf256e 100644 --- a/zephcore/app/RoomServerMesh.h +++ b/zephcore/app/RoomServerMesh.h @@ -148,7 +148,7 @@ protected: } void applyCadPrefs() override { _radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset, - _prefs.cad_probe_interval, _prefs.cad_busycap); + _prefs.probe_interval, _prefs.cad_busycap); } void resetCadStats() override { _radio->resetCadStats(); diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index ee36a4f..17bd502 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -126,7 +126,7 @@ void CommonCLI::loadPrefs(const char* path) { ok = ok && prefs_read(&file, &_prefs->meshtimesync, sizeof(_prefs->meshtimesync)); // 296 ok = ok && prefs_read(&file, &_prefs->cad_auto, sizeof(_prefs->cad_auto)); // 297 ok = ok && prefs_read(&file, &_prefs->cad_offset, sizeof(_prefs->cad_offset)); // 298 - ok = ok && prefs_read(&file, &_prefs->cad_probe_interval, sizeof(_prefs->cad_probe_interval)); // 299 + ok = ok && prefs_read(&file, &_prefs->probe_interval, sizeof(_prefs->probe_interval)); // 299 ok = ok && prefs_read(&file, &_prefs->cad_busycap, sizeof(_prefs->cad_busycap)); // 300 if (!ok) { @@ -174,8 +174,8 @@ void CommonCLI::loadPrefs(const char* path) { _prefs->meshtimesync = constrain(_prefs->meshtimesync, (uint8_t)0, (uint8_t)1); _prefs->cad_auto = constrain(_prefs->cad_auto, (uint8_t)0, (uint8_t)1); _prefs->cad_offset = constrain(_prefs->cad_offset, (int8_t)CAD_OFFSET_MIN, (int8_t)CAD_OFFSET_MAX); - if (_prefs->cad_probe_interval != 0 && _prefs->cad_probe_interval < 10) { - _prefs->cad_probe_interval = 10; + if (_prefs->probe_interval != 0 && _prefs->probe_interval < 10) { + _prefs->probe_interval = 10; } _prefs->cad_busycap = constrain(_prefs->cad_busycap, (uint8_t)0, (uint8_t)90); @@ -249,7 +249,7 @@ void CommonCLI::savePrefs(const char* path) { fs_write(&file, &_prefs->meshtimesync, sizeof(_prefs->meshtimesync)); fs_write(&file, &_prefs->cad_auto, sizeof(_prefs->cad_auto)); fs_write(&file, &_prefs->cad_offset, sizeof(_prefs->cad_offset)); - fs_write(&file, &_prefs->cad_probe_interval, sizeof(_prefs->cad_probe_interval)); + fs_write(&file, &_prefs->probe_interval, sizeof(_prefs->probe_interval)); fs_write(&file, &_prefs->cad_busycap, sizeof(_prefs->cad_busycap)); fs_close(&file); @@ -566,6 +566,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else if (memcmp(config, "dc.restarts", 11) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_callbacks->getDutyCycleTimeoutRestarts()); + } else if (memcmp(config, "probe.interval", 14) == 0) { + /* Seconds between periodic radio measurements — the noise-floor + * sample and the CAD probe that consumes it. 0 = probing off. */ + snprintf(reply, CLI_REPLY_SIZE, "> %u", + (uint32_t)_prefs->probe_interval); } else if (memcmp(config, "cad", 3) == 0) { /* Runtime state + per-level probe stats live in the radio. * Remote replies get the truncated buffer like meshtimesync. */ @@ -653,12 +658,14 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch savePrefs(); strcpy(reply, "OK"); } - } else if (memcmp(config, "cad.probe.interval ", 19) == 0) { - int val = atoi(&config[19]); + /* Governs every periodic radio measurement, not just CAD — the + * noise-floor sampler and the CAD probe share one reading. */ + } else if (memcmp(config, "probe.interval ", 15) == 0) { + int val = atoi(&config[15]); if (val != 0 && (val < 10 || val > 255)) { - strcpy(reply, "Error: interval is 0 (off) or 10-255 seconds"); + strcpy(reply, "Error: interval is 0 (probing off) or 10-255 seconds"); } else { - _prefs->cad_probe_interval = (uint8_t)val; + _prefs->probe_interval = (uint8_t)val; _callbacks->applyCadPrefs(); savePrefs(); strcpy(reply, "OK"); diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index cb3144b..2b4908b 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -90,7 +90,9 @@ struct NodePrefs { uint8_t meshtimesync; // 1 = mesh time-sync clock correction on (default off) uint8_t cad_auto; // 1 = adaptive-CAD staircase acts on probe stats (default off = dry-run) int8_t cad_offset; // operating detPeak offset from family base (-4..4) - uint8_t cad_probe_interval; // seconds between CAD probes (0 = probing off, default 60) + uint8_t probe_interval; // seconds between periodic radio measurements: + // one noise-floor sample, and the CAD probe that + // consumes it (0 = CAD probing off, default 15) uint8_t cad_busycap; // airtime-protection: max % of TX attempts deferred before backing off detPeak (0 = off, default 25) /* ---- Companion-only fields ---- */ @@ -165,7 +167,7 @@ static inline void initNodePrefs(NodePrefs* prefs) { prefs->_reserved_apc_margin = 0; // reserved (was APC), see NodePrefs prefs->cad_auto = 1; // Default ON — adaptive staircase acts on probe stats prefs->cad_offset = 0; // Start at family base detPeak (SF+13 on SX126x) - prefs->cad_probe_interval = 15; // 15 s → staircase responds to change in ~1-2 h + prefs->probe_interval = 15; // floor sample + CAD probe; staircase responds in ~1-2 h prefs->cad_busycap = 25; // back off detPeak once >25% of TX attempts are deferred prefs->wake_on_msg = 1; // Default ON — wake display when message arrives prefs->v_contact_enabled = 1; // Default ON — v-contact loopback admin chat (companion) diff --git a/zephcore/include/mesh/Dispatcher.h b/zephcore/include/mesh/Dispatcher.h index b6d1781..1624401 100644 --- a/zephcore/include/mesh/Dispatcher.h +++ b/zephcore/include/mesh/Dispatcher.h @@ -35,6 +35,12 @@ public: /* Notifies event loop of pending TX so it can schedule a wake. */ typedef void (*tx_queued_callback_t)(uint32_t delay_ms, void *user_data); +/* Wakes the event loop so loop() runs at the next opportunity. For off-main + * code that sets state loop() must drain (MQTT CONNACK, SNTP): without it that + * state waits for whatever deadline happens to fire next, which since the move + * to deadline-driven maintenance can be minutes rather than the old 5 s tick. */ +typedef void (*wake_callback_t)(void *user_data); + typedef uint32_t DispatcherAction; #define ACTION_RELEASE (0) @@ -66,6 +72,8 @@ class Dispatcher { uint32_t n_recv_flood, n_recv_direct; tx_queued_callback_t _tx_queued_cb; void *_tx_queued_user_data; + wake_callback_t _wake_cb; + void *_wake_user_data; void processRecvPacket(Packet *pkt); @@ -126,6 +134,15 @@ public: _tx_queued_cb = cb; _tx_queued_user_data = user_data; } + void setWakeCallback(wake_callback_t cb, void *user_data) { + _wake_cb = cb; + _wake_user_data = user_data; + } + /* Safe from any thread: the callback only posts an event. Public because + * off-main C callbacks (e.g. the SNTP hook) are not class members. */ + void notifyWake() { + if (_wake_cb) _wake_cb(_wake_user_data); + } bool millisHasNowPassed(uint32_t timestamp) const; uint32_t futureMillis(int millis_from_now) const; diff --git a/zephcore/src/Dispatcher.cpp b/zephcore/src/Dispatcher.cpp index 1ef9817..80fe38b 100644 --- a/zephcore/src/Dispatcher.cpp +++ b/zephcore/src/Dispatcher.cpp @@ -43,6 +43,8 @@ Dispatcher::Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr) n_recv_flood = n_recv_direct = 0; _tx_queued_cb = nullptr; _tx_queued_user_data = nullptr; + _wake_cb = nullptr; + _wake_user_data = nullptr; } void Dispatcher::begin() diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 3a878a3..8af9b21 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -737,7 +737,7 @@ public: void applyCadPrefs() override { lora_radio.setCadParams(companion_mesh.prefs.cad_auto != 0, companion_mesh.prefs.cad_offset, - companion_mesh.prefs.cad_probe_interval, + companion_mesh.prefs.probe_interval, companion_mesh.prefs.cad_busycap); } void resetCadStats() override { @@ -1385,7 +1385,7 @@ int main(void) * its proper default. A hand-maintained subset here silently drifts: any * field not listed defaults to 0, and on upgrade the past-EOF read in * loadPrefs then keeps that 0 instead of the real default (this is what - * zeroed cad_probe_interval / cad_auto and, earlier, the GPS settings). */ + * zeroed probe_interval / cad_auto and, earlier, the GPS settings). */ initNodePrefs(&companion_mesh.prefs); /* Companion-specific overrides vs. initNodePrefs defaults: */ companion_mesh.prefs.auto_shutdown_mv = CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS; /* low-batt cutoff (0=off) */ @@ -1547,7 +1547,7 @@ int main(void) lora_radio.enableRxDutyCycle(companion_mesh.prefs.rx_duty_cycle != 0); lora_radio.setCadParams(companion_mesh.prefs.cad_auto != 0, companion_mesh.prefs.cad_offset, - companion_mesh.prefs.cad_probe_interval, + companion_mesh.prefs.probe_interval, companion_mesh.prefs.cad_busycap); ui_set_radio_runtime( lora_radio.getActiveSyncWord(), diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index 52ffbfd..f608737 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -103,7 +103,8 @@ static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(LED1_NODE, gpios); #define MESH_EVENT_TX_DRAIN BIT(5) /* Outbound packet delay expired, run checkSend */ #define MESH_EVENT_RTC_SAVE BIT(6) /* Hardware-RTC write requested off-main */ #define MESH_EVENT_INIT_ADVERT BIT(7) /* Deferred boot advert — send on main thread */ -#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | MESH_EVENT_MAINTENANCE | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_RTC_SAVE | MESH_EVENT_INIT_ADVERT) +#define MESH_EVENT_WAKE BIT(8) /* Off-main state set; run loop() promptly */ +#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | MESH_EVENT_MAINTENANCE | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_RTC_SAVE | MESH_EVENT_INIT_ADVERT | MESH_EVENT_WAKE) /* Maintenance is deadline-driven, not periodic: after every pass the loop asks * the mesh when its soonest pending deadline is (msUntilNextMaintenance) and @@ -290,10 +291,11 @@ static void maintenance_timer_fn(struct k_timer *timer) static void arm_maintenance_wake(void) { uint32_t delay = MAINTENANCE_BACKSTOP_MS; + uint32_t next = MAINTENANCE_BACKSTOP_MS; #ifdef ZEPHCORE_LORA if (repeater_mesh_ptr) { - uint32_t next = repeater_mesh_ptr->msUntilNextMaintenance(); + next = repeater_mesh_ptr->msUntilNextMaintenance(); if (next < delay) { delay = next; @@ -305,6 +307,15 @@ static void arm_maintenance_wake(void) delay = MAINTENANCE_MIN_MS; } + /* Both figures, because which one is binding is the whole diagnosis: + * next=15000 armed=15000 — a real deadline won; working as intended + * next=15000 armed=5000 — the backstop is clamping (it must stay + * above the noise-floor/CAD intervals) + * next=0 armed=50 — a deadline reports due but its state is + * not advancing; repeated = spin + * mesh::MAINTENANCE_IDLE (0x7FFFFFFF) as `next` means nothing pending. */ + LOG_DBG("maint: next=%u armed=%u", (unsigned)next, (unsigned)delay); + k_timer_start(&maintenance_timer, K_MSEC(delay), K_NO_WAIT); } @@ -346,6 +357,15 @@ static void tx_queued_callback(uint32_t delay_ms, void *user_data) ARG_UNUSED(user_data); k_work_reschedule(&tx_drain_work, K_MSEC(delay_ms)); } + +/* Off-main code (MQTT CONNACK, SNTP) set state that loop() must drain. Post + * only — this runs on the MQTT publisher / WiFi thread, and k_event_post is + * the sole thing safe to do from there. */ +static void wake_callback(void *user_data) +{ + ARG_UNUSED(user_data); + k_event_post(&mesh_events, MESH_EVENT_WAKE); +} #endif /* Global instances */ @@ -487,23 +507,44 @@ static void repeater_event_loop(void) /* Packet processing — only on radio/CLI/TX events */ if (repeater_mesh_ptr && (events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | - MESH_EVENT_CLI_RX | MESH_EVENT_TX_DRAIN))) { + MESH_EVENT_CLI_RX | MESH_EVENT_TX_DRAIN | + MESH_EVENT_WAKE))) { repeater_mesh_ptr->loop(); } #endif - /* A maintenance deadline came due — run the pass. */ +#ifdef ZEPHCORE_LORA + /* Radio maintenance runs OPPORTUNISTICALLY, on every pass, whatever + * woke us — not only when its own timer fired. + * + * Every item inside is deadline-gated internally, so this is nearly + * free when nothing is due. The point is what it does to a busy + * node: a hilltop repeater is already waking constantly for packets, + * so maintenance rides along on those wakes, its deadlines advance, + * and arm_maintenance_wake() below keeps pushing the timer out — the + * maintenance timer then almost never fires and costs no wakes at + * all. Running it only on its own event (as this did originally) + * inverted that: the busiest nodes, which can least afford it, paid + * the full timer cadence on top of their packet wakes, and every + * blocked sample burned a retry against a channel that is busy for + * sustained reasons (real traffic in isReceiving(), the RSSI + * prefilter) rather than the transient duty-cycle sleep window the + * retries were sized for. + * + * Ordering matters: this sits AFTER packet processing so an inbound + * frame is handled before we spend SPI time on an RSSI sweep. */ + if (repeater_mesh_ptr) { + repeater_mesh_ptr->maintenanceLoop(); + } +#endif + + /* A maintenance deadline came due — run the time-based work too. */ if (events & MESH_EVENT_MAINTENANCE) { #ifdef ZEPHCORE_LORA - /* Radio maintenance: noise floor calibration, AGC reset, - * RX watchdog. Separated from loop() so these never run - * on packet-driven events. */ + /* Drive loop() so time-based actions (advert timers, + * tempradio set/revert, contacts flush, uplink status) + * still fire when no LoRa/CLI traffic wakes the loop. */ if (repeater_mesh_ptr) { - repeater_mesh_ptr->maintenanceLoop(); - /* Also drive loop() so time-based actions (advert - * timers, tempradio set/revert, contacts flush, - * uplink status) still fire when no LoRa/CLI - * traffic wakes the event loop. */ repeater_mesh_ptr->loop(); } #endif @@ -633,6 +674,7 @@ int main(void) lora_radio.setRxCallback(lora_rx_callback, nullptr); lora_radio.setTxDoneCallback(lora_tx_done_callback, nullptr); repeater_mesh.setTxQueuedCallback(tx_queued_callback, nullptr); + repeater_mesh.setWakeCallback(wake_callback, nullptr); /* Load or generate identity BEFORE begin(). First-boot keygen runs * the layered entropy mixer + Ed25519 derive + reserved-prefix @@ -680,7 +722,7 @@ int main(void) lora_radio.setRxBoost(prefs->rx_boost != 0); lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0); lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset, - prefs->cad_probe_interval, prefs->cad_busycap); + prefs->probe_interval, prefs->cad_busycap); /* Feed initial UI state from loaded prefs */ ui_set_node_name(prefs->node_name); diff --git a/zephcore/src/main_room_server.cpp b/zephcore/src/main_room_server.cpp index 914d344..92596aa 100644 --- a/zephcore/src/main_room_server.cpp +++ b/zephcore/src/main_room_server.cpp @@ -610,7 +610,7 @@ int main(void) lora_radio.setRxBoost(prefs->rx_boost != 0); lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0); lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset, - prefs->cad_probe_interval, prefs->cad_busycap); + prefs->probe_interval, prefs->cad_busycap); /* Feed initial UI state from loaded prefs */ ui_set_node_name(prefs->node_name);