Files
ZephCore/docs/ARCHITECTURE.md
T
2026-10-06 21:54:36 +02:00

100 KiB
Raw Blame History

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; decisions are in adr/.


Table of Contents

  1. Project Overview
  2. Directory Structure
  3. Layer Architecture
  4. Core Mesh Engine
  5. Radio Subsystem
  6. Application Layer
  7. Hardware Adapters
  8. UI Subsystem
  9. Build System
  10. Board Matrix
  11. Packet Format Reference
  12. BLE Protocol Reference
  13. Data Storage
  14. Key Call Flows
  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. 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_<family>.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_timestamps, 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:

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.

  • 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/channels flush after 5-second delay
  • Protocol versioning: V2/V3 frame format negotiation with phone app
  • Ed25519 signing: 3-phase flow (start→data→finish) for signing up to 8KB
  • Flood scope: Transport key filtering for region-scoped sends

6.2.1 V-Contact (Loopback Admin Contact)

ZephCore-only feature (no Arduino equivalent). The companion synthesizes a CHAT contact named v<node_name> 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 memcmps 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 <mV>|0|default — default = board auto-shutdown threshold + 200 mV (so the alert wins the race against the 90 s shutdown confirm window), 3500 mV on boards without auto-shutdown. Alert latches 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 <mV>, 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 <sec> 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 <epoch>, 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)
  • 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 <sec>; 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.
  • 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)
  • 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. 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), 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. 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).

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
  • adapters/mqtt/ZephyrMQTTPublisher.c: MQTT publisher for observed/uplinked packets
  • adapters/ota/wifi_ota.c: WiFi SoftAP + HTTP firmware upload to MCUboot slot1 (ESP32, requires --sysbuild)

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
  • 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_<page>_mono()             — mono / tiny / e-ink layout (always compiled)
render_<page>_color()            — RGB565 layout, wrapped in
                                   #if MC_DISPLAY_COLOR_PANEL
render_<page>()                  — 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/<platform>_common.conf (nrf52/esp32/nrf54l/mg24 specifics)
      → boards/<mcu>/<board>/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<Name>()
    → 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). 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.
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.
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.
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.