Server roles (slice 7) - CommonCLI split into upstream's shape; region CLI moved into CommonCLI; ClientACL, RegionMap, TransportKeyStore and both server meshes in upstream shape over an Arduino compat shim (helpers/compat: Arduino.h, Stream/Print, Serial, SHA256, FILESYSTEM/File over Zephyr fs) - room server processAck port; `get acl` prints as upstream - room: default name Room-XXXX, display/input rotation, live radio UI refresh Event loops (slice 7b) - repeater and room server share server_main_common with a ServerRole descriptor; shared event bits in src/mesh_events.h - room server deadline-driven (idle wakes 2.2/s -> 0.08/s); companion loop() runs on housekeeping (closes the lazy contact-flush gap) - `get loop.wakes` in debug builds (CONFIG_ZEPHCORE_LOOP_WAKE_STATS) Companion transports and WiFi (slice 8) - upstream MultiSerialInterface: BLE, USB/UART and TCP are each a BaseSerialInterface, every connected transport gets every frame; CompanionSerial holds a lossless reply; shared frame_txq for BLE/TCP - SerialCompanionTransport and LinuxTCPTransport replaced by TcpCompanionTransport; ZephyrBLE split (gatt layout, DFU) - WiFi companion (upstream wifi.ssid/pwd/enabled CLI, TCP port 5000) on boards declaring `capabilities: wifi` in zephcore.yml: the PSRAM ESP32-S3 boards, plus Heltec V3 (140 contacts), Wireless Tracker (130) and both C6 boards (280); ESP32-S3 without PSRAM gets bounded WiFi buffers and a measured 72 KB heap; PSRAM boards' internal heap 72 KB - TCP queues: own depth (ZEPHCORE_TCP_QUEUE_SIZE=8), RX waits instead of dropping - RAM: companion ClientACL dropped; main/BT-TX/TCP stacks sized from measured peaks (-7 KB nRF companions, -9 KB ESP32 companions) - ESP32 WiFi stopped working under ~200 frames/s unicast. Fixed by: patches/zephyr/0017 never block the WiFi task in the RX callback; patches/zephyr/0018 lock shared_multi_heap (heap corruption with the WiFi heap in PSRAM); patches/modules/hal_espressif/0001 backport of hal_espressif af97fb02df (PSRAM realloc) Storage and prefs (slice 9) - prefs are now prefs.json through upstream's ConfigSerializer (ported verbatim), with upstream's key names for shared fields and ZephCore's own under "zc"; on first boot the binary new_prefs / repeater prefs is migrated and kept, so a downgrade still boots with the old settings - helpers/PrefsCodec: both legacy binary layouts (companion 272 B, server 311 B) as Zephyr-free codecs; sanitizeNodePrefs is the only validator - fix: server prefs could be replaced by a truncated file when a write failed; identity, prefs and channel writes go through one checked atomic replace (ZephyrFsUtil) - fix: a corrupt cad_auto byte turned adaptive CAD off instead of the default on - fix: a deliberate 300 s GPS duty on a repeater was rewritten to 48 h on every boot (that migration now runs on legacy files only) - one identity loader for all roles (IdentityFile); an unusable key file is always parked as .bad before regenerating - server store no longer stamps repeater defaults on room server and observer; companion first-boot volume policy moved into the store (adoptVolume) - ZephyrDataStore 1435 -> ~850 lines
ZephCore — MeshCore for Zephyr RTOS 
A port of MeshCore LoRa mesh firmware from Arduino to Zephyr RTOS. Aiming for full protocol compatibility with the original Arduino firmware and the MeshCore mobile apps.
Why Zephyr?
The Arduino version uses a loop(). This port replaces that with Zephyr's event-driven primitives (k_event_wait, k_poll, k_msgq), so the CPU sleeps in WFI (Wait For Interrupt) between events.
Other benefits:
- Proper driver model -- LoRa, GNSS, display, sensors, and BLE all use Zephyr subsystem drivers rather than Arduino libraries
- Hierarchical build configuration -- board-specific settings compose cleanly via Kconfig and devicetree overlays
- DFU support -- generates Arduino compatible zip packages for OTA updates and UF2 binaries for drag-and-drop flashing
- Back and forth compatible -- Adapted to softdevice and adafruit's bootloader, so no bootloader re-flashing required.
Supported Boards
nRF52840, ESP32, nRF54L15, MG24, and STM32WL boards, covering SX126x, LR1110, LR2021, and SX127x radios. ZephCore also runs as a native Linux process on SBCs (Femtofox / Luckfox Pico Mini, Raspberry Pi + RAK6421 HAT) with a real SX1262 on SPI/GPIO and the companion app connecting over TCP — see LINUX_NATIVE.md.
For the full board list with exact west build -b strings, radios, and
hardware notes, see the supported boards list and
the Board Porting Guide.
Device Roles
- Companion (default) -- connects to MeshCore mobile apps via BLE/USB. Contacts, channels, offline message queue.
- Repeater -- forwards packets, configured via USB serial CLI. See the Repeater CLI Command Reference for all available commands.
- Room Server -- store-and-forward shared message room (a "BBS"). Clients log in with an admin or guest password and post messages; the server pushes each new post to every other logged-in client. No BLE; configured via the same USB serial CLI as the repeater.
- Observer (ESP32 only) -- listen-only node that publishes received LoRa packets to MQTT over WiFi STA. Configured at runtime via serial CLI.
Building
Prerequisites: Zephyr SDK >=1.0.1 (!) and west installed (required by Zephyr 4.4.0, which is pinned in west.yml).
Optional: adafruit-nrfutil to allow DFU zip generation for OTA updates on nRF52.
Or skip the prerequisites entirely and use the dev container -- SDK, toolchains, west and the vendor blobs, preconfigured to match CI. Open the repo in VS Code and run Dev Containers: Reopen in Container.
# Initialize workspace (first time only)
cd <cloned folder>
west init -l zephcore
west update
# Companion (production — default, no extra conf needed)
west build -b wio_tracker_l1 zephcore --pristine
# Companion (debug logging)
west build -b wio_tracker_l1 zephcore --pristine -- \
-DEXTRA_CONF_FILE="boards/common/debug.conf"
# Repeater
west build -b rak4631 zephcore --pristine -- \
-DEXTRA_CONF_FILE="boards/common/repeater.conf"
# Repeater (debug logging)
west build -b rak4631 zephcore --pristine -- \
-DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/debug.conf"
# Repeater with packet logging (clean RAW/RX/TX lines only, no debug spam)
west build -b rak4631 zephcore --pristine -- \
-DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/packet_logging.conf"
# ESP32 repeater + WiFi AP HTTP OTA (requires sysbuild for MCUboot)
west build -b xiao_esp32s3/esp32s3/procpu zephcore --pristine --sysbuild -- \
-DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/wifi_ota.conf"
# Room Server (store-and-forward BBS, USB CLI)
west build -b rak4631 zephcore --pristine -- \
-DEXTRA_CONF_FILE="boards/common/room_server.conf"
# Observer (ESP32, listen-only WiFi+MQTT)
west build -b xiao_esp32c3 zephcore --pristine -- \
-DEXTRA_CONF_FILE="boards/common/observer.conf"
# Formatter (factory-reset utility)
west build -b wio_tracker_l1 zephcore/tools/formatter --pristine
# BLE debug logging (debug.conf enables logging; the flag raises the BLE adapter to DBG)
west build -b rak4631 zephcore --pristine -- \
-DEXTRA_CONF_FILE="boards/common/debug.conf" -DCONFIG_ZEPHCORE_BLE_LOG_LEVEL_DBG=y
Output binaries are in build/zephyr/ -- .hex, .uf2, and DFU .zip as applicable.
Platform notes:
- ESP32 boards require
west blobs fetch hal_espressifonce before the first build. - ESP32 default builds use
CONFIG_ESP_SIMPLE_BOOT(no MCUboot) --west flashwrites a self-containedzephyr.bin. Thewifi_ota.confoverlay is the exception: it requires MCUboot, so add--sysbuild. - MG24 requires
west blobs fetch hal_silabsandpyocd(west flash --runner pyocd). - nRF54L15 requires
--no-sysbuild(no MCUboot support yet). - Heltec V3 routes console/shell to
uart0-- use the UART serial port for logs/CLI.
Always use --pristine when switching boards or roles.
Architecture Overview
Mobile App <--BLE (NUS)--> [ Companion ] <--LoRa--> Mesh Network
|
k_event_wait()
/ | \
LORA_RX LORA_TX_DONE BLE_RX
All code paths are event-driven. The CPU sleeps in WFI between events.
- LoRa RX: Zephyr driver callback enqueues to a ring buffer and signals the mesh event loop
- LoRa TX: A dedicated thread blocks on
k_poll(), restarts RX on completion, then notifies the mesh loop - BLE: NUS write handler enqueues to
k_msgqand signals the mesh loop; TX usesbt_gatt_notify_cb()chaining - USB: CDC-ACM with V3 binary framing protocol, frame timeout recovery
- Main loop:
k_event_wait()blocks until work arrives; housekeeping runs every 5s
Key Differences from Arduino
| Arduino | Zephyr | |
|---|---|---|
| Idle behavior | Cooperative loop; CPU busy-waits unless board.sleep() called explicitly |
k_event_wait(K_FOREVER) yields to idle thread → WFI between events |
| LoRa TX completion | ISR sets flag, polled in loop() via isSendComplete() |
ISR signals k_poll_signal, dedicated thread blocks on k_poll() |
| BLE transport | Platform-specific (ESP-IDF BLE, Adafruit nRF52 lib) | Unified bt_gatt API across all SoCs |
| LoRa driver | RadioLib (userspace SPI bit-bang) | Zephyr subsystem driver (DTS-configured, kernel-managed SPI) |
| Configuration | platformio.ini + variant.h per board |
Kconfig + devicetree overlays, hierarchical config inheritance |
| Threading | Single loop() + ISRs |
Explicit threads (main mesh, TX wait) + system work queue |
Adaptive Contention Window (ZephCore-only)
Arduino MeshCore uses three static delay knobs (txdelay, rxdelay, direct.txdelay) that add the same retransmit jitter regardless of local conditions. In a linear chain of repeaters where each only hears its neighbor, this adds latency for zero benefit. In dense areas with 50+ neighbors, the same value may be too low to avoid collisions.
ZephCore replaces all three with a self-tuning system based on observed retransmit contention:
-
Dupe counting: When a node retransmits a flood packet, it counts how many times it hears that same packet retransmitted by neighbors within a 10-second window. This is a direct measurement of local contention -- 0 dupes means a quiet linear chain, 15+ means a dense cluster.
-
EMA-based delay sizing: Dupe counts feed into a rolling exponential moving average. This drives a sqrt-curve delay factor for future retransmits: near-zero delay in sparse areas, scaling up in dense ones. At ~15 dupes (moderate density), the factor matches the old Arduino default of 0.5. Jitter is double-capped at
min(2000ms, 6·airtime)for repeaters. -
Reactive per-packet backoff: When a node is waiting to retransmit and hears a neighbor retransmit the same packet, it pushes its own TX back by a random amount (up to
backoff.multiplier × airtime). This is real-time CSMA -- you hear the channel being used for your packet, so you defer. Capped atmin(2000ms, 12·airtime)total reactive extension per packet.
Companion-originated floods use a smaller spread (up to min(1000ms, 3·airtime)) so user messages feel responsive while still avoiding collisions with nearby repeaters that may be in TX/RX.
Direct packets (routed, single next-hop) use minimal fixed jitter instead of adaptive delay, since only the next hop retransmits them.
The old txdelay, rxdelay, and direct.txdelay commands are still accepted for binary compatibility with Arduino prefs but are ignored -- the system is fully adaptive.
CLI commands:
get txdelay-- shows adaptive status: contention estimate and current flood delay factorget/set backoff.multiplier-- per-dupe reactive backoff multiplier (default 0.2, range 0.0-2.0). Set to 0 to disable reactive backoff (EMA window still works). Higher values allow more per-packet deferral in dense areas.
Compatibility: Purely local behavior, no wire protocol changes. Works alongside Arduino MeshCore repeaters -- their retransmits are counted as dupes just the same.
Power Saving
- LoRa RX duty cycle: chip-autonomous receive windowing (sniff mode) reduces LoRa RX current from ~10-15mA to ~3-5mA. Off by default; toggle at runtime with
set rxduty on/off(SX126x only — unsupported on LR1110 due to a mid-preamble lock issue, and on SX127x). Window timing is auto-sized per SF/BW/preamble from the SX126x datasheet constraints. - Production by default: No logging, no asserts, reboot-on-fatal. Add
debug.confto enable logging. - GPIO-gated GPS: Powered on only during fix acquisition
Configuration
Key Kconfig options (set in board configs or via -D flags):
| Option | Default | Description |
|---|---|---|
CONFIG_ZEPHCORE_ROLE_COMPANION |
y | BLE companion mode |
CONFIG_ZEPHCORE_ROLE_REPEATER |
n | USB CLI repeater mode |
CONFIG_ZEPHCORE_ROLE_OBSERVER |
n | Listen-only WiFi+MQTT mode (ESP32) |
CONFIG_ZEPHCORE_RADIO_NATIVE |
y | SX1261/SX1262/SX1268, LLCC68, STM32WL (Zephyr native sx126x driver) |
CONFIG_ZEPHCORE_RADIO_LR1110 |
n | LR1110/LR1120/LR1121 (custom driver) |
CONFIG_ZEPHCORE_RADIO_LR2021 |
n | LR2021 (custom driver) |
CONFIG_ZEPHCORE_RADIO_SX127X |
n | SX1272/SX1276/SX1278 (loramac-node backend) |
CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE |
n | RX duty cycle (sniff mode) boot default; runtime toggle via set rxduty on/off (SX126x only) |
CONFIG_ZEPHCORE_APC |
y (compiled in, runtime OFF) | Adaptive Power Control — enable at runtime via CLI |
CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM |
22 | Initial TX power; lower for boards with external PA |
CONFIG_ZEPHCORE_MAX_TX_POWER_DBM |
22 | Hard cap (radio adapter clamps above this) |
CONFIG_ZEPHCORE_MAX_CONTACTS |
350 | Contact storage slots (companion) |
CONFIG_ZEPHCORE_MAX_CHANNELS |
40 | Channel slots (companion) |
CONFIG_ZEPHCORE_BLE_PASSKEY |
123456 | BLE pairing PIN |
CONFIG_ZEPHCORE_GPS_POLL_INTERVAL_SEC |
300 | Companion GPS duty interval between fixes (seconds, 10–86400); always-on is a runtime setting (set gps duty 0) |
CONFIG_ZEPHCORE_GPS_FIRST_FIX_TIMEOUT_SEC |
300 | Cold-start window for the very first fix (longer to allow almanac download) |
CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC |
172800 | Repeater/room-server GPS duty interval boot default (48 h); 0 = always-on |
CONFIG_ZEPHCORE_WIFI_OTA |
n | WiFi AP + HTTP OTA updates (ESP32 repeaters, requires --sysbuild) |
CONFIG_ZEPHCORE_REPEATER_UPLINK |
n | Repeater WiFi+MQTT uplink (ESP32) |
CONFIG_ZEPHCORE_PACKET_LOGGING |
n | Arduino-compatible mesh packet logging |
CONFIG_ZEPHCORE_HOUSEKEEPING_INTERVAL_MS |
5000 | Periodic maintenance interval |
Project Structure
zephcore/
src/ Core mesh engine (Mesh, Dispatcher, Packet, Identity, ContentionTracker)
app/ Companion / Repeater / Room Server / Observer role implementations
adapters/
ble/ BLE NUS transport
board/ GPIO, LED, power management
clock/ Millisecond and RTC clocks
datastore/ LittleFS filesystem wrapper
gps/ GPS/GNSS drivers
mqtt/ MQTT publisher (observer / repeater uplink)
ota/ WiFi AP + HTTP firmware update server
radio/ LoRa radio drivers (SX126x, LR1110, LR2021, SX127x)
rng/ Random number generator
sensors/ I2C sensor auto-detection
transport/ TCP companion (native Linux) + serial companion (STM32WL)
usb/ USB serial transport (CDC-ACM, V3 framing)
wifi/ WiFi station client
helpers/
ui/ Shared UI plumbing (display, buzzer, multi-tap input)
ui-button/ Single-button page UI
ui-joystick/ 5-way joystick UI (Wio Tracker L1)
boards/
nrf52840/ nRF52840 board overlays and configs
esp32/ ESP32 (classic + C3/C6/S3) board overlays and configs
nrf54l/ nRF54L15 board overlay and config
mg24/ EFR32MG24 board overlay and config
stm32wl/ Seeed LoRa-E5 board overlay and config
linux_native/ native_sim presets (Femtofox, RAK6421)
common/ Shared Kconfig fragments and devicetree includes
lib/ Monocypher crypto library (Ed25519/X25519)
patches/ Auto-applied patches to the Zephyr tree
License
MIT License — see LICENSE. Same license as the
upstream MeshCore project, which this work relies heavily on (see the
official meshcore repo).
A few vendored dependencies carry their own (compatible) licenses — see the
notice at the bottom of LICENSE for details (Monocypher, Zephyr patches).
Logo by recrof, licensed under the WTFPL.
The comic below was stolen from https://www.twonks.co.uk/
(FYI the whole project is 99,9% claude and cursor backed, relying heavily on the official meshcore repo and the work they do in it)
