Merge pull request #25 from rlwilliamson-dev/room-server

app: add Room Server (BBS) role
This commit is contained in:
liquidraver
2026-06-02 23:05:25 +02:00
committed by GitHub
11 changed files with 2693 additions and 5 deletions
+5
View File
@@ -60,6 +60,7 @@ For exact `west build -b` board strings, flash methods, and special setup, see t
- **Companion** (default) -- connects to MeshCore mobile apps via BLE. Contacts, channels, offline message queue.
- **Repeater** -- forwards packets, configured via USB serial CLI. See the [Repeater CLI Command Reference](zephcore/Repeater_CLI_commands.md) for all available commands.
- **Room Server** -- store-and-forward shared message room (a "BBS"). Clients log in with an admin or guest password and post messages; the server pushes each new post to every other logged-in client. No BLE; configured via the same USB serial CLI as the repeater.
- **Observer** (ESP32 only) -- listen-only node that publishes received LoRa packets to MQTT over WiFi STA. Configured at runtime via serial CLI.
## Building
@@ -97,6 +98,10 @@ west build -b rak4631 zephcore --pristine -- \
west build -b xiao_esp32s3/esp32s3/procpu zephcore --pristine --sysbuild -- \
-DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/wifi_ota.conf"
# Room Server (store-and-forward BBS, USB CLI)
west build -b rak4631 zephcore --pristine -- \
-DEXTRA_CONF_FILE="boards/common/room_server.conf"
# Observer (ESP32, listen-only WiFi+MQTT)
west build -b xiao_esp32c3 zephcore --pristine -- \
-DEXTRA_CONF_FILE="boards/common/observer.conf"
+3 -1
View File
@@ -25,10 +25,12 @@
## 1. Project Overview
ZephCore is a LoRa mesh networking firmware running on Zephyr RTOS. It supports two device roles:
ZephCore is a LoRa mesh networking firmware running on Zephyr RTOS. It supports four device roles:
- **Companion**: BLE-connected device paired with a phone app. Full contact/channel/message management.
- **Repeater**: Autonomous headless relay node. CLI administration via authenticated mesh connections or serial UART.
- **Room Server**: Headless store-and-forward shared message room (BBS). Reuses the repeater's ACL/region/CLI; pushes new posts to logged-in clients (per-client sync cursor + ACK).
- **Observer** (ESP32): Listen-only node that publishes received LoRa packets to MQTT over WiFi.
Supported hardware: nRF52840, nRF54L15, ESP32-C3/C6/S3, EFR32MG24 — all with SX1262 or LR1110 LoRa radios.
+23 -1
View File
@@ -539,6 +539,28 @@ if(CONFIG_ZEPHCORE_ROLE_REPEATER)
message(WARNING "ZephCore repeater uplink requested, but MQTT is disabled (likely prod.conf). Build will exclude uplink runtime.")
endif()
target_compile_definitions(app PRIVATE ZEPHCORE_REPEATER=1)
elseif(CONFIG_ZEPHCORE_ROLE_ROOM_SERVER)
message(STATUS "ZephCore Role: ROOM SERVER")
target_sources(app PRIVATE
src/main_room_server.cpp
app/RoomServerMesh.cpp
app/RoomServerRegionCLI.cpp
app/RepeaterDataStore.cpp
helpers/ClientACL.cpp
helpers/RegionMap.cpp
helpers/TransportKeyStore.cpp
helpers/CommonCLI.cpp
)
# Shared USBD CDC ACM init + 1200-baud DFU + DTR event/callback module.
if(NOT CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT AND (CONFIG_USB_CDC_ACM OR CONFIG_USBD_CDC_ACM_CLASS))
target_sources(app PRIVATE
adapters/usb/ZephyrUSBCDC.cpp
)
endif()
target_include_directories(app PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/adapters/usb
)
target_compile_definitions(app PRIVATE ZEPHCORE_ROOM_SERVER=1)
elseif(CONFIG_ZEPHCORE_ROLE_OBSERVER)
message(STATUS "ZephCore Role: OBSERVER")
target_sources(app PRIVATE
@@ -640,7 +662,7 @@ if(CONFIG_ZEPHCORE_UI_BUTTONS OR CONFIG_ZEPHCORE_UI_BUZZER OR CONFIG_ZEPHCORE_UI
${CMAKE_CURRENT_SOURCE_DIR}/helpers/ui
)
# Repeater and Observer builds need weak stubs for companion-only UI mesh actions
if(CONFIG_ZEPHCORE_ROLE_REPEATER OR CONFIG_ZEPHCORE_ROLE_OBSERVER)
if(CONFIG_ZEPHCORE_ROLE_REPEATER OR CONFIG_ZEPHCORE_ROLE_OBSERVER OR CONFIG_ZEPHCORE_ROLE_ROOM_SERVER)
target_sources(app PRIVATE
helpers/ui/ui_mesh_actions_stubs.c
)
+20 -3
View File
@@ -94,6 +94,15 @@ config ZEPHCORE_ROLE_OBSERVER
IATA location code) are configured at runtime via serial CLI
and stored in LittleFS. No BLE, no advertising, no routing.
config ZEPHCORE_ROLE_ROOM_SERVER
bool "Room Server (shared BBS, USB serial CLI)"
help
Store-and-forward shared message room (a "BBS"). Clients log in
with an admin or guest password and post messages; the server
pushes each new post to all other logged-in clients and tracks a
per-client sync cursor. No BLE — configured via USB serial CLI.
Reuses the repeater's ACL, region filtering and CLI command set.
endchoice
config ZEPHCORE_COMPANION_USB
@@ -117,11 +126,11 @@ config ZEPHCORE_COMPANION_USB
For ESP32-S3 boards, USB_DEVICE_STACK_NEXT must be enabled first via
boards/common/esp32s3_usb.conf before this becomes active.
if ZEPHCORE_ROLE_REPEATER
if ZEPHCORE_ROLE_REPEATER || ZEPHCORE_ROLE_ROOM_SERVER
config ZEPHCORE_REPEATER_UPLINK
bool "Enable repeater WiFi+MQTT uplink"
depends on SOC_FAMILY_ESPRESSIF_ESP32
depends on ZEPHCORE_ROLE_REPEATER && SOC_FAMILY_ESPRESSIF_ESP32
default n
help
Adds observer-style WiFi station and MQTT packet publishing to
@@ -162,7 +171,15 @@ config ZEPHCORE_GUEST_PASSWORD
help
Default password for guest access. Empty string disables guest access.
endif # ZEPHCORE_ROLE_REPEATER
config ZEPHCORE_MAX_UNSYNCED_POSTS
int "Room server: max buffered posts"
default 32
depends on ZEPHCORE_ROLE_ROOM_SERVER
help
Size of the room server's circular post buffer. When full, the
oldest unsynced post is overwritten. Matches upstream MeshCore (32).
endif # ZEPHCORE_ROLE_REPEATER || ZEPHCORE_ROLE_ROOM_SERVER
endmenu # Device Role
+2
View File
@@ -2,6 +2,8 @@
All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the mesh (non-zero `sender_timestamp`) cannot access USB-only commands.
> The **Room Server** role shares this CLI — the common commands (radio, region, password, advert, gps, etc.) plus `setperm` / `get acl` all apply.
**Sources:**
- `helpers/CommonCLI.cpp` — common commands shared by all roles
- `app/RepeaterMesh.cpp` — repeater-specific commands
File diff suppressed because it is too large Load Diff
+318
View File
@@ -0,0 +1,318 @@
/*
* SPDX-License-Identifier: Apache-2.0
* RoomServerMesh - LoRa mesh repeater implementation
*
* Extends mesh::Mesh with:
* - ACL-based client authentication
* - Region-based flood filtering
* - Neighbor tracking
* - CLI commands (via USB serial)
* - Protocol handlers (login, status, telemetry, etc.)
*/
#pragma once
#include <mesh/Mesh.h>
#include <mesh/StaticPoolPacketManager.h>
#include <mesh/SimpleMeshTables.h>
#include <helpers/ClientACL.h>
#include <helpers/CommonCLI.h>
#include <helpers/RegionMap.h>
#include <helpers/TransportKeyStore.h>
#include <helpers/RateLimiter.h>
#include <helpers/StatsFormatHelper.h>
#include <helpers/NodePrefs.h>
#include "RepeaterDataStore.h"
#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK)
#include "observer_creds.h"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "v1.15.5-zephyr"
#endif
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE __DATE__
#endif
#define FIRMWARE_ROLE "room_server"
#ifndef MAX_NEIGHBOURS
#ifdef CONFIG_ZEPHCORE_MAX_NEIGHBOURS
#define MAX_NEIGHBOURS CONFIG_ZEPHCORE_MAX_NEIGHBOURS
#else
#define MAX_NEIGHBOURS 16
#endif
#endif
struct NeighbourInfo {
mesh::Identity id;
uint32_t advert_timestamp;
uint32_t heard_timestamp;
int8_t snr; // multiplied by 4
void clear() {
id = mesh::Identity();
advert_timestamp = 0;
heard_timestamp = 0;
snr = 0;
}
};
struct RepeaterStats {
uint16_t batt_milli_volts;
uint16_t curr_tx_queue_len;
int16_t noise_floor;
int16_t last_rssi;
uint32_t n_packets_recv;
uint32_t n_packets_sent;
uint32_t total_air_time_secs;
uint32_t total_up_time_secs;
uint32_t n_sent_flood, n_sent_direct;
uint32_t n_recv_flood, n_recv_direct;
uint16_t err_events;
int16_t last_snr; // x 4
uint16_t n_direct_dups, n_flood_dups;
uint32_t total_rx_air_time_secs;
uint32_t n_recv_errors;
};
#ifndef MAX_UNSYNCED_POSTS
#ifdef CONFIG_ZEPHCORE_MAX_UNSYNCED_POSTS
#define MAX_UNSYNCED_POSTS CONFIG_ZEPHCORE_MAX_UNSYNCED_POSTS
#else
#define MAX_UNSYNCED_POSTS 32
#endif
#endif
/* Post text budget: matches upstream MeshCore (160-byte payload minus a
* 9-byte header = 4-byte timestamp + 1-byte type + 4-byte author prefix). */
#define MAX_POST_TEXT_LEN (160 - 9)
/* A single shared-room post held in the server's circular buffer. */
struct PostInfo {
mesh::Identity author;
uint32_t post_timestamp; // by OUR clock
char text[MAX_POST_TEXT_LEN + 1];
void clear() {
author = mesh::Identity();
post_timestamp = 0;
memset(text, 0, sizeof(text));
}
};
class RoomServerMesh : public mesh::Mesh, public CommonCLICallbacks {
mesh::MainBoard& _board;
RepeaterDataStore* _store;
uint32_t last_millis;
uint64_t uptime_millis;
unsigned long next_local_advert, next_flood_advert;
bool _logging;
NodePrefs _prefs;
ClientACL acl;
CommonCLI _cli;
uint8_t reply_data[MAX_PACKET_PAYLOAD];
uint8_t reply_path[MAX_PATH_SIZE];
uint8_t reply_path_len;
TransportKeyStore key_store;
RegionMap region_map, temp_map;
RegionEntry* load_stack[8];
RegionEntry* recv_pkt_region;
TransportKey default_scope;
RateLimiter discover_limiter, login_fail_limiter;
uint32_t pending_discover_tag;
unsigned long pending_discover_until;
bool region_load_active;
unsigned long dirty_contacts_expiry;
/* Room server: circular post buffer + round-robin push state */
unsigned long next_push;
uint16_t _num_posted, _num_post_pushes;
int next_client_idx;
int next_post_idx;
PostInfo posts[MAX_UNSYNCED_POSTS];
#if MAX_NEIGHBOURS > 0
NeighbourInfo neighbours[MAX_NEIGHBOURS];
#endif
unsigned long set_radio_at, revert_radio_at;
float pending_freq;
float pending_bw;
uint8_t pending_sf;
uint8_t pending_cr;
int matching_peer_indexes[MAX_CLIENTS];
#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK)
ObserverCreds _uplink_creds;
bool _uplink_reboot_required;
char _uplink_pubkey_hex[PUB_KEY_SIZE * 2 + 1];
char _uplink_packets_topic[160];
char _uplink_status_topic[160];
float _uplink_last_score;
float _uplink_last_rssi;
uint8_t _uplink_last_raw[MAX_TRANS_UNIT];
int _uplink_last_raw_len;
unsigned long _uplink_next_status_at;
#endif
void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr);
int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len);
mesh::Packet* createSelfAdvert();
/* Room server: shared-post buffer + push-to-client sync */
void addPost(ClientInfo* client, const char* postData);
void pushPostToClient(ClientInfo* client, PostInfo& post);
uint8_t getUnsyncedCount(ClientInfo* client);
bool processAck(const uint8_t* data);
static bool saveFilter(ClientInfo* client);
void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size);
void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size);
/* Region-definition CLI (defined in app/RepeaterRegionCLI.cpp).
* handleRegionLoadLine: a continuation line during `region load`.
* handleRegionCommand: a `region ...` command. */
void handleRegionLoadLine(char* command, char* reply);
void handleRegionCommand(char* command, char* reply);
protected:
uint8_t getDutyCyclePercent() const override {
/* Arduino formula: duty% = 100 / (af + 1). af=0 → 100%, af=9 → 10%. */
return (uint8_t)(100.0f / (_prefs.airtime_factor + 1.0f) + 0.5f);
}
bool allowPacketForward(const mesh::Packet* packet) override;
bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]);
const char* getLogDateTime() override;
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
void logRx(mesh::Packet* pkt, int len, float score) override;
void logTx(mesh::Packet* pkt, int len) override;
void logTxFail(mesh::Packet* pkt, int len) override;
uint32_t getRetransmitDelay(const mesh::Packet* packet) override;
uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override;
int getInterferenceThreshold() const override {
return _prefs.interference_threshold;
}
int getAGCResetInterval() const override {
if (_prefs.rx_duty_cycle) {
return 0;
}
return ((int)_prefs.agc_reset_interval) * 4000;
}
uint8_t getExtraAckTransmitCount() const override {
return _prefs.multi_acks;
}
bool filterRecvFloodPacket(mesh::Packet* pkt) override;
void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override;
int searchPeersByHash(const uint8_t* hash) override;
void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override;
void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len);
void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override;
bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override;
void onControlDataRecv(mesh::Packet* packet) override;
void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override;
#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK)
bool handleUplinkCommand(const char *command, char *reply);
void markUplinkRebootRequired() { _uplink_reboot_required = true; }
bool isUplinkEnabled() const { return (_uplink_creds._reserved[0] & 0x01) != 0; }
void setUplinkEnabled(bool en) {
if (en) {
_uplink_creds._reserved[0] |= 0x01;
} else {
_uplink_creds._reserved[0] &= (uint8_t)~0x01;
}
}
bool saveUplinkCreds();
void publishUplinkPacket(mesh::Packet *pkt);
void publishUplinkStatus(const char *status);
#endif
public:
RoomServerMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms,
mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
void begin(RepeaterDataStore* store);
void sendNodeDiscoverReq();
/* CommonCLICallbacks */
const char* getFirmwareVer() override { return FIRMWARE_VERSION; }
const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; }
const char* getRole() override { return FIRMWARE_ROLE; }
double getNodeLat() const override;
double getNodeLon() const override;
bool setGpsEnabled(bool enabled) override;
bool isGpsEnabled() const override;
void formatGpsStatsReply(char* reply) override;
const char* getNodeName() { return _prefs.node_name; }
NodePrefs* getNodePrefs() { return &_prefs; }
void savePrefs() override;
void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override;
void freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) override;
bool formatFileSystem() override;
void sendSelfAdvertisement(int delay_millis, bool flood) override;
void updateAdvertTimer() override;
void updateFloodAdvertTimer() override;
void setLoggingOn(bool enable) override { _logging = enable; }
void eraseLogFile() override;
void dumpLogFile() override;
void setTxPower(int8_t power_dbm) override;
void formatNeighborsReply(char* reply) override;
void removeNeighbor(const uint8_t* pubkey, int key_len) override;
void formatStatsReply(char* reply) override;
void formatRadioStatsReply(char* reply) override;
void formatPacketStatsReply(char* reply) override;
mesh::LocalIdentity& getSelfId() override { return self_id; }
void saveIdentity(const mesh::LocalIdentity& new_id) override;
void clearStats() override;
/* Adaptive contention window callbacks */
float getContentionEstimate() const override {
return getContentionTracker().getContentionEstimate();
}
float getFloodDelayFactor() const override {
return getContentionTracker().getFloodDelayFactor();
}
void setBackoffMultiplier(float m) override {
getContentionTracker().setBackoffMultiplier(m);
}
/* Duty-cycle preamble false-positive stats (SX126x only;
* other radios return 0 from the base class). */
uint32_t getDutyCycleTimeoutRestarts() const override;
void resetDutyCycleTimeoutRestarts() override;
#ifdef CONFIG_ZEPHCORE_APC
/* Adaptive Power Control callbacks */
int8_t getAPCReduction() const override {
return getPowerController().getPowerReduction();
}
float getAPCMargin() const override {
return getPowerController().getMarginEstimate();
}
bool isAPCEnabled() const override {
return getPowerController().isEnabled();
}
void setAPCEnabled(bool en) override {
getPowerController().setEnabled(en);
if (!en) {
_radio->setTxPowerReduction(0);
}
}
uint8_t getAPCTargetMargin() const override {
return getPowerController().getTargetMargin();
}
void setAPCTargetMargin(uint8_t margin_db) override {
getPowerController().setTargetMargin(margin_db);
}
#endif
void handleCommand(uint32_t sender_timestamp, char* command, char* reply);
void loop();
bool hasPendingWork() const;
};
+234
View File
@@ -0,0 +1,234 @@
/*
* SPDX-License-Identifier: Apache-2.0
* RoomServerMesh region-definition CLI the `region ...` command family and the
* `region load` continuation-line parser.
*
* Split out of RoomServerMesh.cpp for readability. Defines two RoomServerMesh
* methods invoked from RoomServerMesh::handleCommand(); the file-static parser
* helpers below are used only by this command family.
*/
#include "RoomServerMesh.h"
#include <mesh/Utils.h>
#include <helpers/TxtDataHelpers.h>
#include <stdio.h>
#include <string.h>
/* ---------- region def helpers ---------- */
static char* skipSpaces(char* s) { while (*s == ' ') s++; return s; }
static void rtrimSpaces(char* s) { char* e = s + strlen(s); while (e > s && e[-1] == ' ') *--e = '\0'; }
static char* takeToken(char** cursor) {
char* p = skipSpaces(*cursor);
if (*p == '\0') { *cursor = p; return nullptr; }
char* tok = p;
while (*p && *p != ' ') p++;
if (*p) *p++ = '\0';
*cursor = p;
return tok;
}
static char* splitNameJump(char* tok) {
for (char* q = tok; *q; q++) {
if (*q == '|' || *q == ',') {
*q = '\0';
char* jump = skipSpaces(q + 1);
rtrimSpaces(jump);
return jump;
}
}
return nullptr;
}
static bool processRegionDefSegment(RegionMap* map, char* tok, RegionEntry** cursor, char* reply) {
char* jump = splitNameJump(tok);
char* name = skipSpaces(tok);
if (*name == '\0') { snprintf(reply, 160, "Err - empty name"); return false; }
if (jump && *jump == '\0') { snprintf(reply, 160, "Err - empty jump"); return false; }
RegionEntry* r = map->putRegion(name, (*cursor)->id);
if (r == NULL) { snprintf(reply, 160, "Err - put failed: %s", name); return false; }
r->flags = 0;
if (jump) {
RegionEntry* j = map->findByNamePrefix(jump);
if (j == NULL) { snprintf(reply, 160, "Err - unknown jump: %s", jump); return false; }
*cursor = j;
} else {
*cursor = r;
}
return true;
}
/* ---------------------------------------- */
void RoomServerMesh::handleRegionLoadLine(char* command, char* reply) {
if (StrHelper::isBlank(command)) {
region_map = temp_map;
region_load_active = false;
sprintf(reply, "OK - loaded %d regions", region_map.getCount());
} else {
char* np = command;
while (*np == ' ') np++;
int indent = np - command;
char* ep = np;
while (RegionMap::is_name_char(*ep)) ep++;
if (*ep) { *ep++ = 0; }
while (*ep && *ep != 'F') ep++;
if (indent > 0 && indent < 8 && strlen(np) > 0) {
auto parent = load_stack[indent - 1];
if (parent) {
auto old = region_map.findByName(np);
auto nw = temp_map.putRegion(np, parent->id, old ? old->id : 0);
if (nw) {
nw->flags = old ? old->flags : (*ep == 'F' ? 0 : REGION_DENY_FLOOD);
load_stack[indent] = nw;
}
}
}
reply[0] = 0;
}
}
void RoomServerMesh::handleRegionCommand(char* command, char* reply) {
reply[0] = 0;
// `region def`: cursor-walk bulk region builder — must run before parseTextParts
// mutates and truncates the buffer to 4 segments.
char* cmd = skipSpaces(command);
if (strncmp(cmd, "region def", 10) == 0 && (cmd[10] == ' ' || cmd[10] == '\0')) {
char* payload = skipSpaces(cmd + 10);
rtrimSpaces(payload);
if (*payload == '\0') { snprintf(reply, 160, "Err - empty def"); goto region_done; }
RegionEntry* cursor = &region_map.getWildcard();
for (char* tok; (tok = takeToken(&payload)) != nullptr; ) {
if (!processRegionDefSegment(&region_map, tok, &cursor, reply)) goto region_done;
}
region_map.exportTo(reply, 160);
goto region_done;
}
{
const char* parts[4];
int n = mesh::Utils::parseTextParts(command, parts, 4, ' ');
if (n == 1) {
region_map.exportTo(reply, 160);
} else if (n >= 2 && strcmp(parts[1], "load") == 0) {
temp_map.resetFrom(region_map);
memset(load_stack, 0, sizeof(load_stack));
load_stack[0] = &temp_map.getWildcard();
region_load_active = true;
} else if (n >= 2 && strcmp(parts[1], "save") == 0) {
_prefs.discovery_mod_timestamp = getRTCClock()->getCurrentTime();
savePrefs();
bool success = region_map.save(_store->getRegionsPath());
strcpy(reply, success ? "OK" : "Err - save failed");
} else if (n >= 3 && strcmp(parts[1], "allowf") == 0) {
auto region = region_map.findByNamePrefix(parts[2]);
if (region) {
region->flags &= ~REGION_DENY_FLOOD;
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - unknown region");
}
} else if (n >= 3 && strcmp(parts[1], "denyf") == 0) {
auto region = region_map.findByNamePrefix(parts[2]);
if (region) {
region->flags |= REGION_DENY_FLOOD;
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - unknown region");
}
} else if (n >= 3 && strcmp(parts[1], "get") == 0) {
auto region = region_map.findByNamePrefix(parts[2]);
if (region) {
auto parent = region_map.findById(region->parent);
if (parent && parent->id != 0) {
sprintf(reply, " %s (%s) %s", region->name, parent->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F");
} else {
sprintf(reply, " %s %s", region->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F");
}
} else {
strcpy(reply, "Err - unknown region");
}
} else if (n >= 3 && strcmp(parts[1], "home") == 0) {
auto home = region_map.findByNamePrefix(parts[2]);
if (home) {
region_map.setHomeRegion(home);
sprintf(reply, " home is now %s", home->name);
} else {
strcpy(reply, "Err - unknown region");
}
} else if (n == 2 && strcmp(parts[1], "home") == 0) {
auto home = region_map.getHomeRegion();
sprintf(reply, " home is %s", home ? home->name : "*");
} else if (n >= 3 && strcmp(parts[1], "default") == 0) {
if (strcmp(parts[2], "<null>") == 0) {
region_map.setDefaultRegion(nullptr);
memset(default_scope.key, 0, sizeof(default_scope.key));
region_map.save(_store->getRegionsPath()); // persist in one atomic step
sprintf(reply, " default scope is now <null>");
} else {
auto def = region_map.findByNamePrefix(parts[2]);
if (def == nullptr) {
def = region_map.putRegion(parts[2], 0); // auto-create the default region
}
if (def) {
def->flags = 0; // make sure allow flood enabled
region_map.setDefaultRegion(def);
region_map.getTransportKeysFor(*def, &default_scope, 1);
region_map.save(_store->getRegionsPath()); // persist in one atomic step
sprintf(reply, " default scope is now %s", def->name);
} else {
strcpy(reply, "Err - region table full");
}
}
} else if (n == 2 && strcmp(parts[1], "default") == 0) {
auto def = region_map.getDefaultRegion();
sprintf(reply, " default scope is %s", def ? def->name : "<null>");
} else if (n >= 3 && strcmp(parts[1], "put") == 0) {
auto parent = n >= 4 ? region_map.findByNamePrefix(parts[3]) : &region_map.getWildcard();
if (parent == nullptr) {
strcpy(reply, "Err - unknown parent");
} else {
auto region = region_map.putRegion(parts[2], parent->id);
if (region == nullptr) {
strcpy(reply, "Err - unable to put");
} else {
region->flags = 0; // New default: enable flood
strcpy(reply, "OK - (flood allowed)");
}
}
} else if (n >= 3 && strcmp(parts[1], "remove") == 0) {
auto region = region_map.findByName(parts[2]);
if (region) {
if (region_map.removeRegion(*region)) {
strcpy(reply, "OK");
} else {
strcpy(reply, "Err - not empty");
}
} else {
strcpy(reply, "Err - not found");
}
} else if (n >= 3 && strcmp(parts[1], "list") == 0) {
uint8_t mask = 0;
bool invert = false;
if (strcmp(parts[2], "allowed") == 0) {
mask = REGION_DENY_FLOOD;
invert = false;
} else if (strcmp(parts[2], "denied") == 0) {
mask = REGION_DENY_FLOOD;
invert = true;
} else {
strcpy(reply, "Err - use 'allowed' or 'denied'");
return;
}
int len = region_map.exportNamesTo(reply, 160, mask, invert);
if (len == 0) {
strcpy(reply, "-none-");
}
} else {
strcpy(reply, "Err - ??");
}
} // end parseTextParts scope
region_done:;
}
+19
View File
@@ -0,0 +1,19 @@
# Room server-specific config
#
# Build with: -DEXTRA_CONF_FILE="boards/common/room_server.conf"
#
# Room servers use USB serial CLI for configuration — no BLE, no bonding, no NUS.
# This overrides the BLE settings in zephcore_common.conf.
# ========== Role Selection ==========
CONFIG_ZEPHCORE_ROLE_ROOM_SERVER=y
# ========== Disable BLE entirely ==========
# Room servers have zero BLE functionality — all config via USB serial CLI.
# Saves ~100KB flash and ~30KB RAM on nRF52.
CONFIG_BT=n
# ========== Entropy (required when BT is disabled) ==========
# BT normally pulls in the entropy subsystem. Without BT, we need it explicitly
# for Ed25519 key generation, LoRa random delays, etc.
CONFIG_ENTROPY_GENERATOR=y
+8
View File
@@ -113,6 +113,14 @@ Append `-- -DEXTRA_CONF_FILE="boards/common/repeater.conf"` to any build command
west build -b rak4631 zephcore -- -DEXTRA_CONF_FILE="boards/common/repeater.conf"
```
### Building for Room Server Role
Append `-- -DEXTRA_CONF_FILE="boards/common/room_server.conf"` to any build command:
```
west build -b rak4631 zephcore -- -DEXTRA_CONF_FILE="boards/common/room_server.conf"
```
### Production Build (logging disabled)
```
+573
View File
@@ -0,0 +1,573 @@
/*
* SPDX-License-Identifier: Apache-2.0
* ZephCore - Room Server (USB CLI, Event-Driven)
*
* This is the main entry point for the room server (shared BBS) role.
* Room servers use 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_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"
/* 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) */
#if !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT)
#include <ZephyrUSBCDC.h>
#endif
#include <app/RepeaterDataStore.h>
#include <app/RoomServerMesh.h>
#include <adapters/clock/ZephyrRTCClock.h>
#include <ZephyrSensorManager.h>
/* UI subsystem (display, buttons, buzzer) */
#include "ui_task.h"
/* Radio + mesh includes (shared header selects LR1110 or SX126x) */
#include <mesh/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_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)
/* 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;
/* 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;
/* 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);
while (uart_irq_update(dev) && uart_irq_is_pending(dev)) {
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) {
/* Process command on \r OR \n (support echo from Linux) */
if (byte == '\r' || byte == '\n') {
if (cli_line_idx > 0) {
cli_line_buf[cli_line_idx] = '\0';
/* Debug: log received command */
LOG_INF("CLI cmd len=%d: %.40s%s", cli_line_idx,
cli_line_buf, cli_line_idx > 40 ? "..." : "");
/* Process CLI command */
#ifdef ZEPHCORE_LORA
if (room_mesh_ptr) {
cli_reply_buf[0] = '\0';
room_mesh_ptr->handleCommand(0, cli_line_buf, cli_reply_buf);
if (cli_reply_buf[0] != '\0') {
/* Arduino format: newline, then " -> reply" */
cli_print("\r\n -> ");
cli_print(cli_reply_buf);
}
}
#endif
cli_line_idx = 0;
}
/* New line for next command */
cli_print("\r\n");
} 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;
}
}
}
/* 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 */
static void initial_advert_work_fn(struct k_work *work)
{
ARG_UNUSED(work);
#ifdef ZEPHCORE_LORA
if (room_mesh_ptr) {
LOG_INF("Sending deferred initial advertisement");
room_mesh_ptr->sendSelfAdvertisement(500, false);
}
#endif
}
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);
}
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(). */
#if IS_ENABLED(CONFIG_ZEPHCORE_RADIO_LR1110)
/* LR1110 via Zephyr LoRa driver */
static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0));
static mesh::LR1110Radio lora_radio(lora_dev, zephyr_board);
#elif IS_ENABLED(CONFIG_ZEPHCORE_RADIO_SX127X)
/* SX127x via Zephyr loramac-node driver */
static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0));
static mesh::SX127xRadio lora_radio(lora_dev, zephyr_board);
#else
/* SX126x via Zephyr LoRa driver */
static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0));
static mesh::SX126xRadio lora_radio(lora_dev, zephyr_board);
#endif
static mesh::ZephyrMillisecondClock ms_clock;
static mesh::ZephyrRNG zephyr_rng;
static mesh::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
/* 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();
}
#ifdef ZEPHCORE_LORA
/* 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();
}
#endif
/* Periodic housekeeping — maintenance + display refresh */
if (events & MESH_EVENT_HOUSEKEEPING) {
#ifdef ZEPHCORE_LORA
/* Radio maintenance: noise floor calibration, AGC reset,
* 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
}
}
}
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 !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart)
zephcore_usbd_init();
#endif
#if DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart)
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
/* Initialize repeater data store */
if (!data_store.begin()) {
LOG_ERR("RepeaterDataStore init failed");
}
/* Initialize sensor manager */
sensor_manager_init();
/* Set GPS to repeater mode: power off now, wake every 48h for time sync only.
* This prevents GPS from draining power on boards that have it (e.g., Wio Tracker). */
if (gps_is_available()) {
gps_set_fix_callback(gps_fix_callback);
gps_set_event_callback(gps_event_callback);
gps_set_repeater_mode(true);
}
/* 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.
* Mirrors the temp_prefs pattern in main_companion.cpp. */
data_store.loadPrefs(*room_mesh.getNodePrefs());
lora_radio.setPrefs(room_mesh.getNodePrefs());
/* 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.enableRxDutyCycle(prefs->rx_duty_cycle != 0);
/* 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 DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart)
usb_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart);
#else
/* No CDC ACM (e.g. ESP32 usb_serial) — use 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;
}