From 93c1a589d58902e6466730f645fb5a2259738e99 Mon Sep 17 00:00:00 2001 From: Rastislav Vysoky Date: Wed, 14 Oct 2026 18:44:36 +0200 Subject: [PATCH] add KISS modem role (port of upstream examples/kiss_modem) Raw LoRa packets to and from a host over the USB/console serial port, plus upstream's SetHardware extensions (identity, crypto, radio config, stats). - app/KissModem.cpp/h: upstream text, ZephCore changes fenced: an LBT refusal returns to CSMA until the wait-clear bound; a TX times out only once the radio has concluded (as Dispatcher::loop); SetRadio outside the ZC_RADIO_* ranges is refused; msUntilNextTx() for the event loop. - src/main_kiss_modem.cpp: event-driven composition root; runs the radio maintenance the Dispatcher would. Identity in /lfs/repeater/_main.id. - adapters/transport/ZephyrUartStream: Arduino Stream over IRQ RX/TX rings. - boards/common/kiss_modem.conf: role, BT off, console driver off. - LoRaRadio::getCurrentRSSI(), Print::availableForWrite(). - UI mesh-action stubs for every non-companion role. Tested on 2x XIAO ESP32-C3 + Wio-SX1262 at 869.423 MHz SF7/62.5/CR5. Built for xiao_esp32c3, rak4631, lora_e5_mini; host tests pass. Co-Authored-By: Claude Opus 5.5 --- .editorconfig | 2 +- README.md | 2 + docs/ARCHITECTURE.md | 35 +- docs/BUILDING.md | 4 + docs/DESIGN.md | 10 +- docs/kiss_modem_protocol.md | 289 +++++++ zephcore/CMakeLists.txt | 23 +- zephcore/Kconfig | 8 + zephcore/adapters/radio/LoRaRadio.h | 2 + .../adapters/transport/ZephyrUartStream.cpp | 156 ++++ .../adapters/transport/ZephyrUartStream.h | 52 ++ zephcore/app/KissModem.cpp | 794 ++++++++++++++++++ zephcore/app/KissModem.h | 217 +++++ zephcore/boards/common/kiss_modem.conf | 15 + zephcore/helpers/compat/Stream.h | 1 + zephcore/src/main_kiss_modem.cpp | 213 +++++ 16 files changed, 1808 insertions(+), 15 deletions(-) create mode 100644 docs/kiss_modem_protocol.md create mode 100644 zephcore/adapters/transport/ZephyrUartStream.cpp create mode 100644 zephcore/adapters/transport/ZephyrUartStream.h create mode 100644 zephcore/app/KissModem.cpp create mode 100644 zephcore/app/KissModem.h create mode 100644 zephcore/boards/common/kiss_modem.conf create mode 100644 zephcore/src/main_kiss_modem.cpp diff --git a/.editorconfig b/.editorconfig index 549a265..c8a9b50 100644 --- a/.editorconfig +++ b/.editorconfig @@ -48,7 +48,7 @@ indent_size = unset # changes, each fenced `// ZEPHCORE:`. Upstream mixes 2-space and tab indentation # and has trailing whitespace, so none of it is enforced here. Add a file to this # list when it is converted to upstream shape. -[{zephcore/src/Mesh.cpp,zephcore/src/Dispatcher.cpp,zephcore/src/Packet.cpp,zephcore/src/StaticPoolPacketManager.cpp,zephcore/include/mesh/MeshCore.h,zephcore/include/mesh/Dispatcher.h,zephcore/include/mesh/Mesh.h,zephcore/include/mesh/Packet.h,zephcore/include/mesh/Identity.h,zephcore/include/mesh/Utils.h,zephcore/include/mesh/SimpleMeshTables.h,zephcore/include/mesh/StaticPoolPacketManager.h,zephcore/helpers/BaseChatMesh.cpp,zephcore/helpers/BaseChatMesh.h,zephcore/helpers/ContactInfo.h,zephcore/helpers/ChannelDetails.h,zephcore/helpers/ClientACL.cpp,zephcore/helpers/ClientACL.h,zephcore/helpers/RegionMap.cpp,zephcore/helpers/RegionMap.h,zephcore/helpers/TransportKeyStore.cpp,zephcore/helpers/TransportKeyStore.h,zephcore/helpers/BaseSerialInterface.h,zephcore/helpers/MultiSerialInterface.h,zephcore/helpers/ConfigSerializer.cpp,zephcore/helpers/ConfigSerializer.h,zephcore/helpers/SensorManager.h,zephcore/helpers/sensors/LPPDataHelpers.h}] +[{zephcore/src/Mesh.cpp,zephcore/src/Dispatcher.cpp,zephcore/src/Packet.cpp,zephcore/src/StaticPoolPacketManager.cpp,zephcore/include/mesh/MeshCore.h,zephcore/include/mesh/Dispatcher.h,zephcore/include/mesh/Mesh.h,zephcore/include/mesh/Packet.h,zephcore/include/mesh/Identity.h,zephcore/include/mesh/Utils.h,zephcore/include/mesh/SimpleMeshTables.h,zephcore/include/mesh/StaticPoolPacketManager.h,zephcore/helpers/BaseChatMesh.cpp,zephcore/helpers/BaseChatMesh.h,zephcore/helpers/ContactInfo.h,zephcore/helpers/ChannelDetails.h,zephcore/helpers/ClientACL.cpp,zephcore/helpers/ClientACL.h,zephcore/helpers/RegionMap.cpp,zephcore/helpers/RegionMap.h,zephcore/helpers/TransportKeyStore.cpp,zephcore/helpers/TransportKeyStore.h,zephcore/helpers/BaseSerialInterface.h,zephcore/helpers/MultiSerialInterface.h,zephcore/helpers/ConfigSerializer.cpp,zephcore/helpers/ConfigSerializer.h,zephcore/helpers/SensorManager.h,zephcore/helpers/sensors/LPPDataHelpers.h,zephcore/app/KissModem.cpp,zephcore/app/KissModem.h}] indent_style = unset indent_size = unset tab_width = unset diff --git a/README.md b/README.md index 986211b..5a15d1f 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ the [supported boards list](docs/supported_boards.md). | **Repeater** | Relays packets for everyone else | [Text CLI](docs/CLI_commands.md) over USB, or remote admin over the mesh | | **Room server** | A shared message board that keeps posts for clients who were away | Same CLI as the repeater; users log in from the app | | **Observer** | Listens only and publishes what it hears to MQTT over WiFi (ESP32) | [Its own small CLI](docs/CLI_commands.md#observer-zephcore-only) over USB | +| **KISS modem** | Raw LoRa packets for a host program (KISS TNC, as upstream's `kiss_modem`) | [KISS protocol](docs/kiss_modem_protocol.md) over USB | One role per firmware image; you choose it when you flash. @@ -140,6 +141,7 @@ Roles and extras are config fragments in `zephcore/boards/common/`, added throug | `repeater.conf` | Repeater | | `room_server.conf` | Room server | | `observer.conf` | Observer (ESP32) | +| `kiss_modem.conf` | KISS modem | | `debug.conf` | Logging and asserts | | `packet_logging.conf` | One clean RAW/RX/TX line per packet, nothing else | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0aeb5f9..dc29907 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,12 +27,13 @@ ## 1. Project Overview -ZephCore is a LoRa mesh networking firmware running on Zephyr RTOS. It supports four device roles: +ZephCore is a LoRa mesh networking firmware running on Zephyr RTOS. It supports five device roles: - **Companion**: BLE-connected device paired with a phone app. Full contact/channel/message management. - **Repeater**: Autonomous headless relay node. CLI administration via authenticated mesh connections or serial UART. - **Room Server**: Headless store-and-forward shared message room (BBS). Reuses the repeater's ACL/region/CLI; pushes new posts to logged-in clients (per-client sync cursor + ACK). - **Observer** (ESP32): Listen-only node that publishes received LoRa packets to MQTT over WiFi. +- **KISS modem**: Raw LoRa packets to and from a host over serial (upstream's `kiss_modem`). Supported hardware: nRF52840, nRF54L15, nRF54LM20A, ESP32 (classic PICO-D4 and C3/C6/S3), EFR32MG24, and STM32WL (LoRa-E5). Radios: SX126x family (SX1261/62/68, LLCC68, STM32WL sub-GHz), LR1110, SX127x (SX1272/76/78, loramac-node backend), and LR2021 (validated on the MeshTracker X1). A native Linux port runs the full stack on SBCs (Femtofox, Raspberry Pi) via Zephyr `native_sim` — see `LINUX_NATIVE.md`. @@ -103,7 +104,8 @@ zephcore/ │ ├── RepeaterDataStore.cpp/h # Repeater-specific persistence paths │ ├── RoomServerMesh.cpp/h # Store-and-forward room server (BBS) │ ├── ObserverMesh.cpp/h # Listen-only WiFi+MQTT observer (ESP32) -│ └── main_observer.cpp, observer_creds.cpp/h +│ ├── main_observer.cpp, observer_creds.cpp/h +│ └── KissModem.cpp/h # KISS TNC + SetHardware extensions (upstream verbatim) │ ├── helpers/ # Shared utilities │ ├── compat/ # Arduino Stream/SHA256/FILESYSTEM over Zephyr, for upstream-verbatim code @@ -742,6 +744,24 @@ Headless store-and-forward shared message room (BBS). Clients log in with the ad Listen-only node (ESP32 only): receives LoRa packets and publishes them to an MQTT broker over WiFi STA (`adapters/mqtt/`, `adapters/wifi/`). Never transmits. Configured at runtime via serial CLI (credentials in `observer_creds.cpp`); build with `boards/common/observer.conf`. +### 6.5.1 KISS modem + +Upstream's `examples/kiss_modem` (protocol: [kiss_modem_protocol.md](kiss_modem_protocol.md)). `app/KissModem.cpp/h` +keep upstream's text, with ZephCore changes fenced; `src/main_kiss_modem.cpp` is the composition root. No `Mesh` +or `Dispatcher`: the modem drives `LoRaRadio` directly, and the main loop runs the radio's maintenance (noise floor, +CAD, AGC/drift) as the Dispatcher would. The host serial is the CDC-ACM port, else the console UART, through +`adapters/transport/ZephyrUartStream` (an Arduino `Stream` over interrupt-driven RX/TX rings); the console driver +is off in this role so nothing else writes to the port. Event-driven: serial input, LoRa RX and TX-done, host +output drained, and a one-shot for the sooner of `KissModem::msUntilNextTx()` and the radio's maintenance +deadline. Differences from upstream: +- An LBT refusal from `startSendRaw()` returns to the CSMA slot wait until the wait-clear bound (1.5 × the + airtime of a 255-byte packet from the start of CSMA), then reports TX failed; upstream reports it at once. +- A transmit times out only once the radio has concluded it, as in `Dispatcher::loop()`. +- `SetRadio` outside the `ZC_RADIO_*` ranges (NodePrefs.h) is refused with `HW_ERR_INVALID_PARAM`. +- No periodic AGC reset: the radio's own deaf-aware maintenance replaces it, as in every role. +Radio settings live in RAM only (upstream: the same); the identity is `/lfs/repeater/_main.id`, shared with the +server roles. Build with `boards/common/kiss_modem.conf`. + ### 6.6 CommonCLI Commands System: `ver`, `board`, `reboot`, `start dfu`, `start ota`, `erase` @@ -857,7 +877,8 @@ Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC While the bus is suspended (the computer asleep with the port open) the session is kept but is not a connected client: nothing is queued for it, and it does not hold up the clean-up that runs when the last client leaves. Each change reaches the main loop as a connect or a disconnect. -- Servers (repeater, room server, observer) use the CDC port as their serial console. +- Servers (repeater, room server, observer) use the CDC port as their serial console; the KISS modem uses it for + its binary stream. ### 7.5 Board (`adapters/board/`) @@ -1028,7 +1049,7 @@ prj.conf (base: console; production defaults — LOG=n, ASSERT=n) ### 9.2 Key Kconfig Choices -- **Role**: `ZEPHCORE_ROLE_COMPANION` (default) vs `ZEPHCORE_ROLE_REPEATER` vs `ZEPHCORE_ROLE_ROOM_SERVER` vs `ZEPHCORE_ROLE_OBSERVER` (selected via `repeater.conf` / `room_server.conf` / `observer.conf`) +- **Role**: `ZEPHCORE_ROLE_COMPANION` (default) vs `ZEPHCORE_ROLE_REPEATER` vs `ZEPHCORE_ROLE_ROOM_SERVER` vs `ZEPHCORE_ROLE_OBSERVER` vs `ZEPHCORE_ROLE_KISS_MODEM` (selected via `repeater.conf` / `room_server.conf` / `observer.conf` / `kiss_modem.conf`) - **Radio**: `ZEPHCORE_RADIO_NATIVE` (SX126x, default) vs `ZEPHCORE_RADIO_LR1110` vs `ZEPHCORE_RADIO_LR2021` vs `ZEPHCORE_RADIO_SX127X` - **Features**: Display, buzzer, buttons, multi-tap, Doom (auto-enabled from DT); PSRAM auto-enable from DT (`Kconfig.psram`) @@ -1226,21 +1247,21 @@ Codes `0x80`–`0x90` (`PUSH_CODE_*` in `app/CompanionMesh.h`). Most used: | `/lfs/contacts3` or `/ext/contacts3` | Contacts | 152B × N records | | `/lfs/channels2` or `/ext/channels2` | Channels | 68B × N records | | `/lfs/adv_blobs` or `/ext/adv_blobs` | Advert cache | Fixed-size blob records | -| `/lfs/repeater/_main.id`, `prefs.json` | Repeater/room-server/observer identity + prefs | `prefs.json` as above; legacy 311B `prefs` kept after migration; atomic-replace writes | +| `/lfs/repeater/_main.id`, `prefs.json` | Repeater/room-server/observer identity + prefs (KISS modem: identity only) | `prefs.json` as above; legacy 311B `prefs` kept after migration; atomic-replace writes | | `/lfs/repeater/acl` | Client ACL | 136B × N records | | `/lfs/repeater/regions2` | Region map | Header + 164B × N entries | | `storage_partition` (NVS, 0xD0000 nRF52) | BLE bonds + Zephyr settings | NVS settings backend (≥1.16.2; old `/lfs/settings` file detected by self-heal) | > **Roles are not interchangeable.** Each role formats the whole volume on its first boot if the > volume holds no data for that role: the companion checks `/lfs/prefs.json` and `/lfs/new_prefs` -> (`ZephyrDataStore::hasPrefs()`), the repeater/room-server/observer check `/lfs/repeater/prefs.json`, +> (`ZephyrDataStore::hasPrefs()`), the repeater/room-server/observer/KISS modem check `/lfs/repeater/prefs.json`, > `/lfs/repeater/prefs` and `/lfs/repeater/_main.id` (`RepeaterDataStore::hasRoleData()`). So flashing a repeater over > a companion — or the reverse — erases the previous role's identity, prefs and contacts, plus > `storage_partition` and QSPI. Export your identity before switching roles. The roles' files > never overlap physically (one LittleFS volume, one allocator); the reason for the wipe is that > they share 128 KB and the other role's data crowds out writes. Repeater, room server and > observer share `/lfs/repeater/` and the same prefs layout, so switching among *those three* -> preserves the identity. +> (or to the KISS modem, which keeps only the identity there) preserves the identity. ### Preferences (`prefs.json`) diff --git a/docs/BUILDING.md b/docs/BUILDING.md index aebe002..85f6129 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -42,6 +42,7 @@ west build -b zephcore --pristine -- -DEXTRA_CONF_FILE="boards/common/.conf. set(_kconfig_role companion) -foreach(_r repeater room_server observer) +foreach(_r repeater room_server observer kiss_modem) string(TOUPPER "${_r}" _R) if(CONFIG_ZEPHCORE_ROLE_${_R}) set(_kconfig_role ${_r}) @@ -604,6 +606,21 @@ elseif(CONFIG_ZEPHCORE_ROLE_OBSERVER) ${CMAKE_CURRENT_SOURCE_DIR}/app ) target_compile_definitions(app PRIVATE ZEPHCORE_OBSERVER=1) +elseif(CONFIG_ZEPHCORE_ROLE_KISS_MODEM) + message(STATUS "ZephCore Role: KISS MODEM") + target_sources(app PRIVATE + src/main_kiss_modem.cpp + app/KissModem.cpp + app/RepeaterDataStore.cpp + adapters/transport/ZephyrUartStream.cpp + ) + # USBD CDC ACM init + 1200-baud DFU, where the board has the class driver. + if(NOT CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT AND (CONFIG_USB_CDC_ACM OR CONFIG_USBD_CDC_ACM_CLASS)) + target_sources(app PRIVATE adapters/usb/ZephyrUSBCDC.cpp) + endif() + target_include_directories(app PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/adapters/usb + ) else() message(STATUS "ZephCore Role: COMPANION") target_sources(app PRIVATE @@ -720,8 +737,8 @@ if(CONFIG_ZEPHCORE_UI_BUTTONS OR CONFIG_ZEPHCORE_UI_BUZZER OR CONFIG_ZEPHCORE_UI target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/helpers/ui ) - # Repeater and Observer builds need weak stubs for companion-only UI mesh actions - if(CONFIG_ZEPHCORE_ROLE_REPEATER OR CONFIG_ZEPHCORE_ROLE_OBSERVER OR CONFIG_ZEPHCORE_ROLE_ROOM_SERVER) + # Every other role needs weak stubs for the companion-only UI mesh actions + if(NOT CONFIG_ZEPHCORE_ROLE_COMPANION) target_sources(app PRIVATE helpers/ui/ui_mesh_actions_stubs.c ) diff --git a/zephcore/Kconfig b/zephcore/Kconfig index 6460781..5727265 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -161,6 +161,14 @@ config ZEPHCORE_ROLE_ROOM_SERVER per-client sync cursor. No BLE — configured via USB serial CLI. Reuses the repeater's ACL, region filtering and CLI command set. +config ZEPHCORE_ROLE_KISS_MODEM + bool "KISS modem (raw LoRa packets to a host over serial)" + help + KISS TNC on the USB/console serial port, as upstream MeshCore's + kiss_modem: the host sends and receives raw packets, configures + the radio and uses the node identity through the SetHardware + extensions (docs/kiss_modem_protocol.md). No mesh, no BLE, no CLI. + endchoice config ZEPHCORE_COMPANION_USB diff --git a/zephcore/adapters/radio/LoRaRadio.h b/zephcore/adapters/radio/LoRaRadio.h index 2808395..26b95f2 100644 --- a/zephcore/adapters/radio/LoRaRadio.h +++ b/zephcore/adapters/radio/LoRaRadio.h @@ -81,6 +81,8 @@ public: /* Extended API */ bool isChannelActive(int threshold = 0); + /* Instantaneous RSSI in dBm (-80 where the family cannot read it). */ + int16_t getCurrentRSSI() { return hwGetCurrentRSSI(); } /* Power saving */ void enableRxDutyCycle(bool enable); diff --git a/zephcore/adapters/transport/ZephyrUartStream.cpp b/zephcore/adapters/transport/ZephyrUartStream.cpp new file mode 100644 index 0000000..0e66d82 --- /dev/null +++ b/zephcore/adapters/transport/ZephyrUartStream.cpp @@ -0,0 +1,156 @@ +/* + * SPDX-License-Identifier: MIT + * Arduino Stream over an interrupt-driven Zephyr UART. + */ + +#include "ZephyrUartStream.h" + +#include +#include + +bool ZephyrUartStream::begin(const struct device *dev, void (*on_rx)(void), + void (*on_tx_idle)(void)) +{ + if (!device_is_ready(dev)) { + return false; + } + _dev = dev; + _on_rx = on_rx; + _on_tx_idle = on_tx_idle; + ring_buf_init(&_rx_ring, sizeof(_rx_data), _rx_data); + ring_buf_init(&_tx_ring, sizeof(_tx_data), _tx_data); + k_sem_init(&_tx_drained, 0, 1); + uart_irq_callback_user_data_set(dev, isr, this); + uart_irq_rx_enable(dev); + return true; +} + +void ZephyrUartStream::isr(const struct device *dev, void *user_data) +{ + ZephyrUartStream *self = static_cast(user_data); + + for (;;) { + uart_irq_update(dev); + if (uart_irq_is_pending(dev) <= 0) { + break; + } + + if (uart_irq_rx_ready(dev)) { + uint8_t buf[64]; + uint32_t room = MIN(sizeof(buf), ring_buf_space_get(&self->_rx_ring)); + + if (room == 0) { + /* read() re-enables RX once it has made room. */ + atomic_set(&self->_rx_paused, 1); + uart_irq_rx_disable(dev); + } else { + int n = uart_fifo_read(dev, buf, room); + + if (n > 0) { + ring_buf_put(&self->_rx_ring, buf, n); + } + } + if (self->_on_rx) { + self->_on_rx(); + } + } + + if (uart_irq_tx_ready(dev)) { + uint8_t *out; + bool empty; + k_spinlock_key_t key = k_spin_lock(&self->_tx_lock); + uint32_t claimed = MIN(ring_buf_get_ptr(&self->_tx_ring, &out, 0), 64U); + + if (claimed > 0) { + int sent = uart_fifo_fill(dev, out, claimed); + + if (sent > 0) { + ring_buf_consume(&self->_tx_ring, sent); + } + } + empty = ring_buf_is_empty(&self->_tx_ring); + if (empty) { + /* Inside the lock: write() enables after its put. */ + uart_irq_tx_disable(dev); + } + k_spin_unlock(&self->_tx_lock, key); + + if (empty) { + k_sem_give(&self->_tx_drained); + if (self->_on_tx_idle) { + self->_on_tx_idle(); + } + } + } + } +} + +size_t ZephyrUartStream::write(const uint8_t *buffer, size_t size) +{ + if (!_dev || size == 0) { + return 0; + } + k_spinlock_key_t key = k_spin_lock(&_tx_lock); + uint32_t n = ring_buf_put(&_tx_ring, buffer, size); + + k_spin_unlock(&_tx_lock, key); + if (n > 0) { + uart_irq_tx_enable(_dev); + } + return n; +} + +int ZephyrUartStream::availableForWrite() +{ + if (!_dev) { + return 0; + } + k_spinlock_key_t key = k_spin_lock(&_tx_lock); + uint32_t space = ring_buf_space_get(&_tx_ring); + + k_spin_unlock(&_tx_lock, key); + return (int)space; +} + +int ZephyrUartStream::available() +{ + return _dev ? (int)ring_buf_size_get(&_rx_ring) : 0; +} + +int ZephyrUartStream::read() +{ + uint8_t b; + + if (!_dev || ring_buf_get(&_rx_ring, &b, 1) != 1) { + return -1; + } + if (atomic_cas(&_rx_paused, 1, 0)) { + uart_irq_rx_enable(_dev); + } + return b; +} + +int ZephyrUartStream::peek() +{ + uint8_t b; + + if (!_dev || ring_buf_peek(&_rx_ring, &b, 1) != 1) { + return -1; + } + return b; +} + +void ZephyrUartStream::flush() +{ + if (!_dev) { + return; + } + k_sem_reset(&_tx_drained); + k_spinlock_key_t key = k_spin_lock(&_tx_lock); + bool empty = ring_buf_is_empty(&_tx_ring); + + k_spin_unlock(&_tx_lock, key); + if (!empty) { + k_sem_take(&_tx_drained, K_MSEC(FLUSH_TIMEOUT_MS)); + } +} diff --git a/zephcore/adapters/transport/ZephyrUartStream.h b/zephcore/adapters/transport/ZephyrUartStream.h new file mode 100644 index 0000000..f4943ed --- /dev/null +++ b/zephcore/adapters/transport/ZephyrUartStream.h @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: MIT + * Arduino Stream over an interrupt-driven Zephyr UART (CDC-ACM or plain UART), + * for code ported from upstream that talks to `Serial` (the KISS modem). + * + * RX and TX each go through a ring drained by the UART ISR. A full RX ring + * stops reading the port until the main thread has made room, so the host is + * held off instead of losing bytes. Writes take what fits (availableForWrite()). + * The callbacks run in the ISR: post events only. + */ + +#pragma once + +#include +#include +#include +#include + +class ZephyrUartStream : public Stream { +public: + /* on_rx: bytes arrived. on_tx_idle: the TX ring has drained. */ + bool begin(const struct device *dev, void (*on_rx)(void), void (*on_tx_idle)(void)); + + using Print::write; + size_t write(uint8_t c) override { return write(&c, 1); } + size_t write(const uint8_t *buffer, size_t size) override; + int availableForWrite() override; + + int available() override; + int read() override; + int peek() override; + /* Waits for the TX ring to drain, at most FLUSH_TIMEOUT_MS. */ + void flush() override; + +private: + static constexpr size_t RX_BUF_SIZE = 512; + static constexpr size_t TX_BUF_SIZE = 1024; + static constexpr uint32_t FLUSH_TIMEOUT_MS = 200; + + static void isr(const struct device *dev, void *user_data); + + const struct device *_dev = nullptr; + void (*_on_rx)(void) = nullptr; + void (*_on_tx_idle)(void) = nullptr; + uint8_t _rx_data[RX_BUF_SIZE]; + uint8_t _tx_data[TX_BUF_SIZE]; + struct ring_buf _rx_ring; + struct ring_buf _tx_ring; + struct k_spinlock _tx_lock; + struct k_sem _tx_drained; + atomic_t _rx_paused = ATOMIC_INIT(0); +}; diff --git a/zephcore/app/KissModem.cpp b/zephcore/app/KissModem.cpp new file mode 100644 index 0000000..4acccca --- /dev/null +++ b/zephcore/app/KissModem.cpp @@ -0,0 +1,794 @@ +#include "KissModem.h" +#include + +// ZEPHCORE: Arduino's timing calls, and the shared radio preset ranges. +#include +#include +#include +static inline uint32_t millis() { return k_uptime_get_32(); } +static inline void delay(uint32_t ms) { k_msleep(ms); } + +KissModem::KissModem(Stream& serial, mesh::LocalIdentity& identity, mesh::RNG& rng, + mesh::Radio& radio, mesh::MainBoard& board, SensorManager& sensors) + : _serial(serial), _identity(identity), _rng(rng), _radio(radio), _board(board), _sensors(sensors) { + _rx_len = 0; + _rx_escaped = false; + _rx_active = false; + _has_pending_tx = false; + _pending_tx_len = 0; + _txdelay = KISS_DEFAULT_TXDELAY; + _persistence = KISS_DEFAULT_PERSISTENCE; + _slottime = KISS_DEFAULT_SLOTTIME; + _txtail = 0; + _fullduplex = 0; + _tx_state = TX_IDLE; + _tx_timer = 0; + _tx_csma_start = 0; // ZEPHCORE + _setRadioCallback = nullptr; + _setTxPowerCallback = nullptr; + _getCurrentRssiCallback = nullptr; + _getStatsCallback = nullptr; + _config = {0, 0, 0, 0, 0}; + _signal_report_enabled = true; + resetOutputQueue(); +} + +void KissModem::begin() { + _rx_len = 0; + _rx_escaped = false; + _rx_active = false; + _has_pending_tx = false; + _tx_state = TX_IDLE; + resetOutputQueue(); +} + +void KissModem::resetOutputQueue() { + _tx_frame_head = 0; + _tx_frame_tail = 0; + _tx_frame_count = 0; + _tx_busy_error_pending = false; + _tx_done_pending = false; + _tx_done_result = 0; +} + +void KissModem::popTxFrame() { + _tx_frame_head = (uint8_t)((_tx_frame_head + 1) % KISS_TX_FRAME_QUEUE_DEPTH); + _tx_frame_count--; +} + +uint16_t KissModem::appendEscapedByte(uint8_t* dest, uint16_t idx, uint16_t max_len, uint8_t b) { + if (b == KISS_FEND || b == KISS_FESC) { + if (idx + 2 > max_len) { + return 0; + } + dest[idx++] = KISS_FESC; + dest[idx++] = (b == KISS_FEND) ? KISS_TFEND : KISS_TFESC; + return idx; + } + if (idx + 1 > max_len) { + return 0; + } + dest[idx++] = b; + return idx; +} + +uint16_t KissModem::encodeFrame(uint8_t type, const uint8_t* data, uint16_t len, uint8_t* dest, uint16_t max_len) { + if (max_len < KISS_FRAME_BOUNDARY_BYTES) { + return 0; + } + + uint16_t idx = 0; + dest[idx++] = KISS_FEND; + + idx = appendEscapedByte(dest, idx, max_len, type); + if (idx == 0) { + return 0; + } + + for (uint16_t i = 0; i < len; i++) { + idx = appendEscapedByte(dest, idx, max_len, data[i]); + if (idx == 0) { + return 0; + } + } + + if (idx + 1 > max_len) { + return 0; + } + dest[idx++] = KISS_FEND; + return idx; +} + +bool KissModem::tryFlushFrames() { + while (_tx_frame_count > 0) { + const uint8_t idx = _tx_frame_head; + const uint16_t frame_len = _tx_frame_len[idx]; + uint16_t written_len = _tx_frame_written[idx]; + + if (written_len >= frame_len) { + popTxFrame(); + continue; + } + + const int available = _serial.availableForWrite(); + if (available <= 0) { + return false; + } + + const uint16_t remaining = frame_len - written_len; + const uint16_t chunk_len = (available < (int)remaining) ? (uint16_t)available : remaining; + if (chunk_len == 0) { + return false; + } + + size_t chunk_written = _serial.write(_tx_frame_buf[idx] + written_len, chunk_len); + if (chunk_written == 0) { + return false; + } + + written_len += (uint16_t)chunk_written; + _tx_frame_written[idx] = written_len; + + if (written_len < frame_len) { + return false; + } + + popTxFrame(); + } + return true; +} + +bool KissModem::queueFrame(uint8_t type, const uint8_t* data, uint16_t len, bool mark_busy_error) { + if (_tx_frame_count >= KISS_TX_FRAME_QUEUE_DEPTH && !tryFlushFrames()) { + if (mark_busy_error) { + _tx_busy_error_pending = true; + } + return false; + } + const uint8_t idx = _tx_frame_tail; + uint16_t frame_len = encodeFrame(type, data, len, _tx_frame_buf[idx], sizeof(_tx_frame_buf[idx])); + if (frame_len == 0) { + return false; + } + + _tx_frame_len[idx] = frame_len; + _tx_frame_written[idx] = 0; + _tx_frame_tail = (uint8_t)((_tx_frame_tail + 1) % KISS_TX_FRAME_QUEUE_DEPTH); + _tx_frame_count++; + tryFlushFrames(); + return true; +} + +bool KissModem::queuePendingBusyError() { + if (!_tx_busy_error_pending) { + return true; + } + const uint8_t err = HW_ERR_TX_BUSY; + if (!queueHardwareFrame(HW_RESP_ERROR, &err, 1, false)) { + return false; + } + _tx_busy_error_pending = false; + return true; +} + +bool KissModem::queueHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len, bool mark_busy_error) { + if (len > KISS_MAX_FRAME_SIZE) { + return false; + } + _tx_hw_payload[0] = sub_cmd; + if (len > 0) { + memcpy(_tx_hw_payload + 1, data, len); + } + return queueFrame(KISS_CMD_SETHARDWARE, _tx_hw_payload, len + 1, mark_busy_error); +} + +bool KissModem::queuePendingTxDone() { + if (!_tx_done_pending) { + return true; + } + if (!queueHardwareFrame(HW_RESP_TX_DONE, &_tx_done_result, 1, false)) { + return false; + } + _tx_done_pending = false; + return true; +} + +void KissModem::setTxDonePending(uint8_t result) { + _tx_done_result = result; + _tx_done_pending = true; + _tx_state = TX_DONE_PENDING; +} + +bool KissModem::writeHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len) { + return queueHardwareFrame(sub_cmd, data, len, true); +} + +void KissModem::writeHardwareError(uint8_t error_code) { + writeHardwareFrame(HW_RESP_ERROR, &error_code, 1); +} + +void KissModem::loop() { + tryFlushFrames(); + + while (_serial.available()) { + uint8_t b = _serial.read(); + + if (b == KISS_FEND) { + if (_rx_active && _rx_len > 0) { + processFrame(); + } + _rx_len = 0; + _rx_escaped = false; + _rx_active = true; + continue; + } + + if (!_rx_active) continue; + + if (b == KISS_FESC) { + _rx_escaped = true; + continue; + } + + if (_rx_escaped) { + _rx_escaped = false; + if (b == KISS_TFEND) b = KISS_FEND; + else if (b == KISS_TFESC) b = KISS_FESC; + else continue; + } + + if (_rx_len < KISS_MAX_FRAME_SIZE) { + _rx_buf[_rx_len++] = b; + } else { + /* Buffer full with no FEND; reset so we don't stay stuck ignoring input. */ + _rx_len = 0; + _rx_escaped = false; + _rx_active = false; + } + } + + processTx(); + tryFlushFrames(); + queuePendingBusyError(); +} + +void KissModem::processFrame() { + if (_rx_len < 1) return; + + uint8_t type_byte = _rx_buf[0]; + + if (type_byte == KISS_CMD_RETURN) return; + + uint8_t port = (type_byte >> 4) & 0x0F; + uint8_t cmd = type_byte & 0x0F; + + if (port != 0) return; + + const uint8_t* data = &_rx_buf[1]; + uint16_t data_len = _rx_len - 1; + + switch (cmd) { + case KISS_CMD_DATA: + if (data_len > 0 && data_len <= KISS_MAX_PACKET_SIZE && !_has_pending_tx) { + memcpy(_pending_tx, data, data_len); + _pending_tx_len = data_len; + _has_pending_tx = true; + } else if (_has_pending_tx) { + writeHardwareError(HW_ERR_TX_BUSY); + } + break; + + case KISS_CMD_TXDELAY: + if (data_len >= 1) _txdelay = data[0]; + break; + + case KISS_CMD_PERSISTENCE: + if (data_len >= 1) _persistence = data[0]; + break; + + case KISS_CMD_SLOTTIME: + if (data_len >= 1) _slottime = data[0]; + break; + + case KISS_CMD_TXTAIL: + if (data_len >= 1) _txtail = data[0]; + break; + + case KISS_CMD_FULLDUPLEX: + if (data_len >= 1) _fullduplex = data[0]; + break; + + case KISS_CMD_SETHARDWARE: + if (data_len >= 1) { + handleHardwareCommand(data[0], data + 1, data_len - 1); + } + break; + + default: + break; + } +} + +void KissModem::handleHardwareCommand(uint8_t sub_cmd, const uint8_t* data, uint16_t len) { + switch (sub_cmd) { + case HW_CMD_GET_IDENTITY: + handleGetIdentity(); + break; + case HW_CMD_GET_RANDOM: + handleGetRandom(data, len); + break; + case HW_CMD_VERIFY_SIGNATURE: + handleVerifySignature(data, len); + break; + case HW_CMD_SIGN_DATA: + handleSignData(data, len); + break; + case HW_CMD_ENCRYPT_DATA: + handleEncryptData(data, len); + break; + case HW_CMD_DECRYPT_DATA: + handleDecryptData(data, len); + break; + case HW_CMD_KEY_EXCHANGE: + handleKeyExchange(data, len); + break; + case HW_CMD_HASH: + handleHash(data, len); + break; + case HW_CMD_SET_RADIO: + handleSetRadio(data, len); + break; + case HW_CMD_SET_TX_POWER: + handleSetTxPower(data, len); + break; + case HW_CMD_GET_RADIO: + handleGetRadio(); + break; + case HW_CMD_GET_TX_POWER: + handleGetTxPower(); + break; + case HW_CMD_GET_VERSION: + handleGetVersion(); + break; + case HW_CMD_GET_CURRENT_RSSI: + handleGetCurrentRssi(); + break; + case HW_CMD_IS_CHANNEL_BUSY: + handleIsChannelBusy(); + break; + case HW_CMD_GET_AIRTIME: + handleGetAirtime(data, len); + break; + case HW_CMD_GET_NOISE_FLOOR: + handleGetNoiseFloor(); + break; + case HW_CMD_GET_STATS: + handleGetStats(); + break; + case HW_CMD_GET_BATTERY: + handleGetBattery(); + break; + case HW_CMD_PING: + handlePing(); + break; + case HW_CMD_GET_SENSORS: + handleGetSensors(data, len); + break; + case HW_CMD_GET_MCU_TEMP: + handleGetMCUTemp(); + break; + case HW_CMD_REBOOT: + handleReboot(); + break; + case HW_CMD_GET_DEVICE_NAME: + handleGetDeviceName(); + break; + case HW_CMD_SET_SIGNAL_REPORT: + handleSetSignalReport(data, len); + break; + case HW_CMD_GET_SIGNAL_REPORT: + handleGetSignalReport(); + break; + default: + writeHardwareError(HW_ERR_UNKNOWN_CMD); + break; + } +} + +void KissModem::processTx() { + switch (_tx_state) { + case TX_IDLE: + if (_has_pending_tx) { + _tx_csma_start = millis(); // ZEPHCORE + if (_fullduplex) { + _tx_timer = millis(); + _tx_state = TX_DELAY; + } else { + _tx_timer = millis(); + _tx_state = TX_WAIT_CLEAR; + } + } + break; + + case TX_WAIT_CLEAR: + if (!_radio.isReceiving()) { + uint8_t rand_val; + _rng.random(&rand_val, 1); + if (rand_val <= _persistence) { + _tx_timer = millis(); + _tx_state = TX_DELAY; + } else { + _tx_timer = millis(); + _tx_state = TX_SLOT_WAIT; + } + } else if (millis() - _tx_timer >= _radio.getEstAirtimeFor(KISS_MAX_PACKET_SIZE) * KISS_TX_TIMEOUT_FACTOR) { + _tx_timer = millis(); + _tx_state = TX_DELAY; + } + break; + + case TX_SLOT_WAIT: + if (millis() - _tx_timer >= (uint32_t)_slottime * 10) { + _tx_timer = millis(); + _tx_state = TX_WAIT_CLEAR; + } + break; + + case TX_DELAY: + if (millis() - _tx_timer >= (uint32_t)_txdelay * 10) { + if (_radio.startSendRaw(_pending_tx, _pending_tx_len)) { + _tx_timer = millis(); + _tx_state = TX_SENDING; + // ZEPHCORE: the radio refuses while it hears a signal (LBT) or the last TX + // winds down; back to CSMA until the wait-clear bound, then fail. + } else if (millis() - _tx_csma_start < _radio.getEstAirtimeFor(KISS_MAX_PACKET_SIZE) * KISS_TX_TIMEOUT_FACTOR) { + _tx_timer = millis(); + _tx_state = TX_SLOT_WAIT; + } else { + setTxDonePending(0x00); + } + } + break; + + case TX_SENDING: { + // ZEPHCORE: as Dispatcher::loop(), time out only once the radio has concluded; + // isTxActive() is read first because completion is published before it drops. + bool tx_active = _radio.isTxActive(); + if (_radio.isSendComplete()) { + _radio.onSendFinished(); + setTxDonePending(0x01); + } else if (!tx_active && millis() - _tx_timer >= _radio.getEstAirtimeFor(_pending_tx_len) * KISS_TX_TIMEOUT_FACTOR) { + _radio.onSendFinished(); + setTxDonePending(0x00); + } + break; + } + + case TX_DONE_PENDING: + if (queuePendingTxDone()) { + _has_pending_tx = false; + _tx_state = TX_IDLE; + } + break; + } +} + +void KissModem::onPacketReceived(int8_t snr, int8_t rssi, const uint8_t* packet, uint16_t len) { + if (queueFrame(KISS_CMD_DATA, packet, len) && _signal_report_enabled) { + uint8_t meta[2] = { (uint8_t)snr, (uint8_t)rssi }; + writeHardwareFrame(HW_RESP_RX_META, meta, 2); + } +} + +void KissModem::handleGetIdentity() { + writeHardwareFrame(HW_RESP(HW_CMD_GET_IDENTITY), _identity.pub_key, PUB_KEY_SIZE); +} + +void KissModem::handleGetRandom(const uint8_t* data, uint16_t len) { + if (len < 1) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + + uint8_t requested = data[0]; + if (requested < 1 || requested > 64) { + writeHardwareError(HW_ERR_INVALID_PARAM); + return; + } + + uint8_t buf[64]; + _rng.random(buf, requested); + writeHardwareFrame(HW_RESP(HW_CMD_GET_RANDOM), buf, requested); +} + +void KissModem::handleVerifySignature(const uint8_t* data, uint16_t len) { + if (len < PUB_KEY_SIZE + SIGNATURE_SIZE + 1) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + + mesh::Identity signer(data); + const uint8_t* signature = data + PUB_KEY_SIZE; + const uint8_t* msg = data + PUB_KEY_SIZE + SIGNATURE_SIZE; + uint16_t msg_len = len - PUB_KEY_SIZE - SIGNATURE_SIZE; + + uint8_t result = signer.verify(signature, msg, msg_len) ? 0x01 : 0x00; + writeHardwareFrame(HW_RESP(HW_CMD_VERIFY_SIGNATURE), &result, 1); +} + +void KissModem::handleSignData(const uint8_t* data, uint16_t len) { + if (len < 1) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + + uint8_t signature[SIGNATURE_SIZE]; + _identity.sign(signature, data, len); + writeHardwareFrame(HW_RESP(HW_CMD_SIGN_DATA), signature, SIGNATURE_SIZE); +} + +void KissModem::handleEncryptData(const uint8_t* data, uint16_t len) { + if (len < PUB_KEY_SIZE + 1) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + + const uint8_t* key = data; + const uint8_t* plaintext = data + PUB_KEY_SIZE; + uint16_t plaintext_len = len - PUB_KEY_SIZE; + + uint8_t buf[KISS_MAX_FRAME_SIZE]; + int encrypted_len = mesh::Utils::encryptThenMAC(key, buf, plaintext, plaintext_len); + + if (encrypted_len > 0) { + writeHardwareFrame(HW_RESP(HW_CMD_ENCRYPT_DATA), buf, encrypted_len); + } else { + writeHardwareError(HW_ERR_ENCRYPT_FAILED); + } +} + +void KissModem::handleDecryptData(const uint8_t* data, uint16_t len) { + if (len < PUB_KEY_SIZE + CIPHER_MAC_SIZE + 1) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + + const uint8_t* key = data; + const uint8_t* ciphertext = data + PUB_KEY_SIZE; + uint16_t ciphertext_len = len - PUB_KEY_SIZE; + + uint8_t buf[KISS_MAX_FRAME_SIZE]; + int decrypted_len = mesh::Utils::MACThenDecrypt(key, buf, ciphertext, ciphertext_len); + + if (decrypted_len > 0) { + writeHardwareFrame(HW_RESP(HW_CMD_DECRYPT_DATA), buf, decrypted_len); + } else { + writeHardwareError(HW_ERR_MAC_FAILED); + } +} + +void KissModem::handleKeyExchange(const uint8_t* data, uint16_t len) { + if (len < PUB_KEY_SIZE) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + + uint8_t shared_secret[PUB_KEY_SIZE]; + _identity.calcSharedSecret(shared_secret, data); + writeHardwareFrame(HW_RESP(HW_CMD_KEY_EXCHANGE), shared_secret, PUB_KEY_SIZE); +} + +void KissModem::handleHash(const uint8_t* data, uint16_t len) { + if (len < 1) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + + uint8_t hash[32]; + mesh::Utils::sha256(hash, 32, data, len); + writeHardwareFrame(HW_RESP(HW_CMD_HASH), hash, 32); +} + +void KissModem::handleSetRadio(const uint8_t* data, uint16_t len) { + if (len < 10) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + if (!_setRadioCallback) { + writeHardwareError(HW_ERR_NO_CALLBACK); + return; + } + // ZEPHCORE: refuse a preset outside the ranges every ZephCore setter accepts (NodePrefs.h). + { + uint32_t freq_hz, bw_hz; + memcpy(&freq_hz, data, 4); + memcpy(&bw_hz, data + 4, 4); + float mhz = freq_hz / 1000000.0f, khz = bw_hz / 1000.0f; + if (mhz < ZC_RADIO_FREQ_MIN_MHZ || mhz > ZC_RADIO_FREQ_MAX_MHZ || khz < ZC_RADIO_BW_MIN_KHZ || + khz > ZC_RADIO_BW_MAX_KHZ || data[8] < 5 || data[8] > 12 || data[9] < 5 || data[9] > 8) { + writeHardwareError(HW_ERR_INVALID_PARAM); + return; + } + } + + memcpy(&_config.freq_hz, data, 4); + memcpy(&_config.bw_hz, data + 4, 4); + _config.sf = data[8]; + _config.cr = data[9]; + + _setRadioCallback(_config.freq_hz / 1000000.0f, _config.bw_hz / 1000.0f, _config.sf, _config.cr); + writeHardwareFrame(HW_RESP_OK, nullptr, 0); +} + +void KissModem::handleSetTxPower(const uint8_t* data, uint16_t len) { + if (len < 1) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + if (!_setTxPowerCallback) { + writeHardwareError(HW_ERR_NO_CALLBACK); + return; + } + + _config.tx_power = data[0]; + _setTxPowerCallback(data[0]); + writeHardwareFrame(HW_RESP_OK, nullptr, 0); +} + +void KissModem::handleGetRadio() { + uint8_t buf[10]; + memcpy(buf, &_config.freq_hz, 4); + memcpy(buf + 4, &_config.bw_hz, 4); + buf[8] = _config.sf; + buf[9] = _config.cr; + writeHardwareFrame(HW_RESP(HW_CMD_GET_RADIO), buf, 10); +} + +void KissModem::handleGetTxPower() { + writeHardwareFrame(HW_RESP(HW_CMD_GET_TX_POWER), &_config.tx_power, 1); +} + +void KissModem::handleGetVersion() { + uint8_t buf[2]; + buf[0] = KISS_FIRMWARE_VERSION; + buf[1] = 0; + writeHardwareFrame(HW_RESP(HW_CMD_GET_VERSION), buf, 2); +} + +void KissModem::handleGetCurrentRssi() { + if (!_getCurrentRssiCallback) { + writeHardwareError(HW_ERR_NO_CALLBACK); + return; + } + + float rssi = _getCurrentRssiCallback(); + int8_t rssi_byte = (int8_t)rssi; + writeHardwareFrame(HW_RESP(HW_CMD_GET_CURRENT_RSSI), (uint8_t*)&rssi_byte, 1); +} + +void KissModem::handleIsChannelBusy() { + uint8_t busy = _radio.isReceiving() ? 0x01 : 0x00; + writeHardwareFrame(HW_RESP(HW_CMD_IS_CHANNEL_BUSY), &busy, 1); +} + +void KissModem::handleGetAirtime(const uint8_t* data, uint16_t len) { + if (len < 1) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + + uint8_t packet_len = data[0]; + uint32_t airtime = _radio.getEstAirtimeFor(packet_len); + writeHardwareFrame(HW_RESP(HW_CMD_GET_AIRTIME), (uint8_t*)&airtime, 4); +} + +void KissModem::handleGetNoiseFloor() { + int16_t noise_floor = _radio.getNoiseFloor(); + writeHardwareFrame(HW_RESP(HW_CMD_GET_NOISE_FLOOR), (uint8_t*)&noise_floor, 2); +} + +void KissModem::handleGetStats() { + if (!_getStatsCallback) { + writeHardwareError(HW_ERR_NO_CALLBACK); + return; + } + + uint32_t rx, tx, errors; + _getStatsCallback(&rx, &tx, &errors); + uint8_t buf[12]; + memcpy(buf, &rx, 4); + memcpy(buf + 4, &tx, 4); + memcpy(buf + 8, &errors, 4); + writeHardwareFrame(HW_RESP(HW_CMD_GET_STATS), buf, 12); +} + +void KissModem::handleGetBattery() { + uint16_t mv = _board.getBattMilliVolts(); + writeHardwareFrame(HW_RESP(HW_CMD_GET_BATTERY), (uint8_t*)&mv, 2); +} + +void KissModem::handlePing() { + writeHardwareFrame(HW_RESP(HW_CMD_PING), nullptr, 0); +} + +void KissModem::handleGetSensors(const uint8_t* data, uint16_t len) { + if (len < 1) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + + uint8_t permissions = data[0]; + CayenneLPP telemetry(255); + if (_sensors.querySensors(permissions, telemetry)) { + writeHardwareFrame(HW_RESP(HW_CMD_GET_SENSORS), telemetry.getBuffer(), telemetry.getSize()); + } else { + writeHardwareFrame(HW_RESP(HW_CMD_GET_SENSORS), nullptr, 0); + } +} + +void KissModem::handleGetMCUTemp() { + float temp = _board.getMCUTemperature(); + if (isnan(temp)) { + writeHardwareError(HW_ERR_NO_CALLBACK); + return; + } + int16_t temp_tenths = (int16_t)(temp * 10.0f); + writeHardwareFrame(HW_RESP(HW_CMD_GET_MCU_TEMP), (uint8_t*)&temp_tenths, 2); +} + +void KissModem::handleReboot() { + writeHardwareFrame(HW_RESP_OK, nullptr, 0); + _serial.flush(); + delay(50); + _board.reboot(); +} + +void KissModem::handleGetDeviceName() { + const char* name = _board.getManufacturerName(); + writeHardwareFrame(HW_RESP(HW_CMD_GET_DEVICE_NAME), (const uint8_t*)name, strlen(name)); +} + +void KissModem::handleSetSignalReport(const uint8_t* data, uint16_t len) { + if (len < 1) { + writeHardwareError(HW_ERR_INVALID_LENGTH); + return; + } + _signal_report_enabled = (data[0] != 0x00); + uint8_t val = _signal_report_enabled ? 0x01 : 0x00; + writeHardwareFrame(HW_RESP(HW_CMD_GET_SIGNAL_REPORT), &val, 1); +} + +void KissModem::handleGetSignalReport() { + uint8_t val = _signal_report_enabled ? 0x01 : 0x00; + writeHardwareFrame(HW_RESP(HW_CMD_GET_SIGNAL_REPORT), &val, 1); +} + +// ZEPHCORE: the deadlines processTx() polls millis() for. Channel-clear is +// rechecked once per slot (at least the 10 ms KISS unit); TX_SENDING completes on +// the radio's TX-done event; TX_DONE_PENDING waits for host output space. +uint32_t KissModem::msUntilNextTx() const { + uint32_t elapsed = millis() - _tx_timer; + uint32_t wait; + + switch (_tx_state) { + case TX_WAIT_CLEAR: { + uint32_t slot = _slottime ? (uint32_t)_slottime * 10 : 10; + return slot - elapsed % slot; + } + case TX_SLOT_WAIT: + wait = (uint32_t)_slottime * 10; + break; + case TX_DELAY: + wait = (uint32_t)_txdelay * 10; + break; + case TX_SENDING: + wait = _radio.getEstAirtimeFor(_pending_tx_len) * KISS_TX_TIMEOUT_FACTOR; + if (elapsed >= wait) { + return UINT32_MAX; // the radio is still concluding; its TX-done event follows + } + break; + default: + return _has_pending_tx && _tx_state == TX_IDLE ? 0 : UINT32_MAX; + } + return elapsed >= wait ? 0 : wait - elapsed; +} diff --git a/zephcore/app/KissModem.h b/zephcore/app/KissModem.h new file mode 100644 index 0000000..23f5ab5 --- /dev/null +++ b/zephcore/app/KissModem.h @@ -0,0 +1,217 @@ +#pragma once + +#include +#include // ZEPHCORE: was +#include // ZEPHCORE: was +#include // ZEPHCORE: was +#include + +#define KISS_FEND 0xC0 +#define KISS_FESC 0xDB +#define KISS_TFEND 0xDC +#define KISS_TFESC 0xDD + +#define KISS_MAX_FRAME_SIZE 512 +#define KISS_MAX_PACKET_SIZE 255 +#define KISS_FRAME_BOUNDARY_BYTES 2 +#define KISS_TYPE_BYTES 1 +#define KISS_HW_SUBCMD_BYTES 1 +#define KISS_MAX_ESCAPABLE_BYTES (KISS_MAX_FRAME_SIZE + KISS_TYPE_BYTES + KISS_HW_SUBCMD_BYTES) +#define KISS_MAX_ESCAPED_PAYLOAD_SIZE (2 * KISS_MAX_ESCAPABLE_BYTES) +#define KISS_MAX_ENCODED_FRAME_SIZE (KISS_FRAME_BOUNDARY_BYTES + KISS_MAX_ESCAPED_PAYLOAD_SIZE) +#define KISS_TX_FRAME_QUEUE_DEPTH 2 +#define KISS_HW_MAX_PAYLOAD_SIZE (KISS_MAX_FRAME_SIZE + KISS_HW_SUBCMD_BYTES) + +#define KISS_CMD_DATA 0x00 +#define KISS_CMD_TXDELAY 0x01 +#define KISS_CMD_PERSISTENCE 0x02 +#define KISS_CMD_SLOTTIME 0x03 +#define KISS_CMD_TXTAIL 0x04 +#define KISS_CMD_FULLDUPLEX 0x05 +#define KISS_CMD_SETHARDWARE 0x06 +#define KISS_CMD_RETURN 0xFF + +#define KISS_DEFAULT_TXDELAY 50 +#define KISS_DEFAULT_PERSISTENCE 63 +#define KISS_DEFAULT_SLOTTIME 10 +#define KISS_TX_TIMEOUT_FACTOR 3/2 // 1.5x estimated airtime + +#define HW_CMD_GET_IDENTITY 0x01 +#define HW_CMD_GET_RANDOM 0x02 +#define HW_CMD_VERIFY_SIGNATURE 0x03 +#define HW_CMD_SIGN_DATA 0x04 +#define HW_CMD_ENCRYPT_DATA 0x05 +#define HW_CMD_DECRYPT_DATA 0x06 +#define HW_CMD_KEY_EXCHANGE 0x07 +#define HW_CMD_HASH 0x08 +#define HW_CMD_SET_RADIO 0x09 +#define HW_CMD_SET_TX_POWER 0x0A +#define HW_CMD_GET_RADIO 0x0B +#define HW_CMD_GET_TX_POWER 0x0C +#define HW_CMD_GET_CURRENT_RSSI 0x0D +#define HW_CMD_IS_CHANNEL_BUSY 0x0E +#define HW_CMD_GET_AIRTIME 0x0F +#define HW_CMD_GET_NOISE_FLOOR 0x10 +#define HW_CMD_GET_VERSION 0x11 +#define HW_CMD_GET_STATS 0x12 +#define HW_CMD_GET_BATTERY 0x13 +#define HW_CMD_GET_MCU_TEMP 0x14 +#define HW_CMD_GET_SENSORS 0x15 +#define HW_CMD_GET_DEVICE_NAME 0x16 +#define HW_CMD_PING 0x17 +#define HW_CMD_REBOOT 0x18 +#define HW_CMD_SET_SIGNAL_REPORT 0x19 +#define HW_CMD_GET_SIGNAL_REPORT 0x1A + +/* Response code = command code | 0x80. Generic / unsolicited use 0xF0+. */ +#define HW_RESP(cmd) ((cmd) | 0x80) + +/* Generic responses (shared by multiple commands) */ +#define HW_RESP_OK 0xF0 +#define HW_RESP_ERROR 0xF1 + +/* Unsolicited notifications (no corresponding request) */ +#define HW_RESP_TX_DONE 0xF8 +#define HW_RESP_RX_META 0xF9 + +#define HW_ERR_INVALID_LENGTH 0x01 +#define HW_ERR_INVALID_PARAM 0x02 +#define HW_ERR_NO_CALLBACK 0x03 +#define HW_ERR_MAC_FAILED 0x04 +#define HW_ERR_UNKNOWN_CMD 0x05 +#define HW_ERR_ENCRYPT_FAILED 0x06 +#define HW_ERR_TX_BUSY 0x07 + +#define KISS_FIRMWARE_VERSION 1 + +typedef void (*SetRadioCallback)(float freq, float bw, uint8_t sf, uint8_t cr); +typedef void (*SetTxPowerCallback)(uint8_t power); +typedef float (*GetCurrentRssiCallback)(); +typedef void (*GetStatsCallback)(uint32_t* rx, uint32_t* tx, uint32_t* errors); + +struct RadioConfig { + uint32_t freq_hz; + uint32_t bw_hz; + uint8_t sf; + uint8_t cr; + uint8_t tx_power; +}; + +enum TxState { + TX_IDLE, + TX_WAIT_CLEAR, + TX_SLOT_WAIT, + TX_DELAY, + TX_SENDING, + TX_DONE_PENDING +}; + +class KissModem { + Stream& _serial; + mesh::LocalIdentity& _identity; + mesh::RNG& _rng; + mesh::Radio& _radio; + mesh::MainBoard& _board; + SensorManager& _sensors; + + uint8_t _rx_buf[KISS_MAX_FRAME_SIZE]; + uint16_t _rx_len; + bool _rx_escaped; + bool _rx_active; + + uint8_t _pending_tx[KISS_MAX_PACKET_SIZE]; + uint16_t _pending_tx_len; + bool _has_pending_tx; + + uint8_t _txdelay; + uint8_t _persistence; + uint8_t _slottime; + uint8_t _txtail; + uint8_t _fullduplex; + + TxState _tx_state; + uint32_t _tx_timer; + uint32_t _tx_csma_start; // ZEPHCORE: start of the CSMA wait, bounds LBT refusals + + SetRadioCallback _setRadioCallback; + SetTxPowerCallback _setTxPowerCallback; + GetCurrentRssiCallback _getCurrentRssiCallback; + GetStatsCallback _getStatsCallback; + + RadioConfig _config; + bool _signal_report_enabled; + uint8_t _tx_frame_buf[KISS_TX_FRAME_QUEUE_DEPTH][KISS_MAX_ENCODED_FRAME_SIZE]; + uint16_t _tx_frame_len[KISS_TX_FRAME_QUEUE_DEPTH]; + uint16_t _tx_frame_written[KISS_TX_FRAME_QUEUE_DEPTH]; + uint8_t _tx_frame_head; + uint8_t _tx_frame_tail; + uint8_t _tx_frame_count; + bool _tx_busy_error_pending; + bool _tx_done_pending; + uint8_t _tx_done_result; + uint8_t _tx_hw_payload[KISS_HW_MAX_PAYLOAD_SIZE]; + + static uint16_t appendEscapedByte(uint8_t* dest, uint16_t idx, uint16_t max_len, uint8_t b); + static uint16_t encodeFrame(uint8_t type, const uint8_t* data, uint16_t len, uint8_t* dest, uint16_t max_len); + void resetOutputQueue(); + void popTxFrame(); + bool tryFlushFrames(); + bool queueFrame(uint8_t type, const uint8_t* data, uint16_t len, bool mark_busy_error = true); + bool queuePendingBusyError(); + bool queueHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len, bool mark_busy_error); + bool queuePendingTxDone(); + void setTxDonePending(uint8_t result); + bool writeHardwareFrame(uint8_t sub_cmd, const uint8_t* data, uint16_t len); + void writeHardwareError(uint8_t error_code); + void processFrame(); + void handleHardwareCommand(uint8_t sub_cmd, const uint8_t* data, uint16_t len); + void processTx(); + + void handleGetIdentity(); + void handleGetRandom(const uint8_t* data, uint16_t len); + void handleVerifySignature(const uint8_t* data, uint16_t len); + void handleSignData(const uint8_t* data, uint16_t len); + void handleEncryptData(const uint8_t* data, uint16_t len); + void handleDecryptData(const uint8_t* data, uint16_t len); + void handleKeyExchange(const uint8_t* data, uint16_t len); + void handleHash(const uint8_t* data, uint16_t len); + void handleSetRadio(const uint8_t* data, uint16_t len); + void handleSetTxPower(const uint8_t* data, uint16_t len); + void handleGetRadio(); + void handleGetTxPower(); + void handleGetVersion(); + void handleGetCurrentRssi(); + void handleIsChannelBusy(); + void handleGetAirtime(const uint8_t* data, uint16_t len); + void handleGetNoiseFloor(); + void handleGetStats(); + void handleGetBattery(); + void handlePing(); + void handleGetSensors(const uint8_t* data, uint16_t len); + void handleGetMCUTemp(); + void handleReboot(); + void handleGetDeviceName(); + void handleSetSignalReport(const uint8_t* data, uint16_t len); + void handleGetSignalReport(); + +public: + KissModem(Stream& serial, mesh::LocalIdentity& identity, mesh::RNG& rng, + mesh::Radio& radio, mesh::MainBoard& board, SensorManager& sensors); + + void begin(); + void loop(); + + void setRadioCallback(SetRadioCallback cb) { _setRadioCallback = cb; } + void setTxPowerCallback(SetTxPowerCallback cb) { _setTxPowerCallback = cb; } + void setGetCurrentRssiCallback(GetCurrentRssiCallback cb) { _getCurrentRssiCallback = cb; } + void setGetStatsCallback(GetStatsCallback cb) { _getStatsCallback = cb; } + + void onPacketReceived(int8_t snr, int8_t rssi, const uint8_t* packet, uint16_t len); + bool isTxBusy() const { return _tx_state != TX_IDLE; } + /** True only when radio is actually transmitting; use to skip recvRaw in main loop. */ + bool isActuallyTransmitting() const { return _tx_state == TX_SENDING; } + bool isHostOutputBackedUp() const { return _tx_frame_count > 0 || _tx_busy_error_pending || _tx_done_pending; } + // ZEPHCORE: ms until processTx() has a timed transition, 0 = due now, + // UINT32_MAX = none (it waits on a radio or host event). The event loop arms its wake on it. + uint32_t msUntilNextTx() const; +}; diff --git a/zephcore/boards/common/kiss_modem.conf b/zephcore/boards/common/kiss_modem.conf new file mode 100644 index 0000000..42fa507 --- /dev/null +++ b/zephcore/boards/common/kiss_modem.conf @@ -0,0 +1,15 @@ +# KISS modem role: raw LoRa packets to a host over the USB/console serial port +# (upstream MeshCore examples/kiss_modem, protocol in docs/kiss_modem_protocol.md). +# +# Build with: -DEXTRA_CONF_FILE="boards/common/kiss_modem.conf" + +CONFIG_ZEPHCORE_ROLE_KISS_MODEM=y + +# No BLE; entropy explicitly, as BT no longer pulls it in. +CONFIG_BT=n +CONFIG_ENTROPY_GENERATOR=y + +# The port carries the binary KISS stream: the console driver must not also +# write to it, or boot and printk bytes land in the stream. +CONFIG_UART_CONSOLE=n +CONFIG_SHELL=n diff --git a/zephcore/helpers/compat/Stream.h b/zephcore/helpers/compat/Stream.h index e882dd7..da427bd 100644 --- a/zephcore/helpers/compat/Stream.h +++ b/zephcore/helpers/compat/Stream.h @@ -26,6 +26,7 @@ public: return n; } size_t write(const char *str) { return str ? write((const uint8_t *)str, strlen(str)) : 0; } + virtual int availableForWrite() { return 0; } size_t print(char c) { return write((uint8_t)c); } size_t print(const char *str) { return write(str); } diff --git a/zephcore/src/main_kiss_modem.cpp b/zephcore/src/main_kiss_modem.cpp new file mode 100644 index 0000000..968638d --- /dev/null +++ b/zephcore/src/main_kiss_modem.cpp @@ -0,0 +1,213 @@ +/* + * SPDX-License-Identifier: MIT + * ZephCore - KISS modem + * + * Composition root of the KISS modem role (upstream examples/kiss_modem): raw + * LoRa packets to and from a host over the USB/console serial port, plus + * MeshCore's SetHardware extensions (KissModem). No mesh, no prefs: the host + * configures the radio, the identity is the server roles' one. + * + * Event loop: serial input, LoRa RX/TX-done, host output drained and a one-shot + * for the soonest modem or radio-maintenance deadline each wake KissModem::loop(). + */ + +#include +#include +#include + +#include +LOG_MODULE_REGISTER(zephcore_kiss_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); + +#define ZEPHCORE_USB_STACK \ + (IS_ENABLED(CONFIG_USB_CDC_ACM) || IS_ENABLED(CONFIG_USBD_CDC_ACM_CLASS)) + +#if ZEPHCORE_USB_STACK && !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include "../adapters/datastore/ZephyrFsFormat.h" + +#define MESH_EVENT_HOST_TX_IDLE BIT(MESH_EVENT_ROLE_BASE) /* host output drained */ +#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | \ + MESH_EVENT_MAINTENANCE | MESH_EVENT_HOST_TX_IDLE) + +static struct k_event mesh_events; + +static void post_lora_rx(void *user_data) +{ + ARG_UNUSED(user_data); + k_event_post(&mesh_events, MESH_EVENT_LORA_RX); +} + +static void post_lora_tx_done(void *user_data) +{ + ARG_UNUSED(user_data); + k_event_post(&mesh_events, MESH_EVENT_LORA_TX_DONE); +} + +static void post_host_rx(void) +{ + k_event_post(&mesh_events, MESH_EVENT_CLI_RX); +} + +static void post_host_tx_idle(void) +{ + k_event_post(&mesh_events, MESH_EVENT_HOST_TX_IDLE); +} + +static void wake_timer_fn(struct k_timer *timer) +{ + ARG_UNUSED(timer); + k_event_post(&mesh_events, MESH_EVENT_MAINTENANCE); +} + +K_TIMER_DEFINE(wake_timer, wake_timer_fn, NULL); + +static mesh::ZephyrBoard board; +static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); +static mesh::LoRaRadio radio(lora_dev, board); +static mesh::ZephyrRNG rng; +static mesh::LocalIdentity identity; +static RepeaterDataStore data_store; +static ZephyrUartStream host; +/* RAM only: the radio reads its parameters here, the host sets them. */ +static NodePrefs prefs; +static KissModem modem(host, identity, rng, radio, board, sensors); + +static void apply_cad_prefs(void) +{ + radio.setCadParams(prefs.cad_auto != 0, prefs.cad_offset, prefs.probe_interval, + prefs.cad_busycap, prefs.cad_base); +} + +static void on_set_radio(float freq, float bw, uint8_t sf, uint8_t cr) +{ + bool new_channel = freq != prefs.freq || bw != prefs.bw || sf != prefs.sf; + + prefs.freq = freq; + prefs.bw = bw; + prefs.sf = sf; + prefs.cr = cr; + /* The learned CAD offset belongs to the old channel, as `set radio`. */ + if (new_channel) { + apply_cad_prefs(); + } + radio.reconfigure(); +} + +static void on_set_tx_power(uint8_t power) +{ + /* The radio clamps to the board's limit when it next configures TX. */ + prefs.tx_power_dbm = (int8_t)MIN(power, 30); +} + +static float on_get_current_rssi(void) +{ + return radio.getCurrentRSSI(); +} + +static void on_get_stats(uint32_t *rx, uint32_t *tx, uint32_t *errors) +{ + *rx = radio.getPacketsRecv(); + *tx = radio.getPacketsSent(); + *errors = radio.getPacketsRecvErrors(); +} + +static void arm_wake(void) +{ + uint32_t next = MIN(modem.msUntilNextTx(), radio.msUntilNextMaintenance()); + + if (next >= mesh::MAINTENANCE_IDLE) { + k_timer_stop(&wake_timer); + return; + } + k_timer_start(&wake_timer, K_MSEC(MAX(next, 1U)), K_NO_WAIT); +} + +int main(void) +{ + board.clearBootloaderMagic(); + +#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) && \ + !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) + zephcore_usbd_init(); + const struct device *host_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart); +#else + const struct device *host_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console)); +#endif + + if (!data_store.hasRoleData()) { + LOG_WRN("Volume holds no data for this role - formatting before first boot"); + if (!zephcore_fs_format_all(nullptr)) { + LOG_ERR("First-boot format failed - /lfs is not mounted"); + } + } + if (!data_store.begin()) { + LOG_ERR("RepeaterDataStore init failed"); + } + if (!data_store.loadIdentity(identity)) { + mesh::ZephyrRNG::generateFirstBootIdentity(identity); + data_store.saveIdentity(identity); + } + + initNodePrefs(&prefs); + apply_boot_prefs(&prefs, false); + gps_park(); + env_sensors_init(); + + k_event_init(&mesh_events); + radio.setRxCallback(post_lora_rx, nullptr); + radio.setTxDoneCallback(post_lora_tx_done, nullptr); + radio.setPrefs(&prefs); + radio.begin(); + radio.setRxBoost(prefs.rx_boost != 0); + radio.setFemRxEnable(prefs.fem_rxgain != 0); + apply_cad_prefs(); + + if (!host.begin(host_dev, post_host_rx, post_host_tx_idle)) { + LOG_ERR("Host serial not ready"); + } + + modem.setRadioCallback(on_set_radio); + modem.setTxPowerCallback(on_set_tx_power); + modem.setGetCurrentRssiCallback(on_get_current_rssi); + modem.setGetStatsCallback(on_get_stats); + modem.begin(); + + for (;;) { + /* processTx() takes one state step per call; the second pass takes + * the step the first one made due (a new frame, a finished TX). */ + modem.loop(); + modem.loop(); + + uint8_t rx_buf[256]; + int rx_len; + + while (!modem.isHostOutputBackedUp() && + (rx_len = radio.recvRaw(rx_buf, sizeof(rx_buf))) > 0) { + int8_t snr = (int8_t)(radio.getLastSNR() * 4); + int8_t rssi = (int8_t)radio.getLastRSSI(); + + modem.onPacketReceived(snr, rssi, rx_buf, rx_len); + } + + radio.triggerNoiseFloorCalibrate(0); + radio.cadMaintenance(); + radio.radioMaintenance(); + + arm_wake(); + uint32_t ev = k_event_wait(&mesh_events, MESH_EVENT_ALL, false, K_FOREVER); + + k_event_clear(&mesh_events, ev); + } + + return 0; +}