From 2f863105005c82f6857a4bedab272022d666c09a Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:38:57 +0200 Subject: [PATCH] port lora e5 mini --- zephcore/CMakeLists.txt | 11 +- .../adapters/transport/LinuxTCPTransport.c | 23 +- .../transport/SerialCompanionTransport.c | 478 ++++++++++++++++++ .../adapters/transport/companion_framing.h | 38 ++ zephcore/app/CompanionMesh.cpp | 11 +- zephcore/boards/common/stm32wl_common.conf | 55 ++ zephcore/boards/example_board/README.md | 33 ++ .../boards/stm32wl/lora_e5_mini/board.conf | 33 ++ .../boards/stm32wl/lora_e5_mini/board.overlay | 54 ++ zephcore/include/mesh/MeshCore.h | 4 + zephcore/include/mesh/RNG.h | 4 + zephcore/include/mesh/stm32_cmsis_fixup.h | 47 ++ .../zephyr/0003-lora-sx126x-native.patch | 4 +- 13 files changed, 776 insertions(+), 19 deletions(-) create mode 100644 zephcore/adapters/transport/SerialCompanionTransport.c create mode 100644 zephcore/adapters/transport/companion_framing.h create mode 100644 zephcore/boards/common/stm32wl_common.conf create mode 100644 zephcore/boards/stm32wl/lora_e5_mini/board.conf create mode 100644 zephcore/boards/stm32wl/lora_e5_mini/board.overlay create mode 100644 zephcore/include/mesh/stm32_cmsis_fixup.h diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index fd9285d..309d57d 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -281,6 +281,8 @@ elseif(BOARD MATCHES ".*mg24.*" OR BOARD MATCHES ".*efr32.*") set(ZEPHCORE_PLATFORM_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/mg24_common.conf") elseif(DEFINED BOARD_QUALIFIERS AND BOARD_QUALIFIERS MATCHES ".*mg24.*") set(ZEPHCORE_PLATFORM_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/mg24_common.conf") +elseif(BOARD MATCHES "lora_e5" OR BOARD MATCHES ".*stm32wl.*") + set(ZEPHCORE_PLATFORM_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/stm32wl_common.conf") elseif(BOARD MATCHES "native_sim" OR BOARD MATCHES "native_posix") set(ZEPHCORE_PLATFORM_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/linux_native/linux_common.conf") else() @@ -658,7 +660,8 @@ else() helpers/ui/ui_mesh_actions.cpp app/CompanionMesh.cpp ) - # Companion transport: TCP socket on native Linux, BLE NUS on MCU builds. + # Companion transport: TCP socket on native Linux, serial (UART) on boards + # with no Bluetooth controller (e.g. STM32WL), BLE NUS everywhere else. if(CONFIG_ZEPHCORE_TRANSPORT_TCP) message(STATUS "ZephCore Companion Transport: TCP (Linux)") target_sources(app PRIVATE adapters/transport/LinuxTCPTransport.c) @@ -669,6 +672,12 @@ else() target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/helpers/ui ) + elseif(NOT CONFIG_BT) + # No BLE controller (STM32WL) — companion speaks MeshCore serial framing + # over the chosen console UART. SerialCompanionTransport.c is a full + # drop-in zephcore_ble_* provider, just like LinuxTCPTransport.c. + message(STATUS "ZephCore Companion Transport: Serial (UART)") + target_sources(app PRIVATE adapters/transport/SerialCompanionTransport.c) else() message(STATUS "ZephCore Companion Transport: BLE NUS") target_sources(app PRIVATE adapters/ble/ZephyrBLE.cpp) diff --git a/zephcore/adapters/transport/LinuxTCPTransport.c b/zephcore/adapters/transport/LinuxTCPTransport.c index 75c74c7..1a1aa6f 100644 --- a/zephcore/adapters/transport/LinuxTCPTransport.c +++ b/zephcore/adapters/transport/LinuxTCPTransport.c @@ -38,6 +38,7 @@ #include #include "ZephyrBLE.h" +#include "companion_framing.h" LOG_MODULE_REGISTER(linux_tcp_transport, LOG_LEVEL_INF); @@ -45,9 +46,9 @@ LOG_MODULE_REGISTER(linux_tcp_transport, LOG_LEVEL_INF); #define LISTEN_PORT CONFIG_ZEPHCORE_LINUX_TCP_PORT #define LISTEN_BACKLOG 1 -/* TX congestion control (mirrors ZephyrBLE.cpp). Push codes >= 0x80 are - * lossy event signals; protocol response frames < 0x80 are lossless. */ -#define PUSH_CODE_BASE 0x80 +/* TX congestion control (mirrors ZephyrBLE.cpp). Lossy (>= 0x80) vs lossless + * (< 0x80) classification is shared via companion_is_lossless_protocol_frame() + * in companion_framing.h. */ #define TX_OVERFLOW_RETRY_MS 250 /* Bound on a single blocking send. tx_drain_work_fn runs on the system @@ -57,10 +58,8 @@ LOG_MODULE_REGISTER(linux_tcp_transport, LOG_LEVEL_INF); * let the app reconnect rather than stall the node. */ #define TX_SEND_TIMEOUT_SEC 2 -struct frame { - uint16_t len; - uint8_t buf[MAX_FRAME_SIZE]; -}; +/* struct frame + framing constants + lossless classification are shared via + * companion_framing.h (included above). */ K_MSGQ_DEFINE(ble_send_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); K_MSGQ_DEFINE(ble_recv_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); @@ -104,14 +103,6 @@ static bool overflow_pending; static void overflow_retry_work_fn(struct k_work *work); static K_WORK_DELAYABLE_DEFINE(overflow_retry_work, overflow_retry_work_fn); -static bool is_lossless_protocol_frame(const uint8_t *data, uint16_t len) -{ - if (!data || len == 0) { - return false; - } - return data[0] < PUSH_CODE_BASE; -} - static void close_client_locked(void) { if (client_fd >= 0) { @@ -469,7 +460,7 @@ size_t zephcore_ble_send(const uint8_t *data, uint16_t len) /* Lossless protocol response frames are never dropped — report * failure so the caller retries instead of clobbering overflow. */ - if (is_lossless_protocol_frame(data, len)) { + if (companion_is_lossless_protocol_frame(data, len)) { LOG_DBG("TX queue full for lossless frame hdr=0x%02x, retry later", data[0]); return 0; diff --git a/zephcore/adapters/transport/SerialCompanionTransport.c b/zephcore/adapters/transport/SerialCompanionTransport.c new file mode 100644 index 0000000..e1b4e1f --- /dev/null +++ b/zephcore/adapters/transport/SerialCompanionTransport.c @@ -0,0 +1,478 @@ +/* + * SPDX-License-Identifier: MIT + * ZephCore serial (UART) companion transport — drop-in zephcore_ble_* provider. + * + * For boards with NO Bluetooth and NO USB device controller (e.g. the Seeed + * LoRa-E5 / STM32WL family), the companion app talks to the node over a plain + * UART — on the LoRa-E5 mini that UART is bridged to USB-C by an onboard + * USB-UART chip, so the official MeshCore serial client connects to it exactly + * like a native-USB companion. + * + * This file is the UART analogue of adapters/transport/LinuxTCPTransport.c: it + * implements the full zephcore_ble_* C API (adapters/ble/ZephyrBLE.h) so the + * companion application is transport-agnostic. It is compiled INSTEAD of + * ZephyrBLE.cpp when CONFIG_BT=n and CONFIG_ZEPHCORE_TRANSPORT_TCP=n + * (see the companion-transport selection in CMakeLists.txt). The MeshCore + * '<'/'>' framing + struct frame + congestion classification are shared with + * the TCP transport via companion_framing.h. + * + * I/O is interrupt-driven (uart_irq) on both directions so a contact-sync burst + * (many ~148B frames back-to-back) drains at full line rate with real + * back-pressure instead of blocking the system workqueue on uart_poll_out. + * + * Session model: a UART has no connect/disconnect line event (no DTR — the + * official client never asserts it), and a connected companion may sit idle for + * many minutes between frames. We therefore mark "connected" on the first + * inbound frame (fires on_connected once, lights the UI indicator) and STAY + * connected; we never infer a disconnect from idleness. Per-session reset on a + * reconnecting client is driven by the protocol's CMD_APP_START handler, not by + * any inactivity timer. The only timeout here is a parser-level resync for a + * partial frame that never completes (below) — it touches the RX byte parser + * only, never the session/connection state. + */ + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "ZephyrBLE.h" +#include "companion_framing.h" + +LOG_MODULE_REGISTER(serial_companion, LOG_LEVEL_INF); + +#define FRAME_QUEUE_SIZE CONFIG_ZEPHCORE_BLE_QUEUE_SIZE +#define TX_RING_SIZE 2048 /* ~13 contact frames of headroom */ +#define TX_OVERFLOW_RETRY_MS 250 + +/* Parser-only resync: if a frame has STARTED ('<' seen) but its payload never + * completes within this window, the next inbound byte resets the parser to + * IDLE so a lost/truncated frame can't swallow the following frame. This is NOT + * a session/idle timeout — an idle-but-connected companion sits in RX_IDLE + * between frames and never arms this, and it never affects connection state. */ +#define FRAME_PARTIAL_TIMEOUT_MS 2000 + +/* Companion UART — the chosen console UART (USART1 on the LoRa-E5 mini). */ +static const struct device *uart_dev; + +K_MSGQ_DEFINE(ble_send_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); +K_MSGQ_DEFINE(ble_recv_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); + +static const struct ble_callbacks *transport_cbs; +static enum zephcore_iface active_iface = ZEPHCORE_IFACE_NONE; +static bool transport_enabled; +static uint32_t passkey; + +/* ---- RX byte-assembly state (runs in UART ISR) ---------------------------- */ +enum rx_state { + RX_IDLE = 0, /* waiting for '<' */ + RX_LEN_LO, + RX_LEN_HI, + RX_PAYLOAD, +}; +static enum rx_state rx_st; +static uint16_t rx_idx; +static uint16_t rx_expect; +static uint32_t rx_frame_start; /* k_uptime_get_32() when the '<' arrived */ +static struct frame rx_frame; + +/* ---- TX side: interrupt-driven ring (whole frames committed atomically) ---- */ +static uint8_t tx_ring_data[TX_RING_SIZE]; +static struct ring_buf tx_ring; +static struct k_spinlock tx_lock; + +/* Order-preserving holdover: when the ring is momentarily full, the popped + * frame is parked here (NOT requeued at the tail, which would reorder it behind + * later frames) and re-tried first on the next drain. Touched only by + * tx_drain_work_fn, which runs solely on the system workqueue (never concurrent + * with itself), so it needs no lock. */ +static struct frame held_frame; +static bool held_pending; + +/* ---- TX queue drain (moves frames from ble_send_queue into the TX ring) ---- + * Runs on the system workqueue; never blocks on the wire (the ISR does the + * byte-level draining), so a full sync burst can't starve the workqueue. */ +static void tx_drain_work_fn(struct k_work *work); +static K_WORK_DEFINE(tx_drain_work, tx_drain_work_fn); + +/* on_connected is deferred off the ISR onto the system workqueue (it ends up + * in ui_notify(), which is not guaranteed ISR-safe). */ +static void connect_work_fn(struct k_work *work); +static K_WORK_DEFINE(connect_work, connect_work_fn); + +/* ---- TX congestion control (mirrors LinuxTCPTransport.c / ZephyrBLE.cpp) ---- */ +static bool tx_congested; +static struct frame overflow_frame; +static bool overflow_pending; +static void overflow_retry_work_fn(struct k_work *work); +static K_WORK_DELAYABLE_DEFINE(overflow_retry_work, overflow_retry_work_fn); + +static void connect_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + LOG_INF("serial companion session started"); + if (transport_cbs && transport_cbs->on_connected) { + transport_cbs->on_connected(); + } +} + +/* Mark the session connected on first inbound traffic (fires on_connected once + * — the UI "connected" indicator). Called from the UART ISR, so the callback is + * deferred to a workqueue rather than run inline. */ +static void mark_connected(void) +{ + if (active_iface != ZEPHCORE_IFACE_NONE) { + return; + } + active_iface = ZEPHCORE_IFACE_BLE; /* reuse the BLE path in main_companion */ + k_work_submit(&connect_work); +} + +/* ---- UART ISR: drains RX into the frame parser, fills TX from the ring ----- */ +static void 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)); + + /* Parser-only resync: drop a partial frame that stalled + * mid-payload so it can't consume the bytes of the next + * frame. Connection state is untouched. */ + if (rx_st != RX_IDLE && + (k_uptime_get_32() - rx_frame_start) > FRAME_PARTIAL_TIMEOUT_MS) { + rx_st = RX_IDLE; + } + + for (int i = 0; i < n; i++) { + uint8_t b = buf[i]; + + switch (rx_st) { + case RX_IDLE: + if (b == COMPANION_FRAME_RX_SYNC) { + rx_st = RX_LEN_LO; + rx_frame_start = k_uptime_get_32(); + } + break; + case RX_LEN_LO: + rx_expect = b; + rx_st = RX_LEN_HI; + break; + case RX_LEN_HI: + rx_expect |= ((uint16_t)b) << 8; + rx_idx = 0; + if (rx_expect == 0 || rx_expect > MAX_FRAME_SIZE) { + rx_st = RX_IDLE; /* bad length, resync */ + } else { + rx_st = RX_PAYLOAD; + } + break; + default: /* RX_PAYLOAD */ + rx_frame.buf[rx_idx++] = b; + if (rx_idx >= rx_expect) { + rx_frame.len = rx_expect; + mark_connected(); + /* on_rx_frame queues to ble_recv_queue and + * wakes the mesh thread (ISR-safe: K_NO_WAIT + * k_msgq_put + k_event_post), same as + * ZephyrBLE's secure_nus_rx_write. */ + if (transport_cbs && transport_cbs->on_rx_frame) { + transport_cbs->on_rx_frame(rx_frame.buf, + rx_frame.len); + } + rx_st = RX_IDLE; + } + break; + } + } + } + + if (uart_irq_tx_ready(dev)) { + uint8_t *out; + bool empty; + k_spinlock_key_t key = k_spin_lock(&tx_lock); + uint32_t claimed = ring_buf_get_claim(&tx_ring, &out, 64); + + if (claimed > 0) { + int sent = uart_fifo_fill(dev, out, claimed); + ring_buf_get_finish(&tx_ring, sent > 0 ? sent : 0); + } + empty = ring_buf_is_empty(&tx_ring); + if (empty) { + /* Disable inside the lock so a concurrent enqueue + * can't be stranded (it re-enables after its put). */ + uart_irq_tx_disable(dev); + } + k_spin_unlock(&tx_lock, key); + + if (empty) { + /* Ring drained — pull the next batch from the queue. + * tx_drain_work fires on_tx_idle when fully drained. */ + k_work_submit(&tx_drain_work); + } + } + } +} + +/* Queue one framed frame into the TX ring. Returns true if it fit (committed + * atomically — header + payload together — so frames never tear). */ +static bool tx_ring_put_frame(const struct frame *f) +{ + uint8_t hdr[3] = { + COMPANION_FRAME_TX_SYNC, + (uint8_t)(f->len & 0xFF), + (uint8_t)((f->len >> 8) & 0xFF), + }; + size_t total = sizeof(hdr) + f->len; + + k_spinlock_key_t key = k_spin_lock(&tx_lock); + if (ring_buf_space_get(&tx_ring) < total) { + k_spin_unlock(&tx_lock, key); + return false; + } + ring_buf_put(&tx_ring, hdr, sizeof(hdr)); + ring_buf_put(&tx_ring, f->buf, f->len); + k_spin_unlock(&tx_lock, key); + + uart_irq_tx_enable(uart_dev); + return true; +} + +static void tx_drain_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + + struct frame f; + + /* Re-try the order-preserving holdover first. If it still doesn't fit, + * leave it parked — the TX ISR re-submits this work when the ring drains. */ + if (held_pending) { + if (!tx_ring_put_frame(&held_frame)) { + return; + } + held_pending = false; + } + + /* Move queued frames into the TX ring while it has room. On ring-full, + * park the popped frame in the single holdover slot (preserving FIFO + * order) rather than requeuing it behind later frames. */ + while (k_msgq_get(&ble_send_queue, &f, K_NO_WAIT) == 0) { + if (!tx_ring_put_frame(&f)) { + held_frame = f; + held_pending = true; + return; + } + + /* Clear congestion at the 1/3 low-water mark (hysteresis). */ + if (tx_congested && + k_msgq_num_used_get(&ble_send_queue) <= FRAME_QUEUE_SIZE / 3) { + tx_congested = false; + LOG_INF("tx: congestion cleared"); + } + } + + /* Queue + holdover fully drained into the ring. Fire on_tx_idle only once + * the wire is also idle (ring empty) so the contact pump paces to real + * throughput. */ + if (transport_cbs && transport_cbs->on_tx_idle && !held_pending) { + bool ring_empty; + k_spinlock_key_t key = k_spin_lock(&tx_lock); + ring_empty = ring_buf_is_empty(&tx_ring); + k_spin_unlock(&tx_lock, key); + if (ring_empty) { + transport_cbs->on_tx_idle(); + } + } +} + +static void overflow_retry_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + + if (!overflow_pending) { + return; + } + if (k_msgq_put(&ble_send_queue, &overflow_frame, K_NO_WAIT) == 0) { + overflow_pending = false; + k_work_submit(&tx_drain_work); + } else { + k_work_schedule(&overflow_retry_work, K_MSEC(TX_OVERFLOW_RETRY_MS)); + } +} + +/* ========== Public API (matches ZephyrBLE.h) ========== */ + +void zephcore_ble_init(const struct ble_callbacks *cbs) +{ + transport_cbs = cbs; + transport_enabled = true; + ring_buf_init(&tx_ring, sizeof(tx_ring_data), tx_ring_data); +} + +void zephcore_ble_start(const char *device_name) +{ + ARG_UNUSED(device_name); + + uart_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console)); + if (!device_is_ready(uart_dev)) { + LOG_ERR("companion UART not ready"); + uart_dev = NULL; + return; + } + + uart_irq_callback_set(uart_dev, uart_isr); + uart_irq_rx_enable(uart_dev); + LOG_INF("serial companion transport ready on %s", uart_dev->name); +} + +size_t zephcore_ble_send(const uint8_t *data, uint16_t len) +{ + if (!uart_dev || len == 0 || len > MAX_FRAME_SIZE) { + return 0; + } + + struct frame f; + + f.len = len; + memcpy(f.buf, data, len); + + if (k_msgq_put(&ble_send_queue, &f, K_NO_WAIT) != 0) { + /* Queue full — congestion mode (mirrors LinuxTCPTransport.c). */ + if (!tx_congested) { + LOG_WRN("TX queue full (%u/%u), entering congestion", + k_msgq_num_used_get(&ble_send_queue), + (unsigned)FRAME_QUEUE_SIZE); + tx_congested = true; + } + /* Lossless protocol responses are never dropped — report failure + * so the caller retries instead of clobbering overflow. */ + if (companion_is_lossless_protocol_frame(data, len)) { + return 0; + } + if (overflow_pending) { + LOG_WRN("overflow full, dropping push hdr=0x%02x", data[0]); + return 0; + } + overflow_frame = f; + overflow_pending = true; + k_work_schedule(&overflow_retry_work, K_MSEC(TX_OVERFLOW_RETRY_MS)); + return len; + } + + k_work_submit(&tx_drain_work); + return len; +} + +void zephcore_ble_set_enabled(bool enable) +{ + transport_enabled = enable; + if (!uart_dev) { + return; + } + /* Actually gate the link, not just a flag: stop accepting frames when + * disabled (mirrors the TCP transport closing its socket). */ + if (enable) { + uart_irq_rx_enable(uart_dev); + } else { + uart_irq_rx_disable(uart_dev); + } +} + +bool zephcore_ble_is_enabled(void) +{ + return transport_enabled; +} + +bool zephcore_ble_is_active(void) +{ + return uart_dev != NULL && active_iface == ZEPHCORE_IFACE_BLE; +} + +bool zephcore_ble_is_connected(void) +{ + return active_iface == ZEPHCORE_IFACE_BLE; +} + +bool zephcore_ble_is_congested(void) +{ + return tx_congested; +} + +bool zephcore_ble_is_advertising(void) +{ + /* A UART is "always advertising" once the transport is up — keeps the + * companion housekeeping loop from trying to (re)start advertising. */ + return uart_dev != NULL; +} + +void zephcore_ble_set_passkey(uint32_t pk) +{ + passkey = pk; +} + +uint32_t zephcore_ble_get_passkey(void) +{ + return passkey; +} + +enum zephcore_iface zephcore_ble_get_active_iface(void) +{ + return active_iface; +} + +void zephcore_ble_set_active_iface(enum zephcore_iface iface) +{ + active_iface = iface; +} + +bool zephcore_ble_iface_try_claim(enum zephcore_iface who) +{ + if (active_iface == ZEPHCORE_IFACE_NONE || active_iface == who) { + active_iface = who; + return true; + } + return false; +} + +struct k_msgq *zephcore_ble_get_recv_queue(void) +{ + return &ble_recv_queue; +} + +struct k_msgq *zephcore_ble_get_send_queue(void) +{ + return &ble_send_queue; +} + +void zephcore_ble_kick_tx(void) +{ + k_work_submit(&tx_drain_work); +} + +void zephcore_ble_disconnect(void) +{ + active_iface = ZEPHCORE_IFACE_NONE; + if (transport_cbs && transport_cbs->on_disconnected) { + transport_cbs->on_disconnected(); + } +} + +void zephcore_ble_conn_params_ready(void) +{ + /* No link-layer connection parameters on a UART — no-op. */ +} + +void zephcore_ble_update_name(const char *new_name) +{ + /* No advertising payload on a UART — no-op. */ + ARG_UNUSED(new_name); +} diff --git a/zephcore/adapters/transport/companion_framing.h b/zephcore/adapters/transport/companion_framing.h new file mode 100644 index 0000000..67c2866 --- /dev/null +++ b/zephcore/adapters/transport/companion_framing.h @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: MIT + * Shared MeshCore companion serial framing — used by the non-BLE companion + * transports (LinuxTCPTransport.c, SerialCompanionTransport.c). + * + * Wire format (MeshCore ArduinoSerialInterface): + * App -> Node: '<' (0x3C) | len_LSB | len_MSB | payload... + * Node -> App: '>' (0x3E) | len_LSB | len_MSB | payload... + * + * `struct frame` is the unit stored in the ble_send_queue/ble_recv_queue; its + * layout must match what main_companion.cpp puts on those queues (len + buf). + */ + +#pragma once + +#include +#include +#include + +#include "ZephyrBLE.h" /* MAX_FRAME_SIZE */ + +#define COMPANION_FRAME_RX_SYNC '<' +#define COMPANION_FRAME_TX_SYNC '>' + +/* Push codes >= 0x80 are lossy event signals (droppable under congestion); + * protocol responses < 0x80 are lossless and must never be silently dropped. */ +#define COMPANION_PUSH_CODE_BASE 0x80 + +struct frame { + uint16_t len; + uint8_t buf[MAX_FRAME_SIZE]; +}; + +static inline bool companion_is_lossless_protocol_frame(const uint8_t *data, + uint16_t len) +{ + return data != NULL && len > 0 && data[0] < COMPANION_PUSH_CODE_BASE; +} diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 504d08d..1ac856d 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -1635,8 +1635,17 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) sendPacketError(ERR_ILLEGAL_ARG); return true; } - // Reset contact iterator for fresh start + // Reset per-session state for a fresh app session. BLE/USB also run + // this cleanup on disconnect, but the serial transport has no + // disconnect event, so APP_START is its authoritative session-reset + // point: drop a stale contact iteration (a leftover PACKET_CONTACT_END + // would confuse the new session's sync state machine), a half-finished + // message sync, and free an abandoned Ed25519 sign buffer (else an 8KB + // leak if the previous session dropped mid-CMD_SIGN). Idempotent — all + // no-ops when the state is already clean. _contact_iter_active = false; + cancelSyncPending(); + cleanupSignState(); // Return SELF_INFO uint8_t rsp[90]; // 58 fixed + up to 32 bytes name diff --git a/zephcore/boards/common/stm32wl_common.conf b/zephcore/boards/common/stm32wl_common.conf new file mode 100644 index 0000000..e18691c --- /dev/null +++ b/zephcore/boards/common/stm32wl_common.conf @@ -0,0 +1,55 @@ +# STM32WL Platform Configuration (Seeed LoRa-E5 / STM32WLE5JC) +# +# Included by all STM32WL boards. STM32WL has NO Bluetooth radio and NO USB +# peripheral, so these boards are UART-only: the repeater CLI (and the serial +# companion) run over USART1 (chosen zephyr,console) through the board's +# onboard USB-UART bridge. Flashing is over SWD/ST-Link (west flash / OpenOCD). +# +# Include order: prj.conf -> zephcore_common.conf -> stm32wl_common.conf -> board.conf +# Only contains STM32WL-specific settings; common app/crypto/storage is in +# zephcore_common.conf. + +# ========== Boot / Flash layout ========== +# No MCUboot: the application links at the flash origin via the single +# slot0_partition defined in board.overlay (zephyr,code-partition). +CONFIG_USE_DT_CODE_PARTITION=y + +# ========== No Bluetooth ========== +# STM32WL has no BLE radio. Disabling here makes every build on this platform +# BT-free (the companion transport is serial, not NUS). Mirrors repeater.conf. +CONFIG_BT=n + +# ========== No USB device stack ========== +# STM32WLE5JC has no USB peripheral. Console/CLI is plain UART +# (UART_INTERRUPT_DRIVEN already set in prj.conf). No UF2 output. + +# ========== Entropy / CSPRNG ========== +# STM32WL provides only a raw TRNG (st,stm32-rng), not a hardware CSPRNG. +# Enable the TRNG as the entropy source; with an entropy driver present, +# CONFIG_CSPRNG_ENABLED defaults y and sys_csrand_get() is backed by the +# hardware CS generator. ZephyrRNG additionally conditions identity seeds with +# CPU-jitter + AES-256-CTR (see adapters/rng/ZephyrRNG.cpp), so a weak/slow +# TRNG cannot silently produce correlated keys. CONFIG_CSPRNG_ENABLED (which +# ZephyrRNG BUILD_ASSERTs on) is not directly assignable -- it defaults y once +# an entropy driver is present (subsys/random/Kconfig), so enabling the TRNG +# below is sufficient. +CONFIG_ENTROPY_GENERATOR=y +CONFIG_ENTROPY_STM32_RNG=y + +# ========== Memory (64KB SRAM / 256KB flash) ========== +# RAM is the binding constraint on STM32WL (flash sits at ~63%), so spend the +# abundant flash to reclaim SRAM. +# +# AES lookup tables in ROM: the tf-psa-crypto builtin AES otherwise computes +# ~8KB of forward/reverse tables (RT0..RT3, RSb, ...) into .bss at runtime. +# ROM_TABLES makes them const (flash) instead. Single biggest SRAM win here. +CONFIG_MBEDTLS_AES_ROM_TABLES=y + +# PSA key slots: default 16 each cost ~40B+ of static slot state. The mesh only +# juggles a couple of ephemeral AES keys at once; 8 is ample. +CONFIG_MBEDTLS_PSA_KEY_SLOT_COUNT=8 + +# Trim the common 8KB stacks to fit 64KB SRAM. Crypto (Ed25519/PSA) and the +# RNG jitter sampler run on the main thread, so keep main comfortable. +CONFIG_MAIN_STACK_SIZE=6144 +CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096 diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 2a844c0..eb5a0c0 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -134,6 +134,39 @@ pyocd pack install EFR32MG24B220F1536IM48 Flash with: `west flash --runner pyocd` +### STM32WL (Seeed LoRa-E5) + +| Board | Build string | Flash | +|----------------|---------------------------------------|--------------| +| LoRa-E5 mini | `west build -b lora_e5_mini zephcore` | `west flash` | + +The STM32WLE5JC integrates an SX1262-class sub-GHz radio (driven by the native +SX126x driver via its STM32WL HAL over the internal `subghzspi` bus). RF +front-end, RF switch (PA4/PA5), TCXO and the +22 dBm RFO_HP PA are described in +upstream `zephyr/dts/arm/seeed_studio/lora-e5.dtsi`. + +STM32WL caveats — different from every other ZephCore platform: +- **No Bluetooth and no USB device.** `boards/common/stm32wl_common.conf` forces + `CONFIG_BT=n`. The console/CLI and the companion protocol both run over + **USART1**, bridged to USB-C by the onboard USB-UART chip. +- **Repeater** uses the USART CLI (add `repeater.conf`). The **companion** speaks + MeshCore serial framing over the same UART via `SerialCompanionTransport.c` + (a drop-in `zephcore_ble_*` provider, auto-selected because `CONFIG_BT=n`) — + no BLE pairing, the official serial client connects directly. +- **RAM-bound, not flash-bound:** 64KB SRAM. The companion's contact/queue + arrays are capped hard in `board.conf` (`MAX_CONTACTS=24`, `OFFLINE_QUEUE_SIZE=8`). + AES tables live in ROM (`MBEDTLS_AES_ROM_TABLES`) to reclaim ~8KB SRAM. +- **TRNG only (no HW CSPRNG):** the STM32 TRNG is enabled as the entropy source + and `CSPRNG_ENABLED` auto-resolves on top; `ZephyrRNG` further conditions + identity seeds with jitter + AES-CTR. +- **No MCUboot / UF2:** single app partition at flash origin + a LittleFS volume + (see `board.overlay`). Flash over SWD/ST-Link with `west flash` (OpenOCD). + +Build the repeater: +``` +west build -b lora_e5_mini zephcore -- -DEXTRA_CONF_FILE="boards/common/repeater.conf" +``` + ### Building for Repeater Role Append `-- -DEXTRA_CONF_FILE="boards/common/repeater.conf"` to any build command: diff --git a/zephcore/boards/stm32wl/lora_e5_mini/board.conf b/zephcore/boards/stm32wl/lora_e5_mini/board.conf new file mode 100644 index 0000000..64736da --- /dev/null +++ b/zephcore/boards/stm32wl/lora_e5_mini/board.conf @@ -0,0 +1,33 @@ +# Seeed Studio LoRa-E5 mini (STM32WLE5JC, integrated SX126x sub-GHz radio) +# Board-specific configuration. +# +# Hardware: +# - STM32WLE5JC: Cortex-M4 @ 48MHz (MSI), 256KB flash, 64KB SRAM +# - Integrated SX1262-class sub-GHz radio on the internal subghzspi bus +# - RF front-end: RFO_HP, +22 dBm; RF switch on PA4 (TX) / PA5 (RX) +# (radio node configured in zephyr/dts/arm/seeed_studio/lora-e5.dtsi) +# - 32MHz TCXO (DIO3, 1.7V), 32.768kHz LSE +# - USART1 (PB6/PB7) -> onboard USB-UART bridge: console + CLI +# - No Bluetooth, no USB device (see boards/common/stm32wl_common.conf) +# +# Roles: repeater (USART CLI). Companion is serial-over-USART (no BLE). +# Flash via SWD/ST-Link: west flash (OpenOCD runner, no UF2/DFU). + +# Board identification +CONFIG_ZEPHCORE_BOARD_NAME="LoRa-E5 mini" + +# Device Information Service model name (inert with BT off, kept for consistency) +CONFIG_BT_DIS_MODEL_NUMBER_STR="LoRa-E5 mini" + +# TX power: +22 dBm RFO_HP (clamped to rfo-hp-max-power=22 in the DT radio node). +CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM=22 + +# ========== Companion RAM capacity (64KB SRAM) ========== +# The companion build allocates static contacts[] + offline_queue[] arrays. +# The defaults (350 contacts ~66KB, 256-entry queue ~44KB) dwarf 64KB of SRAM, +# so cap them hard. These only allocate in the companion role; the repeater +# build ignores them. A serial companion on this constrained board is for +# modest contact lists. +CONFIG_ZEPHCORE_MAX_CONTACTS=24 +CONFIG_ZEPHCORE_OFFLINE_QUEUE_SIZE=8 +CONFIG_ZEPHCORE_MAX_CHANNELS=8 diff --git a/zephcore/boards/stm32wl/lora_e5_mini/board.overlay b/zephcore/boards/stm32wl/lora_e5_mini/board.overlay new file mode 100644 index 0000000..0558494 --- /dev/null +++ b/zephcore/boards/stm32wl/lora_e5_mini/board.overlay @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: MIT + * Seeed Studio LoRa-E5 mini (STM32WLE5JC) -- ZephCore overlay + * + * The integrated sub-GHz radio (subghzspi "lora" node), RF switch (PA4/PA5), + * TCXO and RFO_HP front-end are already fully described in + * zephyr/dts/arm/seeed_studio/lora-e5.dtsi, and the lora0 alias + USART1 + * console come from the upstream lora_e5_mini board DTS. This overlay only: + * 1. enables the TRNG entropy source, and + * 2. repartitions the 256KB internal flash for a bootloader-less ZephCore + * image plus a LittleFS data volume. + */ + +/* + * STM32WL TRNG -- entropy source for the CSPRNG used by Ed25519 key + * generation and LoRa contention jitter. STM32WL has only a raw TRNG; + * CONFIG_CSPRNG_ENABLED (stm32wl_common.conf) layers the CS generator on top. + * Matches the bare enable used by upstream nucleo_wl55jc. + */ +&rng { + status = "okay"; +}; + +/* + * Flash repartition -- no MCUboot (flashed over SWD/ST-Link; STM32WL has no + * USB for native DFU/UF2). Total 256KB, 2KB erase pages. + * + * 0x00000 - 0x38000 (224KB) Application (slot0, links at flash origin) + * 0x38000 - 0x40000 ( 32KB) LittleFS /lfs (identity, prefs, contacts) + * + * The upstream board reserves mcuboot(48K) + dual 96K slots + 16K storage, + * whose 96K app slot cannot hold ZephCore -- so we delete and rebuild. + */ +/delete-node/ &boot_partition; +/delete-node/ &slot0_partition; +/delete-node/ &slot1_partition; +/delete-node/ &storage_partition; + +&flash0 { + partitions { + slot0_partition: partition@0 { + label = "image-0"; + reg = <0x00000000 0x00038000>; + }; + + lfs_partition: partition@38000 { + label = "lfs"; + reg = <0x00038000 0x00008000>; + }; + }; +}; + +/* LittleFS auto-mount at /lfs (defines the &lfs fstab node) */ +#include "../../common/filesystem.dtsi" diff --git a/zephcore/include/mesh/MeshCore.h b/zephcore/include/mesh/MeshCore.h index 71b9da1..44c8a39 100644 --- a/zephcore/include/mesh/MeshCore.h +++ b/zephcore/include/mesh/MeshCore.h @@ -8,6 +8,10 @@ #include #include +/* Neutralize STM32 CMSIS peripheral-instance macros (RNG, AES, ...) that + * collide with portable mesh identifiers. No-op off STM32. */ +#include + #define MAX_HASH_SIZE 8 /* SHA256 truncated to 8 bytes for dedup */ #define PUB_KEY_SIZE 32 /* Ed25519 public key */ #define PRV_KEY_SIZE 64 /* Ed25519 expanded private key */ diff --git a/zephcore/include/mesh/RNG.h b/zephcore/include/mesh/RNG.h index 27f9d38..60c5496 100644 --- a/zephcore/include/mesh/RNG.h +++ b/zephcore/include/mesh/RNG.h @@ -8,6 +8,10 @@ #include #include +/* On STM32, CMSIS defines an object-like macro `RNG` that collides with the + * mesh::RNG class below. The fixup header neutralizes it order-independently. */ +#include + namespace mesh { class RNG { diff --git a/zephcore/include/mesh/stm32_cmsis_fixup.h b/zephcore/include/mesh/stm32_cmsis_fixup.h new file mode 100644 index 0000000..5ecdffe --- /dev/null +++ b/zephcore/include/mesh/stm32_cmsis_fixup.h @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: MIT + * STM32 CMSIS peripheral-macro fixup for the portable mesh:: core. + * + * STM32 CMSIS device headers (e.g. stm32wle5xx.h, pulled in transitively via + * -> soc.h) define object-like macros for every on-chip + * peripheral instance: `RNG`, `AES`, `ADC`, `RTC`, `CRC`, ... Several collide + * with generic identifiers in the portable mesh core -- most notably the + * mesh::RNG class. + * + * The collision is order-sensitive: zephcore .cpp files include mesh headers + * (which declare e.g. class RNG) and only afterwards include a Zephyr header + * (e.g. ) that pulls CMSIS in. So a naive `#undef RNG` + * placed next to the class declaration runs before the macro even exists. + * + * Fix: force the CMSIS device header to be processed NOW (via ) so its + * include guard trips, then undef the colliding instance macros. The later + * pull through then re-enters a guarded, already-processed + * header and is a no-op, so the macros stay undef'd for the rest of the + * translation unit -- regardless of whether this header was reached before or + * after the first Zephyr include. + * + * Only zephcore C++ TUs include this; Zephyr's own STM32 drivers (which use the + * CMSIS peripheral pointers legitimately) are separate translation units and + * are unaffected. No-op on every non-STM32 platform. + */ + +#pragma once + +#if defined(CONFIG_SOC_FAMILY_STM32) + +#include + +/* Peripheral-instance macros whose names can collide with portable identifiers. + * #undef of an undefined macro is a harmless no-op, so no per-symbol guards. */ +#undef RNG +#undef AES +#undef ADC +#undef DAC +#undef RTC +#undef CRC +#undef PWR +#undef PKA +#undef TAMP +#undef COMP + +#endif /* CONFIG_SOC_FAMILY_STM32 */ diff --git a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch index fa2d63f..c987b37 100644 --- a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch +++ b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch @@ -846,10 +846,11 @@ index 30243ba5dc7..69eefee6e0f 100644 data->tx_async_signal = async; k_msgq_purge(&data->tx_msgq); -@@ -777,6 +1333,29 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -777,6 +1333,31 @@ static int sx126x_lora_send_async(const struct device *dev, /* Enable antenna and set TX path */ sx126x_set_rf_path(dev, true, true); ++#ifdef CONFIG_LORA_SX126X_NATIVE_STANDALONE /* STM32WL HAL clamps in its own configure_tx_params; its config has no is_sx1261 */ + /* Sec 15.2 TX Clamp workaround (datasheet errata) -- SX1262 only. + * Set bits [4:1] of register 0x08D8 before SetTx to prevent + * PA overshoot that can damage the device. */ @@ -872,6 +873,7 @@ index 30243ba5dc7..69eefee6e0f 100644 + } + } + } ++#endif /* CONFIG_LORA_SX126X_NATIVE_STANDALONE */ + /* Start transmission with 10 second timeout */ ret = sx126x_set_tx(dev, 10000);