Implement MQTT Observer role

This commit is contained in:
liquidraver
2026-04-05 14:34:06 +02:00
parent 37adeefd22
commit 875e6554d4
11 changed files with 1867 additions and 2 deletions
+18 -2
View File
@@ -411,6 +411,22 @@ if(CONFIG_ZEPHCORE_ROLE_REPEATER)
${CMAKE_CURRENT_SOURCE_DIR}/adapters/usb
)
target_compile_definitions(app PRIVATE ZEPHCORE_REPEATER=1)
elseif(CONFIG_ZEPHCORE_ROLE_OBSERVER)
message(STATUS "ZephCore Role: OBSERVER")
target_sources(app PRIVATE
app/main_observer.cpp
app/ObserverMesh.cpp
app/RepeaterDataStore.cpp
adapters/wifi/ZephyrWiFiStation.c
adapters/mqtt/ZephyrMQTTPublisher.c
)
target_include_directories(app PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/adapters/wifi
${CMAKE_CURRENT_SOURCE_DIR}/adapters/mqtt
${CMAKE_CURRENT_SOURCE_DIR}/adapters/usb
${CMAKE_CURRENT_SOURCE_DIR}/app
)
target_compile_definitions(app PRIVATE ZEPHCORE_OBSERVER=1)
else()
message(STATUS "ZephCore Role: COMPANION")
target_sources(app PRIVATE
@@ -462,8 +478,8 @@ if(CONFIG_ZEPHCORE_UI_BUTTONS OR CONFIG_ZEPHCORE_UI_BUZZER OR CONFIG_ZEPHCORE_UI
target_include_directories(app PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/helpers/ui
)
# Repeater builds need weak stubs for companion-only UI mesh actions
if(CONFIG_ZEPHCORE_ROLE_REPEATER)
# Repeater and Observer builds need weak stubs for companion-only UI mesh actions
if(CONFIG_ZEPHCORE_ROLE_REPEATER OR CONFIG_ZEPHCORE_ROLE_OBSERVER)
target_sources(app PRIVATE
helpers/ui/ui_mesh_actions_stubs.c
)
+14
View File
@@ -42,6 +42,10 @@ module = ZEPHCORE_WIFI_OTA
module-str = zephcore_wifi_ota
source "$(ZEPHYR_BASE)/subsys/logging/Kconfig.template.log_config"
module = ZEPHCORE_OBSERVER
module-str = zephcore_observer
source "$(ZEPHYR_BASE)/subsys/logging/Kconfig.template.log_config"
menu "ZephCore"
menu "Device Role"
@@ -66,6 +70,16 @@ config ZEPHCORE_ROLE_REPEATER
No BLE stack. Has ACL authentication, region filtering,
neighbor tracking, and full CLI command set.
config ZEPHCORE_ROLE_OBSERVER
bool "Observer (listen-only, WiFi+MQTT)"
help
ESP32-only listen-only node. Receives LoRa packets without
forwarding. Connects to WiFi (STA mode) and publishes received
packets to an MQTT broker in meshcoretomqtt-compatible format.
All parameters (WiFi SSID/PSK, MQTT host/port/TLS/user/pass,
IATA location code) are configured at runtime via serial CLI
and stored in LittleFS. No BLE, no advertising, no routing.
endchoice
if ZEPHCORE_ROLE_REPEATER
@@ -0,0 +1,469 @@
/*
* SPDX-License-Identifier: Apache-2.0
* ZephyrMQTTPublisher — MQTT client thread for the Observer role.
*
* Thread flow:
* 1. Wait for WIFI_READY_BIT (WiFi + DHCP + SNTP done)
* 2. Resolve broker hostname via zsock_getaddrinfo()
* 3. Connect MQTT (TLS with TLS_PEER_VERIFY_NONE, or plaintext)
* 4. Publish retained "online" status (LWT handles "offline" on disconnect)
* 5. Poll loop: drain publish queue + mqtt_input() keepalive
* 6. On error/disconnect: publish "offline", back off 5s, goto 1
*/
#include "ZephyrMQTTPublisher.h"
#include "ZephyrWiFiStation.h" /* g_wifi_events, WIFI_READY_BIT, zc_wifi_station_* */
#include "observer_creds.h"
#include <zephyr/kernel.h>
#include <zephyr/net/mqtt.h>
#include <zephyr/net/socket.h>
#include <zephyr/net/tls_credentials.h>
#include <zephyr/logging/log.h>
#include <errno.h>
#include <string.h>
#include <stdio.h>
LOG_MODULE_REGISTER(mqtt_pub, CONFIG_LOG_DEFAULT_LEVEL);
/* ========== Publish queue ========== */
/* Pre-serialized message: topic + JSON payload (both copied at enqueue). */
#define PUB_TOPIC_MAX 160
#define PUB_PAYLOAD_MAX 1024
#define PUB_QUEUE_LEN 8
struct pub_msg {
char topic[PUB_TOPIC_MAX];
char payload[PUB_PAYLOAD_MAX];
uint16_t payload_len;
};
K_MSGQ_DEFINE(s_pub_queue, sizeof(struct pub_msg), PUB_QUEUE_LEN, 4);
/* ========== Module state ========== */
static const struct ObserverCreds *s_creds;
static char s_client_id[64];
static char s_status_topic[PUB_TOPIC_MAX];
static char s_packets_topic[PUB_TOPIC_MAX];
static volatile bool s_connected;
/* Event bit to trigger reconnect from external callers */
#define PUB_RECONNECT_BIT BIT(0)
static K_EVENT_DEFINE(s_pub_events);
/* ========== MQTT buffers ========== */
#define MQTT_RX_BUF_SIZE 256
#define MQTT_TX_BUF_SIZE 512
static uint8_t s_rx_buf[MQTT_RX_BUF_SIZE];
static uint8_t s_tx_buf[MQTT_TX_BUF_SIZE];
/* ========== MQTT event callback ========== */
static void mqtt_evt_handler(struct mqtt_client *client,
const struct mqtt_evt *evt)
{
switch (evt->type) {
case MQTT_EVT_CONNACK:
if (evt->result == 0) {
LOG_INF("MQTT connected");
s_connected = true;
} else {
/* Broker return codes: 1=bad proto, 2=id rejected, 3=server unavail,
* 4=bad user/pass, 5=not authorized */
LOG_WRN("MQTT CONNACK rejected: return_code=%d",
evt->param.connack.return_code);
}
break;
case MQTT_EVT_DISCONNECT:
LOG_INF("MQTT disconnected");
s_connected = false;
break;
case MQTT_EVT_PUBLISH:
/* Observer never subscribes — ignore incoming publishes */
break;
case MQTT_EVT_PUBACK:
case MQTT_EVT_PINGRESP:
break;
default:
LOG_DBG("MQTT evt %d", evt->type);
break;
}
}
/* ========== Publish helpers ========== */
static uint16_t s_pkt_id;
static int do_publish(struct mqtt_client *client,
const char *topic, const char *payload, int payload_len,
bool retain)
{
struct mqtt_publish_param p = {
.message = {
.topic = {
.topic = {
.utf8 = (const uint8_t *)topic,
.size = strlen(topic),
},
.qos = MQTT_QOS_0_AT_MOST_ONCE,
},
.payload = {
.data = (uint8_t *)payload,
.len = (uint32_t)payload_len,
},
},
.message_id = ++s_pkt_id,
.dup_flag = 0,
.retain_flag = retain ? 1 : 0,
};
return mqtt_publish(client, &p);
}
static void publish_status(struct mqtt_client *client, bool online)
{
static const char online_json[] = "{\"status\":\"online\"}";
static const char offline_json[] = "{\"status\":\"offline\"}";
const char *msg = online ? online_json : offline_json;
do_publish(client, s_status_topic, msg, strlen(msg), true);
}
/* ========== DNS resolution ========== */
static int resolve_host(const char *host, uint16_t port,
struct sockaddr_in *out_addr)
{
struct zsock_addrinfo hints = {
.ai_family = AF_INET,
.ai_socktype = SOCK_STREAM,
};
struct zsock_addrinfo *result;
char port_str[8];
snprintf(port_str, sizeof(port_str), "%u", port);
int rc = zsock_getaddrinfo(host, port_str, &hints, &result);
if (rc != 0) {
LOG_ERR("DNS lookup failed for %s: %d", host, rc);
return -EHOSTUNREACH;
}
memcpy(out_addr, result->ai_addr, sizeof(*out_addr));
zsock_freeaddrinfo(result);
LOG_INF("Resolved %s → port %u", host, port);
return 0;
}
/* ========== Client setup ========== */
static struct mqtt_client s_client;
static struct sockaddr_in s_broker_addr;
/* LWT strings (must outlive mqtt_connect()) */
static const char s_lwt_offline[] = "{\"status\":\"offline\"}";
static struct mqtt_topic s_lwt_topic;
static struct mqtt_utf8 s_lwt_msg;
static struct mqtt_utf8 s_mqtt_user;
static struct mqtt_utf8 s_mqtt_pass;
static struct mqtt_utf8 s_mqtt_client_id;
static int setup_client(void)
{
mqtt_client_init(&s_client);
/* Client ID */
s_mqtt_client_id.utf8 = (const uint8_t *)s_client_id;
s_mqtt_client_id.size = strlen(s_client_id);
s_client.client_id = s_mqtt_client_id;
/* Broker address (already resolved) */
s_client.broker = &s_broker_addr;
/* RX/TX buffers */
s_client.rx_buf = s_rx_buf;
s_client.rx_buf_size = sizeof(s_rx_buf);
s_client.tx_buf = s_tx_buf;
s_client.tx_buf_size = sizeof(s_tx_buf);
/* Keepalive (matches CONFIG_MQTT_KEEPALIVE=60) */
s_client.keepalive = 60;
s_client.clean_session = 1;
/* Credentials */
if (s_creds->mqtt_user[0] != '\0') {
s_mqtt_user.utf8 = (const uint8_t *)s_creds->mqtt_user;
s_mqtt_user.size = strlen(s_creds->mqtt_user);
s_client.user_name = &s_mqtt_user;
}
if (s_creds->mqtt_password[0] != '\0') {
s_mqtt_pass.utf8 = (const uint8_t *)s_creds->mqtt_password;
s_mqtt_pass.size = strlen(s_creds->mqtt_password);
s_client.password = &s_mqtt_pass;
}
/* LWT — retained "offline" published if connection drops */
s_lwt_topic.topic.utf8 = (const uint8_t *)s_status_topic;
s_lwt_topic.topic.size = strlen(s_status_topic);
s_lwt_topic.qos = MQTT_QOS_0_AT_MOST_ONCE;
s_lwt_msg.utf8 = (const uint8_t *)s_lwt_offline;
s_lwt_msg.size = strlen(s_lwt_offline);
s_client.will_topic = &s_lwt_topic;
s_client.will_message = &s_lwt_msg;
s_client.will_retain = 1;
/* Transport */
if (s_creds->mqtt_tls) {
s_client.transport.type = MQTT_TRANSPORT_SECURE;
struct mqtt_sec_config *tls = &s_client.transport.tls.config;
/* No certificate pinning — encrypted but no CA verification.
* This avoids cert rotation issues (e.g. Let's Encrypt renewals). */
tls->peer_verify = TLS_PEER_VERIFY_NONE;
tls->cipher_count = 0;
tls->cipher_list = NULL;
tls->sec_tag_count = 0;
tls->sec_tag_list = NULL;
tls->hostname = s_creds->mqtt_host; /* SNI */
} else {
s_client.transport.type = MQTT_TRANSPORT_NON_SECURE;
}
s_client.evt_cb = mqtt_evt_handler;
return 0;
}
/* ========== MQTT poll loop ========== */
static int get_sock(void)
{
if (s_creds->mqtt_tls) {
return s_client.transport.tls.sock;
}
return s_client.transport.tcp.sock;
}
/* Wait for socket to be readable, with a timeout_ms cap.
* Returns > 0 if data available, 0 if timeout, < 0 on error/hangup. */
static int poll_socket(int sock, int timeout_ms)
{
struct zsock_pollfd pfd = {
.fd = sock,
.events = ZSOCK_POLLIN,
};
int rc = zsock_poll(&pfd, 1, timeout_ms);
if (rc < 0) {
return -errno;
}
if (pfd.revents & (ZSOCK_POLLHUP | ZSOCK_POLLERR)) {
return -ECONNRESET;
}
return rc; /* 0 = timeout, 1 = data ready */
}
static void run_poll_loop(void)
{
int sock = get_sock();
for (;;) {
/* Check for external reconnect request */
if (k_event_test(&s_pub_events, PUB_RECONNECT_BIT)) {
k_event_clear(&s_pub_events, PUB_RECONNECT_BIT);
LOG_INF("Reconnect requested");
break;
}
/* Drain publish queue */
struct pub_msg msg;
while (k_msgq_get(&s_pub_queue, &msg, K_NO_WAIT) == 0) {
int rc = do_publish(&s_client, msg.topic,
msg.payload, msg.payload_len, false);
if (rc < 0) {
LOG_WRN("Publish failed: %d", rc);
s_connected = false;
return;
}
}
/* Wait up to 100ms for incoming data (keepalive PINGRESP, etc.) */
int pr = poll_socket(sock, 100);
if (pr < 0) {
LOG_WRN("Socket error in poll loop: %d", pr);
s_connected = false;
return;
}
if (pr > 0) {
int rc = mqtt_input(&s_client);
if (rc < 0 && rc != -EAGAIN && rc != -EWOULDBLOCK) {
LOG_WRN("mqtt_input error: %d", rc);
s_connected = false;
return;
}
}
/* Send keepalive ping if needed */
int rc = mqtt_live(&s_client);
if (rc < 0 && rc != -EAGAIN) {
LOG_WRN("mqtt_live error: %d", rc);
s_connected = false;
return;
}
if (!s_connected) {
return;
}
}
}
/* ========== Publisher thread ========== */
#define MQTT_THREAD_STACK_SIZE 8192
#define MQTT_THREAD_PRIORITY 5
static void mqtt_thread_fn(void *p1, void *p2, void *p3);
K_THREAD_DEFINE(mqtt_pub_thread, MQTT_THREAD_STACK_SIZE,
mqtt_thread_fn, NULL, NULL, NULL,
MQTT_THREAD_PRIORITY, 0, 0);
static void mqtt_thread_fn(void *p1, void *p2, void *p3)
{
ARG_UNUSED(p1); ARG_UNUSED(p2); ARG_UNUSED(p3);
LOG_INF("MQTT publisher thread started");
for (;;) {
s_connected = false;
/* Wait until WiFi is ready (blocks indefinitely) */
LOG_INF("Waiting for WiFi ready...");
k_event_wait(&g_wifi_events, WIFI_READY_BIT, false, K_FOREVER);
if (!s_creds || s_creds->mqtt_host[0] == '\0') {
LOG_WRN("No MQTT host configured — set with: set mqtt.host <hostname>");
k_sleep(K_SECONDS(10));
continue;
}
/* Clear reconnect flag before connecting */
k_event_clear(&s_pub_events, PUB_RECONNECT_BIT);
/* Resolve broker hostname */
int rc = resolve_host(s_creds->mqtt_host, s_creds->mqtt_port,
&s_broker_addr);
if (rc < 0) {
LOG_WRN("DNS failed — retry in 10s");
k_sleep(K_SECONDS(10));
continue;
}
/* Configure MQTT client */
setup_client();
/* Connect */
LOG_INF("Connecting to MQTT broker %s:%u (TLS=%u)",
s_creds->mqtt_host, s_creds->mqtt_port,
(unsigned)s_creds->mqtt_tls);
rc = mqtt_connect(&s_client);
if (rc < 0) {
LOG_WRN("mqtt_connect failed: %d — retry in 10s", rc);
k_sleep(K_SECONDS(10));
continue;
}
/* Wait for CONNACK — poll up to 5s total, 500ms per iteration */
{
int sock = get_sock();
for (int i = 0; i < 10 && !s_connected; i++) {
int pr = poll_socket(sock, 500);
if (pr < 0) {
LOG_WRN("CONNACK wait: socket error %d", pr);
break;
}
if (pr > 0) {
int rc2 = mqtt_input(&s_client);
if (rc2 < 0 && rc2 != -EAGAIN &&
rc2 != -EWOULDBLOCK) {
LOG_WRN("CONNACK wait: mqtt_input error %d", rc2);
break;
}
}
}
}
if (!s_connected) {
LOG_WRN("CONNACK not accepted (broker rejected or no response) — retry in 10s");
mqtt_disconnect(&s_client, NULL);
k_sleep(K_SECONDS(10));
continue;
}
/* Publish retained "online" status */
publish_status(&s_client, true);
/* Run poll loop until disconnect or reconnect request */
run_poll_loop();
/* Publish "offline" before closing (best-effort) */
if (s_connected) {
publish_status(&s_client, false);
}
mqtt_disconnect(&s_client, NULL);
LOG_INF("MQTT session ended — retry in 5s");
k_sleep(K_SECONDS(5));
}
}
/* ========== Public API ========== */
void mqtt_publisher_start(const struct ObserverCreds *creds,
const char *client_id,
const char *status_topic,
const char *packets_topic)
{
s_creds = creds;
strncpy(s_client_id, client_id, sizeof(s_client_id) - 1);
strncpy(s_status_topic, status_topic, sizeof(s_status_topic) - 1);
strncpy(s_packets_topic, packets_topic, sizeof(s_packets_topic) - 1);
s_client_id[sizeof(s_client_id) - 1] = '\0';
s_status_topic[sizeof(s_status_topic) - 1] = '\0';
s_packets_topic[sizeof(s_packets_topic) - 1] = '\0';
/* Thread is already created by K_THREAD_DEFINE — nothing more to do. */
LOG_INF("MQTT publisher ready (client_id=%s)", s_client_id);
}
void mqtt_publisher_enqueue(const char *topic, const char *payload, int payload_len)
{
struct pub_msg msg;
strncpy(msg.topic, topic, sizeof(msg.topic) - 1);
msg.topic[sizeof(msg.topic) - 1] = '\0';
int copy_len = payload_len;
if (copy_len >= (int)sizeof(msg.payload)) {
copy_len = (int)sizeof(msg.payload) - 1;
}
memcpy(msg.payload, payload, copy_len);
msg.payload[copy_len] = '\0';
msg.payload_len = (uint16_t)copy_len;
if (k_msgq_put(&s_pub_queue, &msg, K_NO_WAIT) != 0) {
LOG_WRN("Publish queue full — packet dropped");
}
}
bool mqtt_publisher_is_connected(void)
{
return s_connected;
}
void mqtt_publisher_reconnect(void)
{
k_event_post(&s_pub_events, PUB_RECONNECT_BIT);
}
@@ -0,0 +1,53 @@
/*
* SPDX-License-Identifier: Apache-2.0
* ZephyrMQTTPublisher — MQTT client thread for the Observer role.
*
* Dedicated thread: waits for WiFi ready, resolves broker hostname,
* connects with optional TLS (TLS_PEER_VERIFY_NONE), publishes LWT and
* status, then drains a pre-serialized JSON publish queue.
*/
#pragma once
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
struct ObserverCreds;
/*
* Start the MQTT publisher thread.
* creds — runtime credentials; pointer kept, must remain valid.
* client_id — MQTT client identifier string (e.g. "Observer-AABBCCDD").
* status_topic — topic for retained online/offline status messages.
* packets_topic — topic for received LoRa packet JSON messages.
*
* Must be called once from main() after wifi_station_start().
*/
void mqtt_publisher_start(const struct ObserverCreds *creds,
const char *client_id,
const char *status_topic,
const char *packets_topic);
/*
* Enqueue a pre-serialized JSON payload for publishing.
* topic and payload are copied — safe to pass stack buffers.
* Drops the message silently if the queue is full.
* Safe to call from any thread.
*/
void mqtt_publisher_enqueue(const char *topic, const char *payload, int payload_len);
/* Returns true when the MQTT session is active. */
bool mqtt_publisher_is_connected(void);
/*
* Trigger an MQTT reconnect (e.g. after credential change via CLI).
* Safe to call from any thread.
*/
void mqtt_publisher_reconnect(void);
#ifdef __cplusplus
}
#endif
+210
View File
@@ -0,0 +1,210 @@
/*
* SPDX-License-Identifier: Apache-2.0
* ZephyrWiFiStation — WiFi STA connection manager for the Observer role.
*
* Flow:
* wifi_station_start() → issues NET_REQUEST_WIFI_CONNECT
* NET_EVENT_WIFI_CONNECT_RESULT → link up, wait for DHCP
* NET_EVENT_IPV4_DHCP_BOUND → IP assigned → run SNTP → signal WIFI_READY_BIT
* NET_EVENT_WIFI_DISCONNECT_RESULT → clear WIFI_READY_BIT, schedule reconnect in 5s
*/
#include "ZephyrWiFiStation.h"
#include "observer_creds.h"
#include <zephyr/kernel.h>
#include <zephyr/net/net_if.h>
#include <zephyr/net/net_event.h>
#include <zephyr/net/net_mgmt.h>
#include <zephyr/net/wifi_mgmt.h>
#include <zephyr/net/sntp.h>
#include <zephyr/logging/log.h>
#include <string.h>
#include <stdio.h>
LOG_MODULE_REGISTER(wifi_station, CONFIG_LOG_DEFAULT_LEVEL);
/* ========== Shared event object ========== */
K_EVENT_DEFINE(g_wifi_events);
/* ========== Module state ========== */
static const struct ObserverCreds *s_creds;
static void (*s_time_sync_cb)(uint32_t unix_ts);
/* Protect link-up vs. SNTP state */
static volatile bool s_wifi_link_up; /* WiFi associate event received */
static volatile bool s_wifi_ready; /* DHCP + SNTP done */
/* ========== Reconnect work ========== */
static void connect_work_fn(struct k_work *work);
static K_WORK_DELAYABLE_DEFINE(connect_work, connect_work_fn);
/* ========== net_mgmt callbacks ========== */
static struct net_mgmt_event_callback wifi_cb;
static struct net_mgmt_event_callback ipv4_cb;
static void do_sntp_and_signal(void)
{
struct sntp_time ts;
int rc = sntp_simple("pool.ntp.org", 8000, &ts);
if (rc == 0) {
LOG_INF("SNTP synced: %llu", (unsigned long long)ts.seconds);
if (s_time_sync_cb) {
s_time_sync_cb((uint32_t)ts.seconds);
}
} else {
LOG_WRN("SNTP failed (rc=%d) — continuing without time sync", rc);
}
s_wifi_ready = true;
k_event_post(&g_wifi_events, WIFI_READY_BIT);
LOG_INF("WiFi ready (DHCP+SNTP done)");
}
/* Called when DHCP has bound an address */
static void ipv4_event_handler(struct net_mgmt_event_callback *cb,
uint64_t event, struct net_if *iface)
{
ARG_UNUSED(iface);
if (event == NET_EVENT_IPV4_DHCP_BOUND) {
LOG_INF("DHCP bound — starting SNTP sync");
/* Run SNTP — this is called from the net_mgmt work queue.
* sntp_simple() may block for up to 8s; net_mgmt stack must be
* large enough (CONFIG_NET_MGMT_EVENT_STACK_SIZE >= 3072). */
do_sntp_and_signal();
}
}
static void wifi_event_handler(struct net_mgmt_event_callback *cb,
uint64_t event, struct net_if *iface)
{
switch (event) {
case NET_EVENT_WIFI_CONNECT_RESULT: {
#ifdef CONFIG_NET_MGMT_EVENT_INFO
const struct wifi_status *status =
(const struct wifi_status *)cb->info;
bool success = (status == NULL ||
status->conn_status == WIFI_STATUS_CONN_SUCCESS);
int reason = status ? (int)status->conn_status : 0;
#else
bool success = true;
int reason = 0;
#endif
if (success) {
LOG_INF("WiFi link up (SSID: %s)", s_creds ? s_creds->wifi_ssid : "?");
s_wifi_link_up = true;
/* DHCP will fire ipv4_event_handler when lease is obtained */
} else {
LOG_WRN("WiFi connect failed (reason=%d) — retry in 10s", reason);
k_work_reschedule(&connect_work, K_SECONDS(10));
}
break;
}
case NET_EVENT_WIFI_DISCONNECT_RESULT:
LOG_INF("WiFi disconnected");
s_wifi_link_up = false;
s_wifi_ready = false;
k_event_clear(&g_wifi_events, WIFI_READY_BIT);
/* Reconnect after 5s backoff */
k_work_reschedule(&connect_work, K_SECONDS(5));
break;
default:
break;
}
}
/* ========== Connect work ========== */
static void connect_work_fn(struct k_work *work)
{
ARG_UNUSED(work);
if (!s_creds || s_creds->wifi_ssid[0] == '\0') {
LOG_WRN("No WiFi SSID configured — set with: set wifi.ssid <name>");
return;
}
struct net_if *iface = net_if_get_default();
if (!iface) {
LOG_ERR("No network interface");
return;
}
struct wifi_connect_req_params params = {
.ssid = (const uint8_t *)s_creds->wifi_ssid,
.ssid_length = (uint8_t)strlen(s_creds->wifi_ssid),
.channel = WIFI_CHANNEL_ANY,
.band = WIFI_FREQ_BAND_UNKNOWN,
.mfp = WIFI_MFP_OPTIONAL,
};
if (s_creds->wifi_psk[0] != '\0') {
params.psk = (const uint8_t *)s_creds->wifi_psk;
params.psk_length = (uint8_t)strlen(s_creds->wifi_psk);
params.security = WIFI_SECURITY_TYPE_PSK;
} else {
params.security = WIFI_SECURITY_TYPE_NONE;
}
LOG_INF("Connecting to WiFi: %s", s_creds->wifi_ssid);
int rc = net_mgmt(NET_REQUEST_WIFI_CONNECT, iface, &params, sizeof(params));
if (rc < 0 && rc != -EALREADY) {
LOG_ERR("WiFi connect request failed: %d — retry in 10s", rc);
k_work_reschedule(&connect_work, K_SECONDS(10));
}
}
/* ========== Public API ========== */
void zc_wifi_station_start(const struct ObserverCreds *creds,
void (*time_sync_cb)(uint32_t unix_ts))
{
s_creds = creds;
s_time_sync_cb = time_sync_cb;
s_wifi_link_up = false;
s_wifi_ready = false;
/* Register WiFi event callback */
net_mgmt_init_event_callback(&wifi_cb, wifi_event_handler,
NET_EVENT_WIFI_CONNECT_RESULT |
NET_EVENT_WIFI_DISCONNECT_RESULT);
net_mgmt_add_event_callback(&wifi_cb);
/* Register IPv4 DHCP callback */
net_mgmt_init_event_callback(&ipv4_cb, ipv4_event_handler,
NET_EVENT_IPV4_DHCP_BOUND);
net_mgmt_add_event_callback(&ipv4_cb);
/* Trigger first connect attempt */
k_work_schedule(&connect_work, K_MSEC(500));
}
void zc_wifi_station_reconnect(void)
{
/* Attempt to disconnect first; ignore errors (may already be disconnected) */
struct net_if *iface = net_if_get_default();
if (iface) {
net_mgmt(NET_REQUEST_WIFI_DISCONNECT, iface, NULL, 0);
}
/* Clear state and immediately reschedule connect */
s_wifi_link_up = false;
s_wifi_ready = false;
k_event_clear(&g_wifi_events, WIFI_READY_BIT);
k_work_reschedule(&connect_work, K_MSEC(200));
}
bool zc_wifi_station_is_connected(void)
{
return s_wifi_ready;
}
const char *zc_wifi_station_ssid(void)
{
return (s_creds && s_creds->wifi_ssid[0]) ? s_creds->wifi_ssid : "";
}
@@ -0,0 +1,54 @@
/*
* SPDX-License-Identifier: Apache-2.0
* ZephyrWiFiStation — WiFi STA connection manager for the Observer role.
*
* Connects to a WPA2-PSK (or open) network, obtains a DHCP lease, syncs
* time via SNTP, then signals g_wifi_events / WIFI_READY_BIT so the MQTT
* publisher thread can start. Auto-reconnects on link loss.
*/
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <zephyr/kernel.h>
#ifdef __cplusplus
extern "C" {
#endif
struct ObserverCreds;
/*
* Event object and bit shared with ZephyrMQTTPublisher so it can wait
* for WiFi+SNTP to be ready before connecting to the broker.
*/
extern struct k_event g_wifi_events;
#define WIFI_READY_BIT BIT(0) /* WiFi connected + DHCP + SNTP done */
#define WIFI_RECONNECT_BIT BIT(1) /* Internal: trigger reconnect */
/*
* Start the WiFi STA subsystem.
* creds — runtime credentials (SSID, PSK); pointer kept, must remain valid.
* time_sync_cb — called once after SNTP with Unix timestamp; may be NULL.
*
* Must be called once from main() before any other wifi_station_* calls.
*/
void zc_wifi_station_start(const struct ObserverCreds *creds,
void (*time_sync_cb)(uint32_t unix_ts));
/*
* Trigger an immediate reconnect (e.g. after credential change via CLI).
* Safe to call from any thread.
*/
void zc_wifi_station_reconnect(void);
/* Returns true when WiFi is connected AND DHCP+SNTP have completed. */
bool zc_wifi_station_is_connected(void);
/* Returns the SSID currently connected to, or an empty string. */
const char *zc_wifi_station_ssid(void);
#ifdef __cplusplus
}
#endif
+447
View File
@@ -0,0 +1,447 @@
/*
* SPDX-License-Identifier: Apache-2.0
* ObserverMesh — listen-only LoRa mesh node implementation.
*/
#include "ObserverMesh.h"
#include "observer_creds.h"
#include <mesh/Utils.h>
#include <mesh/LoRaConfig.h>
#include <adapters/radio/LoRaRadioBase.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(zephcore_observer, CONFIG_ZEPHCORE_OBSERVER_LOG_LEVEL);
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>
/* Forward declaration — implemented in ZephyrMQTTPublisher.c */
extern "C" {
void mqtt_publisher_enqueue(const char *topic, const char *payload, int payload_len);
bool mqtt_publisher_is_connected(void);
void mqtt_publisher_reconnect(void);
}
/* Forward declaration — implemented in ZephyrWiFiStation.c */
extern "C" {
bool zc_wifi_station_is_connected(void);
void zc_wifi_station_reconnect(void);
const char *zc_wifi_station_ssid(void);
}
namespace mesh {
/* ========== Construction ========== */
ObserverMesh::ObserverMesh(Radio &radio, MillisecondClock &ms, RNG &rng, RTCClock &rtc)
: Dispatcher(radio, ms, _pkt_mgr),
_last_rssi(0.0f), _last_score(0.0f), _last_raw_len(0),
_store(nullptr), _creds(nullptr), _rng(&rng), _rtc(&rtc)
{
memset(_pubkey_hex, 0, sizeof(_pubkey_hex));
memset(_packets_topic, 0, sizeof(_packets_topic));
memset(_status_topic, 0, sizeof(_status_topic));
}
/* ========== begin() ========== */
void ObserverMesh::begin(RepeaterDataStore *store, struct ObserverCreds *creds)
{
_store = store;
_creds = creds;
/* Initialize prefs with observer-specific defaults */
initNodePrefs(&_prefs);
_prefs.cr = 5; /* CR 4/5 */
_prefs.tx_power_dbm = 0; /* observer never TXes anyway */
/* freq=869.618, bw=62.5, sf=8 already set by initNodePrefs */
/* Load persisted prefs (overrides defaults with saved values) */
if (!_store->loadPrefs(_prefs)) {
/* First boot — save observer defaults */
_store->savePrefs(_prefs);
}
/* Load or generate node identity */
if (!_store->loadIdentity(_self_id)) {
LOG_INF("No identity found — generating new keypair");
int attempts = 0;
do {
_self_id = LocalIdentity(_rng);
attempts++;
} while (attempts < 10 &&
(_self_id.pub_key[0] == 0x00 || _self_id.pub_key[0] == 0xFF));
_store->saveIdentity(_self_id);
LOG_INF("New observer identity saved");
}
/* Build hex pubkey string */
Utils::toHex(_pubkey_hex, _self_id.pub_key, PUB_KEY_SIZE);
_pubkey_hex[PUB_KEY_SIZE * 2] = '\0';
/* Build MQTT topic strings */
buildTopics();
/* Log identity */
LOG_INF("Observer ID: %.16s...", _pubkey_hex);
/* Start radio in continuous RX mode */
Dispatcher::begin();
}
void ObserverMesh::buildTopics()
{
const char *iata = (_creds && _creds->mqtt_iata[0] != '\0')
? _creds->mqtt_iata : "XXX";
snprintf(_packets_topic, sizeof(_packets_topic),
"meshcore/%s/%s/packets", iata, _pubkey_hex);
snprintf(_status_topic, sizeof(_status_topic),
"meshcore/%s/%s/status", iata, _pubkey_hex);
}
/* ========== RX hooks ========== */
void ObserverMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len)
{
_last_rssi = rssi;
_last_raw_len = (len <= (int)sizeof(_last_raw)) ? len : (int)sizeof(_last_raw);
memcpy(_last_raw, raw, _last_raw_len);
}
void ObserverMesh::logRx(Packet *packet, int len, float score)
{
(void)packet; (void)len;
_last_score = score;
}
void ObserverMesh::enqueuePacket(Packet *pkt)
{
/* Compute packet hash (8 bytes → 16 hex chars) */
uint8_t hash_bytes[MAX_HASH_SIZE];
char hash_hex[MAX_HASH_SIZE * 2 + 1];
pkt->calculatePacketHash(hash_bytes);
Utils::toHex(hash_hex, hash_bytes, MAX_HASH_SIZE);
hash_hex[MAX_HASH_SIZE * 2] = '\0';
/* Encode raw wire bytes as hex */
/* Each byte → 2 hex chars; max MAX_TRANS_UNIT=255 bytes → 510 chars + NUL */
static char raw_hex[MAX_TRANS_UNIT * 2 + 1];
Utils::toHex(raw_hex, _last_raw, _last_raw_len);
raw_hex[_last_raw_len * 2] = '\0';
/* Get current timestamp from RTC */
uint32_t now_epoch = _rtc ? _rtc->getCurrentTime() : 0;
struct tm tm_now;
time_t t = (time_t)now_epoch;
gmtime_r(&t, &tm_now);
/* Format ISO 8601 timestamp (microseconds always 0 — RTC has 1s resolution) */
char ts_buf[48];
snprintf(ts_buf, sizeof(ts_buf),
"%04d-%02d-%02dT%02d:%02d:%02d.000000",
tm_now.tm_year + 1900, tm_now.tm_mon + 1, tm_now.tm_mday,
tm_now.tm_hour, tm_now.tm_min, tm_now.tm_sec);
/* Format time and date fields matching meshcoretomqtt */
char time_buf[12], date_buf[32];
snprintf(time_buf, sizeof(time_buf), "%02d:%02d:%02d",
tm_now.tm_hour, tm_now.tm_min, tm_now.tm_sec);
snprintf(date_buf, sizeof(date_buf), "%d/%d/%04d",
tm_now.tm_mday, tm_now.tm_mon + 1, tm_now.tm_year + 1900);
/* Route letter: "F" = flood/transport-flood, "D" = direct/transport-direct */
const char *route_str = pkt->isRouteDirect() ? "D" : "F";
/* score in meshcoretomqtt format: integer score * 1000 */
int score_int = (int)(_last_score * 1000.0f);
/* Build JSON payload matching meshcoretomqtt packet format */
static char json_buf[1024];
int json_len = snprintf(json_buf, sizeof(json_buf),
"{"
"\"type\":\"PACKET\","
"\"origin\":\"%s\","
"\"origin_id\":\"%s\","
"\"timestamp\":\"%s\","
"\"direction\":\"rx\","
"\"time\":\"%s\","
"\"date\":\"%s\","
"\"len\":\"%d\","
"\"packet_type\":\"%u\","
"\"route\":\"%s\","
"\"payload_len\":\"%u\","
"\"raw\":\"%s\","
"\"SNR\":\"%d\","
"\"RSSI\":\"%d\","
"\"score\":\"%d\","
"\"hash\":\"%s\""
"}",
_prefs.node_name,
_pubkey_hex,
ts_buf,
time_buf,
date_buf,
_last_raw_len,
(unsigned)pkt->getPayloadType(),
route_str,
(unsigned)pkt->payload_len,
raw_hex,
(int)pkt->getSNR(),
(int)_last_rssi,
score_int,
hash_hex);
if (json_len < 0 || json_len >= (int)sizeof(json_buf)) {
LOG_WRN("Packet JSON truncated (len=%d)", json_len);
json_len = (int)sizeof(json_buf) - 1;
}
mqtt_publisher_enqueue(_packets_topic, json_buf, json_len);
}
DispatcherAction ObserverMesh::onRecvPacket(Packet *pkt)
{
/* Publish every reception — no deduplication.
* The same flood packet heard from different repeaters is published
* separately, each with its own SNR/RSSI (propagation data). */
enqueuePacket(pkt);
return ACTION_RELEASE; /* never retransmit */
}
/* ========== Serial CLI ========== */
#define CLI_REPLY_SIZE 256
bool ObserverMesh::handleCLI(const char *command, char *reply, int reply_size)
{
reply[0] = '\0';
/* ---- help ---- */
if (strcmp(command, "help") == 0 || command[0] == '\0') {
return true; /* caller prints the banner */
}
/* ---- get commands ---- */
if (memcmp(command, "get ", 4) == 0) {
const char *key = command + 4;
if (strcmp(key, "role") == 0) {
snprintf(reply, reply_size, "observer");
} else if (strcmp(key, "name") == 0) {
snprintf(reply, reply_size, "%s", _prefs.node_name);
} else if (strcmp(key, "public.key") == 0) {
snprintf(reply, reply_size, "%s", _pubkey_hex);
} else if (strcmp(key, "board") == 0) {
#ifdef CONFIG_ZEPHCORE_BOARD_NAME
snprintf(reply, reply_size, "%s", CONFIG_ZEPHCORE_BOARD_NAME);
#else
snprintf(reply, reply_size, "unknown");
#endif
} else if (strcmp(key, "version") == 0) {
snprintf(reply, reply_size, "%s (%s)", FIRMWARE_VERSION, FIRMWARE_BUILD_DATE);
} else if (strcmp(key, "radio") == 0) {
snprintf(reply, reply_size,
"freq=%.3f bw=%.1f sf=%u cr=%u tx=%ddBm",
(double)_prefs.freq, (double)_prefs.bw,
_prefs.sf, _prefs.cr, _prefs.tx_power_dbm);
} else if (strcmp(key, "wifi.ssid") == 0) {
snprintf(reply, reply_size, "%s",
(_creds && _creds->wifi_ssid[0]) ? _creds->wifi_ssid : "(not set)");
} else if (strcmp(key, "wifi.status") == 0) {
snprintf(reply, reply_size, "%s",
zc_wifi_station_is_connected() ? "connected" : "disconnected");
} else if (strcmp(key, "mqtt.status") == 0) {
snprintf(reply, reply_size, "%s",
mqtt_publisher_is_connected() ? "connected" : "disconnected");
} else if (strcmp(key, "mqtt.host") == 0) {
snprintf(reply, reply_size, "%s",
(_creds && _creds->mqtt_host[0]) ? _creds->mqtt_host : "(not set)");
} else if (strcmp(key, "mqtt.user") == 0) {
snprintf(reply, reply_size, "%s",
(_creds && _creds->mqtt_user[0]) ? _creds->mqtt_user : "(not set)");
} else if (strcmp(key, "mqtt.iata") == 0) {
snprintf(reply, reply_size, "%s",
(_creds && _creds->mqtt_iata[0]) ? _creds->mqtt_iata : "(not set)");
} else if (strcmp(key, "mqtt.port") == 0) {
snprintf(reply, reply_size, "%u",
(_creds) ? (unsigned)_creds->mqtt_port : 8883u);
} else if (strcmp(key, "mqtt.tls") == 0) {
snprintf(reply, reply_size, "%u",
(_creds) ? (unsigned)_creds->mqtt_tls : 1u);
} else {
snprintf(reply, reply_size, "ERR unknown key: %s", key);
}
return false;
}
/* ---- set commands ---- */
if (memcmp(command, "set ", 4) == 0) {
const char *rest = command + 4;
/* Helper: find value after "key " */
auto find_val = [](const char *s, const char *prefix) -> const char * {
size_t n = strlen(prefix);
if (memcmp(s, prefix, n) == 0 && s[n] == ' ')
return s + n + 1;
return nullptr;
};
const char *val;
if ((val = find_val(rest, "name")) != nullptr) {
strncpy(_prefs.node_name, val, sizeof(_prefs.node_name) - 1);
_prefs.node_name[sizeof(_prefs.node_name) - 1] = '\0';
_store->savePrefs(_prefs);
snprintf(reply, reply_size, "name=%s", _prefs.node_name);
} else if ((val = find_val(rest, "freq")) != nullptr) {
float f = (float)atof(val);
/* Accept Hz (e.g. 869618000) or MHz (e.g. 869.618) */
if (f > 1000000.0f) f /= 1000000.0f;
if (f >= 150.0f && f <= 2500.0f) {
_prefs.freq = f;
_store->savePrefs(_prefs);
((LoRaRadioBase *)_radio)->reconfigureWithParams(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
snprintf(reply, reply_size, "freq=%.3f MHz", (double)_prefs.freq);
} else {
snprintf(reply, reply_size, "ERR freq out of range");
}
} else if ((val = find_val(rest, "sf")) != nullptr) {
int sf = atoi(val);
if (sf >= 7 && sf <= 12) {
_prefs.sf = (uint8_t)sf;
_store->savePrefs(_prefs);
((LoRaRadioBase *)_radio)->reconfigureWithParams(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
snprintf(reply, reply_size, "sf=%u", _prefs.sf);
} else {
snprintf(reply, reply_size, "ERR sf must be 7-12");
}
} else if ((val = find_val(rest, "bw")) != nullptr) {
/* Accept index (0=125, 1=250, 2=500, 3=62.5, 4=41.7, 5=31.25)
* or kHz value directly */
float bw;
int idx = atoi(val);
const float bw_table[] = { 125.0f, 250.0f, 500.0f, 62.5f, 41.7f, 31.25f };
if (idx >= 0 && idx <= 5) {
bw = bw_table[idx];
} else {
bw = (float)atof(val);
}
if (bw > 0.0f) {
_prefs.bw = bw;
_store->savePrefs(_prefs);
((LoRaRadioBase *)_radio)->reconfigureWithParams(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
snprintf(reply, reply_size, "bw=%.2f kHz", (double)_prefs.bw);
} else {
snprintf(reply, reply_size, "ERR invalid bw");
}
} else if ((val = find_val(rest, "cr")) != nullptr) {
int cr = atoi(val);
if (cr >= 5 && cr <= 8) {
_prefs.cr = (uint8_t)cr;
_store->savePrefs(_prefs);
((LoRaRadioBase *)_radio)->reconfigureWithParams(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
snprintf(reply, reply_size, "cr=%u", _prefs.cr);
} else {
snprintf(reply, reply_size, "ERR cr must be 5-8");
}
} else if (!_creds) {
snprintf(reply, reply_size, "ERR creds not initialized");
} else if ((val = find_val(rest, "wifi.ssid")) != nullptr) {
strncpy(_creds->wifi_ssid, val, sizeof(_creds->wifi_ssid) - 1);
_creds->wifi_ssid[sizeof(_creds->wifi_ssid) - 1] = '\0';
observer_creds_save(_creds, _store->getBasePath());
snprintf(reply, reply_size, "wifi.ssid=%s (reconnecting)", _creds->wifi_ssid);
zc_wifi_station_reconnect();
} else if ((val = find_val(rest, "wifi.psk")) != nullptr) {
strncpy(_creds->wifi_psk, val, sizeof(_creds->wifi_psk) - 1);
_creds->wifi_psk[sizeof(_creds->wifi_psk) - 1] = '\0';
observer_creds_save(_creds, _store->getBasePath());
snprintf(reply, reply_size, "wifi.psk=*** (saved, reconnecting)");
zc_wifi_station_reconnect();
} else if ((val = find_val(rest, "mqtt.host")) != nullptr) {
strncpy(_creds->mqtt_host, val, sizeof(_creds->mqtt_host) - 1);
_creds->mqtt_host[sizeof(_creds->mqtt_host) - 1] = '\0';
observer_creds_save(_creds, _store->getBasePath());
snprintf(reply, reply_size, "mqtt.host=%s (reconnecting)", _creds->mqtt_host);
mqtt_publisher_reconnect();
} else if ((val = find_val(rest, "mqtt.port")) != nullptr) {
int port = atoi(val);
if (port > 0 && port <= 65535) {
_creds->mqtt_port = (uint16_t)port;
observer_creds_save(_creds, _store->getBasePath());
snprintf(reply, reply_size, "mqtt.port=%u (reconnecting)", _creds->mqtt_port);
mqtt_publisher_reconnect();
} else {
snprintf(reply, reply_size, "ERR port must be 1-65535");
}
} else if ((val = find_val(rest, "mqtt.tls")) != nullptr) {
int tls = atoi(val);
_creds->mqtt_tls = (tls != 0) ? 1 : 0;
observer_creds_save(_creds, _store->getBasePath());
snprintf(reply, reply_size, "mqtt.tls=%u (reconnecting)", _creds->mqtt_tls);
mqtt_publisher_reconnect();
} else if ((val = find_val(rest, "mqtt.user")) != nullptr) {
strncpy(_creds->mqtt_user, val, sizeof(_creds->mqtt_user) - 1);
_creds->mqtt_user[sizeof(_creds->mqtt_user) - 1] = '\0';
observer_creds_save(_creds, _store->getBasePath());
snprintf(reply, reply_size, "mqtt.user=%s (reconnecting)", _creds->mqtt_user);
mqtt_publisher_reconnect();
} else if ((val = find_val(rest, "mqtt.password")) != nullptr) {
strncpy(_creds->mqtt_password, val, sizeof(_creds->mqtt_password) - 1);
_creds->mqtt_password[sizeof(_creds->mqtt_password) - 1] = '\0';
observer_creds_save(_creds, _store->getBasePath());
snprintf(reply, reply_size, "mqtt.password=*** (saved, reconnecting)");
mqtt_publisher_reconnect();
} else if ((val = find_val(rest, "mqtt.iata")) != nullptr) {
strncpy(_creds->mqtt_iata, val, sizeof(_creds->mqtt_iata) - 1);
_creds->mqtt_iata[sizeof(_creds->mqtt_iata) - 1] = '\0';
observer_creds_save(_creds, _store->getBasePath());
buildTopics(); /* rebuild topic strings with new IATA */
snprintf(reply, reply_size, "mqtt.iata=%s (topics updated, reconnecting)", _creds->mqtt_iata);
mqtt_publisher_reconnect();
} else {
snprintf(reply, reply_size, "ERR unknown key");
}
return false;
}
snprintf(reply, reply_size, "ERR unknown command (type 'help')");
return false;
}
} /* namespace mesh */
+85
View File
@@ -0,0 +1,85 @@
/*
* SPDX-License-Identifier: Apache-2.0
* ObserverMesh — listen-only LoRa mesh node.
*
* Extends mesh::Dispatcher directly (no routing, no flooding, no ACL).
* Every received packet is forwarded to the MQTT publisher queue.
* CLI handles WiFi/MQTT/radio configuration.
*/
#pragma once
#include <mesh/Dispatcher.h>
#include <mesh/StaticPoolPacketManager.h>
#include <mesh/Identity.h>
#include <mesh/RNG.h>
#include <mesh/RTC.h>
#include <helpers/NodePrefs.h>
#include "RepeaterDataStore.h"
#include "observer_creds.h"
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "v1.14.1-zephyr"
#endif
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE __DATE__
#endif
namespace mesh {
class ObserverMesh : public Dispatcher {
StaticPoolPacketManager _pkt_mgr;
/* Cached values set by logRxRaw / logRx before onRecvPacket */
float _last_rssi;
float _last_score;
uint8_t _last_raw[MAX_TRANS_UNIT + 1];
int _last_raw_len;
/* Identity and config */
LocalIdentity _self_id;
NodePrefs _prefs;
RepeaterDataStore *_store;
struct ObserverCreds *_creds;
RNG *_rng;
RTCClock *_rtc;
/* Pre-built MQTT topic strings (set in begin()) */
char _pubkey_hex[PUB_KEY_SIZE * 2 + 1]; /* 64 hex chars + NUL */
char _packets_topic[160];
char _status_topic[160];
/* Private helpers */
void buildTopics();
void enqueuePacket(Packet *pkt);
protected:
/* Capture RSSI + raw bytes before packet is parsed */
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
/* Capture score (called between logRxRaw and onRecvPacket) */
void logRx(Packet *packet, int len, float score) override;
/* Build JSON and enqueue to MQTT publisher */
DispatcherAction onRecvPacket(Packet *pkt) override;
public:
ObserverMesh(Radio &radio, MillisecondClock &ms, RNG &rng, RTCClock &rtc);
/* Initialize: load/generate identity, load/init prefs, start radio RX. */
void begin(RepeaterDataStore *store, struct ObserverCreds *creds);
/* Handle a single serial CLI command.
* reply is filled with the response string (CLI_REPLY_SIZE bytes).
* Returns true if the command was 'help' and the caller should print
* the full banner (too long for the reply buffer). */
bool handleCLI(const char *command, char *reply, int reply_size);
/* Accessors used by main_observer.cpp */
NodePrefs *getNodePrefs() { return &_prefs; }
const LocalIdentity &getSelfId() const { return _self_id; }
const char *getPacketsTopic() const { return _packets_topic; }
const char *getStatusTopic() const { return _status_topic; }
const char *getPubkeyHex() const { return _pubkey_hex; }
};
} /* namespace mesh */
+400
View File
@@ -0,0 +1,400 @@
/*
* SPDX-License-Identifier: Apache-2.0
* ZephCore Observer — listen-only LoRa node with WiFi+MQTT forwarding.
*
* Every received LoRa packet is published to an MQTT broker in
* meshcoretomqtt-compatible JSON format. All parameters (WiFi, MQTT,
* IATA, radio) are runtime-configurable via serial CLI and stored in
* LittleFS. Nothing is hardcoded.
*
* Event loop:
* LORA_RX → ObserverMesh::loop() → enqueuePacket() → mqtt_publisher_enqueue()
* CLI_RX → ObserverMesh::handleCLI() (set/get commands)
*/
#include <stdio.h>
#include <string.h>
#include <zephyr/kernel.h>
#include <zephyr/drivers/uart.h>
#include <zephyr/sys/ring_buffer.h>
#include <zephyr/fs/fs.h>
#include <zephyr/logging/log.h>
#define CLI_REPLY_SIZE 256
LOG_MODULE_REGISTER(zephcore_observer_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL);
#include <app/RepeaterDataStore.h>
#include <app/ObserverMesh.h>
#include <adapters/clock/ZephyrRTCClock.h>
#include <mesh/RadioIncludes.h>
#include <ZephyrWiFiStation.h>
#include <ZephyrMQTTPublisher.h>
#include "observer_creds.h"
/* ========== observer_creds_load / observer_creds_save ========== */
extern "C" bool observer_creds_load(struct ObserverCreds *creds,
const char *base_path)
{
char path[96];
snprintf(path, sizeof(path), "%s/obs_creds", base_path);
struct fs_file_t f;
fs_file_t_init(&f);
int rc = fs_open(&f, path, FS_O_READ);
if (rc < 0) {
LOG_DBG("obs_creds not found (%d) — using defaults", rc);
return false;
}
ssize_t n = fs_read(&f, creds, sizeof(*creds));
fs_close(&f);
if (n != (ssize_t)sizeof(*creds)) {
LOG_WRN("obs_creds truncated (%d/%d) — resetting", (int)n,
(int)sizeof(*creds));
memset(creds, 0, sizeof(*creds));
observer_creds_init(creds);
return false;
}
return true;
}
extern "C" bool observer_creds_save(const struct ObserverCreds *creds,
const char *base_path)
{
char path[96];
snprintf(path, sizeof(path), "%s/obs_creds", base_path);
struct fs_file_t f;
fs_file_t_init(&f);
int rc = fs_open(&f, path, FS_O_WRITE | FS_O_CREATE | FS_O_TRUNC);
if (rc < 0) {
LOG_ERR("Cannot open %s for write: %d", path, rc);
return false;
}
ssize_t n = fs_write(&f, creds, sizeof(*creds));
fs_close(&f);
if (n != (ssize_t)sizeof(*creds)) {
LOG_ERR("obs_creds write failed (%d/%d)", (int)n,
(int)sizeof(*creds));
return false;
}
return true;
}
/* ========== LED (optional) ========== */
#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios)
#include <zephyr/drivers/gpio.h>
#define LED0_NODE DT_ALIAS(led0)
static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
#endif
/* ========== Event loop bits ========== */
#define MESH_EVENT_LORA_RX BIT(0)
#define MESH_EVENT_CLI_RX BIT(1)
#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_CLI_RX)
static struct k_event mesh_events;
/* ========== USB serial CLI ========== */
#define USB_RING_BUF_SIZE 512
#define CLI_LINE_BUF_SIZE 256
static const struct device *usb_dev;
static uint8_t usb_ring_data[USB_RING_BUF_SIZE];
static struct ring_buf usb_ring_buf;
static char cli_line[CLI_LINE_BUF_SIZE];
static char cli_reply[CLI_REPLY_SIZE];
static uint16_t cli_line_idx;
static void cli_print(const char *s)
{
if (!usb_dev) return;
while (*s) uart_poll_out(usb_dev, *s++);
}
static void cli_println(const char *s)
{
cli_print(s);
cli_print("\r\n");
}
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 n = uart_fifo_read(dev, buf, sizeof(buf));
if (n > 0) {
ring_buf_put(&usb_ring_buf, buf, n);
k_event_post(&mesh_events, MESH_EVENT_CLI_RX);
}
}
}
}
/* ========== LoRa callbacks ========== */
static void lora_rx_callback(void *user_data)
{
ARG_UNUSED(user_data);
k_event_post(&mesh_events, MESH_EVENT_LORA_RX);
}
/* Observer never transmits — TX done callback not needed */
/* ========== Help banner ========== */
/* Forward references */
static mesh::ObserverMesh *s_mesh_ptr;
static struct ObserverCreds s_creds;
static void print_banner(void)
{
char line[192];
cli_println("\r\n=== ZephCore Observer ===");
if (s_mesh_ptr) {
const char *name = s_mesh_ptr->getNodePrefs()->node_name;
const char *key = s_mesh_ptr->getPubkeyHex();
snprintf(line, sizeof(line), "Node: %s", name);
cli_println(line);
snprintf(line, sizeof(line), "Key: %.32s...", key);
cli_println(line);
NodePrefs *p = s_mesh_ptr->getNodePrefs();
snprintf(line, sizeof(line),
"Radio: %.3f MHz BW%.1f SF%u CR%u TX%ddBm",
(double)p->freq, (double)p->bw,
p->sf, p->cr, p->tx_power_dbm);
cli_println(line);
}
cli_println("");
snprintf(line, sizeof(line), "WiFi: %-14s (SSID: %s)",
zc_wifi_station_is_connected() ? "CONNECTED" : "DISCONNECTED",
s_creds.wifi_ssid[0] ? s_creds.wifi_ssid : "not set");
cli_println(line);
snprintf(line, sizeof(line), "MQTT: %-14s (host: %s)",
mqtt_publisher_is_connected() ? "CONNECTED" : "DISCONNECTED",
s_creds.mqtt_host[0] ? s_creds.mqtt_host : "not set");
cli_println(line);
snprintf(line, sizeof(line), "IATA: %s",
s_creds.mqtt_iata[0] ? s_creds.mqtt_iata : "not set");
cli_println(line);
cli_println("");
cli_println("--- Configure ---");
cli_println("set wifi.ssid <name> WiFi network name");
cli_println("set wifi.psk <password> WiFi password (empty = open)");
cli_println("set mqtt.host <hostname> MQTT broker hostname");
cli_println("set mqtt.port <port> MQTT broker port (default 8883)");
cli_println("set mqtt.tls <0|1> TLS on/off (default 1)");
cli_println("set mqtt.user <username> MQTT username");
cli_println("set mqtt.password <pass> MQTT password");
cli_println("set mqtt.iata <code> Location code (e.g. BUD BTS VIE SEA)");
cli_println("set name <name> Node display name");
cli_println("set freq <MHz|Hz> LoRa frequency (e.g. 869.618 or 869618000)");
cli_println("set sf <7-12> Spreading factor");
cli_println("set bw <idx> Bandwidth: 3=62.5 0=125 1=250 2=500 kHz");
cli_println("set cr <5-8> Coding rate");
cli_println("");
cli_println("--- Query ---");
cli_println("get wifi.status WiFi connection state");
cli_println("get mqtt.status MQTT connection state");
cli_println("get radio LoRa radio parameters");
cli_println("help Show this screen");
cli_println("=========================");
}
/* ========== CLI RX processing ========== */
static void process_cli_rx(void)
{
uint8_t byte;
while (ring_buf_get(&usb_ring_buf, &byte, 1) == 1) {
if (byte == '\r' || byte == '\n') {
if (cli_line_idx > 0) {
cli_line[cli_line_idx] = '\0';
LOG_DBG("CLI: %s", cli_line);
cli_reply[0] = '\0';
bool want_banner = false;
if (s_mesh_ptr) {
want_banner = s_mesh_ptr->handleCLI(
cli_line, cli_reply,
sizeof(cli_reply));
}
if (want_banner) {
print_banner();
} else if (cli_reply[0] != '\0') {
cli_print("\r\n -> ");
cli_println(cli_reply);
}
cli_line_idx = 0;
}
cli_print("\r\n");
} else if (byte == 0x7F || byte == 0x08) {
if (cli_line_idx > 0) {
cli_line_idx--;
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) - 1) {
uart_poll_out(usb_dev, byte);
cli_line[cli_line_idx++] = (char)byte;
}
}
}
/* ========== Time sync callback ========== */
static mesh::ZephyrRTCClock s_rtc_clock;
static void time_sync_cb(uint32_t unix_ts)
{
s_rtc_clock.setCurrentTime(unix_ts);
LOG_INF("RTC synced from SNTP: %u", unix_ts);
}
/* ========== Global instances ========== */
static mesh::ZephyrBoard s_board;
static mesh::ZephyrMillisecondClock s_ms_clock;
static mesh::ZephyrRNG s_rng;
static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0));
/* Radio prefs — observer-specific defaults set in main() */
static NodePrefs s_radio_prefs;
#if IS_ENABLED(CONFIG_ZEPHCORE_RADIO_LR1110)
static mesh::LR1110Radio lora_radio(lora_dev, s_board, &s_radio_prefs);
#else
static mesh::SX126xRadio lora_radio(lora_dev, s_board, &s_radio_prefs);
#endif
static mesh::ObserverMesh observer_mesh(lora_radio, s_ms_clock, s_rng, s_rtc_clock);
static RepeaterDataStore data_store;
/* ========== main() ========== */
int main(void)
{
/* Initialize radio prefs with observer-specific defaults */
initNodePrefs(&s_radio_prefs);
s_radio_prefs.cr = 5; /* CR 4/5 (initNodePrefs sets 8) */
s_radio_prefs.tx_power_dbm = 0; /* observer never TXes */
strncpy(s_radio_prefs.node_name, "Observer",
sizeof(s_radio_prefs.node_name) - 1);
/* Brief boot delay — lets USB enumerate before first log */
k_sleep(K_MSEC(1500));
LOG_INF("=== ZephCore Observer starting ===");
/* Configure LED */
#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios)
if (gpio_is_ready_dt(&led0)) {
gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE);
}
#endif
/* Initialize LittleFS data store */
if (!data_store.begin()) {
LOG_ERR("RepeaterDataStore init failed");
}
/* Load observer credentials (WiFi, MQTT, IATA) */
memset(&s_creds, 0, sizeof(s_creds));
observer_creds_init(&s_creds);
observer_creds_load(&s_creds, data_store.getBasePath());
/* Init event object BEFORE any callbacks */
k_event_init(&mesh_events);
/* LoRa RX callback — observer never needs TX done */
lora_radio.setRxCallback(lora_rx_callback, nullptr);
/* Initialize and start mesh (loads prefs + identity from flash) */
s_mesh_ptr = &observer_mesh;
observer_mesh.begin(&data_store, &s_creds);
/* Generate a default node name based on pubkey if still generic */
NodePrefs *prefs = observer_mesh.getNodePrefs();
if (strlen(prefs->node_name) == 0 ||
strcmp(prefs->node_name, "Observer") == 0 ||
strcmp(prefs->node_name, "Repeater") == 0) {
/* Use first 4 bytes of pubkey for uniqueness */
const char *hex = observer_mesh.getPubkeyHex();
snprintf(prefs->node_name, sizeof(prefs->node_name),
"Observer-%.8s", hex);
data_store.savePrefs(*prefs);
}
/* Initialize USB serial for CLI */
#if DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart)
usb_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart);
#else
/* ESP32 native USB or other console UART */
usb_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console));
#endif
if (device_is_ready(usb_dev)) {
ring_buf_init(&usb_ring_buf, sizeof(usb_ring_data), usb_ring_data);
uart_irq_callback_set(usb_dev, cli_uart_isr);
uart_irq_rx_enable(usb_dev);
LOG_INF("Serial CLI ready: %s", usb_dev->name);
} else {
LOG_WRN("Serial device not ready — CLI unavailable");
usb_dev = nullptr;
}
/* Print welcome banner */
print_banner();
/* Start WiFi (non-blocking — MQTT thread waits for WIFI_READY_BIT) */
zc_wifi_station_start(&s_creds, time_sync_cb);
/* Start MQTT publisher thread */
char client_id[64];
snprintf(client_id, sizeof(client_id), "%s", prefs->node_name);
mqtt_publisher_start(&s_creds, client_id,
observer_mesh.getStatusTopic(),
observer_mesh.getPacketsTopic());
LOG_INF("Observer event loop running");
/* ========== Event loop ========== */
for (;;) {
uint32_t ev = k_event_wait(&mesh_events, MESH_EVENT_ALL,
false, K_FOREVER);
k_event_clear(&mesh_events, ev);
if (ev & MESH_EVENT_LORA_RX) {
observer_mesh.loop();
}
if (ev & MESH_EVENT_CLI_RX) {
process_cli_rx();
}
}
return 0;
}
+46
View File
@@ -0,0 +1,46 @@
/*
* SPDX-License-Identifier: Apache-2.0
* Observer runtime credentials — stored in LittleFS, configured via serial CLI.
*
* All connection parameters are runtime-configurable so no credentials ever
* appear in the codebase. File path: /lfs/repeater/obs_creds
*/
#pragma once
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
struct ObserverCreds {
char wifi_ssid[64]; /* WiFi network name */
char wifi_psk[64]; /* WiFi password (empty = open network) */
char mqtt_host[128]; /* MQTT broker hostname, e.g. "abydos.hu" */
uint16_t mqtt_port; /* MQTT broker port, e.g. 8883 */
uint8_t mqtt_tls; /* 1 = TLS (no cert verify), 0 = plaintext */
char mqtt_user[64]; /* MQTT username */
char mqtt_password[64]; /* MQTT password */
char mqtt_iata[8]; /* IATA location code, e.g. "BUD", "BTS", "VIE" */
uint8_t _reserved[5]; /* alignment / future use */
};
/* Load creds from /lfs/repeater/obs_creds.
* Returns true on success; on failure fills struct with safe zero defaults. */
bool observer_creds_load(struct ObserverCreds *creds, const char *base_path);
/* Save creds to /lfs/repeater/obs_creds. Returns true on success. */
bool observer_creds_save(const struct ObserverCreds *creds, const char *base_path);
/* Apply sensible defaults to a freshly-zeroed creds struct. */
static inline void observer_creds_init(struct ObserverCreds *creds)
{
creds->mqtt_port = 8883;
creds->mqtt_tls = 1;
}
#ifdef __cplusplus
}
#endif
+71
View File
@@ -0,0 +1,71 @@
# SPDX-License-Identifier: Apache-2.0
# Observer role configuration — listen-only LoRa node with WiFi+MQTT forwarding.
# All parameters (WiFi SSID/PSK, MQTT host/port/TLS/user/pass, IATA location code)
# are configured at runtime via serial CLI and stored in LittleFS.
#
# Build example:
# west build -b xiao_esp32c3 zephcore --pristine -- \
# -DEXTRA_CONF_FILE="boards/common/observer.conf"
CONFIG_ZEPHCORE_ROLE_OBSERVER=y
# No BLE — observer is serial-CLI-only
CONFIG_BT=n
CONFIG_ENTROPY_GENERATOR=y
# =========================================================
# IPv4 networking stack (WiFi STA client mode)
# =========================================================
CONFIG_NETWORKING=y
CONFIG_NET_IPV4=y
CONFIG_NET_IPV6=n
CONFIG_NET_TCP=y
CONFIG_NET_UDP=y
CONFIG_NET_SOCKETS=y
CONFIG_NET_SOCKETS_SOCKOPT_TLS=y
CONFIG_NET_MAX_CONTEXTS=8
CONFIG_NET_MGMT=y
CONFIG_NET_MGMT_EVENT=y
CONFIG_NET_MGMT_EVENT_STACK_SIZE=4096
CONFIG_DNS_RESOLVER=y
CONFIG_DNS_SERVER_IP_ADDRESSES=y
# WiFi STA (client — NOT access point)
CONFIG_WIFI=y
CONFIG_NET_L2_WIFI_MGMT=y
CONFIG_NET_L2_ETHERNET=y
CONFIG_NET_DHCPV4=y
# =========================================================
# MQTT with TLS (no cert verification — Zephyr has no CA bundle)
# =========================================================
CONFIG_MQTT_LIB=y
CONFIG_MQTT_LIB_TLS=y
CONFIG_MQTT_KEEPALIVE=60
# mbedTLS — TLS transport encryption without cert pinning
CONFIG_MBEDTLS=y
CONFIG_MBEDTLS_BUILTIN=y
CONFIG_MBEDTLS_ENABLE_HEAP=y
CONFIG_MBEDTLS_HEAP_SIZE=50000
CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=4096
CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION=y
# TLS 1.2 ciphersuite (auto-selects KEY_EXCHANGE + PSA_WANT_* dependencies)
CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256=y
CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256=y
# TLS 1.2 only (TLS 1.3 causes handshake_failure on most MQTT brokers)
# =========================================================
# SNTP time synchronization after WiFi connect
# =========================================================
CONFIG_SNTP=y
# =========================================================
# Stack / heap sizing for networking
# =========================================================
CONFIG_MAIN_STACK_SIZE=4096
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096
CONFIG_NET_RX_STACK_SIZE=2048
# USB serial for CLI (ESP32 uses native usb_serial, not CDC ACM)
CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT=n