diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3a1c2f0548..2cb17d91d9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,26 @@ "ghcr.io/devcontainers/features/node:1": {}, "ghcr.io/rocker-org/devcontainer-features/apt-packages:1": { "packages": [ - "sudo" + "sudo", + // Mainline Zephyr host build dependencies (companion is NOT built on NCS) + "git", + "cmake", + "ninja-build", + "gperf", + "ccache", + "dfu-util", + "device-tree-compiler", + "wget", + "xz-utils", + "file", + "make", + "libsdl2-dev", + "libmagic1", + "python3-venv", + "python3-dev", + // pyocd (CMSIS-DAP flash/RTT for the XIAO nRF54L15 on-board debugger) + "libusb-1.0-0", + "libhidapi-hidraw0" ] } }, @@ -20,7 +39,12 @@ ], "postCreateCommand": { "platformio": "pipx install platformio", - "opencode": "curl -fsSL https://opencode.ai/install | bash" + "opencode": "curl -fsSL https://opencode.ai/install | bash", + // HEAVY (multi-GB, slow first run). Comment this out and run the script + // manually (`bash .devcontainer/setup-zephyr.sh`) if you'd rather not pay + // the cost on every container (re)create. Non-fatal: PlatformIO still works + // even if this fails. + "zephyr": "bash .devcontainer/setup-zephyr.sh || echo 'Zephyr setup skipped/failed; run .devcontainer/setup-zephyr.sh manually'" }, "customizations": { "vscode": { diff --git a/.devcontainer/setup-zephyr.sh b/.devcontainer/setup-zephyr.sh new file mode 100755 index 0000000000..e05c56d2b2 --- /dev/null +++ b/.devcontainer/setup-zephyr.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Provisions everything the Zephyr MeshCore companion +# (zephyr-port/07_companion) needs, *alongside* the existing PlatformIO setup, +# so the ESP32/nRF52/RP2040/STM32 builds are left untouched. +# +# The companion is built on **mainline Zephyr** (NOT the Nordic nRF Connect SDK): +# see zephyr-port/07_companion/README.md. This script therefore installs: +# 1. a west/Zephyr workspace from zephyrproject-rtos/zephyr -> $ZEPHYR_WORKSPACE +# 2. the matching Zephyr SDK (compilers) via `west sdk install` +# 3. pyocd (CMSIS-DAP flash/RTT for the XIAO's on-board debugger) +# 4. arduino-cli + the three Arduino libraries the companion actually +# compiles ($HOME/Arduino/libraries/{RadioLib,Crypto,base64}); the other +# libs its CMakeLists.txt names (CayenneLPP/RTClib/ArduinoJson) are +# header-shimmed in compat/ and not installed +# +# It is intentionally idempotent: re-running skips work that's already done. +# It is HEAVY (multi-GB clone + Zephyr SDK). Expect the first run to take a while. +# +# Usage: +# bash .devcontainer/setup-zephyr.sh +# +# Override defaults via env, track the moving mainline tip instead of the +# pinned commit: +# ZEPHYR_REV=main bash .devcontainer/setup-zephyr.sh +# +set -euo pipefail + +# --- Tunables ---------------------------------------------------------------- +# Mainline Zephyr revision. Pinned to a known-good commit on `main` that reports +# VERSION 4.4.99 and ships the Seeed XIAO nRF54L15 board +# (boards/seeed/xiao_nrf54l15). Override with ZEPHYR_REV=main to track the tip. +# https://docs.zephyrproject.org/latest/boards/seeed/xiao_nrf54l15/doc/index.html +ZEPHYR_REV="${ZEPHYR_REV:-c9a40340e8e3b94d6660beb98f4d9a858cfd937e}" +ZEPHYR_WORKSPACE="${ZEPHYR_WORKSPACE:-$HOME/zephyrproject}" +VENV_DIR="${VENV_DIR:-$HOME/.zephyr-venv}" + +# The board target the companion builds for (README + CMakeLists). +BOARD="${BOARD:-xiao_nrf54l15/nrf54l15/cpuapp}" + +# Arduino libraries the companion's CMakeLists.txt pulls in by absolute path +# ($HOME/Arduino/libraries/...). Versions match a known-good build; RadioLib +# 7.7.1 already carries the LR2021 driver *and* the multi-SF side-detector API +# (setSideDetector / setLoRaSideDetCad) upstream, so no fork/patch is needed. +ARDUINO_DIR="${ARDUINO_DIR:-$HOME/Arduino}" +RADIOLIB_VER="${RADIOLIB_VER:-7.7.1}" +CRYPTO_VER="${CRYPTO_VER:-0.4.0}" +BASE64_VER="${BASE64_VER:-1.3.0}" +# ----------------------------------------------------------------------------- + +echo ">> Zephyr revision : ${ZEPHYR_REV}" +echo ">> Workspace : ${ZEPHYR_WORKSPACE}" +echo ">> Python venv : ${VENV_DIR}" +echo ">> Board : ${BOARD}" + +# 1) Isolated Python venv for west + pyocd (don't pollute the PlatformIO/pipx env). +if [ ! -d "${VENV_DIR}" ]; then + echo ">> Creating Python venv for west..." + python3 -m venv "${VENV_DIR}" +fi +# shellcheck disable=SC1091 +source "${VENV_DIR}/bin/activate" +pip install --upgrade pip wheel >/dev/null +# west (build system) + pyocd (CMSIS-DAP flash & RTT console for the XIAO). +pip install --upgrade west pyocd >/dev/null + +# 2) Initialise the mainline Zephyr workspace (skip if already initialised). +if [ ! -d "${ZEPHYR_WORKSPACE}/.west" ]; then + echo ">> west init mainline Zephyr workspace (${ZEPHYR_REV})..." + west init -m https://github.com/zephyrproject-rtos/zephyr --mr "${ZEPHYR_REV}" "${ZEPHYR_WORKSPACE}" +else + echo ">> Zephyr workspace already initialised, skipping west init." +fi + +cd "${ZEPHYR_WORKSPACE}" +echo ">> west update (this is the slow part)..." +west update +west zephyr-export + +# 3) Zephyr's Python requirements. +echo ">> Installing Zephyr Python requirements..." +pip install -r zephyr/scripts/requirements.txt >/dev/null + +# 4) Zephyr SDK (toolchain). `west sdk install` auto-selects the version that +# matches this Zephyr tree, so we don't hardcode (and mis-pin) an SDK version. +# Only the Arm toolchain is needed (nRF54L15 = Cortex-M33); without -t it +# would download every architecture's toolchain. +echo ">> Installing the matching Zephyr SDK (compilers)..." +west sdk install -t arm-zephyr-eabi || { + echo "!! 'west sdk install' failed. On older west you may need to install the" + echo "!! Zephyr SDK manually: https://docs.zephyrproject.org/latest/develop/getting_started/index.html" +} + +# 5) arduino-cli + the Arduino libraries the companion build compiles. +# arduino-cli's default sketchbook is $HOME/Arduino, so the libraries land +# in $HOME/Arduino/libraries/{RadioLib,Crypto,base64} exactly where +# CMakeLists.txt looks. +if ! command -v arduino-cli >/dev/null 2>&1; then + echo ">> Installing arduino-cli (user space -> ~/.local/bin)..." + mkdir -p "$HOME/.local/bin" + curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh \ + | BINDIR="$HOME/.local/bin" sh + export PATH="$HOME/.local/bin:$PATH" +fi +# Keep libraries in $HOME/Arduino so they match the CMake absolute paths. +# No --overwrite: an existing config is kept; directories.user is pinned below. +arduino-cli config init >/dev/null 2>&1 || true +arduino-cli config set directories.user "${ARDUINO_DIR}" >/dev/null 2>&1 || true +echo ">> Installing Arduino libraries (RadioLib/Crypto/base64) into ${ARDUINO_DIR}/libraries..." +arduino-cli lib update-index >/dev/null +arduino-cli lib install \ + "RadioLib@${RADIOLIB_VER}" \ + "Crypto@${CRYPTO_VER}" \ + "base64@${BASE64_VER}" + +# 6) Sanity build: confirm the XIAO nRF54L15 target actually compiles here. +echo ">> Smoke-building Zephyr 'hello_world' for ${BOARD}..." +if west build -p always -b "${BOARD}" zephyr/samples/hello_world -d /tmp/xiao_hello; then + echo ">> OK: native Zephyr toolchain can build for the XIAO nRF54L15." +else + echo "!! Zephyr build for ${BOARD} FAILED." + echo "!! Likely cause: ZEPHYR_REV=${ZEPHYR_REV} predates XIAO nRF54L15 board support," + echo "!! or the board target string changed. Confirm the board at:" + echo "!! https://docs.zephyrproject.org/latest/boards/seeed/xiao_nrf54l15/doc/index.html" +fi + +cat < +#elif defined(NRF54_PLATFORM) + #include #elif defined(RP2040_PLATFORM) #include #elif defined(ESP32) @@ -42,7 +44,10 @@ #include -#define SEND_TIMEOUT_BASE_MILLIS 500 +#define SEND_TIMEOUT_BASE_MILLIS 3000 // generous base so first-contact (cold path + // discovery) ACKs land inside the window; warm + // round-trips are 600-800ms so this only affects + // how long a genuine no-ACK takes to report #define FLOOD_SEND_TIMEOUT_FACTOR 16.0f #define DIRECT_SEND_PERHOP_FACTOR 6.0f #define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 @@ -126,7 +131,7 @@ class MyMesh : public BaseChatMesh, ContactVisitor { } void saveContacts() { -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(NRF54_PLATFORM) _fs->remove("/contacts"); File file = _fs->open("/contacts", FILE_O_WRITE); #elif defined(RP2040_PLATFORM) @@ -218,11 +223,14 @@ class MyMesh : public BaseChatMesh, ContactVisitor { } ContactInfo* processAck(const uint8_t *data) override { - if (memcmp(data, &expected_ack_crc, 4) == 0) { // got an ACK from recipient + if (expected_ack_crc != 0 && memcmp(data, &expected_ack_crc, 4) == 0) { // got an ACK from recipient Serial.printf(" Got ACK! (round trip: %d millis)\n", _ms->getMillis() - last_msg_sent); // NOTE: the same ACK can be received multiple times! expected_ack_crc = 0; // reset our expected hash, now that we have received ACK - return NULL; // TODO: really should return ContactInfo pointer + // Return the matched contact (the recipient we sent to) so BaseChatMesh cancels its + // send-timeout; returning NULL here left txt_send_timeout running, so onSendTimeout() + // fired a spurious "ERROR: timed out, no ACK." right after a successful "Got ACK!". + return curr_recipient; } //uint32_t crc; @@ -298,7 +306,7 @@ class MyMesh : public BaseChatMesh, ContactVisitor { BaseChatMesh::begin(); - #if defined(NRF52_PLATFORM) + #if defined(NRF52_PLATFORM) || defined(NRF54_PLATFORM) IdentityStore store(fs, ""); #elif defined(RP2040_PLATFORM) IdentityStore store(fs, "/identity"); @@ -341,7 +349,7 @@ class MyMesh : public BaseChatMesh, ContactVisitor { } void savePrefs() { -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(NRF54_PLATFORM) _fs->remove("/node_prefs"); File file = _fs->open("/node_prefs", FILE_O_WRITE); #elif defined(RP2040_PLATFORM) @@ -528,10 +536,9 @@ class MyMesh : public BaseChatMesh, ContactVisitor { int len = strlen(command); while (Serial.available() && len < sizeof(command)-1) { char c = Serial.read(); - if (c != '\n') { - command[len++] = c; - command[len] = 0; - } + if (c == '\n') c = '\r'; // accept LF or CRLF (arduino-cli monitor sends LF) + command[len++] = c; + command[len] = 0; Serial.print(c); } if (len == sizeof(command)-1) { // command buffer full @@ -564,7 +571,7 @@ void setup() { fast_rng.begin(radio_driver.getRngSeed()); -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(NRF54_PLATFORM) InternalFS.begin(); the_mesh.begin(InternalFS); #elif defined(RP2040_PLATFORM) diff --git a/src/helpers/ClientACL.cpp b/src/helpers/ClientACL.cpp index 1282382737..3591b42a25 100644 --- a/src/helpers/ClientACL.cpp +++ b/src/helpers/ClientACL.cpp @@ -1,7 +1,7 @@ #include "ClientACL.h" static File openWrite(FILESYSTEM* _fs, const char* filename) { - #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(NRF54_PLATFORM) _fs->remove(filename); return _fs->open(filename, FILE_O_WRITE); #elif defined(RP2040_PLATFORM) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b78ad6ebd6..a675ad9561 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -127,7 +127,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { } void CommonCLI::savePrefs(FILESYSTEM* fs) { -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(NRF54_PLATFORM) fs->remove("/com_prefs"); File file = fs->open("/com_prefs", FILE_O_WRITE); #elif defined(RP2040_PLATFORM) diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index dc85d69cdd..4987ae141e 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -46,7 +46,7 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { char filename[40]; sprintf(filename, "%s/%s.id", _dir, name); -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(NRF54_PLATFORM) _fs->remove(filename); File file = _fs->open(filename, FILE_O_WRITE); #elif defined(RP2040_PLATFORM) @@ -68,7 +68,7 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const char filename[40]; sprintf(filename, "%s/%s.id", _dir, name); -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(NRF54_PLATFORM) _fs->remove(filename); File file = _fs->open(filename, FILE_O_WRITE); #elif defined(RP2040_PLATFORM) diff --git a/src/helpers/IdentityStore.h b/src/helpers/IdentityStore.h index d0d7ee457e..5ee7fed849 100644 --- a/src/helpers/IdentityStore.h +++ b/src/helpers/IdentityStore.h @@ -3,7 +3,7 @@ #if defined(ESP32) || defined(RP2040_PLATFORM) #include #define FILESYSTEM fs::FS -#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(NRF54_PLATFORM) #include #define FILESYSTEM Adafruit_LittleFS diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 7b8399e260..e4ee41b6dc 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -59,7 +59,7 @@ static const char* skip_hash(const char* name) { } static File openWrite(FILESYSTEM* _fs, const char* filename) { - #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(NRF54_PLATFORM) _fs->remove(filename); return _fs->open(filename, FILE_O_WRITE); #elif defined(RP2040_PLATFORM) diff --git a/src/helpers/nrf54/InternalFileSystem.cpp b/src/helpers/nrf54/InternalFileSystem.cpp new file mode 100644 index 0000000000..c66ac7bf52 --- /dev/null +++ b/src/helpers/nrf54/InternalFileSystem.cpp @@ -0,0 +1,106 @@ +#include +#include "InternalFileSystem.h" +#include +#include + +// Filesystem region, resident in RRAM +// `const` forces it into .rodata (RRAM/flash) at a stable, linker-allocated +// address (NOT .data/.bss in RAM); so it survives reboot. We write to it at +// runtime via the RRAMC peripheral (RRAM is uniformly writable regardless of the +// .rodata label, the same mechanism the core's EEPROM library uses). +// Force into a .rodata subsection so the linker places it in FLASH/RRAM (the +// linker routes *(.rodata*) -> FLASH). Without an explicit section the `volatile` +// initialised array lands in .data (RAM) and would NOT persist. +__attribute__((section(".rodata.lfs_region"), aligned(LFS_BLOCK_SIZE))) +static const volatile uint8_t g_lfsRegion[LFS_RRAM_TOTAL_SIZE] = { 0 }; +#define LFS_BASE ((uint32_t)(uintptr_t)g_lfsRegion) + +// RRAMC write primitive (adapted from the core's EEPROM library) +static constexpr uint32_t kRramcBase = 0x5004B000UL; +static constexpr uint32_t kRramcSpin = 600000UL; + +static inline NRF_RRAMC_Type* rramc() { return reinterpret_cast(kRramcBase); } + +static bool waitReady(NRF_RRAMC_Type* r, uint32_t spin) { + while (spin-- > 0U) { + if (((r->READY & RRAMC_READY_READY_Msk) >> RRAMC_READY_READY_Pos) == RRAMC_READY_READY_Ready) return true; + } + return false; +} +static bool waitReadyNext(NRF_RRAMC_Type* r, uint32_t spin) { + while (spin-- > 0U) { + if (((r->READYNEXT & RRAMC_READYNEXT_READYNEXT_Msk) >> RRAMC_READYNEXT_READYNEXT_Pos) == RRAMC_READYNEXT_READYNEXT_Ready) return true; + } + return false; +} + +static bool rramWrite(uint32_t addr, const uint8_t* src, size_t len) { + NRF_RRAMC_Type* const r = rramc(); + const uint32_t prev = r->CONFIG; + r->CONFIG = prev | RRAMC_CONFIG_WEN_Msk; + bool ok = waitReady(r, kRramcSpin); + if (ok) { + r->EVENTS_ACCESSERROR = 0U; + for (size_t i = 0; i < len; ++i) { + if (!waitReadyNext(r, kRramcSpin)) { ok = false; break; } + *reinterpret_cast(addr + static_cast(i)) = src[i]; + } + if (r->EVENTS_ACCESSERROR != 0U) ok = false; + } + if (ok) { + r->EVENTS_READY = 0U; + r->TASKS_COMMITWRITEBUF = 1U; + ok = waitReady(r, kRramcSpin); + } + r->CONFIG = prev; + return ok; +} + +// LittleFS block device over RRAM +static int _rram_read(const struct lfs_config* c, lfs_block_t block, lfs_off_t off, void* buffer, lfs_size_t size) { + (void)c; + if (!buffer || !size) return LFS_ERR_INVAL; + memcpy(buffer, (const void*)(LFS_BASE + block * LFS_BLOCK_SIZE + off), size); // RRAM is memory-mapped + return LFS_ERR_OK; +} +static int _rram_prog(const struct lfs_config* c, lfs_block_t block, lfs_off_t off, const void* buffer, lfs_size_t size) { + (void)c; + return rramWrite(LFS_BASE + block * LFS_BLOCK_SIZE + off, (const uint8_t*)buffer, size) ? LFS_ERR_OK : LFS_ERR_IO; +} +static int _rram_erase(const struct lfs_config* c, lfs_block_t block) { + (void)c; (void)block; + return LFS_ERR_OK; // RRAM needs no erase-before-write (unlike NOR flash) +} +static int _rram_sync(const struct lfs_config* c) { (void)c; return LFS_ERR_OK; } + +struct lfs_config _InternalFSConfig = { + .context = NULL, + .read = _rram_read, + .prog = _rram_prog, + .erase = _rram_erase, + .sync = _rram_sync, + + .read_size = LFS_BLOCK_SIZE, + .prog_size = LFS_BLOCK_SIZE, + .block_size = LFS_BLOCK_SIZE, + .block_count = LFS_RRAM_TOTAL_SIZE / LFS_BLOCK_SIZE, + .lookahead = 128, + + .read_buffer = NULL, + .prog_buffer = NULL, + .lookahead_buffer = NULL, + .file_buffer = NULL +}; + +InternalFileSystem InternalFS; + +InternalFileSystem::InternalFileSystem(void) : Adafruit_LittleFS(&_InternalFSConfig) { } + +bool InternalFileSystem::begin(void) { + // mount; on failure format then mount again + if (!Adafruit_LittleFS::begin()) { + this->format(); + if (!Adafruit_LittleFS::begin()) return false; + } + return true; +} diff --git a/src/helpers/nrf54/InternalFileSystem.h b/src/helpers/nrf54/InternalFileSystem.h new file mode 100644 index 0000000000..46e6a8c126 --- /dev/null +++ b/src/helpers/nrf54/InternalFileSystem.h @@ -0,0 +1,22 @@ +#pragma once +// LittleFS-over-RRAM for the nRF54L15 (lolren bare-metal core). Mirrors the STM32 +// InternalFileSystem (helpers/stm32) but backs LittleFS with the nRF54L15 RRAM via +// the RRAMC peripheral instead of STM32 flash HAL. Provides the `Adafruit_LittleFS` +// `InternalFS` instance that MeshCore's FILESYSTEM/IdentityStore expect. + +#include "Adafruit_LittleFS.h" + +#ifndef LFS_RRAM_TOTAL_SIZE + #define LFS_RRAM_TOTAL_SIZE (16 * 2048) // 32 KB filesystem (matches STM32 default) +#endif +#define LFS_BLOCK_SIZE (2048) + +class InternalFileSystem : public Adafruit_LittleFS { +public: + InternalFileSystem(void); + bool begin(void); +}; + +extern InternalFileSystem InternalFS; + +using namespace Adafruit_LittleFS_Namespace; diff --git a/src/helpers/nrf54/SerialBLEInterface.cpp b/src/helpers/nrf54/SerialBLEInterface.cpp new file mode 100644 index 0000000000..0a34df24ff --- /dev/null +++ b/src/helpers/nrf54/SerialBLEInterface.cpp @@ -0,0 +1,298 @@ +#include "SerialBLEInterface.h" +#include +#include +#include "ble_gap.h" +// NOTE: no and no SoftDevice (sd_*) calls, this core's Bluefruit52Lib +// reimplements only the C++ API. SoftDevice GAP calls are swapped for core methods. + +#define BLE_HEALTH_CHECK_INTERVAL 10000 +#define BLE_RETRY_THROTTLE_MS 250 + +// Connection parameters (units: interval=1.25ms, timeout=10ms) +#define BLE_MIN_CONN_INTERVAL 12 // 15ms +#define BLE_MAX_CONN_INTERVAL 24 // 30ms + +// Advertising parameters (units: 0.625ms) +#define BLE_ADV_INTERVAL_MIN 32 // 20ms +#define BLE_ADV_INTERVAL_MAX 244 // 152.5ms +#define BLE_ADV_FAST_TIMEOUT 30 // seconds + +#define BLE_RX_DRAIN_BUF_SIZE 32 + +static SerialBLEInterface* instance = nullptr; + +void SerialBLEInterface::onConnect(uint16_t connection_handle) { + BLE_DEBUG_PRINTLN("SerialBLEInterface: connected handle=0x%04X", connection_handle); + if (instance) { + instance->_conn_handle = connection_handle; + // Request 2M PHY immediately. On 1M the core's tight T_IFS budget blows during the + // pairing handshake (EVT_RX/TX_TIMEOUT -> dropped SMP PDUs -> status=8); 2M halves the + // packet airtime and gives timing margin. Mirrors the Adafruit Bluefruit LE app, which + // negotiates 2M early and pairs reliably on this core. + BLEConnection* conn = Bluefruit.Connection(connection_handle); + if (conn) conn->requestPHY(BLE_GAP_PHY_2MBPS); +#if defined(BLE_NO_PAIRING) + instance->_isDeviceConnected = true; // open mode: no securing step, ready immediately +#else + instance->_isDeviceConnected = false; // wait for onSecured() +#endif + instance->clearBuffers(); + } +} + +void SerialBLEInterface::onDisconnect(uint16_t connection_handle, uint8_t reason) { + BLE_DEBUG_PRINTLN("SerialBLEInterface: disconnected handle=0x%04X reason=%u", connection_handle, reason); + if (instance && instance->_conn_handle == connection_handle) { + instance->_conn_handle = BLE_CONN_HANDLE_INVALID; + instance->_isDeviceConnected = false; + instance->clearBuffers(); + } +} + +void SerialBLEInterface::onSecured(uint16_t connection_handle) { + BLE_DEBUG_PRINTLN("SerialBLEInterface: onSecured handle=0x%04X", connection_handle); + if (instance && instance->isValidConnection(connection_handle, true)) { + instance->_isDeviceConnected = true; + // Preferred connection interval is set in begin() via Bluefruit.Periph.setConnInterval(); + // the core's Bluefruit52Lib has no SoftDevice conn_param_update on an active link. + } +} + +bool SerialBLEInterface::onPairingPasskey(uint16_t connection_handle, uint8_t const passkey[6], bool match_request) { + (void)connection_handle; (void)passkey; + BLE_DEBUG_PRINTLN("SerialBLEInterface: pairing passkey request match=%d", match_request); + return true; +} + +void SerialBLEInterface::onPairingComplete(uint16_t connection_handle, uint8_t auth_status) { + BLE_DEBUG_PRINTLN("SerialBLEInterface: pairing complete handle=0x%04X status=%u", connection_handle, auth_status); + if (instance && instance->isValidConnection(connection_handle)) { + if (auth_status != BLE_GAP_SEC_STATUS_SUCCESS) { + BLE_DEBUG_PRINTLN("SerialBLEInterface: pairing failed, disconnecting"); + instance->disconnect(); + } + } +} + +void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code) { + instance = this; + + char charpin[20]; + snprintf(charpin, sizeof(charpin), "%lu", (unsigned long)pin_code); + + Bluefruit.configPrphBandwidth(BANDWIDTH_MAX); + Bluefruit.begin(); + + char dev_name[32+16]; + if (strcmp(name, "@@MAC") == 0) { + uint8_t mac[6]; + Bluefruit.getAddr(mac); // core API (was sd_ble_gap_addr_get) + sprintf(name, "%02X%02X%02X%02X%02X%02X", + mac[5], mac[4], mac[3], mac[2], mac[1], mac[0]); + } + sprintf(dev_name, "%s%s", prefix, name); + + // preferred connection interval (was sd_ble_gap_ppcp_set) + Bluefruit.Periph.setConnInterval(BLE_MIN_CONN_INTERVAL, BLE_MAX_CONN_INTERVAL); + + Bluefruit.setTxPower(BLE_TX_POWER); + Bluefruit.setName(dev_name); + + (void)charpin; +#if defined(BLE_NO_PAIRING) + // This core's SMP pairing is unreliable on nRF54 (MITM fails status=11, Just Works fails + // status=3, never reaches onSecured). Fall back to an OPEN, unencrypted NUS link: no + // bonding, characteristics readable/writable without pairing. (No MeshCore-level secrecy + // over the BLE hop, acceptable for bring-up; revisit if the core's SMP is fixed.) + Bluefruit.Periph.setConnectCallback(onConnect); + Bluefruit.Periph.setDisconnectCallback(onDisconnect); + + bleuart.setPermission(SECMODE_OPEN, SECMODE_OPEN); + bleuart.begin(); + bleuart.setRxCallback(onBleUartRX); + + bledfu.setPermission(SECMODE_OPEN, SECMODE_OPEN); + bledfu.begin(); +#else + // Encrypted PIN pairing, matching the core's working Security/pairing_pin example exactly: + // setPIN + SECMODE_ENC_WITH_MITM, and crucially NO setIOCaps and NO setPairPasskeyCallback. + // Adding either of those switched the device into a conflicting passkey/numeric-comparison + // mode and pairing failed (status=11). The central is prompted to enter the 6-digit PIN. + Bluefruit.Security.setPIN(charpin); + Bluefruit.Security.setPairCompleteCallback(onPairingComplete); + + Bluefruit.Periph.setConnectCallback(onConnect); + Bluefruit.Periph.setDisconnectCallback(onDisconnect); + Bluefruit.Security.setSecuredCallback(onSecured); + // (no Bluefruit.setEventCallback: the raw conn-param-update event handler is nRF52/ + // SoftDevice-only and not needed, defaults negotiate fine.) + + bleuart.setPermission(SECMODE_ENC_WITH_MITM, SECMODE_ENC_WITH_MITM); + bleuart.begin(); + bleuart.setRxCallback(onBleUartRX); + + bledfu.setPermission(SECMODE_ENC_WITH_MITM, SECMODE_ENC_WITH_MITM); + bledfu.begin(); +#endif + + Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE); + Bluefruit.Advertising.addTxPower(); + Bluefruit.Advertising.addService(bleuart); + Bluefruit.ScanResponse.addName(); + Bluefruit.Advertising.setInterval(BLE_ADV_INTERVAL_MIN, BLE_ADV_INTERVAL_MAX); + Bluefruit.Advertising.setFastTimeout(BLE_ADV_FAST_TIMEOUT); + Bluefruit.Advertising.restartOnDisconnect(true); +} + +void SerialBLEInterface::clearBuffers() { + send_queue_len = 0; + recv_queue_len = 0; + _last_retry_attempt = 0; + bleuart.flush(); +} + +void SerialBLEInterface::shiftSendQueueLeft() { + if (send_queue_len > 0) { + send_queue_len--; + for (uint8_t i = 0; i < send_queue_len; i++) send_queue[i] = send_queue[i + 1]; + } +} + +void SerialBLEInterface::shiftRecvQueueLeft() { + if (recv_queue_len > 0) { + recv_queue_len--; + for (uint8_t i = 0; i < recv_queue_len; i++) recv_queue[i] = recv_queue[i + 1]; + } +} + +bool SerialBLEInterface::isValidConnection(uint16_t handle, bool requireWaitingForSecurity) const { + if (_conn_handle != handle) return false; + BLEConnection* conn = Bluefruit.Connection(handle); + if (conn == nullptr || !conn->connected()) return false; + if (requireWaitingForSecurity && _isDeviceConnected) return false; + return true; +} + +bool SerialBLEInterface::isAdvertising() const { + // The core's Bluefruit52Lib has no advertising-running query; restartOnDisconnect(true) + // keeps advertising alive, so report true (the watchdog restart below is a no-op). + return true; +} + +void SerialBLEInterface::enable() { + if (_isEnabled) return; + _isEnabled = true; + clearBuffers(); + _last_health_check = millis(); + Bluefruit.Advertising.restartOnDisconnect(true); + Bluefruit.Advertising.start(0); +} + +void SerialBLEInterface::disconnect() { + if (_conn_handle != BLE_CONN_HANDLE_INVALID) { + Bluefruit.disconnect(_conn_handle); // core API (was sd_ble_gap_disconnect) + } +} + +void SerialBLEInterface::disable() { + _isEnabled = false; + BLE_DEBUG_PRINTLN("SerialBLEInterface: disable"); + Bluefruit.Advertising.restartOnDisconnect(false); + Bluefruit.Advertising.stop(); + disconnect(); + _last_health_check = 0; +} + +size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) { + if (len > MAX_FRAME_SIZE) { + BLE_DEBUG_PRINTLN("writeFrame(), frame too big, len=%u", (unsigned)len); + return 0; + } + if (isConnected() && len > 0) { + if (send_queue_len >= FRAME_QUEUE_SIZE) { + BLE_DEBUG_PRINTLN("writeFrame(), send_queue is full!"); + return 0; + } + send_queue[send_queue_len].len = len; + memcpy(send_queue[send_queue_len].buf, src, len); + send_queue_len++; + return len; + } + return 0; +} + +size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { + if (send_queue_len > 0) { + if (!isConnected()) { + send_queue_len = 0; + } else { + unsigned long now = millis(); + bool throttle_active = (_last_retry_attempt > 0 && (now - _last_retry_attempt) < BLE_RETRY_THROTTLE_MS); + if (!throttle_active) { + Frame frame_to_send = send_queue[0]; + size_t written = bleuart.write(frame_to_send.buf, frame_to_send.len); + if (written == frame_to_send.len) { + _last_retry_attempt = 0; + shiftSendQueueLeft(); + } else if (written > 0) { + _last_retry_attempt = 0; + shiftSendQueueLeft(); + } else { + if (!isConnected()) { _last_retry_attempt = 0; shiftSendQueueLeft(); } + else { _last_retry_attempt = now; } + } + } + } + } + + if (recv_queue_len > 0) { + size_t len = recv_queue[0].len; + memcpy(dest, recv_queue[0].buf, len); + shiftRecvQueueLeft(); + return len; + } + + unsigned long now = millis(); + if (_isEnabled && !isConnected() && _conn_handle == BLE_CONN_HANDLE_INVALID) { + if (now - _last_health_check >= BLE_HEALTH_CHECK_INTERVAL) { + _last_health_check = now; + if (!isAdvertising()) Bluefruit.Advertising.start(0); + } + } + return 0; +} + +void SerialBLEInterface::onBleUartRX(uint16_t conn_handle) { + if (!instance) return; + if (instance->_conn_handle != conn_handle || !instance->isConnected()) { + while (instance->bleuart.available() > 0) instance->bleuart.read(); + return; + } + while (instance->bleuart.available() > 0) { + if (instance->recv_queue_len >= FRAME_QUEUE_SIZE) { + while (instance->bleuart.available() > 0) instance->bleuart.read(); + break; + } + int avail = instance->bleuart.available(); + if (avail > MAX_FRAME_SIZE) { + uint8_t drain_buf[BLE_RX_DRAIN_BUF_SIZE]; + while (instance->bleuart.available() > 0) { + int chunk = instance->bleuart.available() > BLE_RX_DRAIN_BUF_SIZE ? BLE_RX_DRAIN_BUF_SIZE : instance->bleuart.available(); + instance->bleuart.readBytes(drain_buf, chunk); + } + continue; + } + int read_len = avail; + instance->recv_queue[instance->recv_queue_len].len = read_len; + instance->bleuart.readBytes(instance->recv_queue[instance->recv_queue_len].buf, read_len); + instance->recv_queue_len++; + } +} + +bool SerialBLEInterface::isConnected() const { + return _isDeviceConnected && Bluefruit.connected() > 0; +} + +bool SerialBLEInterface::isWriteBusy() const { + return send_queue_len >= (FRAME_QUEUE_SIZE * 2 / 3); +} diff --git a/src/helpers/nrf54/SerialBLEInterface.h b/src/helpers/nrf54/SerialBLEInterface.h new file mode 100644 index 0000000000..21368e72a0 --- /dev/null +++ b/src/helpers/nrf54/SerialBLEInterface.h @@ -0,0 +1,86 @@ +#pragma once + +#include "../BaseSerialInterface.h" +#include + +// BLE serial interface for the nRF54L15 on the lolren bare-metal core. Adapted from +// helpers/nrf52/SerialBLEInterface: the Bluefruit C++ API (BLEUart/Advertising/Security/ +// DFU) is the same, but this core has NO SoftDevice, so the raw `sd_*` GAP calls are +// swapped for the core's Bluefruit52Lib equivalents (disconnect/getAddr/setConnInterval) +// and the optional raw conn-param-update event handler is dropped (defaults are used). + +#ifndef BLE_TX_POWER +#define BLE_TX_POWER 4 +#endif + +class SerialBLEInterface : public BaseSerialInterface { + BLEDfu bledfu; + BLEUart bleuart; + bool _isEnabled; + bool _isDeviceConnected; + uint16_t _conn_handle; + unsigned long _last_health_check; + unsigned long _last_retry_attempt; + + struct Frame { + uint8_t len; + uint8_t buf[MAX_FRAME_SIZE]; + }; + + #define FRAME_QUEUE_SIZE 12 + + uint8_t send_queue_len; + Frame send_queue[FRAME_QUEUE_SIZE]; + + uint8_t recv_queue_len; + Frame recv_queue[FRAME_QUEUE_SIZE]; + + void clearBuffers(); + void shiftSendQueueLeft(); + void shiftRecvQueueLeft(); + bool isValidConnection(uint16_t handle, bool requireWaitingForSecurity = false) const; + bool isAdvertising() const; + static void onConnect(uint16_t connection_handle); + static void onDisconnect(uint16_t connection_handle, uint8_t reason); + static void onSecured(uint16_t connection_handle); + static bool onPairingPasskey(uint16_t connection_handle, uint8_t const passkey[6], bool match_request); + static void onPairingComplete(uint16_t connection_handle, uint8_t auth_status); + static void onBleUartRX(uint16_t conn_handle); + +public: + SerialBLEInterface() { + _isEnabled = false; + _isDeviceConnected = false; + _conn_handle = BLE_CONN_HANDLE_INVALID; + _last_health_check = 0; + _last_retry_attempt = 0; + send_queue_len = 0; + recv_queue_len = 0; + } + + /** + * init the BLE interface. + * @param prefix a prefix for the device name + * @param name IN/OUT - a name for the device (combined with prefix). If "@@MAC", is modified and returned + * @param pin_code the BLE security pin + */ + void begin(const char* prefix, char* name, uint32_t pin_code); + + void disconnect(); + void enable() override; + void disable() override; + bool isEnabled() const override { return _isEnabled; } + bool isConnected() const override; + bool isWriteBusy() const override; + size_t writeFrame(const uint8_t src[], size_t len) override; + size_t checkRecvFrame(uint8_t dest[]) override; +}; + +#if BLE_DEBUG_LOGGING && ARDUINO + #include + #define BLE_DEBUG_PRINT(F, ...) Serial.printf("BLE: " F, ##__VA_ARGS__) + #define BLE_DEBUG_PRINTLN(F, ...) Serial.printf("BLE: " F "\n", ##__VA_ARGS__) +#else + #define BLE_DEBUG_PRINT(...) {} + #define BLE_DEBUG_PRINTLN(...) {} +#endif diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h new file mode 100644 index 0000000000..af8d12d395 --- /dev/null +++ b/src/helpers/radiolib/CustomLR2021.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include "MeshCore.h" + +// Custom RadioLib driver for the Semtech LR2021 (LoRa Plus), as used on the +// Seeed Wio-LR2021 (LR2021 + nRF54L15). Two board/chip quirks are handled here +// (both discovered during hardware bring-up): + + +#ifndef LR2021_IRQ_DIO + #define LR2021_IRQ_DIO 8 // Wio-LR2021 wires the LR2021's DIO8 to the host IRQ line +#endif + +#ifndef LR2021_RX_BOOST_LEVEL + #define LR2021_RX_BOOST_LEVEL 7 // matches Semtech usp_zephyr rx-boost-cfg = <7> +#endif + +class CustomLR2021 : public LR2021 { + uint8_t _rx_boost_level = 0; + +public: + CustomLR2021(Module *mod) : LR2021(mod) { } + + // route the host IRQ to the DIO the board actually wires (std_init does this + // too; callers that do their own begin() sequence use this directly) + void setIrqDio(uint8_t n) { irqDioNum = n; } + +#if defined(ARDUINO) // Arduino-core bring-up; Zephyr (compat shim, no SPIClass) drives begin() itself + bool std_init(SPIClass* spi = NULL) { + // route the host IRQ to the DIO the board actually wires (default DIO8) + irqDioNum = LR2021_IRQ_DIO; + + #ifdef LORA_CR + uint8_t cr = LORA_CR; + #else + uint8_t cr = 5; + #endif + + #if defined(P_LORA_SCLK) + #if defined(ESP32_PLATFORM) + if (spi) spi->begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI); + #elif defined(NRF52_PLATFORM) + if (spi) { spi->setPins(P_LORA_MISO, P_LORA_SCLK, P_LORA_MOSI); spi->begin(); } + #else + if (spi) spi->begin(); // bare-metal nRF54L15 core: SPI pins are fixed (D8/D9/D10) + #endif + #else + if (spi) spi->begin(); + #endif + + // tcxoVoltage = 0 -> skip SetTcxoMode (RadioLib mis-scales its start_time; see note above). + int status = begin(LORA_FREQ, LORA_BW, LORA_SF, cr, + RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE, LORA_TX_POWER, 16, 0.0f); + if (status != RADIOLIB_ERR_NONE) { + Serial.print("ERROR: LR2021 init failed: "); + Serial.println(status); + return false; + } + + setCRC(2); + explicitHeader(); + + #ifdef RX_BOOSTED_GAIN + if (RX_BOOSTED_GAIN) setRxBoostedGainMode(LR2021_RX_BOOST_LEVEL); + #endif + + return true; // success + } +#endif // ARDUINO + + size_t getPacketLength(bool update) override { + size_t len = LR2021::getPacketLength(update); + if (len == 0 && (getIrqFlags() & RADIOLIB_LR2021_IRQ_LORA_HDR_CRC_ERROR)) { + // corrupted header: return to a known-good state; recvRaw restarts RX + MESH_DEBUG_PRINTLN("LR2021: got header CRC err, calling standby()"); + standby(); + } + return len; + } + +#if RADIOLIB_GODMODE + int16_t startReceive() override { + // re-assert max payload length before every RX: a TX leaves the chip's + // packet-length param at the last TX size, which would clip longer + // incoming packets. Needs GODMODE (setLoRaPacketParams is private). + setLoRaPacketParams(this->preambleLengthLoRa, this->headerType, + RADIOLIB_LR2021_MAX_PACKET_LENGTH, this->crcTypeLoRa, + this->invertIQEnabled); + return LR2021::startReceive(); + } +#endif + + bool isReceiving() { + uint32_t irq = getIrqFlags(); + return (irq & RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED) + || (irq & RADIOLIB_LR2021_IRQ_LORA_HEADER_VALID); + } + + int16_t setRxBoostedGainMode(uint8_t level) { + _rx_boost_level = level; + return LR2021::setRxBoostedGainMode(level); + } + + uint8_t getRxBoostLevel() const { return _rx_boost_level; } +}; diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h new file mode 100644 index 0000000000..4b3ccb4729 --- /dev/null +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -0,0 +1,54 @@ +#pragma once + +#include "CustomLR2021.h" +#include "RadioLibWrappers.h" + +// MeshCore wrapper for the LR2021. Implements the RadioLibWrapper hooks the mesh +// engine needs, using the LR2021's RadioLib API (getRssiInst / getRSSI / getSNR / +// boosted-gain). doResetAGC() is left to the base-class default for now; add an +// LR2021-specific reset only if RX sensitivity degrades over long runs. + +class CustomLR2021Wrapper : public RadioLibWrapper { +public: + CustomLR2021Wrapper(CustomLR2021& radio, mesh::MainBoard& board) + : RadioLibWrapper(radio, board) { } + + void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + ((CustomLR2021 *)_radio)->setFrequency(freq); + ((CustomLR2021 *)_radio)->setSpreadingFactor(sf); + ((CustomLR2021 *)_radio)->setBandwidth(bw); + ((CustomLR2021 *)_radio)->setCodingRate(cr); + updatePreamble(sf); + } + + bool isReceivingPacket() override { + return ((CustomLR2021 *)_radio)->isReceiving(); + } + + void onBeforeStartRecv() override { + // re-arming (setRxPath) while still in continuous RX -> CMD_PERR (-706) + // and a wedged receiver; drop to standby before every startReceive() + _radio->standby(); + } + + float getCurrentRSSI() override { + float rssi = -110; + ((CustomLR2021 *)_radio)->getRssiInst(&rssi); + return rssi; + } + + void onSendFinished() override { + RadioLibWrapper::onSendFinished(); + _radio->setPreambleLength(16); // overcomes weird issues with small and big pkts + } + + float getLastRSSI() const override { return ((CustomLR2021 *)_radio)->getRSSI(); } + float getLastSNR() const override { return ((CustomLR2021 *)_radio)->getSNR(); } + + void setRxBoostedGainMode(bool en) override { + ((CustomLR2021 *)_radio)->setRxBoostedGainMode(en ? LR2021_RX_BOOST_LEVEL : 0); + } + bool getRxBoostedGainMode() const override { + return ((CustomLR2021 *)_radio)->getRxBoostLevel() != 0; + } +}; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index b6519aefa7..86d9abc730 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -104,6 +104,7 @@ void RadioLibWrapper::loop() { } void RadioLibWrapper::startRecv() { + onBeforeStartRecv(); int err = _radio->startReceive(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; @@ -128,7 +129,6 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { len = 0; n_recv_errors++; } else { - // Serial.print(" readData() -> "); Serial.println(len); n_recv++; } } @@ -136,6 +136,7 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { } if (state != STATE_RX) { + onBeforeStartRecv(); int err = _radio->startReceive(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index efd3e17931..76ad90c154 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -1,5 +1,6 @@ #pragma once +#include // min/max/random (RadioLib's generic build omits these) #include #include @@ -18,6 +19,10 @@ class RadioLibWrapper : public mesh::Radio { float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; virtual void doResetAGC(); + // Called right before every startReceive(). Default no-op; radios that cannot + // re-arm RX while already in continuous RX (LR2021) override this to drop to + // standby first. + virtual void onBeforeStartRecv() { } public: RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; } diff --git a/variants/xiao_nrf54l15/README.md b/variants/xiao_nrf54l15/README.md new file mode 100644 index 0000000000..430b65f203 --- /dev/null +++ b/variants/xiao_nrf54l15/README.md @@ -0,0 +1,17 @@ +# MeshCore variant for the Semtech LR2021 Evaluation Kit + +The kit hosts a Seeed Studio XIAO nRF54L15 Core Board driving the [Semtech LR2021 transceivers](https://www.semtech.fr/products/wireless-rf/lora-plus/lr2021) + +https://wiki.seeedstudio.com/semtech_lr2021_evk_getting_started/ + +Supports LoRa CSS on 868 MHz and 2.4 GHz bands + +Recommanded channels for ISM2400 (2400-2483 MHz) : 2403 MHz, 2425 MHz, 2479 MHz with DC 100%. [ref1](https://www.semtech.com/uploads/technology/LoRa/phy-layer-2g4.pdf), [ref2](https://github.com/CampusIoT/datasets/tree/main/TourPerret2G4) + +## TODO +* [ ] floor the bandwith provided by the app on LoRa CSS 2G4 bandwidths (203kHz, 406kHz, 812kHz 1625kHz?) +* [ ] add Long Interleaver (Li) Coding Rate for LoRa CSS on 2.4 GHz +* [ ] support USER button (send advert on double click) +* [ ] support OLED screen +* [ ] fix persistence of BLE pairing +* [ ] add FLRC modulation for high-speed datarates (EU868 and [ISM2400](https://hal.science/hal-05429890)) diff --git a/variants/xiao_nrf54l15/XiaoNrf54l15Board.h b/variants/xiao_nrf54l15/XiaoNrf54l15Board.h new file mode 100644 index 0000000000..360ba61c53 --- /dev/null +++ b/variants/xiao_nrf54l15/XiaoNrf54l15Board.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +// Minimal MainBoard for the Seeed XIAO nRF54L15 on the lolren bare-metal Arduino +// core. Implements only the mesh::MainBoard pure virtuals plus begin(); battery, +// power management, sleep, OTA etc. are stubbed for bring-up (Phase 4) and filled +// in later phases against the core's APIs. + +#ifndef PIN_VBAT_READ + #define PIN_VBAT_READ (PIN_A7) // XIAO nRF54L15: AIN7 / VBAT divider (see core pins_arduino.h) +#endif + +class XiaoNrf54l15Board : public mesh::MainBoard { +protected: + uint8_t startup_reason = 0; + +public: + void begin() { + startup_reason = 0; // BOOT_REASON normal; refine with hwinfo/reset-cause later + } + + uint16_t getBattMilliVolts() override { + // TODO Phase 5: read PIN_VBAT_READ via ADC + the board's divider ratio. + return 0; + } + + const char* getManufacturerName() const override { + return "Seeed XIAO nRF54L15"; + } + + void reboot() override { + NVIC_SystemReset(); // Cortex-M33 system reset + } + + uint8_t getStartupReason() const override { return startup_reason; } +}; diff --git a/variants/xiao_nrf54l15/target.cpp b/variants/xiao_nrf54l15/target.cpp new file mode 100644 index 0000000000..f5c40a483d --- /dev/null +++ b/variants/xiao_nrf54l15/target.cpp @@ -0,0 +1,36 @@ +#include +#include "target.h" + +// Variant glue for the XIAO nRF54L15 + LR2021 (definitions). + +XiaoNrf54l15Board board; + +CustomLR2021 radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); +CustomLR2021Wrapper radio_driver(radio, board); + +VolatileRTCClock rtc_clock; +SensorManager sensors; // no-op stub (Phase 5 full repeater expects a `sensors` global) + +bool radio_init() { + return radio.std_init(&SPI); // sets irqDioNum=8 + begin(tcxoVoltage=0); see CustomLR2021.h +} + +uint32_t radio_get_rng_seed() { + return radio.random(0x7FFFFFFF); +} + +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { + radio.setFrequency(freq); + radio.setSpreadingFactor(sf); + radio.setBandwidth(bw); + radio.setCodingRate(cr); +} + +void radio_set_tx_power(int8_t dbm) { + radio.setOutputPower(dbm); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // new random identity from radio RSSI noise +} diff --git a/variants/xiao_nrf54l15/target.h b/variants/xiao_nrf54l15/target.h new file mode 100644 index 0000000000..60ab0c8293 --- /dev/null +++ b/variants/xiao_nrf54l15/target.h @@ -0,0 +1,62 @@ +#pragma once +// Variant glue for the XIAO nRF54L15 + LR2021 (declarations). +// Mirrors MeshCore's target.h convention: extern the board/radio globals and +// declare the radio_* entry points; target.cpp defines them. + +#include +#include // top-level MeshCore header: makes arduino-cli pull in the MeshCore library +#include +#include + +// --- Board/radio config. In a full PlatformIO variant these are build flags; --- +// --- defined here (guarded) so the .cpp compile standalone under arduino-cli. --- +// --- MUST precede the CustomLR2021 include: its std_init() expands LORA_*. --- +// MeshCore canonical EU defaults (match platformio.ini arduino_base) so this node +// interoperates with any stock MeshCore EU device, not just the 2nd Wio-LR2021. +// freq/bw/sf/cr MUST match between any two nodes that need to hear each other. +#ifndef LORA_FREQ + #define LORA_FREQ 869.618 +#endif +#ifndef LORA_BW + #define LORA_BW 62.5 +#endif +#ifndef LORA_SF + #define LORA_SF 8 +#endif +#ifndef LORA_CR + #define LORA_CR 5 +#endif +#ifndef LORA_TX_POWER + #define LORA_TX_POWER 22 // LR2021 LF PA capable; reduce for EU ERP limits / duty cycle as needed +#endif + +// LR2021 wiring on the Wio-LR2021 (verified on hardware) +#ifndef P_LORA_NSS + #define P_LORA_NSS PIN_D3 +#endif +#ifndef P_LORA_DIO_1 + #define P_LORA_DIO_1 PIN_D0 +#endif +#ifndef P_LORA_RESET + #define P_LORA_RESET PIN_D2 +#endif +#ifndef P_LORA_BUSY + #define P_LORA_BUSY PIN_D1 +#endif + +#include +#include "XiaoNrf54l15Board.h" +#include + +// Globals defined in target.cpp +extern XiaoNrf54l15Board board; +extern CustomLR2021Wrapper radio_driver; +extern VolatileRTCClock rtc_clock; +extern SensorManager sensors; // no-op stub (full repeater expects a `sensors` global) + +// Radio entry points (the contract the firmware / mesh engine call) +bool radio_init(); +uint32_t radio_get_rng_seed(); +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr); +void radio_set_tx_power(int8_t dbm); +mesh::LocalIdentity radio_new_identity(); diff --git a/zephyr-port/.gitignore b/zephyr-port/.gitignore new file mode 100644 index 0000000000..15ff0965f9 --- /dev/null +++ b/zephyr-port/.gitignore @@ -0,0 +1,5 @@ +# Zephyr build artifacts (we build out-of-tree to /tmp, but ignore any local build dirs) +build/ +build-*/ +*.hex +*.elf diff --git a/zephyr-port/07_companion/CMakeLists.txt b/zephyr-port/07_companion/CMakeLists.txt new file mode 100644 index 0000000000..b5fbe49d46 --- /dev/null +++ b/zephyr-port/07_companion/CMakeLists.txt @@ -0,0 +1,79 @@ +# Step 4e — full MeshCore companion (companion_radio MyMesh) on the Zephyr seams. +cmake_minimum_required(VERSION 3.20.0) +find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) +project(mc_zephyr_companion) + +get_filename_component(MC_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +set(CR "${MC_ROOT}/examples/companion_radio") +set(ALFS "${MC_ROOT}/arch/stm32/Adafruit_LittleFS_stm32/src") +set(RADIOLIB "$ENV{HOME}/Arduino/libraries/RadioLib/src") +set(CRYPTO "$ENV{HOME}/Arduino/libraries/Crypto/src") +set(CAYENNE "$ENV{HOME}/Arduino/libraries/CayenneLPP/src") +set(BASE64 "$ENV{HOME}/Arduino/libraries/base64/src") +set(RTCLIB "$ENV{HOME}/Arduino/libraries/RTClib/src") +set(AJSON "$ENV{HOME}/Arduino/libraries/ArduinoJson/src") +set(ED25519 "${MC_ROOT}/lib/ed25519") + +# MeshCore core + helper set (mirrors the Arduino companion curation) +file(GLOB MC_CORE ${MC_ROOT}/src/*.cpp) +set(MC_HELPERS + ${MC_ROOT}/src/helpers/BaseChatMesh.cpp + ${MC_ROOT}/src/helpers/TransportKeyStore.cpp + ${MC_ROOT}/src/helpers/IdentityStore.cpp + ${MC_ROOT}/src/helpers/RegionMap.cpp + ${MC_ROOT}/src/helpers/ClientACL.cpp + ${MC_ROOT}/src/helpers/CommonCLI.cpp + ${MC_ROOT}/src/helpers/AdvertDataHelpers.cpp + ${MC_ROOT}/src/helpers/TxtDataHelpers.cpp + ${MC_ROOT}/src/helpers/StaticPoolPacketManager.cpp + ${MC_ROOT}/src/helpers/radiolib/RadioLibWrappers.cpp) + +# companion firmware +set(MC_APP ${CR}/MyMesh.cpp ${CR}/DataStore.cpp) + +# third-party (generic/non-Arduino mode for RadioLib) +file(GLOB_RECURSE RL_SOURCES ${RADIOLIB}/*.cpp) +list(FILTER RL_SOURCES EXCLUDE REGEX "/hal/Arduino/") +file(GLOB CRYPTO_SRC ${CRYPTO}/*.cpp ${CRYPTO}/*.c) +file(GLOB BASE64_SRC ${BASE64}/*.cpp) +# CayenneLPP + RTClib are shimmed in compat/ (header-only) — real libs pull STL/I2C. +file(GLOB ED_SRC ${ED25519}/*.c) +set(ALFS_SRC ${ALFS}/Adafruit_LittleFS.cpp ${ALFS}/Adafruit_LittleFS_File.cpp + ${ALFS}/littlefs/lfs.c ${ALFS}/littlefs/lfs_util.c) + +target_sources(app PRIVATE + src/main.cpp src/target.cpp src/zephyr_internal_fs.cpp src/serial_ble_interface.cpp + ${MC_CORE} ${MC_HELPERS} ${MC_APP} + ${RL_SOURCES} ${CRYPTO_SRC} ${BASE64_SRC} ${ED_SRC} ${ALFS_SRC}) + +target_include_directories(app PRIVATE + compat src ${MC_ROOT}/src ${CR} ${ALFS} + ${RADIOLIB} ${CRYPTO} ${BASE64} ${ED25519}) + +target_compile_definitions(app PRIVATE + NRF54_PLATFORM=1 RADIOLIB_GODMODE=1 MAX_GROUP_CHANNELS=8 MAX_CONTACTS=100 BLE_PIN_CODE=123456 + LFS_NO_ASSERT # mount-fail (e.g. stale/repartitioned region) -> graceful format, not abort() + # App TX-power slider max + firmware input check. Pin to the LR2021's absolute (sub-GHz LF) + # PA ceiling of 22 dBm, INDEPENDENT of the band/boot default, so a 2.4 GHz build still allows + # 22 once tuned back to sub-GHz. The real per-band limit (HF caps at +12) is enforced at the + # radio layer by clamp_tx_for_band() in target.cpp. (Otherwise MAX would follow LORA_TX_POWER, + # which MC_BAND_2G4 sets to 12 -> the app would be stuck at 12 even on 869 MHz.) + MAX_LORA_TX_POWER=22) + +# 2.4 GHz band: build with `-DMC_BAND_2G4=ON` (e.g. west build -- -DMC_BAND_2G4=ON) to boot on +# the LR2021's 2.4 GHz preset (target.h: 2450 MHz / 500 kHz / SF8 / CR5, TX clamped to +12 dBm) +# instead of the default 865 MHz sub-GHz. The app has no 2.4 GHz preset, so this is how to put +# the node on 2.4 GHz for the over-the-air RF test (the choice then persists in prefs). +option(MC_BAND_2G4 "Boot on the 2.4 GHz LoRa preset instead of sub-GHz" OFF) +# LoRa boot params must be global -D flags (mirroring platformio.ini), not header +# defines: core headers (e.g. RadioLibWrappers.h getSpreadingFactor) expand LORA_SF +# in translation units that never include target.h. Keep in sync with the presets +# documented in src/target.h. +if(MC_BAND_2G4) + target_compile_definitions(app PRIVATE MC_BAND_2G4=1 + LORA_FREQ=2450.0f LORA_BW=500.0f LORA_SF=8 LORA_CR=5 LORA_TX_POWER=12) + message(STATUS "MeshCore: building for 2.4 GHz band (2450 MHz / 500 kHz / SF8 / CR5)") +else() + target_compile_definitions(app PRIVATE + LORA_FREQ=869.618f LORA_BW=62.5f LORA_SF=8 LORA_CR=5 LORA_TX_POWER=22) +endif() diff --git a/zephyr-port/07_companion/README.md b/zephyr-port/07_companion/README.md new file mode 100644 index 0000000000..533bcc9f34 --- /dev/null +++ b/zephyr-port/07_companion/README.md @@ -0,0 +1,130 @@ +# MeshCore companion — Seeed XIAO nRF54L15 + LR2021 (Zephyr) + +A MeshCore **companion-radio** node (the `examples/companion_radio` firmware, BLE-paired to the +MeshCore phone app) running on the Seeed Studio XIAO nRF54L15 with a Semtech LR2021 radio, built on +**mainline Zephyr** (not NCS). The MeshCore core, helpers, and the companion app are compiled +straight from this repo; the radio driver is stock RadioLib in generic (non-Arduino) mode. + +## What's in here + +| Path | Role | +|---|---| +| `src/main.cpp` | Zephyr entry point; runs the MeshCore companion loop | +| `src/target.cpp`, `src/target.h` | board/radio bring-up: pin map, band presets, TX-power clamp; radio = the shared `CustomLR2021`/`CustomLR2021Wrapper` | +| `src/serial_ble_interface.cpp/.h` | BLE transport: custom encryption-gated Nordic-UART (NUS) GATT service; central-driven pairing | +| `src/zephyr_internal_fs.cpp` | `InternalFileSystem` backed by a Zephyr flash partition (LittleFS) for prefs/identity | +| `compat/` | header-only shims (CayenneLPP, RTClib, Arduino glue) so the core builds without those libs | +| `dts/`, `app.overlay`, `prj.conf` | devicetree overlay (SPI/GPIO for the LR2021) and Kconfig | + +All LR2021-specific radio fixes (standby before re-arming RX, header-CRC recovery, RX +max-length re-assert) live in `src/helpers/radiolib/CustomLR2021.h` / +`CustomLR2021Wrapper.h` at the repo root, shared with the bare-metal variant +(`variants/xiao_nrf54l15`). The core `RadioLibWrapper` exposes a neutral +`onBeforeStartRecv()` hook for the standby quirk, so other radios keep stock behaviour. + +## Prerequisites + +1. **Mainline Zephyr 4.4.99** (has the `xiao_nrf54l15` board) + its Python venv and the matching + **Zephyr SDK**. A typical layout: + - `ZEPHYR_BASE=$HOME/zephyrproject/zephyr` + - west + deps in a venv, e.g. `$HOME/.zephyr-venv` +2. **Arduino libraries** under `$HOME/Arduino/libraries/` (the `CMakeLists.txt` references them by + that absolute path). Only three are actually compiled/included: + - `RadioLib/` — the LR2021 driver (generic HAL; the Arduino HAL is excluded by the build) + - `Crypto/` + - `base64/` + + (`CayenneLPP`, `RTClib`, `ArduinoJson` are *not* needed — they are shimmed in `compat/`.) + `ed25519` ships in the repo (`lib/ed25519`). + +## Build + +From this directory (`zephyr-port/07_companion`): + +```sh +source $HOME/.zephyr-venv/bin/activate +export ZEPHYR_BASE=$HOME/zephyrproject/zephyr + +west build -b xiao_nrf54l15/nrf54l15/cpuapp -d build . --pristine +``` + +Default band is **sub-GHz (869.618 MHz / 62.5 kHz / SF8 / CR5)**. For the **2.4 GHz** preset +(2450 MHz / 500 kHz / SF8 / CR5, TX clamped to +12 dBm) add: + +```sh +west build -b xiao_nrf54l15/nrf54l15/cpuapp -d build . --pristine -- -DMC_BAND_2G4=ON +``` + +`MC_BAND_2G4` only sets the **first-boot** band; the running band/SF/BW/CR is also stored in prefs +and can be changed live from the app (`CMD_SET_RADIO_PARAMS`); and that choice now persists across +reboots. Two nodes must be on the same band to hear each other. + +## Flash + +The XIAO's on-board debugger enumerates as a **CMSIS-DAP** probe, so the simplest method is +**pyocd**. The pyocd target name is **`nrf54l`** (not `nrf54l15`), and `-e chip` does a clean erase: + +```sh +pyocd flash -t nrf54l -e chip build/zephyr/zephyr.hex +pyocd reset -t nrf54l +``` + +With more than one board attached, add `-u ` (from `pyocd list`) so you flash the right +one — `-e chip` erases whatever you point it at. + +`west flash` also works if you have J-Link or OpenOCD set up (the board defines both runners; there +is no pyocd runner): + +```sh +west flash -d build --runner jlink # or: --runner openocd +``` + +## Monitor (console / BLE PIN) + +The nRF54L15 has **no USB**, so the console (boot log, the BLE PIN, and the `FS:` / `PREFS:` / +`radio_set_params` diagnostics) is read over the SWD probe via **RTT**. The default build sends the +console to a UART; to get RTT, build with a small overlay and read it with pyocd: + +```sh +# rtt.conf +CONFIG_USE_SEGGER_RTT=y +CONFIG_RTT_CONSOLE=y +CONFIG_UART_CONSOLE=n + +west build -b xiao_nrf54l15/nrf54l15/cpuapp -d build . -- -DEXTRA_CONF_FILE=rtt.conf +pyocd rtt -t nrf54l +``` + +## Pair + +Advertises as **`MeshCore-`**; e.g. `MeshCore-544BA815` (the pubkey-derived default +name) or `MeshCore-NRF54L15-1` after you rename it; a rename from the app updates the advertised +name **live**, no reboot. In the MeshCore phone app, add a new companion device and pair; the node +uses a fixed passkey **`123456`** (printed on the RTT console at boot). Pairing is **central-driven** +(the app initiates encryption on first access to the NUS characteristics), which keeps iOS/Windows +from dropping the link right after the PIN. + +## Notes / limitations + +- **Prefs, identity, and contacts persist across power loss; BLE bonds do not** + (`CONFIG_BT_SETTINGS` off). After a node reboot a previously-paired phone must **forget & re-pair** + (it tries to resume a bond the node no longer holds → `0x13` disconnect loop). The correct settings + backend for RRAM is **ZMS** (not NVS; RRAM has no erase); a 12 KB `settings_partition` is already + reserved in `app.overlay` for when this is enabled. Future work (see `prj.conf`). +- **Filesystem persistence depends on `read_size == prog_size == block_size`** in + `zephyr_internal_fs.cpp` (matches the proven bare-metal `helpers/nrf54` FS). With a smaller + prog/read size, littlefs-v1's in-block commit-log path does **not** read back after a cold boot on + RRAM; the FS remounts to its empty post-format state, so identity/prefs silently reset every boot. + Erase is a no-op (RRAM is byte-alterable). +- **`CONFIG_BT_CTLR_ASSERT_OVERHEAD_START=n`** (which needs `CONFIG_BT_CTLR_ADVANCED_FEATURES=y` to be + overridable; it lives in a `visible if` menu). The first BLE advertising event races the boot + bring-up (radio SPI + FS mount) and runs tens of ms late; left at its default `y` the controller + turns that into a **fatal** assert that takes the whole device (and the LoRa mesh) down. Disabled, + the controller just skips the late event and continues. Likewise, a runtime band change + (`radio_set_params`) is kept lightweight so it can't starve the live BLE connection into a fault. +- The NUS characteristics require **authenticated (MITM) pairing** (`BT_GATT_PERM_WRITE_AUTHEN`); a + central that only does Just Works would be rejected. Relax to `_ENCRYPT` in + `serial_ble_interface.cpp` if needed. +- RRAM writes run synchronously (`CONFIG_SOC_FLASH_NRF_RADIO_SYNC_NONE`) to avoid a ~24 s + settings-write stall while BLE-connected. +- The board has no chip-controlled TCXO; the radio runs on the crystal (`tcxoVoltage = 0`). diff --git a/zephyr-port/07_companion/app.overlay b/zephyr-port/07_companion/app.overlay new file mode 100644 index 0000000000..e52f3ddd0e --- /dev/null +++ b/zephyr-port/07_companion/app.overlay @@ -0,0 +1,44 @@ +/* + * LR2021 wiring on the XIAO nRF54L15 (matches variants/xiao_nrf54l15/target.h): + * SPI = spi00 (xiao_spi): SCK=P2.1(D8) MOSI=P2.2(D10) MISO=P2.4(D9) + * NSS = P1.7 (D3) DIO1 = P1.4 (D0) RESET = P1.6 (D2) BUSY = P1.5 (D1) + * + * RadioLib drives NSS itself (digitalWrite), so the SPI controller is used WITHOUT + * a hardware CS. The four control pins are plain GPIOs (flags=0 / active-high; the + * HAL uses raw ops so RadioLib's LOW/HIGH map to physical levels, as on Arduino). + */ +&spi00 { + status = "okay"; +}; + +/* + * Split the 36 KB RRAM storage region (0x174000..0x17D000): MeshCore's LittleFS keeps + * 24 KB; a 12 KB NVS settings partition holds BLE bonds (CONFIG_BT_SETTINGS) so the app + * doesn't have to re-pair after every reboot. + */ +&storage_partition { + reg = <0x174000 DT_SIZE_K(24)>; +}; + +/* add the settings partition as a sibling under the existing fixed-partitions node */ +&{/soc/rram-controller@5004b000/rram@0/partitions} { + settings_partition: partition@17a000 { + compatible = "zephyr,mapped-partition"; + reg = <0x17a000 DT_SIZE_K(12)>; + label = "settings"; + }; +}; + +/ { + chosen { + zephyr,settings-partition = &settings_partition; + }; + + lora0: lora0 { + compatible = "mc,lora-ctrl"; + nss-gpios = <&gpio1 7 0>; + dio1-gpios = <&gpio1 4 0>; + reset-gpios = <&gpio1 6 0>; + busy-gpios = <&gpio1 5 0>; + }; +}; diff --git a/zephyr-port/07_companion/compat/Arduino.h b/zephyr-port/07_companion/compat/Arduino.h new file mode 100644 index 0000000000..5cf5e4b275 --- /dev/null +++ b/zephyr-port/07_companion/compat/Arduino.h @@ -0,0 +1,107 @@ +/* + * Minimal Arduino-compatibility shim for building MeshCore (and its Arduino-style + * deps: rweather/Crypto, ArduinoHelpers) under Zephyr. Mapped to Zephyr APIs. + * Lives on the include path so MeshCore's `#include ` resolves here. + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef uint8_t byte; +typedef bool boolean; + +#ifndef HIGH +#define HIGH 1 +#define LOW 0 +#define INPUT 0 +#define OUTPUT 1 +#define INPUT_PULLUP 2 +#endif + +#ifndef min +#define min(a, b) ((a) < (b) ? (a) : (b)) +#endif +#ifndef max +#define max(a, b) ((a) > (b) ? (a) : (b)) +#endif +#ifndef constrain +#define constrain(x, lo, hi) ((x) < (lo) ? (lo) : ((x) > (hi) ? (hi) : (x))) +#endif +#ifndef abs +#define abs(x) ((x) > 0 ? (x) : -(x)) +#endif + +/* Flash-string helpers are no-ops (everything is in RAM/RRAM here). */ +#define PROGMEM +#define PSTR(s) (s) +#define F(s) (s) +#define pgm_read_byte(addr) (*(const uint8_t *)(addr)) +#define pgm_read_word(addr) (*(const uint16_t *)(addr)) +typedef const char *__FlashStringHelper; + +#ifdef __cplusplus + +#include "Print.h" +#include "Stream.h" + +static inline uint32_t millis(void) { return (uint32_t)k_uptime_get(); } +static inline uint32_t micros(void) { return (uint32_t)k_ticks_to_us_floor64(k_uptime_ticks()); } +static inline void delay(uint32_t ms) { k_msleep((int32_t)ms); } +static inline void delayMicroseconds(uint32_t us) { k_busy_wait(us); } +static inline void yield(void) {} + +/* newlib-nano / picolibc omit ltoa (MeshCore's TxtDataHelpers uses it). */ +#ifndef MC_COMPAT_LTOA +#define MC_COMPAT_LTOA +static inline char *ltoa(long value, char *result, int base) { + if (base < 2 || base > 36) { *result = '\0'; return result; } + char *ptr = result, *ptr1 = result, tmp_char; + long tmp_value; + do { + tmp_value = value; + value /= base; + *ptr++ = "zyxwvutsrqponmlkjihgfedcba9876543210123456789abcdefghijklmnopqrstuvwxyz"[35 + (tmp_value - value * base)]; + } while (value); + if (tmp_value < 0) *ptr++ = '-'; + *ptr-- = '\0'; + while (ptr1 < ptr) { tmp_char = *ptr; *ptr-- = *ptr1; *ptr1++ = tmp_char; } + return result; +} +#endif + +static inline void randomSeed(unsigned long seed) { (void)seed; } /* HW TRNG, seed ignored */ +static inline long random(long howbig) { + return howbig > 0 ? (long)(sys_rand32_get() % (uint32_t)howbig) : 0; +} +static inline long random(long howsmall, long howbig) { + return howbig <= howsmall ? howsmall : howsmall + random(howbig - howsmall); +} + +/* Serial -> printk shim. Inherits Print (print/println overloads); adds printf. */ +class SerialShim : public Stream { + public: + void begin(unsigned long) {} + void end() {} + void flush() {} + explicit operator bool() const { return true; } + using Print::write; + size_t write(uint8_t c) override { printk("%c", c); return 1; } + __attribute__((format(printf, 2, 3))) + int printf(const char *fmt, ...) { + va_list ap; va_start(ap, fmt); + vprintk(fmt, ap); + va_end(ap); + return 0; + } +}; +extern SerialShim Serial; + +#endif /* __cplusplus */ diff --git a/zephyr-port/07_companion/compat/CayenneLPP.h b/zephyr-port/07_companion/compat/CayenneLPP.h new file mode 100644 index 0000000000..32d4ca200d --- /dev/null +++ b/zephyr-port/07_companion/compat/CayenneLPP.h @@ -0,0 +1,39 @@ +/* + * Minimal CayenneLPP shim for the Zephyr companion. The companion/SensorManager only + * use reset/addVoltage/getBuffer/getSize; the real lib pulls STL (libstdc++). + * This header-only shim keeps the build on picolibc + the Arduino min/max macros. + */ +#pragma once +#include +#include + +#ifndef MC_LPP_BUF +#define MC_LPP_BUF 64 +#endif + +class CayenneLPP { + uint8_t _buf[MC_LPP_BUF]; + uint8_t _pos; + public: + explicit CayenneLPP(uint8_t size = MC_LPP_BUF) { (void)size; _pos = 0; } + void reset() { _pos = 0; } + uint8_t *getBuffer() { return _buf; } + uint8_t getSize() { return _pos; } + uint8_t getError() { return 0; } + + /* Cayenne LPP data channels the companion may emit (channel,type,payload). */ + uint8_t addVoltage(uint8_t ch, float v) { return put(ch, 0x74, (int32_t)(v * 100), 2); } + uint8_t addTemperature(uint8_t ch, float t) { return put(ch, 0x67, (int32_t)(t * 10), 2); } + uint8_t addRelativeHumidity(uint8_t ch, float h) { return put(ch, 0x68, (int32_t)(h * 2), 1); } + uint8_t addAnalogInput(uint8_t ch, float a) { return put(ch, 0x02, (int32_t)(a * 100), 2); } + uint8_t addDigitalInput(uint8_t ch, uint32_t d) { return put(ch, 0x00, (int32_t)d, 1); } + + private: + uint8_t put(uint8_t ch, uint8_t type, int32_t val, uint8_t nbytes) { + if (_pos + 2 + nbytes > (int)sizeof(_buf)) return 0; + _buf[_pos++] = ch; + _buf[_pos++] = type; + for (int i = nbytes - 1; i >= 0; --i) _buf[_pos++] = (uint8_t)(val >> (8 * i)); + return 1; + } +}; diff --git a/zephyr-port/07_companion/compat/Print.h b/zephyr-port/07_companion/compat/Print.h new file mode 100644 index 0000000000..f60d3dbd34 --- /dev/null +++ b/zephyr-port/07_companion/compat/Print.h @@ -0,0 +1,60 @@ +/* Minimal Arduino Print base for the Zephyr compat layer. */ +#pragma once +#include +#include +#include +#include +#include + +#define DEC 10 +#define HEX 16 + +class Print { + public: + __attribute__((format(printf, 2, 3))) + int printf(const char *fmt, ...) { + char b[160]; + va_list ap; va_start(ap, fmt); + int n = vsnprintf(b, sizeof(b), fmt, ap); + va_end(ap); + if (n > (int)sizeof(b)) n = sizeof(b); + return (int)write((const uint8_t *)b, n < 0 ? 0 : n); + } + virtual ~Print() {} + virtual size_t write(uint8_t c) = 0; + virtual size_t write(const uint8_t *buf, size_t size) { + size_t n = 0; + while (size--) { n += write(*buf++); } + return n; + } + size_t write(const char *s) { return write((const uint8_t *)s, strlen_(s)); } + + size_t print(const char *s) { return write(s); } + size_t print(char c) { return write((uint8_t)c); } + size_t print(int v, int base = DEC) { return printNum((long)v, base); } + size_t print(unsigned v, int base = DEC) { return printNum((unsigned long)v, base); } + size_t print(long v, int base = DEC) { return printNum(v, base); } + size_t print(unsigned long v, int base = DEC) { return printNum(v, base); } + size_t print(double v) { char b[32]; int n = snprintf(b, sizeof(b), "%g", v); return write((const uint8_t *)b, n); } + + size_t println() { return write((uint8_t)'\n'); } + size_t println(const char *s) { size_t n = print(s); return n + println(); } + size_t println(int v, int base = DEC) { size_t n = print(v, base); return n + println(); } + size_t println(unsigned long v, int base = DEC) { size_t n = print(v, base); return n + println(); } + size_t println(double v) { size_t n = print(v); return n + println(); } + + private: + static size_t strlen_(const char *s) { size_t n = 0; while (s[n]) n++; return n; } + size_t printNum(long v, int base) { + char b[24]; + int n = (base == HEX) ? snprintf(b, sizeof(b), "%lx", (unsigned long)v) + : snprintf(b, sizeof(b), "%ld", v); + return write((const uint8_t *)b, n); + } + size_t printNum(unsigned long v, int base) { + char b[24]; + int n = (base == HEX) ? snprintf(b, sizeof(b), "%lx", v) + : snprintf(b, sizeof(b), "%lu", v); + return write((const uint8_t *)b, n); + } +}; diff --git a/zephyr-port/07_companion/compat/RTClib.h b/zephyr-port/07_companion/compat/RTClib.h new file mode 100644 index 0000000000..d465d27422 --- /dev/null +++ b/zephyr-port/07_companion/compat/RTClib.h @@ -0,0 +1,25 @@ +/* + * Minimal RTClib shim for the Zephyr companion. MeshCore only uses DateTime (epoch + * <-> Y/M/D h:m:s); the real RTClib pulls Adafruit_I2CDevice/Wire. Header-only. + */ +#pragma once +#include +#include + +class DateTime { + uint32_t _epoch; + struct tm _tm; + public: + DateTime(uint32_t t = 0) : _epoch(t) { + time_t tt = (time_t)t; + struct tm *r = gmtime(&tt); + if (r) _tm = *r; else { _tm = {}; } + } + uint32_t unixtime() const { return _epoch; } + uint16_t year() const { return (uint16_t)(_tm.tm_year + 1900); } + uint8_t month() const { return (uint8_t)(_tm.tm_mon + 1); } + uint8_t day() const { return (uint8_t)_tm.tm_mday; } + uint8_t hour() const { return (uint8_t)_tm.tm_hour; } + uint8_t minute() const { return (uint8_t)_tm.tm_min; } + uint8_t second() const { return (uint8_t)_tm.tm_sec; } +}; diff --git a/zephyr-port/07_companion/compat/Stream.h b/zephyr-port/07_companion/compat/Stream.h new file mode 100644 index 0000000000..9bdd1f7d55 --- /dev/null +++ b/zephyr-port/07_companion/compat/Stream.h @@ -0,0 +1,16 @@ +/* Minimal Arduino Stream base for the Zephyr compat layer. */ +#pragma once +#include "Print.h" + +class Stream : public Print { + public: + virtual int available() { return 0; } + virtual int read() { return -1; } + virtual int peek() { return -1; } + virtual void flush() {} + virtual size_t readBytes(uint8_t *buf, size_t len) { + size_t n = 0; + while (n < len) { int c = read(); if (c < 0) break; buf[n++] = (uint8_t)c; } + return n; + } +}; diff --git a/zephyr-port/07_companion/dts/bindings/mc-lora-ctrl.yaml b/zephyr-port/07_companion/dts/bindings/mc-lora-ctrl.yaml new file mode 100644 index 0000000000..b6ff7494c1 --- /dev/null +++ b/zephyr-port/07_companion/dts/bindings/mc-lora-ctrl.yaml @@ -0,0 +1,20 @@ +description: | + LR2021 control GPIOs driven directly by RadioLib (NSS/DIO1/RESET/BUSY). + Not a real device node; just a holder so GPIO_DT_SPEC_GET generates the + pin/flags cell macros for these phandle-array properties. + +compatible: "mc,lora-ctrl" + +properties: + nss-gpios: + type: phandle-array + required: true + dio1-gpios: + type: phandle-array + required: true + reset-gpios: + type: phandle-array + required: true + busy-gpios: + type: phandle-array + required: true diff --git a/zephyr-port/07_companion/prj.conf b/zephyr-port/07_companion/prj.conf new file mode 100644 index 0000000000..5ee8c9057d --- /dev/null +++ b/zephyr-port/07_companion/prj.conf @@ -0,0 +1,70 @@ +# Step 4e — full MeshCore companion on Zephyr. +CONFIG_BT=y +CONFIG_BT_PERIPHERAL=y +CONFIG_BT_DEVICE_NAME="MeshCore" +CONFIG_BT_DEVICE_NAME_DYNAMIC=y +CONFIG_BT_DEVICE_NAME_MAX=40 +CONFIG_BT_SMP=y +CONFIG_BT_BONDABLE=y +CONFIG_BT_FIXED_PASSKEY=y +# BT_MAX_PAIRED defaults to 1 (= BT_MAX_CONN). Phone A bonds -> the single bond slot fills; +# phone B then connects but its (MITM-enforced) pairing has no free slot and FAILS, so B can't +# reach the encryption-gated NUS -> "needs a hard reset to connect another device". Let a new +# pairing recycle the oldest bond instead. (Bonds are RAM-only here, BT_SETTINGS off, so this +# just means each new phone re-pairs cleanly.) +CONFIG_BT_KEYS_OVERWRITE_OLDEST=y +# NUS: we define our own encryption-gated NUS GATT service in serial_ble_interface.cpp +# (so the central drives pairing). Zephyr's built-in NUS is disabled to avoid a duplicate +# service with the same UUIDs. +# CONFIG_BT_ZEPHYR_NUS=y +CONFIG_BT_L2CAP_TX_MTU=247 +CONFIG_BT_BUF_ACL_TX_SIZE=251 +CONFIG_BT_BUF_ACL_RX_SIZE=251 +# At boot the first BLE advertising event collides with the bring-up storm (LR2021 SPI +# init + LittleFS mount + mesh begin), so the SW-split controller's prepare callback runs +# tens of ms late on that one event. BT_CTLR_ASSERT_OVERHEAD_START (default y, a debug aid) +# turns that latency into a FATAL assert (lll_adv.c LL_ASSERT_OVERHEAD), which crashed the +# whole device -- taking the LoRa mesh down with it (no advert RX, looked like a reset loop). +# Disabling it restores the controller's designed behaviour: gracefully skip the delayed +# event (radio_disable + -ECANCELED) and keep running. +# NOTE: ASSERT_OVERHEAD_START lives in the controller's "Advanced features" menu, which is +# `visible if BT_CTLR_ADVANCED_FEATURES`. Without that gate enabled the prompt is hidden and +# a `=n` is silently ignored (symbol stays at its default y). Enable the gate (visibility +# only, no other behaviour change) so the override below actually takes effect. +CONFIG_BT_CTLR_ADVANCED_FEATURES=y +CONFIG_BT_CTLR_ASSERT_OVERHEAD_START=n + +CONFIG_FLASH=y +CONFIG_FLASH_MAP=y +# RRAM is byte-alterable and writes (no long erase like NOR flash). The flash driver +# otherwise defaults to RADIO_SYNC_TICKER (because BT_LL_SW_SPLIT), which schedules every +# write into BLE idle timeslots. While connected that dribbles a savePrefs() out one tiny +# window per connection event => a fixed 24s per settings write. Disable radio sync: RRAM +# writes are short enough to run synchronously without disturbing the radio (also bumps the +# RRAM write-buffer 1 -> 32, fewer commits). +CONFIG_SOC_FLASH_NRF_RADIO_SYNC_NONE=y + +# Bond persistence DISABLED: on the vanilla open-source controller it broke bonded +# reconnect (phone re-pairs on every reconnect -> disconnect loop) and earlier overflowed +# the bond-save stack. Each connect is a fresh pair (the known-good Android-working flow). +# Robust persistence needs NCS + Nordic SoftDevice Controller (handles bonded-RPA reconnect). +# CONFIG_BT_SETTINGS=y +# CONFIG_SETTINGS=y +# CONFIG_NVS=y +# CONFIG_SETTINGS_NVS=y +# (stacks kept harmless, headroom for the BT/workqueue paths) +CONFIG_BT_RX_STACK_SIZE=4096 +CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096 + +CONFIG_GPIO=y +CONFIG_SPI=y +CONFIG_CPP=y +CONFIG_STD_CPP17=y +CONFIG_CPP_EXCEPTIONS=n +CONFIG_PRINTK=y +CONFIG_CBPRINTF_FP_SUPPORT=y +CONFIG_MAIN_STACK_SIZE=16384 +CONFIG_HEAP_MEM_POOL_SIZE=16384 +CONFIG_FPU=y +CONFIG_REBOOT=y +CONFIG_ENTROPY_GENERATOR=y diff --git a/zephyr-port/07_companion/src/main.cpp b/zephyr-port/07_companion/src/main.cpp new file mode 100644 index 0000000000..45f1df8b4e --- /dev/null +++ b/zephyr-port/07_companion/src/main.cpp @@ -0,0 +1,69 @@ +/* + * Step 4e: the full MeshCore companion on Zephyr. Wires companion_radio's MyMesh + + * DataStore onto the proven Zephyr seams: radio (target.cpp / RadioLib+ZephyrHal), + * filesystem (InternalFS / flash_area), and serial = the bonded bt_nus SerialBLEInterface. + * The phone app pairs (level 4, fixed PIN), then talks the companion protocol over NUS. + */ +#include +#include +#include +#include +#include +#include +#include +#include "serial_ble_interface.h" +#include "MyMesh.h" /* examples/companion_radio (on the include path) */ + +SerialShim Serial; + +StdRNG fast_rng; +SimpleMeshTables tables; +DataStore store(InternalFS, rtc_clock); +MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store); + +int main(void) +{ + printk("\n=== MeshCore companion on Zephyr (board=%s) ===\n", CONFIG_BOARD); + + board.begin(); + if (!radio_init()) { printk("radio_init FAILED\n"); return 0; } + fast_rng.begin(radio_get_rng_seed()); + if (!InternalFS.begin()) { printk("InternalFS FAILED\n"); return 0; } + store.begin(); + the_mesh.begin(false); /* no display (loads prefs from /new_prefs) */ + + /* DEBUG: did the node name survive the last power cycle? On a cold boot this should show + * the previously-set name and exists=1; if it shows the default name / exists=0 the prefs + * file was lost (reformat or never persisted). Paired with the "FS: mounted OK/FAILED" line. */ + printk("PREFS: cold boot -> /new_prefs exists=%d, node_name='%s'\n", + InternalFS.exists("/new_prefs"), the_mesh.getNodePrefs()->node_name); + + char name[48]; + snprintf(name, sizeof(name), "%s%s", BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name); + ble.begin(name, the_mesh.getBLEPin()); + the_mesh.startInterface(ble); + printk("companion up: '%s' pin %lu — connect from the MeshCore app\n", + name, (unsigned long)the_mesh.getBLEPin()); + + while (1) { + the_mesh.loop(); + rtc_clock.tick(); + sensors.loop(); + + /* A rename from the app (CMD_SET_ADVERT_NAME) only updates prefs; push it to BLE so + * the advertised/GAP name follows without a reboot. */ + char cur[48]; + snprintf(cur, sizeof(cur), "%s%s", BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name); + if (strcmp(cur, name) != 0) { + strcpy(name, cur); + ble.setDeviceName(name); + /* DEBUG: confirm the rename reached the FS (savePrefs ran in the CMD handler). + * exists=1 means /new_prefs was (re)written; if it's still 0 the save never landed. */ + printk("PREFS: rename -> '%s', /new_prefs exists=%d\n", + the_mesh.getNodePrefs()->node_name, InternalFS.exists("/new_prefs")); + } + + k_msleep(1); /* snappy serial/radio polling for the app's frame bursts */ + } + return 0; +} diff --git a/zephyr-port/07_companion/src/serial_ble_interface.cpp b/zephyr-port/07_companion/src/serial_ble_interface.cpp new file mode 100644 index 0000000000..502ca50f0b --- /dev/null +++ b/zephyr-port/07_companion/src/serial_ble_interface.cpp @@ -0,0 +1,293 @@ +#include "serial_ble_interface.h" +#include +#include +#include +#include +#include +#include +#include + +SerialBLEInterface ble; + +#ifndef BLE_DBG +#define BLE_DBG(...) printk("BLE: " __VA_ARGS__) +#endif + +#ifndef MC_BLE_VERBOSE +#define MC_BLE_VERBOSE 1 /* dump every companion frame in/out */ +#endif + +#if MC_BLE_VERBOSE +static void mc_dump(const char *dir, const uint8_t *b, uint16_t len) +{ + printk("[%8lld] %s code=%-3u len=%-3u :", (long long)k_uptime_get(), dir, len ? b[0] : 0, len); + for (uint16_t i = 0; i < len && i < 28; i++) printk(" %02x", b[i]); + printk("%s\n", len > 28 ? " ..." : ""); +} +#else +#define mc_dump(d, b, l) +#endif + +/* advertising payload: name + NUS 128-bit UUID (so the MeshCore app finds us). + * g_ad[1] (the scan-list name) is rewritten at begin() from the runtime node name; + * CONFIG_BT_DEVICE_NAME is only a placeholder. bt_set_name() sets the GAP Device Name + * *characteristic* but NOT this advertising payload, so without the rewrite the node + * always advertised the static "MeshCore" regardless of the configured node name. */ +static char g_adv_name[27]; /* 31B adv - 3B flags - 2B AD header => 26 name chars + NUL */ +static struct bt_data g_ad[] = { + BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)), + BT_DATA(BT_DATA_NAME_COMPLETE, CONFIG_BT_DEVICE_NAME, sizeof(CONFIG_BT_DEVICE_NAME) - 1), +}; +static const struct bt_data g_sd[] = { + BT_DATA_BYTES(BT_DATA_UUID128_ALL, + BT_UUID_128_ENCODE(0x6E400001, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9E)), +}; + +/* custom Nordic-UART service, ENCRYPTION-GATED so the *central* drives pairing + * Zephyr's stock bt_nus characteristics are PERM_NONE, which forced us to send a peripheral + * SMP Security Request on connect; iOS/Windows respond to that by pairing then dropping & + * reconnecting. Gating the RX-write and the TX CCC on *authenticated* encryption makes the + * central initiate MITM (passkey) pairing on first access and stay on the same link. */ +static struct bt_uuid_128 nus_srv_uuid = BT_UUID_INIT_128( + BT_UUID_128_ENCODE(0x6E400001, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9E)); +static struct bt_uuid_128 nus_tx_uuid = BT_UUID_INIT_128( /* notify: NODE->APP */ + BT_UUID_128_ENCODE(0x6E400003, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9E)); +static struct bt_uuid_128 nus_rx_uuid = BT_UUID_INIT_128( /* write: APP->NODE */ + BT_UUID_128_ENCODE(0x6E400002, 0xB5A3, 0xF393, 0xE0A9, 0xE50E24DCCA9E)); + +static ssize_t nus_rx_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, + const void *buf, uint16_t len, uint16_t offset, uint8_t flags) +{ + (void)conn; (void)attr; (void)offset; (void)flags; + ble._onRx(buf, len); + return len; +} +static void nus_tx_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) { (void)attr; (void)value; } + +/* attrs[0]=svc [1]=TX chrc [2]=TX value [3]=CCC [4]=RX chrc [5]=RX value; + * notify on attrs[1] (mirrors Zephyr's bt_nus_send). */ +BT_GATT_SERVICE_DEFINE(nus_svc, + BT_GATT_PRIMARY_SERVICE(&nus_srv_uuid), + BT_GATT_CHARACTERISTIC(&nus_tx_uuid.uuid, BT_GATT_CHRC_NOTIFY, + BT_GATT_PERM_NONE, NULL, NULL, NULL), + BT_GATT_CCC(nus_tx_ccc_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE_AUTHEN), + BT_GATT_CHARACTERISTIC(&nus_rx_uuid.uuid, + BT_GATT_CHRC_WRITE | BT_GATT_CHRC_WRITE_WITHOUT_RESP, + BT_GATT_PERM_WRITE_AUTHEN, NULL, nus_rx_write, NULL), +); + +/* C BLE callbacks bridging to the single `ble` instance */ +static void cb_connected(struct bt_conn *conn, uint8_t err) +{ + if (err) { BLE_DBG("connect failed 0x%02x\n", err); return; } + BLE_DBG("connected\n"); + ble._onConnect(conn); + /* Do NOT initiate security here. The NUS characteristics are gated on authenticated + * encryption, so the CENTRAL triggers MITM pairing on first access and keeps the link. + * A peripheral-initiated Security Request is what made iOS/Windows pair-then-reconnect. */ +} +static void cb_disconnected(struct bt_conn *conn, uint8_t reason) +{ + BLE_DBG("disconnected 0x%02x\n", reason); + ble._onDisconnect(conn); +} +static void cb_security_changed(struct bt_conn *conn, bt_security_t level, enum bt_security_err err) +{ + BLE_DBG("security level %d err %d\n", level, err); + ble._onSecured(conn, err == BT_SECURITY_ERR_SUCCESS && level >= BT_SECURITY_L2); +} +BT_CONN_CB_DEFINE(conn_cbs) = { + .connected = cb_connected, + .disconnected = cb_disconnected, + .security_changed = cb_security_changed, +}; + +static void auth_passkey_display(struct bt_conn *conn, unsigned int passkey) +{ + BLE_DBG(">>> enter PASSKEY on phone: %06u <<<\n", passkey); +} +static void auth_cancel(struct bt_conn *conn) { BLE_DBG("pairing cancelled\n"); } +static struct bt_conn_auth_cb auth_cb = { + .passkey_display = auth_passkey_display, + .cancel = auth_cancel, +}; +static void pairing_complete(struct bt_conn *conn, bool bonded) +{ + BLE_DBG("*** PAIRING COMPLETE bonded=%d ***\n", bonded); +} +static void pairing_failed(struct bt_conn *conn, enum bt_security_err reason) +{ + BLE_DBG("!!! PAIRING FAILED reason=%d !!!\n", reason); +} +static struct bt_conn_auth_info_cb auth_info_cb = { + .pairing_complete = pairing_complete, + .pairing_failed = pairing_failed, +}; + +/* --- SerialBLEInterface impl --- */ +void SerialBLEInterface::begin(const char *device_name, uint32_t pin_code) +{ + k_mutex_init(&_lock); + int err = bt_enable(NULL); + if (err) { BLE_DBG("bt_enable %d\n", err); return; } + + if (IS_ENABLED(CONFIG_SETTINGS)) { + settings_load(); /* restore bonds saved by CONFIG_BT_SETTINGS */ + } + bt_passkey_set(pin_code); + bt_conn_auth_cb_register(&auth_cb); + bt_conn_auth_info_cb_register(&auth_info_cb); + setDeviceName(device_name); + BLE_DBG("init done; advertising as '%s' pin %06u\n", bt_get_name(), pin_code); +} + +void SerialBLEInterface::setDeviceName(const char *device_name) +{ + if (!device_name || !*device_name) return; + bt_set_name(device_name); /* GAP Device Name characteristic (read after connect) */ + /* Mirror the name into the advertising payload (the scan-list name the phone shows), + * truncating to the 26-char adv budget. Without this the scan list always read + * "MeshCore", and a runtime rename never reached the advertisement at all. The new + * g_ad[] is picked up by the next mc_start_adv(); if idle, refresh the live advertisement now. */ + size_t n = strlen(device_name); + bool shortened = n > sizeof(g_adv_name) - 1; + if (shortened) n = sizeof(g_adv_name) - 1; + memcpy(g_adv_name, device_name, n); + g_adv_name[n] = '\0'; + g_ad[1].type = shortened ? BT_DATA_NAME_SHORTENED : BT_DATA_NAME_COMPLETE; + g_ad[1].data = (const uint8_t *)g_adv_name; + g_ad[1].data_len = n; + BLE_DBG("device name set to '%s'\n", g_adv_name); + if (_enabled && _conn == nullptr && _advertising) { + bt_le_adv_update_data(g_ad, ARRAY_SIZE(g_ad), g_sd, ARRAY_SIZE(g_sd)); + } +} + +static int mc_start_adv(void) +{ + return bt_le_adv_start(BT_LE_ADV_PARAM(BT_LE_ADV_OPT_CONN, + BT_GAP_ADV_FAST_INT_MIN_2, + BT_GAP_ADV_FAST_INT_MAX_2, NULL), + g_ad, ARRAY_SIZE(g_ad), g_sd, ARRAY_SIZE(g_sd)); +} + +void SerialBLEInterface::enable() +{ + if (_enabled) return; + _enabled = true; + int err = mc_start_adv(); + _advertising = (err == 0); + BLE_DBG("adv start -> %d\n", err); +} + +void SerialBLEInterface::disable() +{ + _enabled = false; + bt_le_adv_stop(); + if (_conn) bt_conn_disconnect((struct bt_conn *)_conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN); +} + +bool SerialBLEInterface::isConnected() const { return _conn != nullptr && _secured; } +bool SerialBLEInterface::isWriteBusy() const { return _send_len >= (QSZ * 2 / 3); } + +void SerialBLEInterface::_onConnect(void *conn) +{ + _conn = bt_conn_ref((struct bt_conn *)conn); + _secured = false; + _advertising = false; /* Zephyr stops connectable adv once a connection forms */ + _send_len = 0; + k_mutex_lock(&_lock, K_FOREVER); _recv_len = 0; k_mutex_unlock(&_lock); +} +void SerialBLEInterface::_onDisconnect(void *conn) +{ + if (_conn) { bt_conn_unref((struct bt_conn *)_conn); _conn = nullptr; } + _secured = false; + /* re-advertise is handled in checkRecvFrame() (main thread) avoid BT calls here */ +} +void SerialBLEInterface::_onSecured(void *conn, bool ok) +{ + (void)conn; + _secured = ok; + /* NOTE: previously requested a conn interval here for Android latency, but an + * unsolicited param update right after pairing stalls iOS . + * Let the central manage connection parameters. */ +} + +void SerialBLEInterface::_onRx(const void *data, uint16_t len) +{ + if (len == 0 || len > MAX_FRAME_SIZE) return; + mc_dump("APP->NODE", (const uint8_t *)data, len); + k_mutex_lock(&_lock, K_FOREVER); + if (_recv_len < QSZ) { + _recv[_recv_len].len = len; + memcpy(_recv[_recv_len].buf, data, len); + _recv_len++; + } else { + printk("BLE: RX queue FULL, dropping frame\n"); + } + k_mutex_unlock(&_lock); +} + +void SerialBLEInterface::shiftSend() +{ + if (_send_len > 0) { + _send_len--; + for (uint8_t i = 0; i < _send_len; i++) _send[i] = _send[i + 1]; + } +} + +size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) +{ + if (len == 0 || len > MAX_FRAME_SIZE) return 0; + if (!isConnected() || _send_len >= QSZ) return 0; + _send[_send_len].len = len; + memcpy(_send[_send_len].buf, src, len); + _send_len++; + return len; +} + +size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) +{ + /* drain queued TX frames over NUS. Push as many as the controller will take this + * call (multiple notifications per connection event) instead of one per loop tick; + * stop on -ENOMEM (buffers full, retry next call). Speeds up multi-frame answers + * (settings/contacts) without touching connection parameters. */ + if (_send_len > 0) { + if (!isConnected()) { + _send_len = 0; + } else { + while (_send_len > 0) { + int rc = bt_gatt_notify((struct bt_conn *)_conn, &nus_svc.attrs[1], _send[0].buf, _send[0].len); + if (rc == 0) { mc_dump("NODE->APP", _send[0].buf, _send[0].len); shiftSend(); } + else if (rc == -ENOTCONN) { shiftSend(); break; } /* gone */ + else { printk("BLE: bt_nus_send rc=%d (retry)\n", rc); break; } /* -ENOMEM: buffers full */ + } + } + } + /* return one received frame */ + size_t out = 0; + k_mutex_lock(&_lock, K_FOREVER); + if (_recv_len > 0) { + out = _recv[0].len; + memcpy(dest, _recv[0].buf, out); + _recv_len--; + for (uint8_t i = 0; i < _recv_len; i++) _recv[i] = _recv[i + 1]; + } + k_mutex_unlock(&_lock); + + /* re-advertise after a disconnect so the app can reconnect without a board reset */ + if (_enabled && _conn == nullptr && !_advertising) { + unsigned long now = k_uptime_get(); + if (now - _last_adv_try > 500) { /* throttle retries */ + _last_adv_try = now; + int rc = mc_start_adv(); + if (rc == 0) { + _advertising = true; + BLE_DBG("re-advertising after disconnect\n"); + } else { + BLE_DBG("re-advertise FAILED rc=%d (will retry)\n", rc); + } + } + } + return out; +} diff --git a/zephyr-port/07_companion/src/serial_ble_interface.h b/zephyr-port/07_companion/src/serial_ble_interface.h new file mode 100644 index 0000000000..00521be86d --- /dev/null +++ b/zephyr-port/07_companion/src/serial_ble_interface.h @@ -0,0 +1,58 @@ +/* + * Zephyr SerialBLEInterface: MeshCore's BaseSerialInterface over the Zephyr NUS + * (bt_nus) + the step-2 bonded pairing (LE SC + MITM + fixed PIN). This is the + * serial seam the companion role will use; the companion-protocol bytes ride + * the encrypted, bonded NUS link that the bare-metal core could never establish. + */ +#pragma once +#include +#include +#include + +class SerialBLEInterface : public BaseSerialInterface { + public: + /* prefix+name -> advertised name; pin_code = fixed BLE passkey. */ + void begin(const char *device_name, uint32_t pin_code); + + /* Apply (or re-apply at runtime) the BLE name: sets the GAP Device Name characteristic + * AND the advertising payload, so a rename takes effect on the next advertise without a + * reboot. Safe to call while connected (the new name is used on the next re-advertise). */ + void setDeviceName(const char *device_name); + + void enable() override; + void disable() override; + bool isEnabled() const override { return _enabled; } + bool isConnected() const override; + bool isWriteBusy() const override; + size_t writeFrame(const uint8_t src[], size_t len) override; + size_t checkRecvFrame(uint8_t dest[]) override; + + /* invoked from the C BLE callbacks (see .cpp) */ + void _onConnect(void *conn); + void _onDisconnect(void *conn); + void _onSecured(void *conn, bool ok); + void _onRx(const void *data, uint16_t len); + + private: + struct Frame { + uint16_t len; + uint8_t buf[MAX_FRAME_SIZE]; + }; + static const uint8_t QSZ = 12; + + Frame _send[QSZ]; + uint8_t _send_len = 0; /* main-thread only */ + Frame _recv[QSZ]; + uint8_t _recv_len = 0; /* filled in BT ctx, read in main -> guarded by _lock */ + struct k_mutex _lock; + + void *_conn = nullptr; /* struct bt_conn* */ + bool _enabled = false; + bool _secured = false; + bool _advertising = false; + unsigned long _last_adv_try = 0; + + void shiftSend(); +}; + +extern SerialBLEInterface ble; diff --git a/zephyr-port/07_companion/src/target.cpp b/zephyr-port/07_companion/src/target.cpp new file mode 100644 index 0000000000..df77138742 --- /dev/null +++ b/zephyr-port/07_companion/src/target.cpp @@ -0,0 +1,110 @@ +#include "target.h" +#include "zephyr_radiolib_hal.h" +/* Chip quirks (header-CRC standby, RX max-length re-assert) and the mesh wrapper + * (standby-before-re-arm, RSSI hooks) are shared with the bare-metal variant via + * CustomLR2021 / CustomLR2021Wrapper — single source for the LR2021 fixes. */ +#include +#include + +static ZephyrHal hal; + +static Module s_mod(&hal, LR_PIN_NSS, LR_PIN_DIO1, LR_PIN_RESET, LR_PIN_BUSY); +static CustomLR2021 s_lora(&s_mod); + +ZBoard board; /* defined before the radio wrapper that references it */ + +static CustomLR2021Wrapper s_radio(s_lora, board); + +RadioLibWrapper &radio_driver = s_radio; +VolatileRTCClock rtc_clock; +SensorManager sensors; + +mesh::LocalIdentity radio_new_identity() +{ + RadioNoiseListener rng(s_lora); + return mesh::LocalIdentity(&rng); /* new identity from LoRa RSSI noise */ +} + +void ZBoard::reboot() { sys_reboot(SYS_REBOOT_COLD); } + +#define MC_HF_CUTOFF_MHZ 1500.0f + +static float s_freq = LORA_FREQ; +static int8_t s_req_dbm = LORA_TX_POWER; /* last app-requested power, pre-clamp */ + +static int8_t clamp_tx_for_band(float freq, int8_t dbm) +{ + if (freq > MC_HF_CUTOFF_MHZ) /* 2.4 GHz HF PA: -19..+12 */ + return dbm > 12 ? 12 : (dbm < -19 ? -19 : dbm); + return dbm > 22 ? 22 : (dbm < -9 ? -9 : dbm); /* sub-GHz LF PA: -9..+22 */ +} + + +static bool radio_bringup(float freq, float bw, uint8_t sf, uint8_t cr) +{ + for (int attempt = 0; attempt < 8; attempt++) { + + int16_t st = s_lora.begin(freq, bw, sf, cr, + RADIOLIB_LR2021_LORA_SYNC_WORD_PRIVATE, + clamp_tx_for_band(freq, s_req_dbm), 16, /*tcxoVoltage=*/0.0f); + if (st == RADIOLIB_ERR_NONE) { + /* match CustomLR2021's proven config: explicit header, CRC, RX boosted gain */ + s_lora.explicitHeader(); + s_lora.setCRC(2); + s_lora.setRxBoostedGainMode(LR2021_RX_BOOST_LEVEL); + int16_t rx = s_lora.startReceive(); /* the operation that fails on a bad boot */ + if (rx == RADIOLIB_ERR_NONE) { + s_lora.standby(); /* idle; the wrapper arms RX in its loop */ + s_freq = freq; + if (attempt) printk("radio: RX ok after %d retr%s\n", + attempt, attempt == 1 ? "y" : "ies"); + return true; + } + printk("radio: begin ok but RX arm failed (%d), resetting\n", rx); + } else { + printk("radio: begin failed (%d), resetting\n", st); + } + s_lora.reset(); /* full chip reset, then retry the whole bring-up */ + k_msleep(50); + } + return false; +} + +bool radio_init() +{ + s_lora.setIrqDio(LR2021_IRQ_DIO); + s_freq = LORA_FREQ; + s_req_dbm = LORA_TX_POWER; + if (!radio_bringup(LORA_FREQ, LORA_BW, LORA_SF, LORA_CR)) return false; + s_radio.begin(); + return true; +} + +uint32_t radio_get_rng_seed() { return s_lora.random(0x7FFFFFFF); } + +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) +{ + + bool band_changed = (freq > MC_HF_CUTOFF_MHZ) != (s_freq > MC_HF_CUTOFF_MHZ); + s_lora.standby(); + int16_t st = s_lora.setFrequency(freq); /* recalibrates the front-end for the band */ + if (st == RADIOLIB_ERR_NONE) st = s_lora.setBandwidth(bw); + if (st == RADIOLIB_ERR_NONE) st = s_lora.setSpreadingFactor(sf); + if (st == RADIOLIB_ERR_NONE) st = s_lora.setCodingRate(cr); + s_freq = freq; + if (band_changed) s_lora.setOutputPower(clamp_tx_for_band(freq, s_req_dbm)); + s_radio.begin(); /* reset wrapper -> dispatcher re-arms RX (startReceive) on the new config */ + if (st != RADIOLIB_ERR_NONE) { + printk("radio_set_params: apply error %d for %u kHz bw=%u kHz sf%u cr%u\n", + st, (unsigned)(freq * 1000.0f), (unsigned)(bw * 1000.0f), sf, cr); + } else { + printk("radio_set_params: now on %u kHz bw=%u kHz sf%u cr%u\n", + (unsigned)(freq * 1000.0f), (unsigned)(bw * 1000.0f), sf, cr); + } +} + +void radio_set_tx_power(int8_t dbm) +{ + s_req_dbm = dbm; + s_lora.setOutputPower(clamp_tx_for_band(s_freq, dbm)); +} diff --git a/zephyr-port/07_companion/src/target.h b/zephyr-port/07_companion/src/target.h new file mode 100644 index 0000000000..bb85df13ef --- /dev/null +++ b/zephyr-port/07_companion/src/target.h @@ -0,0 +1,94 @@ +/* + * Zephyr variant glue for the XIAO nRF54L15 + LR2021: the target.h MeshCore's + * companion expects (board / radio_driver / rtc_clock / sensors + radio_* funcs + + * LoRa/BLE config). The concrete radio seam (RadioLib LR2021 + ZephyrHal) lives in + * target.cpp so its headers (with static-member defs) aren't multiply-included. + */ +#pragma once +#include +#include +#include +#include +#include /* RadioLibWrapper API MyMesh uses */ + +/* 2.4 GHz preset (LR2021 is dual-band) + * The RadioLib driver auto-switches to the HF path for any frequency in 2400-2500 MHz; + * target.cpp clamps TX power to the HF PA max of +12 dBm. NOTE: MeshCore's companion caps + * bandwidth at 500 kHz, so 812/406 (which the chip supports) are rejected by SET_RADIO_PARAMS. */ +#define LORA_FREQ_2G4 2450.0f +#define LORA_BW_2G4 500.0f /* kHz */ +#define LORA_SF_2G4 8 +#define LORA_CR_2G4 5 +#define LORA_TX_POWER_2G4 12 /* dBm — HF PA maximum */ + +/* LoRa boot defaults: MeshCore EU sub-GHz canon, unless the build defines MC_BAND_2G4 (see + * CMakeLists), which boots straight onto the 2.4 GHz preset above. 865 MHz stays the normal + * build; a 2G4 build needs no app interaction (and the choice persists in prefs). The app + * has no 2.4 GHz preset of its own, presets are app-side and sub-GHz only, so this flag is + * the practical way to put the node on 2.4 GHz for the RF test. */ +/* NOTE: the real build-time source of these is CMakeLists.txt (global -D flags, + * mirroring platformio.ini): core headers such as RadioLibWrappers.h expand + * LORA_SF in translation units that never include this file. The block below is + * only a fallback for tooling (clangd/IDE) parsing this header standalone. */ +#ifdef MC_BAND_2G4 + #ifndef LORA_FREQ + #define LORA_FREQ LORA_FREQ_2G4 + #endif + #ifndef LORA_BW + #define LORA_BW LORA_BW_2G4 + #endif + #ifndef LORA_SF + #define LORA_SF LORA_SF_2G4 + #endif + #ifndef LORA_CR + #define LORA_CR LORA_CR_2G4 + #endif + #ifndef LORA_TX_POWER + #define LORA_TX_POWER LORA_TX_POWER_2G4 + #endif +#else + #ifndef LORA_FREQ + #define LORA_FREQ 869.618f + #endif + #ifndef LORA_BW + #define LORA_BW 62.5f + #endif + #ifndef LORA_SF + #define LORA_SF 8 + #endif + #ifndef LORA_CR + #define LORA_CR 5 + #endif + #ifndef LORA_TX_POWER + #define LORA_TX_POWER 22 + #endif +#endif + +/* BLE companion */ +#ifndef BLE_PIN_CODE +#define BLE_PIN_CODE 123456 +#endif +#ifndef BLE_NAME_PREFIX +#define BLE_NAME_PREFIX "MeshCore-" +#endif + +class ZBoard : public mesh::MainBoard { + public: + void begin() {} + uint16_t getBattMilliVolts() override { return 0; } + const char *getManufacturerName() const override { return "XIAO nRF54L15"; } + void reboot() override; /* sys_reboot — defined in target.cpp */ + uint8_t getStartupReason() const override { return _reason; } + uint8_t _reason = 0; +}; + +extern ZBoard board; +extern RadioLibWrapper &radio_driver; /* concrete LR2021 wrapper bound in target.cpp */ +extern VolatileRTCClock rtc_clock; +extern SensorManager sensors; + +bool radio_init(); +uint32_t radio_get_rng_seed(); +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr); +void radio_set_tx_power(int8_t dbm); +mesh::LocalIdentity radio_new_identity(); diff --git a/zephyr-port/07_companion/src/zephyr_internal_fs.cpp b/zephyr-port/07_companion/src/zephyr_internal_fs.cpp new file mode 100644 index 0000000000..fc12dbf47f --- /dev/null +++ b/zephyr-port/07_companion/src/zephyr_internal_fs.cpp @@ -0,0 +1,55 @@ +/* + * Zephyr backing for MeshCore's nrf54 InternalFileSystem (the header MeshCore's + * NRF54_PLATFORM branch includes). Same Adafruit_LittleFS class, lfs block device + * over Zephyr flash_area on `storage_partition` (validated in zephyr-port/06_fs). + * Compile THIS instead of the bare-metal RRAMC InternalFileSystem.cpp. + */ +#include +#include +#include +#include + +#define FS_PARTITION storage_partition + +#define BS 2048 /* littlefs logical block (== read/prog size) */ +#define BC 12 /* 24 KB storage_partition / 2048 */ + +static const struct flash_area *g_fa = NULL; + +static int _fa_read(const struct lfs_config *c, lfs_block_t b, lfs_off_t o, void *buf, lfs_size_t sz) { + (void)c; return flash_area_read(g_fa, (off_t)b * BS + o, buf, sz) == 0 ? LFS_ERR_OK : LFS_ERR_IO; +} +static int _fa_prog(const struct lfs_config *c, lfs_block_t b, lfs_off_t o, const void *buf, lfs_size_t sz) { + (void)c; return flash_area_write(g_fa, (off_t)b * BS + o, buf, sz) == 0 ? LFS_ERR_OK : LFS_ERR_IO; +} +static int _fa_erase(const struct lfs_config *c, lfs_block_t b) { + (void)c; (void)b; + + return LFS_ERR_OK; +} +static int _fa_sync(const struct lfs_config *c) { (void)c; return LFS_ERR_OK; } + +struct lfs_config _InternalFSConfig = { + .context = NULL, + .read = _fa_read, .prog = _fa_prog, .erase = _fa_erase, .sync = _fa_sync, + .read_size = BS, .prog_size = BS, .block_size = BS, .block_count = BC, .lookahead = 128, + .read_buffer = NULL, .prog_buffer = NULL, .lookahead_buffer = NULL, .file_buffer = NULL +}; + +InternalFileSystem InternalFS; +InternalFileSystem::InternalFileSystem(void) : Adafruit_LittleFS(&_InternalFSConfig) {} + +bool InternalFileSystem::begin(void) { + if (g_fa == NULL && flash_area_open(FIXED_PARTITION_ID(FS_PARTITION), &g_fa) != 0) { + printk("FS: flash_area_open FAILED\n"); + return false; + } + if (!Adafruit_LittleFS::begin()) { + printk("FS: mount FAILED -> reformatting (any persisted prefs/contacts are lost)\n"); + this->format(); + if (!Adafruit_LittleFS::begin()) { printk("FS: format+remount FAILED\n"); return false; } + } else { + printk("FS: mounted OK (persisted data intact)\n"); + } + return true; +} diff --git a/zephyr-port/07_companion/src/zephyr_radiolib_hal.h b/zephyr-port/07_companion/src/zephyr_radiolib_hal.h new file mode 100644 index 0000000000..8bffd683ec --- /dev/null +++ b/zephyr-port/07_companion/src/zephyr_radiolib_hal.h @@ -0,0 +1,118 @@ +/* + * Minimal RadioLibHal for Zephyr (XIAO nRF54L15). Maps RadioLib's opaque pin ids + * to gpio_dt_spec from the `lora0` devicetree node, and SPI to the spi00 controller + * (no hardware CS, RadioLib drives NSS via digitalWrite). Raw GPIO ops keep + * RadioLib's Arduino LOW/HIGH == physical-level semantics. + * + * Pin ids passed to Module(hal, cs, irq, rst, gpio): NSS=0 DIO1=1 RESET=2 BUSY=3. + */ +#pragma once +#include +#include +#include +#include + +#define LR_PIN_NSS 0 +#define LR_PIN_DIO1 1 +#define LR_PIN_RESET 2 +#define LR_PIN_BUSY 3 +#define LR_PIN_COUNT 4 + +#define LORA0_NODE DT_NODELABEL(lora0) +#define SPI0_NODE DT_NODELABEL(spi00) + +class ZephyrHal : public RadioLibHal { + public: + /* GPIO modes / levels / edges — opaque tokens RadioLib hands back to us. */ + ZephyrHal() + : RadioLibHal(/*INPUT*/0, /*OUTPUT*/1, /*LOW*/0, /*HIGH*/1, /*RISING*/1, /*FALLING*/2) {} + + void init() override { spiBegin(); } + void term() override {} + + void pinMode(uint32_t pin, uint32_t mode) override { + if (pin >= LR_PIN_COUNT) return; + const struct gpio_dt_spec *g = &gpios[pin]; + gpio_pin_configure(g->port, g->pin, (mode == 1) ? GPIO_OUTPUT : GPIO_INPUT); + } + void digitalWrite(uint32_t pin, uint32_t value) override { + if (pin >= LR_PIN_COUNT) return; + const struct gpio_dt_spec *g = &gpios[pin]; + gpio_pin_set_raw(g->port, g->pin, value ? 1 : 0); + } + uint32_t digitalRead(uint32_t pin) override { + if (pin >= LR_PIN_COUNT) return 0; + const struct gpio_dt_spec *g = &gpios[pin]; + return gpio_pin_get_raw(g->port, g->pin); + } + + void attachInterrupt(uint32_t interruptNum, void (*cb)(void), uint32_t mode) override { + if (interruptNum >= LR_PIN_COUNT) return; + const struct gpio_dt_spec *g = &gpios[interruptNum]; + user_cb = cb; + gpio_pin_configure(g->port, g->pin, GPIO_INPUT); /* RadioLib doesn't pinMode the IRQ */ + if (!cb_added) { /* register the callback exactly once */ + gpio_init_callback(&cb_data, gpioIsr, BIT(g->pin)); + gpio_add_callback(g->port, &cb_data); + cb_added = true; + } + gpio_pin_interrupt_configure(g->port, g->pin, + (mode == 2) ? GPIO_INT_EDGE_FALLING : + (mode == 1) ? GPIO_INT_EDGE_RISING : GPIO_INT_EDGE_BOTH); + } + void detachInterrupt(uint32_t interruptNum) override { + if (interruptNum >= LR_PIN_COUNT) return; + const struct gpio_dt_spec *g = &gpios[interruptNum]; + gpio_pin_interrupt_configure(g->port, g->pin, GPIO_INT_DISABLE); + gpio_remove_callback(g->port, &cb_data); + user_cb = nullptr; + } + + void delay(RadioLibTime_t ms) override { k_msleep((int32_t)ms); } + void delayMicroseconds(RadioLibTime_t us) override { k_busy_wait((uint32_t)us); } + RadioLibTime_t millis() override { return (RadioLibTime_t)k_uptime_get(); } + RadioLibTime_t micros() override { return (RadioLibTime_t)k_ticks_to_us_floor64(k_uptime_ticks()); } + long pulseIn(uint32_t, uint32_t, RadioLibTime_t) override { return 0; } + + void spiBegin() override { + spi_dev = DEVICE_DT_GET(SPI0_NODE); + spi_cfg.frequency = 2000000; /* 2 MHz, SPI mode 0, MSB first */ + spi_cfg.operation = SPI_OP_MODE_MASTER | SPI_WORD_SET(8) | SPI_TRANSFER_MSB; + spi_cfg.slave = 0; + spi_cfg.cs.gpio.port = NULL; /* no HW CS — RadioLib toggles NSS */ + } + void spiBeginTransaction() override {} + void spiEndTransaction() override {} + void spiEnd() override {} + + void spiTransfer(uint8_t *out, size_t len, uint8_t *in) override { + struct spi_buf txb = { .buf = out, .len = len }; + struct spi_buf rxb = { .buf = in, .len = len }; + struct spi_buf_set tx = { .buffers = &txb, .count = 1 }; + struct spi_buf_set rx = { .buffers = &rxb, .count = 1 }; + spi_transceive(spi_dev, &spi_cfg, (out ? &tx : NULL), (in ? &rx : NULL)); + } + + private: + static void (*user_cb)(void); + static struct gpio_callback cb_data; + static bool cb_added; + static void gpioIsr(const struct device *, struct gpio_callback *, uint32_t) { + if (user_cb) user_cb(); + } + + const struct device *spi_dev = nullptr; + struct spi_config spi_cfg = {}; + + /* index by LR_PIN_*; order must match the #defines above */ + const struct gpio_dt_spec gpios[LR_PIN_COUNT] = { + GPIO_DT_SPEC_GET(LORA0_NODE, nss_gpios), + GPIO_DT_SPEC_GET(LORA0_NODE, dio1_gpios), + GPIO_DT_SPEC_GET(LORA0_NODE, reset_gpios), + GPIO_DT_SPEC_GET(LORA0_NODE, busy_gpios), + }; +}; + +void (*ZephyrHal::user_cb)(void) = nullptr; +struct gpio_callback ZephyrHal::cb_data; +bool ZephyrHal::cb_added = false;