server roles, event loops, companion transports, storage and prefs.json

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
This commit is contained in:
liquidraver committed 2026-09-25 08:34:21 +02:00
1 parent b361c33901
commit 38f815a0a1
114 files changed
+11280 -10308

No files matched your search

+10
View File
@@ -255,6 +255,16 @@ void Utils::toHex(char *dest, const uint8_t *src, size_t len)
*dest = 0;
}
void Utils::printHex(Stream &s, const uint8_t *src, size_t len)
{
while (len > 0) {
uint8_t b = *src++;
s.print(hex_chars[b >> 4]);
s.print(hex_chars[b & 0x0F]);
len--;
}
}
bool Utils::fromHex(uint8_t *dest, int dest_size, const char *src_hex)
{
size_t len = strlen(src_hex);
+197 -453
View File
@@ -23,6 +23,8 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/drivers/hwinfo.h>
#include <helpers/boot_info.h>
#include <helpers/LoopWakeStats.h>
#include "mesh_events.h"
#include <zephyr/sys/reboot.h>
#include <ZephyrSensorManager.h>
#include <helpers/time_sync.h>
@@ -38,20 +40,16 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
#include "buzzer.h"
#endif
#include <ZephyrBLE.h>
/* The wired-companion stack (ZephyrCompanionUSB) is compiled when either
* logging needs the CDC console (debug builds), the native-USB companion is
* enabled (CONFIG_ZEPHCORE_COMPANION_USB, default-y on USB-capable boards), or
* the plain-UART companion is enabled (CONFIG_ZEPHCORE_COMPANION_SERIAL, for
* USB-UART-bridge / no-USB boards). All three share the same frame parser,
* TX ring, CLI, and BLE arbitration — only the byte backend differs. */
#define ZEPHCORE_USB_STACK \
(IS_ENABLED(CONFIG_LOG) || IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_USB) || \
IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_SERIAL))
/* The companion transports (BLE, USB/UART, TCP) as upstream
* BaseSerialInterfaces; also defines ZEPHCORE_USB_STACK. */
#include <CompanionInterfaces.h>
#include <helpers/MultiSerialInterface.h>
#include <app/CompanionSerial.h>
#if IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_WIFI)
#include <app/CompanionWifi.h>
#endif
#if ZEPHCORE_USB_STACK
#include <ZephyrCompanionUSB.h>
#include <ZephyrUSBCDC.h>
#endif
@@ -65,8 +63,8 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
#ifdef ZEPHCORE_LORA
#include <app/CompanionMesh.h>
#include <helpers/CommonCLI.h>
#include <helpers/ClientACL.h>
#include <helpers/StatsFormatHelper.h>
#include <app/CompanionCLI.h>
#include <helpers/battery_curve.h>
#endif
@@ -81,17 +79,13 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line)
}
#endif
/* The mesh thread blocks on these; ISRs and callbacks post them. */
#define MESH_EVENT_LORA_RX BIT(0) /* LoRa packet received */
#define MESH_EVENT_LORA_TX_DONE BIT(1) /* LoRa TX complete */
#define MESH_EVENT_BLE_RX BIT(2) /* BLE/USB frame received */
#define MESH_EVENT_HOUSEKEEPING BIT(3) /* Periodic housekeeping (noise floor, etc.) */
#define MESH_EVENT_UI_ACTION BIT(4) /* Button action from UI (deferred to mesh thread) */
#define MESH_EVENT_GPS_ACTION BIT(5) /* GPS state change (main thread only) */
#define MESH_EVENT_TX_DRAIN BIT(6) /* Outbound packet delay expired, run checkSend */
#define MESH_EVENT_PREFS_DIRTY BIT(8) /* Prefs mutated off-main; main flushes to flash */
#define MESH_EVENT_RTC_SAVE BIT(9) /* Hardware-RTC write requested off-main */
#define MESH_EVENT_CONTACT_ITER BIT(10) /* Continue contact-dump iteration on main thread */
/* The mesh thread blocks on these; ISRs and callbacks post them. The shared
* bits are in mesh_events.h; these are the companion's own. */
#define MESH_EVENT_UI_ACTION BIT(MESH_EVENT_ROLE_BASE) /* Button action from UI (deferred to mesh thread) */
#define MESH_EVENT_JOYSTICK_LOOP BIT(MESH_EVENT_ROLE_BASE + 1) /* Run the joystick UI loop (on input, notify or its timers) */
#define MESH_EVENT_PREFS_DIRTY BIT(MESH_EVENT_ROLE_BASE + 2) /* Prefs mutated off-main; main flushes to flash */
#define MESH_EVENT_CONTACT_ITER BIT(MESH_EVENT_ROLE_BASE + 3) /* Continue contact-dump iteration on main thread */
#define MESH_EVENT_LINK BIT(MESH_EVENT_ROLE_BASE + 4) /* A transport connected or disconnected */
#ifdef ZEPHCORE_LORA
static void save_prefs_to_flash(void);
@@ -107,9 +101,8 @@ static atomic_t pending_rtc_epoch = ATOMIC_INIT(0);
#define MESH_EVENT_BASE (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | \
MESH_EVENT_BLE_RX | MESH_EVENT_HOUSEKEEPING | MESH_EVENT_UI_ACTION | \
MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_PREFS_DIRTY | \
MESH_EVENT_RTC_SAVE | MESH_EVENT_CONTACT_ITER)
MESH_EVENT_RTC_SAVE | MESH_EVENT_CONTACT_ITER | MESH_EVENT_LINK)
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK)
#define MESH_EVENT_JOYSTICK_LOOP BIT(7) /* Joystick UI loop tick (50 ms) */
#define MESH_EVENT_ALL (MESH_EVENT_BASE | MESH_EVENT_JOYSTICK_LOOP)
#else
#define MESH_EVENT_ALL MESH_EVENT_BASE
@@ -126,7 +119,7 @@ static void request_rtc_save(uint32_t epoch)
k_event_post(&mesh_events, MESH_EVENT_RTC_SAVE);
}
static void process_companion_rx(void); /* runs on MAIN thread (see ble_on_rx_frame) */
static void process_companion_rx(void); /* runs on MAIN thread (see link_on_rx) */
static void run_contact_iteration(void); /* runs on MAIN thread (see MESH_EVENT_CONTACT_ITER) */
static void housekeeping_timer_fn(struct k_timer *timer);
#if ZEPHCORE_USB_STACK
@@ -160,100 +153,70 @@ struct companion_cli_line { char buf[CLI_LINE_BUF_SIZE]; };
K_MSGQ_DEFINE(companion_cli_queue, sizeof(struct companion_cli_line), 4, 4);
#endif
#if ZEPHCORE_USB_STACK
/* USB TX ring drained (CDC TX interrupt context): resume the contact dump. */
static void usb_on_tx_drain(void)
{
k_event_post(&mesh_events, MESH_EVENT_CONTACT_ITER);
}
#endif
K_TIMER_DEFINE(housekeeping_timer, housekeeping_timer_fn, NULL);
#ifdef ZEPHCORE_LORA
static CompanionMesh *companion_mesh_ptr;
#endif
/* ========== BLE callbacks → main ========== */
/* ========== Companion transports ==========
*
* As upstream's companion_radio/main.cpp: every transport this build has is a
* BaseSerialInterface in one MultiSerialInterface, and CompanionMesh sees only
* that (through CompanionSerial, which keeps a lossless reply the transports
* could not take). Every connected transport gets every frame; a command from
* any of them is served. */
static MultiSerialInterface interface_manager;
static CompanionSerial companion_serial(interface_manager);
#if IS_ENABLED(CONFIG_BT)
static ZephyrBLEInterface ble_interface;
#endif
#if ZEPHCORE_USB_STACK
static ZephyrSerialInterface usb_interface;
#endif
#if IS_ENABLED(CONFIG_ZEPHCORE_TRANSPORT_TCP)
static ZephyrTcpInterface tcp_interface;
#endif
static void ble_on_rx_frame(const uint8_t *data, uint16_t len)
/* The transports' callbacks run on their own threads (BT, sysworkq, ISR, the
* TCP listener), so they only post; the work happens in the event loop. */
static void link_on_rx(void)
{
struct {
uint16_t len;
uint8_t buf[MAX_FRAME_SIZE];
} f;
if (len == 0 || len > MAX_FRAME_SIZE) {
return;
}
f.len = len;
memcpy(f.buf, data, len);
if (k_msgq_put(zephcore_ble_get_recv_queue(), &f, K_NO_WAIT) != 0) {
LOG_WRN("recv queue full");
return;
}
k_event_post(&mesh_events, MESH_EVENT_BLE_RX);
}
/* BLE TX queue empty: resume the contact dump and the TX drain. */
static void ble_on_tx_idle(void)
/* A transport's TX drained: resume the contact dump and the TX drain. */
static void link_on_tx_idle(void)
{
k_event_post(&mesh_events, MESH_EVENT_CONTACT_ITER);
k_event_post(&mesh_events, MESH_EVENT_TX_DRAIN);
}
static void ble_on_connected(void)
/* Counted, so the loop sees every edge even when several coalesce into one
* MESH_EVENT_LINK. */
static atomic_t link_ups;
static atomic_t link_downs;
static void link_on_connected(void)
{
ui_notify(UI_EVENT_BLE_CONNECTED);
atomic_inc(&link_ups);
k_event_post(&mesh_events, MESH_EVENT_LINK);
}
/* One deferred reply is enough: RX pauses until the transport has accepted
* it. A msgq also lets the disconnect path purge it. */
struct pending_reply_frame {
uint16_t len;
uint8_t buf[MAX_FRAME_SIZE];
static void link_on_disconnected(void)
{
atomic_inc(&link_downs);
k_event_post(&mesh_events, MESH_EVENT_LINK);
}
static const struct companion_link_cbs link_cbs = {
.on_rx = link_on_rx,
.on_tx_idle = link_on_tx_idle,
.on_connected = link_on_connected,
.on_disconnected = link_on_disconnected,
};
K_MSGQ_DEFINE(pending_reply, sizeof(struct pending_reply_frame), 1, 4);
/* Session end (BLE disconnect or USB close): drop the contact dump (a stale
* PACKET_CONTACT_END would confuse the next session), keep the un-ACKed
* message queued for re-send, and free an abandoned sign buffer. */
static void companion_session_cleanup(void)
{
k_msgq_purge(&pending_reply);
#ifdef ZEPHCORE_LORA
companion_mesh_ptr->cancelContactIterator();
companion_mesh_ptr->cancelSyncPending();
companion_mesh_ptr->cleanupSignState();
#endif
}
#if ZEPHCORE_USB_STACK
/* A USB session shows as "connected" too, as upstream does for serial. */
static void usb_on_session_start(void)
{
ui_notify(UI_EVENT_BLE_CONNECTED);
}
static void usb_on_session_end(void)
{
companion_session_cleanup();
ui_notify(UI_EVENT_BLE_DISCONNECTED);
}
#endif
static void ble_on_disconnected(void)
{
#if ZEPHCORE_USB_STACK
zephcore_usb_companion_reset_rx();
#endif
companion_session_cleanup();
ui_notify(UI_EVENT_BLE_DISCONNECTED);
}
#if IS_ENABLED(CONFIG_BT)
#if IS_ENABLED(CONFIG_ZEPHCORE_BLE_DFU)
static void ble_on_dfu_request(void)
{
@@ -262,169 +225,90 @@ static void ble_on_dfu_request(void)
#endif
static const struct ble_callbacks ble_cbs = {
.on_rx_frame = ble_on_rx_frame,
.on_tx_idle = ble_on_tx_idle,
.on_connected = ble_on_connected,
.on_disconnected = ble_on_disconnected,
.link = link_cbs,
#if IS_ENABLED(CONFIG_ZEPHCORE_BLE_DFU)
.on_dfu_request = ble_on_dfu_request,
#endif
};
/* ========== Frame send/receive (mesh ↔ BLE/USB) ========== */
static size_t transport_write_frame(const uint8_t *src, size_t len)
{
#if ZEPHCORE_USB_STACK
/* While USB owns the interface the BLE queue is never drained. */
if (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) {
/* A text-CLI session gets no binary frames; report success so the
* caller does not retry one we dropped on purpose. */
if (zephcore_usb_companion_is_text_session()) {
return len;
}
return zephcore_usb_companion_write_frame(src, len);
}
#endif
return zephcore_ble_send(src, (uint16_t)len);
}
static size_t write_frame(const uint8_t *src, size_t len)
/* The last client went away: drop the contact dump (a stale
* PACKET_CONTACT_END would confuse the next session), keep the un-ACKed
* message queued for re-send, free an abandoned sign buffer, and drop a held
* reply nobody will read. Not while another transport is still connected:
* it shares the dump and the sync. */
static void companion_session_cleanup(void)
{
if (len == 0 || len > MAX_FRAME_SIZE ||
k_msgq_num_used_get(&pending_reply) != 0) {
return 0;
}
if (transport_write_frame(src, len) == len) {
return len;
}
/* Only replies are retained (pushes stay lossy). Success once we hold a
* copy, so retrying callers (contact/message sync) do not send it twice. */
if (src[0] < 0x80) {
struct pending_reply_frame f;
f.len = len;
memcpy(f.buf, src, len);
if (k_msgq_put(&pending_reply, &f, K_NO_WAIT) == 0) {
return len;
}
}
return 0;
companion_serial.drop();
#ifdef ZEPHCORE_LORA
companion_mesh_ptr->cancelContactIterator();
companion_mesh_ptr->cancelSyncPending();
companion_mesh_ptr->cleanupSignState();
#endif
}
static void retry_pending_reply(void)
/* MESH_EVENT_LINK: a transport (USB and TCP sessions included) connected or
* disconnected. */
static void process_link_change(void)
{
struct pending_reply_frame f;
if (k_msgq_peek(&pending_reply, &f) == 0 &&
transport_write_frame(f.buf, f.len) == f.len) {
(void)k_msgq_get(&pending_reply, &f, K_NO_WAIT);
if (atomic_set(&link_ups, 0) > 0) {
ui_notify(UI_EVENT_BLE_CONNECTED);
}
if (atomic_set(&link_downs, 0) > 0 && !companion_serial.isConnected()) {
companion_session_cleanup();
ui_notify(UI_EVENT_BLE_DISCONNECTED);
}
}
/* A binary companion client is listening: BLE, or a USB session that is not
* the text CLI (zephcore_ble_is_connected() alone dropped pushes over USB). */
/* A binary companion client is listening on some transport (a USB text-CLI
* session is not one). */
static bool companion_transport_up(void)
{
bool up = zephcore_ble_is_connected();
#if ZEPHCORE_USB_STACK
up = up || (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB &&
!zephcore_usb_companion_is_text_session());
#endif
return up;
}
/* [push_code][data...] */
static void push_callback(uint8_t code, const uint8_t *data, size_t len)
{
if (!companion_transport_up()) return;
uint8_t push_buf[1 + MAX_FRAME_SIZE - 1];
size_t total_len = 1 + len;
if (total_len > MAX_FRAME_SIZE) {
LOG_WRN("data too long %u", (unsigned)len);
total_len = MAX_FRAME_SIZE;
len = MAX_FRAME_SIZE - 1;
}
push_buf[0] = code;
if (len > 0 && data != nullptr) {
memcpy(&push_buf[1], data, len);
}
LOG_DBG("code=0x%02x len=%u (total frame)", code, (unsigned)total_len);
write_frame(push_buf, total_len);
return companion_serial.isConnected();
}
/* Inbound binary frames, on MESH_EVENT_BLE_RX. */
static void process_companion_rx(void)
{
struct {
uint16_t len;
uint8_t buf[MAX_FRAME_SIZE];
} f;
uint8_t buf[MAX_FRAME_SIZE];
size_t len;
bool handled = false;
/* Process all queued frames */
while (k_msgq_num_used_get(&pending_reply) == 0 &&
k_msgq_get(zephcore_ble_get_recv_queue(), &f, K_NO_WAIT) == 0) {
/* CompanionSerial reads nothing while it holds a reply */
while ((len = companion_serial.checkRecvFrame(buf)) > 0) {
handled = true;
#ifdef ZEPHCORE_LORA
/* A contact dump survives commands in between (the app talks to us
* mid-sync); only CMD_APP_START or a disconnect ends it. */
if (!companion_mesh_ptr->handleCmdFrame(f.buf, f.len)) {
LOG_DBG("rx_process: unknown cmd 0x%02x len=%u", f.buf[0], (unsigned)f.len);
if (!companion_mesh_ptr->handleCmdFrame(buf, len)) {
LOG_DBG("rx_process: unknown cmd 0x%02x len=%u", buf[0], (unsigned)len);
uint8_t err_rsp[] = { 0x01, 0x01 }; /* PACKET_ERROR, ERR_UNSUPPORTED */
write_frame(err_rsp, sizeof(err_rsp));
companion_serial.writeFrame(err_rsp, sizeof(err_rsp));
}
#endif
}
ARG_UNUSED(handled);
#if ZEPHCORE_USB_STACK
/* Over USB nothing else kicks the pump that paces multi-frame responses
* (the contact dump), so kick it here. Only after a handled frame: over
* USB the kick always posts MESH_EVENT_CONTACT_ITER, which calls this
* function, and an unconditional kick spun the main thread at 100% CPU
* and starved the LoRa TX-wait thread. */
if (handled && zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) {
zephcore_ble_kick_tx();
/* A command may have started a multi-frame response (the contact dump);
* pump it. Only after a handled frame: CONTACT_ITER also runs this
* function, and an unconditional post spun the main thread at 100% CPU. */
if (handled) {
k_event_post(&mesh_events, MESH_EVENT_CONTACT_ITER);
}
#endif
}
/* The contact dump, as far as TX has room (MESH_EVENT_CONTACT_ITER). */
/* The contact dump, as far as every connected transport has room
* (MESH_EVENT_CONTACT_ITER): BLE and TCP below their 2/3 high-water mark and
* not congested, USB with room for a whole frame. A transport's TX idle
* brings us back. */
static void run_contact_iteration(void)
{
#ifdef ZEPHCORE_LORA
if (!companion_mesh_ptr) {
return;
}
#if ZEPHCORE_USB_STACK
if (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) {
/* Fill the USB TX ring, then stop; usb_on_tx_drain() brings us back
* when it is empty. tx_has_space() guarantees the next frame fits. */
while (zephcore_usb_companion_tx_has_space(CONTACT_FRAME_SIZE)) {
if (!companion_mesh_ptr->continueContactIteration()) {
break; /* iteration complete */
}
}
return;
}
#endif
/* Not while BLE TX is congested, and not above a 2/3 high-water mark. */
if (zephcore_ble_is_congested()) {
return;
}
uint32_t used = k_msgq_num_used_get(zephcore_ble_get_send_queue());
uint32_t queue_size = CONFIG_ZEPHCORE_BLE_QUEUE_SIZE;
bool has_space = (used < (queue_size * 2 / 3));
if (has_space && companion_mesh_ptr) {
if (companion_mesh_ptr->continueContactIteration()) {
zephcore_ble_kick_tx(); /* more to send; the drain brings us back */
while (!companion_serial.isWriteBusy()) {
if (!companion_mesh_ptr->continueContactIteration()) {
break; /* iteration complete */
}
}
#endif
@@ -441,10 +325,15 @@ static void mesh_event_loop(void)
for (;;) {
uint32_t events = k_event_wait(&mesh_events, MESH_EVENT_ALL, false, K_FOREVER);
k_event_clear(&mesh_events, events);
loopWakeNote(events);
/* TX-idle resumes both the retained reply and commands left in RX.
* Housekeeping is a fallback if a transport loses its drain kick. */
retry_pending_reply();
companion_serial.retry();
if (events & MESH_EVENT_LINK) {
process_link_change();
}
#ifdef ZEPHCORE_LORA
/* UI button actions (advert, pref saves) */
@@ -475,10 +364,13 @@ static void mesh_event_loop(void)
run_contact_iteration();
}
/* Packet processing only on radio/transport/TX events */
/* Radio/transport/TX events, and housekeeping for loop()'s own
* deadlines (lazy contact/channel flush, send timeout, keep-alive),
* which would otherwise wait for unrelated traffic. */
if (companion_mesh_ptr &&
(events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE |
MESH_EVENT_BLE_RX | MESH_EVENT_TX_DRAIN))) {
MESH_EVENT_BLE_RX | MESH_EVENT_TX_DRAIN |
MESH_EVENT_HOUSEKEEPING))) {
companion_mesh_ptr->loop();
}
@@ -511,12 +403,14 @@ static void mesh_event_loop(void)
companion_mesh_ptr->msgWaitingWatchdog();
}
#if IS_ENABLED(CONFIG_BT)
/* Advertising watchdog: a transient bt_le_adv_start failure
* otherwise leaves the node undiscoverable until reboot. */
if (zephcore_ble_is_enabled() && !zephcore_ble_is_connected() && !zephcore_ble_is_advertising()) {
LOG_WRN("BLE adv watchdog: not advertising, re-enabling");
zephcore_ble_set_enabled(true);
}
#endif
mesh_housekeeping_ui_refresh();
@@ -617,8 +511,17 @@ static mesh::ZephyrMillisecondClock ms_clock;
static mesh::ZephyrRNG zephyr_rng;
static SimpleMeshTables mesh_tables;
static StaticPoolPacketManager packet_mgr;
static CompanionMesh companion_mesh(lora_radio, ms_clock, zephyr_rng, rtc_clock,
packet_mgr, mesh_tables, data_store);
/* A WiFi companion on a PSRAM board keeps this object (its contacts table and
* offline queue are ~110 KB) in PSRAM, so WiFi and BLE fit in internal DRAM.
* Safe: only the main thread touches it, never an ISR or a flash operation,
* and the SoC boot zeroes .ext_ram.bss before any constructor runs. */
#if IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_WIFI) && IS_ENABLED(CONFIG_ESP_SPIRAM)
#define COMPANION_MESH_SECTION __attribute__((section(".ext_ram.bss.companion_mesh")))
#else
#define COMPANION_MESH_SECTION
#endif
static CompanionMesh companion_mesh COMPANION_MESH_SECTION (lora_radio, ms_clock, zephyr_rng,
rtc_clock, packet_mgr, mesh_tables, data_store);
static void save_prefs_to_flash(void)
{
@@ -632,180 +535,34 @@ static void save_prefs_to_flash(void)
* the v-contact chat, and over-the-air TXT_TYPE_CLI_COMMAND. Not gated on
* ZEPHCORE_USB_STACK. */
class CompanionCLICallbacks : public CommonCLICallbacks {
public:
void savePrefs() override {
data_store.savePrefs(companion_mesh.prefs);
/* No reply held, and every transport's TX drained. */
static bool companion_transport_tx_idle(void)
{
if (companion_serial.holding()) {
return false;
}
const char* getFirmwareVer() override { return FIRMWARE_VERSION; }
const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; }
const char* getRole() override { return "companion"; }
/* CLI `erase`. Re-stamp the init marker as factoryReset() does, or the
* next boot sees no marker and formats a second time. */
bool formatFileSystem() override {
bool ok = data_store.formatFileSystem();
if (ok) {
data_store.writeInitMarker();
}
return ok;
#if IS_ENABLED(CONFIG_BT)
if (!ble_interface.txIdle()) {
return false;
}
/* Same path as the app's advert command; delay_millis is unused. */
void sendSelfAdvertisement(int delay_millis, bool flood) override {
(void)delay_millis;
companion_mesh.sendSelfAdvert(flood);
}
void updateAdvertTimer() override {}
void updateFloodAdvertTimer() override {}
/* Reboot gate: true once the app has everything we owe it, including a
* held-back v-contact ack, so a `reboot` typed there waits for its ack. */
bool transportTxIdle() override {
if (companion_mesh.vcontactConfirmPending() ||
k_msgq_num_used_get(&pending_reply) != 0) {
return false;
}
#if ZEPHCORE_USB_STACK
if (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) {
return zephcore_usb_companion_tx_idle();
}
#endif
return zephcore_ble_tx_idle();
#if ZEPHCORE_USB_STACK
if (!usb_interface.txIdle()) {
return false;
}
#endif
#if IS_ENABLED(CONFIG_ZEPHCORE_TRANSPORT_TCP)
if (!tcp_interface.txIdle()) {
return false;
}
#endif
return true;
}
/* No log file on a companion. */
void setLoggingOn(bool enable) override { (void)enable; }
void eraseLogFile() override {}
void dumpLogFile() override {}
/* No runtime TX-power API in the LoRa driver; log only, as the repeater. */
void setTxPower(int8_t power_dbm) override {
LOG_INF("TX power %d dBm requested (reboot to apply)", power_dbm);
}
bool setRxBoostedGain(bool enable) override {
return lora_radio.setRxBoost(enable);
}
bool setFemRxGain(bool enable) override {
return lora_radio.setFemRxEnable(enable);
}
bool configSideDetectors(const uint8_t* sfs, uint8_t num) override {
return lora_radio.configSideDetectors(sfs, num);
}
/* Adaptive CAD */
int formatFreqErrorStatus(char* buf, int cap) override {
return lora_radio.formatFreqErrorStatus(buf, cap);
}
int formatCadStatus(char* buf, int cap) override {
return lora_radio.formatCadStatus(buf, cap);
}
void applyCadPrefs() override {
lora_radio.setCadParams(companion_mesh.prefs.cad_auto != 0,
companion_mesh.prefs.cad_offset,
companion_mesh.prefs.probe_interval,
companion_mesh.prefs.cad_busycap,
companion_mesh.prefs.cad_base);
companion_mesh.prefs.cad_offset = lora_radio.getCadOffset();
companion_mesh.prefs.cad_base = lora_radio.cadBasePeak();
}
void resetCadStats() override {
lora_radio.resetCadStats();
}
mesh::LocalIdentity& getSelfId() override { return companion_mesh.self_id; }
void saveIdentity(const mesh::LocalIdentity& new_id) override {
companion_mesh.self_id = new_id;
data_store.saveMainIdentity(new_id);
}
void clearStats() override {
lora_radio.resetStats();
lora_radio.resetDutyCycleTimeoutRestarts();
companion_mesh.resetStats();
}
/* Duty-cycle false-preamble re-arm count. Without these the
* CommonCLICallbacks default answers 0 forever, so `get dc.restarts`
* read clean on every companion regardless of what the radio was doing
* — and the companion is the role the duty cycle actually runs in.
* Mirrors RepeaterMesh::getDutyCycleTimeoutRestarts(). */
uint32_t getDutyCycleTimeoutRestarts() const override {
return lora_radio.getDutyCycleTimeoutRestarts();
}
void resetDutyCycleTimeoutRestarts() override {
lora_radio.resetDutyCycleTimeoutRestarts();
}
/* Temp radio params — deferred; stub for now. */
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr,
int timeout_mins) override {
(void)freq; (void)bw; (void)sf; (void)cr; (void)timeout_mins;
}
MeshTimeSync* getMeshTimeSync() override {
return companion_mesh.getMeshTimeSync();
}
/* GPS, through the GPS manager as on the repeater. */
bool setGpsEnabled(bool enabled) override {
if (!gps_is_available()) return false;
gps_enable(enabled);
return true;
}
bool isGpsEnabled() const override {
return gps_is_enabled();
}
void formatGpsStatsReply(char* reply) override {
if (!gps_is_enabled()) {
strcpy(reply, "off");
return;
}
struct gps_state_info gsi;
gps_get_state_info(&gsi);
static const char* const state_str[] = { "off", "standby", "acquiring" };
const char* state = gsi.state < 3 ? state_str[gsi.state] : "unknown";
struct gps_position pos;
bool has_pos = gps_get_last_known_position(&pos);
if (has_pos) {
snprintf(reply, CLI_REPLY_SIZE,
"on state=%s sats=%u fix=%us ago lat=%.6f lon=%.6f",
state, gsi.satellites, gsi.last_fix_age_s,
pos.latitude_ndeg / 1e9, pos.longitude_ndeg / 1e9);
} else if (gsi.next_search_s > 0) {
snprintf(reply, CLI_REPLY_SIZE,
"on state=%s sats=%u no fix next=%us",
state, gsi.satellites, gsi.next_search_s);
} else {
snprintf(reply, CLI_REPLY_SIZE,
"on state=%s sats=%u no fix",
state, gsi.satellites);
}
}
/* stats-core / stats-radio / stats-packets, as the repeater prints them. */
void formatStatsReply(char* reply) override {
StatsFormatHelper::formatCoreStats(reply, zephyr_board, ms_clock,
companion_mesh.getErrFlags(), &packet_mgr);
}
void formatRadioStatsReply(char* reply) override {
StatsFormatHelper::formatRadioStats(reply, &lora_radio, lora_radio,
companion_mesh.getTotalAirTime(), companion_mesh.getReceiveAirTime());
}
void formatPacketStatsReply(char* reply) override {
StatsFormatHelper::formatPacketStats(reply, lora_radio,
companion_mesh.getNumSentFlood(), companion_mesh.getNumSentDirect(),
companion_mesh.getNumRecvFlood(), companion_mesh.getNumRecvDirect());
}
};
static CompanionCLICallbacks companion_cli_cbs;
static ClientACL companion_acl; /* unused by CommonCLI but required by constructor */
static CommonCLI companion_cli(zephyr_board, rtc_clock, companion_acl,
static CompanionCLICallbacks companion_cli_cbs(data_store, companion_mesh, lora_radio, zephyr_board,
ms_clock, packet_mgr, companion_transport_tx_idle);
/* No region map and no client ACL: a companion has neither (nullptr both). */
static CommonCLI companion_cli(zephyr_board, rtc_clock, nullptr, nullptr,
&companion_mesh.prefs, &companion_cli_cbs);
/* CMD_SET_RADIO_PARAMS / CMD_SET_RADIO_TX_POWER (needs companion_cli, hence
@@ -990,12 +747,7 @@ static bool companion_shutdown_hook(int reason)
? "Powering off: low battery"
: "Powering off";
bool connected = zephcore_ble_is_connected();
#if ZEPHCORE_USB_STACK
connected = connected ||
(zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB &&
!zephcore_usb_companion_is_text_session());
#endif
bool connected = companion_transport_up();
if (connected && companion_mesh.isVContactEnabled()) {
companion_mesh.vcontactNotify(msg);
@@ -1022,6 +774,11 @@ static void companion_cli_exec(const char *line, uint32_t sender_timestamp,
if (handle_autoshutdown_cli(line, reply)) {
return;
}
#endif
#if IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_WIFI)
if (companion_wifi_cli(line, companion_mesh.prefs, save_prefs_to_flash, reply)) {
return;
}
#endif
companion_cli.setReplyHeaderUsed(reply_hdr_used);
companion_cli.handleCommand(sender_timestamp, line, reply);
@@ -1180,14 +937,13 @@ static void bt_ready(int err)
return;
}
/* Advertises only if still enabled: the ble_disabled pref was applied
* before bt_enable() (disableBluetooth() is recorded until start). */
#ifdef ZEPHCORE_LORA
zephcore_ble_start(companion_mesh.getDeviceName());
#else
zephcore_ble_start(NULL);
#endif
if (companion_mesh.prefs.ble_disabled) {
zephcore_ble_set_enabled(false);
}
}
#endif /* CONFIG_BT */
@@ -1238,42 +994,8 @@ int main(void)
}
}
/* First-boot migration: fix NVS (BLE bonds) before bt_enable() runs.
*
* nRF52 stores BLE bonds in storage_partition (NVS) at 0xD0000. UF2
* flashing only writes pages covered by the binary, leaving whatever was
* there before. Old firmware (Arduino MeshCore, ZephCore ≤1.16.1) used
* that region as app code; if those bytes accidentally pass Zephyr NVS
* sector validation, settings_load() hangs and BLE never advertises.
*
* A marker file /lfs/_zc_init is written after the first clean boot.
* If absent, we are on the first run of this ZephCore build:
*
* • No prefs, or Arduino prefs (layout-incompatible): full format.
* Covers fresh installs and Arduino MeshCore migrations.
*
* • Valid ZephCore prefs + /lfs/settings present: NVS erase only.
* The old file-based bonds backend (ZephCore ≤1.16.1) left this file;
* 0xD0000 is old app code → must erase. Identity/prefs/contacts
* are preserved; re-pair required (bonds were in /lfs/settings which
* the NVS backend ignores anyway).
*
* • Valid ZephCore prefs + no /lfs/settings: NVS was already initialized
* by ZephCore ≥1.16.2 — skip format entirely, bonds survive. */
if (!data_store.hasInitMarker()) {
if (!data_store.hasPrefs() || data_store.prefsLookLikeArduino()) {
LOG_WRN("First ZephCore boot (%s) — formatting LFS + NVS",
data_store.hasPrefs() ? "Arduino prefs" : "no prefs");
data_store.formatFileSystem();
data_store.begin();
} else if (data_store.hasOldSettingsFile()) {
LOG_WRN("Pre-NVS ZephCore upgrade (found /lfs/settings) — erasing NVS");
data_store.formatNVSOnly();
} else {
LOG_INF("ZephCore upgrade with valid NVS — skipping format, bonds preserved");
}
data_store.writeInitMarker();
}
/* Before bt_enable(): a first boot may have to erase the BLE bond store. */
data_store.adoptVolume();
sensor_manager_init();
@@ -1316,10 +1038,12 @@ int main(void)
data_store.loadPrefs(companion_mesh.prefs);
/* Saved BLE PIN (0 = Kconfig default) */
#if IS_ENABLED(CONFIG_BT)
if (companion_mesh.prefs.ble_pin >= 100000 && companion_mesh.prefs.ble_pin <= 999999) {
zephcore_ble_set_passkey(companion_mesh.prefs.ble_pin);
LOG_INF("BLE passkey loaded from prefs: %06u", companion_mesh.prefs.ble_pin);
}
#endif
/* The radio reads its preset through this from Radio::begin() on. */
lora_radio.setPrefs(&companion_mesh.prefs);
@@ -1355,8 +1079,6 @@ int main(void)
self_identity.pub_key[2], self_identity.pub_key[3]);
}
companion_mesh.setWriteFrameCallback(write_frame);
companion_mesh.setPushCallback(push_callback);
companion_mesh.setBatteryCallback(get_battery_mv);
ui_set_battery_provider(get_battery_mv);
ui_set_power_source_provider([]() { return zephyr_board.isExternalPowered(); });
@@ -1364,7 +1086,11 @@ int main(void)
ui_set_shutdown_hook(companion_shutdown_hook);
companion_mesh.setRadioReconfigureCallback(radio_reconfigure);
companion_mesh.setPinChangeCallback([](uint32_t new_pin) {
#if IS_ENABLED(CONFIG_BT)
zephcore_ble_set_passkey(new_pin);
#else
ARG_UNUSED(new_pin);
#endif
});
companion_mesh.setCLICallback(companion_cli_exec);
companion_mesh_ptr = &companion_mesh;
@@ -1497,25 +1223,43 @@ int main(void)
#endif
#endif
/* Register every transport this build has, as upstream's main.cpp does. */
#if IS_ENABLED(CONFIG_BT)
zephcore_ble_init(&ble_cbs);
interface_manager.addInterface(InterfaceType::Bluetooth, &ble_interface);
#endif
#if ZEPHCORE_USB_STACK
zephcore_usb_companion_init(&mesh_events, MESH_EVENT_BLE_RX,
&zephyr_board);
zephcore_usb_companion_set_session_start_cb(usb_on_session_start);
zephcore_usb_companion_set_session_end_cb(usb_on_session_end);
zephcore_usb_companion_set_tx_drain_cb(usb_on_tx_drain);
zephcore_usb_companion_init(&link_cbs);
/* The text CLI starts when the first byte is not '<'. */
zephcore_usb_companion_set_cli_line_cb(companion_cli_dispatch);
interface_manager.addInterface(InterfaceType::USB, &usb_interface);
#endif
#if IS_ENABLED(CONFIG_ZEPHCORE_TRANSPORT_TCP)
tcp_companion_init(&link_cbs);
interface_manager.addInterface(InterfaceType::WiFi, &tcp_interface);
#endif
/* Enables every interface; the ble_disabled pref then turns BLE back off
* before the stack starts advertising. */
#ifdef ZEPHCORE_LORA
companion_mesh.startInterface(companion_serial);
if (companion_mesh.prefs.ble_disabled) {
interface_manager.disableBluetooth();
}
#else
companion_serial.enable();
#endif
#if IS_ENABLED(CONFIG_ZEPHCORE_TRANSPORT_TCP)
tcp_companion_start(CONFIG_ZEPHCORE_TCP_PORT);
#endif
#if IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_WIFI) && defined(ZEPHCORE_LORA)
companion_wifi_start(companion_mesh.prefs);
#endif
#if IS_ENABLED(CONFIG_BT)
if (bt_enable(bt_ready) != 0) {
LOG_ERR("bt_enable failed");
}
#else
/* No BLE controller: LinuxTCPTransport.c provides zephcore_ble_start(). */
zephcore_ble_start(companion_mesh.getDeviceName());
#endif
/* The main thread becomes the mesh event loop (see MESH_EVENT_*). */
+16 -838
View File
@@ -1,856 +1,34 @@
/*
* SPDX-License-Identifier: MIT
* ZephCore - Repeater (USB CLI, Event-Driven)
* ZephCore - Repeater
*
* This is the main entry point for the repeater role.
* Repeaters use USB serial CLI for configuration (no BLE).
* The repeater role's entry point: its mesh object, and the ServerRole that
* hands it to the shared server code (server_main_common.cpp).
*/
#include <stdio.h>
#include <string.h>
#include <zephyr/kernel.h>
#include <zephyr/sys/util.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(zephcore_repeater_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
#include <zephyr/device.h>
#include <zephyr/devicetree.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/uart.h>
#include <zephyr/sys/ring_buffer.h>
#include <zephyr/drivers/hwinfo.h>
#include <zephyr/sys/reboot.h>
#include "oled_power.h"
#include "boot_prefs.h"
/* BLE controller assert handler — BT is compiled even for repeater (via zephcore_common.conf) */
#if IS_ENABLED(CONFIG_BT_CTLR_ASSERT_HANDLER)
extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line)
{
LOG_ERR("!!! BLE CONTROLLER ASSERT: %s:%u !!!", file ? file : "?", line);
k_sleep(K_MSEC(100));
sys_reboot(SYS_REBOOT_COLD);
}
#endif
/* USB CDC ACM init + 1200-baud DFU + DTR callbacks (shared with companion).
*
* Gate on the CDC-ACM class driver, NOT on DT_HAS_COMPAT_STATUS_OKAY alone: a
* board overlay may expose a cdc_acm_uart DT node unconditionally (the shared
* esp32s3_usb_otg.dtsi does), so the node can be present while the class driver
* — and therefore zephcore_usbd_* and the device object — is not compiled
* (e.g. an ESP32-S3 repeater built without esp32s3_usb.conf). This mirrors the
* repeater CMake condition that compiles ZephyrUSBCDC.cpp. */
#define ZEPHCORE_USB_STACK \
(IS_ENABLED(CONFIG_USB_CDC_ACM) || IS_ENABLED(CONFIG_USBD_CDC_ACM_CLASS))
#if ZEPHCORE_USB_STACK && !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT)
#include <ZephyrUSBCDC.h>
#endif
#include <app/RepeaterDataStore.h>
#include "../adapters/datastore/ZephyrFsFormat.h"
#include "server_main_common.h"
#include <app/RepeaterMesh.h>
#include <adapters/clock/ZephyrRTCClock.h>
#include <adapters/clock/ZephyrRTCDiscover.h>
#include <ZephyrSensorManager.h>
/* UI subsystem (display, buttons, buzzer) */
#include "ui_task.h"
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
#include "display.h"
#endif
/* Headless repeaters link the weak no-op ui_* stubs (ui_headless_stubs.c), so
* the periodic UI refresh in the maintenance pass is pure work for nothing on
* them. Guard the hot path on a real ui_* implementation being linked; the
* one-shot calls at init and in the CLI reply path stay unguarded, matching
* the rest of the file.
*
* This MUST mirror the CMake condition that selects the stubs, not the display
* devicetree node: ZEPHCORE_UI_DESIGN_BUTTON is enabled by BUTTONS *or*
* DISPLAY *or* BUZZER, so a board with buttons/buzzer and no panel still links
* the real UI and still needs these updates. */
#define ZEPHCORE_HAS_UI (IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_BUTTON) || \
IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK))
/* Radio + mesh includes (shared header selects LR1110 or SX126x) */
#include <src/RadioIncludes.h>
#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA)
#include "wifi_ota.h"
#endif
/* LED configuration */
#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios)
#define LED0_NODE DT_ALIAS(led0)
static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
#endif
#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios)
#define LED1_NODE DT_ALIAS(led1)
static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(LED1_NODE, gpios);
#endif
/* USB CLI configuration */
#define USB_RING_BUF_SIZE 512
#define CLI_LINE_BUF_SIZE 256
/*
* Event-driven mesh loop - replaces 50ms polling with true event signaling.
* Events are signaled from ISR/callbacks, mesh loop wakes immediately.
*/
#define MESH_EVENT_LORA_RX BIT(0) /* LoRa packet received */
#define MESH_EVENT_LORA_TX_DONE BIT(1) /* LoRa TX complete */
#define MESH_EVENT_CLI_RX BIT(2) /* CLI command received */
#define MESH_EVENT_MAINTENANCE BIT(3) /* A maintenance deadline came due */
#define MESH_EVENT_GPS_ACTION BIT(4) /* GPS state change (must run on main thread!) */
#define MESH_EVENT_TX_DRAIN BIT(5) /* Outbound packet delay expired, run checkSend */
#define MESH_EVENT_RTC_SAVE BIT(6) /* Hardware-RTC write requested off-main */
#define MESH_EVENT_INIT_ADVERT BIT(7) /* Deferred boot advert — send on main thread */
#define MESH_EVENT_WAKE BIT(8) /* Off-main state set; run loop() promptly */
#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | MESH_EVENT_MAINTENANCE | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_RTC_SAVE | MESH_EVENT_INIT_ADVERT | MESH_EVENT_WAKE)
/* Maintenance is deadline-driven, not periodic: after every pass the loop asks
* the mesh when its soonest pending deadline is (msUntilNextMaintenance) and
* arms a single one-shot wake for exactly that moment. An idle repeater with
* nothing scheduled therefore sleeps until its next real deadline instead of
* waking on a fixed cadence.
*
* MAINTENANCE_BACKSTOP_MS bounds that: it caps how long we will go without a
* pass even when everything reports idle, so a deadline that is missed or
* mis-reported degrades to the old behaviour instead of wedging.
* MAINTENANCE_MIN_MS floors it, so an item that is due-but-blocked (radio
* mid-packet, duty-cycle sleep window) re-arms shortly rather than spinning. */
#define MAINTENANCE_BACKSTOP_MS CONFIG_ZEPHCORE_MAINTENANCE_BACKSTOP_MS
#define MAINTENANCE_MIN_MS 50
/* Event object for mesh loop */
static struct k_event mesh_events;
/* Pending epoch for a deferred zephcore_rtc_save() — gps_fix_callback runs on
* the GNSS modem_chat worker thread, where the RTC's blocking I2C transactions
* would stall NMEA ingest. Stash the latest epoch and let the main thread
* perform the write; concurrent posts coalesce into one save. */
static atomic_t pending_rtc_epoch = ATOMIC_INIT(0);
static void request_rtc_save(uint32_t epoch)
{
atomic_set(&pending_rtc_epoch, (atomic_val_t)epoch);
k_event_post(&mesh_events, MESH_EVENT_RTC_SAVE);
}
/* USB CDC state */
static const struct device *usb_dev;
static uint8_t usb_ring_buf_data[USB_RING_BUF_SIZE];
static struct ring_buf usb_ring_buf;
static char cli_line_buf[CLI_LINE_BUF_SIZE];
static char cli_reply_buf[256];
static uint16_t cli_line_idx;
/* Last byte seen by cli_rx_work_fn, for collapsing CRLF/LFCR pairs. Persists
* across work-item invocations, so a pair split across two USB packets still
* compares correctly. */
static uint8_t cli_prev_byte;
/* Completed CLI lines are handed to the MAIN thread for execution. Byte
* assembly + echo (cli_rx_work_fn) runs on sysworkq and touches no mesh
* state, but handleCommand() mutates the lock-free packet pool / dispatcher
* that loop() also touches — running it on sysworkq races the main loop.
* So we queue the finished line and let the event loop run handleCommand(). */
struct cli_cmd_line { char buf[CLI_LINE_BUF_SIZE]; };
K_MSGQ_DEFINE(cli_cmd_queue, sizeof(struct cli_cmd_line), 4, 4);
/* Work items for event-driven processing */
static void cli_rx_work_fn(struct k_work *work);
static void maintenance_timer_fn(struct k_timer *timer);
static void tx_drain_work_fn(struct k_work *work);
static void initial_advert_work_fn(struct k_work *work);
K_WORK_DEFINE(cli_rx_work, cli_rx_work_fn);
K_WORK_DELAYABLE_DEFINE(tx_drain_work, tx_drain_work_fn);
K_WORK_DELAYABLE_DEFINE(initial_advert_work, initial_advert_work_fn);
/* One-shot maintenance wake, re-armed after every loop pass (see
* arm_maintenance_wake). Not periodic — the period is the deadline. */
K_TIMER_DEFINE(maintenance_timer, maintenance_timer_fn, NULL);
/* Forward declarations */
#ifdef ZEPHCORE_LORA
static RepeaterMesh *repeater_mesh_ptr;
static void refresh_repeater_ui_radio_state(void);
#endif
/* Print string to USB serial */
static void cli_print(const char *str)
{
if (!usb_dev) return;
while (*str) {
uart_poll_out(usb_dev, *str++);
}
}
/* USB CDC UART interrupt callback */
static void cli_uart_isr(const struct device *dev, void *user_data)
{
ARG_UNUSED(user_data);
for (;;) {
uart_irq_update(dev);
if (uart_irq_is_pending(dev) <= 0) {
break;
}
if (uart_irq_rx_ready(dev)) {
uint8_t buf[64];
int recv_len = uart_fifo_read(dev, buf, sizeof(buf));
if (recv_len > 0) {
ring_buf_put(&usb_ring_buf, buf, recv_len);
k_work_submit(&cli_rx_work);
}
}
}
}
/* CLI RX work - processes line-based CLI commands
* Matches Arduino behavior: echo each char, then " -> reply" on enter
*/
static void cli_rx_work_fn(struct k_work *work)
{
ARG_UNUSED(work);
uint8_t byte;
while (ring_buf_get(&usb_ring_buf, &byte, 1) == 1) {
const uint8_t prev = cli_prev_byte;
cli_prev_byte = byte;
/* Process command on \r OR \n. Accepting both is what makes a piped
* `echo "cmd" > /dev/ttyACM0` (LF only) work. The cost is that a CRLF
* terminal sends two terminator bytes per Enter, so collapse the pair
* here: skip a terminator that directly follows a *different* one.
* "\r\r" and "\n\n" (two genuine blank lines) still dispatch twice.
* Blank lines ARE dispatched -- `region load` commits on one, and
* handleCommand() no-ops a blank line otherwise. The previous
* de-duplicator keyed on an empty line buffer instead: it ate every
* blank line (stranding `region load` until a reboot) and still let
* the CRLF tail fall through and emit a stray newline of its own. */
if (byte == '\r' || byte == '\n') {
if ((prev == '\r' || prev == '\n') && byte != prev) {
continue; /* tail of a CRLF/LFCR pair */
}
cli_line_buf[cli_line_idx] = '\0';
/* Debug: log received command */
if (cli_line_idx > 0) {
LOG_INF("CLI cmd len=%d: %.40s%s", cli_line_idx,
cli_line_buf, cli_line_idx > 40 ? "..." : "");
}
/* Hand the command to the main thread (see cli_cmd_queue).
* The reply + trailing newline are emitted there, exactly
* matching the previous inline output order. */
struct cli_cmd_line c;
strncpy(c.buf, cli_line_buf, sizeof(c.buf) - 1);
c.buf[sizeof(c.buf) - 1] = '\0';
if (k_msgq_put(&cli_cmd_queue, &c, K_NO_WAIT) == 0) {
k_event_post(&mesh_events, MESH_EVENT_CLI_RX);
} else {
cli_print("\r\n -> busy\r\n");
}
cli_line_idx = 0;
} else if (byte == 0x7F || byte == 0x08) {
/* Backspace - echo backspace sequence */
if (cli_line_idx > 0) {
cli_line_idx--;
if (usb_dev) {
uart_poll_out(usb_dev, '\b');
uart_poll_out(usb_dev, ' ');
uart_poll_out(usb_dev, '\b');
}
}
} else if (cli_line_idx < sizeof(cli_line_buf) - 1) {
/* Echo character back (like Arduino) */
if (usb_dev) {
uart_poll_out(usb_dev, byte);
}
cli_line_buf[cli_line_idx++] = (char)byte;
}
}
}
#ifdef ZEPHCORE_LORA
/* Run queued CLI commands on the MAIN thread (see cli_cmd_queue). Output
* order matches the old inline path exactly: "\r\n -> reply" when there is
* a reply, then a trailing "\r\n". */
static void process_cli_commands(void)
{
struct cli_cmd_line c;
while (repeater_mesh_ptr && k_msgq_get(&cli_cmd_queue, &c, K_NO_WAIT) == 0) {
cli_reply_buf[0] = '\0';
repeater_mesh_ptr->handleCommand(0, c.buf, cli_reply_buf);
refresh_repeater_ui_radio_state();
if (cli_reply_buf[0] != '\0') {
cli_print("\r\n -> ");
cli_print(cli_reply_buf);
}
cli_print("\r\n");
}
}
#endif
/* Maintenance deadline expired — wake the mesh loop to run the pass. */
static void maintenance_timer_fn(struct k_timer *timer)
{
ARG_UNUSED(timer);
k_event_post(&mesh_events, MESH_EVENT_MAINTENANCE);
}
/* Ask the mesh for its soonest pending deadline and arm the one-shot for it.
* Called after every loop pass, whatever woke us: any event may have created
* or cleared a deadline (a queued advert, a CLI tempradio command, a CAD probe
* that just ran), so the schedule is recomputed from scratch each time rather
* than tracked incrementally.
*
* The backstop below is an unconditional CEILING on the wait, not a fallback
* used only when everything reports idle. That means it must stay well above
* the shortest legitimate recurring deadline (the noise-floor sampler, and the
* CAD probe) or it becomes the effective period and the deadline scheduling
* buys nothing — see ZEPHCORE_MAINTENANCE_BACKSTOP_MS. */
static void arm_maintenance_wake(void)
{
uint32_t delay = MAINTENANCE_BACKSTOP_MS;
uint32_t next = MAINTENANCE_BACKSTOP_MS;
#ifdef ZEPHCORE_LORA
if (repeater_mesh_ptr) {
next = repeater_mesh_ptr->msUntilNextMaintenance();
if (next < delay) {
delay = next;
}
}
#endif
if (delay < MAINTENANCE_MIN_MS) {
delay = MAINTENANCE_MIN_MS;
}
/* Both figures, because which one is binding is the whole diagnosis:
* next=15000 armed=15000 — a real deadline won; working as intended
* next=15000 armed=5000 — the backstop is clamping (it must stay
* above the noise-floor/CAD intervals)
* next=0 armed=50 — a deadline reports due but its state is
* not advancing; repeated = spin
* mesh::MAINTENANCE_IDLE (0x7FFFFFFF) as `next` means nothing pending. */
LOG_DBG("maint: next=%u armed=%u", (unsigned)next, (unsigned)delay);
k_timer_start(&maintenance_timer, K_MSEC(delay), K_NO_WAIT);
}
#ifdef ZEPHCORE_LORA
/* LoRa RX callback - called from ISR context when packet received */
static void lora_rx_callback(void *user_data)
{
ARG_UNUSED(user_data);
k_event_post(&mesh_events, MESH_EVENT_LORA_RX);
}
/* LoRa TX complete callback */
static void lora_tx_done_callback(void *user_data)
{
ARG_UNUSED(user_data);
k_event_post(&mesh_events, MESH_EVENT_LORA_TX_DONE);
}
/* TX drain — Dispatcher queued a packet with a delay.
* Schedule a precise wake so checkSend runs when the delay expires. */
static void tx_drain_work_fn(struct k_work *work)
{
ARG_UNUSED(work);
k_event_post(&mesh_events, MESH_EVENT_TX_DRAIN);
}
/* Deferred initial advertisement — gives GPS time to get a fix at boot.
* Runs on sysworkq (the 10 s delay timer), so it must NOT send the advert
* here: sendSelfAdvertisement() mutates the packet pool / dispatcher shared
* with loop(). Post an event and let the main thread send it. */
static void initial_advert_work_fn(struct k_work *work)
{
ARG_UNUSED(work);
k_event_post(&mesh_events, MESH_EVENT_INIT_ADVERT);
}
static void tx_queued_callback(uint32_t delay_ms, void *user_data)
{
ARG_UNUSED(user_data);
k_work_reschedule(&tx_drain_work, K_MSEC(delay_ms));
}
/* Off-main code (MQTT CONNACK, SNTP) set state that loop() must drain. Post
* only — this runs on the MQTT publisher / WiFi thread, and k_event_post is
* the sole thing safe to do from there. */
static void wake_callback(void *user_data)
{
ARG_UNUSED(user_data);
k_event_post(&mesh_events, MESH_EVENT_WAKE);
}
#endif
/* Global instances */
static mesh::ZephyrRTCClock rtc_clock;
/* GPS event callback - called when GPS work handlers need the main thread
* to process a state transition (wake from standby, fix done, timeout).
* Runs from system work queue context — just posts an event, no blocking. */
static void gps_event_callback(void)
{
k_event_post(&mesh_events, MESH_EVENT_GPS_ACTION);
}
static RepeaterDataStore data_store;
/* GPS fix callback - syncs RTC from GPS time.
* Repeaters do NOT update prefs lat/lon from GPS — prefs coordinates are the
* user's manually-set position used for adverts. Precise GPS position is
* served only via telemetry requests (gps_get_last_known_position). */
static void gps_fix_callback(double lat, double lon, int64_t utc_time)
{
if (utc_time > 0) {
LOG_INF("GPS fix: RTC sync time=%lld", utc_time);
rtc_clock.setCurrentTime((uint32_t)utc_time);
/* Defer the hardware-RTC write to the main thread — blocking I2C
* here would stall NMEA ingest (gps_fix_callback runs in the
* GNSS modem_chat worker context). */
request_rtc_save((uint32_t)utc_time);
}
int lat_deg = (int)lat;
int lon_deg = (int)lon;
int lat_frac = (int)((lat - lat_deg) * 1000000);
int lon_frac = (int)((lon - lon_deg) * 1000000);
if (lat_frac < 0) lat_frac = -lat_frac;
if (lon_frac < 0) lon_frac = -lon_frac;
LOG_INF("GPS fix: lat=%d.%06d lon=%d.%06d (telemetry only)",
lat_deg, lat_frac, lon_deg, lon_frac);
}
#ifdef ZEPHCORE_LORA
static mesh::ZephyrBoard zephyr_board;
static uint16_t get_battery_mv(void)
{
return zephyr_board.getBattMilliVolts();
}
/* Radio is constructed with no prefs pointer; main() binds it to
* repeater_mesh._prefs via setPrefs() before repeater_mesh.begin(). */
static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0));
static mesh::LoRaRadio lora_radio(lora_dev, zephyr_board);
static mesh::ZephyrMillisecondClock ms_clock;
static mesh::ZephyrRNG zephyr_rng;
static SimpleMeshTables mesh_tables;
/* RepeaterMesh requires: board, radio, ms_clock, rng, rtc, tables */
static RepeaterMesh repeater_mesh(zephyr_board, lora_radio, ms_clock, zephyr_rng, rtc_clock, mesh_tables);
/* Strong override of the weak stub in ui_mesh_actions_stubs.c: keep the prefs
* copy in step when the LEDs page toggles, so "get leds" reports what the node
* is actually doing. RAM only — a repeater has no deferred-save path off the UI
* thread, so a UI toggle lasts until reboot; "set leds" is what persists. */
extern "C" void mesh_set_leds_disabled(bool disabled)
{
if (repeater_mesh_ptr) {
repeater_mesh_ptr->getNodePrefs()->leds_disabled = disabled ? 1 : 0;
}
}
static void refresh_repeater_ui_radio_state(void)
{
if (!repeater_mesh_ptr) {
return;
}
ui_set_radio_params(
lora_radio.getActiveFrequencyHz(),
lora_radio.getActiveSpreadingFactor(),
lora_radio.getActiveBandwidthKHzX10(),
lora_radio.getActiveCodingRate(),
lora_radio.getConfiguredTxPower(),
lora_radio.getNoiseFloor());
ui_set_radio_runtime(
lora_radio.getActiveSyncWord(),
lora_radio.getActivePreambleLength(),
lora_radio.isRxDutyCycleEnabled(),
lora_radio.isRadioReady(),
lora_radio.isInRecvMode(),
lora_radio.isTxActive());
ui_set_radio_stats(
lora_radio.getPacketsRecv(),
lora_radio.getPacketsSent(),
lora_radio.getPacketsRecvErrors());
}
#endif
/* Repeater event loop */
static void repeater_event_loop(void)
{
LOG_INF("starting event-driven loop");
/* Print startup banner (no prompt - Arduino style) */
cli_print("\r\n=== ZephCore Repeater ===\r\n");
/* Arm the first maintenance wake; every pass below re-arms it. */
arm_maintenance_wake();
for (;;) {
/* Wait for any mesh event - blocks until signaled */
uint32_t events = k_event_wait(&mesh_events, MESH_EVENT_ALL, false, K_FOREVER);
k_event_clear(&mesh_events, events);
/* GPS state transitions must run on main thread (GNSS driver
* modem_chat blocks on system work queue semaphore). */
if (events & MESH_EVENT_GPS_ACTION) {
gps_process_event();
}
static const ServerRole repeater_role = {
.name = "Repeater",
.name_prefix = "Repeater",
#ifdef ZEPHCORE_LORA
/* Run queued CLI commands here (main thread) BEFORE loop() drains
* any outbound packets they enqueued — keeps all mesh-state
* mutation on the main thread (see cli_cmd_queue). */
if (events & MESH_EVENT_CLI_RX) {
process_cli_commands();
}
/* Deferred boot advert — sent on the main thread (see
* initial_advert_work_fn); loop() below drains the queued packet. */
if (repeater_mesh_ptr && (events & MESH_EVENT_INIT_ADVERT)) {
LOG_INF("Sending deferred initial advertisement");
repeater_mesh_ptr->sendSelfAdvertisement(500, false);
}
/* Packet processing — only on radio/CLI/TX events */
if (repeater_mesh_ptr &&
(events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE |
MESH_EVENT_CLI_RX | MESH_EVENT_TX_DRAIN |
MESH_EVENT_WAKE))) {
repeater_mesh_ptr->loop();
}
.mesh = &repeater_mesh,
.callbacks = &repeater_mesh,
.prefs = repeater_mesh.getNodePrefs(),
.begin = [](RepeaterDataStore *store) { repeater_mesh.begin(store); },
.loop = [] { repeater_mesh.loop(); },
.handle_command = [](char *command, char *reply) { repeater_mesh.handleCommand(0, command, reply); },
.note_gps_time_sync = [] { repeater_mesh.noteGPSTimeSync(); },
#endif
#ifdef ZEPHCORE_LORA
/* Radio maintenance runs OPPORTUNISTICALLY, on every pass, whatever
* woke us — not only when its own timer fired.
*
* Every item inside is deadline-gated internally, so this is nearly
* free when nothing is due. The point is what it does to a busy
* node: a hilltop repeater is already waking constantly for packets,
* so maintenance rides along on those wakes, its deadlines advance,
* and arm_maintenance_wake() below keeps pushing the timer out — the
* maintenance timer then almost never fires and costs no wakes at
* all. Running it only on its own event (as this did originally)
* inverted that: the busiest nodes, which can least afford it, paid
* the full timer cadence on top of their packet wakes, and every
* blocked sample burned a retry against a channel that is busy for
* sustained reasons (real traffic in isReceiving(), the RSSI
* prefilter) rather than the transient duty-cycle sleep window the
* retries were sized for.
*
* Ordering matters: this sits AFTER packet processing so an inbound
* frame is handled before we spend SPI time on an RSSI sweep. */
if (repeater_mesh_ptr) {
repeater_mesh_ptr->maintenanceLoop();
}
#endif
/* A maintenance deadline came due — run the time-based work too. */
if (events & MESH_EVENT_MAINTENANCE) {
#ifdef ZEPHCORE_LORA
/* Drive loop() so time-based actions (advert timers,
* tempradio set/revert, contacts flush, uplink status)
* still fire when no LoRa/CLI traffic wakes the loop. */
if (repeater_mesh_ptr) {
repeater_mesh_ptr->loop();
}
#endif
#if ZEPHCORE_HAS_UI
ui_set_clock(rtc_clock.getCurrentTime());
#ifdef ZEPHCORE_LORA
/* Refresh live radio state (noise floor, TX power
* reduction, RX/TX mode, packet counters). Headless
* repeaters compile this out entirely — every ui_set_*
* below it is a weak no-op there (ui_headless_stubs.c),
* so the whole block was pure work for nothing. */
refresh_repeater_ui_radio_state();
/* Battery is now refreshed lazily from ui_pages_render() with
* a 30 s freshness guard — no periodic ADC fire here. */
#endif
#endif /* ZEPHCORE_HAS_UI */
}
/* Off-main RTC write request (gps_fix_callback runs in modem_chat
* context — see request_rtc_save()). Perform the blocking I2C
* write here on the main thread instead. */
if (events & MESH_EVENT_RTC_SAVE) {
zephcore_rtc_save((uint32_t)atomic_get(&pending_rtc_epoch));
#ifdef ZEPHCORE_LORA
/* GPS just set the clock — arm the mesh time-sync drift envelope. */
if (repeater_mesh_ptr) {
repeater_mesh_ptr->noteGPSTimeSync();
}
#endif
}
/* Re-arm for the soonest deadline this pass left behind. Done for
* every wake, not just maintenance ones: a CLI command, an inbound
* packet or a GPS fix can all create or clear a deadline. */
arm_maintenance_wake();
}
}
};
int main(void)
{
#ifdef ZEPHCORE_LORA
/* Clear any stale bootloader magic from previous sessions.
* Prevents nRF52 boards from re-entering bootloader after reboot. */
zephyr_board.clearBootloaderMagic();
#endif
/* USB CDC init up front so the host can enumerate, then wait for the
* host to open the port (DTR asserted) — event-driven via the usbd
* message callback. Unplugged → 2 s timeout, no banner; attached →
* banner reaches the user the moment the port opens. */
#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) && \
!IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) && \
(IS_ENABLED(CONFIG_USB_CDC_ACM) || IS_ENABLED(CONFIG_USBD_CDC_ACM_CLASS))
zephcore_usbd_init();
zephcore_usbd_wait_dtr(2000);
#endif
LOG_INF("=== ZephCore Repeater starting ===");
#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA)
/* Confirm MCUboot image early — if we just booted after OTA,
* this marks the image as good so MCUboot keeps it. */
wifi_ota_confirm_image();
#endif
/* Configure LEDs */
#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios)
if (gpio_is_ready_dt(&led0)) {
gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE);
}
#endif
#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios)
if (gpio_is_ready_dt(&led1)) {
gpio_pin_configure_dt(&led1, GPIO_OUTPUT_INACTIVE);
}
#endif
/* First boot on a volume that is not this role's - a fresh chip, a
* companion, or a node that was running Arduino MeshCore, whose nRF52
* filesystems overlap our lfs_partition
* (devdocs/HANDOVER_lfs_arduino_overlap.md). Erase everything so we
* start from a known state: Zephyr's automount only
* auto-formats the LittleFS volume when it fails to mount, and never
* touches storage_partition (BLE bonds NVS) or QSPI.
*
* Self-limiting, so it needs no "done" marker: the identity is generated
* and saved a few lines below, and loadPrefs() persists defaults on the
* same boot, so the next boot sees this role's data and skips this. */
if (!data_store.hasRoleData()) {
LOG_WRN("Volume holds no data for this role - formatting before first boot");
if (!zephcore_fs_format_all(nullptr)) {
LOG_ERR("First-boot format failed - /lfs is not mounted");
}
}
/* Initialize repeater data store */
if (!data_store.begin()) {
LOG_ERR("RepeaterDataStore init failed");
}
/* Initialize sensor manager */
sensor_manager_init();
/* Restore wall-clock time from a battery-backed hardware RTC if present
* (shown tagged "L" until the next GPS/CLI sync; no-op if no RTC). */
{
uint32_t rtc_epoch;
if (zephcore_rtc_restore(&rtc_epoch)) {
rtc_clock.setCurrentTime(rtc_epoch);
}
}
/* GPS callbacks only. Duty interval and repeater mode are applied by
* apply_boot_prefs() after loadPrefs() below. */
if (gps_is_available()) {
gps_set_fix_callback(gps_fix_callback);
gps_set_event_callback(gps_event_callback);
}
/* Initialize UI (display + buttons). Shows splash screen, then auto-
* transitions to STATUS page. Display sleeps after auto-off timeout;
* user button is the only wake source for repeater. */
ui_init();
#if !IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
oled_sleep();
#endif
/* Log environment sensor availability */
if (env_sensors_available()) {
LOG_INF("Environment sensors available");
}
#ifdef ZEPHCORE_LORA
repeater_mesh_ptr = &repeater_mesh;
/* Initialize mesh event object BEFORE begin() — radio callbacks post events */
k_event_init(&mesh_events);
/* Set LoRa callbacks for event-driven packet processing */
lora_radio.setRxCallback(lora_rx_callback, nullptr);
lora_radio.setTxDoneCallback(lora_tx_done_callback, nullptr);
repeater_mesh.setTxQueuedCallback(tx_queued_callback, nullptr);
repeater_mesh.setWakeCallback(wake_callback, nullptr);
/* Load or generate identity BEFORE begin(). First-boot keygen runs
* the layered entropy mixer + Ed25519 derive + reserved-prefix
* guard inside ZephyrRNG::generateFirstBootIdentity. */
mesh::LocalIdentity self_identity;
if (!data_store.loadIdentity(self_identity)) {
LOG_INF("No identity found, generating new keypair...");
mesh::ZephyrRNG::generateFirstBootIdentity(self_identity);
data_store.saveIdentity(self_identity);
LOG_INF("New identity saved");
}
repeater_mesh.self_id = self_identity;
/* Log repeater ID (first 8 bytes of public key) */
LOG_INF("Repeater ID: %02x%02x%02x%02x%02x%02x%02x%02x...",
self_identity.pub_key[0], self_identity.pub_key[1],
self_identity.pub_key[2], self_identity.pub_key[3],
self_identity.pub_key[4], self_identity.pub_key[5],
self_identity.pub_key[6], self_identity.pub_key[7]);
/* Load persisted prefs and bind the radio to _prefs BEFORE begin() — the
* radio reads freq/bw/sf/cr through this pointer during Mesh::begin() →
* Dispatcher::begin() → Radio::begin(). Without this, the radio would
* configure on NodePrefs defaults (869.618 MHz) regardless of saved
* settings: CLI readback looked correct but the hardware stayed on EU.
* Every role binds this way; begin() refuses to start without it. */
data_store.loadPrefs(*repeater_mesh.getNodePrefs());
lora_radio.setPrefs(repeater_mesh.getNodePrefs());
/* LED gate and GPS duty from prefs. After loadPrefs() and ui_init(),
* before begin() -- see boot_prefs.h. */
apply_boot_prefs(repeater_mesh.getNodePrefs(), true);
/* Start mesh with data store - loads ACL, regions */
repeater_mesh.begin(&data_store);
/* Generate default node name from hardware device ID if not set */
NodePrefs* prefs = repeater_mesh.getNodePrefs();
if (strlen(prefs->node_name) == 0 || strcmp(prefs->node_name, "Repeater") == 0) {
uint8_t dev_id[8];
ssize_t id_len = hwinfo_get_device_id(dev_id, sizeof(dev_id));
if (id_len >= 4) {
snprintf(prefs->node_name, sizeof(prefs->node_name),
"Repeater-%02X%02X%02X%02X", dev_id[0], dev_id[1], dev_id[2], dev_id[3]);
}
}
/* Apply RX boost and duty cycle from prefs */
lora_radio.setRxBoost(prefs->rx_boost != 0);
lora_radio.setFemRxEnable(prefs->fem_rxgain != 0);
lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0);
lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset,
prefs->probe_interval, prefs->cad_busycap,
prefs->cad_base);
/* Write back the (offset, base) actually in force. setCadParams() may
* have re-anchored the offset across a base-table change, and the pair
* has to be persisted for the NEXT upgrade to have an anchor of its own.
* Guarded so a node whose base has not moved never writes flash at boot. */
if (prefs->cad_base != lora_radio.cadBasePeak() ||
prefs->cad_offset != lora_radio.getCadOffset()) {
prefs->cad_offset = lora_radio.getCadOffset();
prefs->cad_base = lora_radio.cadBasePeak();
repeater_mesh.savePrefs();
}
/* LR2021 side detectors (multi-SF RX) from prefs. extra_sf is a
* zero-terminated list; a rejected set (SF/BW changed since it was
* saved) just leaves the feature off, and non-LR2021 radios report it
* unsupported. */
{
uint8_t n = 0;
while (n < EXTRA_SF_MAX && prefs->extra_sf[n] != 0) n++;
if (n > 0 && !lora_radio.configSideDetectors(prefs->extra_sf, n)) {
LOG_WRN("extra.sf %u SFs rejected for current SF/BW — side detectors off", n);
}
}
/* Physical mounting orientation, from prefs. Both are immediate and
* cheap: the panel rotation is a two-byte SEGMENT_MAP/COM_SCAN remap and
* the axis swap is a flag the input callbacks read per event. A panel
* that cannot rotate logs a warning and stays in its native orientation
* rather than failing the boot. */
zephcore_input_set_flipped(prefs->input_rotate != 0);
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
if (prefs->display_rotate) {
mc_display_set_rotated(true);
}
#endif
/* Feed initial UI state from loaded prefs */
ui_set_node_name(prefs->node_name);
refresh_repeater_ui_radio_state();
ui_set_battery_provider(get_battery_mv);
ui_set_battery(zephyr_board.getBattMilliVolts(), 0);
ui_set_gps_available(gps_is_available());
/* Defer initial advertisement by 10s — gives GPS time for a quick fix.
* Advert payload (including coords) is built when the work fires. */
LOG_INF("Initial advertisement scheduled in 10s");
k_work_schedule(&initial_advert_work, K_SECONDS(10));
#endif
/* USB CDC was initialized earlier (right after clearBootloaderMagic).
* Just acquire the device handle for the CLI's UART IRQ binding below. */
#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart)
usb_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart);
#else
/* No CDC ACM class driver (e.g. ESP32 usb_serial, or an ESP32-S3 repeater
* built without esp32s3_usb.conf where the cdc_acm DT node exists but the
* class isn't compiled) — use the chosen console UART. */
usb_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console));
#endif
if (device_is_ready(usb_dev)) {
LOG_INF("USB CDC device ready: %s", usb_dev->name);
ring_buf_init(&usb_ring_buf, sizeof(usb_ring_buf_data), usb_ring_buf_data);
/* Set up UART interrupt callback */
uart_irq_callback_set(usb_dev, cli_uart_isr);
uart_irq_rx_enable(usb_dev);
} else {
LOG_ERR("USB CDC device not ready");
usb_dev = NULL;
}
/*
* Event-driven architecture: main thread runs repeater event loop.
* No BLE - all configuration via USB serial CLI.
*/
#ifdef ZEPHCORE_LORA
repeater_event_loop(); /* Never returns */
#else
for (;;) {
k_sleep(K_FOREVER);
}
#endif
return 0;
return server_main(repeater_role);
}
+16 -717
View File
@@ -1,735 +1,34 @@
/*
* SPDX-License-Identifier: MIT
* ZephCore - Room Server (USB CLI, Event-Driven)
* ZephCore - Room Server
*
* This is the main entry point for the room server (shared BBS) role.
* Room servers use USB serial CLI for configuration (no BLE).
* The room server (shared BBS) role's entry point: its mesh object, and the
* ServerRole that hands it to the shared server code (server_main_common.cpp).
*/
#include <stdio.h>
#include <string.h>
#include <zephyr/kernel.h>
#include <zephyr/sys/util.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(zephcore_room_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
#include <zephyr/device.h>
#include <zephyr/devicetree.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/uart.h>
#include <zephyr/sys/ring_buffer.h>
#include <zephyr/drivers/hwinfo.h>
#include <zephyr/sys/reboot.h>
#include "oled_power.h"
#include "boot_prefs.h"
/* BLE controller assert handler — BT is compiled even for repeater (via zephcore_common.conf) */
#if IS_ENABLED(CONFIG_BT_CTLR_ASSERT_HANDLER)
extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line)
{
LOG_ERR("!!! BLE CONTROLLER ASSERT: %s:%u !!!", file ? file : "?", line);
k_sleep(K_MSEC(100));
sys_reboot(SYS_REBOOT_COLD);
}
#endif
/* USB CDC ACM init + 1200-baud DFU + DTR callbacks (shared with companion).
*
* Gate on the CDC-ACM class driver, NOT on DT_HAS_COMPAT_STATUS_OKAY alone: a
* board overlay may expose a cdc_acm_uart DT node unconditionally (the shared
* esp32s3_usb_otg.dtsi does), so the node can be present while the class driver
* — and therefore zephcore_usbd_* and the device object — is not compiled
* (e.g. an ESP32-S3 build without esp32s3_usb.conf). This mirrors the CMake
* condition that compiles ZephyrUSBCDC.cpp. */
#define ZEPHCORE_USB_STACK \
(IS_ENABLED(CONFIG_USB_CDC_ACM) || IS_ENABLED(CONFIG_USBD_CDC_ACM_CLASS))
#if ZEPHCORE_USB_STACK && !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT)
#include <ZephyrUSBCDC.h>
#endif
#include <app/RepeaterDataStore.h>
#include "../adapters/datastore/ZephyrFsFormat.h"
#include "server_main_common.h"
#include <app/RoomServerMesh.h>
#include <adapters/clock/ZephyrRTCClock.h>
#include <adapters/clock/ZephyrRTCDiscover.h>
#include <ZephyrSensorManager.h>
/* UI subsystem (display, buttons, buzzer) */
#include "ui_task.h"
/* Radio + mesh includes (shared header selects LR1110 or SX126x) */
#include <src/RadioIncludes.h>
#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA)
#include "wifi_ota.h"
#endif
/* LED configuration */
#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios)
#define LED0_NODE DT_ALIAS(led0)
static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
#endif
#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios)
#define LED1_NODE DT_ALIAS(led1)
static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(LED1_NODE, gpios);
#endif
/* USB CLI configuration */
#define USB_RING_BUF_SIZE 512
#define CLI_LINE_BUF_SIZE 256
/*
* Event-driven mesh loop - replaces 50ms polling with true event signaling.
* Events are signaled from ISR/callbacks, mesh loop wakes immediately.
*/
#define MESH_EVENT_LORA_RX BIT(0) /* LoRa packet received */
#define MESH_EVENT_LORA_TX_DONE BIT(1) /* LoRa TX complete */
#define MESH_EVENT_CLI_RX BIT(2) /* CLI command received */
#define MESH_EVENT_HOUSEKEEPING BIT(3) /* Periodic housekeeping (noise floor, etc.) */
#define MESH_EVENT_GPS_ACTION BIT(4) /* GPS state change (must run on main thread!) */
#define MESH_EVENT_TX_DRAIN BIT(5) /* Outbound packet delay expired, run checkSend */
#define MESH_EVENT_PUSH_TICK BIT(6) /* Room server: drive the post-sync push engine */
#define MESH_EVENT_RTC_SAVE BIT(7) /* Hardware-RTC write requested off-main */
#define MESH_EVENT_INIT_ADVERT BIT(8) /* Deferred boot advert — send on main thread */
#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | MESH_EVENT_HOUSEKEEPING | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_PUSH_TICK | MESH_EVENT_RTC_SAVE | MESH_EVENT_INIT_ADVERT)
/* Housekeeping interval - infrequent to preserve power savings */
#define HOUSEKEEPING_INTERVAL_MS CONFIG_ZEPHCORE_HOUSEKEEPING_INTERVAL_MS
/* Event object for mesh loop */
static struct k_event mesh_events;
/* Pending epoch for a deferred zephcore_rtc_save() — gps_fix_callback runs on
* the GNSS modem_chat worker thread, where the RTC's blocking I2C transactions
* would stall NMEA ingest. Stash the latest epoch and let the main thread
* perform the write; concurrent posts coalesce into one save. */
static atomic_t pending_rtc_epoch = ATOMIC_INIT(0);
static void request_rtc_save(uint32_t epoch)
{
atomic_set(&pending_rtc_epoch, (atomic_val_t)epoch);
k_event_post(&mesh_events, MESH_EVENT_RTC_SAVE);
}
/* USB CDC state */
static const struct device *usb_dev;
static uint8_t usb_ring_buf_data[USB_RING_BUF_SIZE];
static struct ring_buf usb_ring_buf;
static char cli_line_buf[CLI_LINE_BUF_SIZE];
static char cli_reply_buf[256];
static uint16_t cli_line_idx;
/* Last byte seen by cli_rx_work_fn, for collapsing CRLF/LFCR pairs. Persists
* across work-item invocations, so a pair split across two USB packets still
* compares correctly. */
static uint8_t cli_prev_byte;
/* Completed CLI lines are handed to the MAIN thread for execution. Byte
* assembly + echo (cli_rx_work_fn) runs on sysworkq and touches no mesh
* state, but handleCommand() mutates the lock-free packet pool / dispatcher
* that loop() also touches — running it on sysworkq races the main loop.
* So we queue the finished line and let the event loop run handleCommand(). */
struct cli_cmd_line { char buf[CLI_LINE_BUF_SIZE]; };
K_MSGQ_DEFINE(cli_cmd_queue, sizeof(struct cli_cmd_line), 4, 4);
/* Work items for event-driven processing */
static void cli_rx_work_fn(struct k_work *work);
static void housekeeping_timer_fn(struct k_timer *timer);
static void tx_drain_work_fn(struct k_work *work);
static void initial_advert_work_fn(struct k_work *work);
K_WORK_DEFINE(cli_rx_work, cli_rx_work_fn);
K_WORK_DELAYABLE_DEFINE(tx_drain_work, tx_drain_work_fn);
K_WORK_DELAYABLE_DEFINE(initial_advert_work, initial_advert_work_fn);
/* Housekeeping timer for periodic tasks (noise floor calibration, etc.) */
K_TIMER_DEFINE(housekeeping_timer, housekeeping_timer_fn, NULL);
/* Room server push timer — wakes loop() at PUSH_TICK_INTERVAL_MS so the
* post-sync push engine advances at its intended ~1.2 s cadence instead of
* the 5 s housekeeping tick. Keeps post delivery snappy and TX smooth under
* load (without running radio maintenance that often). */
#define PUSH_TICK_INTERVAL_MS 500
static void push_timer_fn(struct k_timer *timer)
{
ARG_UNUSED(timer);
k_event_post(&mesh_events, MESH_EVENT_PUSH_TICK);
}
K_TIMER_DEFINE(push_timer, push_timer_fn, NULL);
/* Forward declarations */
#ifdef ZEPHCORE_LORA
static RoomServerMesh *room_mesh_ptr;
#endif
/* Print string to USB serial */
static void cli_print(const char *str)
{
if (!usb_dev) return;
while (*str) {
uart_poll_out(usb_dev, *str++);
}
}
/* USB CDC UART interrupt callback */
static void cli_uart_isr(const struct device *dev, void *user_data)
{
ARG_UNUSED(user_data);
for (;;) {
uart_irq_update(dev);
if (uart_irq_is_pending(dev) <= 0) {
break;
}
if (uart_irq_rx_ready(dev)) {
uint8_t buf[64];
int recv_len = uart_fifo_read(dev, buf, sizeof(buf));
if (recv_len > 0) {
ring_buf_put(&usb_ring_buf, buf, recv_len);
k_work_submit(&cli_rx_work);
}
}
}
}
/* CLI RX work - processes line-based CLI commands
* Matches Arduino behavior: echo each char, then " -> reply" on enter
*/
static void cli_rx_work_fn(struct k_work *work)
{
ARG_UNUSED(work);
uint8_t byte;
while (ring_buf_get(&usb_ring_buf, &byte, 1) == 1) {
const uint8_t prev = cli_prev_byte;
cli_prev_byte = byte;
/* Process command on \r OR \n. Accepting both is what makes a piped
* `echo "cmd" > /dev/ttyACM0` (LF only) work. The cost is that a CRLF
* terminal sends two terminator bytes per Enter, so collapse the pair
* here: skip a terminator that directly follows a *different* one.
* "\r\r" and "\n\n" (two genuine blank lines) still dispatch twice.
* Blank lines ARE dispatched -- `region load` commits on one, and
* handleCommand() no-ops a blank line otherwise. The previous
* de-duplicator keyed on an empty line buffer instead: it ate every
* blank line (stranding `region load` until a reboot) and still let
* the CRLF tail fall through and emit a stray newline of its own. */
if (byte == '\r' || byte == '\n') {
if ((prev == '\r' || prev == '\n') && byte != prev) {
continue; /* tail of a CRLF/LFCR pair */
}
cli_line_buf[cli_line_idx] = '\0';
/* Debug: log received command */
if (cli_line_idx > 0) {
LOG_INF("CLI cmd len=%d: %.40s%s", cli_line_idx,
cli_line_buf, cli_line_idx > 40 ? "..." : "");
}
/* Hand the command to the main thread (see cli_cmd_queue).
* The reply + trailing newline are emitted there, exactly
* matching the previous inline output order. */
struct cli_cmd_line c;
strncpy(c.buf, cli_line_buf, sizeof(c.buf) - 1);
c.buf[sizeof(c.buf) - 1] = '\0';
if (k_msgq_put(&cli_cmd_queue, &c, K_NO_WAIT) == 0) {
k_event_post(&mesh_events, MESH_EVENT_CLI_RX);
} else {
cli_print("\r\n -> busy\r\n");
}
cli_line_idx = 0;
} else if (byte == 0x7F || byte == 0x08) {
/* Backspace - echo backspace sequence */
if (cli_line_idx > 0) {
cli_line_idx--;
if (usb_dev) {
uart_poll_out(usb_dev, '\b');
uart_poll_out(usb_dev, ' ');
uart_poll_out(usb_dev, '\b');
}
}
} else if (cli_line_idx < sizeof(cli_line_buf) - 1) {
/* Echo character back (like Arduino) */
if (usb_dev) {
uart_poll_out(usb_dev, byte);
}
cli_line_buf[cli_line_idx++] = (char)byte;
}
}
}
#ifdef ZEPHCORE_LORA
/* Run queued CLI commands on the MAIN thread (see cli_cmd_queue). Output
* order matches the old inline path exactly: "\r\n -> reply" when there is
* a reply, then a trailing "\r\n". */
static void process_cli_commands(void)
{
struct cli_cmd_line c;
while (room_mesh_ptr && k_msgq_get(&cli_cmd_queue, &c, K_NO_WAIT) == 0) {
cli_reply_buf[0] = '\0';
room_mesh_ptr->handleCommand(0, c.buf, cli_reply_buf);
if (cli_reply_buf[0] != '\0') {
cli_print("\r\n -> ");
cli_print(cli_reply_buf);
}
cli_print("\r\n");
}
}
#endif
/* Housekeeping timer callback - signals event to wake mesh loop periodically */
static void housekeeping_timer_fn(struct k_timer *timer)
{
ARG_UNUSED(timer);
k_event_post(&mesh_events, MESH_EVENT_HOUSEKEEPING);
}
#ifdef ZEPHCORE_LORA
/* LoRa RX callback - called from ISR context when packet received */
static void lora_rx_callback(void *user_data)
{
ARG_UNUSED(user_data);
k_event_post(&mesh_events, MESH_EVENT_LORA_RX);
}
/* LoRa TX complete callback */
static void lora_tx_done_callback(void *user_data)
{
ARG_UNUSED(user_data);
k_event_post(&mesh_events, MESH_EVENT_LORA_TX_DONE);
}
/* TX drain — Dispatcher queued a packet with a delay.
* Schedule a precise wake so checkSend runs when the delay expires. */
static void tx_drain_work_fn(struct k_work *work)
{
ARG_UNUSED(work);
k_event_post(&mesh_events, MESH_EVENT_TX_DRAIN);
}
/* Deferred initial advertisement — gives GPS time to get a fix at boot.
* Runs on sysworkq (the 10 s delay timer), so it must NOT send the advert
* here: sendSelfAdvertisement() mutates the packet pool / dispatcher shared
* with loop(). Post an event and let the main thread send it. */
static void initial_advert_work_fn(struct k_work *work)
{
ARG_UNUSED(work);
k_event_post(&mesh_events, MESH_EVENT_INIT_ADVERT);
}
static void tx_queued_callback(uint32_t delay_ms, void *user_data)
{
ARG_UNUSED(user_data);
k_work_reschedule(&tx_drain_work, K_MSEC(delay_ms));
}
#endif
/* Global instances */
static mesh::ZephyrRTCClock rtc_clock;
/* GPS event callback - called when GPS work handlers need the main thread
* to process a state transition (wake from standby, fix done, timeout).
* Runs from system work queue context — just posts an event, no blocking. */
static void gps_event_callback(void)
{
k_event_post(&mesh_events, MESH_EVENT_GPS_ACTION);
}
static RepeaterDataStore data_store;
/* GPS fix callback - syncs RTC from GPS time.
* Repeaters do NOT update prefs lat/lon from GPS — prefs coordinates are the
* user's manually-set position used for adverts. Precise GPS position is
* served only via telemetry requests (gps_get_last_known_position). */
static void gps_fix_callback(double lat, double lon, int64_t utc_time)
{
if (utc_time > 0) {
LOG_INF("GPS fix: RTC sync time=%lld", utc_time);
rtc_clock.setCurrentTime((uint32_t)utc_time);
/* Defer the hardware-RTC write to the main thread — blocking I2C
* here would stall NMEA ingest (gps_fix_callback runs in the
* GNSS modem_chat worker context). */
request_rtc_save((uint32_t)utc_time);
}
int lat_deg = (int)lat;
int lon_deg = (int)lon;
int lat_frac = (int)((lat - lat_deg) * 1000000);
int lon_frac = (int)((lon - lon_deg) * 1000000);
if (lat_frac < 0) lat_frac = -lat_frac;
if (lon_frac < 0) lon_frac = -lon_frac;
LOG_INF("GPS fix: lat=%d.%06d lon=%d.%06d (telemetry only)",
lat_deg, lat_frac, lon_deg, lon_frac);
}
#ifdef ZEPHCORE_LORA
static mesh::ZephyrBoard zephyr_board;
static uint16_t get_battery_mv(void)
{
return zephyr_board.getBattMilliVolts();
}
/* Radio is constructed with no prefs pointer; main() binds it to
* room_mesh._prefs via setPrefs() before room_mesh.begin(). */
static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0));
static mesh::LoRaRadio lora_radio(lora_dev, zephyr_board);
static mesh::ZephyrMillisecondClock ms_clock;
static mesh::ZephyrRNG zephyr_rng;
static SimpleMeshTables mesh_tables;
/* RoomServerMesh requires: board, radio, ms_clock, rng, rtc, tables */
static RoomServerMesh room_mesh(zephyr_board, lora_radio, ms_clock, zephyr_rng, rtc_clock, mesh_tables);
#endif
/* Strong override of the weak stub in ui_mesh_actions_stubs.c: keep the prefs
* copy in step when the LEDs page toggles, so "get leds" reports what the node
* is actually doing. RAM only — there is no deferred-save path off the UI
* thread, so a UI toggle lasts until reboot; "set leds" is what persists. */
extern "C" void mesh_set_leds_disabled(bool disabled)
{
if (room_mesh_ptr) {
room_mesh_ptr->getNodePrefs()->leds_disabled = disabled ? 1 : 0;
}
}
/* Repeater event loop */
static void room_event_loop(void)
{
LOG_INF("starting event-driven loop");
/* Print startup banner (no prompt - Arduino style) */
cli_print("\r\n=== ZephCore Room Server ===\r\n");
/* Start housekeeping timer for periodic maintenance tasks */
k_timer_start(&housekeeping_timer, K_MSEC(HOUSEKEEPING_INTERVAL_MS),
K_MSEC(HOUSEKEEPING_INTERVAL_MS));
/* Start the room-server push timer (drives post sync between clients). */
k_timer_start(&push_timer, K_MSEC(PUSH_TICK_INTERVAL_MS),
K_MSEC(PUSH_TICK_INTERVAL_MS));
for (;;) {
/* Wait for any mesh event - blocks until signaled */
uint32_t events = k_event_wait(&mesh_events, MESH_EVENT_ALL, false, K_FOREVER);
k_event_clear(&mesh_events, events);
/* GPS state transitions must run on main thread (GNSS driver
* modem_chat blocks on system work queue semaphore). */
if (events & MESH_EVENT_GPS_ACTION) {
gps_process_event();
}
static const ServerRole room_role = {
.name = "Room Server",
.name_prefix = "Room",
#ifdef ZEPHCORE_LORA
/* Run queued CLI commands here (main thread) BEFORE loop() drains
* any outbound packets they enqueued — keeps all mesh-state
* mutation on the main thread (see cli_cmd_queue). */
if (events & MESH_EVENT_CLI_RX) {
process_cli_commands();
}
/* Deferred boot advert — sent on the main thread (see
* initial_advert_work_fn); loop() below drains the queued packet. */
if (room_mesh_ptr && (events & MESH_EVENT_INIT_ADVERT)) {
LOG_INF("Sending deferred initial advertisement");
room_mesh_ptr->sendSelfAdvertisement(500, false);
}
/* Packet processing — only on radio/CLI/TX events */
if (room_mesh_ptr &&
(events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE |
MESH_EVENT_CLI_RX | MESH_EVENT_TX_DRAIN |
MESH_EVENT_PUSH_TICK))) {
room_mesh_ptr->loop();
}
.mesh = &room_mesh,
.callbacks = &room_mesh,
.prefs = room_mesh.getNodePrefs(),
.begin = [](RepeaterDataStore *store) { room_mesh.begin(store); },
.loop = [] { room_mesh.loop(); },
.handle_command = [](char *command, char *reply) { room_mesh.handleCommand(0, command, reply); },
.note_gps_time_sync = [] { room_mesh.noteGPSTimeSync(); },
#endif
/* Periodic housekeeping — maintenance + display refresh */
if (events & MESH_EVENT_HOUSEKEEPING) {
#ifdef ZEPHCORE_LORA
/* Radio maintenance: noise floor calibration, adaptive-CAD
* probe, RX watchdog. Separated from loop() so these
* never run on packet-driven events. */
if (room_mesh_ptr) {
room_mesh_ptr->maintenanceLoop();
/* Also drive loop() so time-based actions (advert
* timers, tempradio set/revert, contacts flush,
* uplink status) still fire when no LoRa/CLI
* traffic wakes the event loop. */
room_mesh_ptr->loop();
}
#endif
ui_set_clock(rtc_clock.getCurrentTime());
#ifdef ZEPHCORE_LORA
/* Refresh radio params (noise floor changes from calibration) */
if (room_mesh_ptr) {
NodePrefs *p = room_mesh_ptr->getNodePrefs();
ui_set_radio_params(
(uint32_t)(p->freq * 1000000.0f + 0.5f),
p->sf,
(uint16_t)(p->bw * 10.0f + 0.5f),
p->cr,
p->tx_power_dbm,
lora_radio.getNoiseFloor());
}
/* Battery is now refreshed lazily from ui_pages_render() with
* a 30 s freshness guard — no periodic ADC fire here. */
#endif
}
/* Off-main RTC write request (gps_fix_callback runs in modem_chat
* context — see request_rtc_save()). Perform the blocking I2C
* write here on the main thread instead. */
if (events & MESH_EVENT_RTC_SAVE) {
zephcore_rtc_save((uint32_t)atomic_get(&pending_rtc_epoch));
#ifdef ZEPHCORE_LORA
/* GPS just set the clock — arm the mesh time-sync drift envelope. */
if (room_mesh_ptr) {
room_mesh_ptr->noteGPSTimeSync();
}
#endif
}
}
}
};
int main(void)
{
#ifdef ZEPHCORE_LORA
/* Clear any stale bootloader magic from previous sessions.
* Prevents nRF52 boards from re-entering bootloader after reboot. */
zephyr_board.clearBootloaderMagic();
#endif
/* USB CDC init up front so the host can enumerate, then wait for the
* host to open the port (DTR asserted) — event-driven via the usbd
* message callback. Unplugged → 2 s timeout, no banner; attached →
* banner reaches the user the moment the port opens. */
#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) && \
!IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) && \
(IS_ENABLED(CONFIG_USB_CDC_ACM) || IS_ENABLED(CONFIG_USBD_CDC_ACM_CLASS))
zephcore_usbd_init();
zephcore_usbd_wait_dtr(2000);
#endif
LOG_INF("=== ZephCore Room Server starting ===");
#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA)
/* Confirm MCUboot image early — if we just booted after OTA,
* this marks the image as good so MCUboot keeps it. */
wifi_ota_confirm_image();
#endif
/* Configure LEDs */
#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios)
if (gpio_is_ready_dt(&led0)) {
gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE);
}
#endif
#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios)
if (gpio_is_ready_dt(&led1)) {
gpio_pin_configure_dt(&led1, GPIO_OUTPUT_INACTIVE);
}
#endif
/* First boot on a volume that is not this role's - a fresh chip, a
* companion, or a node that was running Arduino MeshCore, whose nRF52
* filesystems overlap our lfs_partition
* (devdocs/HANDOVER_lfs_arduino_overlap.md). Erase everything so we
* start from a known state: Zephyr's automount only
* auto-formats the LittleFS volume when it fails to mount, and never
* touches storage_partition (BLE bonds NVS) or QSPI.
*
* Self-limiting, so it needs no "done" marker: the identity is generated
* and saved a few lines below, and loadPrefs() persists defaults on the
* same boot, so the next boot sees this role's data and skips this. */
if (!data_store.hasRoleData()) {
LOG_WRN("Volume holds no data for this role - formatting before first boot");
if (!zephcore_fs_format_all(nullptr)) {
LOG_ERR("First-boot format failed - /lfs is not mounted");
}
}
/* Initialize repeater data store */
if (!data_store.begin()) {
LOG_ERR("RepeaterDataStore init failed");
}
/* Initialize sensor manager */
sensor_manager_init();
/* Restore wall-clock time from a battery-backed hardware RTC if present
* (shown tagged "L" until the next GPS/CLI sync; no-op if no RTC). */
{
uint32_t rtc_epoch;
if (zephcore_rtc_restore(&rtc_epoch)) {
rtc_clock.setCurrentTime(rtc_epoch);
}
}
/* GPS callbacks only. Duty interval and repeater mode are applied by
* apply_boot_prefs() after loadPrefs() below. */
if (gps_is_available()) {
gps_set_fix_callback(gps_fix_callback);
gps_set_event_callback(gps_event_callback);
}
/* Initialize UI (display + buttons). Shows splash screen, then auto-
* transitions to STATUS page. Display sleeps after auto-off timeout;
* user button is the only wake source for repeater. */
ui_init();
#if !IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
oled_sleep();
#endif
/* Log environment sensor availability */
if (env_sensors_available()) {
LOG_INF("Environment sensors available");
}
#ifdef ZEPHCORE_LORA
room_mesh_ptr = &room_mesh;
/* Initialize mesh event object BEFORE begin() — radio callbacks post events */
k_event_init(&mesh_events);
/* Set LoRa callbacks for event-driven packet processing */
lora_radio.setRxCallback(lora_rx_callback, nullptr);
lora_radio.setTxDoneCallback(lora_tx_done_callback, nullptr);
room_mesh.setTxQueuedCallback(tx_queued_callback, nullptr);
/* Load or generate identity BEFORE begin(). First-boot keygen runs
* the layered entropy mixer + Ed25519 derive + reserved-prefix
* guard inside ZephyrRNG::generateFirstBootIdentity. */
mesh::LocalIdentity self_identity;
if (!data_store.loadIdentity(self_identity)) {
LOG_INF("No identity found, generating new keypair...");
mesh::ZephyrRNG::generateFirstBootIdentity(self_identity);
data_store.saveIdentity(self_identity);
LOG_INF("New identity saved");
}
room_mesh.self_id = self_identity;
/* Log repeater ID (first 8 bytes of public key) */
LOG_INF("Room ID: %02x%02x%02x%02x%02x%02x%02x%02x...",
self_identity.pub_key[0], self_identity.pub_key[1],
self_identity.pub_key[2], self_identity.pub_key[3],
self_identity.pub_key[4], self_identity.pub_key[5],
self_identity.pub_key[6], self_identity.pub_key[7]);
/* Load persisted prefs and bind the radio to _prefs BEFORE begin() — the
* radio reads freq/bw/sf/cr through this pointer during Mesh::begin() →
* Dispatcher::begin() → Radio::begin(). Without this, the radio would
* configure on NodePrefs defaults (869.618 MHz) regardless of saved
* settings: CLI readback looked correct but the hardware stayed on EU.
* Every role binds this way; begin() refuses to start without it. */
data_store.loadPrefs(*room_mesh.getNodePrefs());
lora_radio.setPrefs(room_mesh.getNodePrefs());
/* LED gate and GPS duty from prefs. After loadPrefs() and ui_init(),
* before begin() -- see boot_prefs.h. */
apply_boot_prefs(room_mesh.getNodePrefs(), true);
/* Start mesh with data store - loads ACL, regions */
room_mesh.begin(&data_store);
/* Generate default node name from hardware device ID if not set */
NodePrefs* prefs = room_mesh.getNodePrefs();
if (strlen(prefs->node_name) == 0 || strcmp(prefs->node_name, "Room") == 0) {
uint8_t dev_id[8];
ssize_t id_len = hwinfo_get_device_id(dev_id, sizeof(dev_id));
if (id_len >= 4) {
snprintf(prefs->node_name, sizeof(prefs->node_name),
"Room-%02X%02X%02X%02X", dev_id[0], dev_id[1], dev_id[2], dev_id[3]);
}
}
/* Apply RX boost and duty cycle from prefs */
lora_radio.setRxBoost(prefs->rx_boost != 0);
lora_radio.setFemRxEnable(prefs->fem_rxgain != 0);
lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0);
lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset,
prefs->probe_interval, prefs->cad_busycap,
prefs->cad_base);
/* Write back the (offset, base) actually in force. setCadParams() may
* have re-anchored the offset across a base-table change, and the pair
* has to be persisted for the NEXT upgrade to have an anchor of its own.
* Guarded so a node whose base has not moved never writes flash at boot. */
if (prefs->cad_base != lora_radio.cadBasePeak() ||
prefs->cad_offset != lora_radio.getCadOffset()) {
prefs->cad_offset = lora_radio.getCadOffset();
prefs->cad_base = lora_radio.cadBasePeak();
room_mesh.savePrefs();
}
/* LR2021 side detectors (multi-SF RX) from prefs. extra_sf is a
* zero-terminated list; a rejected set (SF/BW changed since it was
* saved) just leaves the feature off, and non-LR2021 radios report it
* unsupported. */
{
uint8_t n = 0;
while (n < EXTRA_SF_MAX && prefs->extra_sf[n] != 0) n++;
if (n > 0 && !lora_radio.configSideDetectors(prefs->extra_sf, n)) {
LOG_WRN("extra.sf %u SFs rejected for current SF/BW — side detectors off", n);
}
}
/* Feed initial UI state from loaded prefs */
ui_set_node_name(prefs->node_name);
ui_set_radio_params(
(uint32_t)(prefs->freq * 1000000.0f + 0.5f), /* MHz → Hz */
prefs->sf,
(uint16_t)(prefs->bw * 10.0f + 0.5f), /* kHz → 0.1 kHz */
prefs->cr,
prefs->tx_power_dbm,
lora_radio.getNoiseFloor());
ui_set_battery_provider(get_battery_mv);
ui_set_battery(zephyr_board.getBattMilliVolts(), 0);
ui_set_gps_available(gps_is_available());
/* Defer initial advertisement by 10s — gives GPS time for a quick fix.
* Advert payload (including coords) is built when the work fires. */
LOG_INF("Initial advertisement scheduled in 10s");
k_work_schedule(&initial_advert_work, K_SECONDS(10));
#endif
/* USB CDC was initialized earlier (right after clearBootloaderMagic).
* Just acquire the device handle for the CLI's UART IRQ binding below. */
#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart)
usb_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart);
#else
/* No CDC ACM class driver (e.g. ESP32 usb_serial, or an ESP32-S3 build
* without esp32s3_usb.conf where the cdc_acm DT node exists but the class
* isn't compiled) — use the chosen console UART. */
usb_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console));
#endif
if (device_is_ready(usb_dev)) {
LOG_INF("USB CDC device ready: %s", usb_dev->name);
ring_buf_init(&usb_ring_buf, sizeof(usb_ring_buf_data), usb_ring_buf_data);
/* Set up UART interrupt callback */
uart_irq_callback_set(usb_dev, cli_uart_isr);
uart_irq_rx_enable(usb_dev);
} else {
LOG_ERR("USB CDC device not ready");
usb_dev = NULL;
}
/*
* Event-driven architecture: main thread runs repeater event loop.
* No BLE - all configuration via USB serial CLI.
*/
#ifdef ZEPHCORE_LORA
room_event_loop(); /* Never returns */
#else
for (;;) {
k_sleep(K_FOREVER);
}
#endif
return 0;
return server_main(room_role);
}
+30
View File
@@ -0,0 +1,30 @@
/*
* SPDX-License-Identifier: MIT
* Event bits of the mesh thread's k_event, shared by every role.
*
* Each role's main loop blocks in k_event_wait() on these; ISR and work-queue
* callbacks post them. Bits 0-6 mean the same thing in every role that has
* them; bits 2 and 3 are the role's transport input and time tick under
* role-specific names. Role-only bits start at MESH_EVENT_ROLE_BASE.
*/
#pragma once
#include <zephyr/sys/util.h>
#define MESH_EVENT_LORA_RX BIT(0) /* LoRa packet received */
#define MESH_EVENT_LORA_TX_DONE BIT(1) /* LoRa TX complete */
/* Transport input */
#define MESH_EVENT_BLE_RX BIT(2) /* companion: BLE/USB frame received */
#define MESH_EVENT_CLI_RX BIT(2) /* servers, observer: USB CLI line received */
/* Time tick */
#define MESH_EVENT_HOUSEKEEPING BIT(3) /* companion: periodic housekeeping */
#define MESH_EVENT_MAINTENANCE BIT(3) /* servers: a maintenance deadline came due */
#define MESH_EVENT_GPS_ACTION BIT(4) /* GPS state change (must run on the main thread) */
#define MESH_EVENT_TX_DRAIN BIT(5) /* outbound packet delay expired, run checkSend */
#define MESH_EVENT_RTC_SAVE BIT(6) /* hardware-RTC write requested off-main */
#define MESH_EVENT_ROLE_BASE 7 /* first role-only bit */
+865
View File
@@ -0,0 +1,865 @@
/*
* SPDX-License-Identifier: MIT
* ZephCore - shared composition root of the server roles (USB CLI, Event-Driven)
*
* The repeater and the room server both run from here: server_main() is
* called by main_repeater.cpp / main_room_server.cpp with the role's
* ServerRole. Servers use the USB serial CLI for configuration (no BLE).
*/
#include <stdio.h>
#include <string.h>
#include <zephyr/kernel.h>
#include <zephyr/sys/util.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(zephcore_server_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
#include <zephyr/device.h>
#include <zephyr/devicetree.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/uart.h>
#include <zephyr/sys/ring_buffer.h>
#include <zephyr/drivers/hwinfo.h>
#include <zephyr/sys/reboot.h>
#include "oled_power.h"
#include "boot_prefs.h"
/* BLE controller assert handler — BT is compiled even for the servers (via zephcore_common.conf) */
#if IS_ENABLED(CONFIG_BT_CTLR_ASSERT_HANDLER)
extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line)
{
LOG_ERR("!!! BLE CONTROLLER ASSERT: %s:%u !!!", file ? file : "?", line);
k_sleep(K_MSEC(100));
sys_reboot(SYS_REBOOT_COLD);
}
#endif
/* USB CDC ACM init + 1200-baud DFU + DTR callbacks (shared with companion).
*
* Gate on the CDC-ACM class driver, NOT on DT_HAS_COMPAT_STATUS_OKAY alone: a
* board overlay may expose a cdc_acm_uart DT node unconditionally (the shared
* esp32s3_usb_otg.dtsi does), so the node can be present while the class driver
* — and therefore zephcore_usbd_* and the device object — is not compiled
* (e.g. an ESP32-S3 server built without esp32s3_usb.conf). This mirrors the
* CMake condition that compiles ZephyrUSBCDC.cpp. */
#define ZEPHCORE_USB_STACK \
(IS_ENABLED(CONFIG_USB_CDC_ACM) || IS_ENABLED(CONFIG_USBD_CDC_ACM_CLASS))
#if ZEPHCORE_USB_STACK && !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT)
#include <ZephyrUSBCDC.h>
#endif
#include "server_main_common.h"
#include "mesh_events.h"
#include "../adapters/datastore/ZephyrFsFormat.h"
#include <helpers/LoopWakeStats.h>
#include <Serial.h>
#include <adapters/clock/ZephyrRTCDiscover.h>
#include <ZephyrSensorManager.h>
/* UI subsystem (display, buttons, buzzer) */
#include "ui_task.h"
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
#include "display.h"
#endif
/* Headless servers link the weak no-op ui_* stubs (ui_headless_stubs.c), so
* the periodic UI refresh in the maintenance pass is pure work for nothing on
* them. Guard the hot path on a real ui_* implementation being linked; the
* one-shot calls at init and in the CLI reply path stay unguarded, matching
* the rest of the file.
*
* This MUST mirror the CMake condition that selects the stubs, not the display
* devicetree node: ZEPHCORE_UI_DESIGN_BUTTON is enabled by BUTTONS *or*
* DISPLAY *or* BUZZER, so a board with buttons/buzzer and no panel still links
* the real UI and still needs these updates. */
#define ZEPHCORE_HAS_UI (IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_BUTTON) || \
IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK))
#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA)
#include "wifi_ota.h"
#endif
/* LED configuration */
#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios)
#define LED0_NODE DT_ALIAS(led0)
static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
#endif
#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios)
#define LED1_NODE DT_ALIAS(led1)
static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(LED1_NODE, gpios);
#endif
/* USB CLI configuration */
#define USB_RING_BUF_SIZE 512
#define CLI_LINE_BUF_SIZE 256
/* Event bits: the shared ones are in mesh_events.h. */
#define MESH_EVENT_INIT_ADVERT BIT(MESH_EVENT_ROLE_BASE) /* Deferred boot advert — send on main thread */
#define MESH_EVENT_WAKE BIT(MESH_EVENT_ROLE_BASE + 1) /* Off-main state set; run loop() promptly */
#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | MESH_EVENT_MAINTENANCE | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_RTC_SAVE | MESH_EVENT_INIT_ADVERT | MESH_EVENT_WAKE)
/* Maintenance is deadline-driven, not periodic: after every pass the loop asks
* the mesh when its soonest pending deadline is (msUntilNextMaintenance) and
* arms a single one-shot wake for exactly that moment. An idle node with
* nothing scheduled therefore sleeps until its next real deadline instead of
* waking on a fixed cadence.
*
* MAINTENANCE_BACKSTOP_MS bounds that: it caps how long we will go without a
* pass even when everything reports idle, so a deadline that is missed or
* mis-reported degrades to the old behaviour instead of wedging.
* MAINTENANCE_MIN_MS floors it, so an item that is due-but-blocked (radio
* mid-packet, duty-cycle sleep window) re-arms shortly rather than spinning. */
#define MAINTENANCE_BACKSTOP_MS CONFIG_ZEPHCORE_MAINTENANCE_BACKSTOP_MS
#define MAINTENANCE_MIN_MS 50
/* Event object for mesh loop */
static struct k_event mesh_events;
/* Pending epoch for a deferred zephcore_rtc_save() — gps_fix_callback runs on
* the GNSS modem_chat worker thread, where the RTC's blocking I2C transactions
* would stall NMEA ingest. Stash the latest epoch and let the main thread
* perform the write; concurrent posts coalesce into one save. */
static atomic_t pending_rtc_epoch = ATOMIC_INIT(0);
static void request_rtc_save(uint32_t epoch)
{
atomic_set(&pending_rtc_epoch, (atomic_val_t)epoch);
k_event_post(&mesh_events, MESH_EVENT_RTC_SAVE);
}
/* USB CDC state */
static const struct device *usb_dev;
static uint8_t usb_ring_buf_data[USB_RING_BUF_SIZE];
static struct ring_buf usb_ring_buf;
static char cli_line_buf[CLI_LINE_BUF_SIZE];
static char cli_reply_buf[256];
static uint16_t cli_line_idx;
/* Last byte seen by cli_rx_work_fn, for collapsing CRLF/LFCR pairs. Persists
* across work-item invocations, so a pair split across two USB packets still
* compares correctly. */
static uint8_t cli_prev_byte;
/* Completed CLI lines are handed to the MAIN thread for execution. Byte
* assembly + echo (cli_rx_work_fn) runs on sysworkq and touches no mesh
* state, but handleCommand() mutates the lock-free packet pool / dispatcher
* that loop() also touches — running it on sysworkq races the main loop.
* So we queue the finished line and let the event loop run handleCommand(). */
struct cli_cmd_line { char buf[CLI_LINE_BUF_SIZE]; };
K_MSGQ_DEFINE(cli_cmd_queue, sizeof(struct cli_cmd_line), 4, 4);
/* Work items for event-driven processing */
static void cli_rx_work_fn(struct k_work *work);
static void maintenance_timer_fn(struct k_timer *timer);
static void tx_drain_work_fn(struct k_work *work);
static void initial_advert_work_fn(struct k_work *work);
K_WORK_DEFINE(cli_rx_work, cli_rx_work_fn);
K_WORK_DELAYABLE_DEFINE(tx_drain_work, tx_drain_work_fn);
K_WORK_DELAYABLE_DEFINE(initial_advert_work, initial_advert_work_fn);
/* One-shot maintenance wake, re-armed after every loop pass (see
* arm_maintenance_wake). Not periodic — the period is the deadline. */
K_TIMER_DEFINE(maintenance_timer, maintenance_timer_fn, NULL);
/* The running role; set by server_main() once the mesh is wired up, and the
* guard for every path that can run before that. */
#ifdef ZEPHCORE_LORA
static const ServerRole *server_role;
static void refresh_ui_radio_state(void);
#endif
/* Print string to USB serial */
static void cli_print(const char *str)
{
if (!usb_dev) return;
while (*str) {
uart_poll_out(usb_dev, *str++);
}
}
/* Arduino's `Serial` for upstream-text CLI output (compat/Serial.h). */
ConsoleSerial Serial;
size_t ConsoleSerial::write(const uint8_t *buffer, size_t size)
{
if (!usb_dev) return 0;
for (size_t i = 0; i < size; i++) {
uart_poll_out(usb_dev, buffer[i]);
}
return size;
}
/* USB CDC UART interrupt callback */
static void cli_uart_isr(const struct device *dev, void *user_data)
{
ARG_UNUSED(user_data);
for (;;) {
uart_irq_update(dev);
if (uart_irq_is_pending(dev) <= 0) {
break;
}
if (uart_irq_rx_ready(dev)) {
uint8_t buf[64];
int recv_len = uart_fifo_read(dev, buf, sizeof(buf));
if (recv_len > 0) {
ring_buf_put(&usb_ring_buf, buf, recv_len);
k_work_submit(&cli_rx_work);
}
}
}
}
/* CLI RX work - processes line-based CLI commands
* Matches Arduino behavior: echo each char, then " -> reply" on enter
*/
static void cli_rx_work_fn(struct k_work *work)
{
ARG_UNUSED(work);
uint8_t byte;
while (ring_buf_get(&usb_ring_buf, &byte, 1) == 1) {
const uint8_t prev = cli_prev_byte;
cli_prev_byte = byte;
/* Process command on \r OR \n. Accepting both is what makes a piped
* `echo "cmd" > /dev/ttyACM0` (LF only) work. The cost is that a CRLF
* terminal sends two terminator bytes per Enter, so collapse the pair
* here: skip a terminator that directly follows a *different* one.
* "\r\r" and "\n\n" (two genuine blank lines) still dispatch twice.
* Blank lines ARE dispatched -- `region load` commits on one, and
* handleCommand() no-ops a blank line otherwise. The previous
* de-duplicator keyed on an empty line buffer instead: it ate every
* blank line (stranding `region load` until a reboot) and still let
* the CRLF tail fall through and emit a stray newline of its own. */
if (byte == '\r' || byte == '\n') {
if ((prev == '\r' || prev == '\n') && byte != prev) {
continue; /* tail of a CRLF/LFCR pair */
}
cli_line_buf[cli_line_idx] = '\0';
/* Debug: log received command */
if (cli_line_idx > 0) {
LOG_INF("CLI cmd len=%d: %.40s%s", cli_line_idx,
cli_line_buf, cli_line_idx > 40 ? "..." : "");
}
/* Hand the command to the main thread (see cli_cmd_queue).
* The reply + trailing newline are emitted there, exactly
* matching the previous inline output order. */
struct cli_cmd_line c;
strncpy(c.buf, cli_line_buf, sizeof(c.buf) - 1);
c.buf[sizeof(c.buf) - 1] = '\0';
if (k_msgq_put(&cli_cmd_queue, &c, K_NO_WAIT) == 0) {
k_event_post(&mesh_events, MESH_EVENT_CLI_RX);
} else {
cli_print("\r\n -> busy\r\n");
}
cli_line_idx = 0;
} else if (byte == 0x7F || byte == 0x08) {
/* Backspace - echo backspace sequence */
if (cli_line_idx > 0) {
cli_line_idx--;
if (usb_dev) {
uart_poll_out(usb_dev, '\b');
uart_poll_out(usb_dev, ' ');
uart_poll_out(usb_dev, '\b');
}
}
} else if (cli_line_idx < sizeof(cli_line_buf) - 1) {
/* Echo character back (like Arduino) */
if (usb_dev) {
uart_poll_out(usb_dev, byte);
}
cli_line_buf[cli_line_idx++] = (char)byte;
}
}
}
#ifdef ZEPHCORE_LORA
/* Run queued CLI commands on the MAIN thread (see cli_cmd_queue). Output is
* "\r\n", then " -> reply\r\n" when there is a reply: the same bytes as the
* old inline path, but the echoed line is ended BEFORE the command runs, so
* one that prints straight to the console (get acl) starts on its own line. */
static void process_cli_commands(void)
{
struct cli_cmd_line c;
while (server_role && k_msgq_get(&cli_cmd_queue, &c, K_NO_WAIT) == 0) {
cli_reply_buf[0] = '\0';
cli_print("\r\n");
server_role->handle_command(c.buf, cli_reply_buf);
refresh_ui_radio_state();
if (cli_reply_buf[0] != '\0') {
cli_print(" -> ");
cli_print(cli_reply_buf);
cli_print("\r\n");
}
}
}
#endif
/* Maintenance deadline expired — wake the mesh loop to run the pass. */
static void maintenance_timer_fn(struct k_timer *timer)
{
ARG_UNUSED(timer);
k_event_post(&mesh_events, MESH_EVENT_MAINTENANCE);
}
/* Ask the mesh for its soonest pending deadline and arm the one-shot for it.
* Called after every loop pass, whatever woke us: any event may have created
* or cleared a deadline (a queued advert, a CLI tempradio command, a CAD probe
* that just ran), so the schedule is recomputed from scratch each time rather
* than tracked incrementally.
*
* The backstop below is an unconditional CEILING on the wait, not a fallback
* used only when everything reports idle. That means it must stay well above
* the shortest legitimate recurring deadline (the noise-floor sampler, and the
* CAD probe) or it becomes the effective period and the deadline scheduling
* buys nothing — see ZEPHCORE_MAINTENANCE_BACKSTOP_MS. */
static void arm_maintenance_wake(void)
{
uint32_t delay = MAINTENANCE_BACKSTOP_MS;
uint32_t next = MAINTENANCE_BACKSTOP_MS;
#ifdef ZEPHCORE_LORA
if (server_role) {
next = server_role->mesh->msUntilNextMaintenance();
if (next < delay) {
delay = next;
}
}
#endif
if (delay < MAINTENANCE_MIN_MS) {
delay = MAINTENANCE_MIN_MS;
}
/* Both figures, because which one is binding is the whole diagnosis:
* next=15000 armed=15000 — a real deadline won; working as intended
* next=15000 armed=5000 — the backstop is clamping (it must stay
* above the noise-floor/CAD intervals)
* next=0 armed=50 — a deadline reports due but its state is
* not advancing; repeated = spin
* mesh::MAINTENANCE_IDLE (0x7FFFFFFF) as `next` means nothing pending. */
LOG_DBG("maint: next=%u armed=%u", (unsigned)next, (unsigned)delay);
k_timer_start(&maintenance_timer, K_MSEC(delay), K_NO_WAIT);
}
#ifdef ZEPHCORE_LORA
/* LoRa RX callback - called from ISR context when packet received */
static void lora_rx_callback(void *user_data)
{
ARG_UNUSED(user_data);
k_event_post(&mesh_events, MESH_EVENT_LORA_RX);
}
/* LoRa TX complete callback */
static void lora_tx_done_callback(void *user_data)
{
ARG_UNUSED(user_data);
k_event_post(&mesh_events, MESH_EVENT_LORA_TX_DONE);
}
/* TX drain — Dispatcher queued a packet with a delay.
* Schedule a precise wake so checkSend runs when the delay expires. */
static void tx_drain_work_fn(struct k_work *work)
{
ARG_UNUSED(work);
k_event_post(&mesh_events, MESH_EVENT_TX_DRAIN);
}
/* Deferred initial advertisement — gives GPS time to get a fix at boot.
* Runs on sysworkq (the 10 s delay timer), so it must NOT send the advert
* here: sendSelfAdvertisement() mutates the packet pool / dispatcher shared
* with loop(). Post an event and let the main thread send it. */
static void initial_advert_work_fn(struct k_work *work)
{
ARG_UNUSED(work);
k_event_post(&mesh_events, MESH_EVENT_INIT_ADVERT);
}
static void tx_queued_callback(uint32_t delay_ms, void *user_data)
{
ARG_UNUSED(user_data);
k_work_reschedule(&tx_drain_work, K_MSEC(delay_ms));
}
/* Off-main code (MQTT CONNACK, SNTP) set state that loop() must drain. Post
* only — this runs on the MQTT publisher / WiFi thread, and k_event_post is
* the sole thing safe to do from there. */
static void wake_callback(void *user_data)
{
ARG_UNUSED(user_data);
k_event_post(&mesh_events, MESH_EVENT_WAKE);
}
#endif
/* Global instances */
mesh::ZephyrRTCClock rtc_clock;
/* GPS event callback - called when GPS work handlers need the main thread
* to process a state transition (wake from standby, fix done, timeout).
* Runs from system work queue context — just posts an event, no blocking. */
static void gps_event_callback(void)
{
k_event_post(&mesh_events, MESH_EVENT_GPS_ACTION);
}
static RepeaterDataStore data_store;
/* GPS fix callback - syncs RTC from GPS time.
* Servers do NOT update prefs lat/lon from GPS — prefs coordinates are the
* user's manually-set position used for adverts. Precise GPS position is
* served only via telemetry requests (gps_get_last_known_position). */
static void gps_fix_callback(double lat, double lon, int64_t utc_time)
{
if (utc_time > 0) {
LOG_INF("GPS fix: RTC sync time=%lld", utc_time);
rtc_clock.setCurrentTime((uint32_t)utc_time);
/* Defer the hardware-RTC write to the main thread — blocking I2C
* here would stall NMEA ingest (gps_fix_callback runs in the
* GNSS modem_chat worker context). */
request_rtc_save((uint32_t)utc_time);
}
int lat_deg = (int)lat;
int lon_deg = (int)lon;
int lat_frac = (int)((lat - lat_deg) * 1000000);
int lon_frac = (int)((lon - lon_deg) * 1000000);
if (lat_frac < 0) lat_frac = -lat_frac;
if (lon_frac < 0) lon_frac = -lon_frac;
LOG_INF("GPS fix: lat=%d.%06d lon=%d.%06d (telemetry only)",
lat_deg, lat_frac, lon_deg, lon_frac);
}
#ifdef ZEPHCORE_LORA
mesh::ZephyrBoard zephyr_board;
static uint16_t get_battery_mv(void)
{
return zephyr_board.getBattMilliVolts();
}
/* Radio is constructed with no prefs pointer; server_main() binds it to the
* role's _prefs via setPrefs() before the role's begin(). */
static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0));
mesh::LoRaRadio lora_radio(lora_dev, zephyr_board);
mesh::ZephyrMillisecondClock ms_clock;
mesh::ZephyrRNG zephyr_rng;
SimpleMeshTables mesh_tables;
/* Strong override of the weak stub in ui_mesh_actions_stubs.c: keep the prefs
* copy in step when the LEDs page toggles, so "get leds" reports what the node
* is actually doing. RAM only — a server has no deferred-save path off the UI
* thread, so a UI toggle lasts until reboot; "set leds" is what persists. */
extern "C" void mesh_set_leds_disabled(bool disabled)
{
if (server_role) {
server_role->prefs->leds_disabled = disabled ? 1 : 0;
}
}
static void refresh_ui_radio_state(void)
{
if (!server_role) {
return;
}
ui_set_radio_params(
lora_radio.getActiveFrequencyHz(),
lora_radio.getActiveSpreadingFactor(),
lora_radio.getActiveBandwidthKHzX10(),
lora_radio.getActiveCodingRate(),
lora_radio.getConfiguredTxPower(),
lora_radio.getNoiseFloor());
ui_set_radio_runtime(
lora_radio.getActiveSyncWord(),
lora_radio.getActivePreambleLength(),
lora_radio.isRxDutyCycleEnabled(),
lora_radio.isRadioReady(),
lora_radio.isInRecvMode(),
lora_radio.isTxActive());
ui_set_radio_stats(
lora_radio.getPacketsRecv(),
lora_radio.getPacketsSent(),
lora_radio.getPacketsRecvErrors());
}
#endif
/* Server event loop */
static void server_event_loop(void)
{
LOG_INF("starting event-driven loop");
/* Print startup banner (no prompt - Arduino style) */
cli_print("\r\n=== ZephCore ");
cli_print(server_role->name);
cli_print(" ===\r\n");
/* Arm the first maintenance wake; every pass below re-arms it. */
arm_maintenance_wake();
for (;;) {
/* Wait for any mesh event - blocks until signaled */
uint32_t events = k_event_wait(&mesh_events, MESH_EVENT_ALL, false, K_FOREVER);
k_event_clear(&mesh_events, events);
loopWakeNote(events);
/* GPS state transitions must run on main thread (GNSS driver
* modem_chat blocks on system work queue semaphore). */
if (events & MESH_EVENT_GPS_ACTION) {
gps_process_event();
}
#ifdef ZEPHCORE_LORA
/* Run queued CLI commands here (main thread) BEFORE loop() drains
* any outbound packets they enqueued — keeps all mesh-state
* mutation on the main thread (see cli_cmd_queue). */
if (events & MESH_EVENT_CLI_RX) {
process_cli_commands();
}
/* Deferred boot advert — sent on the main thread (see
* initial_advert_work_fn); loop() below drains the queued packet. */
if (server_role && (events & MESH_EVENT_INIT_ADVERT)) {
LOG_INF("Sending deferred initial advertisement");
server_role->callbacks->sendSelfAdvertisement(500, false);
}
/* Packet processing — only on radio/CLI/TX events */
if (server_role &&
(events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE |
MESH_EVENT_CLI_RX | MESH_EVENT_TX_DRAIN |
MESH_EVENT_WAKE))) {
server_role->loop();
}
#endif
#ifdef ZEPHCORE_LORA
/* Radio maintenance runs OPPORTUNISTICALLY, on every pass, whatever
* woke us — not only when its own timer fired.
*
* Every item inside is deadline-gated internally, so this is nearly
* free when nothing is due. The point is what it does to a busy
* node: a hilltop repeater is already waking constantly for packets,
* so maintenance rides along on those wakes, its deadlines advance,
* and arm_maintenance_wake() below keeps pushing the timer out — the
* maintenance timer then almost never fires and costs no wakes at
* all. Running it only on its own event (as this did originally)
* inverted that: the busiest nodes, which can least afford it, paid
* the full timer cadence on top of their packet wakes, and every
* blocked sample burned a retry against a channel that is busy for
* sustained reasons (real traffic in isReceiving(), the RSSI
* prefilter) rather than the transient duty-cycle sleep window the
* retries were sized for.
*
* Ordering matters: this sits AFTER packet processing so an inbound
* frame is handled before we spend SPI time on an RSSI sweep. */
if (server_role) {
server_role->mesh->maintenanceLoop();
}
#endif
/* A maintenance deadline came due — run the time-based work too. */
if (events & MESH_EVENT_MAINTENANCE) {
#ifdef ZEPHCORE_LORA
/* Drive loop() so time-based actions (advert timers,
* tempradio set/revert, contacts flush, uplink status)
* still fire when no LoRa/CLI traffic wakes the loop. */
if (server_role) {
server_role->loop();
}
#endif
#if ZEPHCORE_HAS_UI
ui_set_clock(rtc_clock.getCurrentTime());
#ifdef ZEPHCORE_LORA
/* Refresh live radio state (noise floor, TX power
* reduction, RX/TX mode, packet counters). Headless
* servers compile this out entirely — every ui_set_*
* below it is a weak no-op there (ui_headless_stubs.c),
* so the whole block was pure work for nothing. */
refresh_ui_radio_state();
/* Battery is now refreshed lazily from ui_pages_render() with
* a 30 s freshness guard — no periodic ADC fire here. */
#endif
#endif /* ZEPHCORE_HAS_UI */
}
/* Off-main RTC write request (gps_fix_callback runs in modem_chat
* context — see request_rtc_save()). Perform the blocking I2C
* write here on the main thread instead. */
if (events & MESH_EVENT_RTC_SAVE) {
zephcore_rtc_save((uint32_t)atomic_get(&pending_rtc_epoch));
#ifdef ZEPHCORE_LORA
/* GPS just set the clock — arm the mesh time-sync drift envelope. */
if (server_role) {
server_role->note_gps_time_sync();
}
#endif
}
/* Re-arm for the soonest deadline this pass left behind. Done for
* every wake, not just maintenance ones: a CLI command, an inbound
* packet or a GPS fix can all create or clear a deadline. */
arm_maintenance_wake();
}
}
int server_main(const ServerRole &role)
{
#ifdef ZEPHCORE_LORA
/* Clear any stale bootloader magic from previous sessions.
* Prevents nRF52 boards from re-entering bootloader after reboot. */
zephyr_board.clearBootloaderMagic();
#endif
/* USB CDC init up front so the host can enumerate, then wait for the
* host to open the port (DTR asserted) — event-driven via the usbd
* message callback. Unplugged → 2 s timeout, no banner; attached →
* banner reaches the user the moment the port opens. */
#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) && \
!IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) && \
(IS_ENABLED(CONFIG_USB_CDC_ACM) || IS_ENABLED(CONFIG_USBD_CDC_ACM_CLASS))
zephcore_usbd_init();
zephcore_usbd_wait_dtr(2000);
#endif
LOG_INF("=== ZephCore %s starting ===", role.name);
#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA)
/* Confirm MCUboot image early — if we just booted after OTA,
* this marks the image as good so MCUboot keeps it. */
wifi_ota_confirm_image();
#endif
/* Configure LEDs */
#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios)
if (gpio_is_ready_dt(&led0)) {
gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE);
}
#endif
#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios)
if (gpio_is_ready_dt(&led1)) {
gpio_pin_configure_dt(&led1, GPIO_OUTPUT_INACTIVE);
}
#endif
/* First boot on a volume that is not this role's - a fresh chip, a
* companion, or a node that was running Arduino MeshCore, whose nRF52
* filesystems overlap our lfs_partition
* (devdocs/HANDOVER_lfs_arduino_overlap.md). Erase everything so we
* start from a known state: Zephyr's automount only
* auto-formats the LittleFS volume when it fails to mount, and never
* touches storage_partition (BLE bonds NVS) or QSPI.
*
* Self-limiting, so it needs no "done" marker: the identity is generated
* and saved a few lines below, and loadPrefs() persists defaults on the
* same boot, so the next boot sees this role's data and skips this. */
if (!data_store.hasRoleData()) {
LOG_WRN("Volume holds no data for this role - formatting before first boot");
if (!zephcore_fs_format_all(nullptr)) {
LOG_ERR("First-boot format failed - /lfs is not mounted");
}
}
/* Initialize the server data store */
if (!data_store.begin()) {
LOG_ERR("RepeaterDataStore init failed");
}
/* Initialize sensor manager */
sensor_manager_init();
/* Restore wall-clock time from a battery-backed hardware RTC if present
* (shown tagged "L" until the next GPS/CLI sync; no-op if no RTC). */
{
uint32_t rtc_epoch;
if (zephcore_rtc_restore(&rtc_epoch)) {
rtc_clock.setCurrentTime(rtc_epoch);
}
}
/* GPS callbacks only. Duty interval and repeater mode are applied by
* apply_boot_prefs() after loadPrefs() below. */
if (gps_is_available()) {
gps_set_fix_callback(gps_fix_callback);
gps_set_event_callback(gps_event_callback);
}
/* Initialize UI (display + buttons). Shows splash screen, then auto-
* transitions to STATUS page. Display sleeps after auto-off timeout;
* user button is the only wake source for a server. */
ui_init();
#if !IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
oled_sleep();
#endif
/* Log environment sensor availability */
if (env_sensors_available()) {
LOG_INF("Environment sensors available");
}
#ifdef ZEPHCORE_LORA
server_role = &role;
/* Initialize mesh event object BEFORE begin() — radio callbacks post events */
k_event_init(&mesh_events);
/* Set LoRa callbacks for event-driven packet processing */
lora_radio.setRxCallback(lora_rx_callback, nullptr);
lora_radio.setTxDoneCallback(lora_tx_done_callback, nullptr);
role.mesh->setTxQueuedCallback(tx_queued_callback, nullptr);
role.mesh->setWakeCallback(wake_callback, nullptr);
/* Load or generate identity BEFORE begin(). First-boot keygen runs
* the layered entropy mixer + Ed25519 derive + reserved-prefix
* guard inside ZephyrRNG::generateFirstBootIdentity. */
mesh::LocalIdentity self_identity;
if (!data_store.loadIdentity(self_identity)) {
LOG_INF("No identity found, generating new keypair...");
mesh::ZephyrRNG::generateFirstBootIdentity(self_identity);
data_store.saveIdentity(self_identity);
LOG_INF("New identity saved");
}
role.mesh->self_id = self_identity;
/* Log the node ID (first 8 bytes of public key) */
LOG_INF("%s ID: %02x%02x%02x%02x%02x%02x%02x%02x...", role.name,
self_identity.pub_key[0], self_identity.pub_key[1],
self_identity.pub_key[2], self_identity.pub_key[3],
self_identity.pub_key[4], self_identity.pub_key[5],
self_identity.pub_key[6], self_identity.pub_key[7]);
/* Load persisted prefs and bind the radio to _prefs BEFORE begin() — the
* radio reads freq/bw/sf/cr through this pointer during Mesh::begin() →
* Dispatcher::begin() → Radio::begin(). Without this, the radio would
* configure on NodePrefs defaults (869.618 MHz) regardless of saved
* settings: CLI readback looked correct but the hardware stayed on EU.
* Every role binds this way; begin() refuses to start without it. */
data_store.loadPrefs(*role.prefs);
lora_radio.setPrefs(role.prefs);
/* LED gate and GPS duty from prefs. After loadPrefs() and ui_init(),
* before begin() -- see boot_prefs.h. */
apply_boot_prefs(role.prefs, true);
/* Start mesh with data store - loads ACL, regions */
role.begin(&data_store);
/* Generate default node name from hardware device ID if not set. The
* role's bare prefix (its constructor default) counts as unset, and so
* does "Repeater": the store wrote that for every server role until
* 2026-09, and deployed room servers still carry it. */
NodePrefs* prefs = role.prefs;
if (strlen(prefs->node_name) == 0 || strcmp(prefs->node_name, "Repeater") == 0 ||
strcmp(prefs->node_name, role.name_prefix) == 0) {
uint8_t dev_id[8];
ssize_t id_len = hwinfo_get_device_id(dev_id, sizeof(dev_id));
if (id_len >= 4) {
snprintf(prefs->node_name, sizeof(prefs->node_name),
"%s-%02X%02X%02X%02X", role.name_prefix,
dev_id[0], dev_id[1], dev_id[2], dev_id[3]);
}
}
/* Apply RX boost and duty cycle from prefs */
lora_radio.setRxBoost(prefs->rx_boost != 0);
lora_radio.setFemRxEnable(prefs->fem_rxgain != 0);
lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0);
lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset,
prefs->probe_interval, prefs->cad_busycap,
prefs->cad_base);
/* Write back the (offset, base) actually in force. setCadParams() may
* have re-anchored the offset across a base-table change, and the pair
* has to be persisted for the NEXT upgrade to have an anchor of its own.
* Guarded so a node whose base has not moved never writes flash at boot. */
if (prefs->cad_base != lora_radio.cadBasePeak() ||
prefs->cad_offset != lora_radio.getCadOffset()) {
prefs->cad_offset = lora_radio.getCadOffset();
prefs->cad_base = lora_radio.cadBasePeak();
role.callbacks->savePrefs();
}
/* LR2021 side detectors (multi-SF RX) from prefs. extra_sf is a
* zero-terminated list; a rejected set (SF/BW changed since it was
* saved) just leaves the feature off, and non-LR2021 radios report it
* unsupported. */
{
uint8_t n = 0;
while (n < EXTRA_SF_MAX && prefs->extra_sf[n] != 0) n++;
if (n > 0 && !lora_radio.configSideDetectors(prefs->extra_sf, n)) {
LOG_WRN("extra.sf %u SFs rejected for current SF/BW — side detectors off", n);
}
}
/* Physical mounting orientation, from prefs. Both are immediate and
* cheap: the panel rotation is a two-byte SEGMENT_MAP/COM_SCAN remap and
* the axis swap is a flag the input callbacks read per event. A panel
* that cannot rotate logs a warning and stays in its native orientation
* rather than failing the boot. */
zephcore_input_set_flipped(prefs->input_rotate != 0);
#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY)
if (prefs->display_rotate) {
mc_display_set_rotated(true);
}
#endif
/* Feed initial UI state from loaded prefs */
ui_set_node_name(prefs->node_name);
refresh_ui_radio_state();
ui_set_battery_provider(get_battery_mv);
ui_set_battery(zephyr_board.getBattMilliVolts(), 0);
ui_set_gps_available(gps_is_available());
/* Defer initial advertisement by 10s — gives GPS time for a quick fix.
* Advert payload (including coords) is built when the work fires. */
LOG_INF("Initial advertisement scheduled in 10s");
k_work_schedule(&initial_advert_work, K_SECONDS(10));
#endif
/* USB CDC was initialized earlier (right after clearBootloaderMagic).
* Just acquire the device handle for the CLI's UART IRQ binding below. */
#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart)
usb_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart);
#else
/* No CDC ACM class driver (e.g. ESP32 usb_serial, or an ESP32-S3 server
* built without esp32s3_usb.conf where the cdc_acm DT node exists but the
* class isn't compiled) — use the chosen console UART. */
usb_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console));
#endif
if (device_is_ready(usb_dev)) {
LOG_INF("USB CDC device ready: %s", usb_dev->name);
ring_buf_init(&usb_ring_buf, sizeof(usb_ring_buf_data), usb_ring_buf_data);
/* Set up UART interrupt callback */
uart_irq_callback_set(usb_dev, cli_uart_isr);
uart_irq_rx_enable(usb_dev);
} else {
LOG_ERR("USB CDC device not ready");
usb_dev = NULL;
}
/*
* Event-driven architecture: main thread runs the server event loop.
* No BLE - all configuration via USB serial CLI.
*/
#ifdef ZEPHCORE_LORA
server_event_loop(); /* Never returns */
#else
for (;;) {
k_sleep(K_FOREVER);
}
#endif
return 0;
}
+48
View File
@@ -0,0 +1,48 @@
/*
* SPDX-License-Identifier: MIT
* ZephCore - shared composition root of the server roles (repeater, room server)
*
* The USB text CLI, the radio/GPS/RTC glue, the deadline-driven event loop and
* the boot sequence are the same for both roles. Each role's main file owns
* its mesh object and hands server_main() a ServerRole describing it.
*/
#pragma once
#include <stdint.h>
#include <app/RepeaterDataStore.h>
#include <helpers/CommonCLI.h>
#include <helpers/NodePrefs.h>
#include <adapters/clock/ZephyrRTCClock.h>
/* Radio + mesh includes (shared header selects LR1110 or SX126x) */
#include <src/RadioIncludes.h>
/* What the shared code needs from a role. The function pointers exist because
* loop(), begin() and handleCommand() are not virtual on the mesh classes. */
struct ServerRole {
const char *name; /* "Repeater", "Room Server": banner and logs */
const char *name_prefix; /* default node name is "<prefix>-<device id>" */
#ifdef ZEPHCORE_LORA
mesh::Mesh *mesh;
CommonCLICallbacks *callbacks; /* savePrefs(), sendSelfAdvertisement() */
NodePrefs *prefs;
void (*begin)(RepeaterDataStore *store);
void (*loop)(void);
void (*handle_command)(char *command, char *reply);
void (*note_gps_time_sync)(void);
#endif
};
/* Runs the role; never returns. */
int server_main(const ServerRole &role);
/* The hardware singletons the role's mesh object is built from. */
extern mesh::ZephyrRTCClock rtc_clock;
#ifdef ZEPHCORE_LORA
extern mesh::ZephyrBoard zephyr_board;
extern mesh::LoRaRadio lora_radio;
extern mesh::ZephyrMillisecondClock ms_clock;
extern mesh::ZephyrRNG zephyr_rng;
extern SimpleMeshTables mesh_tables;
#endif