# ZephCore Architecture Guide > Comprehensive developer reference for the ZephCore codebase — a Zephyr RTOS port of the Arduino MeshCore LoRa mesh networking firmware. > For the system view (layers, rules, threading, upstream policy) start with [DESIGN.md](DESIGN.md); decisions are in [adr/](adr/README.md). --- ## Table of Contents 1. [Project Overview](#1-project-overview) 2. [Directory Structure](#2-directory-structure) 3. [Layer Architecture](#3-layer-architecture) 4. [Core Mesh Engine](#4-core-mesh-engine) 5. [Radio Subsystem](#5-radio-subsystem) 6. [Application Layer](#6-application-layer) 7. [Hardware Adapters](#7-hardware-adapters) 8. [UI Subsystem](#8-ui-subsystem) 9. [Build System](#9-build-system) 10. [Board Matrix](#10-board-matrix) 11. [Packet Format Reference](#11-packet-format-reference) 12. [BLE Protocol Reference](#12-ble-protocol-reference) 13. [Data Storage](#13-data-storage) 14. [Key Call Flows](#14-key-call-flows) 15. [Watchdogs and Recovery Mechanisms](#15-watchdogs-and-recovery-mechanisms) --- ## 1. Project Overview ZephCore is a LoRa mesh networking firmware running on Zephyr RTOS. It supports four device roles: - **Companion**: BLE-connected device paired with a phone app. Full contact/channel/message management. - **Repeater**: Autonomous headless relay node. CLI administration via authenticated mesh connections or serial UART. - **Room Server**: Headless store-and-forward shared message room (BBS). Reuses the repeater's ACL/region/CLI; pushes new posts to logged-in clients (per-client sync cursor + ACK). - **Observer** (ESP32): Listen-only node that publishes received LoRa packets to MQTT over WiFi. Supported hardware: nRF52840, nRF54L15, nRF54LM20A, ESP32 (classic PICO-D4 and C3/C6/S3), EFR32MG24, and STM32WL (LoRa-E5). Radios: SX126x family (SX1261/62/68, LLCC68, STM32WL sub-GHz), LR1110, SX127x (SX1272/76/78, loramac-node backend), and LR2021 (validated on the MeshTracker X1). A native Linux port runs the full stack on SBCs (Femtofox, Raspberry Pi) via Zephyr `native_sim` — see `LINUX_NATIVE.md`. ### Upstream Relationship ZephCore is a port of [Arduino MeshCore](https://github.com/meshcore-dev/MeshCore). The core mesh protocol (Mesh.cpp, Dispatcher.cpp, Packet.cpp, Identity.cpp, Utils.cpp) is shared code. Adapters (`adapters/`) bridge MeshCore's HAL interfaces to Zephyr APIs. Binary file formats (prefs, contacts, channels) are byte-compatible with Arduino MeshCore. --- ## 2. Directory Structure ``` zephcore/ ├── src/ # Core mesh engine (shared with Arduino MeshCore) │ ├── Mesh.cpp # Routing protocol: flood, direct, dedup, adverts │ ├── Dispatcher.cpp # Packet queue, radio scheduling, CAD, duty cycle │ ├── Packet.cpp # Packet serialization, hash, wire format │ ├── Identity.cpp # Ed25519 key management, ECDH shared secrets │ ├── Utils.cpp # AES-ECB encrypt, HMAC-SHA256, MAC │ ├── ContentionTracker.cpp # Adaptive contention window (EMA, backoff) │ ├── StaticPoolPacketManager.cpp # Fixed-size packet pool (32 slots) │ ├── main_companion.cpp # Companion mode entry point + event loop │ ├── main_repeater.cpp # Repeater entry point: its mesh object + ServerRole │ ├── main_room_server.cpp # Room server entry point: its mesh object + ServerRole │ ├── server_main_common.{h,cpp} # Both server roles: USB CLI, event loop, boot │ └── mesh_events.h # Event bits shared by every role's 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 │ ├── SimpleMeshTables.h # Hash-based packet deduplication │ └── StaticPoolPacketManager.h # Fixed pool allocator │ ├── adapters/ # Zephyr HAL implementations │ ├── radio/ # LoRa radio drivers │ │ ├── LoRaRadio.cpp/h # The one radio adapter: TX/RX state machine, noise floor, CAD, hygiene │ │ ├── LoRaRadioOps.h # Per-family ops table (driver extension functions) │ │ ├── radio_ops_.cpp # sx126x / sx127x / lr1110 / lr2021 tables (one linked per build) │ │ ├── CadController.* # Adaptive CAD controller (host-tested) │ │ ├── NoiseFloorEstimator.h # Noise-floor EMA (host-tested) │ │ └── radio_common.h, radio_tuning.h # Shared constants │ ├── ble/ZephyrBLE.cpp/h # BLE NUS service, pairing, TX congestion │ ├── board/ZephyrBoard.cpp/h # Battery ADC, LEDs, reboot, bootloader, pwrmgt reasons │ ├── board/zephyr_poweroff.c/h # The one power-off path + shutdown-reason marker │ ├── clock/ # Millisecond uptime + software RTC + I2C RTC discovery │ ├── datastore/ZephyrDataStore.cpp/h # LittleFS persistence │ ├── gps/ZephyrGPSManager.cpp/h # GNSS state machine, fix validation, public API │ ├── gps/gps_power.cpp, gps_module_cfg.cpp, gps_internal.h # module power; module configuration + diag │ ├── mqtt/ZephyrMQTTPublisher.c/h # MQTT packet publisher (observer / uplink) │ ├── mqtt/uplink_creds.h # WiFi/MQTT/location credentials struct (persisted by app/observer_creds) │ ├── ota/wifi_ota.c/h # WiFi SoftAP + HTTP firmware upload │ ├── rng/ZephyrRNG.cpp/h # Hardware CSPRNG with PRNG fallback │ ├── sensors/ # ZephyrSensorManager (upstream SensorManager) over the 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 │ ├── CompanionProtocol.h # Companion protocol opcodes and codes │ ├── CompanionCLI.h # Companion side of CommonCLI │ ├── VContact.cpp # Loopback admin contact (CompanionMesh members) │ ├── RepeaterMesh.cpp/h # Autonomous repeater logic │ ├── RepeaterUplink.cpp # Repeater WiFi+MQTT uplink (ESP32) │ ├── RepeaterDataStore.cpp/h # Repeater-specific persistence paths │ ├── RoomServerMesh.cpp/h # Store-and-forward room server (BBS) │ ├── ObserverMesh.cpp/h # Listen-only WiFi+MQTT observer (ESP32) │ └── main_observer.cpp, observer_creds.cpp/h │ ├── helpers/ # Shared utilities │ ├── compat/ # Arduino Stream/SHA256/FILESYSTEM over Zephyr, for upstream-verbatim code │ ├── 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, Seeed LR2021 EVK (nRF54L15 and nRF54LM20A), ME25LS02 │ ├── esp32/ # XIAO C3/C6/S3, Heltec V3/V4.x, Station G2, T-Beam, ... │ ├── mg24/ # XIAO MG24 │ ├── stm32wl/ # Seeed LoRa-E5 mini │ └── linux_native/ # native_sim presets (Femtofox, RAK6421) — see LINUX_NATIVE.md │ ├── patches/ # Zephyr tree modifications │ ├── zephyr/ # Unified diffs (SX126x extensions, GNSS, native Linux, ...) │ └── zephyr-new/ # New files (LR11xx/LR20xx drivers, native Linux SPI/GPIO, DTS bindings) │ ├── lib/monocypher/ # Vendored crypto library (Ed25519/X25519) ├── tools/ # Formatter (flash erase) + LR1110 firmware updater ├── CMakeLists.txt # Build orchestration ├── Kconfig # All ZephCore configuration options ├── Kconfig.psram # ESP32 PSRAM auto-enable from devicetree ├── prj.conf # Base project config ├── sysbuild.conf # Forces MCUboot when --sysbuild is used └── west.yml # West manifest (Zephyr version pin) ``` --- ## 3. Layer Architecture ``` ┌─────────────────────────────────────────────────┐ │ Phone App (BLE NUS / USB CDC / TCP / UART) │ External │ or Serial CLI (USB CDC / PTY) │ ├─────────────────────────────────────────────────┤ │ CompanionMesh / RepeaterMesh / │ App Layer │ RoomServerMesh / ObserverMesh │ │ ├── BaseChatMesh (contacts, channels, msgs) │ │ ├── CommonCLI (command processor) │ │ ├── ClientACL, RegionMap, TransportKeyStore │ │ └── UI (display, buzzer, buttons) │ ├─────────────────────────────────────────────────┤ │ mesh::Mesh │ Routing │ ├── Flood routing (path hash accumulation) │ │ ├── Direct routing (source-routed paths) │ │ ├── Packet dedup (SimpleMeshTables) │ │ └── Advert / ACK / Trace / Group dispatch │ ├─────────────────────────────────────────────────┤ │ mesh::Dispatcher │ Scheduling │ ├── TX/RX queue management │ │ ├── CAD (channel activity detection) │ │ ├── Duty cycle enforcement (EU ETSI) │ │ ├── RX delay (score-based prioritization) │ │ └── Maintenance (noise floor, image cal) │ ├─────────────────────────────────────────────────┤ │ LoRaRadio + kLoRaRadioOps (one per build) │ Radio HAL │ ├── sx126x ──► Zephyr SX126x driver (0003) │ │ ├── sx127x ──► loramac-node backend │ │ ├── lr1110 ──► our LR11xx driver + LBM SDK │ │ └── lr2021 ──► our LR20xx driver + SDK │ ├─────────────────────────────────────────────────┤ │ 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. checkRecv(): Drain radio RX ring buffer - Parse raw bytes into Packet - Process immediately (no inbound queue / score-based RX delay: the adaptive contention window replaces upstream's rxdelay) 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 ``` **Duty Cycle**: Fixed 1-hour sliding window. Default 10%. Admin packets (REQ, RESPONSE, ANON_REQ, CONTROL) are exempt. ### 4.6 Maintenance Loop Called every ~5 seconds from the main event loop: 1. **Noise floor calibration**: EMA with alpha=1/8, jitter, threshold filtering, warmup 2. **RX mode watchdog**: Flags error if radio stuck outside RX for >8 seconds 3. **AGC reset** (`agcIdleMaintenance()`): warm sleep + recalibration, **SX126x only** — gated on `hwNeedsAgcReset()`, which only that family declares. Semtech prescribe it for a jammed AGC on the SX126x; neither the LR11xx UM nor the LR2021 DS describes such a fault, and running it there cost packets (T1000-E, 2026-08-24: 7.4% miss rate in the 60 s after a fire vs 0.6% elsewhere). Triggered by long silence **and** corroborating evidence — a frozen noise-floor reading — never by silence alone, which is a normal condition rather than a fault. 4. **Image-calibration drift** (`imageCalMaintenance()`): unrelated to the AGC despite the shared hook — LR11xx/LR2021 only, where the datasheets give a temperature threshold. Temperature is read from the **board** (`Board::getMCUTemperature()`), never from the radio; only the delta matters and the die sensor tracks the same ambient without costing the radio an SPI command. Polled hourly, deferred after TX so PA self-heating is not read as ambient drift, and confirmed by a second reading before recalibrating. ### 4.7 Adaptive Contention Window Replaces Arduino MeshCore's static `txdelay`/`rxdelay` with three complementary mechanisms. **EMA Delay Factor (proactive)** `ContentionTracker` measures observed duplicates per retransmitted packet using a **24-entry ring buffer** (sized for ~50-neighbor hilltop topologies with multiple concurrent in-flight floods). Each entry tracks a packet (identified by FNV-1a hash) and records how many dupes arrive within a 10-second observation window. When the window closes, the entry is finalized and an EMA is updated with alpha = 1/8. The resulting estimate feeds the delay factor formula: ``` factor = 0.05 + 0.170 * sqrt(est) ``` Capped at 2.0. During warmup (fewer than 4 finalized entries), factor defaults to 0.5. Sparse nodes converge toward near-zero delay; dense nodes get proportionally higher delay. The flood retransmit jitter window is `5·airtime·factor` clamped by **two ceilings**: - Airtime-scaled: `6·airtime` — keeps SF7/narrow-BW configs from wasting time in oversized windows. - Absolute: `2000ms` — bounds per-hop latency in dense areas even when airtime is large. **Per-Dupe Reactive Backoff** When a duplicate of a pending outbound packet is heard, TX is rescheduled to `now + backoff_multiplier * airtime`. Each dupe triggers a full delay (not diminishing). Cumulative reactive extension is capped at `min(2000ms, 12·airtime)` per packet; after the cap, CAD handles remaining channel activity. `backoff_multiplier` is configurable via `set backoff.multiplier X` (range 0.0–2.0). **Initial-Flood Jitter (companion-only)** Companions don't retransmit floods, but they observe mesh contention and need to spread their *originated* transmissions to avoid colliding with repeaters still busy in TX/RX. `Mesh::passivelyTrackFloods()` (overridden to `true` on `CompanionMesh`) registers every first-hearing of a flood with the ContentionTracker, so the EMA warms up even without forwarding. `Mesh::getInitialFloodJitter(packet)` is added to the caller-supplied delay in both `sendFlood` overloads; on companion this is `rand(0, min(1000ms, 3·airtime, 5·airtime·factor))` — half the repeater's ceilings. Repeaters keep the default 0 (no double-jitter on forwards). **Direct Packets** Direct (source-routed) packets bypass adaptive scaling entirely. They use minimal fixed jitter: `20 + rand(0, airtime / 10)` ms. **CLI** - `get txdelay` — shows current adaptive state (EMA estimate, delay factor, backoff multiplier). - `set backoff.multiplier X` — controls per-dupe reactive delay (0.0–2.0). - `txdelay`, `rxdelay`, `direct.txdelay` — accepted for prefs compatibility but ignored at runtime. **ContentionTracker Resource Usage** ~260 bytes RAM (24-entry ring buffer × ~16B/entry + state). FNV-1a packet hash, 10-second observation window, EMA with alpha = 1/8. ### 4.8 Encryption - **Peer-to-peer**: ECDH shared secret (Curve25519) → AES-128-ECB encrypt → 2-byte HMAC-SHA256 MAC - **Group channels**: SHA-256 of channel name → AES key - **Advertisements**: Ed25519 signature over (pubkey + timestamp + app_data) - **ACKs**: SHA-256(shared_secret + packet_hash) truncated to 4 bytes ### 4.9 Mesh Time Sync (Clock Consensus) ZephCore-only divergence from Arduino MeshCore (like the Adaptive Contention Window). A node senses its own clock error from the Ed25519-signed timestamps in other nodes' adverts and — **opt-in, default off** (`set meshtimesync on`) — corrects it automatically. There is no trusted reference clock on a mesh, so this is a *consensus estimation* problem: the node assumes the majority of tenured advert senders within 3 flood hops is right. User-facing doc: `MESHTIMESYNC.md` at the repo root. **Module**: `helpers/MeshTimeSync.{h,cpp}` — role-agnostic estimator, owns no clock. Each role feeds it verified adverts (`onAdvertHeard`), calls `tick()` periodically (15-min pacing internal), and applies STEP verdicts under its own policy. **Sample table** (per-sender, `CONFIG_ZEPHCORE_TIMESYNC_TABLE_SIZE` slots: 32 default, 16 on RAM-bound companions; 24 B/slot): - 8-byte pubkey prefix — a security floor, not a tuning knob (shorter prefixes are grindable: an attacker could collide a tenured voter's prefix and reset its tenure with validly-signed adverts). - Latest advert timestamp (= the vote, per-sender monotonic — replays and flood dupes are inert) + arrival **uptime**. Skew is recomputed at evaluate time from the uptime anchor, so the node's own steps never stale stored samples. - Tenure tracking: first-heard uptime, advert count. Eligibility = heard ≥ 1 h, ≥ 2 adverts, latest sample ≤ 5 days old (bridges the 47 h flood-advert cadence). - Self-consistency: consecutive samples must satisfy `|Δadvert_ts − Δuptime| ≤ 45 s + 150 ppm × Δuptime`; violation (sender rebooted/corrected/lying) resets that sender's tenure. - **Hop-priority admission** (hop cap 3): a new sender may only displace a young entry farther (higher hop) than it; mature entries are protected unless silent > 24 h. Naive LRU churned hub nodes to zero eligible voters in simulation. **Consensus**: Marzullo interval intersection over eligible votes, each `[skew − r, skew + r]` with `r = 150 s + 15 s × hop` (the 150 s base covers the real fleet's good-clock scatter, not just RF delay). No absolute outlier thresholds against the local clock — clustering does the rejection, so an epoch-reset clock still finds the true cluster. Stepping requires `CONFIG_ZEPHCORE_TIMESYNC_QUORUM` (default 6, floor 3, build-time security knob) eligible senders AND a strict majority inside the intersection; otherwise abstain. **Correction policy** (priority: GPS / manual set > mesh consensus): - Any clock set — GPS fix **or** manual set (`time`, `clock sync`, app time set) — arms the same **7-day suppression** of all stepping, bootstrap included, plus drift-envelope pedigree (`noteGPSSync` and `noteManualSync` are identical). A live GPS re-arms it on every fix (so a repeater's 48 h duty cycle keeps GPS owning the clock); a GPS that cannot fix (indoors, dead antenna) becomes mesh-correctable once 7 days pass without a fix. Sensing always continues; a suppressed node shows `hold (suppressed)` in the dry-run. - Step trigger 10 min, dead band 5 min, step capped **±1 h**, one step per **6 h**, logged loudly. Production contains coherent wrong-time islands (+28 h × 63 repeaters at analysis time); the cap bounds capture drag to 4 h/day. - **Drift-envelope gate**: with a trusted sync + continuous uptime since (pedigree, RAM-only), corrections beyond `elapsed × 300 ppm + 10 min` are physically impossible for a crystal — refused regardless of quorum. - **Bootstrap**: local time < firmware build epoch (`FIRMWARE_BUILD_EPOCH`, CMake-injected) is provably wrong → any 3 agreeing senders, step to the cluster's **low edge** (midpoint − 150 s; undershoot so later refinement is always forward = monotonicity-safe). **Per-role step policy** (policy lives in the role, not the estimator): | Role | Policy | Why | |---|---|---| | Repeater | bidirectional | clock not load-bearing: forwarding/dedup/remote-admin run on `millis()`/hashes; a backward step only mutes own adverts at peers for a window equal to the step | | Observer | bidirectional | clock only stamps observations — exactly what this fixes | | Room server | forward-only | post timestamps feed client `sync_since` ordering | | Companion | forward-only | own clock stamps outgoing DMs; peers hold per-sender replay high-water marks | **Step application**: the shared policy (suppression/pedigree checks inside `evaluateNow`, forward-only skip, uint32-overflow guard, set clock, one `zephcore_rtc_save` per step — never per evaluation) lives in `MeshTimeSync::runTick()`; when it returns true, the role shifts its wall-clock-anchored bookkeeping by `lastStepDelta()` — repeater: neighbor `heard_timestamp`s, ACL `last_activity`, login/anon/discover rate-limiter resets; room server: ACL + login limiter. All policy timers (6 h rate limit, 7-day suppression, tenure, sample age) anchor on **uptime, never wall clock** — otherwise the very steps they govern would distort them. **CLI**: `set meshtimesync {on|off}`, `get meshtimesync` → state + live dry-run (eligible count, votes for/against, skew/radius, would-be verdict) + per-sender evidence table (full table over local USB; remote admin replies are summary-truncated to fit the packet). Sensing always runs, so the dry-run works before enabling. **Accepted limits**: a coordinated same-offset majority around a node captures it (no consensus survives that — Bitcoin timejacking lesson; mitigations: default-off, manual override, caps); sub-quorum islands abstain forever (bootstrap still heals dead clocks with 3 senders). --- ## 5. Radio Subsystem ### 5.1 Class Hierarchy ``` mesh::Radio (abstract interface) └── LoRaRadio (final: state machine, ring buffer, noise floor, CAD, hygiene) uses kLoRaRadioOps, the family's const table of driver extension functions (LoRaRadioOps.h), one linked per build: radio_ops_sx126x.cpp → Zephyr native SX126x driver + sx126x_ext.h radio_ops_sx127x.cpp → Zephyr loramac-node backend (all entries NULL) radio_ops_lr1110.cpp → our lr11xx_lora.c driver + lr11xx_lora.h radio_ops_lr2021.cpp → our lr20xx_lora.c driver + lr20xx_lora.h ``` 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 LoRaRadio 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) `LoRaRadio::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; clears only on grace/deadline release └─ isChannelActive() RSSI fallback ← sub-preamble-threshold energy ``` For SX126x, `hwIsReceiving()` → `sx126x_is_receiving()` reads in this order: 1. **`data->rx_packet_active`** latch (no SPI). Set by the work handler on `HEADER_VALID`; cleared on every terminal event and RX (re)start. Covers the full payload phase. Bounded by a payload deadline: `header_seen_at_ms` is stamped when the latch is promoted, and once `sx126x_max_payload_ms()` (255-byte airtime at the current SF/BW, CR 4/8, LDRO on, +25% +100 ms) has elapsed the latch is released and the sticky PREAMBLE/SYNC/HEADER bits cleared. Continuous RX has no symbol timer, so without this a `HEADER_VALID` whose packet never completes would hold the TX gate closed until reboot; the DC parked-RX watchdog does not cover it (DC-only, and it treats the latch as a legitimate in-flight packet). 2. **Mutex-busy conservative** — if the SPI mutex is contended and `state == RX`, return true (the work handler is likely mid-`RxDone`). 3. **`HEADER_VALID` raw bit** — covers the microseconds between DIO1 firing and the work handler running. 4. **`PREAMBLE_DETECTED` raw bit with SF-aware grace** — `PREAMBLE_DETECTED` is masked off DIO1 (fires on noise), but visible in the IRQ register. On first observation, `is_receiving` records `data->preamble_seen_at_ms`; subsequent calls return true until either `HEADER_VALID` promotes the latch (timestamp reset) or `(preamble_len + 8) × 2^SF / BW` ms elapses — at which point the bit is explicitly cleared and TX is allowed. Grace scales with SF: ~82 ms at SF8, ~786 ms at SF12. The poll path is otherwise non-destructive — IRQ bits are cleared only by the work-handler bulk clear (on any DIO1 event), explicit `clear_irq_status(IRQ_ALL)` at every RX (re)start, the grace-expiry one-bit clear for foreign preambles, and the payload-deadline clear in step 1. ### 5.2.2 CAD-Timeout Recovery `Dispatcher::checkSend()` tracks `cad_busy_start` while `isReceiving()` keeps the TX gate closed. If 4 s elapse (`getCADFailMaxDuration()`), the dispatcher calls `_radio->recoverRxState()` and returns. `LoRaRadio::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. `LoRaRadio::cadMaintenance()` (housekeeping tick) runs one calibration CAD probe per `probe.interval` (default **15 s**) at a signed **level** relative to the family's per-SF base detPeak, restarts RX, and classifies busy verdicts with a ground-truth filter. **Key property:** the probe is *skipped* when RSSI > floor+7 dB, so probes only ever sample the quiet/faint regime — the whole loop is a faint-rejection tuner and `busy%` is faint-regime, not total occupancy. Post-busy classification watches a ~12-symbol window for RX re-sync **or** an RSSI climb above floor+guard (the energy path recovers real packets whose preamble the probe's RX-restart ate — the fix for the FP over-count that used to drive the staircase to the ceiling) → `tp`, else `fp`. Counters decay 6-hourly, reset on any RF param change. With `cad.auto on` the staircase is **knee-seeking**: probes sample op / op−1 / op+1 (½/¼/¼); it steps **up** when the level above is ≥`CAD_KNEE_SLOPE_PERMILLE` (5%) cleaner (steep side, below knee), **down** only on a clean flat plateau (`≤CAD_PLATEAU_CLEAN_PERMILLE`), else holds — slope-based so convergence is independent of a site's FP floor. Highest-priority override: **airtime / faint cap** — step up when the operating busy rate exceeds `cad_busycap` (percent, `set cad.busycap`, default 25, 0=off); self-targeting since only busy nodes reach it, and effectively a faint-tolerance dial (lower = reject faint harder). Each step needs ≥`CAD_STEP_MIN_PROBES` (120); offset clamped to **−8…+12** *narrowed by the driver's own detPeak clamp* (SX126x 12–48, LR11xx 40–100, LR20xx 48–90), persisted via `Dispatcher::onCadOffsetChanged()`. The narrowing is not cosmetic: where `base + offset` falls outside the hardware clamp, several offsets program the **same** peak, and the staircase then compares rungs that are physically identical and reads sampling noise as curvature. `hwCadPeakMin/Max()` report the driver clamp and `CadController::levelMinEff()/levelMaxEff()` derive the usable window, so every level the controller can reach is a distinct configuration and the `pk` shown by `get cad.stats` is what the chip was actually given. It binds on the LR2021, whose 4-symbol base is 51 at SF5–7 (effective −3…+12) and 54 at SF8 (−6…+12); the LR11xx's lowest base of 56 already lands exactly on the 48 floor at −8, so its full window is usable and it keeps the static range. AN1200.48 recommends 21–29 for SX126x (base from Semtech's LBM table, bandwidth-aware), tuned to catch faint — LBT may deliberately sit above it. Probe + offset plumbing is per-driver extension API (`*_cad_probe`, `*_cad_set_peak_offset`, `*_cad_base_peak`); LBT CAD runs 4 symbols (set in `buildModemConfig`), drivers scale their blocking-CAD timeout to `nSym·Tsym + margin`. CLI: `get cad.stats` (3-rung window, `*`=operating, `bc:`=cap), `set cad.auto/offset/probe.interval/busycap/reset`. SX127x: unsupported (no HW CAD). Full mental model + tuning: `ADAPTIVE_CAD.md`. ### 5.4 LR1110 Driver Errata Workarounds The custom `lr11xx_lora.c` driver handles several LR1110 firmware bugs: - **CMD_ERROR IRQ**: Benign error flag on several write commands — cleared silently - **RX buffer drift**: Buffer base shifts 4 bytes per packet → `clear_rxbuffer()` after every RX - **Header error**: Can shift buffer pointer → standby before RX restart - **DIO1 stuck HIGH**: 5-cycle detection → full hardware reset + recovery - **BUSY-high wedge**: a command racing the autonomous `SetRxDutyCycle` sleep phase can leave the chip BUSY-high with DIO1 low. No IRQ ever fires, so the event-driven driver never re-arms and the node goes permanently deaf. A wedge-recovery watchdog on its own work queue (`lr11xx_wedge`, `K_PRIO_COOP(7)`) checks every 3 s: after 12 s of DIO1 silence it polls the BUSY GPIO continuously for 250 ms, and a dwell with no low edge means a genuine wedge → hardware reset + RX restart. False-positive free by construction — a healthy chip, continuous or duty-cycled, always drops BUSY low within one cycle. Reads the GPIO only (no SPI), so it cannot itself disturb the chip or race the autonomous DC state machine. - **Duty-cycle ownership (BUSY is not a mode flag)**: UM §7.2.6 ends the RxDutyCycle loop on exactly three events — a packet (chip returns to the configured fallback, `STDBY_RC`), a host `SetStandby`, or **an NSS falling edge waking the chip from the sleep phase**, for which the manual adds "the user should send the `SetStandby(...)` command". Every host command is an NSS edge, so a command landing in the sleep phase silently ends the cycle: no IRQ, no flag, and every re-arm site hangs off RX_DONE or an error, which cannot fire on a receiver that is no longer listening. The node goes deaf until something independently calls `startReceive()` — which is precisely what the periodic housekeeping tick removed in `fe6e585` (2026-07-28, three days after the 1.16.7 tag) had been doing, and why the duty cycle "worked" in 1.16.7. Measured on a T1000-E 2026-08-24: **10 packets against an SX1262's 94** over 85 minutes with the cycle armed; parity with it off. The guard this replaces was a BUSY read before each command, and it cannot be made correct here. BUSY is high in the sleep phase (command fatal) **and** through ordinary Rx (command harmless), so the pin does not distinguish them — 406 of 407 sampler bursts refused with the cycle armed, 76 of 76 with it off — and it is check-then-act regardless, since the chip can enter sleep between the read and the NSS assert. The driver takes ownership instead: `lr11xx_dc_suspend()` / `lr11xx_dc_resume()` bracket any work that must touch the chip, ending the cycle with the `SetStandby` the manual asks for and re-arming explicitly. `is_receiving()` is deliberately *not* bracketed — it runs on the TX gate, where standing the cycle down would end the reception being asked about — and answers from the DIO1-stamped latch with no bus access. Restored parity to 10/10, the sampler to zero refusals, and adaptive CAD from 2 probes in 9 h to ~20 per 5 min. Identical treatment in the LR2021 driver, whose DS §6.3.8 states the same three rules word for word (**untested on hardware** — no X1 available). Two consequences worth keeping straight: the per-packet re-arm skips the standby after RX_DONE (`restart_rx(data, in_standby=true)`) because the chip has already performed it to spec, and the Rx-boost re-apply was dropped from the duty-cycle paths — §7.2.6 saves and restores the device configuration across each wake, so it was a redundant write inherited by analogy from the SX126x, which genuinely does need one (DS §9.6 retention list). - **Stale SPI reply read as data**: a read is two NSS windows (command, then answer) with a BUSY wait between them. `wait_on_busy()` returns immediately on a BUSY that reads low and cannot distinguish "command finished" from "BUSY has not risen yet", so the answer window can clock out the chip's default status / IRQ stream instead of the payload — silently, since the caller parses IRQ bits as a plausible short integer. `lr11xx_hal_read()` therefore checks the stat1 command-status byte it used to discard and re-issues the command (3 attempts) unless it reports `CMD_DATA`. Same guard in the LR2021 HAL; upstream MeshCore hit this as [PR #3261](https://github.com/meshcore-dev/MeshCore/pull/3261). - **RX duty cycle**: wired via `SetRxDutyCycle` MODE_RX, sized by the shared adapter math (same as SX126x). The earlier "broken, 23-40% loss" verdict was a window-sizing bug (over-sleep + no header budget), not a chip defect — default-off, HW-verify before production use. ### 5.5 SX127x and LR2021 Paths - **SX127x** (`CONFIG_ZEPHCORE_RADIO_SX127X`): uses Zephyr's loramac-node LoRa backend instead of the native driver (`CONFIG_LORA_MODULE_BACKEND_LORAMAC_NODE`). Patch `0004-lora-sx127x-62k5-bandwidth` adds the 62.5 kHz bandwidth MeshCore defaults to. No RX duty cycle and no RX gain boost on this path. Reference board: TTGO LoRa32 (SX1276). - **LR2021** (`CONFIG_ZEPHCORE_RADIO_LR2021`): custom driver in `patches/zephyr-new/drivers/lora/lr20xx/` (copied into the Zephyr tree at configure time, like LR11xx). **Validated on the SenseCAP MeshTracker X1** — RX, TX, LBT and RX duty cycle all confirmed on hardware after a full driver audit (2026-08-12). `promicro_lr2021` builds but is untested; its module was destroyed by overvoltage during bring-up. Notable properties that differ from the SX126x/LR11xx paths: - **Firmware Patch RAM.** DS §22.3 calls the PRAM "highly recommended"; without it the chip runs unpatched. `lr20xx_load_pram()` writes the 560-word image from `0x801000`, activates it with opcode `0x012D`, and verifies the magic word at `0x800FF8` — so the `PRAM loaded:` log line is proof the chip took it, not merely that the writes were accepted. Volatile: reloaded from both reset paths, survives every sleep this driver issues (all with retention). - **Hardware CAD→TX (`CadExitMode = 0x10`).** The chip runs the LBT CAD and, on a clear channel, transmits itself with no host round-trip. Payload and packet params are staged *before* `SetLoraCAD` and DIO1 stays enabled across it. Bounded by `cad_timeout`, which is 24 bits of 32 MHz periods = **524 ms max Tx timeout** — transmits whose airtime exceeds that take the classic CAD→host→`SetTx` route rather than being truncated (at SF7/BW62.5 the crossover is ~96 bytes). - **Front-end calibration is a point calibration, not a band.** `CalibFE` takes up to three individual frequencies (4 MHz steps, bit 15 = LF/HF), unlike the SX126x/LR11xx `CalibrateImage` freq1/freq2 band with datasheet-prescribed edges. It is issued only at config, after a hardware reset, and on the temperature-drift recalibration — never on the Tx/Rx path (DS §6.4.2 keeps the values on chip across retention sleep). (It used to also ride the AGC-reset path; that path no longer exists on this family, and the caller it had explicitly skipped CalibFE anyway, so nothing was lost when it went.) Both 4 MHz neighbours of the operating frequency are calibrated, nearest first, because the SDK rounds the argument up where the chip's own default truncates down. - **Side detectors** (multi-SF receive) are LR2021-only; see `lr20xx_configure_side_detectors()`. Mutually exclusive with CAD, whose SF ordering constraint is the inverse. - **Per-packet frequency error** is decoded and accumulated (`get freqerr`) — diagnostic only, nothing acts on it. - **Reads are status-checked.** The two-window read (command, BUSY wait, answer) can clock its second window before BUSY rises, in which case the chip streams status / IRQ instead of the payload — `GetRxPacketLength` then returns `irq[31:16]`, exactly 4 with `RX_DONE` set, and a real frame is read out of the FIFO at the wrong length. `lr20xx_spi_read_frame()` accepts an answer only when the stat1 header reports `CMD_DATA`, re-issuing the command otherwise (3 attempts). Safe to retry because the Rx FIFO pop is not on this path (`lr20xx_hal_direct_read_fifo()`, single window, structurally immune). #### 5.5.1 LR2021 driver design notes Why the driver is shaped the way it is. Kept here rather than in comments; the code carries only units, datasheet references, and the constraints that would break something if violated. **PA power.** `pa_lf_table[]` is Semtech's `LR20XX_PA_LF_CFG_TABLE` (`examples/radio_hal/lr20xx_pa_pwr_cfg.h`, Clear BSD), indexed −10…+22 dBm, and `lr20xx_get_pa_cfg_for_power()` mirrors `lr20xx_get_tx_cfg()` from `ral_lr20xx_bsp.c`. `half_power`, `pa_duty_cycle` and `pa_lf_slices` are a **matched triple per target power** — not independent knobs, which is why the board-level `pa-hp-sel`/`pa-duty-cycle` devicetree properties were removed. The register is half-dBm (DS Table 7-20, the SDK's `power_half_dbm` parameter name, DS Table 7-16, and the BSP field name all agree); an earlier table modelled it as an opaque calibration value and transmitted +22 dBm requests at 17.5 dBm. Values are chip-level for Semtech's reference design: Semtech applies a per-board matching-network correction separately via `radio_utilities_get_tx_power_offset()`, which ZephCore does not yet have, so absolute radiated power is uncalibrated. **Front-end calibration.** DS §6.4.2 stores calibration on chip, and it survives every sleep this driver issues (all with retention), so it does **not** belong on the Tx/Rx path — Semtech's `ral_lr20xx_init()` calibrates once at init and never during operation. It runs only at `lora_config()`, after `lr20xx_hardware_reset()` (a chip reset discards it) and in `reset_agc()`. `CalibFE` takes up to three **point** frequencies in 4 MHz steps, unlike the SX126x/LR11xx `CalibrateImage` band pair with datasheet-prescribed edges, so the operating frequency can be used directly. The argument is quantised and the SDK rounds **up** where the chip's own no-argument default truncates **down**, so both 4 MHz neighbours are calibrated, nearest first — sidestepping an undocumented reuse rule. (The "±20 MHz" tolerance comes from a BSP comment, not the datasheet.) **LBT and CAD_LBT.** `lr20xx_do_cad()` uses **LoRa CAD** (`SetLoraCadParams` / `SetLoraCAD`) with the per-SF `det_peak` of DS Table 6-19 — not the generic RSSI-threshold CAD, which cannot see a LoRa signal below the noise floor. The two commands have **different exit-mode encodings**; always use `lr20xx_radio_lora_cad_exit_mode_t`. With `CadExitMode = 0x10` the chip performs CAD→Tx itself, removing ~3.9 ms of host round-trip (measured). Its `cad_timeout` doubles as the Tx timeout and is 24 bits of 32 MHz periods = **524 ms maximum**, so transmits whose airtime exceeds that take the classic host path instead — at SF7/BW62.5 the crossover is roughly a 96-byte payload. Without that guard the timeout wraps and truncates the packet on air. **RX duty cycle.** An NSS falling edge terminates the cycle (DS §6.3.8), so incidental pollers must not issue SPI into a sleep window, and TX stands the cycle down deliberately via `lr20xx_dc_takeover()`. `restart_rx()` issues `SetStandby` before re-arming, because a header or CRC error does **not** terminate the loop (§6.3.8 ends it on packet *reception*) and re-arming a live cycle is refused — a refusal that latches CMD_ERROR, holds DIO1 high and used to drive the safety path into a five-strike hardware reset. **Wake budget.** `hwWakeupTimeUs()` is per-device because the TCXO dominates: DS Table 3-23 gives 1 ms warm start plus 115 µs STDBY_RC→Rx, and duty-cycle sleep powers the VTCXO regulator down so the oscillator restarts on every wake. A board declaring `tcxo-startup-delay-ms` that inherits the base class's flat 1500 µs oversizes its sleep window and drops window-edge preambles regardless of signal strength. **Firmware Patch RAM.** Loaded from both reset paths and verified by the magic word at `0x800FF8`, so the `PRAM loaded:` line is proof the chip took the patch rather than that the writes were accepted. Lost on reset, preserved by retention sleep. ### 5.6 Default Radio Parameters | Parameter | Default | Notes | |-----------|---------|-------| | Frequency | 869.618 MHz | EU 869.4-869.65 MHz band (500mW ERP allowed) | | Bandwidth | 62 kHz | | | Spreading Factor | 8 | | | Coding Rate | 4/8 | | | Preamble | 16 symbols | | | TX Power | 22 dBm | Clamped by `CONFIG_ZEPHCORE_MAX_TX_POWER_DBM` | --- ## 6. Application Layer ### 6.1 Class Hierarchy ``` mesh::Mesh ├── BaseChatMesh (contacts, channels, messages, connections) │ └── CompanionMesh (BLE protocol, phone sync, offline queue, ACK tracking) ├── RepeaterMesh (ClientACL, RegionMap, CLI, rate limiting, neighbor tracking) ├── RoomServerMesh (store-and-forward BBS; reuses repeater ACL/region/CLI) └── ObserverMesh (listen-only; publishes packets to MQTT over WiFi — ESP32) ``` ### 6.2 CompanionMesh Handles the binary BLE protocol with ~50 command opcodes. Key features: - **Offline queue**: circular buffer with peek/confirm pattern (survives BLE drops); `CONFIG_ZEPHCORE_OFFLINE_QUEUE_SIZE`, default 256 frames (lowered on RAM-bound boards) - **ACK tracking**: 8-slot table, computes expected ACK = SHA256(secret + hash)[0:4] - **Contact iteration**: Streaming protocol with `lastmod` filtering for incremental sync. The dump advances only while a client is connected and every connected transport has room; it is cancelled when the last client leaves - **Lazy write batching**: dirty contacts and channels are written 5 s after the last change, so a burst (a contacts import) is written once; a contact change that only refreshes liveness waits up to an hour (`LAZY_WRITE_LIVENESS_MS`) - **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 (every cause `zephcore_boot_reset_cause_str()` can label — offline-queue only, so routine power-on "noise" costs nothing over the air). Two causes are silent when they are the only ones raised: a debugger reset, and a low-power wake (button or GPIO wake from System OFF), which would otherwise announce itself after every power cycle. A VBUS wake from System OFF on nRF52840 reports `POR` and is not silent. All of them still appear in the boot log. **Identity**: seed = `SHA256("zc-vcontact" || self_prv_key || counter)`, pubkey = that seed's Ed25519 public point — stable per node, unique per device. It is a **real curve point**, which the bare hash it replaced was not: a random 32-byte string decompresses to a valid Ed25519 point only ~50% of the time, so half of all nodes advertised a v-contact that strict clients reject on contact upsert and DM build ("peer pub_key is not a valid Ed25519 point"). ZephCore never noticed because it only `memcmp`s this key. The `counter` byte re-rolls the key on the protocol-reserved `0x00`/`0xFF` prefix (P = 2/256 per try). Seeding from the *private* key keeps the v-contact unlinkable to its node by outsiders, and its private half is **derived and dropped** — never stored, never signs, never does ECDH. Only this node can recompute it. **No-RF invariants** (all enforced in `CompanionMesh`): 1. `vcontactHandleFrame()` intercepts `CMD_SEND_TXT_MSG` (and the handful of other opcodes that must succeed) *before* any contact lookup — the CLI runs and the reply is written straight into the offline queue. **No packet object is ever created**, so nothing can reach the dispatcher or radio. 2. The v-contact never enters the real contacts table (`CMD_ADD_UPDATE_CONTACT` for its key is intercepted — it keeps only the app-owned `flags` byte, in `prefs.v_contact_flags`, and replies OK), so it is never in the RF RX matching path. Every other pubkey-addressed opcode (login, telemetry, binary req, path discovery…) misses `lookupContactByPubKey()` and fails `ERR_NOT_FOUND` before a packet exists. 3. Even a hand-crafted over-the-air packet addressed to the derived pubkey is inert: unknown dest, undecryptable by everyone including this node. **App plumbing**: appears as a virtual tail entry in the `CMD_GET_CONTACTS` iteration (and `+1` in the CONTACT_START total); pushed as `NEW_ADVERT` on runtime enable and rename, `CONTACT_DELETED` on disable. Send/ack choreography is synthesized (SENT + immediate SEND_CONFIRMED, trip time 0). CLI replies are chunked at ≤150 chars on line breaks (offline-queue frames cap at 172 bytes). `_vcontact_lastmod` is re-stamped once per app session at `CMD_APP_START`, before the `CMD_GET_CONTACTS` that follows it. Without that the timestamp only moved on boot/rename/identity-import, so the app showed an ever-growing "last seen" age *and* — because the sync gate is `_vcontact_lastmod > _contact_iter_since` — the v-contact was streamed exactly once ever, leaving the app holding a contact the node no longer mentioned. **App-side delete is session-scoped** (`_vcontact_app_hidden`): the v-contact is withheld from sync and adverts for the rest of that session, and returns at the next `CMD_APP_START`. It deliberately does **not** touch `prefs.v_contact_enabled` — the v-contact is an ordinary entry in the app's contact list, so a "purge all contacts" walks it like any other, and the old behaviour (delete ⇒ pref off) let a routine purge silently disable a firmware feature with no way back except the USB CLI. Durable disable is node-side only: `set v.contact off`. Notices queued while hidden stay in the offline queue and drain on the next connect; only their `MSG_WAITING` prompt is suppressed. **Clock gating (no 1970 timestamps)**: while the RTC has never been synced (time < firmware build epoch) the v-contact is *deferred* — withheld from contact sync and adverts, and notices are buffered in a small RAM slot (`_vcontact_pending`) instead of queued with an epoch-0 timestamp. `vcontactClockSynced()` activates it and flushes the buffer; hooked at `CMD_APP_START` (covers hardware-RTC boards, already valid), successful `CMD_SET_DEVICE_TIME` (typical app connect flow), and GPS time sync. **Resend dedupe**: app retry attempts reuse the message timestamp (only the attempt byte changes); `_vcontact_last_ts` suppresses re-execution — a dupe gets the full ack choreography but the CLI does not run twice. Side effect: sending the identical command twice within the same wall-clock second only executes once (same app-side timestamp). Synthesized `est_timeout` is 3 s so the app's retry timer doesn't race the loopback confirmation. **Stats**: `CompanionCLICallbacks` overrides `formatStatsReply`/`formatRadioStatsReply`/`formatPacketStatsReply` with the repeater's `StatsFormatHelper` JSON, so `stats-core`/`stats-radio`/ `stats-packets` return real data over USB and the v-contact. **Notices ride the offline queue** — emitted while nothing is connected, they are delivered on the first app connect/sync. RAM-backed: lost on reboot (the restart-reason message partially compensates) and bounded by `CONFIG_ZEPHCORE_OFFLINE_QUEUE_SIZE`. **Settings** (companion `v.*` CLI namespace, prefs offsets 152–154 plus `v_contact_flags` at 166): - `set/get v.contact on|off` — default on. The only durable disable; turning it off also clears `v_contact_flags`, since the app drops the contact. - `set/get v.batteryalert |0|default` — default = board auto-shutdown threshold + 200 mV (so the alert wins the race against the 90 s shutdown confirm window), 3500 mV on boards without auto-shutdown. Alert latches once per discharge cycle; re-arms on external power, recovery above threshold + 150 mV, or threshold change. **Low-battery policy** (`app/PowerPolicy.cpp`, every companion, UI or headless): the v-contact alert above and auto-shutdown (`get|set autoshutdown `, 0 = off; default `CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS`, **3200 mV on nRF52**, 0 elsewhere because ESP32 cannot tell USB power from battery). Both sample every 30 s on the housekeeping tick, act on the third low reading in a row, skip on external power, and treat anything under 2000 mV as "no battery" (a board without a cell reads 0). A shutdown with an app connected sends a v-contact notice and waits 1 s; otherwise it stores the reason (`Low Voltage`) for the next boot. The UI only draws the warning (`ui_show_low_battery()`). A stored 3300 mV (the old default) is migrated to 3200 once (`zc.aoff_set`). ### 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 - **Node discovery**: answers discovery requests only while forwarding is on. With `advert.interval` and `flood.advert.interval` both `0` the node is hidden and does not answer, as upstream (`isHiddenNode()`); the request still counts against the discovery rate limit - **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 `LoRaRadio::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, and its composition root: `main_room_server.cpp` hands `server_main_common.cpp` a `ServerRole`, so the room runs the repeater's deadline-driven loop, where the post push engine is one more deadline (`RoomServerMesh::msUntilNextPush()`). 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 System (power): `poweroff`/`shutdown`, `get pwrmgt.support/source/bootreason/bootmv` GPS: `gps`, `gps on/off/sync/setloc/advert`, `get gps`, `set gps duty ` Sensors: `sensor get/set/list` (upstream's `SensorManager` settings: `gps`) Companion extras: `get|set autoshutdown`, `get|set v.contact`, `get|set v.batteryalert`, `help` 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: `CLI_commands.md`. --- ## 7. Hardware Adapters ### 7.1 BLE (`adapters/ble/`) - Nordic UART Service (NUS) with AUTHEN permissions on CCC + RX (forces pairing) - Passkey-based MITM pairing (SC + MITM + Bonding), runtime configurable PIN via `app_passkey` callback - DisplayOnly IO capability — phone enters passkey displayed on device / known to user - Advertising always uses `BT_LE_ADV_OPT_USE_IDENTITY` — exposes the stable identity address even when privacy is enabled, preserving Android connect-from-app - `CONFIG_BT_PRIVACY` **disabled** on nRF52840 / MG24: identity address is advertised directly; both iOS and Android work without RPA. Android's Flutter BLE plugin fails `connectGatt()` to RPA-advertised devices from app context. - `CONFIG_BT_PRIVACY` **enabled** on ESP32-S3 (`boards/common/esp32_common.conf`): the Espressif controller's privacy-OFF Secure-Connections path produces a MIC failure against iOS (HCI disconnect `0x3d` at encryption start). Privacy ON keeps the controller on its working SC path. `USE_IDENTITY` advertising preserves Android compatibility. Do **not** remove `USE_IDENTITY` while ESP32 privacy is on. - Pairing triggered reactively: phone hits ATT error 0x05 on secured attribute → initiates SMP pairing (Apple Accessory Design Guidelines §55 compliant — no proactive Security Request) - **Unpaired-connection timeout (15 s)**: a connection that never reaches security L2 is disconnected. With `CONFIG_BT_MAX_CONN=1` the stack stops advertising while the slot is held, and the companion advertising watchdog skips any state where a connection exists — so a client that connects and never pairs (a scanner app left open, iOS routinely) otherwise makes the node invisible until it is power cycled. Every characteristic on both services is `*_AUTHEN`, so an unsecured connection can do nothing and the drop costs a legitimate client nothing. Armed in `connected()`, cancelled by `security_changed()` at L2+ and by `disconnected()`; the expiry handler reaches the connection via `bt_conn_foreach()` rather than `current_conn`, which belongs to the Bluetooth callback thread. - TX congestion control: queue (12 frames) + overflow buffer + retry + timeout watchdog - Fast/slow advertising switching with post-disconnect flap prevention - DLE (Data Length Extension) to 251 bytes - One of the companion transports served at the same time (BLE, USB/UART, TCP; [7.6](#76-wifi--mqtt--tcp-transports)) - 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. The flash is `zephyr,deferred-init` and `/ext` is not automounted (`boards/common/qspi-ext.dtsi`): `zephcore_fs_mount_ext()` brings it up on first use, because a boot-time probe could beat a cold power rail (first boot after a UF2 update) and silently drop to internal flash. Roles that keep nothing on `/ext` (repeater, room server, observer) put the flash into deep power-down at start-up (`zephcore_fs_ext_power_down()`), with chip-select held high; a factory `erase` resumes it and puts it back - **BLE bonds**: NVS (`storage_partition`, 0xD0000 on nRF52) via Zephyr settings backend (≥1.16.2) - **Prefs**: `prefs.json` through upstream's `ConfigSerializer`, with upstream's key names for shared fields and ZephCore's own under `zc` (see §13); the older binary file is read once to migrate and kept - **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), `ZephyrDataStore::adoptVolume()` (called from `main_companion.cpp` before `bt_enable()`) picks one of three paths: 1. **No prefs, or a legacy prefs file whose radio preset is implausible** → full LFS + NVS format. `prefsRadioImplausible()` range-checks `freq`/`sf`/`bw` at our offsets (a `prefs.json` is always ours). Covers fresh installs and foreign volumes. 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. Loading also range-checks `freq`/`sf`/`bw` and falls back to the compile-time defaults on out-of-range values, so a misread 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 / MeshTracker X1 (AG3335): multi-GPIO power sequencing; standby is the module's RTC backup sleep (`$PAIR650,0` before `GPS_EN` is cut, VRTC kept, a `GPS_RTC_INT` pulse wakes it) - Standby depth is decided from the duty interval alone, on every board: up to `prefs.gps_standby_max` (`set gps standby`, default 3600 s) the module keeps its state (backup sleep, standby pin, VRTC), beyond it the board's full power-off is used, as it is for `gps off`. `gps_power.cpp` maps the two onto the pins a board declares; a board with one off state uses it for both - GPS time blocks phone time sync for 2 hours after last fix - Three files: `ZephyrGPSManager.cpp` (states, fix validation, public API), `gps_power.cpp` (power line / PMU rail / UART sleep commands, UARTE PM), `gps_module_cfg.cpp` (GNSS-API or PMTK/PCAS/UBX configuration, `get gps diag`); `gps_internal.h` carries the feature detection - **Main thread only.** The GNSS callbacks (system work queue) only validate and post: a validated fix is snapshotted and handed to the fix callback by `gps_process_event()` on the main thread, so the callback sets the clock and the node position directly. The UI's GPS toggles post an action too. - The SoC light-sleep lock is held only while ACQUIRING (`gps_hold_sleep_lock()`, tracked 1:1), never through standby - Boot: servers apply `prefs.gps_enabled` like upstream's `applyGpsPrefs()` (`zc.gps_set` in prefs.json marks it a real choice; older files are upgraded to on once, because older firmware ran the GPS regardless). The observer parks its GPS (`gps_park()`) **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 every acquisition that follows a full power-off: the first one after `gps_enable()` (boot, `gps on`; `gps off` is a full power-off on every board) and every wake from a standby longer than `prefs.gps_standby_max`. Longer to allow almanac download and poor reception. - `CONFIG_ZEPHCORE_GPS_FIX_TIMEOUT_SEC` (default 120s): the normal per-wake timeout for all other acquisitions (warm start). Like the standby depth, the choice follows from the duty interval alone, never from the board. **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/`) - **`ZephyrUSBCDC`** (every role): the USB device and its CDC ACM class, the 1200-baud touch into the bootloader, DTR tracking, and `zephcore_usbd_detach()` before a reset (a soft reset leaves the ESP32-S3's PHY attached). A host that went away is reported as a DTR drop in three cases: DTR low, VBUS removed, and a bus reset. The reset case exists because the CDC class keeps the old line state across a reset, so nothing else says that the port the host had open is gone (a hub resetting upstream leaves VBUS up). Bus suspend, resume and reset also reach a second callback. While a host is on the bus (from reset, configuration or resume until suspend or VBUS loss) the module blocks ESP32 light sleep: sleep stops the USB clock and the host loses the device without being told. - **`ZephyrCompanionUSB`** (companion): the wired companion transport, over the CDC port or a plain UART (`zephcore,companion-uart`). Framing: `<` (to the node) or `>` (from it), a little-endian 16-bit length, the payload; a first byte that is printable and not `<` starts the text CLI instead. It has its own receive queue and an interrupt-driven 2 KB TX ring whose "drained" callback paces the contact dump. - **Session rule**: a session starts on the first inbound frame or CLI line and ends on a DTR drop as defined above. While the bus is suspended (the computer asleep with the port open) the session is kept but is not a connected client: nothing is queued for it, and it does not hold up the clean-up that runs when the last client leaves. Each change reaches the main loop as a connect or a disconnect. - Servers (repeater, room server, observer) use the CDC port as their serial console. ### 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). A board with a fuel gauge (AXP2101) reads voltage and charge from it instead; one with an nPM1300 charger (XIAO nRF54LM20A) reads the voltage from the PMIC's own ADC through the sensor API and takes the percentage from the discharge curve - UF2 bootloader entry via GPREGRET magic (0x57 = UF2, 0xA8 = BLE DFU) - TX LED bracketing for LoRa transmissions (gated by the LED master switch below) - Bootloader version detection via flash memory scan - Battery reading cached 10 s for every caller (UI, telemetry, stats, alerts), under a mutex - `powerOff()` (CLI `poweroff`/`shutdown`, UI power-off, low-battery shutdown all end in `zephcore_power_off()`, `board/zephyr_poweroff.c`): UI hook (heartbeat, display), GPS/sensor/buzzer rails off, LoRa held in reset, sw0 SENSE armed (nRF), power latch released, System OFF - Upstream's pwrmgt getters: reset cause from `boot_info`, the last fatal error from `fatal_reboot.c`'s `__noinit` record (reason, pc, lr, thread; resolve pc with `addr2line` on the same build), shutdown reason from the `/lfs/shutdn` marker, battery voltage at boot **Telemetry** (`REQ_TYPE_GET_TELEMETRY_DATA`, every role, upstream's shape): battery on channel 1, then `sensors.querySensors()` (`adapters/sensors/ZephyrSensorManager.cpp`): GPS position on channel 1 while the GPS is on and has a fix, each environment sensor and power-monitor channel found at boot on its own channel from 2 up (probe order), board-local analog sensors (T1000-E) on channel 1; then the MCU temperature on channel 1 (the SoC's die sensor: the `die-temp0` alias, which is `coretemp` on ESP32-S3/C3/C6, else the Nordic nodelabel `temp`; omitted where there is none: classic ESP32, STM32WL, MG24). Servers give guests battery + MCU temperature only and honour the requester's inverse permission mask, as upstream. Encoded by `helpers/compat/CayenneLPP.h`: upstream's library API and wire format, but values round to the nearest step where the library truncates. **Clock** (`adapters/clock/ZephyrRTCClock`): `setCurrentTime()` also writes the hardware RTC when the board has one (coalesced on the system work queue; a time outside 2000-2099 is not written), as upstream's `AutoDiscoverRTCClock` writes its chip; `seedCurrentTime()` is the boot restore, not written back. A chip is adopted at boot only once it is identified (`adapters/clock/ZephyrRTCDiscover.c`), because other parts can answer at the same address: a device is passed over when two reads each show a bit set that the chip's data sheet fixes at zero, or a time field out of range, and a time is restored only from a read in which every field is valid. An RV-3028 cleanly identified in 12-hour mode has its `twelve-hour-bit` cleared first, so the chip converts its own hours. No time is taken from a cleanly identified RV-3028 whose `twelve-hour-bit` register cannot be read or reads FFh, or whose clear or re-read fails. The time is written in each chip's own encoding (the RX8130CE takes a one-hot weekday, `weekday-one-hot` in its descriptor), and an RV-3028 whose descriptor carries `rv3028-eeprom-config` (the RAK12002 on a RAK4631) has that configuration, its backup switchover, stored in the chip's EEPROM. Without a hardware RTC, the last time read is kept in no-init memory (RTC slow memory on ESP32) and restored after a reset that keeps RAM (reboot, crash, watchdog), as upstream's `ESP32RTCClock`; a power cycle starts from 1970 again. **LED master switch** (`helpers/led_gate.{c,h}`, `set leds on|off`, all roles): one process-wide flag every LED driver consults — heartbeat and unread-message LEDs in `helpers/ui/ui_common.c`, the `lora-tx-led` in `ZephyrBoard::onBeforeTransmit()`, and the message/shutdown flashes. It lives outside the UI layer because `ui_common.c` is only compiled when a UI is enabled, while a headless repeater still blinks on every transmit. `ui_common.c` overrides the weak `zephcore_leds_ui_sync()` hook so a CLI change also stops a lit heartbeat and refreshes the UI's LEDs page. Persisted in `NodePrefs.leds_disabled` (companion offset 93; repeater offset 120, magic-encoded — see §13). Does not cover the display backlight, which has its own UI brightness setting (`display_brightness`). **LED brightness** (`set leds.brightness 0-100`): a board that aliases `heartbeat-pwm-led` / `lora-tx-pwm-led` drives that LED through PWM (`zephcore_led_pwm_write()`), and the PWM alias wins over `led0` / `lora-tx-led`. `CONFIG_ZEPHCORE_LED_PWM` follows the aliases: it selects `CONFIG_PWM` and stops the server and observer mains from configuring `led0` as a GPIO, because that pin belongs to the PWM driver. Persisted in `prefs.json` as `zc.leds_brightness`; it is not part of the legacy binary files. ### 7.6 WiFi / MQTT / TCP Transports - **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by the observer, the repeater uplink and the WiFi companion. The observer and the uplink run with the driver's WiFi power save (minimum modem sleep: the WiFi radio is off between the access point's DTIM beacons); the WiFi companion requests power save off, as before. On ESP32 light-sleep builds the station blocks SoC light sleep until the link is ready (DHCP, SNTP) and its client has reported a session with `zc_wifi_station_session_up()`, and again whenever either is lost. With no such client (the WiFi companion) the block stays for the life of the boot. While the SoC sleeps the WiFi MAC wakes it for each beacon it listens to, and `helpers/pm_esp32_wake.c` refuses a sleep while the MAC's beacon timer is active. On light-sleep companion builds the WiFi companion runs with power save too and reports a session at once, so the station blocks sleep only while the link is not ready. - **`adapters/mqtt/ZephyrMQTTPublisher.c`**: MQTT publisher for observed/uplinked packets. It reports its session to the station: up at CONNACK, down when the session ends for any reason. Keepalive: PINGREQ once a quarter of the 60 s keepalive is left, so a broker that enforces the keepalive without grace does not close an idle session. TLS is used without certificate verification, but the chain still has to be parsed: the uplink and observer builds carry P-256 and P-384 with SHA-256 and SHA-384 for that. - **`adapters/ota/wifi_ota.c`**: WiFi SoftAP + HTTP firmware upload to MCUboot slot1 (ESP32, requires `--sysbuild`) Companion transports, as upstream's `companion_radio`: each is a `BaseSerialInterface` (`adapters/transport/CompanionInterfaces.h`) registered in one `MultiSerialInterface`, so BLE, USB/UART and TCP can all be connected at once; every connected client gets every frame (a reply to one app also reaches the other). - **`adapters/ble/ZephyrBLE.cpp`** (+ `ble_gatt_layout.cpp`, `ble_dfu.cpp`): BLE NUS. Blocks ESP32 light sleep from connect until the link is encrypted (pairing does not survive sleep); an encrypted link and advertising do not block it. - **`adapters/usb/ZephyrCompanionUSB.cpp`**: USB CDC-ACM, or a plain UART (`zephcore,companion-uart` chosen node; the only link on the Bluetooth-less LoRa-E5), with the text CLI - **`adapters/transport/TcpCompanionTransport.c`**: TCP (port 5000, MeshCore `SerialWifiInterface` framing) on native Linux and on WiFi companions (`app/CompanionWifi.cpp`, `capabilities: wifi: true`) - **`adapters/transport/frame_txq.c`**: the TX queue BLE and TCP share (congestion, overflow slot, lossless replies) --- ## 8. UI Subsystem ### 8.1 Architecture Event-driven, no dedicated thread. Two UI frontends share the same plumbing (`helpers/ui/`: display, buzzer, multi-tap input filter, mesh action queue, clock-source tag `time_sync.c`, `ui_radio_state.h`, `telemetry_text.cpp`): - **Button UI** (`helpers/ui-button/`): single-button page cycler — most boards, every role. Renders on the system work queue; changes mesh state only through `ui_mesh_actions` (posted, applied on the main thread). - **Joystick UI** (`helpers/ui-joystick/`): 5-way joystick / keypad menu UI, companion only (Wio Tracker L1, Wio L1 1W, GAT562-30S, ThinkNode M9). Keys go through a queue to `JoystickUITask::loop()` **on the main thread**, so its direct `CompanionMesh` calls (through `getMesh()`) are safe. `screens/repeater_admin.cpp` is the remote admin screen. Battery percentage on both UIs is the board's own (`ZephyrBoard::getBattPercent()`: discharge curve, or the fuel gauge). Low-battery policy is not UI: see `app/PowerPolicy.cpp` (the UI only draws `ui_show_low_battery()`). ``` Hardware buttons → Zephyr input subsystem → Longpress filter → Multi-tap filter → ui_input_cb() → page navigation / action dispatch → schedule_render() → render_work (50ms OLED / 200ms EPD debounce) → CFB framebuffer → display ``` Color TFT panels (T114, T096, Wireless Tracker) are wrapped as 1bpp displays for CFB via the `zephcore,mono-tft` shim (`display_mono_tft.c`). ### 8.2 Pages (Button UI) **Companion** (up to 12 pages): Messages, Recent, Radio, Bluetooth, Advert, GPS, Buzzer (if buzzer present), LEDs, Sensors, Offgrid, DFU, Shutdown **Repeater** (3 pages): Status, Radio, Shutdown ### 8.2.1 Renderer Split (mono / color) Pages whose color layout genuinely diverges from the mono layout are split into dedicated renderers behind a compile-time seam, instead of branching on capability inline (and never into per-board renderer files): ``` render__mono() — mono / tiny / e-ink layout (always compiled) render__color() — RGB565 layout, wrapped in #if MC_DISPLAY_COLOR_PANEL render_() — thin dispatcher: #if MC_DISPLAY_COLOR_PANEL if (mc_display_has_color()) { _color(); return; } #endif _mono(); ``` `MC_DISPLAY_COLOR_PANEL` is defined (in `display.h`) only when a `tft` node exists in devicetree. On a mono/e-ink board the color bodies — and every color-only helper they reference (`draw_activity_graph`, `use_compact_color_home`, the `activity_*` buffers, …) — are dropped at compile time, so color rendering costs zero flash/RAM there. Adding a new color board reuses `_color`; it must never fork a board-specific renderer. Pages with a **shared** flow that only tints per-row (Recent, GPS, Sensors, Status) stay as single functions with inline `if (mc_display_has_color())` — that already is the "one layout, colored" ideal, and the color branch dead-code-eliminates on mono via the constant-false `mc_display_has_color()`. Split pages: Messages, Radio, Traffic, Bluetooth, Advert, LEDs, Offgrid, DFU, Shutdown. ### 8.3 Multi-Tap Input Single button; tap-count → key-code mapping comes from the board's devicetree `tap-codes` (up to 5). Typical mapping: - 1 tap → Page next - 2 taps → LED heartbeat toggle - 3 taps → Notification mode (sound+vibrate → vibrate → silent → sound → …; boards with no motor fall back to a plain on/off toggle) - 4 taps → GPS toggle - 5 taps → Flood advert (immediate, no delay) ### 8.4 Buzzer and vibration Non-blocking RTTTL parser on dedicated work queue. Predefined melodies for startup, shutdown, messages, ACKs. 2-second safety watchdog auto-silences on work queue stall. Boards with a DRV2605 haptic driver (`ti,drv2605` in DT) also vibrate on every notification — `buzzer_play()` pulses the motor. The two outputs share one setting, the notification mode, which lives in `helpers/buzzer_gate.c` (always linked, same pattern as `led_gate.c`, so the CLI resolves its symbols on boards that compile no buzzer). `set buzzer 0|1|2|3` and the 3-tap button action both drive it: | Mode | Name | Buzzer | Motor | |------|------|--------|-------| | 0 | silent | - | - | | 1 | sound+vib | yes | yes | | 2 | vibrate | - | yes | | 3 | sound | yes | - | Modes 2 and 3 are rejected on boards with no motor, where they would be indistinguishable from 0 and 1. The setting persists in the existing `buzzer_quiet` prefs byte — 0 and 1 keep their original meaning, 2 and 3 are new and read as "quiet" by older firmware, so a downgrade silences a node left on sound-only. ### 8.5 Doom Easter Egg Wolf3D-style raycaster on OLED: textured walls, 2 enemy types, shooting, HUD. Bypasses CFB, writes directly to display. ~1.7KB RAM, ~5KB flash. Enabled via `CONFIG_ZEPHCORE_EASTER_EGG_DOOM`. Button UI: triple-press ENTER on Messages page. Joystick UI: Tools menu → "Doom". --- ## 9. Build System ### 9.1 Config Layering ``` prj.conf (base: console; production defaults — LOG=n, ASSERT=n) → boards/common/zephcore_common.conf (ALL boards: BLE, crypto, FS, LoRa, sensors) → boards/common/_common.conf (nrf52/esp32/nrf54l/mg24 specifics) → boards///board.conf (board-specific pins, features) → [optional] repeater.conf, debug.conf (user extras, LAST = highest priority) ``` ### 9.2 Key Kconfig Choices - **Role**: `ZEPHCORE_ROLE_COMPANION` (default) vs `ZEPHCORE_ROLE_REPEATER` vs `ZEPHCORE_ROLE_ROOM_SERVER` vs `ZEPHCORE_ROLE_OBSERVER` (selected via `repeater.conf` / `room_server.conf` / `observer.conf`) - **Radio**: `ZEPHCORE_RADIO_NATIVE` (SX126x, default) vs `ZEPHCORE_RADIO_LR1110` vs `ZEPHCORE_RADIO_LR2021` vs `ZEPHCORE_RADIO_SX127X` - **Features**: Display, buzzer, buttons, multi-tap, Doom (auto-enabled from DT); PSRAM auto-enable from DT (`Kconfig.psram`) ### 9.3 Platform Notes - **nRF52840**: Zephyr open-source BLE controller, UF2 bootloader, partial flash erase for BLE coexistence - **nRF54L15**: Same BLE controller as nRF52, CMSIS-DAP via SAMD11 bridge, no native USB - **nRF54LM20A** (the LR2021 EVK with a XIAO nRF54LM20A, its own board directory): built and flashed like the nRF54L15 (`--no-sysbuild`, CMSIS-DAP); source-only, no published firmware - **ESP32-C3/C6/S3**: Espressif proprietary BLE blob, 32KB heap, asserts disabled (blob IRQ false positives); simple-boot by default, MCUboot only with `--sysbuild` (WiFi OTA) - **ESP32 classic (PICO-D4)**: much smaller DRAM — contact/queue caps shrunk in `board.conf`; console/CLI on `uart0` (no native USB); DIO flash mode required (QIO bootloops) - **EFR32MG24**: SiLabs proprietary BLE blob, 32KB heap, SEMAILBOX enabled for hardware TRNG/crypto entropy, ADC disabled (no battery divider), CMSIS-DAP via onboard SAMD11 - **STM32WL (LoRa-E5)**: no BLE, no USB device — companion protocol and CLI run over USART1; 64KB SRAM caps contacts/queues hard; TRNG entropy; single app partition, flash via SWD - **Native Linux (`native_sim`)**: real SPI/GPIO via spidev + GPIO chardev; TCP companion transport; file-backed flash — see `LINUX_NATIVE.md` ### 9.4 Patches Applied automatically at CMake configure time; a failed patch aborts the configure with the offending patch named. | Patch | Risk | Purpose | |-------|------|---------| | 0001-lora-lr11xx-lr20xx-build | LOW | Registers the LR11xx and LR20xx drivers in the Zephyr LoRa build | | 0003-lora-sx126x-native | **HIGH** | DIO1 work queue, duty cycle, CAD, RX-busy gating, band RSSI/AGC calibration, PA/OCP tuning, extension API | | 0004-lora-sx127x-62k5-bandwidth | LOW | Adds 62.5 kHz bandwidth to the loramac-node backend | | 0005-gnss-config-and-version-query | MEDIUM | Air530Z nav-rate config + `$PCAS06` version query; NMEA generic dump | | 0006-blobs-py | LOW | Fix `west blobs fetch` KeyError | | 0007-spi-gpio-native-linux | LOW | Wires native-Linux SPI/GPIO drivers into the Zephyr build | | 0008-flash-sim-per-node-file | LOW | Flash simulator defaults to per-node settings file (native Linux) | | 0009-display-ssd16xx-fill-ram-white | LOW | E-paper full-refresh-to-white anti-ghosting helper | | 0010-uarte-pm-suspend-bounded-rxto-wait | MEDIUM | Bounds the nRF UARTE STOPRX/RXTO spin on PM suspend; unbounded upstream, wedges the mesh thread | | 0017-wifi-esp32-rx-never-block | MEDIUM | ESP32 Wi-Fi RX callback drops a frame instead of waiting 100 ms for a net buffer; the wait livelocked RX under load | | 0019-usb-cdc-acm-poll-out-flush | MEDIUM | USB CDC ACM: a full TX buffer is handed to the stack from `poll_out()` and `fifo_fill()`, and only the current buffer counts as free space while the bus is suspended. Without it console bursts are cut at 64 bytes and a companion can hang on its first reply after boot | **One patch per file.** No upstream file is touched by more than one patch, so apply order is irrelevant and no patch can be anchored inside another's added lines. Consolidated 2026-08-20 (15 → 9): `0002` folded into `0001`, and `0011`–`0015` folded into `0003`, each keeping its rationale as a `== section ==` in that patch's preamble. `0002` is a deliberate numbering gap. Add a new sx126x fix by regenerating `0003`, never by stacking an `0016` on it — see `WEST_UPDATE.md`. New drivers in `patches/zephyr-new/` (LR11xx, LR20xx, native-Linux SPI/GPIO, DTS bindings) are copied — not patched — into the Zephyr tree at configure time. ### 9.5 Flash Partition Layouts **nRF52840 SD v6**: SoftDevice 152KB → App 680KB → NVS 16KB → LFS 128KB → UF2 48KB **nRF52840 SD v7**: SoftDevice 156KB → App 676KB → NVS 16KB → LFS 128KB → UF2 48KB **ESP32 (4MB)**: Boot + App → LFS 192KB + NVS 16KB **ESP32-S3 (8/16MB)**: Boot + App → LFS 384KB + NVS 16KB **nRF54L15**: MCUboot 64KB → App 1272KB → LFS 92KB **EFR32MG24**: MCUboot 48KB (reserved) → App 1344KB → LFS 144KB **STM32WL**: App at flash origin → LFS (no bootloader) --- ## 10. Board Matrix Build strings: `docs/supported_boards.md`. Flash methods: `docs/BUILDING.md`. Adding a board: `zephcore/boards/example_board/README.md`. | Board | SoC | Radio | GPS | Display | Notable extras | |-------|-----|-------|-----|---------|----------------| | RAK4631 / WisMesh Pocket | nRF52840 | SX1262 | u-blox MAX-7Q (opt) | WisBlock OLED (opt) | I2C sensors | | RAK3401 1W | nRF52840 | SX1262+SKY66122 (30dBm) | u-blox MAX-7Q (opt) | - | I2C sensors | | RAK WisMesh Tag | nRF52840 | SX1262 | AT6558R | - | Accelerometer, buzzer, multitap | | T1000-E | nRF52840 | **LR1110** | AG3335 | - | Buzzer, button, multitap | | SenseCAP MeshTracker X1 | nRF52840 | **LR2021** | AG3335M (L1+L5) | - | SPA06 barometer, DRV2605L vibration, YSN8900 RTC, QSPI 8MB, RGB LEDs, buzzer | | ThinkNode M1 | nRF52840 | SX1262 | Air530Z | EPD 200x200 (SSD1681) | Buzzer, 2 buttons, QSPI 2MB, RGB LEDs | | ThinkNode M3 | nRF52840 | **LR1110** | Yes | - | Buzzer, 2 buttons, RGB LEDs | | ThinkNode M6 | nRF52840 | SX1262 | L76K | - | QSPI, RGB LEDs | | Wio Tracker L1 | nRF52840 | SX1262 | L76K | OLED 128x64 (SH1106) | 5-way joystick UI, buzzer, QSPI 2MB | | LilyGo T-Echo | nRF52840 | SX1262 (TCXO 1.8V) | L76K | EPD 1.54" (SSD1681) | BME280, QSPI, touch-button backlight | | Heltec T114 | nRF52840 | SX1262 | - | TFT 240x135 (ST7789V) | Screenless build via `no_display.conf` | | Heltec Mesh Node T096 | nRF52840 | SX1262+KCT8103L PA | UC6580 | TFT 160x80 (ST7735S) | Button, LED, battery ADC | | Ikoka Nano 30dBm | nRF52840 | SX1262+PA (30dBm) | - | - | RGB LEDs | | GAT562 30S Mesh Kit | nRF52840 | SX1262+PA (1W) | Yes | OLED (SSD1306) | 5-way joystick, buzzer, solar | | SenseCAP Solar | nRF52840 | SX1262 | L76K | - | QSPI, battery monitor | | XIAO nRF52840 + Wio-SX1262 | nRF52840 | SX1262 | - | - | - | | ProMicro SX1262 | nRF52840 | SX1262 (E22-900M30S) | Yes | - | Button, LED, battery ADC | | muzi works R1 Neo | nRF52840 | SX1262 | Yes | - | Buzzer, button, RX8130CE RTC, latched-rail power-off | | XIAO nRF54L15 | nRF54L15 | SX1262 | - | - | Contacts capped at 450 | | Seeed LR2021 LoRa Plus EVK | nRF54L15 or nRF54LM20A | **LR2021** | - | - | Two board directories, one per XIAO; the nRF54LM20A one is source-only | | 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 V4-R8 | ESP32-S3R8 | SX1262+PA (KCT8103L) | L76K (optional) | OLED (SSD1306) | 16MB flash, 8MB octal 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`–`0x42`) 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/prefs.json` | Companion preferences | `ConfigSerializer` text (upstream keys + `zc{}`); atomic replace | | `/lfs/new_prefs` | Legacy companion preferences | 272B binary; read once to migrate, then kept for older firmware | | `/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/_main.id`, `prefs.json` | Repeater/room-server/observer identity + prefs | `prefs.json` as above; legacy 311B `prefs` kept after migration; atomic-replace writes | | `/lfs/repeater/acl` | Client ACL | 136B × N records | | `/lfs/repeater/regions2` | Region map | Header + 164B × N entries | | `storage_partition` (NVS, 0xD0000 nRF52) | BLE bonds + Zephyr settings | NVS settings backend (≥1.16.2; old `/lfs/settings` file detected by self-heal) | > **Roles are not interchangeable.** Each role formats the whole volume on its first boot if the > volume holds no data for that role: the companion checks `/lfs/prefs.json` and `/lfs/new_prefs` > (`ZephyrDataStore::hasPrefs()`), the repeater/room-server/observer check `/lfs/repeater/prefs.json`, > `/lfs/repeater/prefs` and `/lfs/repeater/_main.id` (`RepeaterDataStore::hasRoleData()`). So flashing a repeater over > a companion — or the reverse — erases the previous role's identity, prefs and contacts, plus > `storage_partition` and QSPI. Export your identity before switching roles. The roles' files > never overlap physically (one LittleFS volume, one allocator); the reason for the wipe is that > they share 128 KB and the other role's data crowds out writes. Repeater, room server and > observer share `/lfs/repeater/` and the same prefs layout, so switching among *those three* > preserves the identity. ### Preferences (`prefs.json`) Both roles store prefs as `prefs.json` through upstream MeshCore's `ConfigSerializer` (`helpers/ConfigSerializer.{h,cpp}`, verbatim), as upstream has since v1.17.0. The mapping from ZephCore's single `NodePrefs` struct is `helpers/PrefsJson.cpp`: fields whose meaning matches upstream's use its keys and nesting (companion: `name`, `lat`, `lon`, `radio{}`, `gps{}`, `repeat{disable}`, `comp{}`, `wifi{}`; servers: `name`, `pass`, `guest`, `owner`, `adv_int`, `f_adv_int`, `lat`, `lon`, `radio{}`, `gps{}`, `repeat{}`, `room{}`, `power{}`), and ZephCore's own settings live under `zc{}`. Unknown keys are ignored and missing keys keep their defaults, so adding a field never shifts another. Writes are atomic (`.tmp`, sync, rename); a file that does not parse falls back to the legacy file, then to defaults. **Legacy binary files.** Firmware before `prefs.json` wrote field-by-field binary files: companion `/lfs/new_prefs` (272 bytes) and servers `/lfs/repeater/prefs` (311 bytes; `leds_disabled` at 120 is magic-encoded `0xA0`/`0xA1`). `helpers/PrefsCodec.cpp` reads (and, for tests, writes) both, pinned by host tests. On the first boot without `prefs.json` the legacy file is decoded and `prefs.json` written; the legacy file is **kept**, so a downgrade still boots with the settings as they were at the upgrade. Byte maps: `memory/prefs-format.md`. --- ## 14. Key Call Flows ### 14.1 Receiving a LoRa Packet → Application ``` DIO1 interrupt → Zephyr lora driver → async RX callback → LoRaRadio::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 addToOfflineQueue() ``` ### 14.2 Sending a Text Message ``` Phone sends CMD_SEND_TXT_MSG via BLE NUS → CompanionMesh::handleCmdFrame() → handleCmd() → BaseChatMesh::sendMessage(contact, text) → composeMsgPacket(): ECDH secret → AES encrypt → MAC → if contact has path: trySendDirect() → else: sendFlood() → Mesh::sendFlood() → mark seen → queue outbound → Dispatcher::checkSend() → CAD check → duty cycle check → LBT → startSendRaw() ``` ### 14.3 Repeater Forwarding a Packet ``` Dispatcher::checkRecv() → Mesh::onRecvPacket() → flood packet, not for us → routeRecvPacket() → allowPacketForward() → RepeaterMesh checks: disable_fwd? flood_max? region filter? → if allowed: append self hash to path, ACTION_RETRANSMIT_DELAYED → re-queued outbound with priority = hop count ``` ### 14.4 Noise Floor Calibration Cycle ``` main event loop (every 5s) → Dispatcher::maintenanceLoop() → radio->triggerNoiseFloorCalibrate(threshold) → guards: in RX? TX active? duty cycle? mid-receive? → read 8 RSSI samples, take median → first sample: seed directly → warmup (<8 ticks): accept unconditionally → periodic bypass (every 16th): accept unconditionally → otherwise: reject if sample ≥ floor + 14dB → EMA: floor += round((sample - floor) / 8) → clamp [-120, -50] dBm ``` --- ## 15. Watchdogs and Recovery Mechanisms **There is no hardware watchdog.** No `CONFIG_WATCHDOG`, no `task_wdt`, no `wdt` node enabled on any board — the `wdt` nodes visible in board `.dts` files are inherited SoC definitions, and the `RTCWDT` references in the TTGO board configs concern the ESP32 ROM bootloader's own watchdog, not something ZephCore arms. The only consumer of the concept is the boot breadcrumb in `main_companion.cpp`, which reports `RESET_WATCHDOG` in the "Restarted:" v-contact message (see [6.2.1](#621-v-contact-loopback-admin-contact)). It reads the cause from `zephcore_boot_reset_cause()` rather than from `hwinfo_get_reset_cause()` directly: `helpers/boot_info.c` captures the register once at POST_KERNEL on every role, logs it with its labels, and clears it where the platform implements a clear, so a later reader sees the same cause instead of a cleared register. nRF implements `hwinfo_clear_reset_cause()`; ESP32 does not, so the weak stub returns `-ENOSYS` and nothing is cleared there. That is harmless on ESP32, whose cause comes from `esp_reset_reason()` and does not accumulate across boots, but the capture is what readers should rely on rather than the register. Zephyr's ESP32 hwinfo driver returns 0 for the reasons it has no case for; `boot_info` maps those itself (USB-Serial-JTAG and JTAG resets, i.e. every esptool flash, to `DEBUG`; power glitch to `BROWNOUT`; CPU lockup; eFuse error to `HARDWARE`). Everything below is software: bounded stall detection in the layer that owns the state machine. Each entry names what it recovers, because several are deliberately diagnostic-only and recover nothing. ### 15.1 Named watchdogs | Watchdog | Location | Period | Trigger → action | |----------|----------|--------|------------------| | SX126x parked-RX | `patches/zephyr/0003-lora-sx126x-native.patch` (`sx126x_dc_watchdog_handler`) | `2×(preamble+8)` symbols, floor 250 ms | Duty-cycle only. Two consecutive samples showing the chip parked in full RX after a false preamble detect → re-arm the DC cycle. Two-strike so a sighting can never fall inside one real packet's preamble→header gap and abort a live reception. Counted by `get dc.restarts`. | | LR11xx wedge-recovery | `lr11xx_lora.c` (`lr11xx_wedge_watchdog_handler`, own `lr11xx_wedge` queue) | 3 s | DIO1 silent >12 s **and** BUSY continuously high for a 250 ms confirm poll → hardware reset + RX restart. See [5.4](#54-lr1110-driver-errata-workarounds). | | Radio stall | `Dispatcher::maintenanceLoop()` | `RADIO_STALL_THRESHOLD_MS` (8 s) | Radio neither in RX nor mid-TX for the whole window → latch `ERR_EVENT_STARTRX_TIMEOUT`. **Diagnostic only.** The bit is surfaced everywhere: repeater/room-server `stats`, binary telemetry, MQTT uplink, and the companion's BLE device-status response. | | Contact-dump stall | `main_companion.cpp` housekeeping | housekeeping tick | Dump active and the iterator cursor unmoved across a whole tick → re-post `MESH_EVENT_CONTACT_ITER`. The dump is pumped solely by the BLE/USB tx-idle callback, so one lost kick would strand it silently. | | BLE advertising | `main_companion.cpp` housekeeping | housekeeping tick | Enabled, not connected, not advertising → `zephcore_ble_set_enabled(true)`. Covers transient `bt_le_adv_start` failure, which would otherwise leave the node undiscoverable until reboot. | | BLE TX timeout | `ZephyrBLE.cpp` (`BLE_TX_TIMEOUT_MS`) | 2 s | `ble_tx_in_progress` set with no completion callback → clear the flag and proceed to the next TX. Sits 3 s inside the 5 s supervision timeout. | | USB partial-input | `ZephyrCompanionUSB.cpp` (`USB_FRAME_TIMEOUT_MS`) | byte-driven | Mid-frame or mid-text-line too long → reset parser to `USB_RX_IDLE`. **Not a timer** — it only runs when bytes arrive, so it never wakes a sleeping node. | | Buzzer safety | `helpers/ui/buzzer.c` (`BUZZER_TONE_MAX_MS`) | 2 s | Note handler stalls → silence PWM + amp off. The PWM block is autonomous and would otherwise drive the pin forever after a crash or work-queue stall. | ### 15.2 Unnamed, same job Timeouts and deadlines that are watchdogs in everything but name: | Mechanism | Location | Bounds | |-----------|----------|--------| | RX-latch payload deadline | all three custom radio paths: SX126x `patch 0003` ("Bound the lifetime of the RX-busy latch"), `lr11xx_lora.c`, `lr20xx_lora.c` (`header_seen_at_ms` + `*_max_payload_ms()`) | A `HEADER_VALID` whose packet never completes would pin the TX gate closed and silently mute the node — continuous RX has no symbol timer. Released at 255-byte airtime +25% +100 ms. See [5.2.1](#521-rx-busy-gate-tx-during-rx-prevention). | | Stuck-DIO1 counter | `lr11xx_lora.c` and `lr20xx_lora.c` | 5 empty DIO1 cycles → hardware reset. Counting rather than timing; on the LR11xx it complements the wedge watchdog rather than replacing it. | | CAD timeout | `Dispatcher::checkSend()` | 4 s (~20 retry attempts) → `ERR_EVENT_CAD_TIMEOUT` + `recoverRxState()`, rather than falling through to TX. See [5.2.2](#522-cad-timeout-recovery). | | Chip-side TX timeout | SX126x `SetTx` deadline (`patch 0003`, "Scale the chip-side Tx timeout from airtime instead of a fixed 10 s"; airtime +25% +500 ms, floored at 10 s, clamped 262143 ms); LR2021 `TIMEOUT` IRQ handler | The chip stops the transmission when this fires, so a fixed value is a truncation, not a safeguard — at SF12/BW62.5 the old flat 10 s cut every packet from 76 bytes up. | | Serial partial-frame resync | `ZephyrCompanionUSB.cpp` (`USB_FRAME_TIMEOUT_MS`) | 2 s. Parser-level only — deliberately **not** a session or idle timeout; an idle-but-connected companion sits in `RX_IDLE` indefinitely. | | TCP send timeout | `TcpCompanionTransport.c` | Native Linux and WiFi companions. A peer that can't accept a frame in the window is wedged → close it, rather than hang the whole queue. | | Bounded RXTO wait | `patches/zephyr/0010-uarte-pm-suspend-bounded-rxto-wait.patch` | `uarte_pm_suspend()` busy-waits for RXTO with no timeout upstream. Landing in the STOPRX race with bytes in flight spins forever on the main thread and wedges the entire mesh (observed: RAK3401 1W repeater on 1.16.6, CLI answering only `-> busy`). Backstop for the GPS UART PM path in [7.3](#73-gps-adaptersgps). |