diff --git a/.editorconfig b/.editorconfig index b45e3c9..58c0d5e 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/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}] indent_style = unset indent_size = unset tab_width = unset diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bbdcb14..08bf6e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -54,8 +54,10 @@ zephcore/ │ ├── ContentionTracker.cpp # Adaptive contention window (EMA, backoff) │ ├── StaticPoolPacketManager.cpp # Fixed-size packet pool (32 slots) │ ├── main_companion.cpp # Companion mode entry point + event loop -│ ├── main_repeater.cpp # Repeater mode entry point + event loop -│ └── main_room_server.cpp # Room server mode entry point + event loop +│ ├── main_repeater.cpp # Repeater entry point: its mesh object + ServerRole +│ ├── main_room_server.cpp # Room server entry point: its mesh object + ServerRole +│ ├── server_main_common.{h,cpp} # Both server roles: USB CLI, event loop, boot +│ └── mesh_events.h # Event bits shared by every role's loop │ ├── include/mesh/ # Core interfaces (shared with Arduino MeshCore) │ ├── Mesh.h, Dispatcher.h, Packet.h, Identity.h, Utils.h @@ -89,16 +91,18 @@ zephcore/ │ ├── app/ # Application layer │ ├── CompanionMesh.cpp/h # Phone-connected companion logic +│ ├── CompanionProtocol.h # Companion protocol opcodes and codes +│ ├── CompanionCLI.h # Companion side of CommonCLI +│ ├── VContact.cpp # Loopback admin contact (CompanionMesh members) │ ├── RepeaterMesh.cpp/h # Autonomous repeater logic -│ ├── RepeaterRegionCLI.cpp # Repeater `region` CLI commands │ ├── RepeaterUplink.cpp # Repeater WiFi+MQTT uplink (ESP32) │ ├── RepeaterDataStore.cpp/h # Repeater-specific persistence paths │ ├── RoomServerMesh.cpp/h # Store-and-forward room server (BBS) -│ ├── RoomServerRegionCLI.cpp # Room server `region` CLI commands │ ├── ObserverMesh.cpp/h # Listen-only WiFi+MQTT observer (ESP32) │ └── main_observer.cpp, observer_creds.cpp/h │ ├── helpers/ # Shared utilities +│ ├── compat/ # Arduino Stream/SHA256/FILESYSTEM over Zephyr, for upstream-verbatim code │ ├── BaseChatMesh.cpp/h # Contact/channel/message base class │ ├── CommonCLI.cpp/h # Serial/mesh CLI command processor │ ├── MeshTimeSync.cpp/h # Mesh clock-consensus estimator (§4.9) @@ -710,7 +714,7 @@ Autonomous operation features: ### 6.4 RoomServerMesh -Headless store-and-forward shared message room (BBS). Clients log in with the admin or guest password and post messages; the server pushes each new post to every other logged-in client (per-client sync cursor + ACK). Reuses the repeater's ACL, region filtering, and USB CLI. Entry point `main_room_server.cpp`; build with `boards/common/room_server.conf`. +Headless store-and-forward shared message room (BBS). Clients log in with the admin or guest password and post messages; the server pushes each new post to every other logged-in client (per-client sync cursor + ACK). Reuses the repeater's ACL, region filtering, and USB CLI, and its composition root: `main_room_server.cpp` hands `server_main_common.cpp` a `ServerRole`, so the room runs the repeater's deadline-driven loop, where the post push engine is one more deadline (`RoomServerMesh::msUntilNextPush()`). Build with `boards/common/room_server.conf`. ### 6.5 ObserverMesh @@ -752,20 +756,20 @@ Full command reference with constraints and remote-admin restrictions: `Repeater - **Internal**: LittleFS on flash (`/lfs`), 256-byte cache for reduced flash I/O - **External**: Optional LittleFS on QSPI (`/ext`) with auto-migration - **BLE bonds**: NVS (`storage_partition`, 0xD0000 on nRF52) via Zephyr settings backend (≥1.16.2) -- **Prefs**: 152-byte binary (companion `new_prefs`), Arduino-compatible base + ZephCore extension fields, field-by-field I/O (see §13) +- **Prefs**: `prefs.json` through upstream's `ConfigSerializer`, with upstream's key names for shared fields and ZephCore's own under `zc` (see §13); the older binary file is read once to migrate and kept - **Contacts**: 152-byte records, stored on external flash if available - **Channels**: 68-byte records (4 pad + 32 name + 32 secret) - **Blobs**: Fixed-size records with LRU eviction by timestamp **First-boot migration (3-way FS self-heal)** -A marker file `/lfs/_zc_init` is written after the first clean ZephCore boot. On every subsequent boot it is present and the logic below is skipped. On first boot (marker absent), `main_companion.cpp` picks one of three paths before `bt_enable()` runs: +A marker file `/lfs/_zc_init` is written after the first clean ZephCore boot. On every subsequent boot it is present and the logic below is skipped. On first boot (marker absent), `ZephyrDataStore::adoptVolume()` (called from `main_companion.cpp` before `bt_enable()`) picks one of three paths: -1. **No prefs, or Arduino MeshCore prefs** → full LFS + NVS format. Arduino's `new_prefs` omits `node_lat`/`node_lon`, shifting `freq`/`sf`/`bw` by 16 bytes; `prefsLookLikeArduino()` detects this by range-checking those fields. Covers fresh installs and Arduino → ZephCore migrations. +1. **No prefs, or a legacy prefs file whose radio preset is implausible** → full LFS + NVS format. `prefsRadioImplausible()` range-checks `freq`/`sf`/`bw` at our offsets (a `prefs.json` is always ours). Covers fresh installs and foreign volumes. 2. **Valid ZephCore prefs + `/lfs/settings` present** → NVS-only erase (`formatNVSOnly()`). ZephCore ≤1.16.1 stored BLE bonds in `/lfs/settings` (file backend); ≤1.16.1 used 0xD0000 as app code, so bytes there may pass NVS sector validation and hang `settings_load()`. Identity/prefs/contacts are preserved; re-pairing is required. 3. **Valid ZephCore prefs + no `/lfs/settings`** → skip format entirely. NVS was already initialised by ZephCore ≥1.16.2; bonds survive the upgrade. -`loadPrefs()` also range-checks `freq`/`sf`/`bw` after deserialisation and reverts to compile-time defaults on out-of-range values, so a misread Arduino prefs file never corrupts the radio config. +Loading also range-checks `freq`/`sf`/`bw` and falls back to the compile-time defaults on out-of-range values, so a misread file never corrupts the radio config. ### 7.3 GPS (`adapters/gps/`) @@ -819,11 +823,19 @@ Does not cover the display backlight, which has its own UI brightness setting (` ### 7.6 WiFi / MQTT / TCP Transports -- **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by observer and repeater uplink +- **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by the observer, the repeater uplink and the WiFi companion - **`adapters/mqtt/ZephyrMQTTPublisher.c`**: MQTT publisher for observed/uplinked packets - **`adapters/ota/wifi_ota.c`**: WiFi SoftAP + HTTP firmware upload to MCUboot slot1 (ESP32, requires `--sysbuild`) -- **`adapters/transport/LinuxTCPTransport.c`**: TCP companion transport on native Linux (port 5000, MeshCore `SerialWifiInterface` framing) -- **`adapters/transport/SerialCompanionTransport.c`**: UART companion transport (STM32WL — drop-in `zephcore_ble_*` provider, auto-selected when `CONFIG_BT=n`) + +Companion transports, as upstream's `companion_radio`: each is a `BaseSerialInterface` +(`adapters/transport/CompanionInterfaces.h`) registered in one `MultiSerialInterface`, so +BLE, USB/UART and TCP can all be connected at once; every connected client gets every +frame (a reply to one app also reaches the other). + +- **`adapters/ble/ZephyrBLE.cpp`** (+ `ble_gatt_layout.cpp`, `ble_dfu.cpp`): BLE NUS +- **`adapters/usb/ZephyrCompanionUSB.cpp`**: USB CDC-ACM, or a plain UART (`zephcore,companion-uart` chosen node; the only link on the Bluetooth-less LoRa-E5), with the text CLI +- **`adapters/transport/TcpCompanionTransport.c`**: TCP (port 5000, MeshCore `SerialWifiInterface` framing) on native Linux and on WiFi companions (`app/CompanionWifi.cpp`, `capabilities: wifi: true`) +- **`adapters/transport/frame_txq.c`**: the TX queue BLE and TCP share (congestion, overflow slot, lossless replies) --- @@ -1115,19 +1127,20 @@ Codes `0x80`–`0x90` (`PUSH_CODE_*` in `app/CompanionMesh.h`). Most used: | Path | Content | Format | |------|---------|--------| | `/lfs/_main.id` | Node identity | 64B private key + 32B public key | -| `/lfs/new_prefs` | Companion preferences | 152B binary, field-by-field (Arduino-compatible superset) | +| `/lfs/prefs.json` | Companion preferences | `ConfigSerializer` text (upstream keys + `zc{}`); atomic replace | +| `/lfs/new_prefs` | Legacy companion preferences | 272B binary; read once to migrate, then kept for older firmware | | `/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/*` | Repeater/room-server identity + prefs | 297B prefs; atomic-replace writes | +| `/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/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/new_prefs` -> (`ZephyrDataStore::hasPrefs()`), the repeater/room-server/observer check `/lfs/repeater/prefs` -> and `/lfs/repeater/_main.id` (`RepeaterDataStore::hasRoleData()`). So flashing a repeater over +> 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`, +> `/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 @@ -1135,29 +1148,24 @@ Codes `0x80`–`0x90` (`PUSH_CODE_*` in `app/CompanionMesh.h`). Most used: > observer share `/lfs/repeater/` and the same prefs layout, so switching among *those three* > preserves the identity. -### Preferences Binary Layouts +### Preferences (`prefs.json`) -Two distinct field-by-field serializations (NOT raw struct dumps), both Arduino-compatible -in their shared base fields: +Both roles store prefs as `prefs.json` through upstream MeshCore's `ConfigSerializer` +(`helpers/ConfigSerializer.{h,cpp}`, verbatim), as upstream has since v1.17.0. The mapping from +ZephCore's single `NodePrefs` struct is `helpers/PrefsJson.cpp`: fields whose meaning matches +upstream's use its keys and nesting (companion: `name`, `lat`, `lon`, `radio{}`, `gps{}`, +`repeat{disable}`, `comp{}`, `wifi{}`; servers: `name`, `pass`, `guest`, `owner`, `adv_int`, +`f_adv_int`, `lat`, `lon`, `radio{}`, `gps{}`, `repeat{}`, `room{}`, `power{}`), and ZephCore's own +settings live under `zc{}`. Unknown keys are ignored and missing keys keep their defaults, so +adding a field never shifts another. Writes are atomic (`.tmp`, sync, rename); a file that does not +parse falls back to the legacy file, then to defaults. -**Companion `/lfs/new_prefs` (168 bytes)** — `adapters/datastore/ZephyrDataStore.cpp` -`loadPrefs()`/`savePrefs()` (offset comments inline). Arduino companion layout (name, lat/lon, -radio params, telemetry modes, BLE pin, GPS, autoadd) plus ZephCore extensions from offset 92: -rx_boost(92), leds_disabled(93), reserved(94-95, was APC), default flood scope name/key(96-142), -ble_disabled(143), display/wake/screen-off/auto-shutdown(144-149), rx_duty_cycle(150), -meshtimesync(151). - -**Repeater/room-server `/lfs/repeater/prefs` (305 bytes)** — `app/RepeaterDataStore.cpp` -`loadPrefs()`/`savePrefs()` (offset comments inline). This is the only serializer for the -repeater layout; `helpers/CommonCLI.cpp` carried a second, unreachable copy of it until it was -removed — do not add prefs fields anywhere but the two files named in this section. -Key ranges: name(4-36), radio(72-119), adaptive-delay(80-111, ignored at runtime), -leds_disabled(120, magic-encoded `0xA0`/`0xA1` — the byte formerly held `agc_reset_interval`, which -stored seconds/4, so any other value is a legacy interval and decodes to "LEDs on"), -Arduino-bridge(127-151, read+discarded), GPS(156-161), owner_info(170-290), rx_boost/duty(290-291), -reserved(292-293, was APC), flood_max_unscoped/advert(294-295), meshtimesync(296). Older shorter files -load cleanly — reads past EOF are no-ops, so newer fields keep their defaults and a one-time -upgrade block migrates them. +**Legacy binary files.** Firmware before `prefs.json` wrote field-by-field binary files: companion +`/lfs/new_prefs` (272 bytes) and servers `/lfs/repeater/prefs` (311 bytes; `leds_disabled` at 120 +is magic-encoded `0xA0`/`0xA1`). `helpers/PrefsCodec.cpp` reads (and, for tests, writes) both, +pinned by host tests. On the first boot without `prefs.json` the legacy file is decoded and +`prefs.json` written; the legacy file is **kept**, so a downgrade still boots with the settings as +they were at the upgrade. Byte maps: `memory/prefs-format.md`. --- @@ -1265,6 +1273,6 @@ Timeouts and deadlines that are watchdogs in everything but name: | Stuck-DIO1 counter | `lr11xx_lora.c` and `lr20xx_lora.c` | 5 empty DIO1 cycles → hardware reset. Counting rather than timing; on the LR11xx it complements the wedge watchdog rather than replacing it. | | CAD timeout | `Dispatcher::checkSend()` | 4 s (~20 retry attempts) → `ERR_EVENT_CAD_TIMEOUT` + `recoverRxState()`, rather than falling through to TX. See [5.2.2](#522-cad-timeout-recovery). | | Chip-side TX timeout | SX126x `SetTx` deadline (`patch 0003`, "Scale the chip-side Tx timeout from airtime instead of a fixed 10 s"; airtime +25% +500 ms, floored at 10 s, clamped 262143 ms); LR2021 `TIMEOUT` IRQ handler | The chip stops the transmission when this fires, so a fixed value is a truncation, not a safeguard — at SF12/BW62.5 the old flat 10 s cut every packet from 76 bytes up. | -| Serial partial-frame resync | `SerialCompanionTransport.c` (`FRAME_PARTIAL_TIMEOUT_MS`) | 2 s. Parser-level only — deliberately **not** a session or idle timeout; an idle-but-connected companion sits in `RX_IDLE` indefinitely. | -| TCP send timeout | `LinuxTCPTransport.c` | Native sim only. A peer that can't accept a frame in the window is wedged → close it, rather than hang the whole queue. | +| Serial partial-frame resync | `ZephyrCompanionUSB.cpp` (`USB_FRAME_TIMEOUT_MS`) | 2 s. Parser-level only — deliberately **not** a session or idle timeout; an idle-but-connected companion sits in `RX_IDLE` indefinitely. | +| TCP send timeout | `TcpCompanionTransport.c` | Native Linux and WiFi companions. A peer that can't accept a frame in the window is wedged → close it, rather than hang the whole queue. | | Bounded RXTO wait | `patches/zephyr/0010-uarte-pm-suspend-bounded-rxto-wait.patch` | `uarte_pm_suspend()` busy-waits for RXTO with no timeout upstream. Landing in the STOPRX race with bytes in flight spins forever on the main thread and wedges the entire mesh (observed: RAK3401 1W repeater on 1.16.6, CLI answering only `-> busy`). Backstop for the GPS UART PM path in [7.3](#73-gps-adaptersgps). | diff --git a/docs/LINUX_NATIVE.md b/docs/LINUX_NATIVE.md index ed329b7..f2282fb 100644 --- a/docs/LINUX_NATIVE.md +++ b/docs/LINUX_NATIVE.md @@ -253,7 +253,7 @@ The binary prints its PTY path and TCP listen port at startup. Logs go to stderr Use the MeshCore companion app's **"Connect via WiFi"** (TCP/Network) mode: - **Host:** the SBC's IP address -- **Port:** `5000` (default; configurable via `CONFIG_ZEPHCORE_LINUX_TCP_PORT`) +- **Port:** `5000` (default; configurable via `CONFIG_ZEPHCORE_TCP_PORT`) Wire format: **MeshCore SerialWifiInterface framing** (matches ESP32 Arduino reference in `src/helpers/esp32/SerialWifiInterface.cpp`): - App → Node: `['<' (0x3C)][length_LSB][length_MSB][NUS payload...]` @@ -455,7 +455,7 @@ The host adapter requires the GPIO V2 uAPI (kernel ≥ 5.10, late 2020). All cur Another service (Flask, Docker registry, AirPlay…) has port 5000. Override: ```bash -west build … -- -DCONFIG_ZEPHCORE_LINUX_TCP_PORT=15000 +west build … -- -DCONFIG_ZEPHCORE_TCP_PORT=15000 ``` ### "TCP companion client disconnected" loops @@ -529,7 +529,7 @@ Another instance of the binary is already running, or you killed the last one wi │ • SX1262 driver (unchanged) │ │ • New spi_native_linux driver ──┐ │ │ • New gpio_native_linux driver ──┤ │ -│ • LinuxTCPTransport (replaces BLE) ──┤ │ +│ • TcpCompanionTransport (companion) ──┤ │ └──────────────────────────────────────────┼──┘ │ host syscalls + zsock_* @@ -565,7 +565,7 @@ All native-Linux board files live under **`boards/linux_native/`**: > These are `EXTRA_CONF_FILE` **presets**, not Zephyr boards — you still build with > `-b native_sim … -DEXTRA_CONF_FILE="boards/linux_native/.conf"`, not `-b `. -- `adapters/transport/LinuxTCPTransport.c` — TCP companion transport +- `adapters/transport/TcpCompanionTransport.c` — TCP companion transport (an interface in the `MultiSerialInterface`) - `adapters/transport/linux_native_setup.c` — forces real-time clock mode at boot (bakes in `--rt`) - `patches/zephyr-new/drivers/spi/spi_native_linux*` — spidev SPI driver - `patches/zephyr-new/drivers/gpio/gpio_native_linux*` — GPIO driver (kernel GPIO V2 chardev ioctls, no libgpiod) @@ -588,7 +588,7 @@ End-to-end verified under WSL Ubuntu 24.04 (gcc 13.3): - ✅ `native_sim/native/64` builds clean → `build/zephyr/zephcore_native_linux.exe` (~4.3 MB ELF). - ✅ Binary runs. Zephyr OS boots, mesh event loop starts. - ✅ `spi_native_linux` driver loads and attempts to open `/dev/spidev0.0` (fails in WSL: no SPI hardware). -- ✅ `LinuxTCPTransport` listens on port 5000. +- ✅ `TcpCompanionTransport` listens on port 5000. - ✅ TCP client connect/disconnect works; `SerialWifiInterface` (`<`/`>` + 2-byte LE length) framing parses correctly. - ✅ **End-to-end on real Femtofox hardware (2026-06-02):** radio RX, CAD (LBT), and TX all working; companion app connects and meshes; settings persist across restarts. (Required fixing two bugs in the native GPIO driver — see "Known caveats" below.) - ✅ **End-to-end on real Raspberry Pi Zero 2 W + RAK6421/RAK13300 hardware (2026-06-03):** SX1262 init, SPI with spidev hardware CS (CE0), DIO3 TCXO, DIO2 RF switch, and over-the-air RX/TX all confirmed. (Required dropping the GPIO chip-select from the Pi overlays — CE0 is owned by the kernel SPI controller; see the `Failed to request line 8` troubleshooting entry.) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index d24a3df..207e914 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -7,7 +7,7 @@ All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the **Sources:** - `helpers/CommonCLI.cpp` — common commands shared by all roles - `app/RepeaterMesh.cpp` — repeater-specific commands (`setperm`, `get acl`, `region`, `discover.neighbors`) -- `app/RepeaterRegionCLI.cpp` / `app/RoomServerRegionCLI.cpp` — the `region` sub-CLI +- `helpers/CommonCLI.cpp` `handleRegionCmd()` — the `region` sub-CLI (as upstream); the `region load` line reader stays in each role - `app/RepeaterUplink.cpp` — `get`/`set uplink.*` (ESP32 uplink builds only) - `app/RoomServerMesh.cpp` — room-server-specific commands (`room.post`) @@ -60,7 +60,7 @@ All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the |---------|-------------| | `neighbors` | Display current neighbor list | | `neighbor.remove ` | Remove a neighbor entry by its public key. A prefix is accepted — the hex is truncated to at most 32 bytes and matched at whatever length you give. **Repeater only in effect:** `RoomServerMesh` does not override `removeNeighbor`, so on a room server this replies `OK` and does nothing. | -| `discover.neighbors` | *(repeater only)* Broadcast a node discovery request to find nearby nodes. Takes no arguments — anything after it replies `Err - discover.neighbors has no options`. Not implemented on room servers. | +| `discover.neighbors` | *(repeater only)* Broadcast a node discovery request to find nearby nodes; responses are collected for 60 s (as upstream `bf9c6cb5`). Takes no arguments — anything after it replies `Err - discover.neighbors has no options`. Not implemented on room servers. | --- @@ -71,7 +71,7 @@ All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the | `password ` | Set the admin password (**max 15 characters**) | | `setperm ` | Set ACL permissions for a node (app format: 2-char hex perms first) | | `setperm ` | Set ACL permissions for a node (Arduino format: pubkey first, decimal perms) | -| `get acl` | *(USB only)* List all ACL entries with permissions and public keys | +| `get acl` | *(USB only)* List all ACL entries, as upstream: `ACL:` then one ` ` line per entry, printed straight to the console (the ` -> ` reply line is empty). Guest entries (permissions 0) are skipped. | > **Password length:** admin and guest passwords are capped at **15 characters** (16-byte storage incl. NUL; same limit as Arduino MeshCore). The login-send path silently truncates anything longer, so a password >15 chars will never authenticate. Applies to `set guest.password` as well. @@ -235,6 +235,20 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. --- +## Companion WiFi (`CONFIG_ZEPHCORE_COMPANION_WIFI`) + +Companion builds of boards whose `zephcore.yml` declares `capabilities: wifi: true` (the PSRAM ESP32-S3 boards, plus Heltec V3, Wireless Tracker and the two C6 boards with fewer contacts) join a WiFi network and serve the companion protocol over TCP on port 5000, beside BLE and USB. Upstream's commands and replies; every change applies on the next reboot. Reachable over USB, from the app's CLI and, as upstream, over the air from a contact with remote CLI permission — including `get wifi.pwd`. + +| Command | Description | +|---------|-------------| +| `set wifi.ssid ` | Network to join (up to 32 characters; spaces allowed) | +| `set wifi.pwd ` | Its password (up to 63 characters; empty = open network) | +| `get wifi.ssid` / `get wifi.pwd` | The saved values (`(not set)` for no SSID) | +| `set wifi.enabled <0\|1>` / `get wifi.enabled` | Join the saved network at boot (default 1; nothing happens until an SSID is set) | +| `set wifi.clear` | Forget SSID and password | +| `get wifi.status` | `connected` once the link is up and DHCP has an address, else `disconnected` | +| `get wifi.ip` | The address the app connects to (`(not connected)` otherwise) | + ## `get` — Read Configuration | Command | Returns | @@ -281,6 +295,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `get gps diag` | What the last GPS module-configuration attempt did — which path ran, bytes sent, and tracked satellites per constellation. See **GPS configuration diagnostics** in the GPS section for the field reference | | `get meshtimesync` | Mesh time-sync state + live dry-run: on/off, eligible voter count, votes for/against, consensus skew and radius, would-be verdict (`ok`/`in-band`/`step±N`/`abstain (reason)`/`hold (reason)`; a recent clock set — manual or GPS — shows as `hold (suppressed)`, and a backward step a forward-only role would refuse is annotated `(skipped: forward-only)`), step counters, suppression countdown, and a per-sender evidence table (`prefix hops count skew E`, `E` = counted toward the verdict above). Entries that count print first, so a size-capped reply never hides the ones that explain the summary; if the table doesn't fully fit, a trailing `+N more` shows how many were left out. Sensing runs even while off, so this works as a dry-run before enabling. Over remote admin the reply is truncated to the packet size (summary always fits); the full table needs the USB CLI. | | `get probe.interval` | Seconds between periodic radio measurements (noise-floor sample + CAD probe). 0 = CAD probing off | +| `get loop.wakes` | Debug builds only (`CONFIG_ZEPHCORE_LOOP_WAKE_STATS`, on in `debug.conf`). Event-loop wakes since boot: `wakes=N up=Ss` then a count per event bit (`bN=`, the role's `MESH_EVENT_*` bit numbers; one wake can carry several bits). Wakes per second = wakes / up. Read it twice and take the difference to measure a window. | | `get dc.restarts` | Duty-cycle re-arm counter — RxTimeout re-arms **plus** parked-RX watchdog recoveries, sharing one total. **Read it as a rate: divide by uptime.** A bare count is not interpretable, and the two sources it merges cost very differently. An RxTimeout re-arm is ~7 ms of deaf time (the `Calibrate(ALL)` gap in the driver's `restart_rx`) after which the chip returns to duty cycle immediately — packets, not power. A watchdog recovery means the chip sat parked in *full RX* for one to two watchdog periods (`2·(preamble+8)` symbols, floored at 250 ms) — power, not packets, since parked RX still receives. The counter cannot tell you which, so read the worst case. **Measured normal: ~250/hr on a high site at SF8/BW 62.5** (one every ~14 s), where the worst case — every event a park — costs about 3.5% of the duty cycle's savings. Nothing to act on below roughly **2000/hr**; above that the parked-RX share starts eating a meaningful fraction of the saving and it becomes worth splitting the counter to find out. A high rate means the preamble detector is tripping without a decodable packet following, which on an elevated site is usually distant marginal traffic rather than interference — cross-check `get cad.stats`, whose adaptive detPeak offset rises independently in a genuinely busy RF environment. Reset by `clear stats`. | | `get cad` | Always `on` — ZephCore performs CAD/LBT unconditionally and has no enable knob. Kept as a boolean reply for Arduino MeshCore app compatibility; the real status lives in `get cad.stats`. | | `get cad.auto` | Whether the adaptive-CAD staircase is acting on probe statistics (`on`/`off`). Set with `set cad.auto`. | diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 2d37126..fac02d3 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -205,6 +205,16 @@ if(ZEPHCORE_PLATFORM STREQUAL "esp32") message(STATUS " WiFi OTA: auto-enabled (ESP32 repeater)") endif() + # WiFi companion (app over TCP beside BLE and USB) on boards whose + # zephcore.yml declares `capabilities: wifi: true`: the ones with the RAM + # for WiFi and BLE together. Companion only; the servers' WiFi is the + # uplink / OTA, configured separately. + if(ZEPHCORE_BUILD_ROLE STREQUAL "companion" AND ZEPHCORE_BOARD_WIFI + AND NOT "wifi_companion" IN_LIST _user_conf_names) + zephcore_add_conf(${_zc}/wifi_companion.conf) + message(STATUS " WiFi companion: auto-enabled (board declares wifi)") + endif() + # USB CDC-ACM companion transport on S3 boards whose board.overlay includes # esp32s3_usb_otg.dtsi (that include declares the cdc_acm_uart node the USB # companion call sites are compiled behind — see ZEPHCORE_USB_STACK in @@ -319,6 +329,7 @@ target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/helpers + ${CMAKE_CURRENT_SOURCE_DIR}/helpers/compat ${CMAKE_CURRENT_SOURCE_DIR}/app ${CMAKE_CURRENT_SOURCE_DIR}/lib/monocypher ${CMAKE_CURRENT_SOURCE_DIR}/adapters/board @@ -363,10 +374,17 @@ target_sources(app PRIVATE helpers/buzzer_gate.c # QSPI bring-up probe. Self-stubs unless CONFIG_ZEPHCORE_QSPI_RDID_PROBE. helpers/qspi_probe.c - # Factory format of every storage region. Compiled for all roles: the - # companion builds ZephyrDataStore, the other three build - # RepeaterDataStore, and both call into this. + # Storage shared by every role's store (the companion builds + # ZephyrDataStore, the other three RepeaterDataStore): factory format, + # power-safe replace and non-logging probes, the identity file, and the + # prefs byte codecs. adapters/datastore/ZephyrFsFormat.c + adapters/datastore/ZephyrFsUtil.c + adapters/datastore/IdentityFile.cpp + adapters/datastore/PrefsFile.cpp + helpers/PrefsCodec.cpp + helpers/PrefsJson.cpp + helpers/ConfigSerializer.cpp ) # Boot-time hardware-RTC auto-discovery (compact raw-I2C). Always compiled so @@ -492,23 +510,25 @@ if(CONFIG_ZEPHCORE_ROLE_REPEATER OR CONFIG_ZEPHCORE_ROLE_ROOM_SERVER) target_sources(app PRIVATE src/main_repeater.cpp app/RepeaterMesh.cpp - app/RepeaterRegionCLI.cpp ) else() message(STATUS "ZephCore Role: ROOM SERVER") target_sources(app PRIVATE src/main_room_server.cpp app/RoomServerMesh.cpp - app/RoomServerRegionCLI.cpp ) endif() - # Shared by both server roles: persistence under /lfs/repeater/, client ACL, - # region filtering, transport keys, and the text CLI. + # Shared by both server roles: the composition root (USB CLI, event loop, + # boot), persistence under /lfs/repeater/, client ACL, region filtering, + # transport keys, and the text CLI. target_sources(app PRIVATE + src/server_main_common.cpp app/RepeaterDataStore.cpp helpers/ClientACL.cpp helpers/RegionMap.cpp helpers/TransportKeyStore.cpp + helpers/compat/SHA256.cpp + helpers/compat/ZephyrFS.cpp helpers/CommonCLI.cpp ) # Headless build (e.g. native-Linux SBC): no display/buttons/buzzer, so the @@ -575,6 +595,8 @@ else() adapters/datastore/ZephyrDataStore.cpp helpers/BaseChatMesh.cpp helpers/TransportKeyStore.cpp + helpers/compat/SHA256.cpp + helpers/compat/ZephyrFS.cpp helpers/ui/ui_mesh_actions.cpp app/CompanionMesh.cpp app/VContact.cpp @@ -584,27 +606,41 @@ else() # COMPANION_SERIAL (e.g. plain ESP32/S3 companions) failed to link otherwise. helpers/CommonCLI.cpp ) - # Companion transport: TCP socket on native Linux, serial (UART) on boards - # with no Bluetooth controller (e.g. STM32WL), BLE NUS everywhere else. + # Companion transports: main_companion.cpp registers every one the build + # has in upstream's MultiSerialInterface — BLE NUS when there is a + # controller, TCP (native Linux, WiFi companion), and the wired link below. + target_sources(app PRIVATE adapters/transport/frame_txq.c) + if(CONFIG_BT) + message(STATUS "ZephCore Companion Transport: BLE NUS") + target_sources(app PRIVATE adapters/ble/ZephyrBLE.cpp) + if(CONFIG_BT_GATT_SERVICE_CHANGED) + target_sources(app PRIVATE adapters/ble/ble_gatt_layout.cpp) + endif() + if(CONFIG_ZEPHCORE_BLE_DFU) + target_sources(app PRIVATE adapters/ble/ble_dfu.cpp) + endif() + endif() if(CONFIG_ZEPHCORE_TRANSPORT_TCP) - message(STATUS "ZephCore Companion Transport: TCP (Linux)") - target_sources(app PRIVATE adapters/transport/LinuxTCPTransport.c) - # Headless Linux build: no display/buttons/buzzer source files are - # compiled, but companion code calls ui_* unconditionally. Weak no-op - # stubs satisfy the link. + message(STATUS "ZephCore Companion Transport: TCP") + target_sources(app PRIVATE adapters/transport/TcpCompanionTransport.c) + endif() + if(CONFIG_ZEPHCORE_COMPANION_WIFI) + target_sources(app PRIVATE + app/CompanionWifi.cpp + adapters/wifi/ZephyrWiFiStation.c + ) + target_include_directories(app PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/adapters/wifi + ) + endif() + # Headless build (e.g. native Linux): no display/buttons/buzzer source files + # are compiled, but companion code calls ui_* unconditionally. Weak no-op + # stubs satisfy the link. + if(NOT CONFIG_ZEPHCORE_UI_DESIGN_BUTTON AND NOT CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) target_sources(app PRIVATE helpers/ui/ui_headless_stubs.c) target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/helpers/ui ) - elseif(NOT CONFIG_BT) - # No BLE controller (STM32WL) — companion speaks MeshCore serial framing - # over the chosen console UART. SerialCompanionTransport.c is a full - # drop-in zephcore_ble_* provider, just like LinuxTCPTransport.c. - message(STATUS "ZephCore Companion Transport: Serial (UART)") - target_sources(app PRIVATE adapters/transport/SerialCompanionTransport.c) - else() - message(STATUS "ZephCore Companion Transport: BLE NUS") - target_sources(app PRIVATE adapters/ble/ZephyrBLE.cpp) endif() target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/adapters/transport diff --git a/zephcore/Kconfig b/zephcore/Kconfig index c56ba17..93dffb7 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -1,5 +1,49 @@ # SPDX-License-Identifier: MIT +# WiFi companion on a PSRAM board: the WiFi heap and the network buffers go to +# PSRAM, so WiFi and BLE fit together (ZEPHCORE_COMPANION_WIFI). Ahead of +# Kconfig.zephyr, because a choice takes its first matching default and the +# driver's own ESP_WIFI_HEAP default is unconditional. +choice ESP_WIFI_HEAP + default ESP_WIFI_HEAP_SPIRAM if ZEPHCORE_COMPANION_WIFI && ESP_SPIRAM +endchoice + +config ESP32_WIFI_NET_ALLOC_SPIRAM + default y if ZEPHCORE_COMPANION_WIFI && ESP_SPIRAM + +# WiFi companion on an ESP32-S3 without PSRAM: the WiFi heap is the system heap, +# and Espressif's ADD_SIZE reservation (51 KB for WiFi alone) is sized for its +# default 10 static / 32 dynamic RX and 32 TX buffers. Bound the buffers and size +# the heap from measurement instead (72 KB, HEAP_MEM_POOL_SIZE in +# wifi_companion.conf): measured 2026-09-24 on a XIAO ESP32-S3 built without +# PSRAM, peak 61.6 KB under devdocs/tools/mem_load.py, and heap exhaustion under +# a ~10k frames/s flood recovers (devdocs/lld/08-connectivity.md §13). Saves +# ~12 KB of DRAM, spent on contacts. S3 only: C3/C6 and the classic ESP32 run +# different BLE controllers with different heap needs, not measured here. +config HEAP_MEM_POOL_IGNORE_MIN + default y if ZEPHCORE_COMPANION_WIFI && SOC_SERIES_ESP32S3 && !ESP_SPIRAM + +config ESP32_WIFI_STATIC_RX_BUFFER_NUM + default 6 if ZEPHCORE_COMPANION_WIFI && SOC_SERIES_ESP32S3 && !ESP_SPIRAM + +config ESP32_WIFI_DYNAMIC_RX_BUFFER_NUM + default 16 if ZEPHCORE_COMPANION_WIFI && SOC_SERIES_ESP32S3 && !ESP_SPIRAM + +config ESP32_WIFI_DYNAMIC_TX_BUFFER_NUM + default 16 if ZEPHCORE_COMPANION_WIFI && SOC_SERIES_ESP32S3 && !ESP_SPIRAM + +# Main thread stack. Measured 2026-09-24 (CONFIG_ZEPHCORE_MEM_STATS, +# devdocs/tools/mem_load.py: fresh BLE pairing + bonded reconnects, protocol +# golden over BLE and TCP, 8 KB sign, key export/import, flood advert, 120 +# contacts added and dumped): companion peak 3008 B on nRF52 (T1000-E), 3828 B +# on ESP32-S3 with WiFi. 6144 for the companion (the LoRa-E5 companion already +# ran 6144); the servers keep 8192 (region CLI, uplink). A conf fragment still +# overrides (stm32wl_common 6144, observer 4096). Ahead of Kconfig.zephyr so it +# beats Zephyr's own default. +config MAIN_STACK_SIZE + default 6144 if ZEPHCORE_ROLE_COMPANION + default 8192 + source "$(ZEPHYR_BASE)/Kconfig.zephyr" # DT-driven PSRAM auto-enable for ESP32 boards (see Kconfig.psram). @@ -123,10 +167,9 @@ config ZEPHCORE_COMPANION_USB Compile and init the USB CDC-ACM companion protocol stack (ZephyrCompanionUSB / ZephyrUSBCDC) independently of CONFIG_LOG. The full ~60-opcode binary protocol is available over USB even in - production builds (CONFIG_LOG=n). USB and BLE arbitrate the active - interface first-come-first-served: CMD_APP_START claims it for USB only - if no BLE session is already active (and is ignored while BLE is live); - on USB unplug the interface is handed back to BLE or released to idle. + production builds (CONFIG_LOG=n). It is one interface in the + companion's MultiSerialInterface: USB, BLE (and TCP) clients can be + connected at once, and every connected one gets every frame. Enabled by default for all companion builds on platforms that already have CONFIG_USB_DEVICE_STACK_NEXT=y (all nRF52840 boards). On @@ -140,24 +183,27 @@ config ZEPHCORE_COMPANION_USB there too — but not on repeater/observer/room-server or debug builds, which keep the port for the console. +DT_CHOSEN_ZEPHCORE_COMPANION_UART := zephcore,companion-uart + config ZEPHCORE_COMPANION_SERIAL bool "Enable plain-UART companion transport" depends on ZEPHCORE_ROLE_COMPANION && !ZEPHCORE_COMPANION_USB + default y if !BT && $(dt_chosen_enabled,$(DT_CHOSEN_ZEPHCORE_COMPANION_UART)) help Run the same wired-companion stack (ZephyrCompanionUSB: frame parser, - TX ring, text CLI, BLE-arbitrated interface) over a plain UART instead - of a native-USB CDC-ACM endpoint. For boards whose USB-C is wired to a - USB-UART bridge rather than the SoC's native USB (e.g. Heltec V3 / - CP2102), or that have no USB device controller at all. + TX ring, text CLI) over a plain UART instead of a native-USB CDC-ACM + endpoint. For boards whose USB-C is wired to a USB-UART bridge rather + than the SoC's native USB (e.g. Heltec V3 / CP2102), or that have no USB + device controller at all. The board points the transport at its UART with the - `zephcore,companion-uart` chosen node (e.g. = &uart0); that UART must be - dedicated to the companion (console/shell moved off or disabled), since - the protocol stream and a text console cannot share one line. Coexists - with BLE exactly like the USB backend — same first-come arbitration. + `zephcore,companion-uart` chosen node (e.g. = &uart0); that UART should + be dedicated to the companion (console/shell moved off or disabled), + since the protocol stream and a text console cannot share one line. + It is one interface in the MultiSerialInterface, beside BLE and TCP. - Distinct from the legacy SerialCompanionTransport (no-BLE STM32WL path): - this one keeps BLE and adds the text CLI. + On by default for a board without Bluetooth that names the chosen node + (the LoRa-E5 mini): the UART is then its only companion link. if ZEPHCORE_ROLE_REPEATER || ZEPHCORE_ROLE_ROOM_SERVER @@ -375,64 +421,95 @@ config ZEPHCORE_BLE_DFU endmenu # BLE Configuration -menu "Linux Companion Transport (native_sim)" +menu "Companion TCP Transport" config ZEPHCORE_TRANSPORT_TCP - bool "Use TCP socket transport instead of BLE NUS" + bool "Companion over a TCP socket" default n help - Replaces the ZephyrBLE adapter with LinuxTCPTransport, exposing - the companion NUS protocol over a TCP socket on Linux SBC builds - (BOARD=native_sim). Auto-enabled by boards/linux_native/linux_common.conf. + Serve the companion protocol over TCP (TcpCompanionTransport), as one + more interface in the MultiSerialInterface beside BLE and USB: the + native-Linux builds (linux_common.conf turns it on) and the WiFi + companion (ZEPHCORE_COMPANION_WIFI selects it). The wire format is MeshCore's SerialWifiInterface framing: - ['<'][len_LSB][len_MSB][NUS payload] app->node and ['>']... node->app. + ['<'][len_LSB][len_MSB][payload] app->node and ['>']... node->app. -config ZEPHCORE_LINUX_TCP_PORT +config ZEPHCORE_TCP_PORT int "TCP listen port" depends on ZEPHCORE_TRANSPORT_TCP default 5000 range 1 65535 help - Port the companion TCP transport binds to. Default 5000 matches - the upstream MeshCore Linux companion service so a single mobile - app TCP configuration works against both backends. + Port the companion TCP transport listens on. Default 5000 matches + upstream MeshCore's companion TCP_PORT and its Linux companion service, + so one mobile-app TCP configuration works against every backend. -endmenu # Linux Companion Transport +config ZEPHCORE_TCP_QUEUE_SIZE + int "TCP TX/RX frame queue depth" + depends on ZEPHCORE_TRANSPORT_TCP + default 8 + range 4 64 + help + Frames queued between the TCP socket and the companion, each way. + Smaller than the BLE queues (ZEPHCORE_BLE_QUEUE_SIZE, 24): there is + one TCP client, the socket buffers on both ends absorb bursts, and + the RX side waits for room instead of dropping, so a full queue + pushes back through TCP flow control. 16 frames less each way is + ~5.7 KB of DRAM, which the no-PSRAM WiFi boards spend on contacts. + +config ZEPHCORE_COMPANION_WIFI + bool "WiFi companion (app over TCP on the saved network)" + depends on ZEPHCORE_ROLE_COMPANION && WIFI + select ZEPHCORE_TRANSPORT_TCP + help + Join the network saved with upstream's wifi.* CLI (set wifi.ssid, + set wifi.pwd, set wifi.enabled; applied on reboot) and serve the + companion protocol over TCP on ZEPHCORE_TCP_PORT, beside BLE and USB: + every connected transport is served (MultiSerialInterface). + + Turned on by boards/common/wifi_companion.conf, which CMake adds to + companion builds of boards whose zephcore.yml declares + `capabilities: wifi: true` (the ones with the RAM for WiFi and BLE + together). + +endmenu # Companion TCP Transport endif # ZEPHCORE_ROLE_COMPANION config ZEPHCORE_HOUSEKEEPING_INTERVAL_MS - int "Periodic housekeeping interval (ms)" - depends on ZEPHCORE_ROLE_COMPANION || ZEPHCORE_ROLE_ROOM_SERVER + int "Companion housekeeping interval (ms)" + depends on ZEPHCORE_ROLE_COMPANION default 5000 range 1000 60000 help - Wake interval for noise floor calibration, battery reads, and UI refresh. + Wake interval for the companion's periodic housekeeping: noise floor + calibration, battery reads, UI refresh, and loop()'s own deadlines. Longer = lower power, less responsive monitoring. - Companion and room server only. Both still run a fixed periodic tick: - the companion's block contains genuine pollers (contact-dump progress, - BLE advertising watchdog) that have no deadline to arm, and the room - server is paced by its own 500 ms push timer regardless. Repeaters are - deadline-driven instead — see ZEPHCORE_MAINTENANCE_BACKSTOP_MS. + Companion only. It keeps a fixed periodic tick because its block + contains genuine pollers of external state (contact-dump progress, BLE + advertising watchdog, battery) that have no deadline to arm. Repeaters + and room servers are deadline-driven instead — see + ZEPHCORE_MAINTENANCE_BACKSTOP_MS. config ZEPHCORE_MAINTENANCE_BACKSTOP_MS - int "Repeater maintenance backstop (ms)" - depends on ZEPHCORE_ROLE_REPEATER + int "Repeater / room server maintenance backstop (ms)" + depends on ZEPHCORE_ROLE_REPEATER || ZEPHCORE_ROLE_ROOM_SERVER default 60000 range 15000 600000 help - Hard ceiling on how long a repeater will go without a maintenance pass. + Hard ceiling on how long a repeater or room server will go without a + maintenance pass. - main_repeater.cpp arms a one-shot wake at the soonest deadline reported + The event loop arms a one-shot wake at the soonest deadline reported by msUntilNextMaintenance(), then clamps it to this value. The clamp is UNCONDITIONAL, so this is a safety net against a deadline that is missed or mis-reported — not a value that should ever bind in normal operation. MUST stay above the radio measurement interval ("set probe.interval", default 15 s; ZEPHCORE_NOISE_FLOOR_INTERVAL_MS when probing is off) — - the shortest recurring deadline on an idle repeater. Set at or below it + the shortest recurring deadline on an idle node. Set at or below it and this silently becomes the wake period, reproducing the old fixed 5 s tick and making the deadline scheduling a no-op. That is exactly the bug the original 5000 default shipped with. @@ -782,6 +859,15 @@ config ZEPHCORE_MEM_STATS creation), which costs boot time, so leave this off in releases. Resolve heap addresses to symbols with nm on zephyr.elf. +config ZEPHCORE_LOOP_WAKE_STATS + bool "CLI: count event-loop wakes" + help + Count every return from the mesh thread's k_event_wait(), in total and + per event bit, and report them with `get loop.wakes`. For measuring a + role's wake profile (wakes per second = wakes / up), e.g. before and + after a change to its scheduling. Measure on the release config with + this passed by -D, so debug logging does not add wakes of its own. + config ZEPHCORE_LORA_RETENTION_DEBUG bool "SX126x: verify the warm-start retention list on air" depends on ZEPHCORE_RADIO_NATIVE diff --git a/zephcore/adapters/ble/ZephyrBLE.cpp b/zephcore/adapters/ble/ZephyrBLE.cpp index aabc111..5324cba 100644 --- a/zephcore/adapters/ble/ZephyrBLE.cpp +++ b/zephcore/adapters/ble/ZephyrBLE.cpp @@ -32,6 +32,8 @@ LOG_MODULE_REGISTER(zephcore_ble, CONFIG_ZEPHCORE_BLE_LOG_LEVEL); #include #include "ZephyrBLE.h" +#include "ble_internal.h" +#include "frame_txq.h" /* ========== Constants ========== */ @@ -49,26 +51,13 @@ LOG_MODULE_REGISTER(zephcore_ble, CONFIG_ZEPHCORE_BLE_LOG_LEVEL); /* TX timeout watchdog - reset ble_tx_in_progress if callback never fires */ #define BLE_TX_TIMEOUT_MS 2000 -/* Congestion overflow retry interval — when the TX queue is full, the - * stuck frame retries at this cadence. Slow enough to not hammer the - * BLE stack when the link is marginal, fast enough to recover quickly. */ -#define BLE_TX_OVERFLOW_RETRY_MS 250 -/* App push notifications are 0x80+; protocol response packets are < 0x80. */ -#define PUSH_CODE_BASE 0x80 /* Advertising intervals (Apple Accessory Design Guidelines §5.5) */ #define BT_ADV_FAST_INTERVAL 32 /* 20ms in 0.625ms units */ #define BT_ADV_FAST_DURATION_MS (60 * 1000) /* fast window after boot/disconnect */ #define BT_ADV_INTERVAL CONFIG_ZEPHCORE_BLE_ADV_SLOW_INTERVAL -/* ========== Frame type for queues ========== */ - -struct frame { - uint16_t len; - uint8_t buf[MAX_FRAME_SIZE]; -}; - /* ========== Static state ========== */ /* Deferred connection parameter update — applied after initial sync @@ -79,6 +68,15 @@ static bool conn_params_pending; /* Callbacks to main */ static const struct ble_callbacks *ble_cbs; +#if IS_ENABLED(CONFIG_ZEPHCORE_BLE_DFU) +void zephcore_ble_dfu_request(void) +{ + if (ble_cbs && ble_cbs->on_dfu_request) { + ble_cbs->on_dfu_request(); + } +} +#endif + /* Advertising data */ static char device_name[DEVICE_NAME_MAX]; static const uint8_t ad_flags = BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR; @@ -89,31 +87,16 @@ static struct bt_data sd[1]; static size_t ad_len; static size_t sd_len; -/* Queues — ISR-safe, no mutex needed */ +/* Queues — ISR-safe, no mutex needed. The send queue is driven through + * ble_txq: congestion, the overflow slot and the lossless rule (frame_txq.h). */ K_MSGQ_DEFINE(ble_send_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); K_MSGQ_DEFINE(ble_recv_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); +static struct frame_txq ble_txq; /* TX retry buffer - used when BLE returns -ENOMEM/-EAGAIN */ static struct frame tx_retry_frame; static bool tx_retry_pending = false; -/* TX congestion control — flow-control mechanism for queue-full conditions. - * - * When the TX queue is full, instead of blocking or dropping: - * 1. Set ble_tx_congested flag → callers (contact iteration, etc.) stop sending - * 2. Save the stuck frame to overflow_frame → retried every 250ms - * 3. When queue drains below low water mark (1/3) → clear congestion - * 4. On disconnect → clear everything - * - * Water marks (with default FRAME_QUEUE_SIZE=12): - * - Contact iteration pauses: 2/3 = 8 frames (high water) - * - Congestion mode enters: 12/12 = full - * - Congestion mode clears: 1/3 = 4 frames (low water) - */ -static bool ble_tx_congested; -static struct frame overflow_frame; -static bool overflow_pending; - /* Connection state */ static struct bt_conn *current_conn; static bool nus_notif_enabled; @@ -121,279 +104,6 @@ static bool ble_tx_ready = false; static bool ble_tx_in_progress = false; static int64_t ble_tx_start_time = 0; -#if IS_ENABLED(CONFIG_BT_GATT_SERVICE_CHANGED) -/* Bump when static BT_GATT_SERVICE_DEFINE layout or registration order changes. - * 1 = pre-2cf4b97 (dfu_svc before secure_nus_svc) - * 2 = secure_nus_svc_dfu after secure_nus_svc (+ packet/revision chars) */ -#define ZEPHCORE_GATT_LAYOUT_VERSION 2 - -/* Delay before sending Service Changed after L2 security is established. - * On a fresh pairing, pairing_complete() fires within this window and marks the - * peer current, so only genuine bonded reconnects with a stale layout get an SC. - * The delay also gives the peer time to subscribe to the Service Changed CCC. */ -#define GATT_SC_INDICATE_DELAY_MS 1000 - -static bool gatt_sc_ind_in_flight; -static const struct bt_gatt_attr *gatt_sc_value_attr; -static struct bt_gatt_indicate_params gatt_sc_ind_params; -static uint16_t gatt_sc_ind_range[2]; - -static void gatt_peer_settings_key(char *key, size_t key_len, const bt_addr_le_t *addr) -{ - char addr_str[BT_ADDR_LE_STR_LEN]; - - bt_addr_le_to_str(addr, addr_str, sizeof(addr_str)); - snprintk(key, key_len, "ble/gatt_peer/%s", addr_str); -} - -static int gatt_peer_layout_load(const bt_addr_le_t *addr, uint8_t *ver_out) -{ -#if IS_ENABLED(CONFIG_SETTINGS) - char key[64]; - ssize_t len; - - gatt_peer_settings_key(key, sizeof(key), addr); - len = settings_load_one(key, ver_out, sizeof(*ver_out)); - if (len == (ssize_t)sizeof(*ver_out)) { - return 0; - } -#endif - return -ENOENT; -} - -static int gatt_peer_layout_save(const bt_addr_le_t *addr, uint8_t ver) -{ -#if IS_ENABLED(CONFIG_SETTINGS) - char key[64]; - int err; - - gatt_peer_settings_key(key, sizeof(key), addr); - err = settings_save_one(key, &ver, sizeof(ver)); - return err; -#else - ARG_UNUSED(addr); - ARG_UNUSED(ver); - return -ENOTSUP; -#endif -} - -static void gatt_peer_layout_delete(const bt_addr_le_t *addr) -{ -#if IS_ENABLED(CONFIG_SETTINGS) - char key[64]; - - gatt_peer_settings_key(key, sizeof(key), addr); - settings_delete(key); -#else - ARG_UNUSED(addr); -#endif -} - -static int gatt_global_layout_save(uint8_t ver) -{ -#if IS_ENABLED(CONFIG_SETTINGS) - return settings_save_one("ble/gatt_layout", &ver, sizeof(ver)); -#else - ARG_UNUSED(ver); - return -ENOTSUP; -#endif -} - -struct gatt_layout_bond_ctx { - int count; -}; - -static void gatt_layout_count_bonds(const struct bt_bond_info *info, void *user_data) -{ - struct gatt_layout_bond_ctx *ctx = - static_cast(user_data); - - ARG_UNUSED(info); - ctx->count++; -} - -static void gatt_layout_check_after_settings_load(void) -{ -#if IS_ENABLED(CONFIG_SETTINGS) - struct gatt_layout_bond_ctx bond_ctx = { 0 }; - uint8_t global; - uint8_t prev; - ssize_t len; - - bt_foreach_bond(BT_ID_DEFAULT, gatt_layout_count_bonds, &bond_ctx); - - len = settings_load_one("ble/gatt_layout", &global, sizeof(global)); - if (len != (ssize_t)sizeof(global)) { - if (bond_ctx.count == 0) { - /* Fresh device — no stale phone caches to fix. */ - if (gatt_global_layout_save(ZEPHCORE_GATT_LAYOUT_VERSION) == 0) { - LOG_DBG("GATT layout v%u seeded (no bonds)", - ZEPHCORE_GATT_LAYOUT_VERSION); - } - return; - } - - prev = 1; /* bonded before layout tracking existed */ - } else { - prev = global; - } - - if (prev != ZEPHCORE_GATT_LAYOUT_VERSION) { - LOG_INF("GATT layout v%u -> v%u (per-peer SC on connect)", - prev, ZEPHCORE_GATT_LAYOUT_VERSION); - if (gatt_global_layout_save(ZEPHCORE_GATT_LAYOUT_VERSION) != 0) { - LOG_WRN("GATT layout global version save failed"); - } - } -#endif -} - -static uint8_t gatt_sc_find_value_attr(const struct bt_gatt_attr *attr, uint16_t handle, - void *user_data) -{ - ARG_UNUSED(handle); - ARG_UNUSED(user_data); - - if (!bt_uuid_cmp(attr->uuid, BT_UUID_GATT_CHRC)) { - const struct bt_gatt_chrc *chrc = - static_cast(attr->user_data); - - if (!bt_uuid_cmp(chrc->uuid, BT_UUID_GATT_SC)) { - gatt_sc_value_attr = bt_gatt_attr_next(attr); - return BT_GATT_ITER_STOP; - } - } - - return BT_GATT_ITER_CONTINUE; -} - -static void gatt_sc_indicate_cb(struct bt_conn *conn, struct bt_gatt_indicate_params *params, - uint8_t err) -{ - const bt_addr_le_t *addr = bt_conn_get_dst(conn); - - ARG_UNUSED(params); - - gatt_sc_ind_in_flight = false; - - if (err) { - LOG_WRN("Service Changed indicate failed: 0x%02x (will retry)", err); - return; - } - - if (gatt_peer_layout_save(addr, ZEPHCORE_GATT_LAYOUT_VERSION) != 0) { - LOG_WRN("GATT peer layout save failed (will retry on reconnect)"); - return; - } - - LOG_INF("GATT layout v%u: Service Changed confirmed", ZEPHCORE_GATT_LAYOUT_VERSION); -} - -static void gatt_peer_mark_current_no_sc(const bt_addr_le_t *addr) -{ - if (gatt_peer_layout_save(addr, ZEPHCORE_GATT_LAYOUT_VERSION) != 0) { - LOG_WRN("GATT peer layout save failed"); - return; - } - - LOG_DBG("GATT layout v%u: peer marked current (no SC needed)", ZEPHCORE_GATT_LAYOUT_VERSION); -} - -static void maybe_indicate_service_changed(struct bt_conn *conn) -{ - const bt_addr_le_t *addr = bt_conn_get_dst(conn); - uint8_t peer_ver; - int err; - - if (gatt_sc_ind_in_flight) { - return; - } - - if (gatt_peer_layout_load(addr, &peer_ver) == 0 && - peer_ver == ZEPHCORE_GATT_LAYOUT_VERSION) { - return; - } - - if (!gatt_sc_value_attr) { - bt_gatt_foreach_attr(0x0001, 0xffff, gatt_sc_find_value_attr, NULL); - if (!gatt_sc_value_attr) { - LOG_ERR("Service Changed characteristic not found"); - return; - } - } - - gatt_sc_ind_range[0] = sys_cpu_to_le16(0x0001); - gatt_sc_ind_range[1] = sys_cpu_to_le16(0xffff); - - memset(&gatt_sc_ind_params, 0, sizeof(gatt_sc_ind_params)); - gatt_sc_ind_params.attr = gatt_sc_value_attr; - gatt_sc_ind_params.func = gatt_sc_indicate_cb; - gatt_sc_ind_params.data = gatt_sc_ind_range; - gatt_sc_ind_params.len = sizeof(gatt_sc_ind_range); - - err = bt_gatt_indicate(conn, &gatt_sc_ind_params); - if (err) { - LOG_WRN("Service Changed indicate err %d (will retry)", err); - return; - } - - gatt_sc_ind_in_flight = true; - LOG_INF("GATT layout migration: Service Changed indicated"); -} - -/* Deferred from security_changed() — see GATT_SC_INDICATE_DELAY_MS. Runs on the - * single active connection (BT_MAX_CONN=1); cancelled on disconnect. */ -static void gatt_sc_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - - if (current_conn) { - maybe_indicate_service_changed(current_conn); - } -} -static K_WORK_DELAYABLE_DEFINE(gatt_sc_work, gatt_sc_work_fn); -#endif /* CONFIG_BT_GATT_SERVICE_CHANGED */ - -/* Active interface tracking */ -static enum zephcore_iface active_iface = ZEPHCORE_IFACE_NONE; - -/* active_iface is mutated from two threads — the Bluetooth callback thread - * (connect / security / pairing / disconnect) and the USB workqueue - * (CMD_APP_START / DTR drop). This mutex makes the check-then-act claim and - * release sequences atomic so the two transports can never both believe they - * own the interface (or lose a write to it). */ -K_MUTEX_DEFINE(ble_iface_lock); - -/* Atomically claim the interface for `who` iff it is currently idle. - * Returns true only if this call performed the NONE -> who transition. */ -static bool iface_claim_if_idle(enum zephcore_iface who) -{ - k_mutex_lock(&ble_iface_lock, K_FOREVER); - bool claimed = (active_iface == ZEPHCORE_IFACE_NONE); - if (claimed) { - active_iface = who; - } - k_mutex_unlock(&ble_iface_lock); - return claimed; -} - -/* Atomically release the interface if `who` currently owns it. */ -static void iface_release(enum zephcore_iface who) -{ - k_mutex_lock(&ble_iface_lock, K_FOREVER); - if (active_iface == who) { - active_iface = ZEPHCORE_IFACE_NONE; - } - k_mutex_unlock(&ble_iface_lock); -} - -/* Claim the active interface for BLE, but only if nothing else owns it. - * First-come-first-served: a live USB session must not be evicted by BLE - * connecting/pairing in the background. Returns true if BLE now owns it. */ -static bool ble_claim_iface_if_idle(void) -{ - return iface_claim_if_idle(ZEPHCORE_IFACE_BLE); -} /* DLE tracking — set after successful DLE request to avoid double-request */ static bool dle_requested; @@ -423,6 +133,8 @@ static bool adv_running; /* Administrative BLE state */ static bool ble_enabled = true; +/* zephcore_ble_start() has run: advertising can be started or stopped */ +static bool ble_started; /* Runtime BLE passkey */ static uint32_t ble_passkey = CONFIG_ZEPHCORE_BLE_PASSKEY; @@ -430,14 +142,6 @@ static uint32_t ble_passkey = CONFIG_ZEPHCORE_BLE_PASSKEY; /* NUS TX characteristic attribute — resolved at init, avoids hard-coded offset */ static const struct bt_gatt_attr *nus_tx_attr; -static bool is_lossless_protocol_frame(const uint8_t *data, uint16_t len) -{ - if (!data || len == 0) { - return false; - } - return data[0] < PUSH_CODE_BASE; -} - /* ========== Forward declarations ========== */ static void ble_tx_complete_cb(struct bt_conn *conn, void *user_data); @@ -477,130 +181,13 @@ BT_GATT_SERVICE_DEFINE(secure_nus_svc, NULL, secure_nus_rx_write, NULL), ); -#if IS_ENABLED(CONFIG_ZEPHCORE_BLE_DFU) -/* ========== Legacy Nordic/Adafruit buttonless DFU service ========== - * - * Mirrors Adafruit BLEDfu (Arduino MeshCore >=1.15.0) so the same DFU tools - * interoperate: a paired phone writes 0x01 to the control point and the device - * resets into the bootloader's BLE OTA mode. We use the *unbonded* OTA reset - * (GPREGRET 0xA8, same as `start ota`): the bootloader comes up as a fresh DFU - * target and the tool re-scans for it (the legacy buttonless flow). Adafruit's - * 0xB1 bonded-resume path needs SoftDevice peer-data enrollment we can't - * replicate on Zephyr, so the phone makes a fresh connection to the bootloader. - * - * The service exposes the FULL legacy DFU shape Adafruit ships (not just the - * control point): iOS's LegacyDFUService treats the DFU Packet (1532) char as - * a *required* characteristic — discovery fails (DFU "instantly fails") without - * it — and reads the DFU Revision (1534 = 0x0001 "app mode") to classify the - * device as an application that supports the buttonless jump (missing → the app - * can't identify the device → shows it nameless). Packet is a no-op here: the - * real image upload happens in the bootloader, not the running app. All three - * chars sit behind AUTHEN, matching the Arduino companion's service-wide MITM - * floor (`bledfu.setPermission(SECMODE_ENC_WITH_MITM, ...)`); a bonded phone's - * DFU app reuses the OS-level bond, an unpaired stranger is rejected. - * - * NOTE on the service symbol name: Zephyr registers static GATT services in - * the order ld's SORT_BY_NAME emits them — i.e. alphabetically by the symbol - * passed to BT_GATT_SERVICE_DEFINE. This service MUST sort *after* - * `secure_nus_svc` so the NUS attribute handles stay fixed; otherwise every - * NUS handle shifts and bonded phones with cached handles get ATT 0x03 - * (Write Not Permitted) on the NUS RX write. Hence the `secure_nus_svc_dfu` - * name (a string sorts before its own extensions, so NUS keeps the low range). - */ -static struct bt_uuid_128 dfu_svc_uuid = BT_UUID_INIT_128( - BT_UUID_128_ENCODE(0x00001530, 0x1212, 0xefde, 0x1523, 0x785feabcd123)); -static struct bt_uuid_128 dfu_ctrl_uuid = BT_UUID_INIT_128( - BT_UUID_128_ENCODE(0x00001531, 0x1212, 0xefde, 0x1523, 0x785feabcd123)); -static struct bt_uuid_128 dfu_packet_uuid = BT_UUID_INIT_128( - BT_UUID_128_ENCODE(0x00001532, 0x1212, 0xefde, 0x1523, 0x785feabcd123)); -static struct bt_uuid_128 dfu_revision_uuid = BT_UUID_INIT_128( - BT_UUID_128_ENCODE(0x00001534, 0x1212, 0xefde, 0x1523, 0x785feabcd123)); - -/* DFU Revision = 0x0001 (DFU_REV_APPMODE), little-endian — tells the DFU app - * "this is an application that supports the buttonless jump to bootloader". */ -static const uint8_t dfu_revision[2] = { 0x01, 0x00 }; - -static void dfu_jump_work_fn(struct k_work *work); -static K_WORK_DELAYABLE_DEFINE(dfu_jump_work, dfu_jump_work_fn); - -static void dfu_jump_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - if (ble_cbs && ble_cbs->on_dfu_request) { - ble_cbs->on_dfu_request(); /* sets GPREGRET + resets; never returns */ - } -} - -static ssize_t dfu_ctrl_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, - const void *buf, uint16_t len, uint16_t offset, uint8_t flags) -{ - ARG_UNUSED(conn); - ARG_UNUSED(attr); - ARG_UNUSED(offset); - ARG_UNUSED(flags); - /* Adafruit BLEDfu jump command: first byte 0x01 (1-2 byte write). */ - if (len >= 1 && ((const uint8_t *)buf)[0] == 0x01) { - LOG_INF("buttonless DFU requested - rebooting to BLE OTA"); - /* Defer so the ATT write response flushes and the DFU tool can - * arm its disconnect/rescan before we reset. */ - k_work_schedule(&dfu_jump_work, K_MSEC(250)); - } - return len; -} - -static void dfu_ctrl_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) -{ - ARG_UNUSED(attr); - ARG_UNUSED(value); -} - -/* DFU Packet — present only so the DFU library's characteristic discovery - * succeeds; the actual image transfer happens in the bootloader, not here. */ -static ssize_t dfu_packet_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, - const void *buf, uint16_t len, uint16_t offset, uint8_t flags) -{ - ARG_UNUSED(conn); - ARG_UNUSED(attr); - ARG_UNUSED(buf); - ARG_UNUSED(offset); - ARG_UNUSED(flags); - return len; /* no-op in app mode */ -} - -static ssize_t dfu_revision_read(struct bt_conn *conn, const struct bt_gatt_attr *attr, - void *buf, uint16_t len, uint16_t offset) -{ - return bt_gatt_attr_read(conn, attr, buf, len, offset, - dfu_revision, sizeof(dfu_revision)); -} - -BT_GATT_SERVICE_DEFINE(secure_nus_svc_dfu, - BT_GATT_PRIMARY_SERVICE(&dfu_svc_uuid), - BT_GATT_CHARACTERISTIC(&dfu_ctrl_uuid.uuid, - BT_GATT_CHRC_WRITE | BT_GATT_CHRC_NOTIFY, - BT_GATT_PERM_WRITE_AUTHEN, - NULL, dfu_ctrl_write, NULL), - BT_GATT_CCC(dfu_ctrl_ccc_changed, - BT_GATT_PERM_READ_AUTHEN | BT_GATT_PERM_WRITE_AUTHEN), - BT_GATT_CHARACTERISTIC(&dfu_packet_uuid.uuid, - BT_GATT_CHRC_WRITE_WITHOUT_RESP, - BT_GATT_PERM_WRITE_AUTHEN, - NULL, dfu_packet_write, NULL), - BT_GATT_CHARACTERISTIC(&dfu_revision_uuid.uuid, - BT_GATT_CHRC_READ, - BT_GATT_PERM_READ_AUTHEN, - dfu_revision_read, NULL, NULL), -); -#endif /* CONFIG_ZEPHCORE_BLE_DFU */ /* ========== Work items ========== */ static void tx_drain_work_fn(struct k_work *work); -static void overflow_retry_work_fn(struct k_work *work); static void adv_slow_work_fn(struct k_work *work); K_WORK_DELAYABLE_DEFINE(tx_drain_work, tx_drain_work_fn); -K_WORK_DELAYABLE_DEFINE(overflow_retry_work, overflow_retry_work_fn); K_WORK_DELAYABLE_DEFINE(adv_slow_work, adv_slow_work_fn); /* ========== Unpaired-connection timeout ========== @@ -749,15 +336,6 @@ static void connected(struct bt_conn *conn, uint8_t err) } LOG_INF("connected: %s", addr); - /* Reject BLE connections while USB is the active transport. - * The user connected via BLE expecting to exchange messages, but - * USB owns the interface — they would get nothing and be confused. */ - if (active_iface == ZEPHCORE_IFACE_USB) { - LOG_INF("connected: USB active — rejecting BLE connection from %s", addr); - bt_conn_disconnect(conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN); - return; - } - current_conn = bt_conn_ref(conn); /* Zephyr stops advertising internally when the conn slot is consumed @@ -790,8 +368,8 @@ static void connected(struct bt_conn *conn, uint8_t err) * CONFIG_BT_SMP and CONFIG_BT_BONDABLE are enabled. */ /* Notify main of BLE connection */ - if (ble_cbs && ble_cbs->on_connected) { - ble_cbs->on_connected(); + if (ble_cbs && ble_cbs->link.on_connected) { + ble_cbs->link.on_connected(); } } @@ -814,27 +392,20 @@ static void disconnected(struct bt_conn *conn, uint8_t reason) ble_tx_ready = false; conn_params_pending = false; - /* Clear interface state if BLE was active */ - iface_release(ZEPHCORE_IFACE_BLE); - /* Clear queues, retry state, and congestion */ - k_msgq_purge(&ble_send_queue); + frame_txq_reset(&ble_txq); k_msgq_purge(&ble_recv_queue); tx_retry_pending = false; - overflow_pending = false; - ble_tx_congested = false; k_work_cancel_delayable(&tx_drain_work); - k_work_cancel_delayable(&overflow_retry_work); #if IS_ENABLED(CONFIG_BT_GATT_SERVICE_CHANGED) - k_work_cancel_delayable(&gatt_sc_work); - gatt_sc_ind_in_flight = false; + ble_gatt_layout_disconnected(); #endif /* Notify main of BLE disconnection */ - if (ble_cbs && ble_cbs->on_disconnected) { - ble_cbs->on_disconnected(); + if (ble_cbs && ble_cbs->link.on_disconnected) { + ble_cbs->link.on_disconnected(); } } @@ -872,7 +443,6 @@ static void security_changed(struct bt_conn *conn, bt_security_t level, enum bt_ if (level >= BT_SECURITY_L2 && !ble_tx_ready) { LOG_INF("security established, enabling TX"); ble_tx_ready = true; - ble_claim_iface_if_idle(); /* If CCC was already subscribed (bonded reconnect — phone writes * CCC before security_changed fires), kick TX now. On fresh @@ -889,7 +459,7 @@ static void security_changed(struct bt_conn *conn, bt_security_t level, enum bt_ * Service Changed forces rediscovery without breaking the bond. * Deferred so a fresh pairing (pairing_complete marks the peer * current) doesn't trigger a needless SC. */ - k_work_reschedule(&gatt_sc_work, K_MSEC(GATT_SC_INDICATE_DELAY_MS)); + ble_gatt_layout_security_ready(conn); #endif #if defined(CONFIG_BT_USER_DATA_LEN_UPDATE) /* Fallback DLE request — if le_phy_updated() already sent it, @@ -1016,16 +586,9 @@ static void pairing_complete(struct bt_conn *conn, bool bonded) #if IS_ENABLED(CONFIG_BT_GATT_SERVICE_CHANGED) /* Fresh pairing always does full GATT discovery — no Service Changed needed. */ if (bonded) { - gatt_peer_mark_current_no_sc(bt_conn_get_dst(conn)); + ble_gatt_layout_paired(bt_conn_get_dst(conn)); } #endif - - if (ble_claim_iface_if_idle()) { - LOG_INF("pairing complete, activating BLE interface"); - } else { - LOG_INF("pairing complete, %s already active — BLE paired but not promoted", - active_iface == ZEPHCORE_IFACE_USB ? "USB" : "BLE"); - } } static void pairing_failed(struct bt_conn *conn, enum bt_security_err reason) @@ -1041,7 +604,7 @@ static void pairing_failed(struct bt_conn *conn, enum bt_security_err reason) static void bond_deleted(uint8_t id, const bt_addr_le_t *peer) { ARG_UNUSED(id); - gatt_peer_layout_delete(peer); + ble_gatt_layout_bond_deleted(peer); } #endif @@ -1053,37 +616,6 @@ static struct bt_conn_auth_info_cb auth_info_cb = { #endif }; -/* ========== TX congestion overflow retry ========== */ - -static void overflow_retry_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - - if (!overflow_pending) { - return; - } - - /* Abandon overflow if connection is gone */ - if (!current_conn || active_iface == ZEPHCORE_IFACE_NONE) { - overflow_pending = false; - ble_tx_congested = false; - LOG_INF("overflow cleared (disconnected)"); - return; - } - - if (k_msgq_put(&ble_send_queue, &overflow_frame, K_NO_WAIT) == 0) { - overflow_pending = false; - LOG_DBG("overflow frame queued hdr=0x%02x, kicking drain", - overflow_frame.buf[0]); - kick_tx_drain(); - /* Congestion flag cleared by tx_drain at low water mark */ - } else { - /* Still full — retry at reduced rate */ - LOG_DBG("overflow retry: queue still full, retry in 250ms"); - k_work_schedule(&overflow_retry_work, K_MSEC(BLE_TX_OVERFLOW_RETRY_MS)); - } -} - /* ========== TX drain work ========== */ static void kick_tx_drain(void) @@ -1091,23 +623,18 @@ static void kick_tx_drain(void) k_work_schedule(&tx_drain_work, K_NO_WAIT); } +/* The txq abandons its parked push when this goes false */ +static bool ble_link_up(void) +{ + return current_conn != NULL; +} + static void tx_drain_work_fn(struct k_work *work) { ARG_UNUSED(work); struct frame f; int err; - /* USB TX path is handled in main — only BLE TX here */ - if (active_iface == ZEPHCORE_IFACE_USB) { - /* Let main handle USB TX — just signal tx_idle if queue empty */ - if (k_msgq_num_used_get(&ble_send_queue) == 0) { - if (ble_cbs && ble_cbs->on_tx_idle) { - ble_cbs->on_tx_idle(); - } - } - return; - } - /* * BLE TX path - Event-driven (like Arduino's HVN_TX_COMPLETE) * Uses bt_gatt_notify_cb() callback to chain TX without polling. @@ -1170,31 +697,15 @@ static void tx_drain_work_fn(struct k_work *work) } } - /* Get next frame from queue */ - if (k_msgq_get(&ble_send_queue, &f, K_NO_WAIT) != 0) { - /* TX queue empty — clear congestion and signal idle */ - if (ble_tx_congested) { - ble_tx_congested = false; - LOG_INF("tx_drain: congestion cleared (queue empty)"); - } - if (ble_cbs && ble_cbs->on_tx_idle) { - ble_cbs->on_tx_idle(); + /* Get next frame from queue (the txq clears congestion at 1/3 and empty) */ + if (frame_txq_get(&ble_txq, &f) != 0) { + if (ble_cbs && ble_cbs->link.on_tx_idle) { + ble_cbs->link.on_tx_idle(); } bt_conn_unref(conn); return; } - /* Clear congestion at low water mark (1/3 of queue) — gives headroom - * before hitting full again. Hysteresis: ON at full, OFF at 1/3. */ - if (ble_tx_congested) { - uint32_t used = k_msgq_num_used_get(&ble_send_queue); - if (used <= FRAME_QUEUE_SIZE / 3) { - ble_tx_congested = false; - LOG_INF("tx_drain: congestion cleared (queue=%u/%u)", - used, (unsigned)FRAME_QUEUE_SIZE); - } - } - LOG_DBG("tx_drain[BLE]: sending len=%u hdr=0x%02x queue=%u", (unsigned)f.len, f.buf[0], k_msgq_num_used_get(&ble_send_queue)); @@ -1264,9 +775,16 @@ static ssize_t secure_nus_rx_write(struct bt_conn *conn, const struct bt_gatt_at LOG_DBG("NUS RX: len=%u cmd=0x%02x", len, cmd); - /* Notify main via callback */ - if (ble_cbs && ble_cbs->on_rx_frame) { - ble_cbs->on_rx_frame(data, len); + struct frame f; + + f.len = len; + memcpy(f.buf, data, len); + if (k_msgq_put(&ble_recv_queue, &f, K_NO_WAIT) != 0) { + LOG_WRN("recv queue full"); + return len; + } + if (ble_cbs && ble_cbs->link.on_rx) { + ble_cbs->link.on_rx(); } return len; @@ -1333,6 +851,8 @@ static void start_fast_adv(void) void zephcore_ble_init(const struct ble_callbacks *cbs) { ble_cbs = cbs; + frame_txq_init(&ble_txq, &ble_send_queue, FRAME_QUEUE_SIZE, "ble", + ble_link_up, kick_tx_drain); /* Resolve NUS TX characteristic attribute once — avoids hard-coded * array offset in secure_nus_send(). attrs[2] = TX char value @@ -1353,94 +873,46 @@ void zephcore_ble_start(const char *name) * No app-side seeding needed. */ settings_load(); #if IS_ENABLED(CONFIG_BT_GATT_SERVICE_CHANGED) - gatt_layout_check_after_settings_load(); + ble_gatt_layout_check_after_settings_load(); #endif } build_device_name_and_adv(name); - LOG_DBG("init complete, starting adv"); - start_fast_adv(); + ble_started = true; + if (ble_enabled) { + LOG_DBG("init complete, starting adv"); + start_fast_adv(); + } } size_t zephcore_ble_send(const uint8_t *data, uint16_t len) { - if (len == 0 || len > MAX_FRAME_SIZE) { - LOG_WRN("invalid len=%u", (unsigned)len); - return 0; - } - - /* Don't queue frames if no active transport */ - if (active_iface == ZEPHCORE_IFACE_BLE && !current_conn) { + /* Nothing is queued without a connection */ + if (!current_conn) { LOG_DBG("no BLE conn, dropping len=%u hdr=0x%02x", - (unsigned)len, data[0]); - return 0; - } - if (active_iface == ZEPHCORE_IFACE_NONE) { - LOG_DBG("no active iface, dropping len=%u hdr=0x%02x", - (unsigned)len, data[0]); + (unsigned)len, len ? data[0] : 0); return 0; } + return frame_txq_put(&ble_txq, data, len); +} +size_t zephcore_ble_recv(uint8_t *dest) +{ struct frame f; - f.len = len; - memcpy(f.buf, data, len); - if (k_msgq_put(&ble_send_queue, &f, K_NO_WAIT) != 0) { - /* Queue full — enter congestion mode. - * - * Instead of blocking (would stall LoRa) or dropping (loses - * frames), we signal congestion so all senders stop, then - * save this frame and retry at a reduced 250ms cadence until - * the queue drains or the connection drops. - * - * Callers check zephcore_ble_is_congested() and hold off: - * - contact iteration (run_contact_iteration): pauses iteration - * - main thread (sendPush): pushes are best-effort signals, - * actual message data is safe in the offline queue - */ - if (!ble_tx_congested) { - LOG_WRN("TX queue full (%u/%u), entering congestion", - k_msgq_num_used_get(&ble_send_queue), - (unsigned)FRAME_QUEUE_SIZE); - ble_tx_congested = true; - } - - /* Protocol response frames are lossless. If queue is full, report - * failure so the caller can retry instead of overflow replacement. */ - if (is_lossless_protocol_frame(data, len)) { - LOG_DBG("TX queue full for lossless protocol frame hdr=0x%02x, retry later", data[0]); - return 0; - } - - /* Save to overflow — retried at 250ms intervals. - * If overflow is already occupied, drop the new frame rather - * than clobber the buffered one. Only MSG_WAITING / PATH_UPDATED - * / CONTACTS_FULL are truly idempotent; most other push codes - * (ADVERT, SEND_CONFIRMED, RAW_DATA, telemetry/status responses, - * etc.) carry per-event data, so silent replacement = data loss. - * Chat messages are unaffected — message bytes live in the - * CompanionMesh offline queue and ride the lossless response - * path (data[0] < 0x80) on sync. */ - if (overflow_pending) { - LOG_WRN("overflow full, dropping push hdr=0x%02x", data[0]); - return 0; - } - overflow_frame = f; - overflow_pending = true; - k_work_schedule(&overflow_retry_work, K_MSEC(BLE_TX_OVERFLOW_RETRY_MS)); - return len; /* Accepted into overflow — will be retried */ + if (k_msgq_get(&ble_recv_queue, &f, K_NO_WAIT) != 0) { + return 0; } - - LOG_DBG("queued len=%u hdr=0x%02x queue=%u", - (unsigned)len, data[0], k_msgq_num_used_get(&ble_send_queue)); - - kick_tx_drain(); - return len; + memcpy(dest, f.buf, f.len); + return f.len; } void zephcore_ble_set_enabled(bool enable) { ble_enabled = enable; + if (!ble_started) { + return; /* zephcore_ble_start() honours it */ + } if (!enable) { /* Disconnect current connection if any */ if (current_conn) { @@ -1461,6 +933,7 @@ void zephcore_ble_set_enabled(bool enable) LOG_INF("BLE enabled"); } } + bool zephcore_ble_is_enabled(void) { return ble_enabled; @@ -1468,7 +941,7 @@ bool zephcore_ble_is_enabled(void) bool zephcore_ble_is_active(void) { - return active_iface == ZEPHCORE_IFACE_BLE && current_conn != NULL && ble_tx_ready; + return current_conn != NULL && ble_tx_ready; } bool zephcore_ble_is_connected(void) @@ -1476,9 +949,9 @@ bool zephcore_ble_is_connected(void) return current_conn != NULL; } -bool zephcore_ble_is_congested(void) +bool zephcore_ble_is_write_busy(void) { - return ble_tx_congested; + return frame_txq_busy(&ble_txq); } bool zephcore_ble_tx_idle(void) @@ -1487,10 +960,9 @@ bool zephcore_ble_tx_idle(void) if (!current_conn) { return true; } - return k_msgq_num_used_get(&ble_send_queue) == 0 && + return frame_txq_empty(&ble_txq) && !ble_tx_in_progress && - !tx_retry_pending && - !overflow_pending; + !tx_retry_pending; } bool zephcore_ble_is_advertising(void) @@ -1513,54 +985,6 @@ uint32_t zephcore_ble_get_passkey(void) return ble_passkey; } -enum zephcore_iface zephcore_ble_get_active_iface(void) -{ - k_mutex_lock(&ble_iface_lock, K_FOREVER); - enum zephcore_iface iface = active_iface; - k_mutex_unlock(&ble_iface_lock); - return iface; -} - -void zephcore_ble_set_active_iface(enum zephcore_iface iface) -{ - k_mutex_lock(&ble_iface_lock, K_FOREVER); - active_iface = iface; - k_mutex_unlock(&ble_iface_lock); -} - -bool zephcore_ble_iface_try_claim(enum zephcore_iface who) -{ - k_mutex_lock(&ble_iface_lock, K_FOREVER); - bool ok = (active_iface == ZEPHCORE_IFACE_NONE || active_iface == who); - if (ok) { - active_iface = who; - } - k_mutex_unlock(&ble_iface_lock); - return ok; -} - -struct k_msgq *zephcore_ble_get_recv_queue(void) -{ - return &ble_recv_queue; -} - -struct k_msgq *zephcore_ble_get_send_queue(void) -{ - return &ble_send_queue; -} - -void zephcore_ble_kick_tx(void) -{ - kick_tx_drain(); -} - -void zephcore_ble_disconnect(void) -{ - if (current_conn) { - bt_conn_disconnect(current_conn, BT_HCI_ERR_REMOTE_USER_TERM_CONN); - } -} - void zephcore_ble_update_name(const char *new_name) { build_device_name_and_adv(new_name); diff --git a/zephcore/adapters/ble/ZephyrBLE.h b/zephcore/adapters/ble/ZephyrBLE.h index 110a903..ab15c90 100644 --- a/zephcore/adapters/ble/ZephyrBLE.h +++ b/zephcore/adapters/ble/ZephyrBLE.h @@ -1,7 +1,12 @@ /* * SPDX-License-Identifier: MIT * ZephCore BLE Adapter — NUS service, advertising, security, TX/RX + * + * One companion transport among several: CompanionInterfaces.h wraps it as a + * BaseSerialInterface for the MultiSerialInterface. Nothing here knows about + * the other transports. */ + #pragma once #include @@ -9,58 +14,48 @@ #include #include -/* Maximum companion transport frame size. - * +4 over the base 172 to accommodate transport codes (region scoping). */ -#define MAX_FRAME_SIZE 176 +#include "companion_framing.h" /* MAX_FRAME_SIZE, struct frame, companion_link_cbs */ #ifdef __cplusplus extern "C" { #endif -/* Callbacks from BLE adapter to main */ struct ble_callbacks { - /* RX frame received; runs on system work queue — must not block */ - void (*on_rx_frame)(const uint8_t *data, uint16_t len); - /* TX queue drained */ - void (*on_tx_idle)(void); - /* BLE connected */ - void (*on_connected)(void); - /* BLE disconnected */ - void (*on_disconnected)(void); + /* Frame waiting (zephcore_ble_recv), TX drained, (dis)connected. Raised + * from the BT and system work queue threads: post events only. */ + struct companion_link_cbs link; /* Buttonless DFU control-point write — defer reboot into the * bootloader's BLE OTA mode. May be NULL (feature disabled). */ void (*on_dfu_request)(void); }; -enum zephcore_iface { - ZEPHCORE_IFACE_NONE, - ZEPHCORE_IFACE_BLE, - ZEPHCORE_IFACE_USB, -}; - /** Register callbacks and auth handlers. Call before bt_enable(). */ void zephcore_ble_init(const struct ble_callbacks *cbs); -/** Load settings, build adv data, start advertising. Call from bt_ready(). */ +/** Load settings, build adv data, start advertising if enabled. Call from bt_ready(). */ void zephcore_ble_start(const char *device_name); /** Queue a frame for BLE TX. Returns bytes queued, or 0 on failure. */ size_t zephcore_ble_send(const uint8_t *data, uint16_t len); -/** Enable/disable BLE. Disabling disconnects and stops advertising. */ +/** Take the next received frame into dest (MAX_FRAME_SIZE). Returns its length, 0 if none. */ +size_t zephcore_ble_recv(uint8_t *dest); + +/** Enable/disable BLE. Disabling disconnects and stops advertising. Before + * zephcore_ble_start() this only records the wish, which start() honours. */ void zephcore_ble_set_enabled(bool enable); /** True if BLE is enabled */ bool zephcore_ble_is_enabled(void); -/** True if BLE is the active transport and ready to send. */ +/** True when a secured client is connected: frames can be exchanged. */ bool zephcore_ble_is_active(void); -/** True if BLE has an active connection (regardless of interface state). */ +/** True if BLE has a connection (secured or not). */ bool zephcore_ble_is_connected(void); -/** True if TX queue is full and overflow retry is active. */ -bool zephcore_ble_is_congested(void); +/** True while senders should hold off: TX congested, or the queue at its 2/3 high-water mark. */ +bool zephcore_ble_is_write_busy(void); /** True when every queued frame has been transmitted and link-layer acked: * send queue empty, nothing in flight, no retry or overflow frame held back. @@ -78,30 +73,6 @@ bool zephcore_ble_is_advertising(void); void zephcore_ble_set_passkey(uint32_t passkey); uint32_t zephcore_ble_get_passkey(void); -/** Get/set active interface (BLE/USB coexistence). Both are thread-safe — - * active_iface is mutated from the BLE callback thread and the USB workqueue. */ -enum zephcore_iface zephcore_ble_get_active_iface(void); -void zephcore_ble_set_active_iface(enum zephcore_iface iface); - -/** Atomically claim the active interface for `who` unless the other transport - * already owns it. Succeeds (returns true) if the interface is idle or already - * held by `who`; fails if a different interface is active. Thread-safe — use - * this instead of a get-then-set sequence to avoid a check-then-act race. */ -bool zephcore_ble_iface_try_claim(enum zephcore_iface who); - -/** Get recv/send queues for USB path sharing. */ -struct k_msgq *zephcore_ble_get_recv_queue(void); -struct k_msgq *zephcore_ble_get_send_queue(void); - -/** Kick the TX drain work. Call after putting frames in the send queue. */ -void zephcore_ble_kick_tx(void); - -/** - * Disconnect the current BLE connection (if any). - * Used by USB when it takes over as active interface. - */ -void zephcore_ble_disconnect(void); - /** * Apply deferred connection parameters. * Call after the initial app sync is complete (channels + contacts + diff --git a/zephcore/adapters/ble/ble_dfu.cpp b/zephcore/adapters/ble/ble_dfu.cpp new file mode 100644 index 0000000..ddc2c41 --- /dev/null +++ b/zephcore/adapters/ble/ble_dfu.cpp @@ -0,0 +1,134 @@ +/* + * SPDX-License-Identifier: MIT + * ZephCore BLE - legacy Nordic/Adafruit buttonless DFU service + * + * Split out of ZephyrBLE.cpp. The service symbol name keeps the GATT handle + * order (see below); the jump goes through zephcore_ble_dfu_request(). + */ + +#include +#include +#include +LOG_MODULE_DECLARE(zephcore_ble, CONFIG_ZEPHCORE_BLE_LOG_LEVEL); + +#include +#include +#include +#include +#include +#include +#include + +#include "ble_internal.h" + +/* ========== Legacy Nordic/Adafruit buttonless DFU service ========== + * + * Mirrors Adafruit BLEDfu (Arduino MeshCore >=1.15.0) so the same DFU tools + * interoperate: a paired phone writes 0x01 to the control point and the device + * resets into the bootloader's BLE OTA mode. We use the *unbonded* OTA reset + * (GPREGRET 0xA8, same as `start ota`): the bootloader comes up as a fresh DFU + * target and the tool re-scans for it (the legacy buttonless flow). Adafruit's + * 0xB1 bonded-resume path needs SoftDevice peer-data enrollment we can't + * replicate on Zephyr, so the phone makes a fresh connection to the bootloader. + * + * The service exposes the FULL legacy DFU shape Adafruit ships (not just the + * control point): iOS's LegacyDFUService treats the DFU Packet (1532) char as + * a *required* characteristic — discovery fails (DFU "instantly fails") without + * it — and reads the DFU Revision (1534 = 0x0001 "app mode") to classify the + * device as an application that supports the buttonless jump (missing → the app + * can't identify the device → shows it nameless). Packet is a no-op here: the + * real image upload happens in the bootloader, not the running app. All three + * chars sit behind AUTHEN, matching the Arduino companion's service-wide MITM + * floor (`bledfu.setPermission(SECMODE_ENC_WITH_MITM, ...)`); a bonded phone's + * DFU app reuses the OS-level bond, an unpaired stranger is rejected. + * + * NOTE on the service symbol name: Zephyr registers static GATT services in + * the order ld's SORT_BY_NAME emits them — i.e. alphabetically by the symbol + * passed to BT_GATT_SERVICE_DEFINE. This service MUST sort *after* + * `secure_nus_svc` so the NUS attribute handles stay fixed; otherwise every + * NUS handle shifts and bonded phones with cached handles get ATT 0x03 + * (Write Not Permitted) on the NUS RX write. Hence the `secure_nus_svc_dfu` + * name (a string sorts before its own extensions, so NUS keeps the low range). + */ +static struct bt_uuid_128 dfu_svc_uuid = BT_UUID_INIT_128( + BT_UUID_128_ENCODE(0x00001530, 0x1212, 0xefde, 0x1523, 0x785feabcd123)); +static struct bt_uuid_128 dfu_ctrl_uuid = BT_UUID_INIT_128( + BT_UUID_128_ENCODE(0x00001531, 0x1212, 0xefde, 0x1523, 0x785feabcd123)); +static struct bt_uuid_128 dfu_packet_uuid = BT_UUID_INIT_128( + BT_UUID_128_ENCODE(0x00001532, 0x1212, 0xefde, 0x1523, 0x785feabcd123)); +static struct bt_uuid_128 dfu_revision_uuid = BT_UUID_INIT_128( + BT_UUID_128_ENCODE(0x00001534, 0x1212, 0xefde, 0x1523, 0x785feabcd123)); + +/* DFU Revision = 0x0001 (DFU_REV_APPMODE), little-endian — tells the DFU app + * "this is an application that supports the buttonless jump to bootloader". */ +static const uint8_t dfu_revision[2] = { 0x01, 0x00 }; + +static void dfu_jump_work_fn(struct k_work *work); +static K_WORK_DELAYABLE_DEFINE(dfu_jump_work, dfu_jump_work_fn); + +static void dfu_jump_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + zephcore_ble_dfu_request(); /* sets GPREGRET + resets; never returns */ +} + +static ssize_t dfu_ctrl_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, + const void *buf, uint16_t len, uint16_t offset, uint8_t flags) +{ + ARG_UNUSED(conn); + ARG_UNUSED(attr); + ARG_UNUSED(offset); + ARG_UNUSED(flags); + /* Adafruit BLEDfu jump command: first byte 0x01 (1-2 byte write). */ + if (len >= 1 && ((const uint8_t *)buf)[0] == 0x01) { + LOG_INF("buttonless DFU requested - rebooting to BLE OTA"); + /* Defer so the ATT write response flushes and the DFU tool can + * arm its disconnect/rescan before we reset. */ + k_work_schedule(&dfu_jump_work, K_MSEC(250)); + } + return len; +} + +static void dfu_ctrl_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) +{ + ARG_UNUSED(attr); + ARG_UNUSED(value); +} + +/* DFU Packet — present only so the DFU library's characteristic discovery + * succeeds; the actual image transfer happens in the bootloader, not here. */ +static ssize_t dfu_packet_write(struct bt_conn *conn, const struct bt_gatt_attr *attr, + const void *buf, uint16_t len, uint16_t offset, uint8_t flags) +{ + ARG_UNUSED(conn); + ARG_UNUSED(attr); + ARG_UNUSED(buf); + ARG_UNUSED(offset); + ARG_UNUSED(flags); + return len; /* no-op in app mode */ +} + +static ssize_t dfu_revision_read(struct bt_conn *conn, const struct bt_gatt_attr *attr, + void *buf, uint16_t len, uint16_t offset) +{ + return bt_gatt_attr_read(conn, attr, buf, len, offset, + dfu_revision, sizeof(dfu_revision)); +} + +BT_GATT_SERVICE_DEFINE(secure_nus_svc_dfu, + BT_GATT_PRIMARY_SERVICE(&dfu_svc_uuid), + BT_GATT_CHARACTERISTIC(&dfu_ctrl_uuid.uuid, + BT_GATT_CHRC_WRITE | BT_GATT_CHRC_NOTIFY, + BT_GATT_PERM_WRITE_AUTHEN, + NULL, dfu_ctrl_write, NULL), + BT_GATT_CCC(dfu_ctrl_ccc_changed, + BT_GATT_PERM_READ_AUTHEN | BT_GATT_PERM_WRITE_AUTHEN), + BT_GATT_CHARACTERISTIC(&dfu_packet_uuid.uuid, + BT_GATT_CHRC_WRITE_WITHOUT_RESP, + BT_GATT_PERM_WRITE_AUTHEN, + NULL, dfu_packet_write, NULL), + BT_GATT_CHARACTERISTIC(&dfu_revision_uuid.uuid, + BT_GATT_CHRC_READ, + BT_GATT_PERM_READ_AUTHEN, + dfu_revision_read, NULL, NULL), +); diff --git a/zephcore/adapters/ble/ble_gatt_layout.cpp b/zephcore/adapters/ble/ble_gatt_layout.cpp new file mode 100644 index 0000000..a02f770 --- /dev/null +++ b/zephcore/adapters/ble/ble_gatt_layout.cpp @@ -0,0 +1,283 @@ +/* + * SPDX-License-Identifier: MIT + * ZephCore BLE - GATT layout versioning and Service Changed + * + * When the static GATT table changes shape, bonded phones still hold the old + * attribute handles. A per-peer layout version in settings decides whether a + * reconnecting bonded peer gets a Service Changed indication. Split out of + * ZephyrBLE.cpp; ZephyrBLE calls the ble_gatt_layout_* hooks. + */ + +#include +#include +#include +LOG_MODULE_DECLARE(zephcore_ble, CONFIG_ZEPHCORE_BLE_LOG_LEVEL); + +#include +#include +#include +#include +#include +#include +#include + +#include "ble_internal.h" + +/* Bump when static BT_GATT_SERVICE_DEFINE layout or registration order changes. + * 1 = pre-2cf4b97 (dfu_svc before secure_nus_svc) + * 2 = secure_nus_svc_dfu after secure_nus_svc (+ packet/revision chars) */ +#define ZEPHCORE_GATT_LAYOUT_VERSION 2 + +/* Delay before sending Service Changed after L2 security is established. + * On a fresh pairing, pairing_complete() fires within this window and marks the + * peer current, so only genuine bonded reconnects with a stale layout get an SC. + * The delay also gives the peer time to subscribe to the Service Changed CCC. */ +#define GATT_SC_INDICATE_DELAY_MS 1000 + +static bool gatt_sc_ind_in_flight; +static const struct bt_gatt_attr *gatt_sc_value_attr; +static struct bt_gatt_indicate_params gatt_sc_ind_params; +static uint16_t gatt_sc_ind_range[2]; + +static void gatt_peer_settings_key(char *key, size_t key_len, const bt_addr_le_t *addr) +{ + char addr_str[BT_ADDR_LE_STR_LEN]; + + bt_addr_le_to_str(addr, addr_str, sizeof(addr_str)); + snprintk(key, key_len, "ble/gatt_peer/%s", addr_str); +} + +static int gatt_peer_layout_load(const bt_addr_le_t *addr, uint8_t *ver_out) +{ +#if IS_ENABLED(CONFIG_SETTINGS) + char key[64]; + ssize_t len; + + gatt_peer_settings_key(key, sizeof(key), addr); + len = settings_load_one(key, ver_out, sizeof(*ver_out)); + if (len == (ssize_t)sizeof(*ver_out)) { + return 0; + } +#endif + return -ENOENT; +} + +static int gatt_peer_layout_save(const bt_addr_le_t *addr, uint8_t ver) +{ +#if IS_ENABLED(CONFIG_SETTINGS) + char key[64]; + int err; + + gatt_peer_settings_key(key, sizeof(key), addr); + err = settings_save_one(key, &ver, sizeof(ver)); + return err; +#else + ARG_UNUSED(addr); + ARG_UNUSED(ver); + return -ENOTSUP; +#endif +} + +static void gatt_peer_layout_delete(const bt_addr_le_t *addr) +{ +#if IS_ENABLED(CONFIG_SETTINGS) + char key[64]; + + gatt_peer_settings_key(key, sizeof(key), addr); + settings_delete(key); +#else + ARG_UNUSED(addr); +#endif +} + +static int gatt_global_layout_save(uint8_t ver) +{ +#if IS_ENABLED(CONFIG_SETTINGS) + return settings_save_one("ble/gatt_layout", &ver, sizeof(ver)); +#else + ARG_UNUSED(ver); + return -ENOTSUP; +#endif +} + +struct gatt_layout_bond_ctx { + int count; +}; + +static void gatt_layout_count_bonds(const struct bt_bond_info *info, void *user_data) +{ + struct gatt_layout_bond_ctx *ctx = + static_cast(user_data); + + ARG_UNUSED(info); + ctx->count++; +} + +void ble_gatt_layout_check_after_settings_load(void) +{ +#if IS_ENABLED(CONFIG_SETTINGS) + struct gatt_layout_bond_ctx bond_ctx = { 0 }; + uint8_t global; + uint8_t prev; + ssize_t len; + + bt_foreach_bond(BT_ID_DEFAULT, gatt_layout_count_bonds, &bond_ctx); + + len = settings_load_one("ble/gatt_layout", &global, sizeof(global)); + if (len != (ssize_t)sizeof(global)) { + if (bond_ctx.count == 0) { + /* Fresh device — no stale phone caches to fix. */ + if (gatt_global_layout_save(ZEPHCORE_GATT_LAYOUT_VERSION) == 0) { + LOG_DBG("GATT layout v%u seeded (no bonds)", + ZEPHCORE_GATT_LAYOUT_VERSION); + } + return; + } + + prev = 1; /* bonded before layout tracking existed */ + } else { + prev = global; + } + + if (prev != ZEPHCORE_GATT_LAYOUT_VERSION) { + LOG_INF("GATT layout v%u -> v%u (per-peer SC on connect)", + prev, ZEPHCORE_GATT_LAYOUT_VERSION); + if (gatt_global_layout_save(ZEPHCORE_GATT_LAYOUT_VERSION) != 0) { + LOG_WRN("GATT layout global version save failed"); + } + } +#endif +} + +static uint8_t gatt_sc_find_value_attr(const struct bt_gatt_attr *attr, uint16_t handle, + void *user_data) +{ + ARG_UNUSED(handle); + ARG_UNUSED(user_data); + + if (!bt_uuid_cmp(attr->uuid, BT_UUID_GATT_CHRC)) { + const struct bt_gatt_chrc *chrc = + static_cast(attr->user_data); + + if (!bt_uuid_cmp(chrc->uuid, BT_UUID_GATT_SC)) { + gatt_sc_value_attr = bt_gatt_attr_next(attr); + return BT_GATT_ITER_STOP; + } + } + + return BT_GATT_ITER_CONTINUE; +} + +static void gatt_sc_indicate_cb(struct bt_conn *conn, struct bt_gatt_indicate_params *params, + uint8_t err) +{ + const bt_addr_le_t *addr = bt_conn_get_dst(conn); + + ARG_UNUSED(params); + + gatt_sc_ind_in_flight = false; + + if (err) { + LOG_WRN("Service Changed indicate failed: 0x%02x (will retry)", err); + return; + } + + if (gatt_peer_layout_save(addr, ZEPHCORE_GATT_LAYOUT_VERSION) != 0) { + LOG_WRN("GATT peer layout save failed (will retry on reconnect)"); + return; + } + + LOG_INF("GATT layout v%u: Service Changed confirmed", ZEPHCORE_GATT_LAYOUT_VERSION); +} + +static void gatt_peer_mark_current_no_sc(const bt_addr_le_t *addr) +{ + if (gatt_peer_layout_save(addr, ZEPHCORE_GATT_LAYOUT_VERSION) != 0) { + LOG_WRN("GATT peer layout save failed"); + return; + } + + LOG_DBG("GATT layout v%u: peer marked current (no SC needed)", ZEPHCORE_GATT_LAYOUT_VERSION); +} + +static void maybe_indicate_service_changed(struct bt_conn *conn) +{ + const bt_addr_le_t *addr = bt_conn_get_dst(conn); + uint8_t peer_ver; + int err; + + if (gatt_sc_ind_in_flight) { + return; + } + + if (gatt_peer_layout_load(addr, &peer_ver) == 0 && + peer_ver == ZEPHCORE_GATT_LAYOUT_VERSION) { + return; + } + + if (!gatt_sc_value_attr) { + bt_gatt_foreach_attr(0x0001, 0xffff, gatt_sc_find_value_attr, NULL); + if (!gatt_sc_value_attr) { + LOG_ERR("Service Changed characteristic not found"); + return; + } + } + + gatt_sc_ind_range[0] = sys_cpu_to_le16(0x0001); + gatt_sc_ind_range[1] = sys_cpu_to_le16(0xffff); + + memset(&gatt_sc_ind_params, 0, sizeof(gatt_sc_ind_params)); + gatt_sc_ind_params.attr = gatt_sc_value_attr; + gatt_sc_ind_params.func = gatt_sc_indicate_cb; + gatt_sc_ind_params.data = gatt_sc_ind_range; + gatt_sc_ind_params.len = sizeof(gatt_sc_ind_range); + + err = bt_gatt_indicate(conn, &gatt_sc_ind_params); + if (err) { + LOG_WRN("Service Changed indicate err %d (will retry)", err); + return; + } + + gatt_sc_ind_in_flight = true; + LOG_INF("GATT layout migration: Service Changed indicated"); +} + +/* The connection security_changed() handed us. Not a reference: ZephyrBLE + * holds that, and ble_gatt_layout_disconnected() clears this and cancels the + * work before the connection goes away. */ +static struct bt_conn *sc_conn; + +/* Deferred from security_changed() — see GATT_SC_INDICATE_DELAY_MS. Runs on the + * single active connection (BT_MAX_CONN=1); cancelled on disconnect. */ +static void gatt_sc_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + + if (sc_conn) { + maybe_indicate_service_changed(sc_conn); + } +} +static K_WORK_DELAYABLE_DEFINE(gatt_sc_work, gatt_sc_work_fn); + +void ble_gatt_layout_security_ready(struct bt_conn *conn) +{ + sc_conn = conn; + k_work_reschedule(&gatt_sc_work, K_MSEC(GATT_SC_INDICATE_DELAY_MS)); +} + +void ble_gatt_layout_disconnected(void) +{ + k_work_cancel_delayable(&gatt_sc_work); + gatt_sc_ind_in_flight = false; + sc_conn = NULL; +} + +void ble_gatt_layout_paired(const bt_addr_le_t *addr) +{ + gatt_peer_mark_current_no_sc(addr); +} + +void ble_gatt_layout_bond_deleted(const bt_addr_le_t *addr) +{ + gatt_peer_layout_delete(addr); +} diff --git a/zephcore/adapters/ble/ble_internal.h b/zephcore/adapters/ble/ble_internal.h new file mode 100644 index 0000000..32556c8 --- /dev/null +++ b/zephcore/adapters/ble/ble_internal.h @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: MIT + * ZephCore BLE - hooks between ZephyrBLE.cpp and the parts split out of it + */ + +#pragma once + +#include +#include + +/* GATT layout versioning / Service Changed (ble_gatt_layout.cpp, + * CONFIG_BT_GATT_SERVICE_CHANGED). */ +void ble_gatt_layout_check_after_settings_load(void); +void ble_gatt_layout_security_ready(struct bt_conn *conn); +void ble_gatt_layout_disconnected(void); +void ble_gatt_layout_paired(const bt_addr_le_t *addr); +void ble_gatt_layout_bond_deleted(const bt_addr_le_t *addr); + +/* Buttonless DFU (ble_dfu.cpp, CONFIG_ZEPHCORE_BLE_DFU): the control-point + * write asks ZephyrBLE to run main's on_dfu_request. */ +void zephcore_ble_dfu_request(void); diff --git a/zephcore/adapters/datastore/FsStream.h b/zephcore/adapters/datastore/FsStream.h new file mode 100644 index 0000000..5a195e3 --- /dev/null +++ b/zephcore/adapters/datastore/FsStream.h @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: MIT + * Arduino Streams over an open Zephyr file, for ConfigSerializer (prefs.json). + * + * The parser calls available() and read() once per character, so the reader + * refills a small buffer instead of asking the filesystem each time. The + * writer remembers any short write so the atomic replace can refuse to commit. + */ + +#pragma once + +#include +#include + +class FsReadStream : public Stream { + struct fs_file_t *_f; + uint8_t _buf[64]; + size_t _len = 0; + size_t _pos = 0; + bool _eof = false; + + bool fill() + { + if (_pos < _len) { + return true; + } + if (_eof) { + return false; + } + ssize_t n = fs_read(_f, _buf, sizeof(_buf)); + + if (n <= 0) { + _eof = true; + return false; + } + _len = (size_t)n; + _pos = 0; + return true; + } + +public: + explicit FsReadStream(struct fs_file_t *f) : _f(f) {} + + int available() override { return fill() ? (int)(_len - _pos) : 0; } + int read() override { return fill() ? _buf[_pos++] : -1; } + int peek() override { return fill() ? _buf[_pos] : -1; } + size_t write(uint8_t) override { return 0; } +}; + +class FsWriteStream : public Stream { + struct fs_file_t *_f; + bool _ok = true; + +public: + explicit FsWriteStream(struct fs_file_t *f) : _f(f) {} + + bool ok() const { return _ok; } + + size_t write(uint8_t c) override { return write(&c, 1); } + size_t write(const uint8_t *buf, size_t size) override + { + ssize_t n = fs_write(_f, buf, size); + + if (n < 0 || (size_t)n != size) { + _ok = false; + return n > 0 ? (size_t)n : 0; + } + return size; + } + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } +}; diff --git a/zephcore/adapters/datastore/IdentityFile.cpp b/zephcore/adapters/datastore/IdentityFile.cpp new file mode 100644 index 0000000..2cf512b --- /dev/null +++ b/zephcore/adapters/datastore/IdentityFile.cpp @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: MIT + * IdentityFile - load and save a node identity file. See IdentityFile.h. + */ + +#include "IdentityFile.h" +#include "ZephyrFsUtil.h" + +#include +#include + +LOG_MODULE_REGISTER(zephcore_identity, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); + +bool zephcore_identity_load(const char *path, mesh::LocalIdentity &id) +{ + if (!zephcore_fs_exists(path)) { + return false; + } + + uint8_t buf[PRV_KEY_SIZE + PUB_KEY_SIZE + 32]; + size_t len = 0; + + if (zephcore_fs_read_file(path, buf, sizeof(buf), &len) && len >= PRV_KEY_SIZE) { + if (id.readFromStorage(buf, len)) { + return true; + } + if (id.recoverFromStorage(buf, len)) { + /* Deliberately not re-persisted: the file is the only record of + * what went wrong, and re-deriving costs a few ms per boot. */ + LOG_WRN("%s: pub/prv mismatch - advertising the pub its private key owns", + path); + return true; + } + } + + /* No layout coheres: adverts would verify nowhere, inbound DMs would not + * decrypt, and a key export would contradict SELF_INFO. */ + char bad_path[64]; + + if (snprintf(bad_path, sizeof(bad_path), "%s.bad", path) < (int)sizeof(bad_path)) { + zephcore_fs_remove(bad_path); + if (fs_rename(path, bad_path) == 0) { + LOG_ERR("%s: incoherent (%d bytes) - kept at %s, regenerating", path, + (int)len, bad_path); + return false; + } + } + LOG_ERR("%s: incoherent (%d bytes) - could not park it, regenerating over it", path, + (int)len); + return false; +} + +bool zephcore_identity_save(const char *path, const mesh::LocalIdentity &id) +{ + uint8_t buf[PRV_KEY_SIZE + PUB_KEY_SIZE + 32]; + size_t n = id.writeToStorage(buf, sizeof(buf)); + + if (n == 0) { + return false; + } + return zephcore_fs_atomic_replace(path, buf, n, "identity"); +} diff --git a/zephcore/adapters/datastore/IdentityFile.h b/zephcore/adapters/datastore/IdentityFile.h new file mode 100644 index 0000000..eb4fda9 --- /dev/null +++ b/zephcore/adapters/datastore/IdentityFile.h @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: MIT + * IdentityFile - load and save a node identity file (pub || prv, as Arduino + * MeshCore's IdentityStore), shared by every role's store. + */ + +#pragma once + +#include + +/* False if the file is absent, or holds no coherent key pair. In the second + * case the bytes are first moved to ".bad", because the caller then + * generates a fresh identity and saves it over path, and the old private key + * may still be recoverable by hand. A stored pub that does not match its prv + * is repaired in RAM (the pub the private key owns) and reported each boot. */ +bool zephcore_identity_load(const char *path, mesh::LocalIdentity &id); + +/* Power-safe replace (ZephyrFsUtil). */ +bool zephcore_identity_save(const char *path, const mesh::LocalIdentity &id); diff --git a/zephcore/adapters/datastore/PrefsFile.cpp b/zephcore/adapters/datastore/PrefsFile.cpp new file mode 100644 index 0000000..d0750c5 --- /dev/null +++ b/zephcore/adapters/datastore/PrefsFile.cpp @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: MIT + * PrefsFile - read and write a prefs.json file. See PrefsFile.h. + */ + +#include "PrefsFile.h" +#include "FsStream.h" +#include "ZephyrFsUtil.h" + +#include + +LOG_MODULE_REGISTER(zephcore_prefs_file, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); + +bool zephcore_prefs_json_load(const char *path, NodePrefs &p, PrefsFromJsonFn from_json) +{ + struct fs_dirent ent; + + if (fs_stat(path, &ent) != 0) { + return false; + } + if (ent.size == 0) { + /* The parser accepts an empty stream as "no keys" */ + LOG_ERR("%s is empty", path); + return false; + } + + struct fs_file_t file; + + fs_file_t_init(&file); + if (fs_open(&file, path, FS_O_READ) < 0) { + LOG_ERR("%s: open failed", path); + return false; + } + FsReadStream in(&file); + bool ok = from_json(p, in); + + fs_close(&file); + if (!ok) { + LOG_ERR("%s does not parse (%u bytes)", path, (unsigned)ent.size); + } + return ok; +} + +struct json_writer_ctx { + const NodePrefs *p; + PrefsToJsonFn to_json; +}; + +static bool json_writer(struct fs_file_t *file, void *arg) +{ + const struct json_writer_ctx *c = static_cast(arg); + FsWriteStream out(file); + + return c->to_json(*c->p, out) && out.ok(); +} + +bool zephcore_prefs_json_save(const char *path, const NodePrefs &p, PrefsToJsonFn to_json) +{ + struct json_writer_ctx c = { &p, to_json }; + + return zephcore_fs_atomic_write(path, json_writer, &c, "savePrefs"); +} diff --git a/zephcore/adapters/datastore/PrefsFile.h b/zephcore/adapters/datastore/PrefsFile.h new file mode 100644 index 0000000..80b2907 --- /dev/null +++ b/zephcore/adapters/datastore/PrefsFile.h @@ -0,0 +1,17 @@ +/* + * SPDX-License-Identifier: MIT + * PrefsFile - read and write a prefs.json file for either role's store. + */ + +#pragma once + +#include + +typedef bool (*PrefsFromJsonFn)(NodePrefs &p, Stream &in); +typedef bool (*PrefsToJsonFn)(const NodePrefs &p, Stream &out); + +/* False if the file is absent, empty or does not parse; p is then untouched. */ +bool zephcore_prefs_json_load(const char *path, NodePrefs &p, PrefsFromJsonFn from_json); + +/* Power-safe replace; false (and the old file kept) on any short write. */ +bool zephcore_prefs_json_save(const char *path, const NodePrefs &p, PrefsToJsonFn to_json); diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 997e97c..9d63c29 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -8,6 +8,10 @@ #include "ZephyrDataStore.h" #include "ZephyrFsFormat.h" +#include "ZephyrFsUtil.h" +#include "IdentityFile.h" +#include "PrefsFile.h" +#include #include // ADV_TYPE_NONE (transient/anon contacts) #include #include @@ -28,94 +32,10 @@ struct BlobRec { uint8_t data[MAX_ADVERT_PKT_LEN]; }; -typedef bool (*AtomicWriteFn)(struct fs_file_t *file, void *ctx); - -static bool atomicWriteTempFile(const char *path, AtomicWriteFn write_fn, void *ctx, const char *op_tag) -{ - char tmp_path[56]; - int pl = snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); - if (pl <= 0 || pl >= (int)sizeof(tmp_path)) { - LOG_ERR("%s: path too long", op_tag); - return false; - } - - /* Best-effort cleanup of a leftover temp from an interrupted write. - * - * Guarded by fs_stat rather than unlinking blind: on the normal path the - * file does not exist, fs_unlink() returns -ENOENT, and Zephyr's FS layer - * logs that at ERR level regardless of us ignoring the return. That put - * an line on the happy path of every atomic save — 23 of them in a - * 90-minute capture, one per save — which is exactly the noise that makes - * a real filesystem error invisible. */ - struct fs_dirent tmp_ent; - - if (fs_stat(tmp_path, &tmp_ent) == 0) { - fs_unlink(tmp_path); - } - - struct fs_file_t file; - fs_file_t_init(&file); - int rc = fs_open(&file, tmp_path, FS_O_CREATE | FS_O_WRITE); - if (rc < 0) { - LOG_ERR("%s: open %s failed: %d", op_tag, tmp_path, rc); - return false; - } - - bool write_ok = write_fn(&file, ctx); - int sync_rc = fs_sync(&file); - fs_close(&file); - if (!write_ok || sync_rc < 0) { - if (!write_ok) { - LOG_ERR("%s: write failed", op_tag); - } - if (sync_rc < 0) { - LOG_ERR("%s: sync failed: %d", op_tag, sync_rc); - } - fs_unlink(tmp_path); - return false; - } - - rc = fs_rename(tmp_path, path); - if (rc < 0) { - LOG_ERR("%s: rename %s -> %s failed: %d", op_tag, tmp_path, path, rc); - fs_unlink(tmp_path); - return false; - } - return true; -} - /* Track mount status - filesystems are automounted via DTS fstab */ static bool lfs_mounted; static bool ext_lfs_mounted; -/* Check if a filesystem is mounted (mount-list lookup, never logs) */ -static bool is_mounted(const char *mount_point) -{ - /* Walk the registered mount list rather than calling fs_statvfs(). - * - * fs_statvfs() on a path that is not mounted makes Zephyr's FS layer log - * "mount point not found!!" at ERR, which put an line on the happy - * path of every boot: on boards with no external flash (the probe can - * never succeed) and, on boards that do have it, on every boot before the - * deferred-init retry below mounts it. - * - * Deliberately NOT solved by gating the probe on - * DT_NODE_EXISTS(DT_NODELABEL(qspi_lfs)): that would still log on the - * deferred-init path, and it would silently stop detecting /ext on any - * future board that mounts external flash under a different node label. - * fs_readmount() is a pure lookup, logs nothing, and stays correct in - * every one of those permutations. */ - int index = 0; - const char *name = NULL; - - while (fs_readmount(&index, &name) == 0) { - if (name != NULL && strcmp(name, mount_point) == 0) { - return true; - } - } - return false; -} - bool ZephyrDataStore::mount() { if (lfs_mounted) { @@ -123,7 +43,7 @@ bool ZephyrDataStore::mount() } /* Check if internal LFS was automounted */ - if (is_mounted(mountPoint())) { + if (zephcore_fs_is_mounted(mountPoint())) { lfs_mounted = true; LOG_INF("Internal LittleFS at %s (automounted)", mountPoint()); } else { @@ -132,7 +52,7 @@ bool ZephyrDataStore::mount() } /* Check if external QSPI was automounted */ - if (is_mounted(extMountPoint())) { + if (zephcore_fs_is_mounted(extMountPoint())) { ext_lfs_mounted = true; LOG_INF("External QSPI LittleFS at %s (automounted, 100 blobs)", extMountPoint()); } else { @@ -165,7 +85,7 @@ bool ZephyrDataStore::mount() FS_FSTAB_DECLARE_ENTRY(DT_NODELABEL(qspi_lfs)); int rc = fs_mount(&FS_FSTAB_ENTRY(DT_NODELABEL(qspi_lfs))); - if (is_mounted(extMountPoint())) { + if (zephcore_fs_is_mounted(extMountPoint())) { ext_lfs_mounted = true; LOG_INF("External QSPI LittleFS at %s (mounted on retry, rc=%d)", extMountPoint(), rc); @@ -207,153 +127,57 @@ void ZephyrDataStore::begin() checkAdvBlobFile(); } -bool ZephyrDataStore::exists(const char *path) const +static bool copy_writer(struct fs_file_t *dst, void *ctx) { - struct fs_dirent ent; - return fs_stat(path, &ent) == 0; -} + struct fs_file_t *src = static_cast(ctx); + uint8_t buf[64]; + ssize_t n; -bool ZephyrDataStore::removeFile(const char *path) -{ - /* Idempotent: an absent file is a successful removal. - * - * Guarded by fs_stat rather than unlinking blind because fs_unlink() - * returns -ENOENT for a missing path and Zephyr's FS layer logs that at - * ERR level regardless of us ignoring the return. Every caller here is - * best-effort cleanup of a file that usually is NOT there, so unguarded - * this puts an line on the happy path of every boot — exactly the - * noise that makes a real filesystem error invisible. */ - struct fs_dirent entry; - - if (fs_stat(path, &entry) != 0) { - return true; + while ((n = fs_read(src, buf, sizeof(buf))) > 0) { + if (fs_write(dst, buf, n) != n) { + return false; + } } - return fs_unlink(path) == 0; -} - -bool ZephyrDataStore::openRead(const char *path, uint8_t *buf, size_t buf_sz, size_t &out_len) const -{ - struct fs_file_t file; - fs_file_t_init(&file); - int rc = fs_open(&file, path, FS_O_READ); - if (rc < 0) { - return false; - } - ssize_t n = fs_read(&file, buf, buf_sz); - fs_close(&file); - if (n < 0) { - return false; - } - out_len = (size_t)n; - return true; -} - -struct AtomicReplaceCtx { - const uint8_t *buf; - size_t len; -}; - -static bool atomicReplaceWriter(struct fs_file_t *file, void *ctx) -{ - AtomicReplaceCtx *c = static_cast(ctx); - ssize_t n = fs_write(file, c->buf, c->len); - return !(n < 0 || (size_t)n != c->len); -} - -/* Power-safe replace helper used for identity + prefs. */ -bool ZephyrDataStore::atomicReplaceFile(const char *path, const uint8_t *buf, size_t len) -{ - AtomicReplaceCtx ctx = { - .buf = buf, - .len = len, - }; - return atomicWriteTempFile(path, atomicReplaceWriter, &ctx, "atomicReplaceFile"); + return n == 0; } bool ZephyrDataStore::copyFile(const char *src, const char *dst) { - char tmp_path[64]; - int pl = snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", dst); - if (pl <= 0 || pl >= (int)sizeof(tmp_path)) { - return false; - } + struct fs_file_t src_file; - /* Guarded for the same reason as atomicWriteTempFile(): a blind unlink of - * a file that is normally absent logs -ENOENT at ERR level from the FS - * layer, on the success path. */ - struct fs_dirent tmp_ent; - - if (fs_stat(tmp_path, &tmp_ent) == 0) { - fs_unlink(tmp_path); - } - - struct fs_file_t src_file, dst_file; fs_file_t_init(&src_file); - fs_file_t_init(&dst_file); - if (fs_open(&src_file, src, FS_O_READ) < 0) { return false; } - - if (fs_open(&dst_file, tmp_path, FS_O_CREATE | FS_O_WRITE) < 0) { - fs_close(&src_file); - return false; - } - - uint8_t buf[64]; - ssize_t n; - bool ok = true; - while ((n = fs_read(&src_file, buf, sizeof(buf))) > 0) { - if (fs_write(&dst_file, buf, n) != n) { - ok = false; - break; - } - } + bool ok = zephcore_fs_atomic_write(dst, copy_writer, &src_file, "copyFile"); fs_close(&src_file); - if (!ok || n < 0) { - fs_close(&dst_file); - fs_unlink(tmp_path); - return false; - } - - int rc = fs_sync(&dst_file); - fs_close(&dst_file); - if (rc < 0) { - fs_unlink(tmp_path); - return false; - } - - if (fs_rename(tmp_path, dst) < 0) { - fs_unlink(tmp_path); - return false; - } - return true; + return ok; } void ZephyrDataStore::migrateToExternalFS() { /* Migrate contacts from internal to external if not present */ - if (!exists(EXT_CONTACTS_FILE) && exists(INT_CONTACTS_FILE)) { + if (!zephcore_fs_exists(EXT_CONTACTS_FILE) && zephcore_fs_exists(INT_CONTACTS_FILE)) { LOG_INF("Migrating contacts to external storage"); if (copyFile(INT_CONTACTS_FILE, EXT_CONTACTS_FILE)) { - removeFile(INT_CONTACTS_FILE); + zephcore_fs_remove(INT_CONTACTS_FILE); } } /* Migrate channels */ - if (!exists(EXT_CHANNELS_FILE) && exists(INT_CHANNELS_FILE)) { + if (!zephcore_fs_exists(EXT_CHANNELS_FILE) && zephcore_fs_exists(INT_CHANNELS_FILE)) { LOG_INF("Migrating channels to QSPI"); if (copyFile(INT_CHANNELS_FILE, EXT_CHANNELS_FILE)) { - removeFile(INT_CHANNELS_FILE); + zephcore_fs_remove(INT_CHANNELS_FILE); } } /* Migrate adv_blobs (extend to 100 records) */ - if (!exists(EXT_ADV_BLOBS_FILE) && exists(INT_ADV_BLOBS_FILE)) { + if (!zephcore_fs_exists(EXT_ADV_BLOBS_FILE) && zephcore_fs_exists(INT_ADV_BLOBS_FILE)) { LOG_INF("Migrating adv_blobs to QSPI (20 -> 100 slots)"); if (copyFile(INT_ADV_BLOBS_FILE, EXT_ADV_BLOBS_FILE)) { - removeFile(INT_ADV_BLOBS_FILE); + zephcore_fs_remove(INT_ADV_BLOBS_FILE); struct fs_file_t file; fs_file_t_init(&file); if (fs_open(&file, EXT_ADV_BLOBS_FILE, FS_O_RDWR) == 0) { @@ -369,21 +193,21 @@ void ZephyrDataStore::migrateToExternalFS() } /* Clean up old files on internal if they exist on external */ - if (exists(EXT_CONTACTS_FILE) && exists(INT_CONTACTS_FILE)) { - removeFile(INT_CONTACTS_FILE); + if (zephcore_fs_exists(EXT_CONTACTS_FILE) && zephcore_fs_exists(INT_CONTACTS_FILE)) { + zephcore_fs_remove(INT_CONTACTS_FILE); } - if (exists(EXT_CHANNELS_FILE) && exists(INT_CHANNELS_FILE)) { - removeFile(INT_CHANNELS_FILE); + if (zephcore_fs_exists(EXT_CHANNELS_FILE) && zephcore_fs_exists(INT_CHANNELS_FILE)) { + zephcore_fs_remove(INT_CHANNELS_FILE); } - if (exists(EXT_ADV_BLOBS_FILE) && exists(INT_ADV_BLOBS_FILE)) { - removeFile(INT_ADV_BLOBS_FILE); + if (zephcore_fs_exists(EXT_ADV_BLOBS_FILE) && zephcore_fs_exists(INT_ADV_BLOBS_FILE)) { + zephcore_fs_remove(INT_ADV_BLOBS_FILE); } } void ZephyrDataStore::checkAdvBlobFile() { const char *path = advBlobsFile(); - if (exists(path)) { + if (zephcore_fs_exists(path)) { return; } BlobRec zeroes; @@ -419,7 +243,7 @@ bool ZephyrDataStore::formatFileSystem() return mounted; } -void ZephyrDataStore::factoryReset() +bool ZephyrDataStore::factoryReset() { LOG_INF("=== FACTORY RESET STARTING ==="); if (formatFileSystem()) { @@ -430,9 +254,10 @@ void ZephyrDataStore::factoryReset() * push contacts/channels onto internal flash. */ writeInitMarker(); LOG_INF("=== FACTORY RESET COMPLETE - REBOOT REQUIRED ==="); - } else { - LOG_ERR("=== FACTORY RESET FAILED ==="); + return true; } + LOG_ERR("=== FACTORY RESET FAILED ==="); + return false; } /* ── First-boot migration ──────────────────────────────────────────── */ @@ -443,7 +268,7 @@ static constexpr const char *ZC_INIT_MARKER = "/lfs/_zc_init"; bool ZephyrDataStore::hasInitMarker() const { - return exists(ZC_INIT_MARKER); + return zephcore_fs_exists(ZC_INIT_MARKER); } void ZephyrDataStore::writeInitMarker() @@ -457,7 +282,7 @@ void ZephyrDataStore::writeInitMarker() bool ZephyrDataStore::hasPrefs() const { - return exists(PREFS_FILE); + return zephcore_fs_exists(PREFS_JSON_FILE) || zephcore_fs_exists(PREFS_FILE); } /* Erase only the NVS (BLE bonds) partition — used when upgrading from @@ -481,15 +306,18 @@ void ZephyrDataStore::formatNVSOnly() #endif } -/* Returns true if the prefs file was written by Arduino MeshCore. - * Arduino's layout omits node_lat/node_lon (16 bytes inserted by ZephCore - * after node_name at offset 36), so freq/sf/bw land at the wrong offsets - * and produce values outside the physical RF ranges used as the signal. */ -bool ZephyrDataStore::prefsLookLikeArduino() const +/* True if the legacy prefs file's radio preset (freq/sf/bw at our offsets) is + * outside ZC_RADIO_*, i.e. the file is not ours. Upstream's new_prefs has + * shared our offsets up to 92 since 2025-02 (lat/lon included), so this is a + * plausibility test, not an Arduino detector. A prefs.json is always ours. */ +bool ZephyrDataStore::prefsRadioImplausible() const { + if (zephcore_fs_exists(PREFS_JSON_FILE)) { + return false; + } uint8_t buf[72]; size_t len = 0; - if (!openRead(PREFS_FILE, buf, sizeof(buf), len) || len < 68) { + if (!zephcore_fs_read_file(PREFS_FILE, buf, sizeof(buf), &len) || len < 68) { return false; } float freq, bw; @@ -497,9 +325,6 @@ bool ZephyrDataStore::prefsLookLikeArduino() const memcpy(&freq, &buf[56], sizeof(float)); sf = buf[60]; memcpy(&bw, &buf[64], sizeof(float)); - /* The Arduino misread lands at freq≈0 and sf≤1, so the sf test is what - * actually catches it — the frequency range can safely span both RF - * bands without weakening the detection. */ return (freq < ZC_RADIO_FREQ_MIN_MHZ || freq > ZC_RADIO_FREQ_MAX_MHZ || sf < 5 || sf > 12 || bw < ZC_RADIO_BW_MIN_KHZ || bw > ZC_RADIO_BW_MAX_KHZ); @@ -510,53 +335,51 @@ bool ZephyrDataStore::prefsLookLikeArduino() const * path; ≥1.16.2 moved to NVS. Presence means 0xD0000 has old app code. */ bool ZephyrDataStore::hasOldSettingsFile() const { - return exists("/lfs/settings"); + return zephcore_fs_exists("/lfs/settings"); +} + +void ZephyrDataStore::adoptVolume() +{ + /* /lfs/_zc_init is written after the first clean boot of ZephCore, so + * this runs once per volume. + * + * - No prefs, or prefs whose radio preset is implausible (a file in + * some other layout): format everything, bonds included. + * - Our prefs plus /lfs/settings: an upgrade from ZephCore <= 1.16.1 on + * nRF52, whose file-based bond store left old app code where the NVS + * bond partition now is. Bytes there can pass NVS sector validation + * and hang settings_load(), so BLE never advertises: erase the NVS + * only; identity, prefs and contacts stay, the phone re-pairs. + * (On nRF54L and MG24 /lfs/settings is the live bond store, but a + * volume that has booted this firmware once has the marker.) + * - Our prefs, no /lfs/settings: bonds already live in NVS, keep all. */ + if (hasInitMarker()) { + return; + } + if (!hasPrefs() || prefsRadioImplausible()) { + LOG_WRN("First ZephCore boot (%s) - formatting LFS + NVS", + hasPrefs() ? "foreign prefs" : "no prefs"); + formatFileSystem(); + begin(); + } else if (hasOldSettingsFile()) { + LOG_WRN("Pre-NVS ZephCore upgrade (found /lfs/settings) - erasing NVS"); + formatNVSOnly(); + } else { + LOG_INF("ZephCore upgrade with valid NVS - skipping format, bonds preserved"); + } + writeInitMarker(); } /* ── Identity ──────────────────────────────────────────────────────── */ bool ZephyrDataStore::loadMainIdentity(mesh::LocalIdentity &identity) { - uint8_t buf[PRV_KEY_SIZE + PUB_KEY_SIZE + 32]; - size_t len = 0; - if (!openRead(MAIN_ID_FILE, buf, sizeof(buf), len) || len < PRV_KEY_SIZE) { - return false; - } - if (identity.readFromStorage(buf, len)) { - return true; - } - if (identity.recoverFromStorage(buf, len)) { - /* Deliberately not re-persisted: the file is the only record of what - * went wrong, and re-deriving costs a few ms per boot. The repeated - * warning is the point — this state should be visible, not papered - * over silently. */ - LOG_WRN("main identity pub/prv mismatch - advertising the pub its private key owns"); - return true; - } - - /* No layout coheres — the stored pub is not prv·B under any reading, so - * the pair is unusable: adverts would verify nowhere, inbound DMs would - * not decrypt, and CMD_EXPORT_PRIVATE_KEY would hand the app a key that - * contradicts SELF_INFO. Returning false makes the caller generate a - * fresh identity, so park the bytes first rather than overwriting them — - * the private key may still be extractable by hand. */ - LOG_ERR("main identity incoherent (%d bytes) - keeping bytes at %s, regenerating", - (int)len, MAIN_ID_BAD_FILE); - fs_unlink(MAIN_ID_BAD_FILE); - if (fs_rename(MAIN_ID_FILE, MAIN_ID_BAD_FILE) < 0) { - LOG_ERR("failed to park corrupt identity - regenerating over it"); - } - return false; + return zephcore_identity_load(MAIN_ID_FILE, identity); } bool ZephyrDataStore::saveMainIdentity(const mesh::LocalIdentity &identity) { - uint8_t buf[PRV_KEY_SIZE + PUB_KEY_SIZE + 32]; - size_t n = identity.writeToStorage(buf, sizeof(buf)); - if (n == 0) { - return false; - } - return atomicReplaceFile(MAIN_ID_FILE, buf, n); + return zephcore_identity_save(MAIN_ID_FILE, identity); } /* ── Shutdown-reason breadcrumb ────────────────────────────────────── */ @@ -564,7 +387,7 @@ bool ZephyrDataStore::saveMainIdentity(const mesh::LocalIdentity &identity) void ZephyrDataStore::saveShutdownReason(uint8_t code) { /* Best-effort: called at a software power-off, possibly at low battery. */ - (void)atomicReplaceFile(SHUTDOWN_FILE, &code, 1); + (void)zephcore_fs_atomic_replace(SHUTDOWN_FILE, &code, 1, "shutdown marker"); } uint8_t ZephyrDataStore::takeShutdownReason() @@ -582,12 +405,12 @@ uint8_t ZephyrDataStore::takeShutdownReason() return 0; } - if (openRead(SHUTDOWN_FILE, &code, sizeof(code), len) && len >= 1) { - removeFile(SHUTDOWN_FILE); + if (zephcore_fs_read_file(SHUTDOWN_FILE, &code, sizeof(code), &len) && len >= 1) { + zephcore_fs_remove(SHUTDOWN_FILE); return code; } /* Stray/empty file — clear it so it can't linger. */ - removeFile(SHUTDOWN_FILE); + zephcore_fs_remove(SHUTDOWN_FILE); return 0; } @@ -595,441 +418,59 @@ uint8_t ZephyrDataStore::takeShutdownReason() void ZephyrDataStore::loadPrefs(NodePrefs &prefs) { - /* Save caller's defaults — restored if the file contains invalid radio - * params (e.g. an Arduino MeshCore new_prefs whose layout diverges from - * ZephCore at the freq/sf/bw offsets due to the inserted lat/lon fields). */ - NodePrefs saved_defaults = prefs; - - bool prefs_exists = exists(PREFS_FILE); - if (!prefs_exists) { - LOG_DBG("loadPrefs: no prefs file found, persisting defaults"); - /* Persist defaults so flash always has a prefs file from boot 1. - * Lets later code (e.g. tempradio revert) trust that flash is - * authoritative without a "first run" special case. */ - savePrefs(prefs); + if (zephcore_prefs_json_load(PREFS_JSON_FILE, prefs, companionPrefsFromJson)) { return; } + bool had_json = zephcore_fs_exists(PREFS_JSON_FILE); - uint8_t buf[256]; + /* No usable prefs.json: the legacy binary file, read once and migrated. + * It is kept, so firmware from before prefs.json still boots with the + * settings as they were at the upgrade. */ + if (zephcore_fs_exists(PREFS_FILE)) { + if (loadLegacyPrefs(prefs)) { + LOG_INF("loadPrefs: %s %s from %s", had_json ? "recovered" : "migrated", + PREFS_JSON_FILE, PREFS_FILE); + } + } else { + /* First boot: persist the defaults, so later code (e.g. tempradio + * revert) can trust flash without a "first run" special case. */ + LOG_DBG("loadPrefs: no prefs file found, persisting defaults"); + } + savePrefs(prefs); +} + +bool ZephyrDataStore::loadLegacyPrefs(NodePrefs &prefs) +{ + uint8_t buf[COMPANION_PREFS_SIZE + 48]; size_t len = 0; - if (!openRead(PREFS_FILE, buf, sizeof(buf), len)) { - LOG_ERR("loadPrefs: read failed"); - return; + + if (!zephcore_fs_read_file(PREFS_FILE, buf, sizeof(buf), &len)) { + LOG_ERR("loadPrefs: read of %s failed", PREFS_FILE); + return false; } if (len < 90) { - /* gps_interval occupies bytes 86-89, so a shorter blob would read - * its top bytes from uninitialized stack — reject rather than load a - * garbage interval. */ - LOG_ERR("loadPrefs: file too small (%d bytes, need 90)", (int)len); - return; + LOG_ERR("loadPrefs: %s too small (%d bytes, need 90)", PREFS_FILE, (int)len); + return false; } - LOG_DBG("loadPrefs: loaded %d bytes from %s", (int)len, PREFS_FILE); + if (!companionPrefsDecode(prefs, buf, len)) { + float freq, bw; - size_t off = 0; - memcpy(&prefs.airtime_factor, &buf[off], sizeof(float)); - off += 4; - memcpy(prefs.node_name, &buf[off], 32); - off += 36; /* 32 name + 4 pad */ - memcpy(&prefs.node_lat, &buf[off], sizeof(double)); - off += 8; - memcpy(&prefs.node_lon, &buf[off], sizeof(double)); - off += 8; - memcpy(&prefs.freq, &buf[off], sizeof(float)); - off += 4; - prefs.sf = buf[off++]; - prefs.cr = buf[off++]; - /* Offset 62: client_repeat (Arduino-compatible placement) */ - prefs.client_repeat = buf[off++]; - prefs.manual_add_contacts = buf[off++]; - memcpy(&prefs.bw, &buf[off], sizeof(float)); - off += 4; - - /* Sanity-check core radio params before consuming the rest of the file. - * An Arduino MeshCore new_prefs is layout-incompatible: ZephCore inserts - * node_lat (8) + node_lon (8) after node_name, shifting freq/sf/bw by - * +16 bytes. The misread values are freq≈0, sf≤1, bw=garbage — all - * outside the physical RF ranges below. Revert to the caller's defaults - * so the radio starts on the correct channel and the user can pair via - * BLE and reconfigure. */ - if (prefs.freq < ZC_RADIO_FREQ_MIN_MHZ || prefs.freq > ZC_RADIO_FREQ_MAX_MHZ || - prefs.sf < 5 || prefs.sf > 12 || - prefs.bw < ZC_RADIO_BW_MIN_KHZ || prefs.bw > ZC_RADIO_BW_MAX_KHZ) { - LOG_WRN("loadPrefs: radio params out of range " - "(freq=%.1f sf=%d bw=%.1f) — ignoring prefs (incompatible format?)", - (double)prefs.freq, (int)prefs.sf, (double)prefs.bw); - prefs = saved_defaults; - return; + memcpy(&freq, &buf[56], sizeof(freq)); + memcpy(&bw, &buf[64], sizeof(bw)); + LOG_WRN("loadPrefs: radio params out of range (freq=%.1f sf=%d bw=%.1f) - " + "ignoring %s (incompatible format?)", + (double)freq, (int)buf[60], (double)bw, PREFS_FILE); + return false; } - - prefs.tx_power_dbm = buf[off++]; - prefs.telemetry_mode_base = buf[off++]; - prefs.telemetry_mode_loc = buf[off++]; - prefs.telemetry_mode_env = buf[off++]; - memcpy(&prefs.rx_delay_base, &buf[off], sizeof(float)); - off += 4; - prefs.advert_loc_policy = buf[off++]; - prefs.multi_acks = buf[off++]; - /* Offset 78: path_hash_mode (Arduino treats as pad — harmless) */ - prefs.path_hash_mode = buf[off++]; - off += 1; /* pad */ - memcpy(&prefs.ble_pin, &buf[off], sizeof(uint32_t)); - off += 4; - prefs.buzzer_quiet = buf[off++]; - prefs.gps_enabled = buf[off++]; - memcpy(&prefs.gps_interval, &buf[off], sizeof(uint32_t)); - off += 4; - prefs.autoadd_config = buf[off++]; - - /* Offset 91: autoadd_max_hops (matches Arduino layout) */ - if (off < len) { - prefs.autoadd_max_hops = buf[off++]; - } - - /* Offset 92: rx_boost (ZephCore extension — Arduino stops at 92 bytes) */ - if (off < len) { - prefs.rx_boost = buf[off++]; - } else { - prefs.rx_boost = 1; /* Default to boosted for better sensitivity */ - } - - /* Offset 93: leds_disabled (ZephCore extension) */ - if (off < len) { - prefs.leds_disabled = buf[off++]; - } else { - prefs.leds_disabled = 0; /* Default: LEDs on */ - } - - /* Offsets 94-95: RESERVED — formerly apc_enabled / apc_margin (APC, - * removed in 1.16.6). Still consumed so offset 96 onward keeps landing - * where already-deployed nodes wrote it. Values are ignored. */ - if (off < len) { - prefs._reserved_apc_enabled = buf[off++]; - } - if (off < len) { - prefs._reserved_apc_margin = buf[off++]; - } - - /* Offset 96: default_scope_name (31 bytes) — v11 FIRMWARE_VER_CODE */ - if (off + 31 <= len) { - memcpy(prefs.default_scope_name, &buf[off], 31); - off += 31; - } else { - memset(prefs.default_scope_name, 0, sizeof(prefs.default_scope_name)); - } - - /* Offset 127: default_scope_key (16 bytes) */ - if (off + 16 <= len) { - memcpy(prefs.default_scope_key, &buf[off], 16); - off += 16; - } else { - memset(prefs.default_scope_key, 0, sizeof(prefs.default_scope_key)); - } - - /* Offset 143: ble_disabled (ZephCore extension) */ - if (off < len) { - prefs.ble_disabled = buf[off++]; - } else { - prefs.ble_disabled = 0; - } - - /* Offset 144: display_brightness (ZephCore extension, 0 = default 100%) */ - if (off < len) { - prefs.display_brightness = buf[off++]; - } else { - prefs.display_brightness = 0; - } - - /* Offset 145: wake_on_msg (ZephCore extension, 0 = don't wake, 1 = wake on message) */ - if (off < len) { - prefs.wake_on_msg = buf[off++]; - } else { - prefs.wake_on_msg = 1; - } - - /* Offset 146: screen_off_secs (ZephCore extension, 2 bytes LE, 0 = Kconfig default) */ - if (off + 2 <= len) { - prefs.screen_off_secs = (uint16_t)buf[off] | ((uint16_t)buf[off + 1] << 8); - off += 2; - } else { - prefs.screen_off_secs = 0; - } - - /* Offset 148: auto_shutdown_mv (ZephCore extension, 2 bytes LE). - * Absent in pre-existing files → fall back to the Kconfig default so - * upgrades inherit the board's built-in threshold. */ - if (off + 2 <= len) { - prefs.auto_shutdown_mv = (uint16_t)buf[off] | ((uint16_t)buf[off + 1] << 8); - off += 2; - } else { - prefs.auto_shutdown_mv = CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS; - } - - /* Offset 150: rx_duty_cycle (ZephCore extension). Absent in pre-existing - * files → leave the caller's in-RAM default (0 = continuous RX) untouched - * so the no-op past-EOF read can't force it on. */ - if (off < len) { - prefs.rx_duty_cycle = buf[off++]; - if (prefs.rx_duty_cycle > 1) { - prefs.rx_duty_cycle = 0; - } - } - - /* Offset 151: meshtimesync (ZephCore extension, default 0 = off) */ - if (off < len) { - prefs.meshtimesync = buf[off++]; - if (prefs.meshtimesync > 1) { - prefs.meshtimesync = 0; - } - } - - /* Offset 152: v_contact_enabled (ZephCore extension, default 1 = on) */ - if (off < len) { - prefs.v_contact_enabled = buf[off++]; - if (prefs.v_contact_enabled > 1) { - prefs.v_contact_enabled = 1; - } - } else { - prefs.v_contact_enabled = 1; - } - - /* Offset 153: v_battery_alert_mv (ZephCore extension, 2 bytes LE). - * 0xFFFF sentinel = derive from board auto-shutdown threshold; 0 = off. */ - if (off + 2 <= len) { - prefs.v_battery_alert_mv = (uint16_t)buf[off] | ((uint16_t)buf[off + 1] << 8); - off += 2; - } else { - prefs.v_battery_alert_mv = 0xFFFF; - } - - /* Offset 155: cad_auto (ZephCore extension, default 0 = dry-run) */ - if (off < len) { - prefs.cad_auto = buf[off++]; - if (prefs.cad_auto > 1) { - prefs.cad_auto = 0; - } - } - - /* Offset 156: cad_offset (ZephCore extension, signed, default 0) */ - if (off < len) { - prefs.cad_offset = (int8_t)buf[off++]; - if (prefs.cad_offset < CAD_OFFSET_MIN || prefs.cad_offset > CAD_OFFSET_MAX) { - prefs.cad_offset = 0; - } - } - - /* Offset 157: probe_interval (ZephCore extension, seconds; 0 = off). - * Absent in pre-existing files → keep the in-RAM default (60). */ - if (off < len) { - prefs.probe_interval = buf[off++]; - if (prefs.probe_interval != 0 && prefs.probe_interval < 10) { - prefs.probe_interval = 10; - } - } - - /* Offset 158: cad_busycap (ZephCore extension, percent; 0 = off). - * Absent in pre-existing files → keep the in-RAM default (25). */ - if (off < len) { - prefs.cad_busycap = buf[off++]; - if (prefs.cad_busycap > 90) { - prefs.cad_busycap = 90; - } - } - - /* Offset 159: adc_multiplier (ZephCore extension, float LE, 0 = board - * DT default). Absent in pre-existing files → keep 0.0 so the DT - * default stays in effect. Same range guard as the repeater CLI path - * (CommonCLI constrains 0..30000); NaN/garbage resets to default. */ - if (off + 4 <= len) { - memcpy(&prefs.adc_multiplier, &buf[off], sizeof(float)); - off += 4; - if (prefs.adc_multiplier != prefs.adc_multiplier || - prefs.adc_multiplier < 0.0f || prefs.adc_multiplier > 30000.0f) { - prefs.adc_multiplier = 0.0f; - } - } - - /* Offset 163-165: extra_sf (ZephCore extension, LR2021 side detectors). - * Absent in pre-existing files → stays zeroed = feature off. Values - * are re-validated by the driver when applied, so a corrupt byte here - * costs a rejected config, not a bad radio state. */ - for (int i = 0; i < EXTRA_SF_MAX && off < len; i++) { - prefs.extra_sf[i] = buf[off++]; - } - - /* Offset 166: v_contact_flags (ZephCore extension). Absent in pre-existing - * files → stays 0, which is exactly the old behaviour (no favourite, no - * telemetry permissions). */ - if (off < len) { - prefs.v_contact_flags = buf[off++]; - } - - /* Offset 167: fem_rxgain (ZephCore extension). Absent in pre-existing - * files → keeps the caller's initNodePrefs() default of 1, which is the - * behaviour every deployed node already has (the FEM's chip-enable has - * always been asserted for RX). */ - if (off < len) { - prefs.fem_rxgain = buf[off++]; - } - - /* Offset 168-169: display_rotate / input_rotate (ZephCore extension). - * Absent in pre-existing files → both stay at the initNodePrefs() default - * of 0, i.e. the stock mounting orientation every deployed node runs. */ - if (off < len) { - prefs.display_rotate = buf[off++]; - } - if (off < len) { - prefs.input_rotate = buf[off++]; - } - - /* Offset 170: cad_base (ZephCore extension). Absent in pre-existing - * files → stays 0, which setCadParams() reads as "no base recorded" and - * leaves the stored offset exactly where it is. That is the right - * migration: a node upgrading across the table change has no way to know - * which base its offset was learned against, so re-anchoring it would be - * guessing, and the staircase re-converges on its own either way. */ - if (off < len) { - prefs.cad_base = buf[off++]; - } - - /* Offset 171: tz_offset (ZephCore extension, signed whole hours). - * Absent in pre-existing files → stays 0 = UTC, which is exactly what - * every already-deployed node displays today. Range is re-checked by - * sanitizeNodePrefs() below. */ - if (off < len) { - prefs.tz_offset = (int8_t)buf[off++]; - } - - /* Offset 172-173: leds_radio_mode / leds_hb_mode (ZephCore extension). - * Absent in pre-existing files → both stay at the initNodePrefs() defaults - * of 0, and 0 is deliberately the behaviour every already-deployed node has - * (activity LED on transmit, heartbeat with unread indication). Range is - * re-checked by sanitizeNodePrefs() below. */ - if (off < len) { - prefs.leds_radio_mode = buf[off++]; - } - if (off < len) { - prefs.leds_hb_mode = buf[off++]; - } - - sanitizeNodePrefs(&prefs); + return true; } void ZephyrDataStore::savePrefs(const NodePrefs &prefs) { - uint8_t buf[256]; - uint8_t pad[8] = {0}; - size_t off = 0; - memcpy(&buf[off], &prefs.airtime_factor, sizeof(float)); - off += 4; - memcpy(&buf[off], prefs.node_name, 32); - off += 32; - memcpy(&buf[off], pad, 4); - off += 4; - memcpy(&buf[off], &prefs.node_lat, sizeof(double)); - off += 8; - memcpy(&buf[off], &prefs.node_lon, sizeof(double)); - off += 8; - memcpy(&buf[off], &prefs.freq, sizeof(float)); - off += 4; - buf[off++] = prefs.sf; - buf[off++] = prefs.cr; - /* Offset 62: client_repeat (Arduino-compatible placement) */ - buf[off++] = prefs.client_repeat; - buf[off++] = prefs.manual_add_contacts; - memcpy(&buf[off], &prefs.bw, sizeof(float)); - off += 4; - buf[off++] = prefs.tx_power_dbm; - buf[off++] = prefs.telemetry_mode_base; - buf[off++] = prefs.telemetry_mode_loc; - buf[off++] = prefs.telemetry_mode_env; - memcpy(&buf[off], &prefs.rx_delay_base, sizeof(float)); - off += 4; - buf[off++] = prefs.advert_loc_policy; - buf[off++] = prefs.multi_acks; - /* Offset 78: path_hash_mode (Arduino treats as pad — harmless) */ - buf[off++] = prefs.path_hash_mode; - buf[off++] = 0; /* pad */ - memcpy(&buf[off], &prefs.ble_pin, sizeof(uint32_t)); - off += 4; - buf[off++] = prefs.buzzer_quiet; - buf[off++] = prefs.gps_enabled; - memcpy(&buf[off], &prefs.gps_interval, sizeof(uint32_t)); - off += 4; - buf[off++] = prefs.autoadd_config; - /* Offset 91: autoadd_max_hops (matches Arduino layout) */ - buf[off++] = prefs.autoadd_max_hops; - /* Offset 92: rx_boost (ZephCore extension — Arduino ignores) */ - buf[off++] = prefs.rx_boost; - /* Offset 93: leds_disabled (ZephCore extension) */ - buf[off++] = prefs.leds_disabled; - /* Offsets 94-95: RESERVED — formerly apc_enabled / apc_margin (removed - * in 1.16.6). Written back unchanged to hold the layout. */ - buf[off++] = prefs._reserved_apc_enabled; - buf[off++] = prefs._reserved_apc_margin; - /* Offset 96: default_scope_name (31 bytes) — v11 FIRMWARE_VER_CODE */ - memcpy(&buf[off], prefs.default_scope_name, 31); - off += 31; - /* Offset 127: default_scope_key (16 bytes) */ - memcpy(&buf[off], prefs.default_scope_key, 16); - off += 16; - /* Offset 143: ble_disabled (ZephCore extension) */ - buf[off++] = prefs.ble_disabled; - /* Offset 144: display_brightness (ZephCore extension) */ - buf[off++] = prefs.display_brightness; - /* Offset 145: wake_on_msg (ZephCore extension) */ - buf[off++] = prefs.wake_on_msg; - /* Offset 146: screen_off_secs (ZephCore extension, 2 bytes LE) */ - buf[off++] = prefs.screen_off_secs & 0xFF; - buf[off++] = (prefs.screen_off_secs >> 8) & 0xFF; - /* Offset 148: auto_shutdown_mv (ZephCore extension, 2 bytes LE) */ - buf[off++] = prefs.auto_shutdown_mv & 0xFF; - buf[off++] = (prefs.auto_shutdown_mv >> 8) & 0xFF; - /* Offset 150: rx_duty_cycle (ZephCore extension) */ - buf[off++] = prefs.rx_duty_cycle; - /* Offset 151: meshtimesync (ZephCore extension) */ - buf[off++] = prefs.meshtimesync; - /* Offset 152: v_contact_enabled (ZephCore extension) */ - buf[off++] = prefs.v_contact_enabled; - /* Offset 153: v_battery_alert_mv (ZephCore extension, 2 bytes LE) */ - buf[off++] = prefs.v_battery_alert_mv & 0xFF; - buf[off++] = (prefs.v_battery_alert_mv >> 8) & 0xFF; - /* Offset 155: cad_auto (ZephCore extension) */ - buf[off++] = prefs.cad_auto; - /* Offset 156: cad_offset (ZephCore extension, signed) */ - buf[off++] = (uint8_t)prefs.cad_offset; - /* Offset 157: probe_interval (ZephCore extension, seconds) */ - buf[off++] = prefs.probe_interval; - /* Offset 158: cad_busycap (ZephCore extension, percent) */ - buf[off++] = prefs.cad_busycap; - /* Offset 159: adc_multiplier (ZephCore extension, float LE, 0 = board - * DT default). Was applied at boot but never serialized before this - * field existed — battery calibration silently reset every reboot. */ - memcpy(&buf[off], &prefs.adc_multiplier, sizeof(float)); - off += 4; - /* Offset 163-165: extra_sf (ZephCore extension, LR2021 side detectors) */ - memcpy(&buf[off], prefs.extra_sf, EXTRA_SF_MAX); - off += EXTRA_SF_MAX; - /* Offset 166: v_contact_flags (ZephCore extension) */ - buf[off++] = prefs.v_contact_flags; - /* Offset 167: fem_rxgain (ZephCore extension, external FEM LNA in RX) */ - buf[off++] = prefs.fem_rxgain; - /* Offset 168-169: display_rotate / input_rotate (ZephCore extension) */ - buf[off++] = prefs.display_rotate; - buf[off++] = prefs.input_rotate; - /* Offset 170: cad_base (ZephCore extension, family base detPeak the - * cad_offset above was learned against) */ - buf[off++] = prefs.cad_base; - /* Offset 171: tz_offset (ZephCore extension, signed whole hours from UTC, - * display only — the stored clock is always UTC) */ - buf[off++] = (uint8_t)prefs.tz_offset; - /* Offset 172-173: leds_radio_mode / leds_hb_mode (ZephCore extension). */ - buf[off++] = prefs.leds_radio_mode; - buf[off++] = prefs.leds_hb_mode; - /* Total: 174 bytes */ + bool ok = zephcore_prefs_json_save(PREFS_JSON_FILE, prefs, companionPrefsToJson); - bool ok = atomicReplaceFile(PREFS_FILE, buf, off); - LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'", - PREFS_FILE, ok ? 1 : 0, (int)off, prefs.node_name); + LOG_DBG("savePrefs: wrote %s, ok=%d, name='%.16s'", PREFS_JSON_FILE, ok ? 1 : 0, + prefs.node_name); } /* ── Contacts: contacts3 (152B records, Arduino-compatible) ────────── */ @@ -1085,7 +526,7 @@ void ZephyrDataStore::loadContacts(DataStoreHost *host) * fs_open() on a missing path is logged at ERR by Zephyr's FS layer no * matter how gracefully we handle the return. The open below is kept as * the real error path (a file that exists but cannot be opened). */ - if (!exists(path)) { + if (!zephcore_fs_exists(path)) { LOG_DBG("loadContacts: no contacts file found"); return; } @@ -1127,7 +568,7 @@ void ZephyrDataStore::saveContacts(DataStoreHost *host) /* Atomic replace ONLY where there is external flash — deliberately, and * not to be "fixed" later. * - * atomicWriteTempFile() needs room for a second full copy before the + * zephcore_fs_atomic_write() needs room for a second full copy before the * rename. contacts3 is by far the largest store here (152 B per record, * ~47 KB at 313 contacts) and internal /lfs on these boards is 128 KB * total, shared with identity, prefs and channels2. Two copies would sit @@ -1178,7 +619,7 @@ void ZephyrDataStore::saveContacts(DataStoreHost *host) ContactsWriterCtx *cctx = static_cast(arg); return (*cctx->fn)(dst); }; - write_ok = atomicWriteTempFile(path, atomic_contacts_writer, &ctx, "saveContacts"); + write_ok = zephcore_fs_atomic_write(path, atomic_contacts_writer, &ctx, "saveContacts"); } else { /* Overwrite in place, then truncate — never unlink first. * @@ -1242,7 +683,7 @@ void ZephyrDataStore::loadChannels(DataStoreHost *host) /* Probe first — same reason as loadContacts(): absent until a channel is * configured, and a missing-path fs_open() is logged at ERR by the FS * layer regardless of us handling it. */ - if (!exists(path)) { + if (!zephcore_fs_exists(path)) { return; } @@ -1300,7 +741,7 @@ void ZephyrDataStore::saveChannels(DataStoreHost *host) } return true; }; - if (!atomicWriteTempFile(path, channels_writer, &ctx, "saveChannels")) { + if (!zephcore_fs_atomic_write(path, channels_writer, &ctx, "saveChannels")) { return; } LOG_INF("saveChannels: saved %u channels to %s", channel_idx, path); diff --git a/zephcore/adapters/datastore/ZephyrDataStore.h b/zephcore/adapters/datastore/ZephyrDataStore.h index 8d8ef5f..0a84169 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.h +++ b/zephcore/adapters/datastore/ZephyrDataStore.h @@ -50,20 +50,18 @@ public: uint32_t getStorageUsedKb() const; uint32_t getStorageTotalKb() const; - /* Factory reset - delete all stored data */ - void factoryReset(); + /* Factory reset: erase every storage region, keep the volume marked as + * ours so the next boot does not format it again. */ + bool factoryReset(); + + /* First boot of ZephCore on this volume: format a foreign one, repair + * an old bond store. Must run before bt_enable(). */ + void adoptVolume(); /* Check if external QSPI flash is available */ bool hasExternalStorage() const { return _has_ext_fs; } uint32_t getExternalStorageKb() const; - /* First-boot migration helpers — see formatNVSOnly() in .cpp */ - bool hasInitMarker() const; - void writeInitMarker(); - void formatNVSOnly(); - bool hasPrefs() const; - bool prefsLookLikeArduino() const; - bool hasOldSettingsFile() const; static bool mount(); static void unmount(); @@ -73,11 +71,11 @@ public: private: /* Internal flash (always available) - identity, prefs */ static constexpr const char *MNT_POINT = "/lfs"; + static constexpr const char *PREFS_JSON_FILE = "/lfs/prefs.json"; + /* Legacy binary prefs (PrefsCodec): read once to migrate, then kept for + * firmware from before prefs.json. */ static constexpr const char *PREFS_FILE = "/lfs/new_prefs"; static constexpr const char *MAIN_ID_FILE = "/lfs/_main.id"; - /* Where an identity that parses under no known layout is parked before - * a fresh one is generated over the top — see loadMainIdentity(). */ - static constexpr const char *MAIN_ID_BAD_FILE = "/lfs/_main.id.bad"; static constexpr const char *SHUTDOWN_FILE = "/lfs/shutdn"; /* External QSPI flash (optional) - contacts, channels, blobs */ @@ -101,10 +99,13 @@ private: int maxBlobRecs() const { return _has_ext_fs ? 100 : 20; } void checkAdvBlobFile(); + bool loadLegacyPrefs(NodePrefs &prefs); + bool hasInitMarker() const; + void writeInitMarker(); + void formatNVSOnly(); + bool hasPrefs() const; + bool prefsRadioImplausible() const; + bool hasOldSettingsFile() const; void migrateToExternalFS(); - bool openRead(const char *path, uint8_t *buf, size_t buf_sz, size_t &out_len) const; - bool atomicReplaceFile(const char *path, const uint8_t *buf, size_t len); - bool exists(const char *path) const; - bool removeFile(const char *path); bool copyFile(const char *src, const char *dst); }; diff --git a/zephcore/adapters/datastore/ZephyrFsFormat.c b/zephcore/adapters/datastore/ZephyrFsFormat.c index 67996f9..80329ef 100644 --- a/zephcore/adapters/datastore/ZephyrFsFormat.c +++ b/zephcore/adapters/datastore/ZephyrFsFormat.c @@ -4,6 +4,7 @@ */ #include "ZephyrFsFormat.h" +#include "ZephyrFsUtil.h" #include #include @@ -18,13 +19,6 @@ LOG_MODULE_REGISTER(zephcore_fs_format, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); /* fs_mount() can return 0 having mounted nothing useful, and a remount that * silently failed would leave the caller reporting a healthy store over an * unmounted volume. Ask the VFS instead of trusting the return code. */ -static bool is_mounted(const char *mount_point) -{ - struct fs_statvfs stat; - - return fs_statvfs(mount_point, &stat) == 0; -} - static void flatten(uint8_t id, const char *tag) { const struct flash_area *fap; @@ -82,7 +76,7 @@ bool zephcore_fs_format_all(bool *out_ext_mounted) /* Remount: littlefs_mount() auto-formats blank flash, then mounts. */ int rc = fs_mount(&FS_FSTAB_ENTRY(DT_NODELABEL(lfs))); - bool mounted = is_mounted(LFS_MNT_POINT); + bool mounted = zephcore_fs_is_mounted(LFS_MNT_POINT); if (mounted) { LOG_INF("format: %s remounted (rc=%d)", LFS_MNT_POINT, rc); @@ -100,7 +94,7 @@ bool zephcore_fs_format_all(bool *out_ext_mounted) * /ext on the next boot ("Migrating contacts to external storage"). */ { int ext_rc = fs_mount(&FS_FSTAB_ENTRY(DT_NODELABEL(qspi_lfs))); - bool ext_mounted = is_mounted(EXT_MNT_POINT); + bool ext_mounted = zephcore_fs_is_mounted(EXT_MNT_POINT); if (ext_mounted) { LOG_INF("format: %s remounted (rc=%d)", EXT_MNT_POINT, ext_rc); diff --git a/zephcore/adapters/datastore/ZephyrFsUtil.c b/zephcore/adapters/datastore/ZephyrFsUtil.c new file mode 100644 index 0000000..07ab143 --- /dev/null +++ b/zephcore/adapters/datastore/ZephyrFsUtil.c @@ -0,0 +1,130 @@ +/* + * SPDX-License-Identifier: MIT + * ZephyrFsUtil - small filesystem helpers shared by every role's store. + */ + +#include "ZephyrFsUtil.h" + +#include +#include +#include + +LOG_MODULE_REGISTER(zephcore_fs, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); + +bool zephcore_fs_is_mounted(const char *mount_point) +{ + /* fs_readmount() is a pure lookup. fs_statvfs() would log "mount point + * not found" at ERR for an absent /ext on every boot of a board without + * external flash, and before a deferred-init QSPI is mounted. */ + int index = 0; + const char *name = NULL; + + while (fs_readmount(&index, &name) == 0) { + if (name != NULL && strcmp(name, mount_point) == 0) { + return true; + } + } + return false; +} + +bool zephcore_fs_exists(const char *path) +{ + struct fs_dirent ent; + + return fs_stat(path, &ent) == 0; +} + +bool zephcore_fs_remove(const char *path) +{ + if (!zephcore_fs_exists(path)) { + return true; + } + return fs_unlink(path) == 0; +} + +bool zephcore_fs_read_file(const char *path, uint8_t *buf, size_t cap, size_t *out_len) +{ + struct fs_file_t file; + + fs_file_t_init(&file); + if (fs_open(&file, path, FS_O_READ) < 0) { + return false; + } + ssize_t n = fs_read(&file, buf, cap); + + fs_close(&file); + if (n < 0) { + return false; + } + *out_len = (size_t)n; + return true; +} + +bool zephcore_fs_atomic_write(const char *path, zephcore_fs_writer_t writer, void *ctx, + const char *tag) +{ + char tmp_path[64]; + int pl = snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path); + + if (pl <= 0 || pl >= (int)sizeof(tmp_path)) { + LOG_ERR("%s: path too long", tag); + return false; + } + + /* A temp left by an interrupted write */ + zephcore_fs_remove(tmp_path); + + struct fs_file_t file; + + fs_file_t_init(&file); + int rc = fs_open(&file, tmp_path, FS_O_CREATE | FS_O_WRITE); + + if (rc < 0) { + LOG_ERR("%s: open %s failed: %d", tag, tmp_path, rc); + return false; + } + + bool write_ok = writer(&file, ctx); + int sync_rc = fs_sync(&file); + + fs_close(&file); + if (!write_ok || sync_rc < 0) { + if (!write_ok) { + LOG_ERR("%s: write failed", tag); + } + if (sync_rc < 0) { + LOG_ERR("%s: sync failed: %d", tag, sync_rc); + } + fs_unlink(tmp_path); + return false; + } + + rc = fs_rename(tmp_path, path); + if (rc < 0) { + LOG_ERR("%s: rename %s -> %s failed: %d", tag, tmp_path, path, rc); + fs_unlink(tmp_path); + return false; + } + return true; +} + +struct replace_ctx { + const uint8_t *buf; + size_t len; +}; + +static bool replace_writer(struct fs_file_t *file, void *ctx) +{ + const struct replace_ctx *c = ctx; + ssize_t n = fs_write(file, c->buf, c->len); + + return n >= 0 && (size_t)n == c->len; +} + +bool zephcore_fs_atomic_replace(const char *path, const uint8_t *buf, size_t len, + const char *tag) +{ + struct replace_ctx c = { .buf = buf, .len = len }; + + return zephcore_fs_atomic_write(path, replace_writer, &c, tag); +} diff --git a/zephcore/adapters/datastore/ZephyrFsUtil.h b/zephcore/adapters/datastore/ZephyrFsUtil.h new file mode 100644 index 0000000..2b0454f --- /dev/null +++ b/zephcore/adapters/datastore/ZephyrFsUtil.h @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: MIT + * ZephyrFsUtil - small filesystem helpers shared by every role's store. + * + * One implementation of the power-safe replace (temp file, sync, rename) and + * of the probes that must not log. Zephyr's fs layer logs at ERR level for a + * missing path (fs_open, fs_unlink) and an unknown mount point (fs_statvfs), + * so every probe here checks first: an line on the happy path of each + * boot or save hides the real filesystem errors. + */ + +#pragma once + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Mount-list lookup; never logs. */ +bool zephcore_fs_is_mounted(const char *mount_point); + +bool zephcore_fs_exists(const char *path); + +/* Idempotent: an absent file is a successful removal. */ +bool zephcore_fs_remove(const char *path); + +/* Read up to cap bytes of path. False if the file is absent or unreadable. */ +bool zephcore_fs_read_file(const char *path, uint8_t *buf, size_t cap, size_t *out_len); + +/* Write path through ".tmp": writer fills the open temp file, then it is + * synced and renamed over path. On any failure (writer false, sync, rename) + * the temp is removed and path is left as it was. tag prefixes the log. */ +typedef bool (*zephcore_fs_writer_t)(struct fs_file_t *file, void *ctx); +bool zephcore_fs_atomic_write(const char *path, zephcore_fs_writer_t writer, void *ctx, + const char *tag); + +/* zephcore_fs_atomic_write() of one buffer; every byte must be written. */ +bool zephcore_fs_atomic_replace(const char *path, const uint8_t *buf, size_t len, + const char *tag); + +#ifdef __cplusplus +} +#endif diff --git a/zephcore/adapters/transport/CompanionInterfaces.h b/zephcore/adapters/transport/CompanionInterfaces.h new file mode 100644 index 0000000..e5cf909 --- /dev/null +++ b/zephcore/adapters/transport/CompanionInterfaces.h @@ -0,0 +1,77 @@ +/* + * SPDX-License-Identifier: MIT + * The companion transports as upstream BaseSerialInterfaces, for one + * MultiSerialInterface (upstream's companion_radio/main.cpp shape): BLE, the + * wired link (USB CDC or UART) and TCP (native Linux, WiFi). + * + * Each is a thin wrapper over its C transport. txIdle() is ours: the + * reboot-class CLI commands wait for every transport's TX to drain. + */ + +#pragma once + +#include +#include "companion_framing.h" + +/* The wired transport (ZephyrCompanionUSB) is compiled when logging needs the + * CDC console (debug builds), the native-USB companion is enabled, or the + * plain-UART companion is. MUST match the CMake condition that compiles + * ZephyrCompanionUSB.cpp. */ +#define ZEPHCORE_USB_STACK \ + (IS_ENABLED(CONFIG_LOG) || IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_USB) || \ + IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_SERIAL)) + +#if IS_ENABLED(CONFIG_BT) +#include + +class ZephyrBLEInterface : public BaseSerialInterface { +public: + void enable() override { zephcore_ble_set_enabled(true); } + void disable() override { zephcore_ble_set_enabled(false); } + bool isEnabled() const override { return zephcore_ble_is_enabled(); } + bool isConnected() const override { return zephcore_ble_is_active(); } + bool isWriteBusy() const override { return zephcore_ble_is_write_busy(); } + size_t writeFrame(const uint8_t src[], size_t len) override { + return zephcore_ble_send(src, (uint16_t)len); + } + size_t checkRecvFrame(uint8_t dest[]) override { return zephcore_ble_recv(dest); } + bool txIdle() const { return zephcore_ble_tx_idle(); } +}; +#endif + +#if ZEPHCORE_USB_STACK +#include + +/* Always enabled: the port is there whether or not a host has opened it. */ +class ZephyrSerialInterface : public BaseSerialInterface { +public: + void enable() override {} + void disable() override {} + bool isEnabled() const override { return true; } + bool isConnected() const override { return zephcore_usb_companion_is_connected(); } + bool isWriteBusy() const override { return zephcore_usb_companion_is_write_busy(); } + size_t writeFrame(const uint8_t src[], size_t len) override { + return zephcore_usb_companion_write_frame(src, len); + } + size_t checkRecvFrame(uint8_t dest[]) override { return zephcore_usb_companion_recv(dest); } + bool txIdle() const { return zephcore_usb_companion_tx_idle(); } +}; +#endif + +#if IS_ENABLED(CONFIG_ZEPHCORE_TRANSPORT_TCP) +#include "TcpCompanionTransport.h" + +class ZephyrTcpInterface : public BaseSerialInterface { +public: + void enable() override { tcp_companion_set_enabled(true); } + void disable() override { tcp_companion_set_enabled(false); } + bool isEnabled() const override { return tcp_companion_is_enabled(); } + bool isConnected() const override { return tcp_companion_is_connected(); } + bool isWriteBusy() const override { return tcp_companion_is_write_busy(); } + size_t writeFrame(const uint8_t src[], size_t len) override { + return tcp_companion_send(src, (uint16_t)len); + } + size_t checkRecvFrame(uint8_t dest[]) override { return tcp_companion_recv(dest); } + bool txIdle() const { return tcp_companion_tx_idle(); } +}; +#endif diff --git a/zephcore/adapters/transport/LinuxTCPTransport.c b/zephcore/adapters/transport/LinuxTCPTransport.c deleted file mode 100644 index 194408d..0000000 --- a/zephcore/adapters/transport/LinuxTCPTransport.c +++ /dev/null @@ -1,611 +0,0 @@ -/* - * SPDX-License-Identifier: MIT - * ZephCore TCP companion transport — drop-in replacement for ZephyrBLE. - * - * Implements the exact zephcore_ble_* C API from adapters/ble/ZephyrBLE.h - * over a TCP socket. - * - * Wire format: MeshCore SerialWifiInterface framing (ESP32 Arduino reference - * implementation: src/helpers/esp32/SerialWifiInterface.cpp): - * - * App → Node: [ '<' (0x3C) | length_LSB | length_MSB | payload... ] - * Node → App: [ '>' (0x3E) | length_LSB | length_MSB | payload... ] - * - * 3-byte header: 1-byte direction marker + 2-byte LE payload length. - * Frames with type != '<' are silently skipped (matches Arduino behavior). - * - * A single client at a time is supported (one BT_MAX_CONN=1 analogue). - * - * Architecture: - * - Listen thread accepts a client, then loops reading frames and - * firing on_rx_frame (which queues to ble_recv_queue via the callback). - * - TX is a work-queue item driven by zephcore_ble_kick_tx(), draining - * ble_send_queue with zsock_send() until empty or the socket dies. - * - * Used only when CONFIG_ZEPHCORE_TRANSPORT_TCP=y and CONFIG_BT=n - * (the linux_common.conf preset turns CONFIG_BT off). - */ - -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -#include "ZephyrBLE.h" -#include "companion_framing.h" - -LOG_MODULE_REGISTER(linux_tcp_transport, LOG_LEVEL_INF); - -#define FRAME_QUEUE_SIZE CONFIG_ZEPHCORE_BLE_QUEUE_SIZE -#define LISTEN_PORT CONFIG_ZEPHCORE_LINUX_TCP_PORT -#define LISTEN_BACKLOG 1 - -/* TX congestion control (mirrors ZephyrBLE.cpp). Lossy (>= 0x80) vs lossless - * (< 0x80) classification is shared via companion_is_lossless_protocol_frame() - * in companion_framing.h. */ -#define TX_OVERFLOW_RETRY_MS 250 - -/* Bound on a single blocking send. tx_drain_work_fn runs on the system - * workqueue, so an unbounded zsock_send() against a peer that has stopped - * reading would hang the whole queue (incl. overflow_retry_work). A peer - * that can't accept one frame within this window is wedged — close it and - * let the app reconnect rather than stall the node. */ -#define TX_SEND_TIMEOUT_SEC 2 - -/* struct frame + framing constants + lossless classification are shared via - * companion_framing.h (included above). */ - -K_MSGQ_DEFINE(ble_send_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); -K_MSGQ_DEFINE(ble_recv_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); - -static const struct ble_callbacks *transport_cbs; -static enum zephcore_iface active_iface = ZEPHCORE_IFACE_NONE; - -static bool transport_enabled; -static uint32_t passkey; - -/* Socket state */ -static int listen_fd = -1; -static int client_fd = -1; -static struct k_mutex sock_mu; - -/* Listen + RX thread */ -static K_KERNEL_STACK_DEFINE(listen_thread_stack, 4096); -static struct k_thread listen_thread; -static bool listen_thread_started; - -/* TX work — drains ble_send_queue */ -static void tx_drain_work_fn(struct k_work *work); -static K_WORK_DEFINE(tx_drain_work, tx_drain_work_fn); - -/* TX congestion control — ported from ZephyrBLE.cpp so the TCP transport - * back-pressures instead of dropping frames when the send queue fills (e.g. - * an advert/message push colliding with a channel/contact import burst). - * - * 1. Queue full → set sticky tx_congested; senders check - * zephcore_ble_is_congested() and hold off. - * 2. Lossless protocol frame (data[0] < 0x80) → return 0 so the caller - * retries — never dropped. - * 3. Non-lossless push → stash one frame in overflow, retried every 250ms. - * 4. Drain below 1/3 (or empty) → clear congestion (hysteresis). - * 5. Disconnect → reset everything. - */ -static bool tx_congested; -static struct frame overflow_frame; -static bool overflow_pending; - -static void overflow_retry_work_fn(struct k_work *work); -static K_WORK_DELAYABLE_DEFINE(overflow_retry_work, overflow_retry_work_fn); - -static void close_client_locked(void) -{ - if (client_fd >= 0) { - zsock_close(client_fd); - client_fd = -1; - } - - /* Reset TX congestion state on disconnect (mirrors ZephyrBLE). */ - overflow_pending = false; - tx_congested = false; - k_work_cancel_delayable(&overflow_retry_work); -} - -/* Write `len` bytes, retrying on partial sends. Returns 0 or -errno. */ -static int sock_send_all(int fd, const uint8_t *buf, size_t len) -{ - size_t off = 0; - - while (off < len) { - ssize_t n = zsock_send(fd, buf + off, len - off, 0); - - if (n < 0) { - if (errno == EINTR) { - continue; - } - return -errno; - } - if (n == 0) { - return -ECONNRESET; - } - off += (size_t)n; - } - return 0; -} - -/* Read exactly `len` bytes (blocking). Returns 0 on success, -errno on - * error, -ECONNRESET on clean EOF. */ -static int sock_recv_all(int fd, uint8_t *buf, size_t len) -{ - size_t off = 0; - - while (off < len) { - ssize_t n = zsock_recv(fd, buf + off, len - off, 0); - - if (n < 0) { - if (errno == EINTR) { - continue; - } - return -errno; - } - if (n == 0) { - return -ECONNRESET; - } - off += (size_t)n; - } - return 0; -} - -static void tx_drain_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - - struct frame f; - - while (k_msgq_get(&ble_send_queue, &f, K_NO_WAIT) == 0) { - /* Clear congestion at low water mark (1/3) — hysteresis vs full. */ - if (tx_congested && - k_msgq_num_used_get(&ble_send_queue) <= FRAME_QUEUE_SIZE / 3) { - tx_congested = false; - LOG_INF("tx_drain: congestion cleared (queue=%u/%u)", - k_msgq_num_used_get(&ble_send_queue), - (unsigned)FRAME_QUEUE_SIZE); - } - - k_mutex_lock(&sock_mu, K_FOREVER); - int fd = client_fd; - - if (fd < 0) { - k_mutex_unlock(&sock_mu); - /* No client — drop the frame. */ - LOG_DBG("tx: no client, dropping len=%u", f.len); - continue; - } - - /* SerialWifiInterface framing: '>' + length LE + payload */ - uint8_t hdr[3]; - - hdr[0] = '>'; - hdr[1] = (uint8_t)(f.len & 0xFF); - hdr[2] = (uint8_t)(f.len >> 8); - - int err = sock_send_all(fd, hdr, 3); - - if (err == 0) { - err = sock_send_all(fd, f.buf, f.len); - } - k_mutex_unlock(&sock_mu); - - if (err != 0) { - if (err == -EAGAIN || err == -EWOULDBLOCK) { - LOG_WRN("tx send timeout (peer not reading), closing client"); - } else { - LOG_WRN("tx send err=%d, closing client", err); - } - k_mutex_lock(&sock_mu, K_FOREVER); - close_client_locked(); - active_iface = ZEPHCORE_IFACE_NONE; - k_mutex_unlock(&sock_mu); - - if (transport_cbs && transport_cbs->on_disconnected) { - transport_cbs->on_disconnected(); - } - break; - } - } - - /* Queue fully drained — clear any residual congestion. */ - if (tx_congested && k_msgq_num_used_get(&ble_send_queue) == 0) { - tx_congested = false; - LOG_INF("tx_drain: congestion cleared (queue empty)"); - } - - if (transport_cbs && transport_cbs->on_tx_idle && - k_msgq_num_used_get(&ble_send_queue) == 0) { - transport_cbs->on_tx_idle(); - } -} - -/* Re-inject the stashed overflow push once the queue has room. Mirrors - * ZephyrBLE.cpp's overflow_retry_work_fn. */ -static void overflow_retry_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - - if (!overflow_pending) { - return; - } - - /* Abandon overflow if the client is gone. */ - k_mutex_lock(&sock_mu, K_FOREVER); - bool connected = (client_fd >= 0); - - k_mutex_unlock(&sock_mu); - - if (!connected) { - overflow_pending = false; - tx_congested = false; - LOG_INF("overflow cleared (disconnected)"); - return; - } - - if (k_msgq_put(&ble_send_queue, &overflow_frame, K_NO_WAIT) == 0) { - overflow_pending = false; - LOG_DBG("overflow frame queued hdr=0x%02x, kicking drain", - overflow_frame.buf[0]); - k_work_submit(&tx_drain_work); - /* Congestion flag cleared by tx_drain at low water mark. */ - } else { - /* Still full — retry at reduced rate. */ - LOG_DBG("overflow retry: queue still full, retry in %dms", - TX_OVERFLOW_RETRY_MS); - k_work_schedule(&overflow_retry_work, K_MSEC(TX_OVERFLOW_RETRY_MS)); - } -} - -static void listen_thread_fn(void *a, void *b, void *c) -{ - ARG_UNUSED(a); - ARG_UNUSED(b); - ARG_UNUSED(c); - - struct net_sockaddr_in addr = {0}; - - addr.sin_family = NET_AF_INET; - addr.sin_addr.s_addr = 0; /* NET_INADDR_ANY */ - addr.sin_port = net_htons(LISTEN_PORT); - - listen_fd = zsock_socket(NET_AF_INET, NET_SOCK_STREAM, NET_IPPROTO_TCP); - if (listen_fd < 0) { - LOG_ERR("zsock_socket failed: %d", errno); - return; - } - - int one = 1; - (void)zsock_setsockopt(listen_fd, ZSOCK_SOL_SOCKET, ZSOCK_SO_REUSEADDR, - &one, sizeof(one)); - - if (zsock_bind(listen_fd, (struct net_sockaddr *)&addr, sizeof(addr)) < 0) { - LOG_ERR("bind(%d) failed: %d", LISTEN_PORT, errno); - zsock_close(listen_fd); - listen_fd = -1; - return; - } - - if (zsock_listen(listen_fd, LISTEN_BACKLOG) < 0) { - LOG_ERR("listen failed: %d", errno); - zsock_close(listen_fd); - listen_fd = -1; - return; - } - - LOG_INF("TCP companion transport listening on :%u", (unsigned)LISTEN_PORT); - - while (true) { - struct net_sockaddr_in caddr; - net_socklen_t clen = sizeof(caddr); - int fd = zsock_accept(listen_fd, (struct net_sockaddr *)&caddr, &clen); - - if (fd < 0) { - if (errno == EINTR) { - continue; - } - LOG_ERR("accept failed: %d", errno); - k_sleep(K_MSEC(100)); - continue; - } - - /* Bound blocking sends so a non-reading peer can't hang the - * system workqueue (see TX_SEND_TIMEOUT_SEC). */ - struct zsock_timeval sndtimeo = { - .tv_sec = TX_SEND_TIMEOUT_SEC, - .tv_usec = 0, - }; - (void)zsock_setsockopt(fd, ZSOCK_SOL_SOCKET, ZSOCK_SO_SNDTIMEO, - &sndtimeo, sizeof(sndtimeo)); - - k_mutex_lock(&sock_mu, K_FOREVER); - if (client_fd >= 0) { - LOG_WRN("Second client rejected (already connected)"); - zsock_close(fd); - k_mutex_unlock(&sock_mu); - continue; - } - client_fd = fd; - active_iface = ZEPHCORE_IFACE_BLE; - k_mutex_unlock(&sock_mu); - - LOG_INF("TCP companion client connected"); - - if (transport_cbs && transport_cbs->on_connected) { - transport_cbs->on_connected(); - } - - /* RX loop: SerialWifiInterface framing. - * Each frame: ['<':1][length:2LE][payload]. - * Frames with type != '<' are skipped (matches Arduino). */ - while (true) { - uint8_t hdr[3]; - int err = sock_recv_all(fd, hdr, 3); - - if (err != 0) { - break; - } - - uint16_t flen = (uint16_t)hdr[1] | ((uint16_t)hdr[2] << 8); - if (flen > MAX_FRAME_SIZE) { - LOG_WRN("Oversized frame: len=%u, closing client", flen); - break; - } - - /* Skip frames not from app ('<'), or bad length. */ - if (hdr[0] != '<' || flen == 0) { - /* Drain and discard the payload. */ - for (uint16_t i = 0; i < flen; i++) { - uint8_t discard; - if (sock_recv_all(fd, &discard, 1) != 0) { - goto client_done; - } - } - LOG_WRN("Skipping frame: type=0x%02x len=%u", hdr[0], flen); - continue; - } - - struct frame f; - - f.len = flen; - err = sock_recv_all(fd, f.buf, flen); - if (err != 0) { - break; - } - - /* Do NOT k_msgq_put here — ble_on_rx_frame() in - * main_companion.cpp handles queueing, matching the - * ZephyrBLE.cpp pattern (secure_nus_rx_write just calls - * on_rx_frame without touching the recv queue). */ - if (transport_cbs && transport_cbs->on_rx_frame) { - transport_cbs->on_rx_frame(f.buf, f.len); - } - } - client_done: - - k_mutex_lock(&sock_mu, K_FOREVER); - close_client_locked(); - active_iface = ZEPHCORE_IFACE_NONE; - k_mutex_unlock(&sock_mu); - - LOG_INF("TCP companion client disconnected"); - - if (transport_cbs && transport_cbs->on_disconnected) { - transport_cbs->on_disconnected(); - } - } -} - -/* ========== Public API (matches ZephyrBLE.h) ========== */ - -void zephcore_ble_init(const struct ble_callbacks *cbs) -{ - transport_cbs = cbs; - k_mutex_init(&sock_mu); - transport_enabled = true; -} - -void zephcore_ble_start(const char *device_name) -{ - ARG_UNUSED(device_name); - - if (listen_thread_started) { - return; - } - - k_thread_create(&listen_thread, listen_thread_stack, - K_KERNEL_STACK_SIZEOF(listen_thread_stack), - listen_thread_fn, NULL, NULL, NULL, - K_PRIO_COOP(7), 0, K_NO_WAIT); - k_thread_name_set(&listen_thread, "linux_tcp_listen"); - listen_thread_started = true; -} - -size_t zephcore_ble_send(const uint8_t *data, uint16_t len) -{ - if (len == 0 || len > MAX_FRAME_SIZE) { - return 0; - } - - k_mutex_lock(&sock_mu, K_FOREVER); - int fd = client_fd; - - k_mutex_unlock(&sock_mu); - - if (fd < 0) { - return 0; - } - - struct frame f; - - f.len = len; - memcpy(f.buf, data, len); - - if (k_msgq_put(&ble_send_queue, &f, K_NO_WAIT) != 0) { - /* Queue full — enter congestion mode (mirrors ZephyrBLE.cpp). - * Senders hold off via zephcore_ble_is_congested(). */ - if (!tx_congested) { - LOG_WRN("TX queue full (%u/%u), entering congestion", - k_msgq_num_used_get(&ble_send_queue), - (unsigned)FRAME_QUEUE_SIZE); - tx_congested = true; - } - - /* Lossless protocol response frames are never dropped — report - * failure so the caller retries instead of clobbering overflow. */ - if (companion_is_lossless_protocol_frame(data, len)) { - LOG_DBG("TX queue full for lossless frame hdr=0x%02x, retry later", - data[0]); - return 0; - } - - /* Non-lossless push: stash one frame in overflow, retried at - * 250ms. If overflow is occupied, drop rather than clobber it. */ - if (overflow_pending) { - LOG_WRN("overflow full, dropping push hdr=0x%02x", data[0]); - return 0; - } - overflow_frame = f; - overflow_pending = true; - k_work_schedule(&overflow_retry_work, K_MSEC(TX_OVERFLOW_RETRY_MS)); - return len; /* Accepted into overflow — will be retried. */ - } - - k_work_submit(&tx_drain_work); - return len; -} - -void zephcore_ble_set_enabled(bool enable) -{ - transport_enabled = enable; - if (!enable) { - k_mutex_lock(&sock_mu, K_FOREVER); - close_client_locked(); - k_mutex_unlock(&sock_mu); - } -} - -bool zephcore_ble_is_enabled(void) -{ - return transport_enabled; -} - -bool zephcore_ble_is_active(void) -{ - bool active; - - k_mutex_lock(&sock_mu, K_FOREVER); - active = (client_fd >= 0); - k_mutex_unlock(&sock_mu); - return active; -} - -bool zephcore_ble_is_connected(void) -{ - return zephcore_ble_is_active(); -} - -bool zephcore_ble_is_congested(void) -{ - /* Sticky flag (mirrors ZephyrBLE): set at full, cleared at 1/3. - * Callers also check the 2/3 high-water mark on the raw queue. */ - return tx_congested; -} - -bool zephcore_ble_tx_idle(void) -{ - bool no_client; - - k_mutex_lock(&sock_mu, K_FOREVER); - no_client = (client_fd < 0); - k_mutex_unlock(&sock_mu); - - /* No client — tx_drain_work_fn drops queued frames rather than holding - * them, so there is nothing to wait for. Mirrors ZephyrBLE's disconnected - * case. */ - if (no_client) { - return true; - } - - /* Sends are synchronous inside tx_drain_work_fn, so an empty queue means - * every frame has reached the socket. overflow_pending is the one frame - * held back outside the queue. No "in progress" flag is needed: the drain - * work and the reboot poller share the system workqueue and never run - * concurrently. */ - return k_msgq_num_used_get(&ble_send_queue) == 0 && !overflow_pending; -} - -bool zephcore_ble_is_advertising(void) -{ - /* TCP transport "advertises" by listening. Always true once started. */ - return listen_fd >= 0; -} - -void zephcore_ble_set_passkey(uint32_t pk) -{ - passkey = pk; -} - -uint32_t zephcore_ble_get_passkey(void) -{ - return passkey; -} - -enum zephcore_iface zephcore_ble_get_active_iface(void) -{ - return active_iface; -} - -void zephcore_ble_set_active_iface(enum zephcore_iface iface) -{ - active_iface = iface; -} - -struct k_msgq *zephcore_ble_get_recv_queue(void) -{ - return &ble_recv_queue; -} - -struct k_msgq *zephcore_ble_get_send_queue(void) -{ - return &ble_send_queue; -} - -void zephcore_ble_kick_tx(void) -{ - k_work_submit(&tx_drain_work); -} - -void zephcore_ble_disconnect(void) -{ - k_mutex_lock(&sock_mu, K_FOREVER); - close_client_locked(); - active_iface = ZEPHCORE_IFACE_NONE; - k_mutex_unlock(&sock_mu); - - if (transport_cbs && transport_cbs->on_disconnected) { - transport_cbs->on_disconnected(); - } -} - -void zephcore_ble_conn_params_ready(void) -{ - /* No BLE conn-param negotiation on TCP — no-op. */ -} - -void zephcore_ble_update_name(const char *new_name) -{ - /* TCP transport has no advertising payload to update. */ - ARG_UNUSED(new_name); -} diff --git a/zephcore/adapters/transport/SerialCompanionTransport.c b/zephcore/adapters/transport/SerialCompanionTransport.c deleted file mode 100644 index b7e7b7f..0000000 --- a/zephcore/adapters/transport/SerialCompanionTransport.c +++ /dev/null @@ -1,513 +0,0 @@ -/* - * SPDX-License-Identifier: MIT - * ZephCore serial (UART) companion transport — drop-in zephcore_ble_* provider. - * - * For boards with NO Bluetooth and NO USB device controller (e.g. the Seeed - * LoRa-E5 / STM32WL family), the companion app talks to the node over a plain - * UART — on the LoRa-E5 mini that UART is bridged to USB-C by an onboard - * USB-UART chip, so the official MeshCore serial client connects to it exactly - * like a native-USB companion. - * - * This file is the UART analogue of adapters/transport/LinuxTCPTransport.c: it - * implements the full zephcore_ble_* C API (adapters/ble/ZephyrBLE.h) so the - * companion application is transport-agnostic. It is compiled INSTEAD of - * ZephyrBLE.cpp when CONFIG_BT=n and CONFIG_ZEPHCORE_TRANSPORT_TCP=n - * (see the companion-transport selection in CMakeLists.txt). The MeshCore - * '<'/'>' framing + struct frame + congestion classification are shared with - * the TCP transport via companion_framing.h. - * - * I/O is interrupt-driven (uart_irq) on both directions so a contact-sync burst - * (many ~148B frames back-to-back) drains at full line rate with real - * back-pressure instead of blocking the system workqueue on uart_poll_out. - * - * Session model: a UART has no connect/disconnect line event (no DTR — the - * official client never asserts it), and a connected companion may sit idle for - * many minutes between frames. We therefore mark "connected" on the first - * inbound frame (fires on_connected once, lights the UI indicator) and STAY - * connected; we never infer a disconnect from idleness. Per-session reset on a - * reconnecting client is driven by the protocol's CMD_APP_START handler, not by - * any inactivity timer. The only timeout here is a parser-level resync for a - * partial frame that never completes (below) — it touches the RX byte parser - * only, never the session/connection state. - */ - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "ZephyrBLE.h" -#include "companion_framing.h" - -LOG_MODULE_REGISTER(serial_companion, LOG_LEVEL_INF); - -#define FRAME_QUEUE_SIZE CONFIG_ZEPHCORE_BLE_QUEUE_SIZE -#define TX_RING_SIZE 2048 /* ~13 contact frames of headroom */ -#define TX_OVERFLOW_RETRY_MS 250 - -/* Parser-only resync: if a frame has STARTED ('<' seen) but its payload never - * completes within this window, the next inbound byte resets the parser to - * IDLE so a lost/truncated frame can't swallow the following frame. This is NOT - * a session/idle timeout — an idle-but-connected companion sits in RX_IDLE - * between frames and never arms this, and it never affects connection state. */ -#define FRAME_PARTIAL_TIMEOUT_MS 2000 - -/* Companion UART — the chosen console UART (USART1 on the LoRa-E5 mini). */ -static const struct device *uart_dev; - -K_MSGQ_DEFINE(ble_send_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); -K_MSGQ_DEFINE(ble_recv_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); - -static const struct ble_callbacks *transport_cbs; -static enum zephcore_iface active_iface = ZEPHCORE_IFACE_NONE; -static bool transport_enabled; -static uint32_t passkey; - -/* ---- RX byte-assembly state (runs in UART ISR) ---------------------------- */ -enum rx_state { - RX_IDLE = 0, /* waiting for '<' */ - RX_LEN_LO, - RX_LEN_HI, - RX_PAYLOAD, -}; -static enum rx_state rx_st; -static uint16_t rx_idx; -static uint16_t rx_expect; -static uint32_t rx_frame_start; /* k_uptime_get_32() when the '<' arrived */ -static struct frame rx_frame; - -/* ---- TX side: interrupt-driven ring (whole frames committed atomically) ---- */ -static uint8_t tx_ring_data[TX_RING_SIZE]; -static struct ring_buf tx_ring; -static struct k_spinlock tx_lock; - -/* Order-preserving holdover: when the ring is momentarily full, the popped - * frame is parked here (NOT requeued at the tail, which would reorder it behind - * later frames) and re-tried first on the next drain. Touched only by - * tx_drain_work_fn, which runs solely on the system workqueue (never concurrent - * with itself), so it needs no lock. */ -static struct frame held_frame; -static bool held_pending; - -/* ---- TX queue drain (moves frames from ble_send_queue into the TX ring) ---- - * Runs on the system workqueue; never blocks on the wire (the ISR does the - * byte-level draining), so a full sync burst can't starve the workqueue. */ -static void tx_drain_work_fn(struct k_work *work); -static K_WORK_DEFINE(tx_drain_work, tx_drain_work_fn); - -/* on_connected is deferred off the ISR onto the system workqueue (it ends up - * in ui_notify(), which is not guaranteed ISR-safe). */ -static void connect_work_fn(struct k_work *work); -static K_WORK_DEFINE(connect_work, connect_work_fn); - -/* ---- TX congestion control (mirrors LinuxTCPTransport.c / ZephyrBLE.cpp) ---- */ -static bool tx_congested; -static struct frame overflow_frame; -static bool overflow_pending; -static void overflow_retry_work_fn(struct k_work *work); -static K_WORK_DELAYABLE_DEFINE(overflow_retry_work, overflow_retry_work_fn); - -static void connect_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - LOG_INF("serial companion session started"); - if (transport_cbs && transport_cbs->on_connected) { - transport_cbs->on_connected(); - } -} - -/* Mark the session connected on first inbound traffic (fires on_connected once - * — the UI "connected" indicator). Called from the UART ISR, so the callback is - * deferred to a workqueue rather than run inline. */ -static void mark_connected(void) -{ - if (active_iface != ZEPHCORE_IFACE_NONE) { - return; - } - active_iface = ZEPHCORE_IFACE_BLE; /* reuse the BLE path in main_companion */ - k_work_submit(&connect_work); -} - -/* ---- UART ISR: drains RX into the frame parser, fills TX from the ring ----- */ -static void uart_isr(const struct device *dev, void *user_data) -{ - ARG_UNUSED(user_data); - - for (;;) { - uart_irq_update(dev); - if (uart_irq_is_pending(dev) <= 0) { - break; - } - - if (uart_irq_rx_ready(dev)) { - uint8_t buf[64]; - int n = uart_fifo_read(dev, buf, sizeof(buf)); - - /* Parser-only resync: drop a partial frame that stalled - * mid-payload so it can't consume the bytes of the next - * frame. Connection state is untouched. */ - if (rx_st != RX_IDLE && - (k_uptime_get_32() - rx_frame_start) > FRAME_PARTIAL_TIMEOUT_MS) { - rx_st = RX_IDLE; - } - - for (int i = 0; i < n; i++) { - uint8_t b = buf[i]; - - switch (rx_st) { - case RX_IDLE: - if (b == COMPANION_FRAME_RX_SYNC) { - rx_st = RX_LEN_LO; - rx_frame_start = k_uptime_get_32(); - } - break; - case RX_LEN_LO: - rx_expect = b; - rx_st = RX_LEN_HI; - break; - case RX_LEN_HI: - rx_expect |= ((uint16_t)b) << 8; - rx_idx = 0; - if (rx_expect == 0 || rx_expect > MAX_FRAME_SIZE) { - rx_st = RX_IDLE; /* bad length, resync */ - } else { - rx_st = RX_PAYLOAD; - } - break; - default: /* RX_PAYLOAD */ - rx_frame.buf[rx_idx++] = b; - if (rx_idx >= rx_expect) { - rx_frame.len = rx_expect; - mark_connected(); - /* on_rx_frame queues to ble_recv_queue and - * wakes the mesh thread (ISR-safe: K_NO_WAIT - * k_msgq_put + k_event_post), same as - * ZephyrBLE's secure_nus_rx_write. */ - if (transport_cbs && transport_cbs->on_rx_frame) { - transport_cbs->on_rx_frame(rx_frame.buf, - rx_frame.len); - } - rx_st = RX_IDLE; - } - break; - } - } - } - - if (uart_irq_tx_ready(dev)) { - uint8_t *out; - bool empty; - k_spinlock_key_t key = k_spin_lock(&tx_lock); - uint32_t claimed = MIN(ring_buf_get_ptr(&tx_ring, &out, 0), 64U); - - if (claimed > 0) { - int sent = uart_fifo_fill(dev, out, claimed); - - if (sent > 0) { - ring_buf_consume(&tx_ring, sent); - } - } - empty = ring_buf_is_empty(&tx_ring); - if (empty) { - /* Disable inside the lock so a concurrent enqueue - * can't be stranded (it re-enables after its put). */ - uart_irq_tx_disable(dev); - } - k_spin_unlock(&tx_lock, key); - - if (empty) { - /* Ring drained — pull the next batch from the queue. - * tx_drain_work fires on_tx_idle when fully drained. */ - k_work_submit(&tx_drain_work); - } - } - } -} - -/* Queue one framed frame into the TX ring. Returns true if it fit (committed - * atomically — header + payload together — so frames never tear). */ -static bool tx_ring_put_frame(const struct frame *f) -{ - uint8_t hdr[3] = { - COMPANION_FRAME_TX_SYNC, - (uint8_t)(f->len & 0xFF), - (uint8_t)((f->len >> 8) & 0xFF), - }; - size_t total = sizeof(hdr) + f->len; - - k_spinlock_key_t key = k_spin_lock(&tx_lock); - if (ring_buf_space_get(&tx_ring) < total) { - k_spin_unlock(&tx_lock, key); - return false; - } - ring_buf_put(&tx_ring, hdr, sizeof(hdr)); - ring_buf_put(&tx_ring, f->buf, f->len); - k_spin_unlock(&tx_lock, key); - - uart_irq_tx_enable(uart_dev); - return true; -} - -static void tx_drain_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - - struct frame f; - - /* Re-try the order-preserving holdover first. If it still doesn't fit, - * leave it parked — the TX ISR re-submits this work when the ring drains. */ - if (held_pending) { - if (!tx_ring_put_frame(&held_frame)) { - return; - } - held_pending = false; - } - - /* Move queued frames into the TX ring while it has room. On ring-full, - * park the popped frame in the single holdover slot (preserving FIFO - * order) rather than requeuing it behind later frames. */ - while (k_msgq_get(&ble_send_queue, &f, K_NO_WAIT) == 0) { - if (!tx_ring_put_frame(&f)) { - held_frame = f; - held_pending = true; - return; - } - - /* Clear congestion at the 1/3 low-water mark (hysteresis). */ - if (tx_congested && - k_msgq_num_used_get(&ble_send_queue) <= FRAME_QUEUE_SIZE / 3) { - tx_congested = false; - LOG_INF("tx: congestion cleared"); - } - } - - /* Queue + holdover fully drained into the ring. Fire on_tx_idle only once - * the wire is also idle (ring empty) so the contact pump paces to real - * throughput. */ - if (transport_cbs && transport_cbs->on_tx_idle && !held_pending) { - bool ring_empty; - k_spinlock_key_t key = k_spin_lock(&tx_lock); - ring_empty = ring_buf_is_empty(&tx_ring); - k_spin_unlock(&tx_lock, key); - if (ring_empty) { - transport_cbs->on_tx_idle(); - } - } -} - -static void overflow_retry_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - - if (!overflow_pending) { - return; - } - if (k_msgq_put(&ble_send_queue, &overflow_frame, K_NO_WAIT) == 0) { - overflow_pending = false; - k_work_submit(&tx_drain_work); - } else { - k_work_schedule(&overflow_retry_work, K_MSEC(TX_OVERFLOW_RETRY_MS)); - } -} - -/* ========== Public API (matches ZephyrBLE.h) ========== */ - -void zephcore_ble_init(const struct ble_callbacks *cbs) -{ - transport_cbs = cbs; - transport_enabled = true; - ring_buf_init(&tx_ring, sizeof(tx_ring_data), tx_ring_data); -} - -void zephcore_ble_start(const char *device_name) -{ - ARG_UNUSED(device_name); - - uart_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console)); - if (!device_is_ready(uart_dev)) { - LOG_ERR("companion UART not ready"); - uart_dev = NULL; - return; - } - - uart_irq_callback_set(uart_dev, uart_isr); - uart_irq_rx_enable(uart_dev); - LOG_INF("serial companion transport ready on %s", uart_dev->name); -} - -size_t zephcore_ble_send(const uint8_t *data, uint16_t len) -{ - if (!uart_dev || len == 0 || len > MAX_FRAME_SIZE) { - return 0; - } - - struct frame f; - - f.len = len; - memcpy(f.buf, data, len); - - if (k_msgq_put(&ble_send_queue, &f, K_NO_WAIT) != 0) { - /* Queue full — congestion mode (mirrors LinuxTCPTransport.c). */ - if (!tx_congested) { - LOG_WRN("TX queue full (%u/%u), entering congestion", - k_msgq_num_used_get(&ble_send_queue), - (unsigned)FRAME_QUEUE_SIZE); - tx_congested = true; - } - /* Lossless protocol responses are never dropped — report failure - * so the caller retries instead of clobbering overflow. */ - if (companion_is_lossless_protocol_frame(data, len)) { - return 0; - } - if (overflow_pending) { - LOG_WRN("overflow full, dropping push hdr=0x%02x", data[0]); - return 0; - } - overflow_frame = f; - overflow_pending = true; - k_work_schedule(&overflow_retry_work, K_MSEC(TX_OVERFLOW_RETRY_MS)); - return len; - } - - k_work_submit(&tx_drain_work); - return len; -} - -void zephcore_ble_set_enabled(bool enable) -{ - transport_enabled = enable; - if (!uart_dev) { - return; - } - /* Actually gate the link, not just a flag: stop accepting frames when - * disabled (mirrors the TCP transport closing its socket). */ - if (enable) { - uart_irq_rx_enable(uart_dev); - } else { - uart_irq_rx_disable(uart_dev); - } -} - -bool zephcore_ble_is_enabled(void) -{ - return transport_enabled; -} - -bool zephcore_ble_is_active(void) -{ - return uart_dev != NULL && active_iface == ZEPHCORE_IFACE_BLE; -} - -bool zephcore_ble_is_connected(void) -{ - return active_iface == ZEPHCORE_IFACE_BLE; -} - -bool zephcore_ble_is_congested(void) -{ - return tx_congested; -} - -bool zephcore_ble_tx_idle(void) -{ - /* No UART bound — nothing can be in flight, and nothing ever will be. */ - if (uart_dev == NULL) { - return true; - } - - /* The wire itself is the last stage: the TX ISR drains tx_ring into the - * UART FIFO asynchronously, so an empty send queue is not enough. Poll - * the ring too, plus both hold-back slots (order-preserving holdover and - * the overflow retry frame). Mirrors zephcore_ble_tx_idle() in - * ZephyrBLE.cpp, and matches the ring_empty test tx_drain_work_fn uses - * before firing on_tx_idle. - * - * Deliberately no "drain in progress" flag: tx_drain_work_fn and the - * reboot poller both run on the system workqueue, so this can never - * observe a half-finished drain. */ - bool ring_empty; - k_spinlock_key_t key = k_spin_lock(&tx_lock); - - ring_empty = ring_buf_is_empty(&tx_ring); - k_spin_unlock(&tx_lock, key); - - return k_msgq_num_used_get(&ble_send_queue) == 0 && - ring_empty && !held_pending && !overflow_pending; -} - -bool zephcore_ble_is_advertising(void) -{ - /* A UART is "always advertising" once the transport is up — keeps the - * companion housekeeping loop from trying to (re)start advertising. */ - return uart_dev != NULL; -} - -void zephcore_ble_set_passkey(uint32_t pk) -{ - passkey = pk; -} - -uint32_t zephcore_ble_get_passkey(void) -{ - return passkey; -} - -enum zephcore_iface zephcore_ble_get_active_iface(void) -{ - return active_iface; -} - -void zephcore_ble_set_active_iface(enum zephcore_iface iface) -{ - active_iface = iface; -} - -bool zephcore_ble_iface_try_claim(enum zephcore_iface who) -{ - if (active_iface == ZEPHCORE_IFACE_NONE || active_iface == who) { - active_iface = who; - return true; - } - return false; -} - -struct k_msgq *zephcore_ble_get_recv_queue(void) -{ - return &ble_recv_queue; -} - -struct k_msgq *zephcore_ble_get_send_queue(void) -{ - return &ble_send_queue; -} - -void zephcore_ble_kick_tx(void) -{ - k_work_submit(&tx_drain_work); -} - -void zephcore_ble_disconnect(void) -{ - active_iface = ZEPHCORE_IFACE_NONE; - if (transport_cbs && transport_cbs->on_disconnected) { - transport_cbs->on_disconnected(); - } -} - -void zephcore_ble_conn_params_ready(void) -{ - /* No link-layer connection parameters on a UART — no-op. */ -} - -void zephcore_ble_update_name(const char *new_name) -{ - /* No advertising payload on a UART — no-op. */ - ARG_UNUSED(new_name); -} diff --git a/zephcore/adapters/transport/TcpCompanionTransport.c b/zephcore/adapters/transport/TcpCompanionTransport.c new file mode 100644 index 0000000..b5f0319 --- /dev/null +++ b/zephcore/adapters/transport/TcpCompanionTransport.c @@ -0,0 +1,432 @@ +/* + * SPDX-License-Identifier: MIT + * ZephCore TCP companion transport — see TcpCompanionTransport.h. + * + * Wire format: MeshCore SerialWifiInterface framing (Arduino reference: + * src/helpers/wifi/SerialWifiInterface.cpp): + * + * App → Node: [ '<' (0x3C) | length_LSB | length_MSB | payload... ] + * Node → App: [ '>' (0x3E) | length_LSB | length_MSB | payload... ] + * + * Frames with type != '<' are skipped (matches Arduino). One client at a time. + * + * Architecture: + * - The listener thread accepts a client, then reads frames into the recv + * queue and raises on_rx. + * - TX is a work item draining the frame_txq with zsock_send() until empty + * or the socket dies. + */ + +#include "TcpCompanionTransport.h" +#include "frame_txq.h" + +#include +#include +#include +#include +#include +#include + +LOG_MODULE_REGISTER(tcp_companion, LOG_LEVEL_INF); + +#define FRAME_QUEUE_SIZE CONFIG_ZEPHCORE_TCP_QUEUE_SIZE +#define LISTEN_BACKLOG 1 + +/* Bound on a single blocking send. tx_drain_work_fn runs on the system + * workqueue, so an unbounded zsock_send() against a peer that has stopped + * reading would hang the whole queue (incl. the txq's overflow retry). A peer + * that can't accept one frame within this window is wedged — close it and + * let the app reconnect rather than stall the node. */ +#define TX_SEND_TIMEOUT_SEC 2 + +/* Longest the RX thread waits for room in the recv queue before dropping. */ +#define RX_QUEUE_WAIT_MS 250 + +K_MSGQ_DEFINE(tcp_send_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); +K_MSGQ_DEFINE(tcp_recv_queue, sizeof(struct frame), FRAME_QUEUE_SIZE, 4); +static struct frame_txq tcp_txq; + +static const struct companion_link_cbs *s_link; +static uint16_t s_port; +static bool transport_enabled = true; + +/* Socket state */ +static int listen_fd = -1; +static int client_fd = -1; +static struct k_mutex sock_mu; + +/* Listen + RX thread. Peak 1524 B with WiFi (XIAO ESP32-S3, 2026-09-24, + * CONFIG_ZEPHCORE_MEM_STATS under the protocol golden and a bulk dump over + * TCP); was 4096. */ +static K_KERNEL_STACK_DEFINE(listen_thread_stack, 3072); +static struct k_thread listen_thread; +static bool listen_thread_started; + +/* TX work — drains the txq */ +static void tx_drain_work_fn(struct k_work *work); +static K_WORK_DEFINE(tx_drain_work, tx_drain_work_fn); + +static bool tcp_link_up(void) +{ + bool up; + + k_mutex_lock(&sock_mu, K_FOREVER); + up = (client_fd >= 0); + k_mutex_unlock(&sock_mu); + return up; +} + +static void tcp_kick(void) +{ + k_work_submit(&tx_drain_work); +} + +static void close_client_locked(void) +{ + if (client_fd >= 0) { + zsock_close(client_fd); + client_fd = -1; + } + frame_txq_reset(&tcp_txq); +} + +/* Write `len` bytes, retrying on partial sends. Returns 0 or -errno. */ +static int sock_send_all(int fd, const uint8_t *buf, size_t len) +{ + size_t off = 0; + + while (off < len) { + ssize_t n = zsock_send(fd, buf + off, len - off, 0); + + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -errno; + } + if (n == 0) { + return -ECONNRESET; + } + off += (size_t)n; + } + return 0; +} + +/* Read exactly `len` bytes (blocking). Returns 0 on success, -errno on + * error, -ECONNRESET on clean EOF. */ +static int sock_recv_all(int fd, uint8_t *buf, size_t len) +{ + size_t off = 0; + + while (off < len) { + ssize_t n = zsock_recv(fd, buf + off, len - off, 0); + + if (n < 0) { + if (errno == EINTR) { + continue; + } + return -errno; + } + if (n == 0) { + return -ECONNRESET; + } + off += (size_t)n; + } + return 0; +} + +static void tx_drain_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + + struct frame f; + + while (frame_txq_get(&tcp_txq, &f) == 0) { + k_mutex_lock(&sock_mu, K_FOREVER); + int fd = client_fd; + + if (fd < 0) { + k_mutex_unlock(&sock_mu); + /* No client — drop the frame. */ + LOG_DBG("tx: no client, dropping len=%u", f.len); + continue; + } + + /* SerialWifiInterface framing: '>' + length LE + payload */ + uint8_t hdr[3]; + + hdr[0] = COMPANION_FRAME_TX_SYNC; + hdr[1] = (uint8_t)(f.len & 0xFF); + hdr[2] = (uint8_t)(f.len >> 8); + + int err = sock_send_all(fd, hdr, 3); + + if (err == 0) { + err = sock_send_all(fd, f.buf, f.len); + } + k_mutex_unlock(&sock_mu); + + if (err != 0) { + if (err == -EAGAIN || err == -EWOULDBLOCK) { + LOG_WRN("tx send timeout (peer not reading), closing client"); + } else { + LOG_WRN("tx send err=%d, closing client", err); + } + k_mutex_lock(&sock_mu, K_FOREVER); + bool was_open = (client_fd >= 0); + + close_client_locked(); + k_mutex_unlock(&sock_mu); + + if (was_open && s_link && s_link->on_disconnected) { + s_link->on_disconnected(); + } + return; + } + } + + if (s_link && s_link->on_tx_idle) { + s_link->on_tx_idle(); + } +} + +static void listen_thread_fn(void *a, void *b, void *c) +{ + ARG_UNUSED(a); + ARG_UNUSED(b); + ARG_UNUSED(c); + + struct net_sockaddr_in addr = {0}; + + addr.sin_family = NET_AF_INET; + addr.sin_addr.s_addr = 0; /* NET_INADDR_ANY */ + addr.sin_port = net_htons(s_port); + + listen_fd = zsock_socket(NET_AF_INET, NET_SOCK_STREAM, NET_IPPROTO_TCP); + if (listen_fd < 0) { + LOG_ERR("zsock_socket failed: %d", errno); + return; + } + + int one = 1; + (void)zsock_setsockopt(listen_fd, ZSOCK_SOL_SOCKET, ZSOCK_SO_REUSEADDR, + &one, sizeof(one)); + + if (zsock_bind(listen_fd, (struct net_sockaddr *)&addr, sizeof(addr)) < 0) { + LOG_ERR("bind(%u) failed: %d", (unsigned)s_port, errno); + zsock_close(listen_fd); + listen_fd = -1; + return; + } + + if (zsock_listen(listen_fd, LISTEN_BACKLOG) < 0) { + LOG_ERR("listen failed: %d", errno); + zsock_close(listen_fd); + listen_fd = -1; + return; + } + + LOG_INF("TCP companion transport listening on :%u", (unsigned)s_port); + + while (true) { + struct net_sockaddr_in caddr; + net_socklen_t clen = sizeof(caddr); + int fd = zsock_accept(listen_fd, (struct net_sockaddr *)&caddr, &clen); + + if (fd < 0) { + if (errno == EINTR) { + continue; + } + LOG_ERR("accept failed: %d", errno); + k_sleep(K_MSEC(100)); + continue; + } + + if (!transport_enabled) { + LOG_INF("TCP companion disabled, client refused"); + zsock_close(fd); + continue; + } + + /* Bound blocking sends so a non-reading peer can't hang the + * system workqueue (see TX_SEND_TIMEOUT_SEC). */ + struct zsock_timeval sndtimeo = { + .tv_sec = TX_SEND_TIMEOUT_SEC, + .tv_usec = 0, + }; + (void)zsock_setsockopt(fd, ZSOCK_SOL_SOCKET, ZSOCK_SO_SNDTIMEO, + &sndtimeo, sizeof(sndtimeo)); + + k_mutex_lock(&sock_mu, K_FOREVER); + if (client_fd >= 0) { + LOG_WRN("Second client rejected (already connected)"); + zsock_close(fd); + k_mutex_unlock(&sock_mu); + continue; + } + client_fd = fd; + k_mutex_unlock(&sock_mu); + + LOG_INF("TCP companion client connected"); + + if (s_link && s_link->on_connected) { + s_link->on_connected(); + } + + /* RX loop: SerialWifiInterface framing. + * Each frame: ['<':1][length:2LE][payload]. + * Frames with type != '<' are skipped (matches Arduino). */ + while (true) { + uint8_t hdr[3]; + int err = sock_recv_all(fd, hdr, 3); + + if (err != 0) { + break; + } + + uint16_t flen = (uint16_t)hdr[1] | ((uint16_t)hdr[2] << 8); + if (flen > MAX_FRAME_SIZE) { + LOG_WRN("Oversized frame: len=%u, closing client", flen); + break; + } + + /* Skip frames not from app ('<'), or bad length. */ + if (hdr[0] != COMPANION_FRAME_RX_SYNC || flen == 0) { + /* Drain and discard the payload. */ + for (uint16_t i = 0; i < flen; i++) { + uint8_t discard; + if (sock_recv_all(fd, &discard, 1) != 0) { + goto client_done; + } + } + LOG_WRN("Skipping frame: type=0x%02x len=%u", hdr[0], flen); + continue; + } + + struct frame f; + + f.len = flen; + err = sock_recv_all(fd, f.buf, flen); + if (err != 0) { + break; + } + + /* Wait for room rather than drop: until this returns the + * socket is not read, so a burst backs up into TCP flow + * control instead of losing a command. Bounded, so a + * companion that stopped draining cannot pin the thread. */ + if (k_msgq_put(&tcp_recv_queue, &f, K_MSEC(RX_QUEUE_WAIT_MS)) != 0) { + LOG_WRN("recv queue full, frame 0x%02x dropped", f.buf[0]); + continue; + } + if (s_link && s_link->on_rx) { + s_link->on_rx(); + } + } + client_done: + + k_mutex_lock(&sock_mu, K_FOREVER); + /* The TX path may already have closed it (and reported it) */ + bool was_open = (client_fd == fd); + + if (was_open) { + close_client_locked(); + } + k_msgq_purge(&tcp_recv_queue); + k_mutex_unlock(&sock_mu); + + LOG_INF("TCP companion client disconnected"); + + if (was_open && s_link && s_link->on_disconnected) { + s_link->on_disconnected(); + } + } +} + +/* ========== Public API ========== */ + +void tcp_companion_init(const struct companion_link_cbs *link) +{ + s_link = link; + k_mutex_init(&sock_mu); + frame_txq_init(&tcp_txq, &tcp_send_queue, FRAME_QUEUE_SIZE, "tcp", + tcp_link_up, tcp_kick); +} + +void tcp_companion_start(uint16_t port) +{ + if (listen_thread_started) { + return; + } + s_port = port; + + k_thread_create(&listen_thread, listen_thread_stack, + K_KERNEL_STACK_SIZEOF(listen_thread_stack), + listen_thread_fn, NULL, NULL, NULL, + K_PRIO_COOP(7), 0, K_NO_WAIT); + k_thread_name_set(&listen_thread, "tcp_companion"); + listen_thread_started = true; +} + +size_t tcp_companion_send(const uint8_t *data, uint16_t len) +{ + if (!tcp_link_up()) { + return 0; + } + return frame_txq_put(&tcp_txq, data, len); +} + +size_t tcp_companion_recv(uint8_t *dest) +{ + struct frame f; + + if (k_msgq_get(&tcp_recv_queue, &f, K_NO_WAIT) != 0) { + return 0; + } + memcpy(dest, f.buf, f.len); + return f.len; +} + +void tcp_companion_set_enabled(bool enable) +{ + transport_enabled = enable; + if (!enable) { + k_mutex_lock(&sock_mu, K_FOREVER); + /* Shutting the socket ends the listener's blocking recv; the + * listener then reports the disconnect. */ + if (client_fd >= 0) { + (void)zsock_shutdown(client_fd, ZSOCK_SHUT_RDWR); + } + k_mutex_unlock(&sock_mu); + } +} + +bool tcp_companion_is_enabled(void) +{ + return transport_enabled; +} + +bool tcp_companion_is_connected(void) +{ + return tcp_link_up(); +} + +bool tcp_companion_is_write_busy(void) +{ + return frame_txq_busy(&tcp_txq); +} + +bool tcp_companion_tx_idle(void) +{ + /* No client — the drain drops queued frames rather than holding them, + * so there is nothing to wait for. */ + if (!tcp_link_up()) { + return true; + } + + /* Sends are synchronous inside tx_drain_work_fn, so an empty queue means + * every frame has reached the socket. No "in progress" flag is needed: + * the drain work and the reboot poller share the system workqueue and + * never run concurrently. */ + return frame_txq_empty(&tcp_txq); +} diff --git a/zephcore/adapters/transport/TcpCompanionTransport.h b/zephcore/adapters/transport/TcpCompanionTransport.h new file mode 100644 index 0000000..66245de --- /dev/null +++ b/zephcore/adapters/transport/TcpCompanionTransport.h @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: MIT + * ZephCore TCP companion transport (MeshCore SerialWifiInterface framing) + * + * A TCP server for one app at a time, over Zephyr sockets: the native-Linux + * builds' companion link, and the WiFi companion's. CompanionInterfaces.h + * wraps it as a BaseSerialInterface for the MultiSerialInterface. + */ + +#pragma once + +#include +#include +#include + +#include "companion_framing.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* Callbacks are raised from the listener thread and the system work queue: + * post events only. */ +void tcp_companion_init(const struct companion_link_cbs *link); + +/* Start listening on `port` (once; later calls are no-ops). Works before the + * network is up: accept() simply waits. */ +void tcp_companion_start(uint16_t port); + +size_t tcp_companion_send(const uint8_t *data, uint16_t len); +size_t tcp_companion_recv(uint8_t *dest); + +/* Disabling drops the client and refuses new ones. */ +void tcp_companion_set_enabled(bool enable); +bool tcp_companion_is_enabled(void); + +bool tcp_companion_is_connected(void); +bool tcp_companion_is_write_busy(void); +bool tcp_companion_tx_idle(void); + +#ifdef __cplusplus +} +#endif diff --git a/zephcore/adapters/transport/companion_framing.h b/zephcore/adapters/transport/companion_framing.h index 67c2866..c474297 100644 --- a/zephcore/adapters/transport/companion_framing.h +++ b/zephcore/adapters/transport/companion_framing.h @@ -1,14 +1,12 @@ /* * SPDX-License-Identifier: MIT - * Shared MeshCore companion serial framing — used by the non-BLE companion - * transports (LinuxTCPTransport.c, SerialCompanionTransport.c). + * What every companion transport shares: the frame unit, the serial framing, + * the lossless/lossy split, and the callbacks a transport raises to main. * - * Wire format (MeshCore ArduinoSerialInterface): + * Wire format of the byte-stream transports (MeshCore ArduinoSerialInterface / + * SerialWifiInterface): * App -> Node: '<' (0x3C) | len_LSB | len_MSB | payload... * Node -> App: '>' (0x3E) | len_LSB | len_MSB | payload... - * - * `struct frame` is the unit stored in the ble_send_queue/ble_recv_queue; its - * layout must match what main_companion.cpp puts on those queues (len + buf). */ #pragma once @@ -17,7 +15,9 @@ #include #include -#include "ZephyrBLE.h" /* MAX_FRAME_SIZE */ +/* Same value as upstream's BaseSerialInterface.h (+4 over 172 for transport + * codes), so the two definitions never disagree. */ +#define MAX_FRAME_SIZE 176 // +4 for transport codes (region scoping) #define COMPANION_FRAME_RX_SYNC '<' #define COMPANION_FRAME_TX_SYNC '>' @@ -36,3 +36,20 @@ static inline bool companion_is_lossless_protocol_frame(const uint8_t *data, { return data != NULL && len > 0 && data[0] < COMPANION_PUSH_CODE_BASE; } + +#ifdef __cplusplus +extern "C" { +#endif + +/* Raised by a transport, from its own thread or work queue: they must only + * post events. main supplies one set for every transport. */ +struct companion_link_cbs { + void (*on_rx)(void); /* a frame is waiting in the transport's recv queue */ + void (*on_tx_idle)(void); /* the transport's TX has drained */ + void (*on_connected)(void); /* a client can now be reached */ + void (*on_disconnected)(void); /* that client went away */ +}; + +#ifdef __cplusplus +} +#endif diff --git a/zephcore/adapters/transport/frame_txq.c b/zephcore/adapters/transport/frame_txq.c new file mode 100644 index 0000000..cf2bdd2 --- /dev/null +++ b/zephcore/adapters/transport/frame_txq.c @@ -0,0 +1,143 @@ +/* + * SPDX-License-Identifier: MIT + * Companion frame TX queue — see frame_txq.h. + */ + +#include "frame_txq.h" + +#include +#include +#include +LOG_MODULE_REGISTER(zephcore_txq, CONFIG_ZEPHCORE_BLE_LOG_LEVEL); + +#define TXQ_OVERFLOW_RETRY_MS 250 + +static void retry_work_fn(struct k_work *work) +{ + struct k_work_delayable *dw = k_work_delayable_from_work(work); + struct frame_txq *t = CONTAINER_OF(dw, struct frame_txq, retry_work); + + if (!t->overflow_pending) { + return; + } + + /* Abandon the parked push if the link is gone */ + if (!t->link_up()) { + t->overflow_pending = false; + t->congested = false; + LOG_INF("%s: overflow cleared (disconnected)", t->name); + return; + } + + if (k_msgq_put(t->q, &t->overflow, K_NO_WAIT) == 0) { + t->overflow_pending = false; + LOG_DBG("%s: overflow frame queued hdr=0x%02x, kicking drain", t->name, + t->overflow.buf[0]); + t->kick(); + /* Congestion is cleared by the drain at the low-water mark */ + } else { + LOG_DBG("%s: overflow retry: queue still full, retry in %dms", t->name, + TXQ_OVERFLOW_RETRY_MS); + k_work_schedule(&t->retry_work, K_MSEC(TXQ_OVERFLOW_RETRY_MS)); + } +} + +void frame_txq_init(struct frame_txq *t, struct k_msgq *q, uint32_t size, const char *name, + bool (*link_up)(void), void (*kick)(void)) +{ + t->q = q; + t->size = size; + t->name = name; + t->link_up = link_up; + t->kick = kick; + t->congested = false; + t->overflow_pending = false; + k_work_init_delayable(&t->retry_work, retry_work_fn); +} + +size_t frame_txq_put(struct frame_txq *t, const uint8_t *data, uint16_t len) +{ + struct frame f; + + if (len == 0 || len > MAX_FRAME_SIZE) { + LOG_WRN("%s: invalid len=%u", t->name, (unsigned)len); + return 0; + } + f.len = len; + memcpy(f.buf, data, len); + + if (k_msgq_put(t->q, &f, K_NO_WAIT) != 0) { + /* Queue full: enter congestion so senders hold off. Blocking would + * stall LoRa; dropping would lose frames. */ + if (!t->congested) { + LOG_WRN("%s: TX queue full (%u/%u), entering congestion", t->name, + k_msgq_num_used_get(t->q), (unsigned)t->size); + t->congested = true; + } + + /* A lossless response is never parked: fail, the caller retries */ + if (companion_is_lossless_protocol_frame(data, len)) { + LOG_DBG("%s: TX queue full for lossless frame hdr=0x%02x, retry later", + t->name, data[0]); + return 0; + } + + /* One lossy push may wait in the overflow slot. A second is dropped + * rather than clobber it: most push codes carry per-event data. + * Chat messages are safe either way, in the offline queue. */ + if (t->overflow_pending) { + LOG_WRN("%s: overflow full, dropping push hdr=0x%02x", t->name, data[0]); + return 0; + } + t->overflow = f; + t->overflow_pending = true; + k_work_schedule(&t->retry_work, K_MSEC(TXQ_OVERFLOW_RETRY_MS)); + return len; + } + + LOG_DBG("%s: queued len=%u hdr=0x%02x queue=%u", t->name, (unsigned)len, data[0], + k_msgq_num_used_get(t->q)); + t->kick(); + return len; +} + +int frame_txq_get(struct frame_txq *t, struct frame *f) +{ + if (k_msgq_get(t->q, f, K_NO_WAIT) != 0) { + if (t->congested) { + t->congested = false; + LOG_INF("%s: congestion cleared (queue empty)", t->name); + } + return -EAGAIN; + } + + /* Hysteresis: on at full, off at 1/3, for headroom before full again */ + if (t->congested) { + uint32_t used = k_msgq_num_used_get(t->q); + + if (used <= t->size / 3) { + t->congested = false; + LOG_INF("%s: congestion cleared (queue=%u/%u)", t->name, used, + (unsigned)t->size); + } + } + return 0; +} + +bool frame_txq_busy(const struct frame_txq *t) +{ + return t->congested || k_msgq_num_used_get(t->q) >= (t->size * 2 / 3); +} + +bool frame_txq_empty(const struct frame_txq *t) +{ + return k_msgq_num_used_get(t->q) == 0 && !t->overflow_pending; +} + +void frame_txq_reset(struct frame_txq *t) +{ + k_work_cancel_delayable(&t->retry_work); + k_msgq_purge(t->q); + t->overflow_pending = false; + t->congested = false; +} diff --git a/zephcore/adapters/transport/frame_txq.h b/zephcore/adapters/transport/frame_txq.h new file mode 100644 index 0000000..e91b3e1 --- /dev/null +++ b/zephcore/adapters/transport/frame_txq.h @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: MIT + * Companion frame TX queue, shared by the queued transports (BLE, TCP). + * + * The transport owns the drain (a GATT notify chain, a socket send); this owns + * everything before it, identically for every transport: + * - a frame queue; + * - congestion: set when the queue is full, cleared at 1/3 (hysteresis), and + * reported through frame_txq_busy() so senders (the contact dump) hold off; + * - lossless protocol responses (< 0x80) are never dropped: when the queue is + * full the put fails and the caller retries; + * - one lossy push (>= 0x80) is parked in an overflow slot and re-queued every + * 250 ms until it fits or the link goes down; a second one is dropped rather + * than overwrite it. + */ + +#pragma once + +#include +#include +#include +#include "companion_framing.h" + +#ifdef __cplusplus +extern "C" { +#endif + +struct frame_txq { + struct k_msgq *q; + uint32_t size; /* queue depth, for the water marks */ + const char *name; /* log prefix */ + bool (*link_up)(void); /* overflow is abandoned while false */ + void (*kick)(void); /* start the transport's drain */ + bool congested; + bool overflow_pending; + struct frame overflow; + struct k_work_delayable retry_work; +}; + +void frame_txq_init(struct frame_txq *t, struct k_msgq *q, uint32_t size, const char *name, + bool (*link_up)(void), void (*kick)(void)); + +/* Queue a frame and kick the drain. Returns len when queued (or parked in the + * overflow slot), 0 when it was not accepted. */ +size_t frame_txq_put(struct frame_txq *t, const uint8_t *data, uint16_t len); + +/* Next frame for the drain: 0, or -EAGAIN when the queue is empty (congestion + * is cleared then; the caller signals TX idle). Clears congestion at 1/3. */ +int frame_txq_get(struct frame_txq *t, struct frame *f); + +/* Senders should hold off: congested, or at the 2/3 high-water mark. */ +bool frame_txq_busy(const struct frame_txq *t); + +/* Nothing queued and nothing parked. */ +bool frame_txq_empty(const struct frame_txq *t); + +/* Drop everything (link down). */ +void frame_txq_reset(struct frame_txq *t); + +#ifdef __cplusplus +} +#endif diff --git a/zephcore/adapters/usb/ZephyrCompanionUSB.cpp b/zephcore/adapters/usb/ZephyrCompanionUSB.cpp index a98f636..c0885ea 100644 --- a/zephcore/adapters/usb/ZephyrCompanionUSB.cpp +++ b/zephcore/adapters/usb/ZephyrCompanionUSB.cpp @@ -2,8 +2,9 @@ * SPDX-License-Identifier: MIT * ZephCore USB CDC Companion Transport * - * V3-framed USB CDC for companion mode. Extracted from main_companion.cpp. - * Only compiled when CONFIG_LOG is enabled (debug builds). + * V3-framed companion link over USB CDC or a plain UART, with the text CLI. + * One transport among several: CompanionInterfaces.h wraps it as a + * BaseSerialInterface for the MultiSerialInterface. * * USBD lifecycle + 1200-baud DFU detection + DTR state tracking live in * the shared ZephyrUSBCDC module; this file just runs the V3 frame parser @@ -19,17 +20,14 @@ #include LOG_MODULE_REGISTER(zephcore_usb, CONFIG_ZEPHCORE_USB_LOG_LEVEL); -#include -#include - #include "ZephyrCompanionUSB.h" #include "ZephyrUSBCDC.h" - -/* MAX_FRAME_SIZE defined in CompanionMesh.h */ +#include "companion_framing.h" #define USB_RING_BUF_SIZE 512 /* USB RX ring buffer size */ #define USB_TX_RING_BUF_SIZE 2048 /* USB TX ring buffer (~13 contact frames of headroom) */ #define USB_FRAME_TIMEOUT_MS 2000 /* Partial frame timeout - reset parser after 2s of no completion */ +#define USB_RECV_QUEUE_DEPTH 4 /* complete frames waiting for the main thread */ /* Companion serial framing (MeshCore ArduinoSerialInterface): * app → device: '<' len_lo len_hi @@ -58,8 +56,7 @@ enum usb_rx_state { * cdc-acm-uart, so existing native-USB and nRF builds resolve identically. * * COMPANION_HAS_DTR is true only for the CDC backend — a plain UART has no DTR - * line, so session end there is protocol-driven (CMD_APP_START), exactly like - * the legacy SerialCompanionTransport. */ + * line, so a session there, once started, lasts until reboot. */ #if DT_HAS_CHOSEN(zephcore_companion_uart) # define COMPANION_UART_DEV DEVICE_DT_GET(DT_CHOSEN(zephcore_companion_uart)) # define COMPANION_HAS_DTR DT_NODE_HAS_COMPAT(DT_CHOSEN(zephcore_companion_uart), zephyr_cdc_acm_uart) @@ -87,25 +84,24 @@ static uint32_t usb_frame_start_time; /* Timestamp of sync byte for current fra /* TX side: interrupt-driven so the contact pump gets real backpressure + * a "drained" event (the USB analogue of BLE's notify-complete) instead of a * fixed delay. write_frame queues whole frames here under usb_tx_lock; the TX - * ISR drains into the CDC FIFO and fires s_tx_drain_cb when the ring empties. */ + * ISR drains into the CDC FIFO and raises on_tx_idle when the ring empties. */ static uint8_t usb_tx_ring_buf_data[USB_TX_RING_BUF_SIZE]; static struct ring_buf usb_tx_ring_buf; static struct k_spinlock usb_tx_lock; -/* Main-thread wake for assembled binary frames (set by init). The byte - * assembly below runs on sysworkq, but V3-protocol parsing must happen on the - * main thread (handleCmdFrame mutates mesh state shared with loop()), so - * we post this event instead of running the parser here. */ -static struct k_event *s_mesh_events; -static uint32_t s_mesh_event_ble_rx; +/* Callbacks to main (set by init): frame waiting, TX drained, session + * start/end. The byte assembly below runs on sysworkq, but V3-protocol + * parsing must happen on the main thread (handleCmdFrame mutates mesh state + * shared with loop()), so a complete frame is queued and on_rx posts the + * event instead of running the parser here. */ +static const struct companion_link_cbs *s_link; -/* Session start/end callbacks (mirror BLE on_connected / on_disconnected), - * set by main. start fires on first-frame claim, end on DTR drop. */ -static void (*s_session_start_cb)(void); -static void (*s_session_end_cb)(void); +/* Complete binary frames, drained by zephcore_usb_companion_recv() */ +K_MSGQ_DEFINE(usb_recv_queue, sizeof(struct frame), USB_RECV_QUEUE_DEPTH, 4); -/* TX-drained callback (mirrors BLE on_tx_idle) — re-kicks the contact pump. */ -static void (*s_tx_drain_cb)(void); +/* A session starts on the first traffic after open (a binary frame or a CLI + * line) and ends when the host drops DTR. */ +static bool usb_session_active; /* CLI text line callback — fired when a complete line arrives in text mode. */ static void (*s_cli_line_cb)(const char *line); @@ -120,10 +116,10 @@ static uint8_t usb_text_len; #define USB_CLI_BANNER "\r\n=== ZephCore Companion ===\r\n" static bool usb_text_banner_sent; -/* True when the USB interface was claimed by the text CLI (not a binary V3 - * companion app). While set, main suppresses binary frame/push output to USB so - * the serial console only ever sees text. Set on the claiming transition, reset - * when the session ends. */ +/* True when the session was opened by the text CLI (not a binary V3 companion + * app). While set the session reports not connected, so no binary frame or + * push reaches the serial console. Set when the session starts, reset when it + * ends. */ static bool usb_session_is_text; /* Echo text-mode bytes back via the interrupt-driven TX ring (NOT uart_poll_out, @@ -185,45 +181,31 @@ static void usb_uart_isr(const struct device *dev, void *user_data) } k_spin_unlock(&usb_tx_lock, key); - if (empty && s_tx_drain_cb) { + if (empty && s_link && s_link->on_tx_idle) { /* Channel idle — let the pump queue the next batch. */ - s_tx_drain_cb(); + s_link->on_tx_idle(); } } } } -/* Claim the interface for USB on the first inbound traffic of a session — - * a binary frame or a complete CLI line — mirroring BLE, which claims on - * connect. The official client opens with CMD_DEVICE_QUERY (0x16), not - * CMD_APP_START, so the claim is gated on any first traffic, not a specific - * opcode. Returns true when USB owns the interface and the caller should - * process the input; false while a BLE session holds it. */ -static bool usb_claim_active(uint8_t log_tag, bool is_text) +/* Start a session on its first inbound traffic — a binary frame or a complete + * CLI line. The official client opens with CMD_DEVICE_QUERY (0x16), not + * CMD_APP_START, so any first traffic starts it. Other transports (BLE, WiFi) + * may be live at the same time: every connected one is served. */ +static void usb_session_begin(uint8_t log_tag, bool is_text) { - if (zephcore_ble_get_active_iface() != ZEPHCORE_IFACE_USB) { - /* try_claim succeeds when idle or already USB (reconnect) and fails - * only while a BLE session is live, so USB can't steal it — and the - * compare-and-set can't race a concurrent BLE claim. */ - if (zephcore_ble_iface_try_claim(ZEPHCORE_IFACE_USB)) { - /* Record session kind on the claiming transition so main can - * suppress binary output for a text-CLI session. */ - usb_session_is_text = is_text; - zephcore_ble_set_enabled(false); - LOG_INF("usb_rx: first traffic 0x%02x → IFACE_USB (%s)", log_tag, - is_text ? "text" : "binary"); - /* New USB session — mirror BLE's on-connect UI notification - * (Arduino shows "connected" for serial transports too). Fires - * once per session: try_claim only returns true on NONE→USB. */ - if (s_session_start_cb) { - s_session_start_cb(); - } - } else { - LOG_INF("usb_rx: traffic 0x%02x ignored, BLE is active", log_tag); - return false; - } + if (usb_session_active) { + return; + } + usb_session_active = true; + usb_session_is_text = is_text; + LOG_INF("usb_rx: first traffic 0x%02x, session started (%s)", log_tag, + is_text ? "text" : "binary"); + /* Arduino shows "connected" for serial transports too */ + if (s_link && s_link->on_connected) { + s_link->on_connected(); } - return true; } /* USB RX work - parses V3 frames from ring buffer */ @@ -258,15 +240,12 @@ static void usb_rx_work_fn(struct k_work *work) usb_rx_st = USB_RX_LEN_LO; usb_frame_start_time = k_uptime_get_32(); } else if (byte >= 0x20 && byte <= 0x7E) { - /* Printable ASCII. Enter text CLI mode only when the - * interface is idle, or we're already in a text session - * (subsequent command lines). Never during a BLE session, - * nor a binary USB companion session — an official client's - * bytes must not be parsed as CLI. The iface read is - * side-effect-free; the claim happens later, on Enter. */ - enum zephcore_iface ifc = zephcore_ble_get_active_iface(); - if (ifc == ZEPHCORE_IFACE_NONE || - (ifc == ZEPHCORE_IFACE_USB && usb_session_is_text)) { + /* Printable ASCII. Enter text CLI mode only before a + * session starts, or in a text session (subsequent command + * lines). Never in a binary session — an official client's + * bytes must not be parsed as CLI. The session starts + * later, on Enter. */ + if (!usb_session_active || usb_session_is_text) { /* Arm the inactivity watchdog so a stray byte resyncs * to IDLE on its own. */ usb_text_len = 0; @@ -280,7 +259,7 @@ static void usb_rx_work_fn(struct k_work *work) } usb_cli_echo((const char *)&byte, 1); } - /* else: printable but iface busy/binary — ignore */ + /* else: printable in a binary session — ignore */ } /* else: ignore control bytes / noise */ break; @@ -289,14 +268,15 @@ static void usb_rx_work_fn(struct k_work *work) /* Any text byte is activity — refresh the inactivity watchdog. */ usb_frame_start_time = k_uptime_get_32(); if (byte == '\n' || byte == '\r') { - /* Line complete — dispatch if non-empty. Claim USB first - * (refused while BLE owns the session) so a CLI command can't - * mutate state out from under an active BLE app. The reply - * (companion_cli_dispatch) emits its own leading CRLF, so the - * Enter keystroke itself is not echoed — matching the repeater. */ + /* Line complete — dispatch if non-empty. The command runs + * on the main thread (companion_cli_dispatch queues it), so it + * is safe beside a live BLE or WiFi app. The reply emits its + * own leading CRLF, so the Enter keystroke itself is not + * echoed — matching the repeater. */ if (usb_text_len > 0) { usb_text_line[usb_text_len] = '\0'; - if (usb_claim_active((uint8_t)usb_text_line[0], true) && s_cli_line_cb) { + usb_session_begin((uint8_t)usb_text_line[0], true); + if (s_cli_line_cb) { s_cli_line_cb(usb_text_line); } } @@ -340,19 +320,19 @@ static void usb_rx_work_fn(struct k_work *work) LOG_DBG("usb_rx: frame complete len=%u hdr=0x%02x", payload_len, payload[0]); - /* Claim USB (refused while BLE owns the session), then process. */ - if (usb_claim_active(payload[0], false)) { - struct { - uint16_t len; - uint8_t buf[MAX_FRAME_SIZE]; - } f; - f.len = payload_len; - memcpy(f.buf, payload, payload_len); - /* Queue the frame and wake the main thread to parse it - * (parsing on sysworkq would race loop()). */ - if (k_msgq_put(zephcore_ble_get_recv_queue(), &f, K_NO_WAIT) == 0) { - k_event_post(s_mesh_events, s_mesh_event_ble_rx); + usb_session_begin(payload[0], false); + struct frame f; + + f.len = payload_len; + memcpy(f.buf, payload, payload_len); + /* Queue the frame and wake the main thread to parse it + * (parsing on sysworkq would race loop()). */ + if (k_msgq_put(&usb_recv_queue, &f, K_NO_WAIT) == 0) { + if (s_link && s_link->on_rx) { + s_link->on_rx(); } + } else { + LOG_WRN("usb_rx: recv queue full, frame 0x%02x dropped", payload[0]); } /* Reset for next frame */ @@ -375,30 +355,9 @@ static void on_dtr_change(bool dtr_active) return; } LOG_INF("usb_dtr: DTR dropped, USB disconnected"); - /* While USB owns the interface, BLE claims are rejected (see connected()), - * so this thread is the only writer of active_iface here — the get/set - * pair below needs no extra locking beyond the thread-safe accessors. */ - if (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) { - /* A USB companion session is ending — run the same per-session - * cleanup BLE does on disconnect (cancel contact iteration and - * message sync, free the sign buffer). Without this, stale sync - * state carries into the next session and an in-flight sign op - * leaks its 8KB buffer. */ - if (s_session_end_cb) { - s_session_end_cb(); - } - if (zephcore_ble_is_connected()) { - /* BLE client is physically connected — hand off to it. */ - zephcore_ble_set_active_iface(ZEPHCORE_IFACE_BLE); - LOG_INF("usb_dtr: → IFACE_BLE (BLE was connected)"); - } else { - /* Nobody connected — go idle and restart advertising - * so the phone can find the companion again. */ - zephcore_ble_set_active_iface(ZEPHCORE_IFACE_NONE); - zephcore_ble_set_enabled(true); - LOG_INF("usb_dtr: → IFACE_NONE, BLE advertising restarted"); - } - } + bool was_active = usb_session_active; + + usb_session_active = false; ring_buf_reset(&usb_ring_buf); usb_rx_st = USB_RX_IDLE; usb_frame_len = 0; @@ -406,12 +365,19 @@ static void on_dtr_change(bool dtr_active) usb_text_len = 0; usb_text_banner_sent = false; /* re-banner the next text session */ usb_session_is_text = false; + k_msgq_purge(&usb_recv_queue); /* Discard any pending TX from the closed session. */ uart_irq_tx_disable(usb_dev); k_spinlock_key_t key = k_spin_lock(&usb_tx_lock); ring_buf_reset(&usb_tx_ring_buf); k_spin_unlock(&usb_tx_lock, key); + + /* The session is over: main runs its per-session cleanup (contact dump, + * sync, sign buffer) once no other transport is still connected. */ + if (was_active && s_link && s_link->on_disconnected) { + s_link->on_disconnected(); + } } #endif /* COMPANION_HAS_DTR */ @@ -476,23 +442,28 @@ bool zephcore_usb_companion_tx_has_space(size_t payload_len) return ok; } -void zephcore_usb_companion_reset_rx(void) +size_t zephcore_usb_companion_recv(uint8_t *dest) { - ring_buf_reset(&usb_ring_buf); - usb_rx_st = USB_RX_IDLE; - usb_frame_len = 0; - usb_rx_idx = 0; - usb_text_len = 0; - usb_text_banner_sent = false; - usb_session_is_text = false; + struct frame f; - /* Drop any half-sent TX too — the session it belonged to is gone. */ - if (usb_dev) { - uart_irq_tx_disable(usb_dev); + if (k_msgq_get(&usb_recv_queue, &f, K_NO_WAIT) != 0) { + return 0; } - k_spinlock_key_t key = k_spin_lock(&usb_tx_lock); - ring_buf_reset(&usb_tx_ring_buf); - k_spin_unlock(&usb_tx_lock, key); + memcpy(dest, f.buf, f.len); + return f.len; +} + +bool zephcore_usb_companion_is_connected(void) +{ + return usb_session_active && !usb_session_is_text; +} + +/* Only a connected session is ever busy: MultiSerialInterface asks every + * enabled transport, and this one is always enabled. */ +bool zephcore_usb_companion_is_write_busy(void) +{ + return zephcore_usb_companion_is_connected() && + !zephcore_usb_companion_tx_has_space(MAX_FRAME_SIZE); } bool zephcore_usb_companion_is_text_session(void) @@ -500,21 +471,6 @@ bool zephcore_usb_companion_is_text_session(void) return usb_session_is_text; } -void zephcore_usb_companion_set_session_start_cb(void (*cb)(void)) -{ - s_session_start_cb = cb; -} - -void zephcore_usb_companion_set_session_end_cb(void (*cb)(void)) -{ - s_session_end_cb = cb; -} - -void zephcore_usb_companion_set_tx_drain_cb(void (*cb)(void)) -{ - s_tx_drain_cb = cb; -} - void zephcore_usb_companion_set_cli_line_cb(void (*cb)(const char *line)) { s_cli_line_cb = cb; @@ -531,14 +487,9 @@ void zephcore_usb_companion_write_text(const char *text, size_t len) uart_irq_tx_enable(usb_dev); } -void zephcore_usb_companion_init(struct k_event *mesh_events, - uint32_t mesh_event_ble_rx, - void *board) +void zephcore_usb_companion_init(const struct companion_link_cbs *link) { - ARG_UNUSED(board); - - s_mesh_events = mesh_events; - s_mesh_event_ble_rx = mesh_event_ble_rx; + s_link = link; /* COMPANION_UART_DEV resolves to the chosen `zephcore,companion-uart` node, * or the sole cdc-acm-uart for back-compat (see the backend block above). diff --git a/zephcore/adapters/usb/ZephyrCompanionUSB.h b/zephcore/adapters/usb/ZephyrCompanionUSB.h index 9473b3e..a71f402 100644 --- a/zephcore/adapters/usb/ZephyrCompanionUSB.h +++ b/zephcore/adapters/usb/ZephyrCompanionUSB.h @@ -1,45 +1,50 @@ /* * SPDX-License-Identifier: MIT - * ZephCore USB CDC Companion Transport + * ZephCore wired companion transport (USB CDC or plain UART) * - * V3-framed USB CDC for companion mode: ISR, ring buffer, frame parsing, - * DTR monitoring, DFU trigger, and frame write. + * V3 framing plus the text CLI: ISR, ring buffers, frame parser, DTR + * monitoring. One transport among several: CompanionInterfaces.h wraps it as + * a BaseSerialInterface for the MultiSerialInterface. */ + #pragma once #include #include #include +#include "companion_framing.h" + #ifdef __cplusplus extern "C" { #endif /** - * Initialize USB CDC companion transport. - * Sets up ring buffer, UART ISR, and DTR polling. - * Call after device_is_ready() for the CDC ACM device. - * - * @param mesh_events Pointer to the k_event used by the mesh event loop - * @param mesh_event_ble_rx Bitmask for MESH_EVENT_BLE_RX (posted to wake the - * main thread when a complete frame is assembled) - * @param board Opaque pointer to ZephyrBoard (for DFU reboot) + * Initialize the transport: ring buffers, UART ISR, DTR tracking. + * @param link frame waiting / TX drained / session start / session end + * (DTR drop). Raised from the ISR and sysworkq: post events only. */ -void zephcore_usb_companion_init(struct k_event *mesh_events, - uint32_t mesh_event_ble_rx, - void *board); +void zephcore_usb_companion_init(const struct companion_link_cbs *link); /** - * Queue a frame for interrupt-driven USB TX (sync byte + LE length + payload). + * Queue a frame for interrupt-driven TX (sync byte + LE length + payload). * @return number of payload bytes queued, or 0 if the TX ring can't fit the - * whole frame (caller should back off and retry on the drain callback) + * whole frame (caller should back off and retry on TX idle) */ size_t zephcore_usb_companion_write_frame(const uint8_t *src, size_t len); +/** Take the next received binary frame into dest (MAX_FRAME_SIZE). Returns its length, 0 if none. */ +size_t zephcore_usb_companion_recv(uint8_t *dest); + +/** A binary companion session is open (a text-CLI session is not a client). */ +bool zephcore_usb_companion_is_connected(void); + +/** The TX ring cannot take another full-size frame. */ +bool zephcore_usb_companion_is_write_busy(void); + /** * @return true if the TX ring can hold one more frame carrying `payload_len` - * bytes (plus 3 bytes of framing). Used by the contact pump to pace - * itself against real TX backpressure instead of a fixed delay. + * bytes (plus 3 bytes of framing). */ bool zephcore_usb_companion_tx_has_space(size_t payload_len); @@ -47,33 +52,6 @@ bool zephcore_usb_companion_tx_has_space(size_t payload_len); * is bound (nothing to wait for). Counterpart to zephcore_ble_tx_idle(). */ bool zephcore_usb_companion_tx_idle(void); -/** - * Register a callback fired when the TX ring fully drains (USB analogue of the - * BLE on_tx_idle notify-complete event). Used to resume the contact pump. May - * be NULL. Invoked from the CDC TX interrupt-callback context. - */ -void zephcore_usb_companion_set_tx_drain_cb(void (*cb)(void)); - -/** - * Reset USB RX state (e.g., on BLE disconnect). - */ -void zephcore_usb_companion_reset_rx(void); - -/** - * Register a callback invoked when a USB companion session starts — i.e. the - * first inbound frame claims the interface for USB. Mirrors BLE on_connected - * (e.g. light the UI connection indicator). May be NULL. - */ -void zephcore_usb_companion_set_session_start_cb(void (*cb)(void)); - -/** - * Register a callback invoked when a USB companion session ends — i.e. the - * host closed the port (DTR dropped) while USB owned the interface. Mirrors - * the BLE on_disconnected cleanup: cancel in-flight contact iteration and - * message sync, free the sign buffer. May be NULL. - */ -void zephcore_usb_companion_set_session_end_cb(void (*cb)(void)); - /** * Register a callback fired when a complete text CLI line arrives over USB. * Activated when the first byte of a session is not the V3 sync byte ('<'). @@ -88,10 +66,9 @@ void zephcore_usb_companion_set_cli_line_cb(void (*cb)(const char *line)); void zephcore_usb_companion_write_text(const char *text, size_t len); /** - * True when the active USB session was opened by the text CLI rather than a - * binary V3 companion app. Main uses this to suppress binary frame/push output - * to USB so the serial console only ever receives text. False when no USB - * session or when a binary companion owns the interface. + * True when the USB session was opened by the text CLI rather than a binary + * V3 companion app. Such a session reports not connected, so no binary frame + * or push reaches the serial console. */ bool zephcore_usb_companion_is_text_session(void); diff --git a/zephcore/adapters/wifi/ZephyrWiFiStation.c b/zephcore/adapters/wifi/ZephyrWiFiStation.c index 2fc878b..b6fae1c 100644 --- a/zephcore/adapters/wifi/ZephyrWiFiStation.c +++ b/zephcore/adapters/wifi/ZephyrWiFiStation.c @@ -10,7 +10,6 @@ */ #include "ZephyrWiFiStation.h" -#include "observer_creds.h" #include "../../helpers/pm_sleep_guard.h" #include @@ -18,7 +17,10 @@ #include #include #include +#include +#if IS_ENABLED(CONFIG_SNTP) #include +#endif #include #include @@ -32,7 +34,8 @@ K_EVENT_DEFINE(g_wifi_events); /* ========== Module state ========== */ -static const struct ObserverCreds *s_creds; +static const char *s_ssid; +static const char *s_psk; static void (*s_time_sync_cb)(uint32_t unix_ts); /* Protect link-up vs. SNTP state */ @@ -51,6 +54,7 @@ static struct net_mgmt_event_callback ipv4_cb; static void do_sntp_and_signal(void) { +#if IS_ENABLED(CONFIG_SNTP) struct sntp_time ts; int rc = sntp_simple("pool.ntp.org", 8000, &ts); if (rc == 0) { @@ -61,6 +65,7 @@ static void do_sntp_and_signal(void) } else { LOG_WRN("SNTP failed (rc=%d) — continuing without time sync", rc); } +#endif s_wifi_ready = true; k_event_post(&g_wifi_events, WIFI_READY_BIT); @@ -97,7 +102,7 @@ static void wifi_event_handler(struct net_mgmt_event_callback *cb, int reason = 0; #endif if (success) { - LOG_INF("WiFi link up (SSID: %s)", s_creds ? s_creds->wifi_ssid : "?"); + LOG_INF("WiFi link up (SSID: %s)", s_ssid ? s_ssid : "?"); s_wifi_link_up = true; /* Disable power save — WIFI_PS_MIN_MODEM (default) sleeps between * DTIM beacons, which delays ACKs and causes the MQTT broker's TCP @@ -138,7 +143,7 @@ static void connect_work_fn(struct k_work *work) { ARG_UNUSED(work); - if (!s_creds || s_creds->wifi_ssid[0] == '\0') { + if (!s_ssid || s_ssid[0] == '\0') { LOG_WRN("No WiFi SSID configured — set with: set wifi.ssid "); return; } @@ -150,22 +155,22 @@ static void connect_work_fn(struct k_work *work) } struct wifi_connect_req_params params = { - .ssid = (const uint8_t *)s_creds->wifi_ssid, - .ssid_length = (uint8_t)strlen(s_creds->wifi_ssid), + .ssid = (const uint8_t *)s_ssid, + .ssid_length = (uint8_t)strlen(s_ssid), .channel = WIFI_CHANNEL_ANY, .band = WIFI_FREQ_BAND_UNKNOWN, .mfp = WIFI_MFP_OPTIONAL, }; - if (s_creds->wifi_psk[0] != '\0') { - params.psk = (const uint8_t *)s_creds->wifi_psk; - params.psk_length = (uint8_t)strlen(s_creds->wifi_psk); + if (s_psk && s_psk[0] != '\0') { + params.psk = (const uint8_t *)s_psk; + params.psk_length = (uint8_t)strlen(s_psk); params.security = WIFI_SECURITY_TYPE_PSK; } else { params.security = WIFI_SECURITY_TYPE_NONE; } - LOG_INF("Connecting to WiFi: %s", s_creds->wifi_ssid); + LOG_INF("Connecting to WiFi: %s", s_ssid); int rc = net_mgmt(NET_REQUEST_WIFI_CONNECT, iface, ¶ms, sizeof(params)); if (rc < 0 && rc != -EALREADY) { LOG_ERR("WiFi connect request failed: %d — retry in 10s", rc); @@ -175,10 +180,11 @@ static void connect_work_fn(struct k_work *work) /* ========== Public API ========== */ -void zc_wifi_station_start(const struct ObserverCreds *creds, +void zc_wifi_station_start(const char *ssid, const char *psk, void (*time_sync_cb)(uint32_t unix_ts)) { - s_creds = creds; + s_ssid = ssid; + s_psk = psk; s_time_sync_cb = time_sync_cb; s_wifi_link_up = false; s_wifi_ready = false; @@ -227,5 +233,28 @@ bool zc_wifi_station_is_connected(void) const char *zc_wifi_station_ssid(void) { - return (s_creds && s_creds->wifi_ssid[0]) ? s_creds->wifi_ssid : ""; + return (s_ssid && s_ssid[0]) ? s_ssid : ""; +} + +bool zc_wifi_station_ip(char *buf, size_t len) +{ + struct net_if *iface = net_if_get_default(); + + if (!iface || !s_wifi_link_up) { + return false; + } + + struct net_if_ipv4 *ipv4 = iface->config.ip.ipv4; + + if (!ipv4) { + return false; + } + for (int i = 0; i < NET_IF_MAX_IPV4_ADDR; i++) { + struct net_if_addr *a = &ipv4->unicast[i].ipv4; + + if (a->is_used && a->addr_state == NET_ADDR_PREFERRED) { + return net_addr_ntop(NET_AF_INET, &a->address.in_addr, buf, len) != NULL; + } + } + return false; } diff --git a/zephcore/adapters/wifi/ZephyrWiFiStation.h b/zephcore/adapters/wifi/ZephyrWiFiStation.h index c956a13..f3d6cbc 100644 --- a/zephcore/adapters/wifi/ZephyrWiFiStation.h +++ b/zephcore/adapters/wifi/ZephyrWiFiStation.h @@ -1,15 +1,18 @@ /* * SPDX-License-Identifier: MIT - * ZephyrWiFiStation — WiFi STA connection manager for the Observer role. + * ZephyrWiFiStation — WiFi STA connection manager: the observer, the repeater + * uplink and the WiFi companion. * * Connects to a WPA2-PSK (or open) network, obtains a DHCP lease, syncs - * time via SNTP, then signals g_wifi_events / WIFI_READY_BIT so the MQTT - * publisher thread can start. Auto-reconnects on link loss. + * time via SNTP (when CONFIG_SNTP), then signals g_wifi_events / + * WIFI_READY_BIT so the MQTT publisher thread can start. Auto-reconnects on + * link loss. */ #pragma once #include +#include #include #include @@ -17,8 +20,6 @@ extern "C" { #endif -struct ObserverCreds; - /* * Event object and bit shared with ZephyrMQTTPublisher so it can wait * for WiFi+SNTP to be ready before connecting to the broker. @@ -29,12 +30,13 @@ extern struct k_event g_wifi_events; /* * Start the WiFi STA subsystem. - * creds — runtime credentials (SSID, PSK); pointer kept, must remain valid. + * ssid, psk — the network; pointers kept (read at every connect), so they + * must remain valid. psk "" = open network. * time_sync_cb — called once after SNTP with Unix timestamp; may be NULL. * * Must be called once from main() before any other wifi_station_* calls. */ -void zc_wifi_station_start(const struct ObserverCreds *creds, +void zc_wifi_station_start(const char *ssid, const char *psk, void (*time_sync_cb)(uint32_t unix_ts)); /* @@ -49,6 +51,9 @@ bool zc_wifi_station_is_connected(void); /* Returns the SSID currently connected to, or an empty string. */ const char *zc_wifi_station_ssid(void); +/* The interface's IPv4 address as text into buf; false when there is none. */ +bool zc_wifi_station_ip(char *buf, size_t len); + #ifdef __cplusplus } #endif diff --git a/zephcore/app/CompanionCLI.h b/zephcore/app/CompanionCLI.h new file mode 100644 index 0000000..07a56d6 --- /dev/null +++ b/zephcore/app/CompanionCLI.h @@ -0,0 +1,187 @@ +/* + * SPDX-License-Identifier: MIT + * CompanionCLICallbacks: the companion's side of CommonCLI (prefs, identity, + * radio, GPS, stats). main_companion owns the instance and the CommonCLI. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include "CompanionMesh.h" + +class CompanionCLICallbacks : public CommonCLICallbacks { + ZephyrDataStore& _store; + CompanionMesh& _mesh; + mesh::LoRaRadio& _radio; + mesh::ZephyrBoard& _board; + mesh::MillisecondClock& _ms; + mesh::PacketManager& _pool; + bool (*_tx_idle)(void); + +public: + /* tx_idle: the transport has sent everything queued (main_companion + * knows the pending reply and which transport is active). */ + CompanionCLICallbacks(ZephyrDataStore& store, CompanionMesh& mesh, mesh::LoRaRadio& radio, + mesh::ZephyrBoard& board, mesh::MillisecondClock& ms, + mesh::PacketManager& pool, bool (*tx_idle)(void)) + : _store(store), _mesh(mesh), _radio(radio), _board(board), _ms(ms), _pool(pool), + _tx_idle(tx_idle) {} + + void savePrefs() override { + _store.savePrefs(_mesh.prefs); + } + const char* getFirmwareVer() override { return FIRMWARE_VERSION; } + const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } + const char* getRole() override { return "companion"; } + /* CLI `erase`: the factory reset, which keeps the volume marked as ours + * so the next boot does not format it a second time. */ + bool formatFileSystem() override { return _store.factoryReset(); } + + /* Same path as the app's advert command; delay_millis is unused. */ + void sendSelfAdvertisement(int delay_millis, bool flood) override { + (void)delay_millis; + _mesh.sendSelfAdvert(flood); + } + void updateAdvertTimer() override {} + void updateFloodAdvertTimer() override {} + + /* Reboot gate: true once the app has everything we owe it, including a + * held-back v-contact ack, so a `reboot` typed there waits for its ack. */ + bool transportTxIdle() override { + return !_mesh.vcontactConfirmPending() && _tx_idle(); + } + + /* No log file on a companion. */ + void setLoggingOn(bool enable) override { (void)enable; } + void eraseLogFile() override {} + void dumpLogFile() override {} + + /* No runtime TX-power API in the LoRa driver; log only, as the repeater. */ + void setTxPower(int8_t power_dbm) override { + LOG_INF("TX power %d dBm requested (reboot to apply)", power_dbm); + } + + bool setRxBoostedGain(bool enable) override { + return _radio.setRxBoost(enable); + } + + bool setFemRxGain(bool enable) override { + return _radio.setFemRxEnable(enable); + } + + bool configSideDetectors(const uint8_t* sfs, uint8_t num) override { + return _radio.configSideDetectors(sfs, num); + } + + /* Adaptive CAD */ + int formatFreqErrorStatus(char* buf, int cap) override { + return _radio.formatFreqErrorStatus(buf, cap); + } + int formatCadStatus(char* buf, int cap) override { + return _radio.formatCadStatus(buf, cap); + } + void applyCadPrefs() override { + _radio.setCadParams(_mesh.prefs.cad_auto != 0, + _mesh.prefs.cad_offset, + _mesh.prefs.probe_interval, + _mesh.prefs.cad_busycap, + _mesh.prefs.cad_base); + _mesh.prefs.cad_offset = _radio.getCadOffset(); + _mesh.prefs.cad_base = _radio.cadBasePeak(); + } + void resetCadStats() override { + _radio.resetCadStats(); + } + + mesh::LocalIdentity& getSelfId() override { return _mesh.self_id; } + + void saveIdentity(const mesh::LocalIdentity& new_id) override { + _mesh.self_id = new_id; + _store.saveMainIdentity(new_id); + } + + void clearStats() override { + _radio.resetStats(); + _radio.resetDutyCycleTimeoutRestarts(); + _mesh.resetStats(); + } + + /* Duty-cycle false-preamble re-arm count. Without these the + * CommonCLICallbacks default answers 0 forever, so `get dc.restarts` + * read clean on every companion regardless of what the radio was doing + * — and the companion is the role the duty cycle actually runs in. + * Mirrors RepeaterMesh::getDutyCycleTimeoutRestarts(). */ + uint32_t getDutyCycleTimeoutRestarts() const override { + return _radio.getDutyCycleTimeoutRestarts(); + } + + void resetDutyCycleTimeoutRestarts() override { + _radio.resetDutyCycleTimeoutRestarts(); + } + + /* Temp radio params — deferred; stub for now. */ + void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, + int timeout_mins) override { + (void)freq; (void)bw; (void)sf; (void)cr; (void)timeout_mins; + } + + MeshTimeSync* getMeshTimeSync() override { + return _mesh.getMeshTimeSync(); + } + + /* GPS, through the GPS manager as on the repeater. */ + bool setGpsEnabled(bool enabled) override { + if (!gps_is_available()) return false; + gps_enable(enabled); + return true; + } + bool isGpsEnabled() const override { + return gps_is_enabled(); + } + void formatGpsStatsReply(char* reply) override { + if (!gps_is_enabled()) { + strcpy(reply, "off"); + return; + } + struct gps_state_info gsi; + gps_get_state_info(&gsi); + static const char* const state_str[] = { "off", "standby", "acquiring" }; + const char* state = gsi.state < 3 ? state_str[gsi.state] : "unknown"; + struct gps_position pos; + bool has_pos = gps_get_last_known_position(&pos); + if (has_pos) { + snprintf(reply, CLI_REPLY_SIZE, + "on state=%s sats=%u fix=%us ago lat=%.6f lon=%.6f", + state, gsi.satellites, gsi.last_fix_age_s, + pos.latitude_ndeg / 1e9, pos.longitude_ndeg / 1e9); + } else if (gsi.next_search_s > 0) { + snprintf(reply, CLI_REPLY_SIZE, + "on state=%s sats=%u no fix next=%us", + state, gsi.satellites, gsi.next_search_s); + } else { + snprintf(reply, CLI_REPLY_SIZE, + "on state=%s sats=%u no fix", + state, gsi.satellites); + } + } + + /* stats-core / stats-radio / stats-packets, as the repeater prints them. */ + void formatStatsReply(char* reply) override { + StatsFormatHelper::formatCoreStats(reply, _board, _ms, + _mesh.getErrFlags(), &_pool); + } + void formatRadioStatsReply(char* reply) override { + StatsFormatHelper::formatRadioStats(reply, &_radio, _radio, + _mesh.getTotalAirTime(), _mesh.getReceiveAirTime()); + } + void formatPacketStatsReply(char* reply) override { + StatsFormatHelper::formatPacketStats(reply, _radio, + _mesh.getNumSentFlood(), _mesh.getNumSentDirect(), + _mesh.getNumRecvFlood(), _mesh.getNumRecvDirect()); + } +}; diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 2d74e5b..f34117c 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -14,7 +14,9 @@ #include #include #include +#if IS_ENABLED(CONFIG_BT) #include +#endif #include #include #include @@ -64,8 +66,7 @@ CompanionMesh::CompanionMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mes ZephyrDataStore &store) : BaseChatMesh(radio, ms, rng, rtc, mgr, tables), _store(&store) { - _push_cb = nullptr; - _write_cb = nullptr; + _serial = nullptr; _batt_cb = nullptr; _radio_reconfig_cb = nullptr; _pin_change_cb = nullptr; @@ -319,13 +320,16 @@ void CompanionMesh::flushDirtyChannels() } } +void CompanionMesh::startInterface(BaseSerialInterface &serial) +{ + _serial = &serial; + serial.enable(); +} + bool CompanionMesh::writeFrame(const uint8_t *data, size_t len) { LOG_DBG("RSP: 0x%02x len=%u", data[0], (unsigned)len); - if (_write_cb) { - return _write_cb(data, len) == len; - } - return false; + return _serial && _serial->writeFrame(data, len) == len; } void CompanionMesh::writeOKFrame() @@ -350,14 +354,25 @@ void CompanionMesh::sendPacketSent(uint8_t result, uint32_t tag, uint32_t est_ti writeFrame(rsp, sizeof(rsp)); } +/* [push_code][data...], only while an app is listening (pushes are lossy) */ void CompanionMesh::sendPush(uint8_t code, const uint8_t *data, size_t len) { - LOG_DBG("sendPush: code=0x%02x len=%u _push_cb=%s", code, (unsigned)len, _push_cb ? "set" : "NULL"); - if (_push_cb) { - _push_cb(code, data, len); - } else { - LOG_WRN("sendPush: _push_cb is NULL, push lost!"); + if (!_serial || !_serial->isConnected()) { + return; } + + uint8_t frame[MAX_FRAME_SIZE]; + + if (1 + len > MAX_FRAME_SIZE) { + LOG_WRN("push 0x%02x: data too long %u", code, (unsigned)len); + len = MAX_FRAME_SIZE - 1; + } + frame[0] = code; + if (len > 0 && data != nullptr) { + memcpy(&frame[1], data, len); + } + LOG_DBG("sendPush: code=0x%02x len=%u", code, (unsigned)(1 + len)); + writeFrame(frame, 1 + len); } size_t CompanionMesh::serializeContact(uint8_t *buf, const ContactInfo &c, uint8_t header) @@ -2233,8 +2248,10 @@ bool CompanionMesh::handleCmdSetAdvertName(const uint8_t *data, size_t len) memcpy(prefs.node_name, &data[1], nlen); prefs.node_name[nlen] = '\0'; _store->savePrefs(prefs); +#if IS_ENABLED(CONFIG_BT) /* Push the new name to BLE so scanners see it without a reboot. */ zephcore_ble_update_name(prefs.node_name); +#endif /* v-contact name tracks the node name — update the app's copy. */ vcontactPushAdvert(); } @@ -2523,7 +2540,9 @@ bool CompanionMesh::handleCmdSyncNextMessage(const uint8_t *data, size_t len) /* Initial sync is done — safe to apply deferred * connection parameters now without disrupting * channel/contact/message throughput. */ +#if IS_ENABLED(CONFIG_BT) zephcore_ble_conn_params_ready(); +#endif /* Full initial sync (contacts + messages) complete: stop * suppressing v-contact notice prompts. Any notice queued during diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 8a768c5..b4e4ce2 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -6,6 +6,7 @@ #pragma once #include +#include #include #include #include @@ -45,11 +46,6 @@ #define AUTO_ADD_ROOM_SERVER (1 << 3) #define AUTO_ADD_SENSOR (1 << 4) -/* Canonical definition is in ZephyrBLE.h */ -#ifndef MAX_FRAME_SIZE -#define MAX_FRAME_SIZE 176 -#endif - /* 1 header + 32 pubkey + 1 type + 1 flags + 1 path_len + 64 path + 32 name + 4*4 fields */ #define CONTACT_FRAME_SIZE 148 @@ -80,8 +76,6 @@ struct AdvertPath { uint8_t path[MAX_PATH_SIZE]; }; -typedef void (*PushCallback)(uint8_t code, const uint8_t *data, size_t len); -typedef size_t (*WriteFrameCallback)(const uint8_t *data, size_t len); typedef uint16_t (*GetBatteryCallback)(void); /* preset_changed: freq/bw/sf moved, not just TX power; the adaptive-CAD state @@ -119,8 +113,8 @@ public: /* One companion-protocol frame from the app; true if handled. */ bool handleCmdFrame(const uint8_t *data, size_t len); - void setPushCallback(PushCallback cb) { _push_cb = cb; } - void setWriteFrameCallback(WriteFrameCallback cb) { _write_cb = cb; } + /* As upstream: every frame to and from the app goes through `serial` */ + void startInterface(BaseSerialInterface &serial); void setBatteryCallback(GetBatteryCallback cb) { _batt_cb = cb; } void setRadioReconfigureCallback(RadioReconfigureCallback cb) { _radio_reconfig_cb = cb; } void setPinChangeCallback(PinChangeCallback cb) { _pin_change_cb = cb; } @@ -281,8 +275,7 @@ protected: private: ZephyrDataStore *_store; - PushCallback _push_cb; - WriteFrameCallback _write_cb; + BaseSerialInterface *_serial; GetBatteryCallback _batt_cb; RadioReconfigureCallback _radio_reconfig_cb; PinChangeCallback _pin_change_cb; diff --git a/zephcore/app/CompanionSerial.h b/zephcore/app/CompanionSerial.h new file mode 100644 index 0000000..2921c69 --- /dev/null +++ b/zephcore/app/CompanionSerial.h @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: MIT + * The companion's _serial: upstream's MultiSerialInterface, plus the one rule + * ZephCore adds on top of it — a lossless reply is never lost to a full + * transport. + * + * A reply (code < 0x80) the transports could not take is held here and + * reported as sent, so callers that retry (contact and message sync) do not + * send it twice. While it is held, nothing else is written and no command is + * read: the app sees replies in order. retry() pushes it out once a transport + * has room; drop() discards it when the session ends. Pushes (>= 0x80) stay + * lossy. Main thread only. + */ + +#pragma once + +#include +#include + +class CompanionSerial : public BaseSerialInterface { + BaseSerialInterface &_inner; + uint16_t _held_len = 0; /* 0 = nothing held */ + uint8_t _held[MAX_FRAME_SIZE]; + +public: + explicit CompanionSerial(BaseSerialInterface &inner) : _inner(inner) {} + + void enable() override { _inner.enable(); } + void disable() override { _inner.disable(); } + bool isEnabled() const override { return _inner.isEnabled(); } + bool isConnected() const override { return _inner.isConnected(); } + void loop() override { _inner.loop(); } + bool isWriteBusy() const override { return _held_len != 0 || _inner.isWriteBusy(); } + + size_t writeFrame(const uint8_t src[], size_t len) override + { + if (len == 0 || len > MAX_FRAME_SIZE || _held_len != 0) { + return 0; + } + /* Nobody to hold it for; holding would stall the next session */ + if (!_inner.isConnected()) { + return 0; + } + if (_inner.writeFrame(src, len) == len) { + return len; + } + if (src[0] < 0x80) { + memcpy(_held, src, len); + _held_len = (uint16_t)len; + return len; + } + return 0; + } + + size_t checkRecvFrame(uint8_t dest[]) override + { + return _held_len != 0 ? 0 : _inner.checkRecvFrame(dest); + } + + /* Send the held reply if a transport has room now. */ + void retry() + { + if (_held_len == 0) { + return; + } + if (!_inner.isConnected() || _inner.writeFrame(_held, _held_len) == _held_len) { + _held_len = 0; + } + } + + void drop() { _held_len = 0; } + bool holding() const { return _held_len != 0; } +}; diff --git a/zephcore/app/CompanionWifi.cpp b/zephcore/app/CompanionWifi.cpp new file mode 100644 index 0000000..cbbbe08 --- /dev/null +++ b/zephcore/app/CompanionWifi.cpp @@ -0,0 +1,96 @@ +/* + * SPDX-License-Identifier: MIT + * WiFi companion — see CompanionWifi.h. The CLI below is upstream's + * (examples/companion_radio/MyMesh.cpp, ENABLE_WIFI_INTERFACE), on our prefs + * and ZephyrWiFiStation instead of Arduino's WiFi class. + */ + +#include "CompanionWifi.h" + +#include +#include +#include +#include +#include +#include + +LOG_MODULE_REGISTER(zephcore_wifi_companion, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); + +/* The station reads its SSID and password at every (re)connect; these copies + * keep a CLI change from taking effect before the reboot it asks for. */ +static char s_ssid[sizeof(((NodePrefs *)0)->wifi_ssid)]; +static char s_pwd[sizeof(((NodePrefs *)0)->wifi_pwd)]; + +void companion_wifi_start(const NodePrefs &prefs) +{ + if (!prefs.wifi_enabled) { + LOG_INF("WiFi companion off (wifi.enabled 0)"); + return; + } + if (prefs.wifi_ssid[0] == '\0') { + LOG_INF("WiFi companion: no network set (set wifi.ssid, wifi.pwd, reboot)"); + return; + } + memcpy(s_ssid, prefs.wifi_ssid, sizeof(s_ssid)); + memcpy(s_pwd, prefs.wifi_pwd, sizeof(s_pwd)); + LOG_INF("WiFi companion: joining %s", s_ssid); + /* No SNTP time: the app sets the companion's clock, and time only moves + * forward (see time_sync.h). */ + zc_wifi_station_start(s_ssid, s_pwd, nullptr); +} + +bool companion_wifi_cli(const char *command, NodePrefs &prefs, void (*save)(void), char *reply) +{ + if (memcmp(command, "set wifi.ssid ", 14) == 0) { + StrHelper::strncpy(prefs.wifi_ssid, &command[14], sizeof(prefs.wifi_ssid)); + save(); + sprintf(reply, "> wifi.ssid is now %s (set wifi.pwd too, then reboot)", prefs.wifi_ssid); + return true; + } + if (memcmp(command, "set wifi.pwd ", 13) == 0) { + StrHelper::strncpy(prefs.wifi_pwd, &command[13], sizeof(prefs.wifi_pwd)); + save(); + strcpy(reply, "> wifi.pwd updated (reboot to apply)"); + return true; + } + if (strcmp(command, "get wifi.pwd") == 0) { + sprintf(reply, "> %s", prefs.wifi_pwd); + return true; + } + if (strcmp(command, "set wifi.clear") == 0) { + prefs.wifi_ssid[0] = 0; + prefs.wifi_pwd[0] = 0; + save(); + strcpy(reply, "> wifi config cleared (reboot to apply)"); + return true; + } + if (strcmp(command, "get wifi.ssid") == 0) { + sprintf(reply, "> %s", prefs.wifi_ssid[0] ? prefs.wifi_ssid : "(not set)"); + return true; + } + if (memcmp(command, "set wifi.enabled ", 17) == 0) { + prefs.wifi_enabled = atoi(&command[17]) ? 1 : 0; + save(); + sprintf(reply, "> wifi.enabled is now %d (reboot to apply)", prefs.wifi_enabled); + return true; + } + if (strcmp(command, "get wifi.enabled") == 0) { + sprintf(reply, "> %d", prefs.wifi_enabled); + return true; + } + if (strcmp(command, "get wifi.status") == 0) { + strcpy(reply, zc_wifi_station_is_connected() ? "> connected" : "> disconnected"); + return true; + } + if (strcmp(command, "get wifi.ip") == 0) { + char ip[16]; + + if (zc_wifi_station_ip(ip, sizeof(ip))) { + sprintf(reply, "> %s", ip); + } else { + strcpy(reply, "> (not connected)"); + } + return true; + } + return false; +} diff --git a/zephcore/app/CompanionWifi.h b/zephcore/app/CompanionWifi.h new file mode 100644 index 0000000..0de65e9 --- /dev/null +++ b/zephcore/app/CompanionWifi.h @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: MIT + * WiFi companion (CONFIG_ZEPHCORE_COMPANION_WIFI): join the saved network so + * the app can reach the node over TCP (TcpCompanionTransport, one more + * interface beside BLE and USB), and upstream's wifi.* CLI. + */ + +#pragma once + +#include + +/* Join the saved network, when enabled and set. Boot only: as upstream, every + * wifi.* change applies on the next reboot. */ +void companion_wifi_start(const NodePrefs &prefs); + +/* Upstream's companion wifi.* commands. True when `command` was one of them + * (reply filled); save persists the prefs. */ +bool companion_wifi_cli(const char *command, NodePrefs &prefs, void (*save)(void), char *reply); diff --git a/zephcore/app/ObserverMesh.cpp b/zephcore/app/ObserverMesh.cpp index 2113e24..84c6c7e 100644 --- a/zephcore/app/ObserverMesh.cpp +++ b/zephcore/app/ObserverMesh.cpp @@ -11,7 +11,6 @@ #include /* generateFirstBootIdentity (hardened keygen) */ #include -#include #include LOG_MODULE_REGISTER(zephcore_observer, CONFIG_ZEPHCORE_OBSERVER_LOG_LEVEL); @@ -58,32 +57,10 @@ void ObserverMesh::begin(RepeaterDataStore *store, struct ObserverCreds *creds) _prefs.tx_power_dbm = 0; /* observer never TXes anyway */ /* freq=869.618, bw=62.5, sf=7 already set by initNodePrefs */ - /* First boot has to be detected BEFORE loadPrefs(): the store is shared - * with the repeater and its no-file branch re-runs initNodePrefs(), applies - * *repeater* defaults over whatever the caller passed in, saves them, and - * returns true. So the observer values set above are silently discarded on - * a fresh unit and there is no return code that says so. Probing for the - * file is the only observer-local way to tell — the alternative, changing - * the no-file branch, would alter repeater and room-server behaviour. */ - char prefs_path[64]; - struct fs_dirent prefs_ent; - snprintf(prefs_path, sizeof(prefs_path), "%s/prefs", _store->getBasePath()); - const bool first_boot = (fs_stat(prefs_path, &prefs_ent) < 0); - - /* Load persisted prefs (overrides defaults with saved values) */ + /* Persisted prefs override the defaults above; on a fresh unit the store + * saves these defaults as they are. */ _store->loadPrefs(_prefs); - if (first_boot) { - /* Re-apply the observer defaults the shared no-file branch overwrote, - * then persist them so this runs exactly once. Only the prefs file is - * rewritten — obs_creds (WiFi/MQTT/IATA/lat/lon) is a separate file and - * is never touched here. */ - _prefs.cr = 5; - _prefs.tx_power_dbm = 0; - _store->savePrefs(_prefs); - LOG_INF("First boot — saved observer prefs defaults"); - } - /* Load or generate node identity. * * Use ZephyrRNG::generateFirstBootIdentity — the SAME hardened path the diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index 50ba53e..4a9a439 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -5,6 +5,10 @@ #include "RepeaterDataStore.h" #include "../adapters/datastore/ZephyrFsFormat.h" +#include "../adapters/datastore/ZephyrFsUtil.h" +#include "../adapters/datastore/IdentityFile.h" +#include "../adapters/datastore/PrefsFile.h" +#include #include #include #include @@ -39,11 +43,6 @@ bool RepeaterDataStore::begin() { const char* RepeaterDataStore::getBasePath() const { return BASE_PATH; } -static bool fileExists(const char* path) { - struct fs_dirent entry; - return fs_stat(path, &entry) == 0; -} - bool RepeaterDataStore::hasRoleData() const { char path[64]; @@ -61,304 +60,101 @@ bool RepeaterDataStore::hasRoleData() const { * Self-limiting: loadPrefs() persists defaults on boot 1 and main_*.cpp * saves a generated identity on the same boot, so after one successful * boot at least one of these exists and the check never fires again. */ - static const char* const ours[] = { "prefs", "_main.id" }; + static const char* const ours[] = { "prefs.json", "prefs", "_main.id" }; for (size_t i = 0; i < ARRAY_SIZE(ours); i++) { snprintf(path, sizeof(path), "%s/%s", BASE_PATH, ours[i]); - if (fileExists(path)) return true; + if (zephcore_fs_exists(path)) return true; } return false; } -const char* RepeaterDataStore::getAclPath() const { - static char buf[48]; - snprintf(buf, sizeof(buf), "%s/acl", BASE_PATH); - return buf; -} - -const char* RepeaterDataStore::getRegionsPath() const { - static char buf[48]; - snprintf(buf, sizeof(buf), "%s/regions2", BASE_PATH); - return buf; -} - bool RepeaterDataStore::loadIdentity(mesh::LocalIdentity& id) { char path[48]; snprintf(path, sizeof(path), "%s/_main.id", BASE_PATH); - - struct fs_file_t file; - fs_file_t_init(&file); - - int ret = fs_open(&file, path, FS_O_READ); - if (ret < 0) { - LOG_DBG("No identity file at %s", path); - return false; - } - - uint8_t buf[PRV_KEY_SIZE + PUB_KEY_SIZE]; - ssize_t n = fs_read(&file, buf, sizeof(buf)); - fs_close(&file); - - LOG_DBG("loadIdentity: read %d bytes from %s", (int)n, path); - - if (n >= PRV_KEY_SIZE) { - if (id.readFromStorage(buf, n)) { - LOG_INF("Loaded identity from %s", path); - return true; - } - if (id.recoverFromStorage(buf, n)) { - /* Not re-persisted on purpose — see ZephyrDataStore::loadMainIdentity. */ - LOG_WRN("identity pub/prv mismatch - advertising the pub its private key owns"); - return true; - } - LOG_ERR("loadIdentity: no coherent key layout in %d bytes", (int)n); - } - - /* Unusable pair — the caller regenerates, so park the bytes instead of - * letting a fresh identity overwrite them (same as the companion). */ - char bad_path[56]; - if (snprintf(bad_path, sizeof(bad_path), "%s.bad", path) < (int)sizeof(bad_path)) { - fs_unlink(bad_path); - if (fs_rename(path, bad_path) == 0) { - LOG_ERR("Identity file corrupt - kept at %s", bad_path); - return false; - } - } - LOG_ERR("Identity file corrupt"); - return false; + return zephcore_identity_load(path, id); } bool RepeaterDataStore::saveIdentity(const mesh::LocalIdentity& id) { if (!_initialized) begin(); char path[48]; - char tmp_path[56]; snprintf(path, sizeof(path), "%s/_main.id", BASE_PATH); - if (snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path) >= (int)sizeof(tmp_path)) { - return false; - } - - /* Guarded by fileExists() rather than unlinking blind: on the normal path - * the temp is absent, fs_unlink() returns -ENOENT, and Zephyr's FS layer - * logs that at ERR level regardless of us ignoring the return -- putting an - * line on the happy path of every save, which is exactly the noise - * that makes a real filesystem error invisible. Same guard as - * ZephyrDataStore::atomicWrite(). */ - if (fileExists(tmp_path)) { - fs_unlink(tmp_path); - } - - struct fs_file_t file; - fs_file_t_init(&file); - - int ret = fs_open(&file, tmp_path, FS_O_CREATE | FS_O_WRITE); - if (ret < 0) { - LOG_ERR("Failed to open %s for write: %d", tmp_path, ret); - return false; - } - - /* pub || prv, same as the companion and Arduino MeshCore. Older builds - * wrote prv alone (64 bytes); readFromStorage() still accepts those. */ - uint8_t buf[PUB_KEY_SIZE + PRV_KEY_SIZE]; - int len = id.writeToStorage(buf, sizeof(buf)); - ssize_t n = fs_write(&file, buf, len); - ret = fs_sync(&file); - fs_close(&file); - - if (n != len || ret < 0) { - LOG_ERR("Failed to write identity: wrote %d of %d sync=%d", (int)n, len, ret); - fs_unlink(tmp_path); - return false; - } - - if (fs_rename(tmp_path, path) < 0) { - LOG_ERR("saveIdentity: rename failed"); - fs_unlink(tmp_path); - return false; - } - LOG_INF("Saved identity to %s", path); - return true; + return zephcore_identity_save(path, id); } bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { char path[48]; - snprintf(path, sizeof(path), "%s/prefs", BASE_PATH); + snprintf(path, sizeof(path), "%s/prefs.json", BASE_PATH); - struct fs_file_t file; - fs_file_t_init(&file); - - int ret = fs_open(&file, path, FS_O_READ); - if (ret < 0) { - LOG_DBG("No prefs file at %s, using defaults", path); - initNodePrefs(&prefs); - strcpy(prefs.node_name, "Repeater"); - prefs.advert_loc_policy = ADVERT_LOC_PREFS; - prefs.loop_detect = LOOP_DETECT_MODERATE; - /* path_hash_mode = 1 comes from initNodePrefs() now. */ - prefs.gps_interval = CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC; // repeater default (48h) - /* Persist defaults so flash always has a prefs file from boot 1. - * Lets later code (e.g. tempradio revert) trust that flash is - * authoritative without a "first run" special case. */ - savePrefs(prefs); + if (zephcore_prefs_json_load(path, prefs, serverPrefsFromJson)) { + LOG_INF("Loaded prefs from %s", path); return true; } + bool had_json = zephcore_fs_exists(path); - struct fs_dirent entry; - ret = fs_stat(path, &entry); - LOG_DBG("loadPrefs: file size = %d bytes", ret < 0 ? 0 : (int)entry.size); + /* No usable prefs.json: the legacy binary file, read once and migrated. + * It is kept, so firmware from before prefs.json still boots with the + * settings as they were at the upgrade. With neither, the caller's prefs + * are the role's defaults (set in its constructor). Either way they are + * saved, so flash always holds a prefs file from boot 1 and later code + * (e.g. tempradio revert) can trust it without a "first run" case. */ + char legacy[48]; + snprintf(legacy, sizeof(legacy), "%s/prefs", BASE_PATH); + if (zephcore_fs_exists(legacy)) { + if (loadLegacyPrefs(legacy, prefs)) { + LOG_INF("loadPrefs: %s %s from %s", had_json ? "recovered" : "migrated", + path, legacy); + } + } else { + LOG_DBG("No prefs file at %s, saving the role defaults", path); + } + savePrefs(prefs); + return true; +} - uint8_t pad[25]; +bool RepeaterDataStore::loadLegacyPrefs(const char* path, NodePrefs& prefs) { + uint8_t buf[SERVER_PREFS_SIZE + 32]; + size_t len = 0; + if (!zephcore_fs_read_file(path, buf, sizeof(buf), &len)) { + LOG_ERR("loadPrefs: read of %s failed, using defaults", path); + return false; + } + LOG_DBG("loadPrefs: file size = %d bytes", (int)len); - /* Read prefs in same format as Arduino CommonCLI for compatibility */ - fs_read(&file, &prefs.airtime_factor, sizeof(prefs.airtime_factor)); - fs_read(&file, &prefs.node_name, sizeof(prefs.node_name)); - fs_read(&file, pad, 4); - fs_read(&file, &prefs.node_lat, sizeof(prefs.node_lat)); - fs_read(&file, &prefs.node_lon, sizeof(prefs.node_lon)); - fs_read(&file, &prefs.password, sizeof(prefs.password)); - fs_read(&file, &prefs.freq, sizeof(prefs.freq)); - fs_read(&file, &prefs.tx_power_dbm, sizeof(prefs.tx_power_dbm)); - fs_read(&file, &prefs.disable_fwd, sizeof(prefs.disable_fwd)); - fs_read(&file, &prefs.advert_interval, sizeof(prefs.advert_interval)); - fs_read(&file, pad, 1); - fs_read(&file, &prefs.rx_delay_base, sizeof(prefs.rx_delay_base)); - fs_read(&file, &prefs.tx_delay_factor, sizeof(prefs.tx_delay_factor)); - fs_read(&file, &prefs.guest_password, sizeof(prefs.guest_password)); - fs_read(&file, &prefs.direct_tx_delay_factor, sizeof(prefs.direct_tx_delay_factor)); - fs_read(&file, &prefs.backoff_multiplier, sizeof(prefs.backoff_multiplier)); - fs_read(&file, &prefs.sf, sizeof(prefs.sf)); - fs_read(&file, &prefs.cr, sizeof(prefs.cr)); - fs_read(&file, &prefs.allow_read_only, sizeof(prefs.allow_read_only)); - fs_read(&file, &prefs.multi_acks, sizeof(prefs.multi_acks)); - fs_read(&file, &prefs.bw, sizeof(prefs.bw)); - /* 120: leds_disabled, magic-encoded. Formerly agc_reset_interval — see the - * LEDS_PREF_* comment in NodePrefs.h for why this is not a bare 0/1. - * leds_byte stays 0 (→ LEDs on) if the file is short. */ - uint8_t leds_byte = 0; - fs_read(&file, &leds_byte, sizeof(leds_byte)); - fs_read(&file, &prefs.path_hash_mode, sizeof(prefs.path_hash_mode)); - fs_read(&file, &prefs.loop_detect, sizeof(prefs.loop_detect)); - fs_read(&file, pad, 1); - fs_read(&file, &prefs.flood_max, sizeof(prefs.flood_max)); - fs_read(&file, &prefs.flood_advert_interval, sizeof(prefs.flood_advert_interval)); - fs_read(&file, &prefs.interference_threshold, sizeof(prefs.interference_threshold)); - fs_read(&file, pad, 25); // skip bridge settings - fs_read(&file, &prefs.powersaving_enabled, sizeof(prefs.powersaving_enabled)); - fs_read(&file, pad, 3); - fs_read(&file, &prefs.gps_enabled, sizeof(prefs.gps_enabled)); - fs_read(&file, &prefs.gps_interval, sizeof(prefs.gps_interval)); - fs_read(&file, &prefs.advert_loc_policy, sizeof(prefs.advert_loc_policy)); - fs_read(&file, &prefs.discovery_mod_timestamp, sizeof(prefs.discovery_mod_timestamp)); - fs_read(&file, &prefs.adc_multiplier, sizeof(prefs.adc_multiplier)); - fs_read(&file, prefs.owner_info, sizeof(prefs.owner_info)); - /* ZephCore extensions — absent in old 290-byte files; fs_read past EOF is a - * no-op so these fields keep the initNodePrefs() defaults the caller passed - * in (rx_boost=1, rx_duty_cycle=0). The upgrade block below forces - * repeater-specific values for old files. */ - fs_read(&file, &prefs.rx_boost, sizeof(prefs.rx_boost)); - fs_read(&file, &prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle)); - /* RESERVED — formerly apc_enabled / apc_margin (APC, removed in 1.16.6). - * Still consumed so the fields after them stay at their stored offsets. */ - fs_read(&file, &prefs._reserved_apc_enabled, sizeof(prefs._reserved_apc_enabled)); - fs_read(&file, &prefs._reserved_apc_margin, sizeof(prefs._reserved_apc_margin)); - /* Flood hop-ceiling extensions (absent in <296-byte files; the no-op EOF - * read leaves the constructor defaults flood_max_unscoped=64, flood_max_advert=8). */ - fs_read(&file, &prefs.flood_max_unscoped, sizeof(prefs.flood_max_unscoped)); - fs_read(&file, &prefs.flood_max_advert, sizeof(prefs.flood_max_advert)); - /* Mesh time sync (absent in <297-byte files; no-op EOF read keeps default 0 = off) */ - fs_read(&file, &prefs.meshtimesync, sizeof(prefs.meshtimesync)); - /* Adaptive CAD (absent in <300-byte files; no-op EOF reads keep defaults - * auto=0, offset=0, probe_interval=60) */ - fs_read(&file, &prefs.cad_auto, sizeof(prefs.cad_auto)); - fs_read(&file, &prefs.cad_offset, sizeof(prefs.cad_offset)); - fs_read(&file, &prefs.probe_interval, sizeof(prefs.probe_interval)); - /* cad_busycap absent in <301-byte files; EOF read keeps default 25 */ - fs_read(&file, &prefs.cad_busycap, sizeof(prefs.cad_busycap)); - /* LR2021 side-detector SFs, offsets 301-303. Absent in <304-byte files; - * the no-op EOF read leaves the zeroed default = feature off. */ - fs_read(&file, prefs.extra_sf, sizeof(prefs.extra_sf)); - /* External FEM RX gain, offset 304. Absent in <305-byte files; the no-op - * EOF read keeps the initNodePrefs() default fem_rxgain=1, which is what - * every already-deployed node has been running. */ - fs_read(&file, &prefs.fem_rxgain, sizeof(prefs.fem_rxgain)); - /* Mounting orientation, offsets 305-306. Absent in <307-byte files; the - * no-op EOF reads keep the initNodePrefs() defaults of 0/0, which is the - * stock orientation every already-deployed node runs. */ - fs_read(&file, &prefs.display_rotate, sizeof(prefs.display_rotate)); - fs_read(&file, &prefs.input_rotate, sizeof(prefs.input_rotate)); - /* Family base detPeak cad_offset was learned against, offset 307. Absent - * in <308-byte files; the no-op EOF read leaves 0, which setCadParams() - * reads as "no base recorded" and acts on by leaving the stored offset - * alone — correct, since a node upgrading across a table change cannot - * know which base its offset came from. */ - fs_read(&file, &prefs.cad_base, sizeof(prefs.cad_base)); - /* Display timezone offset, offset 308. Absent in <309-byte files; the - * no-op EOF read leaves 0 = UTC, which is what every already-deployed - * node shows today. Range is re-checked by sanitizeNodePrefs(). */ - fs_read(&file, &prefs.tz_offset, sizeof(prefs.tz_offset)); - /* LED activity/heartbeat modes, offsets 309-310. Absent in <311-byte - * files; the no-op EOF read leaves the initNodePrefs() defaults of 0/0, - * which are deliberately the behaviour every already-deployed node has - * (activity LED on transmit, heartbeat with unread indication). */ - fs_read(&file, &prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); - fs_read(&file, &prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); + if (len >= 120) { + float freq, bw; + memcpy(&freq, &buf[72], sizeof(freq)); + memcpy(&bw, &buf[116], sizeof(bw)); + if (freq < ZC_RADIO_FREQ_MIN_MHZ || freq > ZC_RADIO_FREQ_MAX_MHZ || + buf[112] < 5 || buf[112] > 12 || + bw < ZC_RADIO_BW_MIN_KHZ || bw > ZC_RADIO_BW_MAX_KHZ) { + LOG_WRN("Invalid radio params in prefs, using defaults: freq=%.3f sf=%u bw=%.1f", + (double)freq, buf[112], (double)bw); + } + } + /* Radio params outside ZC_RADIO_* fall back to the LoRaConfig defaults, + * everything else is sanitized; fields past the end keep the caller's. */ + serverPrefsDecode(prefs, buf, len); - fs_close(&file); - - /* Only the explicit "off" magic disables LEDs; a legacy AGC interval or an - * unwritten byte both mean "on". */ - prefs.leds_disabled = (leds_byte == LEDS_PREF_OFF) ? 1 : 0; - - /* The 0.0-or-NaN -> 0.2 coercion that used to live here is gone. It could - * not tell "field absent from an old file" from "the user set 0.0 to turn - * reactive backoff off", so the documented off switch never survived a - * reboot. Both cases are now handled properly: initNodePrefs() supplies - * 0.2 and a short-file fs_read() is a no-op that keeps it, while NaN and - * out-of-range values are caught by sanitizeNodePrefs(). */ - - LOG_INF("Loaded prefs from %s", path); LOG_DBG(" name='%s' freq=%.3f sf=%u bw=%.1f tx_pwr=%d", prefs.node_name, (double)prefs.freq, prefs.sf, (double)prefs.bw, prefs.tx_power_dbm); - /* Validate radio params - use defaults if garbage */ - if (prefs.freq < ZC_RADIO_FREQ_MIN_MHZ || prefs.freq > ZC_RADIO_FREQ_MAX_MHZ || - prefs.sf < 5 || prefs.sf > 12 || - prefs.bw < ZC_RADIO_BW_MIN_KHZ || prefs.bw > ZC_RADIO_BW_MAX_KHZ) { - LOG_WRN("Invalid radio params in prefs, using defaults: freq=%.3f sf=%u bw=%.1f", - (double)prefs.freq, prefs.sf, (double)prefs.bw); - prefs.freq = mesh::LoRaConfig::FREQ_MHZ; - prefs.bw = mesh::LoRaConfig::BANDWIDTH_KHZ; - prefs.sf = mesh::LoRaConfig::SPREADING_FACTOR; - prefs.cr = mesh::LoRaConfig::CODING_RATE; - prefs.tx_power_dbm = mesh::LoRaConfig::TX_POWER_DBM; - } - /* Everything else that came off flash — bounds, NaNs, and the char fields, - * which the file format stores without terminators. */ - sanitizeNodePrefs(&prefs); - - /* One-time format upgrade: old files (< 294 bytes) never saved the ZephCore - * extension fields, and stored path_hash_mode/loop_detect as zero padding. - * Apply repeater defaults and re-save so values survive subsequent reboots. */ - if (ret >= 0 && entry.size < 294) { + /* Old files (< 294 bytes) never saved the ZephCore extension fields, and + * stored path_hash_mode/loop_detect as zero padding: the repeater defaults. */ + if (len < 294) { prefs.rx_boost = 1; prefs.path_hash_mode = 1; prefs.loop_detect = LOOP_DETECT_MODERATE; - savePrefs(prefs); - LOG_INF("loadPrefs: upgraded prefs format (%d -> 297 bytes)", (int)entry.size); + LOG_INF("loadPrefs: upgraded an old %d-byte prefs file", (int)len); } - /* Repeater GPS-interval unification migration: before this firmware the - * repeater ignored gps_interval (hardcoded 48h), so a stored companion - * default (300) was never a deliberate choice. Bump it to the repeater - * default once, so now-honoring the field doesn't silently switch existing - * units to 5-min GPS polling. (Triggers only on exactly 300; after the - * one-time rewrite it won't re-fire. A deliberate 300 on a repeater isn't - * reachable via the CLI — use 299/301 if you really want ~5 min.) */ + /* Before the repeater honoured gps_interval it ran a fixed 48 h, so a + * stored companion default (300) was never a choice: the repeater + * default instead. Legacy files only; prefs.json is past this. */ if (prefs.gps_interval == CONFIG_ZEPHCORE_GPS_POLL_INTERVAL_SEC) { prefs.gps_interval = CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC; - savePrefs(prefs); } - return true; } @@ -366,120 +162,8 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { if (!_initialized) begin(); char path[48]; - char tmp_path[56]; - snprintf(path, sizeof(path), "%s/prefs", BASE_PATH); - if (snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path) >= (int)sizeof(tmp_path)) { - return false; - } - - /* Guarded by fileExists() rather than unlinking blind: on the normal path - * the temp is absent, fs_unlink() returns -ENOENT, and Zephyr's FS layer - * logs that at ERR level regardless of us ignoring the return -- putting an - * line on the happy path of every save, which is exactly the noise - * that makes a real filesystem error invisible. Same guard as - * ZephyrDataStore::atomicWrite(). */ - if (fileExists(tmp_path)) { - fs_unlink(tmp_path); - } - - struct fs_file_t file; - fs_file_t_init(&file); - - int ret = fs_open(&file, tmp_path, FS_O_CREATE | FS_O_WRITE); - if (ret < 0) { - LOG_ERR("Failed to open %s for write: %d", tmp_path, ret); - return false; - } - - uint8_t pad[25]; - memset(pad, 0, sizeof(pad)); - - /* Write prefs in same format as Arduino CommonCLI for compatibility */ - fs_write(&file, &prefs.airtime_factor, sizeof(prefs.airtime_factor)); - fs_write(&file, &prefs.node_name, sizeof(prefs.node_name)); - fs_write(&file, pad, 4); - fs_write(&file, &prefs.node_lat, sizeof(prefs.node_lat)); - fs_write(&file, &prefs.node_lon, sizeof(prefs.node_lon)); - fs_write(&file, &prefs.password, sizeof(prefs.password)); - fs_write(&file, &prefs.freq, sizeof(prefs.freq)); - fs_write(&file, &prefs.tx_power_dbm, sizeof(prefs.tx_power_dbm)); - fs_write(&file, &prefs.disable_fwd, sizeof(prefs.disable_fwd)); - fs_write(&file, &prefs.advert_interval, sizeof(prefs.advert_interval)); - fs_write(&file, pad, 1); - fs_write(&file, &prefs.rx_delay_base, sizeof(prefs.rx_delay_base)); - fs_write(&file, &prefs.tx_delay_factor, sizeof(prefs.tx_delay_factor)); - fs_write(&file, &prefs.guest_password, sizeof(prefs.guest_password)); - fs_write(&file, &prefs.direct_tx_delay_factor, sizeof(prefs.direct_tx_delay_factor)); - fs_write(&file, &prefs.backoff_multiplier, sizeof(prefs.backoff_multiplier)); - fs_write(&file, &prefs.sf, sizeof(prefs.sf)); - fs_write(&file, &prefs.cr, sizeof(prefs.cr)); - fs_write(&file, &prefs.allow_read_only, sizeof(prefs.allow_read_only)); - fs_write(&file, &prefs.multi_acks, sizeof(prefs.multi_acks)); - fs_write(&file, &prefs.bw, sizeof(prefs.bw)); - /* 120: leds_disabled, magic-encoded (was agc_reset_interval). */ - { - uint8_t leds_byte = prefs.leds_disabled ? LEDS_PREF_OFF : LEDS_PREF_ON; - fs_write(&file, &leds_byte, sizeof(leds_byte)); - } - fs_write(&file, &prefs.path_hash_mode, sizeof(prefs.path_hash_mode)); - fs_write(&file, &prefs.loop_detect, sizeof(prefs.loop_detect)); - fs_write(&file, pad, 1); - fs_write(&file, &prefs.flood_max, sizeof(prefs.flood_max)); - fs_write(&file, &prefs.flood_advert_interval, sizeof(prefs.flood_advert_interval)); - fs_write(&file, &prefs.interference_threshold, sizeof(prefs.interference_threshold)); - fs_write(&file, pad, 25); // skip bridge settings - fs_write(&file, &prefs.powersaving_enabled, sizeof(prefs.powersaving_enabled)); - fs_write(&file, pad, 3); - fs_write(&file, &prefs.gps_enabled, sizeof(prefs.gps_enabled)); - fs_write(&file, &prefs.gps_interval, sizeof(prefs.gps_interval)); - fs_write(&file, &prefs.advert_loc_policy, sizeof(prefs.advert_loc_policy)); - fs_write(&file, &prefs.discovery_mod_timestamp, sizeof(prefs.discovery_mod_timestamp)); - fs_write(&file, &prefs.adc_multiplier, sizeof(prefs.adc_multiplier)); - fs_write(&file, prefs.owner_info, sizeof(prefs.owner_info)); - /* ZephCore extensions */ - fs_write(&file, &prefs.rx_boost, sizeof(prefs.rx_boost)); - fs_write(&file, &prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle)); - /* RESERVED — formerly apc_enabled / apc_margin (removed in 1.16.6). - * Written back unchanged to hold the layout. */ - fs_write(&file, &prefs._reserved_apc_enabled, sizeof(prefs._reserved_apc_enabled)); - fs_write(&file, &prefs._reserved_apc_margin, sizeof(prefs._reserved_apc_margin)); - /* Flood hop-ceiling extensions (extend the format past 294 bytes) */ - fs_write(&file, &prefs.flood_max_unscoped, sizeof(prefs.flood_max_unscoped)); - fs_write(&file, &prefs.flood_max_advert, sizeof(prefs.flood_max_advert)); - /* Mesh time sync on/off (offset 296) */ - fs_write(&file, &prefs.meshtimesync, sizeof(prefs.meshtimesync)); - /* Adaptive CAD (offsets 297-300) */ - fs_write(&file, &prefs.cad_auto, sizeof(prefs.cad_auto)); - fs_write(&file, &prefs.cad_offset, sizeof(prefs.cad_offset)); - fs_write(&file, &prefs.probe_interval, sizeof(prefs.probe_interval)); - fs_write(&file, &prefs.cad_busycap, sizeof(prefs.cad_busycap)); - /* LR2021 side-detector SFs (offsets 301-303) */ - fs_write(&file, prefs.extra_sf, sizeof(prefs.extra_sf)); - /* External FEM RX gain (offset 304) */ - fs_write(&file, &prefs.fem_rxgain, sizeof(prefs.fem_rxgain)); - /* Mounting orientation (offsets 305-306) */ - fs_write(&file, &prefs.display_rotate, sizeof(prefs.display_rotate)); - fs_write(&file, &prefs.input_rotate, sizeof(prefs.input_rotate)); - /* Family base detPeak cad_offset was learned against (offset 307) */ - fs_write(&file, &prefs.cad_base, sizeof(prefs.cad_base)); - /* Display timezone offset (offset 308) — signed whole hours from UTC, - * applied only when formatting the on-device clock */ - fs_write(&file, &prefs.tz_offset, sizeof(prefs.tz_offset)); - /* LED activity/heartbeat modes (offsets 309-310) */ - fs_write(&file, &prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); - fs_write(&file, &prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); - - ret = fs_sync(&file); - fs_close(&file); - if (ret < 0) { - LOG_ERR("savePrefs: sync failed: %d", ret); - fs_unlink(tmp_path); - return false; - } - - if (fs_rename(tmp_path, path) < 0) { - LOG_ERR("savePrefs: rename failed"); - fs_unlink(tmp_path); + snprintf(path, sizeof(path), "%s/prefs.json", BASE_PATH); + if (!zephcore_prefs_json_save(path, prefs, serverPrefsToJson)) { return false; } LOG_INF("Saved prefs to %s", path); diff --git a/zephcore/app/RepeaterDataStore.h b/zephcore/app/RepeaterDataStore.h index e673a14..c1d186b 100644 --- a/zephcore/app/RepeaterDataStore.h +++ b/zephcore/app/RepeaterDataStore.h @@ -3,8 +3,8 @@ * RepeaterDataStore - Filesystem storage for repeater * * Uses /lfs/repeater/ prefix to keep data separate from companion, so the - * two prefs layouts (301 B here, 163 B there, different field order) can - * never be read through each other. + * two prefs layouts (311 B here, 272 B there, different field order; see + * helpers/PrefsCodec.h) can never be read through each other. * * The roles are NOT interchangeable: booting a repeater onto a companion * volume formats it, and vice versa. They are different kinds of node, and @@ -37,11 +37,9 @@ public: bool loadPrefs(NodePrefs& prefs); bool savePrefs(const NodePrefs& prefs); - /* ACL management - paths passed to ClientACL */ - const char* getAclPath() const; - - /* Region management - paths passed to RegionMap */ - const char* getRegionsPath() const; + /* The /lfs/repeater directory as a FILESYSTEM, for the storage code ported + * from upstream (ClientACL, RegionMap), which names files from its root. */ + FILESYSTEM* getFS() { return &_fs; } /* Factory reset - erase all repeater data */ bool formatFileSystem(); @@ -58,5 +56,9 @@ public: private: bool _initialized; + /* Legacy binary prefs (PrefsCodec): read once to migrate, then kept for + * firmware from before prefs.json. */ + bool loadLegacyPrefs(const char* path, NodePrefs& prefs); static constexpr const char* BASE_PATH = "/lfs/repeater"; + ZephyrFS _fs{BASE_PATH}; }; diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 81c8074..cff264e 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -1,10 +1,9 @@ -/* - * SPDX-License-Identifier: MIT - * RepeaterMesh - LoRa mesh repeater implementation - */ - #include "RepeaterMesh.h" +//#include // ZEPHCORE: not in Zephyr's minimal C++, see simple_sort + +// ZEPHCORE: platform includes and helpers. #include +#include #include #include #include @@ -30,21 +29,21 @@ /* Helper to get radio driver for stats — uses LoRaRadio (works for SX126x and LR1110) */ static inline mesh::LoRaRadio& getRadioDriver(mesh::Radio* radio) { - return *static_cast(radio); + return *static_cast(radio); } /* Simple sort helper since is not available in Zephyr minimal C++ */ template static void simple_sort(T* arr, int count, Comparator cmp) { - for (int i = 0; i < count - 1; i++) { - for (int j = i + 1; j < count; j++) { - if (cmp(arr[j], arr[i])) { - T temp = arr[i]; - arr[i] = arr[j]; - arr[j] = temp; - } - } - } + for (int i = 0; i < count - 1; i++) { + for (int j = i + 1; j < count; j++) { + if (cmp(arr[j], arr[i])) { + T temp = arr[i]; + arr[i] = arr[j]; + arr[j] = temp; + } + } + } } LOG_MODULE_REGISTER(zephcore_repeater, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); @@ -57,1532 +56,1703 @@ static RepeaterMesh *s_uplink_mesh; static atomic_t s_uplink_sntp_pending; static void uplink_time_sync_cb(uint32_t unix_ts) { - if (s_uplink_mesh) { - s_uplink_mesh->getRTCClock()->setCurrentTime(unix_ts); - atomic_set(&s_uplink_sntp_pending, 1); - /* Wake the main loop rather than waiting for its next deadline. */ - s_uplink_mesh->notifyWake(); - } + if (s_uplink_mesh) { + s_uplink_mesh->getRTCClock()->setCurrentTime(unix_ts); + atomic_set(&s_uplink_sntp_pending, 1); + /* Wake the main loop rather than waiting for its next deadline. */ + s_uplink_mesh->notifyWake(); + } } #endif -/* Protocol constants */ +/* Helper: futureMillis */ +static inline unsigned long futureMillis(uint32_t delta_ms) { + return k_uptime_get() + delta_ms; +} + +static inline bool millisHasNowPassed(unsigned long target) { + return (int64_t)k_uptime_get() >= (int64_t)target; +} + +static void radio_set_tx_power(uint8_t power_dbm) { + /* TX power is configured as part of lora_config() in radio_set_params + * The Zephyr LoRa driver doesn't have a separate lora_set_tx_power API. + * Instead, we log that TX power setting is requested. The actual power + * is set in the board defconfig via CONFIG_LORA_TX_POWER. */ + LOG_INF("TX power %d dBm requested (configured via board defconfig)", power_dbm); +} + +// ZEPHCORE: upstream's variant-global rtc_clock is the mesh's own clock here. +#define rtc_clock (*getRTCClock()) + +/* ------------------------------ Config -------------------------------- */ + +#ifndef LORA_FREQ + #define LORA_FREQ 915.0 +#endif +#ifndef LORA_BW + #define LORA_BW 250 +#endif +#ifndef LORA_SF + #define LORA_SF 10 +#endif +#ifndef LORA_CR + #define LORA_CR 5 +#endif +#ifndef LORA_TX_POWER + #define LORA_TX_POWER 20 +#endif + +#ifndef ADVERT_NAME + #define ADVERT_NAME "repeater" +#endif +#ifndef ADVERT_LAT + #define ADVERT_LAT 0.0 +#endif +#ifndef ADVERT_LON + #define ADVERT_LON 0.0 +#endif + +#ifndef ADMIN_PASSWORD + #define ADMIN_PASSWORD "password" +#endif + +#ifndef SERVER_RESPONSE_DELAY + #define SERVER_RESPONSE_DELAY 300 +#endif + +#ifndef TXT_ACK_DELAY + #define TXT_ACK_DELAY 200 +#endif + #define FIRMWARE_VER_LEVEL 2 -#define REQ_TYPE_GET_STATUS 0x01 +#define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS #define REQ_TYPE_KEEP_ALIVE 0x02 #define REQ_TYPE_GET_TELEMETRY_DATA 0x03 #define REQ_TYPE_GET_ACCESS_LIST 0x05 #define REQ_TYPE_GET_NEIGHBOURS 0x06 -#define REQ_TYPE_GET_OWNER_INFO 0x07 +#define REQ_TYPE_GET_OWNER_INFO 0x07 // FIRMWARE_VER_LEVEL >= 2 -#define RESP_SERVER_LOGIN_OK 0 +#define RESP_SERVER_LOGIN_OK 0 // response to ANON_REQ #define ANON_REQ_TYPE_REGIONS 0x01 #define ANON_REQ_TYPE_OWNER 0x02 -#define ANON_REQ_TYPE_BASIC 0x03 +#define ANON_REQ_TYPE_BASIC 0x03 // just remote clock #define CLI_REPLY_DELAY_MILLIS 600 -#define LAZY_CONTACTS_WRITE_DELAY 5000 -#define SERVER_RESPONSE_DELAY 300 -#define TXT_ACK_DELAY 200 -/* Helper: futureMillis */ -static inline unsigned long futureMillis(uint32_t delta_ms) { - return k_uptime_get() + delta_ms; -} +#define LAZY_CONTACTS_WRITE_DELAY 5000 -static inline bool millisHasNowPassed(unsigned long target) { - return (int64_t)k_uptime_get() >= (int64_t)target; -} +void RepeaterMesh::putNeighbour(const mesh::Identity &id, uint32_t timestamp, float snr) { +#if MAX_NEIGHBOURS // check if neighbours enabled + // find existing neighbour, else use least recently updated + uint32_t oldest_timestamp = 0xFFFFFFFF; + NeighbourInfo *neighbour = &neighbours[0]; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + // if neighbour already known, we should update it + if (id.matches(neighbours[i].id)) { + neighbour = &neighbours[i]; + break; + } -static void radio_set_tx_power(uint8_t power_dbm) { - /* TX power is configured as part of lora_config() in radio_set_params - * The Zephyr LoRa driver doesn't have a separate lora_set_tx_power API. - * Instead, we log that TX power setting is requested. The actual power - * is set in the board defconfig via CONFIG_LORA_TX_POWER. */ - LOG_INF("TX power %d dBm requested (configured via board defconfig)", power_dbm); -} + // otherwise we should update the least recently updated neighbour + if (neighbours[i].heard_timestamp < oldest_timestamp) { + neighbour = &neighbours[i]; + oldest_timestamp = neighbour->heard_timestamp; + } + } -void RepeaterMesh::putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr) { -#if MAX_NEIGHBOURS > 0 - uint32_t oldest_timestamp = 0xFFFFFFFF; - NeighbourInfo* neighbour = &neighbours[0]; - - for (int i = 0; i < MAX_NEIGHBOURS; i++) { - if (id.matches(neighbours[i].id)) { - neighbour = &neighbours[i]; - break; - } - if (neighbours[i].heard_timestamp < oldest_timestamp) { - neighbour = &neighbours[i]; - oldest_timestamp = neighbour->heard_timestamp; - } - } - - neighbour->id = id; - neighbour->advert_timestamp = timestamp; - neighbour->heard_timestamp = getRTCClock()->getCurrentTime(); - neighbour->snr = (int8_t)(snr * 4); + // update neighbour info + neighbour->id = id; + neighbour->advert_timestamp = timestamp; + neighbour->heard_timestamp = getRTCClock()->getCurrentTime(); + neighbour->snr = (int8_t)(snr * 4); #endif } +// ZEPHCORE: ours. Login bookkeeping differs (see body). uint8_t RepeaterMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood) { - ClientInfo* client = nullptr; + ClientInfo* client = nullptr; - if (data[0] == 0) { - client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); - } + if (data[0] == 0) { + client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); + } - if (client == nullptr) { - uint8_t perms; + if (client == nullptr) { + uint8_t perms; - /* Constant-time comparison: zero-pad BOTH the received password and - * the stored passwords into cleared buffers (copying only up to the - * NUL) before comparing full-width. Don't trust the stored buffer to - * be zero-padded: a password set over a longer previous value via the - * CLI leaves trailing garbage past the NUL (and such garbage may - * already be persisted in flash on upgraded devices). Comparing the - * raw stored buffer full-width would then fail to match a correct - * password. Compare both unconditionally so timing is identical for - * any wrong password regardless of which (admin/guest) it most - * resembles. */ - uint8_t received[sizeof(_prefs.password)] = {0}; - uint8_t admin_pw[sizeof(_prefs.password)] = {0}; - uint8_t guest_pw[sizeof(_prefs.guest_password)] = {0}; - size_t r_len = strnlen((const char *)data, sizeof(received) - 1); - memcpy(received, data, r_len); - memcpy(admin_pw, _prefs.password, strnlen(_prefs.password, sizeof(admin_pw) - 1)); - memcpy(guest_pw, _prefs.guest_password, strnlen(_prefs.guest_password, sizeof(guest_pw) - 1)); + /* Constant-time comparison: zero-pad BOTH the received password and + * the stored passwords into cleared buffers (copying only up to the + * NUL) before comparing full-width. Don't trust the stored buffer to + * be zero-padded: a password set over a longer previous value via the + * CLI leaves trailing garbage past the NUL (and such garbage may + * already be persisted in flash on upgraded devices). Comparing the + * raw stored buffer full-width would then fail to match a correct + * password. Compare both unconditionally so timing is identical for + * any wrong password regardless of which (admin/guest) it most + * resembles. */ + uint8_t received[sizeof(_prefs.password)] = {0}; + uint8_t admin_pw[sizeof(_prefs.password)] = {0}; + uint8_t guest_pw[sizeof(_prefs.guest_password)] = {0}; + size_t r_len = strnlen((const char *)data, sizeof(received) - 1); + memcpy(received, data, r_len); + memcpy(admin_pw, _prefs.password, strnlen(_prefs.password, sizeof(admin_pw) - 1)); + memcpy(guest_pw, _prefs.guest_password, strnlen(_prefs.guest_password, sizeof(guest_pw) - 1)); - bool admin_match = mesh::Utils::constantTimeEqual(received, - admin_pw, - sizeof(received)); - bool guest_match = mesh::Utils::constantTimeEqual(received, - guest_pw, - sizeof(received)); + bool admin_match = mesh::Utils::constantTimeEqual(received, + admin_pw, + sizeof(received)); + bool guest_match = mesh::Utils::constantTimeEqual(received, + guest_pw, + sizeof(received)); - /* An empty stored guest password means OPEN GUEST ACCESS on a repeater, - * deliberately, matching Arduino MeshCore: its NodePrefs default is - * guest_password[0] = 0 (src/helpers/CommonCLI.h) and - * examples/simple_repeater/MyMesh.cpp compares with a plain strcmp, so a - * blank submitted password logs in as guest. Only a blank submission - * matches -- a wrong non-blank password still fails, because both - * buffers are zero-padded and compared full-width. - * - * This is deliberately NOT what RoomServerMesh.cpp does, and the two - * must not be "made consistent". There an empty guest password disables - * guest access, because on a room server that password is what gates - * posting and an accidentally open room is a real hole (the 2026-07-30 - * finding: every ZephCore room server shipped open). Arduino closes the - * same hole from the other side, by defaulting the room server's - * guest_password to ROOM_PASSWORD rather than leaving it empty. - * - * The blast radius on a repeater is small by construction: - * PERM_ACL_GUEST cannot run CLI commands -- the PAYLOAD_TYPE_TXT_MSG - * path in onPeerDataRecv() requires isAdmin() -- and cannot read the - * access list (REQ_TYPE_GET_ACCESS_LIST likewise). A guest gets login - * plus status/telemetry. Set a guest password to close it. */ + /* An empty stored guest password means OPEN GUEST ACCESS on a repeater, + * deliberately, matching Arduino MeshCore: its NodePrefs default is + * guest_password[0] = 0 (src/helpers/CommonCLI.h) and + * examples/simple_repeater/MyMesh.cpp compares with a plain strcmp, so a + * blank submitted password logs in as guest. Only a blank submission + * matches -- a wrong non-blank password still fails, because both + * buffers are zero-padded and compared full-width. + * + * This is deliberately NOT what RoomServerMesh.cpp does, and the two + * must not be "made consistent". There an empty guest password disables + * guest access, because on a room server that password is what gates + * posting and an accidentally open room is a real hole (the 2026-07-30 + * finding: every ZephCore room server shipped open). Arduino closes the + * same hole from the other side, by defaulting the room server's + * guest_password to ROOM_PASSWORD rather than leaving it empty. + * + * The blast radius on a repeater is small by construction: + * PERM_ACL_GUEST cannot run CLI commands -- the PAYLOAD_TYPE_TXT_MSG + * path in onPeerDataRecv() requires isAdmin() -- and cannot read the + * access list (REQ_TYPE_GET_ACCESS_LIST likewise). A guest gets login + * plus status/telemetry. Set a guest password to close it. */ - if (admin_match) { - perms = PERM_ACL_ADMIN; - } else if (guest_match) { - perms = PERM_ACL_GUEST; - } else { - /* Apply global failed-login rate limit. The check itself is - * unconditional regardless of admin/guest path so its timing - * doesn't leak which credential the attempt was closer to. */ - if (!login_fail_limiter.allow(getRTCClock()->getCurrentTime())) { - LOG_WRN("Login rate-limited (failed attempts exceeded)"); - } else { - LOG_WRN("Invalid password"); - } - return 0; - } + if (admin_match) { + perms = PERM_ACL_ADMIN; + } else if (guest_match) { + perms = PERM_ACL_GUEST; + } else { + /* Apply global failed-login rate limit. The check itself is + * unconditional regardless of admin/guest path so its timing + * doesn't leak which credential the attempt was closer to. */ + if (!login_fail_limiter.allow(getRTCClock()->getCurrentTime())) { + LOG_WRN("Login rate-limited (failed attempts exceeded)"); + } else { + LOG_WRN("Invalid password"); + } + return 0; + } - client = acl.putClient(sender, 0); - if (sender_timestamp <= client->last_timestamp) { - LOG_WRN("Possible login replay attack!"); - return 0; - } + client = acl.putClient(sender, 0); + if (sender_timestamp <= client->last_timestamp) { + LOG_WRN("Possible login replay attack!"); + return 0; + } - LOG_INF("Login success"); - client->last_timestamp = sender_timestamp; - client->last_activity = getRTCClock()->getCurrentTime(); - /* Role assignment only ever escalates. putClient() hands back the - * existing entry for a known pubkey, so writing the role bits - * unconditionally lets a later guest login on a key that already holds - * admin silently strip those rights — the operator is then locked out - * of their own repeater with no way back in over the air. Revoking - * admin is a deliberate act and belongs to the CLI. */ - if (!client->isAdmin()) { - client->permissions &= ~0x03; - client->permissions |= perms; - } - memcpy(client->shared_secret, secret, PUB_KEY_SIZE); + LOG_INF("Login success"); + client->last_timestamp = sender_timestamp; + client->last_activity = getRTCClock()->getCurrentTime(); + /* Role assignment only ever escalates. putClient() hands back the + * existing entry for a known pubkey, so writing the role bits + * unconditionally lets a later guest login on a key that already holds + * admin silently strip those rights — the operator is then locked out + * of their own repeater with no way back in over the air. Revoking + * admin is a deliberate act and belongs to the CLI. */ + if (!client->isAdmin()) { + client->permissions &= ~0x03; + client->permissions |= perms; + } + memcpy(client->shared_secret, secret, PUB_KEY_SIZE); - if (client->isAdmin()) { - /* Flush admin sessions now instead of leaving them on the lazy - * timer. The entry carries the shared secret this session's - * traffic is encrypted with; if power is lost before the timer - * fires, the client keeps a secret the repeater no longer knows - * and every later request decrypts to nothing, which presents as - * a wrong password or an unreachable node. A repeater is the - * device most likely to lose power unattended, so the few - * milliseconds are worth it. save() writes the whole ACL, so any - * other pending changes go out with it. */ - if (_store) { - acl.save(_store->getAclPath()); - dirty_contacts_expiry = 0; - } else if (!dirty_contacts_expiry) { - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - } - } - } + if (client->isAdmin()) { + /* Flush admin sessions now instead of leaving them on the lazy + * timer. The entry carries the shared secret this session's + * traffic is encrypted with; if power is lost before the timer + * fires, the client keeps a secret the repeater no longer knows + * and every later request decrypts to nothing, which presents as + * a wrong password or an unreachable node. A repeater is the + * device most likely to lose power unattended, so the few + * milliseconds are worth it. save() writes the whole ACL, so any + * other pending changes go out with it. */ + if (_store) { + acl.save(_store->getFS()); + dirty_contacts_expiry = 0; + } else if (!dirty_contacts_expiry) { + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + } + } + } - if (is_flood) { - client->out_path_len = OUT_PATH_UNKNOWN; - } + if (is_flood) { + client->out_path_len = OUT_PATH_UNKNOWN; + } - uint32_t now = getRTCClock()->getCurrentTimeUnique(); - memcpy(reply_data, &now, 4); - reply_data[4] = RESP_SERVER_LOGIN_OK; - reply_data[5] = 0; - reply_data[6] = client->isAdmin() ? 1 : 0; - reply_data[7] = client->permissions; - getRNG()->random(&reply_data[8], 4); - reply_data[12] = FIRMWARE_VER_LEVEL; + uint32_t now = getRTCClock()->getCurrentTimeUnique(); + memcpy(reply_data, &now, 4); + reply_data[4] = RESP_SERVER_LOGIN_OK; + reply_data[5] = 0; + reply_data[6] = client->isAdmin() ? 1 : 0; + reply_data[7] = client->permissions; + getRNG()->random(&reply_data[8], 4); + reply_data[12] = FIRMWARE_VER_LEVEL; - return 13; + return 13; } +// ZEPHCORE: ours. Reply path bounded by the bytes actually received. uint8_t RepeaterMesh::handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len) { - if (anon_limiter.allow(getRTCClock()->getCurrentTime())) { - if (data_len < 1) return 0; - reply_path_len = *data++; - data_len--; - /* data is anon-req-supplied; bound copy with remaining data_len. - * If the claimed path is longer than the bytes provided, copyPath - * rejects (returns 0) and leaves reply_path stale — fall back to a - * flood reply instead of emitting a corrupt direct path (F2). - * path_len == 0 is a valid zero-hop direct reply, so don't treat - * its (also-0) return as a rejection. */ - if (reply_path_len != 0 && - mesh::Packet::copyPath(reply_path, data, data_len, reply_path_len) == 0) { - reply_path_len = OUT_PATH_UNKNOWN; - } + if (anon_limiter.allow(getRTCClock()->getCurrentTime())) { + if (data_len < 1) return 0; + reply_path_len = *data++; + data_len--; + /* data is anon-req-supplied; bound copy with remaining data_len. + * If the claimed path is longer than the bytes provided, copyPath + * rejects (returns 0) and leaves reply_path stale — fall back to a + * flood reply instead of emitting a corrupt direct path (F2). + * path_len == 0 is a valid zero-hop direct reply, so don't treat + * its (also-0) return as a rejection. */ + if (reply_path_len != 0 && + mesh::Packet::copyPath(reply_path, data, data_len, reply_path_len) == 0) { + reply_path_len = OUT_PATH_UNKNOWN; + } - memcpy(reply_data, &sender_timestamp, 4); - uint32_t now = getRTCClock()->getCurrentTime(); - memcpy(&reply_data[4], &now, 4); + memcpy(reply_data, &sender_timestamp, 4); + uint32_t now = getRTCClock()->getCurrentTime(); + memcpy(&reply_data[4], &now, 4); - return 8 + region_map.exportNamesTo((char*)&reply_data[8], sizeof(reply_data) - 12, REGION_DENY_FLOOD); - } - return 0; + return 8 + region_map.exportNamesTo((char*)&reply_data[8], sizeof(reply_data) - 12, REGION_DENY_FLOOD); + } + return 0; } +// ZEPHCORE: ours. Reply path bounded by the bytes actually received. uint8_t RepeaterMesh::handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len) { - if (anon_limiter.allow(getRTCClock()->getCurrentTime())) { - if (data_len < 1) return 0; - reply_path_len = *data++; - data_len--; - /* data is anon-req-supplied; bound copy with remaining data_len. - * If the claimed path is longer than the bytes provided, copyPath - * rejects (returns 0) and leaves reply_path stale — fall back to a - * flood reply instead of emitting a corrupt direct path (F2). - * path_len == 0 is a valid zero-hop direct reply, so don't treat - * its (also-0) return as a rejection. */ - if (reply_path_len != 0 && - mesh::Packet::copyPath(reply_path, data, data_len, reply_path_len) == 0) { - reply_path_len = OUT_PATH_UNKNOWN; - } + if (anon_limiter.allow(getRTCClock()->getCurrentTime())) { + if (data_len < 1) return 0; + reply_path_len = *data++; + data_len--; + /* data is anon-req-supplied; bound copy with remaining data_len. + * If the claimed path is longer than the bytes provided, copyPath + * rejects (returns 0) and leaves reply_path stale — fall back to a + * flood reply instead of emitting a corrupt direct path (F2). + * path_len == 0 is a valid zero-hop direct reply, so don't treat + * its (also-0) return as a rejection. */ + if (reply_path_len != 0 && + mesh::Packet::copyPath(reply_path, data, data_len, reply_path_len) == 0) { + reply_path_len = OUT_PATH_UNKNOWN; + } - memcpy(reply_data, &sender_timestamp, 4); - uint32_t now = getRTCClock()->getCurrentTime(); - memcpy(&reply_data[4], &now, 4); - sprintf((char*)&reply_data[8], "%s\n%s", _prefs.node_name, _prefs.owner_info); + memcpy(reply_data, &sender_timestamp, 4); + uint32_t now = getRTCClock()->getCurrentTime(); + memcpy(&reply_data[4], &now, 4); + sprintf((char*)&reply_data[8], "%s\n%s", _prefs.node_name, _prefs.owner_info); - return 8 + strlen((char*)&reply_data[8]); - } - return 0; + return 8 + strlen((char*)&reply_data[8]); + } + return 0; } +// ZEPHCORE: ours. Reply path bounded by the bytes actually received. uint8_t RepeaterMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len) { - if (anon_limiter.allow(getRTCClock()->getCurrentTime())) { - if (data_len < 1) return 0; - reply_path_len = *data++; - data_len--; - /* data is anon-req-supplied; bound copy with remaining data_len. - * If the claimed path is longer than the bytes provided, copyPath - * rejects (returns 0) and leaves reply_path stale — fall back to a - * flood reply instead of emitting a corrupt direct path (F2). - * path_len == 0 is a valid zero-hop direct reply, so don't treat - * its (also-0) return as a rejection. */ - if (reply_path_len != 0 && - mesh::Packet::copyPath(reply_path, data, data_len, reply_path_len) == 0) { - reply_path_len = OUT_PATH_UNKNOWN; - } + if (anon_limiter.allow(getRTCClock()->getCurrentTime())) { + if (data_len < 1) return 0; + reply_path_len = *data++; + data_len--; + /* data is anon-req-supplied; bound copy with remaining data_len. + * If the claimed path is longer than the bytes provided, copyPath + * rejects (returns 0) and leaves reply_path stale — fall back to a + * flood reply instead of emitting a corrupt direct path (F2). + * path_len == 0 is a valid zero-hop direct reply, so don't treat + * its (also-0) return as a rejection. */ + if (reply_path_len != 0 && + mesh::Packet::copyPath(reply_path, data, data_len, reply_path_len) == 0) { + reply_path_len = OUT_PATH_UNKNOWN; + } - memcpy(reply_data, &sender_timestamp, 4); - uint32_t now = getRTCClock()->getCurrentTime(); - memcpy(&reply_data[4], &now, 4); - reply_data[8] = 0; // features - if (_prefs.disable_fwd) { - reply_data[8] |= 0x80; - } - return 9; - } - return 0; + memcpy(reply_data, &sender_timestamp, 4); + uint32_t now = getRTCClock()->getCurrentTime(); + memcpy(&reply_data[4], &now, 4); + reply_data[8] = 0; // features + if (_prefs.disable_fwd) { + reply_data[8] |= 0x80; + } + return 9; + } + return 0; } +// ZEPHCORE: ours. ZephCore telemetry, stats and ACL paging. int RepeaterMesh::handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len) { - memcpy(reply_data, &sender_timestamp, 4); + memcpy(reply_data, &sender_timestamp, 4); - if (payload[0] == REQ_TYPE_GET_STATUS) { - auto& radio_driver = getRadioDriver(_radio); - RepeaterStats stats; - stats.batt_milli_volts = _board.getBattMilliVolts(); - stats.curr_tx_queue_len = _mgr->getOutboundTotal(); - stats.noise_floor = (int16_t)_radio->getNoiseFloor(); - stats.last_rssi = (int16_t)radio_driver.getLastRSSI(); - stats.n_packets_recv = radio_driver.getPacketsRecv(); - stats.n_packets_sent = radio_driver.getPacketsSent(); - stats.total_air_time_secs = getTotalAirTime() / 1000; - stats.total_up_time_secs = uptime_millis / 1000; - stats.n_sent_flood = getNumSentFlood(); - stats.n_sent_direct = getNumSentDirect(); - stats.n_recv_flood = getNumRecvFlood(); - stats.n_recv_direct = getNumRecvDirect(); - stats.err_events = _err_flags; - stats.last_snr = (int16_t)(radio_driver.getLastSNR() * 4); - stats.n_direct_dups = ((SimpleMeshTables *)getTables())->getNumDirectDups(); - stats.n_flood_dups = ((SimpleMeshTables *)getTables())->getNumFloodDups(); - stats.total_rx_air_time_secs = getReceiveAirTime() / 1000; - stats.n_recv_errors = radio_driver.getPacketsRecvErrors(); - memcpy(&reply_data[4], &stats, sizeof(stats)); - return 4 + sizeof(stats); - } + if (payload[0] == REQ_TYPE_GET_STATUS) { + auto& radio_driver = getRadioDriver(_radio); + RepeaterStats stats; + stats.batt_milli_volts = _board.getBattMilliVolts(); + stats.curr_tx_queue_len = _mgr->getOutboundTotal(); + stats.noise_floor = (int16_t)_radio->getNoiseFloor(); + stats.last_rssi = (int16_t)radio_driver.getLastRSSI(); + stats.n_packets_recv = radio_driver.getPacketsRecv(); + stats.n_packets_sent = radio_driver.getPacketsSent(); + stats.total_air_time_secs = getTotalAirTime() / 1000; + stats.total_up_time_secs = uptime_millis / 1000; + stats.n_sent_flood = getNumSentFlood(); + stats.n_sent_direct = getNumSentDirect(); + stats.n_recv_flood = getNumRecvFlood(); + stats.n_recv_direct = getNumRecvDirect(); + stats.err_events = _err_flags; + stats.last_snr = (int16_t)(radio_driver.getLastSNR() * 4); + stats.n_direct_dups = ((SimpleMeshTables *)getTables())->getNumDirectDups(); + stats.n_flood_dups = ((SimpleMeshTables *)getTables())->getNumFloodDups(); + stats.total_rx_air_time_secs = getReceiveAirTime() / 1000; + stats.n_recv_errors = radio_driver.getPacketsRecvErrors(); + memcpy(&reply_data[4], &stats, sizeof(stats)); + return 4 + sizeof(stats); + } - if (payload[0] == REQ_TYPE_GET_TELEMETRY_DATA) { - /* CayenneLPP telemetry response using SimpleLPP encoder */ - SimpleLPP lpp(&reply_data[4], sizeof(reply_data) - 4); + if (payload[0] == REQ_TYPE_GET_TELEMETRY_DATA) { + /* CayenneLPP telemetry response using SimpleLPP encoder */ + SimpleLPP lpp(&reply_data[4], sizeof(reply_data) - 4); - /* Battery voltage — channel 1 = TELEM_CHANNEL_SELF (matches Arduino) */ - const uint8_t CH_SELF = 1; - uint16_t batt_mv = _board.getBattMilliVolts(); - lpp.addVoltage(CH_SELF, batt_mv / 1000.0f); + /* Battery voltage — channel 1 = TELEM_CHANNEL_SELF (matches Arduino) */ + const uint8_t CH_SELF = 1; + uint16_t batt_mv = _board.getBattMilliVolts(); + lpp.addVoltage(CH_SELF, batt_mv / 1000.0f); - /* Environment sensors — prefer external, fallback to MCU die temp */ - struct env_data env; - if (env_sensors_read(&env) == 0) { - if (env.has_temperature) { - lpp.addTemperature(CH_SELF, env.temperature_c); - } else if (env.has_mcu_temperature) { - lpp.addTemperature(CH_SELF, env.mcu_temperature_c); - } else { - /* Last resort: MCU temp from board API */ - float mcu_temp = _board.getMCUTemperature(); - if (!isnan(mcu_temp)) { - lpp.addTemperature(CH_SELF, mcu_temp); - } - } - if (env.has_humidity) { - lpp.addRelativeHumidity(CH_SELF, env.humidity_pct); - } - if (env.has_pressure) { - lpp.addBarometricPressure(CH_SELF, env.pressure_hpa); - } - if (env.has_luminosity) { - lpp.addLuminosity(CH_SELF, env.luminosity); - } - } else { - /* No env sensors at all — try MCU temp directly */ - float mcu_temp = _board.getMCUTemperature(); - if (!isnan(mcu_temp)) { - lpp.addTemperature(CH_SELF, mcu_temp); - } - } + /* Environment sensors — prefer external, fallback to MCU die temp */ + struct env_data env; + if (env_sensors_read(&env) == 0) { + if (env.has_temperature) { + lpp.addTemperature(CH_SELF, env.temperature_c); + } else if (env.has_mcu_temperature) { + lpp.addTemperature(CH_SELF, env.mcu_temperature_c); + } else { + /* Last resort: MCU temp from board API */ + float mcu_temp = _board.getMCUTemperature(); + if (!isnan(mcu_temp)) { + lpp.addTemperature(CH_SELF, mcu_temp); + } + } + if (env.has_humidity) { + lpp.addRelativeHumidity(CH_SELF, env.humidity_pct); + } + if (env.has_pressure) { + lpp.addBarometricPressure(CH_SELF, env.pressure_hpa); + } + if (env.has_luminosity) { + lpp.addLuminosity(CH_SELF, env.luminosity); + } + } else { + /* No env sensors at all — try MCU temp directly */ + float mcu_temp = _board.getMCUTemperature(); + if (!isnan(mcu_temp)) { + lpp.addTemperature(CH_SELF, mcu_temp); + } + } - /* Power monitors (INA219/INA3221/ina2xx) */ - if (power_sensors_available()) { - struct power_data pwr; - if (power_sensors_read(&pwr) == 0) { - uint8_t ch = CH_SELF + 1; - for (int j = 0; j < pwr.num_channels; j++) { - if (pwr.channels[j].valid) { - lpp.addVoltage(ch, pwr.channels[j].voltage_v); - lpp.addCurrent(ch, pwr.channels[j].current_a); - lpp.addPower(ch, pwr.channels[j].power_w); - ch++; - } - } - } - } + /* Power monitors (INA219/INA3221/ina2xx) */ + if (power_sensors_available()) { + struct power_data pwr; + if (power_sensors_read(&pwr) == 0) { + uint8_t ch = CH_SELF + 1; + for (int j = 0; j < pwr.num_channels; j++) { + if (pwr.channels[j].valid) { + lpp.addVoltage(ch, pwr.channels[j].voltage_v); + lpp.addCurrent(ch, pwr.channels[j].current_a); + lpp.addPower(ch, pwr.channels[j].power_w); + ch++; + } + } + } + } - /* GPS precise position — admin-only, and only via telemetry, never - * adverts. Guests get the rest of the LPP payload but no position: - * adverts already publish the operator-set prefs coordinates, so - * there is no reason to hand a guest login the live fix as well. */ - if (sender->isAdmin()) { - struct gps_position gpos; - if (gps_get_last_known_position(&gpos)) { - lpp.addGPS(CH_SELF, - (float)(gpos.latitude_ndeg / 1e9), - (float)(gpos.longitude_ndeg / 1e9), - gpos.altitude_mm / 1000.0f); - } + /* GPS precise position — admin-only, and only via telemetry, never + * adverts. Guests get the rest of the LPP payload but no position: + * adverts already publish the operator-set prefs coordinates, so + * there is no reason to hand a guest login the live fix as well. */ + if (sender->isAdmin()) { + struct gps_position gpos; + if (gps_get_last_known_position(&gpos)) { + lpp.addGPS(CH_SELF, + (float)(gpos.latitude_ndeg / 1e9), + (float)(gpos.longitude_ndeg / 1e9), + gpos.altitude_mm / 1000.0f); + } - /* Wake GPS / extend acquire window so the next telemetry poll has - * a fresher fix. In repeater mode GPS is normally off between the - * 48h time-sync cycles — this opportunistically rearms acquire - * when someone actually cares about our position. No-op if GPS - * is disabled in prefs. Gated with the position itself so a guest - * can't hold the GPS awake by polling. */ - if (gps_is_available() && gps_is_enabled()) { - gps_request_fresh_fix(); - } - } + /* Wake GPS / extend acquire window so the next telemetry poll has + * a fresher fix. In repeater mode GPS is normally off between the + * 48h time-sync cycles — this opportunistically rearms acquire + * when someone actually cares about our position. No-op if GPS + * is disabled in prefs. Gated with the position itself so a guest + * can't hold the GPS awake by polling. */ + if (gps_is_available() && gps_is_enabled()) { + gps_request_fresh_fix(); + } + } - return 4 + lpp.getSize(); - } + return 4 + lpp.getSize(); + } - if (payload[0] == REQ_TYPE_GET_ACCESS_LIST && sender->isAdmin()) { - uint8_t res1 = payload[1]; - uint8_t res2 = payload[2]; - if (res1 == 0 && res2 == 0) { - uint8_t ofs = 4; - for (int i = 0; i < acl.getNumClients() && (size_t)(ofs + 7) <= sizeof(reply_data) - 4; i++) { - auto c = acl.getClientByIdx(i); - if (c->permissions == 0) continue; - memcpy(&reply_data[ofs], c->id.pub_key, 6); - ofs += 6; - reply_data[ofs++] = c->permissions; - } - return ofs; - } - } + if (payload[0] == REQ_TYPE_GET_ACCESS_LIST && sender->isAdmin()) { + uint8_t res1 = payload[1]; + uint8_t res2 = payload[2]; + if (res1 == 0 && res2 == 0) { + uint8_t ofs = 4; + for (int i = 0; i < acl.getNumClients() && (size_t)(ofs + 7) <= sizeof(reply_data) - 4; i++) { + auto c = acl.getClientByIdx(i); + if (c->permissions == 0) continue; + memcpy(&reply_data[ofs], c->id.pub_key, 6); + ofs += 6; + reply_data[ofs++] = c->permissions; + } + return ofs; + } + } - if (payload[0] == REQ_TYPE_GET_NEIGHBOURS) { + if (payload[0] == REQ_TYPE_GET_NEIGHBOURS) { #if MAX_NEIGHBOURS > 0 - uint8_t request_version = payload[1]; - if (request_version == 0) { - int reply_offset = 4; - uint8_t count = payload[2]; - uint16_t offset; - memcpy(&offset, &payload[3], 2); - uint8_t order_by = payload[5]; - uint8_t pubkey_prefix_length = payload[6]; + uint8_t request_version = payload[1]; + if (request_version == 0) { + int reply_offset = 4; + uint8_t count = payload[2]; + uint16_t offset; + memcpy(&offset, &payload[3], 2); + uint8_t order_by = payload[5]; + uint8_t pubkey_prefix_length = payload[6]; - if (pubkey_prefix_length > PUB_KEY_SIZE) { - pubkey_prefix_length = PUB_KEY_SIZE; - } + if (pubkey_prefix_length > PUB_KEY_SIZE) { + pubkey_prefix_length = PUB_KEY_SIZE; + } - // Create sorted copy - int16_t neighbours_count = 0; - NeighbourInfo* sorted_neighbours[MAX_NEIGHBOURS]; - for (int i = 0; i < MAX_NEIGHBOURS; i++) { - if (neighbours[i].heard_timestamp > 0) { - sorted_neighbours[neighbours_count++] = &neighbours[i]; - } - } + // Create sorted copy + int16_t neighbours_count = 0; + NeighbourInfo* sorted_neighbours[MAX_NEIGHBOURS]; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp > 0) { + sorted_neighbours[neighbours_count++] = &neighbours[i]; + } + } - // Sort - if (order_by == 0) { - simple_sort(sorted_neighbours, (int)neighbours_count, - [](const NeighbourInfo* a, const NeighbourInfo* b) { - return a->heard_timestamp > b->heard_timestamp; - }); - } else if (order_by == 1) { - simple_sort(sorted_neighbours, (int)neighbours_count, - [](const NeighbourInfo* a, const NeighbourInfo* b) { - return a->heard_timestamp < b->heard_timestamp; - }); - } else if (order_by == 2) { - simple_sort(sorted_neighbours, (int)neighbours_count, - [](const NeighbourInfo* a, const NeighbourInfo* b) { - return a->snr > b->snr; - }); - } else if (order_by == 3) { - simple_sort(sorted_neighbours, (int)neighbours_count, - [](const NeighbourInfo* a, const NeighbourInfo* b) { - return a->snr < b->snr; - }); - } + // Sort + if (order_by == 0) { + simple_sort(sorted_neighbours, (int)neighbours_count, + [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->heard_timestamp > b->heard_timestamp; + }); + } else if (order_by == 1) { + simple_sort(sorted_neighbours, (int)neighbours_count, + [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->heard_timestamp < b->heard_timestamp; + }); + } else if (order_by == 2) { + simple_sort(sorted_neighbours, (int)neighbours_count, + [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->snr > b->snr; + }); + } else if (order_by == 3) { + simple_sort(sorted_neighbours, (int)neighbours_count, + [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->snr < b->snr; + }); + } - // Build results - int results_count = 0; - int results_offset = 0; - uint8_t results_buffer[130]; - for (int index = 0; index < count && index + offset < neighbours_count; index++) { - int entry_size = pubkey_prefix_length + 4 + 1; - if (results_offset + entry_size > (int)sizeof(results_buffer)) break; + // Build results + int results_count = 0; + int results_offset = 0; + uint8_t results_buffer[130]; + for (int index = 0; index < count && index + offset < neighbours_count; index++) { + int entry_size = pubkey_prefix_length + 4 + 1; + if (results_offset + entry_size > (int)sizeof(results_buffer)) break; - auto neighbour = sorted_neighbours[index + offset]; - uint32_t heard_seconds_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp; - memcpy(&results_buffer[results_offset], neighbour->id.pub_key, pubkey_prefix_length); - results_offset += pubkey_prefix_length; - memcpy(&results_buffer[results_offset], &heard_seconds_ago, 4); - results_offset += 4; - memcpy(&results_buffer[results_offset], &neighbour->snr, 1); - results_offset += 1; - results_count++; - } + auto neighbour = sorted_neighbours[index + offset]; + uint32_t heard_seconds_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp; + memcpy(&results_buffer[results_offset], neighbour->id.pub_key, pubkey_prefix_length); + results_offset += pubkey_prefix_length; + memcpy(&results_buffer[results_offset], &heard_seconds_ago, 4); + results_offset += 4; + memcpy(&results_buffer[results_offset], &neighbour->snr, 1); + results_offset += 1; + results_count++; + } - memcpy(&reply_data[reply_offset], &neighbours_count, 2); - reply_offset += 2; - memcpy(&reply_data[reply_offset], &results_count, 2); - reply_offset += 2; - memcpy(&reply_data[reply_offset], results_buffer, results_offset); - reply_offset += results_offset; + memcpy(&reply_data[reply_offset], &neighbours_count, 2); + reply_offset += 2; + memcpy(&reply_data[reply_offset], &results_count, 2); + reply_offset += 2; + memcpy(&reply_data[reply_offset], results_buffer, results_offset); + reply_offset += results_offset; - return reply_offset; - } + return reply_offset; + } #endif - } + } - if (payload[0] == REQ_TYPE_GET_OWNER_INFO) { - sprintf((char*)&reply_data[4], "%s\n%s\n%s", FIRMWARE_VERSION, _prefs.node_name, _prefs.owner_info); - return 4 + strlen((char*)&reply_data[4]); - } + if (payload[0] == REQ_TYPE_GET_OWNER_INFO) { + sprintf((char*)&reply_data[4], "%s\n%s\n%s", FIRMWARE_VERSION, _prefs.node_name, _prefs.owner_info); + return 4 + strlen((char*)&reply_data[4]); + } - return 0; + return 0; } -mesh::Packet* RepeaterMesh::createSelfAdvert() { - uint8_t app_data[MAX_ADVERT_DATA_SIZE]; - uint8_t app_data_len = _cli.buildAdvertData(ADV_TYPE_REPEATER, app_data); - return createAdvert(self_id, app_data, app_data_len); +mesh::Packet *RepeaterMesh::createSelfAdvert() { + uint8_t app_data[MAX_ADVERT_DATA_SIZE]; + uint8_t app_data_len = _cli.buildAdvertData(ADV_TYPE_REPEATER, app_data); + + return createAdvert(self_id, app_data, app_data_len); } -static uint8_t max_loop_minimal[] = { 0, /* 1-byte */ 4, /* 2-byte */ 2, /* 3-byte */ 1 }; +// ZEPHCORE: no openAppend() here: no log file on flash + +static uint8_t max_loop_minimal[] = { 0, /* 1-byte */ 4, /* 2-byte */ 2, /* 3-byte */ 1 }; static uint8_t max_loop_moderate[] = { 0, /* 1-byte */ 2, /* 2-byte */ 1, /* 3-byte */ 1 }; -static uint8_t max_loop_strict[] = { 0, /* 1-byte */ 1, /* 2-byte */ 1, /* 3-byte */ 1 }; +static uint8_t max_loop_strict[] = { 0, /* 1-byte */ 1, /* 2-byte */ 1, /* 3-byte */ 1 }; bool RepeaterMesh::isLooped(const mesh::Packet* packet, const uint8_t max_counters[]) { - uint8_t hash_size = packet->getPathHashSize(); - uint8_t hash_count = packet->getPathHashCount(); - uint8_t n = 0; - const uint8_t* path = packet->path; - while (hash_count > 0) { - if (self_id.isHashMatch(path, hash_size)) n++; - hash_count--; - path += hash_size; - } - return n >= max_counters[hash_size]; -} - -void RepeaterMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { - if (scope.isNull()) { - sendFlood(pkt, delay_millis, path_hash_size); - } else { - uint16_t codes[2]; - codes[0] = scope.calcTransportCode(pkt); - codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? - sendFlood(pkt, codes, delay_millis, path_hash_size); - } + uint8_t hash_size = packet->getPathHashSize(); + uint8_t hash_count = packet->getPathHashCount(); + uint8_t n = 0; + const uint8_t* path = packet->path; + while (hash_count > 0) { // count how many times this node is already in the path + if (self_id.isHashMatch(path, hash_size)) n++; + hash_count--; + path += hash_size; + } + return n >= max_counters[hash_size]; } +// ZEPHCORE: ours. An explicit unscoped-flood flag instead of isWildcard() (UPSTREAM_TRACKER fad11c90). void RepeaterMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { - TransportKey req_scope; - bool req_scope_known = recv_pkt_region != nullptr && !recv_pkt_region->isWildcard() - && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; + TransportKey req_scope; + bool req_scope_known = recv_pkt_region != nullptr && !recv_pkt_region->isWildcard() + && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; - switch (mesh::chooseReplyScope(req_scope_known, recv_pkt_unscoped_flood, !default_scope.isNull())) { - case mesh::REPLY_SCOPE_REQUEST: - sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // same scope as the request - break; - case mesh::REPLY_SCOPE_DEFAULT: - // requester's scope is unknown: a DIRECT request (no transport codes), or a - // code that matched no Region. Un-scoped would be dropped at hop 0 by every - // repeater running flood.max.unscoped=0. - sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); - break; - case mesh::REPLY_SCOPE_NONE: - sendFlood(packet, delay_millis, path_hash_size); // send un-scoped - break; - } + switch (mesh::chooseReplyScope(req_scope_known, recv_pkt_unscoped_flood, !default_scope.isNull())) { + case mesh::REPLY_SCOPE_REQUEST: + sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // same scope as the request + break; + case mesh::REPLY_SCOPE_DEFAULT: + // requester's scope is unknown: a DIRECT request (no transport codes), or a + // code that matched no Region. Un-scoped would be dropped at hop 0 by every + // repeater running flood.max.unscoped=0. + sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); + break; + case mesh::REPLY_SCOPE_NONE: + sendFlood(packet, delay_millis, path_hash_size); // send un-scoped + break; + } } -bool RepeaterMesh::allowPacketForward(const mesh::Packet* packet) { - if (_prefs.disable_fwd) return false; - if (packet->isRouteFlood() - && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, - _prefs.flood_max_advert)) { - return false; - } - if (packet->isRouteFlood() && recv_pkt_region == nullptr) return false; - if (packet->isRouteFlood() && _prefs.loop_detect != LOOP_DETECT_OFF) { - const uint8_t* maximums; - if (_prefs.loop_detect == LOOP_DETECT_MINIMAL) { - maximums = max_loop_minimal; - } else if (_prefs.loop_detect == LOOP_DETECT_MODERATE) { - maximums = max_loop_moderate; - } else { - maximums = max_loop_strict; - } - if (isLooped(packet, maximums)) { - MESH_DEBUG_PRINTLN("allowPacketForward: FLOOD packet loop detected!"); - return false; - } - } - return true; +bool RepeaterMesh::allowPacketForward(const mesh::Packet *packet) { + if (_prefs.disable_fwd) return false; + if (packet->isRouteFlood() + && mesh::isFloodHopLimitExceeded(packet, _prefs.flood_max, _prefs.flood_max_unscoped, _prefs.flood_max_advert)) { + return false; + } + if (packet->isRouteFlood() && recv_pkt_region == NULL) { + MESH_DEBUG_PRINTLN("allowPacketForward: unknown transport code, or wildcard not allowed for FLOOD packet"); + return false; + } + if (packet->isRouteFlood() && _prefs.loop_detect != LOOP_DETECT_OFF) { + const uint8_t* maximums; + if (_prefs.loop_detect == LOOP_DETECT_MINIMAL) { + maximums = max_loop_minimal; + } else if (_prefs.loop_detect == LOOP_DETECT_MODERATE) { + maximums = max_loop_moderate; + } else { + maximums = max_loop_strict; + } + if (isLooped(packet, maximums)) { + MESH_DEBUG_PRINTLN("allowPacketForward: FLOOD packet loop detected!"); + return false; + } + } + return true; } +// ZEPHCORE: ours. Zephyr clock formatting. const char* RepeaterMesh::getLogDateTime() { - static char tmp[48]; - uint32_t now = getRTCClock()->getCurrentTime(); - /* Match Arduino format: "HH:MM:SS - D/M/YYYY U" */ - time_t t = (time_t)now; - struct tm* tm = gmtime(&t); - if (tm) { - snprintf(tmp, sizeof(tmp), "%02d:%02d:%02d - %d/%d/%d U", - tm->tm_hour, tm->tm_min, tm->tm_sec, - tm->tm_mday, tm->tm_mon + 1, tm->tm_year + 1900); - } else { - snprintf(tmp, sizeof(tmp), "%u", now); - } - return tmp; + static char tmp[48]; + uint32_t now = getRTCClock()->getCurrentTime(); + /* Match Arduino format: "HH:MM:SS - D/M/YYYY U" */ + time_t t = (time_t)now; + struct tm* tm = gmtime(&t); + if (tm) { + snprintf(tmp, sizeof(tmp), "%02d:%02d:%02d - %d/%d/%d U", + tm->tm_hour, tm->tm_min, tm->tm_sec, + tm->tm_mday, tm->tm_mon + 1, tm->tm_year + 1900); + } else { + snprintf(tmp, sizeof(tmp), "%u", now); + } + return tmp; } +// ZEPHCORE: ours. Packet logging through helpers/PacketLog.h. void RepeaterMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { #if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING) - /* Arduino-compatible RAW packet hex dump */ - static char hex_buf[MAX_TRANS_UNIT * 2 + 1]; - mesh::Utils::toHex(hex_buf, raw, len); - printk("%s RAW: %s\n", getLogDateTime(), hex_buf); + /* Arduino-compatible RAW packet hex dump */ + static char hex_buf[MAX_TRANS_UNIT * 2 + 1]; + mesh::Utils::toHex(hex_buf, raw, len); + printk("%s RAW: %s\n", getLogDateTime(), hex_buf); #endif - (void)snr; - (void)rssi; + (void)snr; + (void)rssi; #if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) - _uplink_last_rssi = rssi; - _uplink_last_raw_len = len <= (int)sizeof(_uplink_last_raw) ? len : (int)sizeof(_uplink_last_raw); - if (_uplink_last_raw_len > 0) { - memcpy(_uplink_last_raw, raw, _uplink_last_raw_len); - } + _uplink_last_rssi = rssi; + _uplink_last_raw_len = len <= (int)sizeof(_uplink_last_raw) ? len : (int)sizeof(_uplink_last_raw); + if (_uplink_last_raw_len > 0) { + memcpy(_uplink_last_raw, raw, _uplink_last_raw_len); + } #endif } +// ZEPHCORE: ours. Packet logging through helpers/PacketLog.h. void RepeaterMesh::logRx(mesh::Packet* pkt, int len, float score) { - packet_log_rx(getLogDateTime(), pkt, _radio->getLastRSSI(), score, _radio->getEstAirtimeFor(len)); - if (_logging) { - LOG_INF("RX len=%d type=%d route=%s payload_len=%d SNR=%d RSSI=%d", - len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", - pkt->payload_len, (int)_radio->getLastSNR(), (int)_radio->getLastRSSI()); - } + packet_log_rx(getLogDateTime(), pkt, _radio->getLastRSSI(), score, _radio->getEstAirtimeFor(len)); + if (_logging) { + LOG_INF("RX len=%d type=%d route=%s payload_len=%d SNR=%d RSSI=%d", + len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", + pkt->payload_len, (int)_radio->getLastSNR(), (int)_radio->getLastRSSI()); + } #if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) - _uplink_last_score = score; - publishUplinkPacket(pkt); + _uplink_last_score = score; + publishUplinkPacket(pkt); #endif } +// ZEPHCORE: ours. Packet logging through helpers/PacketLog.h. void RepeaterMesh::logTx(mesh::Packet* pkt, int len) { - packet_log_tx(getLogDateTime(), pkt); - if (_logging) { - LOG_INF("TX len=%d type=%d route=%s payload_len=%d", - len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", - pkt->payload_len); - } + packet_log_tx(getLogDateTime(), pkt); + if (_logging) { + LOG_INF("TX len=%d type=%d route=%s payload_len=%d", + len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", + pkt->payload_len); + } } +// ZEPHCORE: ours. Packet logging through helpers/PacketLog.h. void RepeaterMesh::logTxFail(mesh::Packet* pkt, int len) { - if (_logging) { - LOG_WRN("TX FAIL len=%d type=%d route=%s payload_len=%d", - len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", - pkt->payload_len); - } + if (_logging) { + LOG_WRN("TX FAIL len=%d type=%d route=%s payload_len=%d", + len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", + pkt->payload_len); + } } +// ZEPHCORE: no calcRxDelay() here: rx delay is replaced by the adaptive contention window + +// ZEPHCORE: ours. Adaptive contention window. uint32_t RepeaterMesh::getRetransmitDelay(const mesh::Packet* packet) { - return computeAdaptiveFloodDelay(packet); + return computeAdaptiveFloodDelay(packet); } - +// ZEPHCORE: ours. Adaptive contention window. uint32_t RepeaterMesh::getDirectRetransmitDelay(const mesh::Packet* packet) { - return computeAdaptiveDirectDelay(packet); + return computeAdaptiveDirectDelay(packet); } +// ZEPHCORE: ours. Also records whether the packet was an unscoped flood. mesh::DispatcherAction RepeaterMesh::onRecvPacket(mesh::Packet* pkt) { - // Determine the request packet's region so sendFloodReply() can echo the same - // scope. Runs for every packet (not just floods) so recv_pkt_region is cleared - // for direct packets instead of inheriting the last flood's region. - recv_pkt_unscoped_flood = (pkt->getRouteType() == ROUTE_TYPE_FLOOD); - if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { - recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); - } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { - if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { - recv_pkt_region = nullptr; - } else { - recv_pkt_region = ®ion_map.getWildcard(); - } - } else { - recv_pkt_region = nullptr; - } - return Mesh::onRecvPacket(pkt); + // Determine the request packet's region so sendFloodReply() can echo the same + // scope. Runs for every packet (not just floods) so recv_pkt_region is cleared + // for direct packets instead of inheriting the last flood's region. + recv_pkt_unscoped_flood = (pkt->getRouteType() == ROUTE_TYPE_FLOOD); + if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { + recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); + } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { + if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { + recv_pkt_region = nullptr; + } else { + recv_pkt_region = ®ion_map.getWildcard(); + } + } else { + recv_pkt_region = nullptr; + } + return Mesh::onRecvPacket(pkt); } +// ZEPHCORE: ours. Bounded request parsing. void RepeaterMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) { - if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { - uint32_t timestamp; - memcpy(×tamp, data, 4); + if (packet->getPayloadType() == PAYLOAD_TYPE_ANON_REQ) { + uint32_t timestamp; + memcpy(×tamp, data, 4); - data[len] = 0; - uint8_t reply_len; + data[len] = 0; + uint8_t reply_len; - reply_path_len = OUT_PATH_UNKNOWN; - if (data[4] == 0 || data[4] >= ' ') { - reply_len = handleLoginReq(sender, secret, timestamp, &data[4], packet->isRouteFlood()); - } else if (data[4] == ANON_REQ_TYPE_REGIONS && packet->isRouteDirect()) { - reply_len = handleAnonRegionsReq(sender, timestamp, &data[5], (len > 5) ? (len - 5) : 0); - } else if (data[4] == ANON_REQ_TYPE_OWNER && packet->isRouteDirect()) { - reply_len = handleAnonOwnerReq(sender, timestamp, &data[5], (len > 5) ? (len - 5) : 0); - } else if (data[4] == ANON_REQ_TYPE_BASIC && packet->isRouteDirect()) { - reply_len = handleAnonClockReq(sender, timestamp, &data[5], (len > 5) ? (len - 5) : 0); - } else { - reply_len = 0; - } + reply_path_len = OUT_PATH_UNKNOWN; + if (data[4] == 0 || data[4] >= ' ') { + reply_len = handleLoginReq(sender, secret, timestamp, &data[4], packet->isRouteFlood()); + } else if (data[4] == ANON_REQ_TYPE_REGIONS && packet->isRouteDirect()) { + reply_len = handleAnonRegionsReq(sender, timestamp, &data[5], (len > 5) ? (len - 5) : 0); + } else if (data[4] == ANON_REQ_TYPE_OWNER && packet->isRouteDirect()) { + reply_len = handleAnonOwnerReq(sender, timestamp, &data[5], (len > 5) ? (len - 5) : 0); + } else if (data[4] == ANON_REQ_TYPE_BASIC && packet->isRouteDirect()) { + reply_len = handleAnonClockReq(sender, timestamp, &data[5], (len > 5) ? (len - 5) : 0); + } else { + reply_len = 0; + } - if (reply_len == 0) return; + if (reply_len == 0) return; - /* A DIRECT request that supplied no reply path can still be answered - * along the out_path already stored for this client, as - * onPeerDataRecv() does for REQ. Flooding it instead is both wasteful - * and — under flood.max.unscoped=0 — silently undeliverable. */ - ClientInfo* client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); - bool have_out_path = client != nullptr && client->out_path_len != OUT_PATH_UNKNOWN; + /* A DIRECT request that supplied no reply path can still be answered + * along the out_path already stored for this client, as + * onPeerDataRecv() does for REQ. Flooding it instead is both wasteful + * and — under flood.max.unscoped=0 — silently undeliverable. */ + ClientInfo* client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); + bool have_out_path = client != nullptr && client->out_path_len != OUT_PATH_UNKNOWN; - switch (mesh::chooseReplyRoute(packet->isRouteFlood(), - reply_path_len != OUT_PATH_UNKNOWN, have_out_path)) { - case mesh::REPLY_ROUTE_PATH_RETURN: { - // let this sender know the path TO here, so they can use sendDirect() later - mesh::Packet* path = createPathReturn(sender, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); - if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - break; - } - case mesh::REPLY_ROUTE_DIRECT_SUPPLIED: { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); - if (reply) sendDirect(reply, reply_path, reply_path_len, SERVER_RESPONSE_DELAY); - break; - } - case mesh::REPLY_ROUTE_DIRECT_OUT_PATH: { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); - if (reply) sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); - break; - } - case mesh::REPLY_ROUTE_FLOOD: { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); - if (reply) sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - break; - } - } - } + switch (mesh::chooseReplyRoute(packet->isRouteFlood(), + reply_path_len != OUT_PATH_UNKNOWN, have_out_path)) { + case mesh::REPLY_ROUTE_PATH_RETURN: { + // let this sender know the path TO here, so they can use sendDirect() later + mesh::Packet* path = createPathReturn(sender, secret, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + break; + } + case mesh::REPLY_ROUTE_DIRECT_SUPPLIED: { + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); + if (reply) sendDirect(reply, reply_path, reply_path_len, SERVER_RESPONSE_DELAY); + break; + } + case mesh::REPLY_ROUTE_DIRECT_OUT_PATH: { + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); + if (reply) sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); + break; + } + case mesh::REPLY_ROUTE_FLOOD: { + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, secret, reply_data, reply_len); + if (reply) sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + break; + } + } + } } -int RepeaterMesh::searchPeersByHash(const uint8_t* hash) { - int n = 0; - for (int i = 0; i < acl.getNumClients(); i++) { - if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { - matching_peer_indexes[n++] = i; - } - } - return n; +int RepeaterMesh::searchPeersByHash(const uint8_t *hash) { + int n = 0; + for (int i = 0; i < acl.getNumClients(); i++) { + if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { + matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) + } + } + return n; } -void RepeaterMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); - } +void RepeaterMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) { + // lookup pre-calculated shared_secret + memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); + } else { + MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); + } } -static bool isShare(const mesh::Packet* packet) { - if (packet->hasTransportCodes()) { - return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; - } - return false; +static bool isShare(const mesh::Packet *packet) { + if (packet->hasTransportCodes()) { + return packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0; // codes { 0, 0 } means 'send to nowhere' + } + return false; } +// ZEPHCORE: ours. Feeds the mesh time sync. void RepeaterMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, - const uint8_t* app_data, size_t app_data_len) { - mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); + const uint8_t* app_data, size_t app_data_len) { + mesh::Mesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); - /* Signature already verified by mesh::Mesh before this hook fires. - * Skip share rebroadcasts — they replay stale stored adverts and would - * churn the original sender's tenure. */ - if (!isShare(packet)) { - _timesync.onAdvertHeard(id.pub_key, timestamp, packet->getPathHashCount(), - (uint32_t)(k_uptime_get() / 1000)); - } + /* Signature already verified by mesh::Mesh before this hook fires. + * Skip share rebroadcasts — they replay stale stored adverts and would + * churn the original sender's tenure. */ + if (!isShare(packet)) { + _timesync.onAdvertHeard(id.pub_key, timestamp, packet->getPathHashCount(), + (uint32_t)(k_uptime_get() / 1000)); + } - if (packet->getPathHashCount() == 0 && !isShare(packet)) { - AdvertDataParser parser(app_data, app_data_len); - if (parser.isValid() && parser.getType() == ADV_TYPE_REPEATER) { - putNeighbour(id, timestamp, packet->getSNR()); - } - } + if (packet->getPathHashCount() == 0 && !isShare(packet)) { + AdvertDataParser parser(app_data, app_data_len); + if (parser.isValid() && parser.getType() == ADV_TYPE_REPEATER) { + putNeighbour(id, timestamp, packet->getSNR()); + } + } } -void RepeaterMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, - const uint8_t* secret, uint8_t* data, size_t len) { - int i = matching_peer_indexes[sender_idx]; - if (i < 0 || i >= acl.getNumClients()) { - LOG_WRN("onPeerDataRecv: invalid peer idx: %d", i); - return; - } - ClientInfo* client = acl.getClientByIdx(i); +void RepeaterMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, const uint8_t *secret, + uint8_t *data, size_t len) { + int i = matching_peer_indexes[sender_idx]; + if (i < 0 || i >= acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context) + MESH_DEBUG_PRINTLN("onPeerDataRecv: invalid peer idx: %d", i); + return; + } + ClientInfo* client = acl.getClientByIdx(i); - if (type == PAYLOAD_TYPE_REQ) { - uint32_t timestamp; - memcpy(×tamp, data, 4); + if (type == PAYLOAD_TYPE_REQ) { // request (from a Known admin client!) + uint32_t timestamp; + memcpy(×tamp, data, 4); - if (timestamp > client->last_timestamp) { - int reply_len = handleRequest(client, timestamp, &data[4], len - 4); - if (reply_len == 0) return; + if (timestamp > client->last_timestamp) { // prevent replay attacks + int reply_len = handleRequest(client, timestamp, &data[4], len - 4); + if (reply_len == 0) return; // invalid command - client->last_timestamp = timestamp; - client->last_activity = getRTCClock()->getCurrentTime(); + client->last_timestamp = timestamp; + client->last_activity = getRTCClock()->getCurrentTime(); - if (packet->isRouteFlood()) { - mesh::Packet* path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); - if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); - if (reply) { - if (client->out_path_len != OUT_PATH_UNKNOWN) { - sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); - } else { - sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } - } - } - } else { - LOG_DBG("onPeerDataRecv: possible replay attack"); - } - } else if (type == PAYLOAD_TYPE_TXT_MSG && len > 5 && client->isAdmin()) { - uint32_t sender_timestamp; - memcpy(&sender_timestamp, data, 4); - uint8_t flags = (data[4] >> 2); + if (packet->isRouteFlood()) { + // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response + mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + } else { + mesh::Packet *reply = + createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); + if (reply) { + if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT + sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); + } else { + sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + } + } + } + } else { + MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); + } + } else if (type == PAYLOAD_TYPE_TXT_MSG && len > 5 && client->isAdmin()) { // a CLI command + uint32_t sender_timestamp; + memcpy(&sender_timestamp, data, 4); // timestamp (by sender's RTC clock - which could be wrong) + uint8_t flags = (data[4] >> 2); // message attempt number, and other flags - /* TXT_TYPE_CLI_COMMAND (v1.18+) is handled exactly like TXT_TYPE_CLI_DATA - * here: both reach this block only behind client->isAdmin(), and both - * are covered by the monotonic sender_timestamp / is_retry gates below. */ - if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA || - flags == TXT_TYPE_CLI_COMMAND)) { - LOG_DBG("onPeerDataRecv: unsupported text type: flags=%02x", flags); - } else if (sender_timestamp >= client->last_timestamp) { - bool is_retry = (sender_timestamp == client->last_timestamp); - client->last_timestamp = sender_timestamp; - client->last_activity = getRTCClock()->getCurrentTime(); + if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA || flags == TXT_TYPE_CLI_COMMAND)) { + MESH_DEBUG_PRINTLN("onPeerDataRecv: unsupported text type received: flags=%02x", (uint32_t)flags); + } else if (sender_timestamp >= client->last_timestamp) { // prevent replay attacks + bool is_retry = (sender_timestamp == client->last_timestamp); + client->last_timestamp = sender_timestamp; + client->last_activity = getRTCClock()->getCurrentTime(); - data[len] = 0; + // len can be > original length, but 'text' will be padded with zeroes + data[len] = 0; // need to make a C string again, with null terminator - if (flags == TXT_TYPE_PLAIN) { - uint32_t ack_hash; - mesh::Utils::sha256((uint8_t*)&ack_hash, 4, data, 5 + strlen((char*)&data[5]), - client->id.pub_key, PUB_KEY_SIZE); - mesh::Packet* ack = createAck(ack_hash); - if (ack) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { - sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize()); - } else { - sendDirect(ack, client->out_path, client->out_path_len, TXT_ACK_DELAY); - } - } - } + if (flags == TXT_TYPE_PLAIN) { // for legacy CLI, send Acks + uint32_t ack_hash; // calc truncated hash of the message timestamp + text + sender pub_key, to prove + // to sender that we got it + mesh::Utils::sha256((uint8_t *)&ack_hash, 4, data, 5 + strlen((char *)&data[5]), client->id.pub_key, + PUB_KEY_SIZE); - uint8_t temp[5 + CLI_REMOTE_REPLY_SIZE]; - char* command = (char*)&data[5]; - char* reply = (char*)&temp[5]; - if (is_retry) { - *reply = 0; - } else { - handleCommand(sender_timestamp, command, reply); - } + mesh::Packet *ack = createAck(ack_hash); + if (ack) { + if (client->out_path_len == OUT_PATH_UNKNOWN) { + sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize()); + } else { + sendDirect(ack, client->out_path, client->out_path_len, TXT_ACK_DELAY); + } + } + } - int text_len = strlen(reply); - if (text_len > 0) { - uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); - if (timestamp == sender_timestamp) timestamp++; - memcpy(temp, ×tamp, 4); - temp[4] = (TXT_TYPE_CLI_DATA << 2); + uint8_t temp[166]; + char *command = (char *)&data[5]; + char *reply = (char *)&temp[5]; + if (is_retry) { + *reply = 0; + } else { + handleCommand(sender_timestamp, command, reply); + } + int text_len = strlen(reply); + if (text_len > 0) { + uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + if (timestamp == sender_timestamp) { + // WORKAROUND: the two timestamps need to be different, in the CLI view + timestamp++; + } + memcpy(temp, ×tamp, 4); // mostly an extra blob to help make packet_hash unique + temp[4] = (TXT_TYPE_CLI_DATA << 2); // NOTE: legacy was: TXT_TYPE_PLAIN - auto reply_pkt = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); - if (reply_pkt) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { - sendFloodReply(reply_pkt, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); - } else { - sendDirect(reply_pkt, client->out_path, client->out_path_len, CLI_REPLY_DELAY_MILLIS); - } - } - } - } else { - LOG_DBG("onPeerDataRecv: possible replay attack"); - } - } + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); + if (reply) { + if (client->out_path_len == OUT_PATH_UNKNOWN) { + sendFloodReply(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); + } else { + sendDirect(reply, client->out_path, client->out_path_len, CLI_REPLY_DELAY_MILLIS); + } + } + } + } else { + MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); + } + } } -bool RepeaterMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, - uint8_t* path, uint8_t path_len, uint8_t extra_type, - uint8_t* extra, uint8_t extra_len) { - int i = matching_peer_indexes[sender_idx]; - if (i >= 0 && i < acl.getNumClients()) { - LOG_DBG("PATH to client, path_len=%d", path_len); - auto client = acl.getClientByIdx(i); - /* path source bounded by upstream packet parser; client->out_path - * is MAX_PATH_SIZE-sized. */ - client->out_path_len = mesh::Packet::copyPath(client->out_path, path, MAX_PATH_SIZE, path_len); - client->last_activity = getRTCClock()->getCurrentTime(); - } - return false; +bool RepeaterMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t *secret, uint8_t *path, + uint8_t path_len, uint8_t extra_type, uint8_t *extra, uint8_t extra_len) { + // TODO: prevent replay attacks + int i = matching_peer_indexes[sender_idx]; + + if (i >= 0 && i < acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context) + MESH_DEBUG_PRINTLN("PATH to client, path_len=%d", (uint32_t)path_len); + auto client = acl.getClientByIdx(i); + + // store a copy of path, for sendDirect() + client->out_path_len = mesh::Packet::copyPath(client->out_path, path, path_len); + client->last_activity = getRTCClock()->getCurrentTime(); + } else { + MESH_DEBUG_PRINTLN("onPeerPathRecv: invalid peer idx: %d", i); + } + + // NOTE: no reciprocal path send!! + return false; } +#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 +#define CTL_TYPE_NODE_DISCOVER_RESP 0x90 + void RepeaterMesh::onControlDataRecv(mesh::Packet* packet) { - uint8_t type = packet->payload[0] & 0xF0; - if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 && - !_prefs.disable_fwd && discover_limiter.allow(getRTCClock()->getCurrentTime())) { - int i = 1; - uint8_t filter = packet->payload[i++]; - uint32_t tag; - memcpy(&tag, &packet->payload[i], 4); - i += 4; - uint32_t since = 0; - if (packet->payload_len >= i + 4) { - memcpy(&since, &packet->payload[i], 4); - i += 4; - } + uint8_t type = packet->payload[0] & 0xF0; // just test upper 4 bits + if (type == CTL_TYPE_NODE_DISCOVER_REQ && packet->payload_len >= 6 + && !_prefs.disable_fwd && discover_limiter.allow(rtc_clock.getCurrentTime()) + ) { + int i = 1; + uint8_t filter = packet->payload[i++]; + uint32_t tag; + memcpy(&tag, &packet->payload[i], 4); i += 4; + uint32_t since; + if (packet->payload_len >= i+4) { // optional since field + memcpy(&since, &packet->payload[i], 4); i += 4; + } else { + since = 0; + } - if ((filter & (1 << ADV_TYPE_REPEATER)) != 0 && _prefs.discovery_mod_timestamp >= since) { - bool prefix_only = packet->payload[0] & 1; - uint8_t data[6 + PUB_KEY_SIZE]; - data[0] = CTL_TYPE_NODE_DISCOVER_RESP | ADV_TYPE_REPEATER; - data[1] = packet->_snr; - memcpy(&data[2], &tag, 4); - memcpy(&data[6], self_id.pub_key, PUB_KEY_SIZE); - auto resp = createControlData(data, prefix_only ? 6 + 8 : 6 + PUB_KEY_SIZE); - if (resp) { - sendZeroHop(resp, getRetransmitDelay(resp) * 4); - } - } - } else if (type == CTL_TYPE_NODE_DISCOVER_RESP && packet->payload_len >= 6) { - uint8_t node_type = packet->payload[0] & 0x0F; - if (node_type != ADV_TYPE_REPEATER) return; - if (packet->payload_len < 6 + PUB_KEY_SIZE) return; + if ((filter & (1 << ADV_TYPE_REPEATER)) != 0 && _prefs.discovery_mod_timestamp >= since) { + bool prefix_only = packet->payload[0] & 1; + uint8_t data[6 + PUB_KEY_SIZE]; + data[0] = CTL_TYPE_NODE_DISCOVER_RESP | ADV_TYPE_REPEATER; // low 4-bits for node type + data[1] = packet->_snr; // let sender know the inbound SNR ( x 4) + memcpy(&data[2], &tag, 4); // include tag from request, for client to match to + memcpy(&data[6], self_id.pub_key, PUB_KEY_SIZE); + auto resp = createControlData(data, prefix_only ? 6 + 8 : 6 + PUB_KEY_SIZE); + if (resp) { + sendZeroHop(resp, getRetransmitDelay(resp)*4); // apply random delay (widened x4), as multiple nodes can respond to this + } + } + } else if (type == CTL_TYPE_NODE_DISCOVER_RESP && packet->payload_len >= 6) { + uint8_t node_type = packet->payload[0] & 0x0F; + if (node_type != ADV_TYPE_REPEATER) { + return; + } + if (packet->payload_len < 6 + PUB_KEY_SIZE) { + MESH_DEBUG_PRINTLN("onControlDataRecv: DISCOVER_RESP pubkey too short: %d", (uint32_t)packet->payload_len); + return; + } - /* Only accept responses matching our pending discover tag */ - if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) { - pending_discover_tag = 0; - return; - } - uint32_t tag; - memcpy(&tag, &packet->payload[2], 4); - if (tag != pending_discover_tag) return; + if (pending_discover_tag == 0 || millisHasNowPassed(pending_discover_until)) { + pending_discover_tag = 0; + return; + } + uint32_t tag; + memcpy(&tag, &packet->payload[2], 4); + if (tag != pending_discover_tag) { + return; + } - mesh::Identity id(&packet->payload[6]); - if (id.matches(self_id)) return; - putNeighbour(id, getRTCClock()->getCurrentTime(), packet->getSNR()); - } + mesh::Identity id(&packet->payload[6]); + if (id.matches(self_id)) { + return; + } + putNeighbour(id, rtc_clock.getCurrentTime(), packet->getSNR()); + } } void RepeaterMesh::sendNodeDiscoverReq() { - uint8_t data[10]; - data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // prefix_only=0 - data[1] = (1 << ADV_TYPE_REPEATER); - getRNG()->random(&data[2], 4); // tag - memcpy(&pending_discover_tag, &data[2], 4); - pending_discover_until = futureMillis(30000); - uint32_t since = 0; - memcpy(&data[6], &since, 4); + uint8_t data[10]; + data[0] = CTL_TYPE_NODE_DISCOVER_REQ; // prefix_only=0 + data[1] = (1 << ADV_TYPE_REPEATER); + getRNG()->random(&data[2], 4); // tag + memcpy(&pending_discover_tag, &data[2], 4); + pending_discover_until = futureMillis(60000); + uint32_t since = 0; + memcpy(&data[6], &since, 4); - auto pkt = createControlData(data, sizeof(data)); - if (pkt) { - sendZeroHop(pkt); - } + auto pkt = createControlData(data, sizeof(data)); + if (pkt) { + sendZeroHop(pkt); + } } +// ZEPHCORE: ours. Zephyr datastore and ZephCore members. RepeaterMesh::RepeaterMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, - mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables) - : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(), tables), - _board(board), - _cli(board, rtc, acl, &_prefs, this), - region_map(key_store), temp_map(key_store), - discover_limiter(4, 120), - anon_limiter(4, 180), - /* Failed-login rate limit: 4 wrong-password attempts per 180s. Matches - * anon_limiter's shape so legitimate operators don't notice; brute-force - * attempts hit the cap quickly and trip the LOG_WRN below. Global rate - * (not per-sender) — trade-off documented in CRYPTO_AUDIT_INDEX.md - * Phase 4 (mitigation for upstream MeshCore#2556). */ - login_fail_limiter(4, 180) { + mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables) + : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(), tables), + _board(board), + _cli(board, rtc, ®ion_map, &acl, &_prefs, this), + region_map(key_store), temp_map(key_store), + discover_limiter(4, 120), + anon_limiter(4, 180), + /* Failed-login rate limit: 4 wrong-password attempts per 180s. Matches + * anon_limiter's shape so legitimate operators don't notice; brute-force + * attempts hit the cap quickly and trip the LOG_WRN below. Global rate + * (not per-sender) — trade-off documented in CRYPTO_AUDIT_INDEX.md + * Phase 4 (mitigation for upstream MeshCore#2556). */ + login_fail_limiter(4, 180) { - _store = nullptr; - last_millis = 0; - uptime_millis = 0; - next_local_advert = next_flood_advert = 0; - dirty_contacts_expiry = 0; - set_radio_at = revert_radio_at = 0; - _logging = false; - region_load_active = false; - recv_pkt_region = nullptr; - recv_pkt_unscoped_flood = false; - memset(default_scope.key, 0, sizeof(default_scope.key)); - pending_discover_tag = 0; - pending_discover_until = 0; + _store = nullptr; + last_millis = 0; + uptime_millis = 0; + next_local_advert = next_flood_advert = 0; + dirty_contacts_expiry = 0; + set_radio_at = revert_radio_at = 0; + _logging = false; + region_load_active = false; + recv_pkt_region = nullptr; + recv_pkt_unscoped_flood = false; + memset(default_scope.key, 0, sizeof(default_scope.key)); + pending_discover_tag = 0; + pending_discover_until = 0; #if MAX_NEIGHBOURS > 0 - for (int i = 0; i < MAX_NEIGHBOURS; i++) { - neighbours[i].clear(); - } + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + neighbours[i].clear(); + } #endif - initNodePrefs(&_prefs); - strcpy(_prefs.node_name, "Repeater"); - _prefs.advert_loc_policy = ADVERT_LOC_PREFS; // Repeaters always advertise prefs coordinates - _prefs.loop_detect = LOOP_DETECT_MODERATE; - /* path_hash_mode = 1 moved into initNodePrefs() -- it is the default for - * every role, and a default listed only at one call site is invisible to - * the others (the companion never got it). */ + initNodePrefs(&_prefs); + strcpy(_prefs.node_name, "Repeater"); + _prefs.advert_loc_policy = ADVERT_LOC_PREFS; // Repeaters always advertise prefs coordinates + _prefs.loop_detect = LOOP_DETECT_MODERATE; + _prefs.gps_interval = CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC; // ZEPHCORE: 48 h, a fix for time sync + /* path_hash_mode = 1 moved into initNodePrefs() -- it is the default for + * every role, and a default listed only at one call site is invisible to + * the others (the companion never got it). */ #if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) - memset(&_uplink_creds, 0, sizeof(_uplink_creds)); - observer_creds_init(&_uplink_creds); - _uplink_reboot_required = false; - memset(_uplink_pubkey_hex, 0, sizeof(_uplink_pubkey_hex)); - memset(_uplink_packets_topic, 0, sizeof(_uplink_packets_topic)); - memset(_uplink_status_topic, 0, sizeof(_uplink_status_topic)); - _uplink_last_score = 0.0f; - _uplink_last_rssi = 0.0f; - _uplink_last_raw_len = 0; - _uplink_next_status_at = 0; - atomic_set(&_uplink_connect_pending, 0); + memset(&_uplink_creds, 0, sizeof(_uplink_creds)); + observer_creds_init(&_uplink_creds); + _uplink_reboot_required = false; + memset(_uplink_pubkey_hex, 0, sizeof(_uplink_pubkey_hex)); + memset(_uplink_packets_topic, 0, sizeof(_uplink_packets_topic)); + memset(_uplink_status_topic, 0, sizeof(_uplink_status_topic)); + _uplink_last_score = 0.0f; + _uplink_last_rssi = 0.0f; + _uplink_last_raw_len = 0; + _uplink_next_status_at = 0; + atomic_set(&_uplink_connect_pending, 0); #endif } +// ZEPHCORE: ours. Zephyr storage and radio setup. void RepeaterMesh::begin(RepeaterDataStore* store) { - _store = store; + _store = store; - /* Prefs and identity are loaded by the caller (main_repeater.cpp) before - * begin() — the radio reads freq/bw/sf/cr through _prefs during - * Mesh::begin() → Dispatcher::begin() → Radio::begin(). */ - mesh::Mesh::begin(); - _contention.setBackoffMultiplier(_prefs.backoff_multiplier); - acl.load(_store->getAclPath(), self_id); - region_map.load(_store->getRegionsPath()); + /* Prefs and identity are loaded by the caller (main_repeater.cpp) before + * begin() — the radio reads freq/bw/sf/cr through _prefs during + * Mesh::begin() → Dispatcher::begin() → Radio::begin(). */ + mesh::Mesh::begin(); + _contention.setBackoffMultiplier(_prefs.backoff_multiplier); + acl.load(_store->getFS(), self_id); + region_map.load(_store->getFS()); - // establish default-scope from persisted default region (if any) - { - RegionEntry* r = region_map.getDefaultRegion(); - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } - } + // establish default-scope from persisted default region (if any) + { + RegionEntry* r = region_map.getDefaultRegion(); + if (r) { + region_map.getTransportKeysFor(*r, &default_scope, 1); + } + } - updateAdvertTimer(); - updateFloodAdvertTimer(); + updateAdvertTimer(); + updateFloodAdvertTimer(); - _board.setAdcMultiplier(_prefs.adc_multiplier); + _board.setAdcMultiplier(_prefs.adc_multiplier); - LOG_INF("RepeaterMesh started: %s (freq=%.2f bw=%.0f sf=%d cr=%d)", - _prefs.node_name, (double)_prefs.freq, (double)_prefs.bw, _prefs.sf, _prefs.cr); + LOG_INF("RepeaterMesh started: %s (freq=%.2f bw=%.0f sf=%d cr=%d)", + _prefs.node_name, (double)_prefs.freq, (double)_prefs.bw, _prefs.sf, _prefs.cr); #if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) - observer_creds_load(&_uplink_creds, _store->getBasePath()); - mesh::Utils::toHex(_uplink_pubkey_hex, self_id.pub_key, PUB_KEY_SIZE); - _uplink_pubkey_hex[PUB_KEY_SIZE * 2] = '\0'; + observer_creds_load(&_uplink_creds, _store->getBasePath()); + mesh::Utils::toHex(_uplink_pubkey_hex, self_id.pub_key, PUB_KEY_SIZE); + _uplink_pubkey_hex[PUB_KEY_SIZE * 2] = '\0'; - const char *iata = _uplink_creds.mqtt_iata[0] ? _uplink_creds.mqtt_iata : "XXX"; - snprintf(_uplink_packets_topic, sizeof(_uplink_packets_topic), - "meshcore/%s/%s/packets", iata, _uplink_pubkey_hex); - snprintf(_uplink_status_topic, sizeof(_uplink_status_topic), - "meshcore/%s/%s/status", iata, _uplink_pubkey_hex); + const char *iata = _uplink_creds.mqtt_iata[0] ? _uplink_creds.mqtt_iata : "XXX"; + snprintf(_uplink_packets_topic, sizeof(_uplink_packets_topic), + "meshcore/%s/%s/packets", iata, _uplink_pubkey_hex); + snprintf(_uplink_status_topic, sizeof(_uplink_status_topic), + "meshcore/%s/%s/status", iata, _uplink_pubkey_hex); - if (isUplinkEnabled() && _uplink_creds.wifi_ssid[0] && _uplink_creds.mqtt_host[0]) { - s_uplink_mesh = this; - zc_wifi_station_start(&_uplink_creds, uplink_time_sync_cb); - mqtt_publisher_start(&_uplink_creds, _prefs.node_name, - _uplink_status_topic, _uplink_packets_topic); - mqtt_publisher_set_connect_cb([]() { - /* Runs on the MQTT publisher thread — DON'T publish here. It would - * race the main-thread producer on the publisher's staging buffer - * and toggle the battery-ADC regulator off-main. Defer to the main - * loop via an atomic flag drained in maintenanceLoop(). */ - if (s_uplink_mesh) { - atomic_set(&s_uplink_mesh->_uplink_connect_pending, 1); - /* Wake the main loop so "online" publishes immediately. - * Without this the flag waits for whatever deadline fires - * next — up to UPLINK_STATUS_INTERVAL_MS (5 min) on an idle - * repeater, since maintenance no longer ticks every 5 s. */ - s_uplink_mesh->notifyWake(); - } - }); - _uplink_next_status_at = futureMillis(UPLINK_STATUS_INTERVAL_MS); - LOG_INF("Repeater uplink active: %s", _uplink_packets_topic); - } else { - LOG_INF("Repeater uplink inactive"); - } + if (isUplinkEnabled() && _uplink_creds.wifi_ssid[0] && _uplink_creds.mqtt_host[0]) { + s_uplink_mesh = this; + zc_wifi_station_start(_uplink_creds.wifi_ssid, _uplink_creds.wifi_psk, uplink_time_sync_cb); + mqtt_publisher_start(&_uplink_creds, _prefs.node_name, + _uplink_status_topic, _uplink_packets_topic); + mqtt_publisher_set_connect_cb([]() { + /* Runs on the MQTT publisher thread — DON'T publish here. It would + * race the main-thread producer on the publisher's staging buffer + * and toggle the battery-ADC regulator off-main. Defer to the main + * loop via an atomic flag drained in maintenanceLoop(). */ + if (s_uplink_mesh) { + atomic_set(&s_uplink_mesh->_uplink_connect_pending, 1); + /* Wake the main loop so "online" publishes immediately. + * Without this the flag waits for whatever deadline fires + * next — up to UPLINK_STATUS_INTERVAL_MS (5 min) on an idle + * repeater, since maintenance no longer ticks every 5 s. */ + s_uplink_mesh->notifyWake(); + } + }); + _uplink_next_status_at = futureMillis(UPLINK_STATUS_INTERVAL_MS); + LOG_INF("Repeater uplink active: %s", _uplink_packets_topic); + } else { + LOG_INF("Repeater uplink inactive"); + } #endif } -double RepeaterMesh::getNodeLat() const { - struct gps_position pos; - if (gps_get_last_known_position(&pos)) { - return pos.latitude_ndeg / 1e9; - } - return _prefs.node_lat; -} - -double RepeaterMesh::getNodeLon() const { - struct gps_position pos; - if (gps_get_last_known_position(&pos)) { - return pos.longitude_ndeg / 1e9; - } - return _prefs.node_lon; -} - -bool RepeaterMesh::setGpsEnabled(bool enabled) { - if (!gps_is_available()) return false; - gps_enable(enabled); - return true; -} - -bool RepeaterMesh::isGpsEnabled() const { - return gps_is_enabled(); -} - -void RepeaterMesh::formatGpsStatsReply(char* reply) { - if (!gps_is_enabled()) { - strcpy(reply, "off"); - return; - } - - struct gps_state_info gsi; - gps_get_state_info(&gsi); - - static const char* const state_str[] = { "off", "standby", "acquiring" }; - const char* state = gsi.state < 3 ? state_str[gsi.state] : "unknown"; - - struct gps_position pos; - bool has_pos = gps_get_last_known_position(&pos); - - if (has_pos) { - snprintf(reply, CLI_REPLY_SIZE, - "on state=%s sats=%u fix=%us ago lat=%.6f lon=%.6f", - state, gsi.satellites, gsi.last_fix_age_s, - pos.latitude_ndeg / 1e9, pos.longitude_ndeg / 1e9); - } else if (gsi.next_search_s > 0) { - snprintf(reply, CLI_REPLY_SIZE, - "on state=%s sats=%u no fix next=%us", - state, gsi.satellites, gsi.next_search_s); - } else { - snprintf(reply, CLI_REPLY_SIZE, - "on state=%s sats=%u no fix", - state, gsi.satellites); - } - - /* Per-constellation tally is deliberately NOT appended here — "get gps" - * has to fit a LoRa reply. It lives in "get gps diag" instead. */ -} - -void RepeaterMesh::savePrefs() { - if (_store) { - _store->savePrefs(_prefs); - } +void RepeaterMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { + if (scope.isNull()) { + sendFlood(pkt, delay_millis, path_hash_size); + } else { + uint16_t codes[2]; + codes[0] = scope.calcTransportCode(pkt); + codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? + sendFlood(pkt, codes, delay_millis, path_hash_size); + } } void RepeaterMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { - set_radio_at = futureMillis(2000); - pending_freq = freq; - pending_bw = bw; - pending_sf = sf; - pending_cr = cr; - revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); -} - -void RepeaterMesh::freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { - auto& radio = getRadioDriver(_radio); - if (!radio.hasRadioOverride()) { - /* Holds the radio on the preset it is ALREADY running while _prefs - * move ahead of it, so the learned CAD offset still applies — see - * setRadioOverride(). */ - radio.setRadioOverride(freq, bw, sf, cr, /*visiting_new_preset=*/false); - } + set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params + pending_freq = freq; + pending_bw = bw; + pending_sf = sf; + pending_cr = cr; + + revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); // schedule when to revert radio params } +// ZEPHCORE: ours. Zephyr storage. bool RepeaterMesh::formatFileSystem() { - if (_store) { - return _store->formatFileSystem(); - } - return false; + if (_store) { + return _store->formatFileSystem(); + } + return false; } void RepeaterMesh::sendSelfAdvertisement(int delay_millis, bool flood) { - mesh::Packet* pkt = createSelfAdvert(); - if (pkt) { - if (flood) { - sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); - } else { - sendZeroHop(pkt, delay_millis); - } - } else { - LOG_ERR("Unable to create advertisement packet"); - } + mesh::Packet *pkt = createSelfAdvert(); + if (pkt) { + if (flood) { + sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); + } else { + sendZeroHop(pkt, delay_millis); + } + } else { + MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); + } } void RepeaterMesh::updateAdvertTimer() { - if (_prefs.advert_interval > 0) { - next_local_advert = futureMillis(((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); - } else { - next_local_advert = 0; - } + if (_prefs.advert_interval > 0) { // schedule local advert timer + next_local_advert = futureMillis(((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); + } else { + next_local_advert = 0; // stop the timer + } } void RepeaterMesh::updateFloodAdvertTimer() { - if (_prefs.flood_advert_interval > 0) { - next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); - } else { - next_flood_advert = 0; - } + if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer + next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); + } else { + next_flood_advert = 0; // stop the timer + } +} + +// ZEPHCORE: ours. No log file. +void RepeaterMesh::dumpLogFile() { + // Logging to file not implemented in Zephyr version + LOG_INF("Log dump not implemented"); +} + +// ZEPHCORE: ours. No runtime TX-power API. +void RepeaterMesh::setTxPower(int8_t power_dbm) { + radio_set_tx_power(power_dbm); +} + +// ZEPHCORE: ours. Through the LoRa adapter. +bool RepeaterMesh::setRxBoostedGain(bool enable) { + return getRadioDriver(_radio).setRxBoost(enable); +} + +#if 1 // ZEPHCORE: every radio family (was USE_LR2021); non-LR2021 adapters report unsupported +// ZEPHCORE: ours. Through the LoRa adapter. +bool RepeaterMesh::configSideDetectors(const uint8_t* sfs, uint8_t num) { + return getRadioDriver(_radio).configSideDetectors(sfs, num); +} +#endif + +// ZEPHCORE: ours. No ; insertion sort. +void RepeaterMesh::formatNeighborsReply(char* reply) { + char* dp = reply; + +#if MAX_NEIGHBOURS > 0 + int16_t neighbours_count = 0; + NeighbourInfo* sorted_neighbours[MAX_NEIGHBOURS]; + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp > 0) { + sorted_neighbours[neighbours_count++] = &neighbours[i]; + } + } + + simple_sort(sorted_neighbours, (int)neighbours_count, + [](const NeighbourInfo* a, const NeighbourInfo* b) { + return a->heard_timestamp > b->heard_timestamp; + }); + + for (int i = 0; i < neighbours_count && dp - reply < 134; i++) { + NeighbourInfo* neighbour = sorted_neighbours[i]; + + if (i > 0) *dp++ = '\n'; + + char hex[10]; + mesh::Utils::toHex(hex, neighbour->id.pub_key, 4); + + uint32_t secs_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp; + sprintf(dp, "%s:%u:%d", hex, secs_ago, neighbour->snr); + while (*dp) dp++; + } +#endif + + if (dp == reply) { + strcpy(dp, "-none-"); + dp += 6; + } + *dp = 0; +} + +// ZEPHCORE: ours. NeighbourInfo::clear(). +void RepeaterMesh::removeNeighbor(const uint8_t* pubkey, int key_len) { +#if MAX_NEIGHBOURS > 0 + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (memcmp(neighbours[i].id.pub_key, pubkey, key_len) == 0) { + neighbours[i].clear(); + } + } +#endif +} + +void RepeaterMesh::startRegionsLoad() { + temp_map.resetFrom(region_map); // rebuild regions in a temp instance + memset(load_stack, 0, sizeof(load_stack)); + load_stack[0] = &temp_map.getWildcard(); + region_load_active = true; +} + +// ZEPHCORE: ours. Through RepeaterDataStore. +bool RepeaterMesh::saveRegions() { + return region_map.save(_store->getFS()); +} + +void RepeaterMesh::onDefaultRegionChanged(const RegionEntry* r) { + if (r) { + region_map.getTransportKeysFor(*r, &default_scope, 1); + } else { + memset(default_scope.key, 0, sizeof(default_scope.key)); + } +} + +// ZEPHCORE: ours. Our board handle. +void RepeaterMesh::formatStatsReply(char* reply) { + StatsFormatHelper::formatCoreStats(reply, _board, *_ms, _err_flags, _mgr); +} + +// ZEPHCORE: ours. StatsFormatHelper over the LoRa adapter. +void RepeaterMesh::formatRadioStatsReply(char* reply) { + auto& radio_driver = getRadioDriver(_radio); + StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); +} + +// ZEPHCORE: ours. StatsFormatHelper over the LoRa adapter. +void RepeaterMesh::formatPacketStatsReply(char* reply) { + auto& radio_driver = getRadioDriver(_radio); + StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), + getNumRecvFlood(), getNumRecvDirect()); +} + +// ZEPHCORE: ours. Through RepeaterDataStore. +void RepeaterMesh::saveIdentity(const mesh::LocalIdentity& new_id) { + if (_store) { + _store->saveIdentity(new_id); + } +} + +// ZEPHCORE: ours. Also clears the radio adapter counters. +void RepeaterMesh::clearStats() { + auto& radio_driver = getRadioDriver(_radio); + radio_driver.resetStats(); + radio_driver.resetDutyCycleTimeoutRestarts(); + resetStats(); + ((SimpleMeshTables *)getTables())->resetStats(); +} + +// ZEPHCORE: ours. Reply-header budget, blank lines, uplink and neighbour CLI. +void RepeaterMesh::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { + if (region_load_active) { + handleRegionLoadLine(sender_timestamp, command, reply); + return; + } + + /* Blank line: nothing to run. The USB reader forwards these (see + * cli_rx_work_fn) because `region load` commits on one -- which is + * handled above, before this returns. */ + if (StrHelper::isBlank(command)) { reply[0] = 0; return; } + + while (*command == ' ') command++; + + uint8_t hdr_used = 0; + if (strlen(command) > 4 && command[2] == '|') { + memcpy(reply, command, 3); + reply += 3; + command += 3; + hdr_used = 3; + } + /* Tell the shared CLI how much of the caller's buffer is already spent, so + * the self-limiting handlers do not write past temp[5 + CLI_REMOTE_REPLY_SIZE]. + * Set unconditionally: a stale value would shrink the next reply. */ + _cli.setReplyHeaderUsed(hdr_used); + +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) + if (handleUplinkCommand(command, reply)) { + return; + } +#endif + + // ACL commands - supports BOTH formats for app compatibility: + // Old Arduino: setperm {pubkey-hex} {permissions} (pubkey is long, perms is short) + // MeshCore App: setperm {permissions} {pubkey-hex} (perms is short 2-char hex, pubkey is long) + // Detection: if first part is <= 2 chars, it's permissions; otherwise it's pubkey + if (memcmp(command, "setperm ", 8) == 0) { + char* first = &command[8]; + char* sp = strchr(first, ' '); + if (sp == nullptr) { + strcpy(reply, "Err - bad params"); + } else { + *sp++ = 0; // null terminate first part + char* second = sp; + + // Detect format: if first part is short (1-2 chars), it's permissions + int first_len = strlen(first); + char* hex; + uint8_t perms; + + if (first_len <= 2) { + // App format: setperm {perms} {pubkey} + perms = (uint8_t)strtol(first, nullptr, 16); + hex = second; + } else { + // Arduino format: setperm {pubkey} {perms} + hex = first; + perms = (uint8_t)atoi(second); + } + + uint8_t pubkey[PUB_KEY_SIZE]; + int hex_len = strlen(hex); + if (hex_len > PUB_KEY_SIZE * 2) hex_len = PUB_KEY_SIZE * 2; + if (mesh::Utils::fromHex(pubkey, hex_len / 2, hex)) { + if (acl.applyPermissions(self_id, pubkey, hex_len / 2, perms)) { + if (!dirty_contacts_expiry) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - invalid params"); + } + } else { + strcpy(reply, "Err - bad pubkey"); + } + } + } else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) { + Serial.println("ACL:"); + for (int i = 0; i < acl.getNumClients(); i++) { + auto c = acl.getClientByIdx(i); + if (c->permissions == 0) continue; // skip deleted (or guest) entries + + Serial.printf("%02X ", c->permissions); + mesh::Utils::printHex(Serial, c->id.pub_key, PUB_KEY_SIZE); + Serial.printf("\n"); + } + reply[0] = 0; + } else if (memcmp(command, "discover.neighbors", 18) == 0) { + const char* sub = command + 18; + while (*sub == ' ') sub++; + if (*sub != 0) { + strcpy(reply, "Err - discover.neighbors has no options"); + } else { + sendNodeDiscoverReq(); + strcpy(reply, "OK - Discover sent"); + } + } else { + _cli.handleCommand(sender_timestamp, command, reply); + } +} + +// ZEPHCORE: ours. Deadline-driven maintenance. +void RepeaterMesh::loop() { + mesh::Mesh::loop(); + + if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { + mesh::Packet* pkt = createSelfAdvert(); + if (pkt) sendFloodScoped(default_scope, pkt, (uint32_t)0, _prefs.path_hash_mode + 1); + updateFloodAdvertTimer(); + updateAdvertTimer(); + } else if (next_local_advert && millisHasNowPassed(next_local_advert)) { + mesh::Packet* pkt = createSelfAdvert(); + if (pkt) sendZeroHop(pkt); + updateAdvertTimer(); + } + + if (set_radio_at && millisHasNowPassed(set_radio_at)) { + set_radio_at = 0; + getRadioDriver(_radio).setRadioOverride(pending_freq, pending_bw, pending_sf, pending_cr); + LOG_INF("Temp radio params applied"); + } + + if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { + revert_radio_at = 0; + getRadioDriver(_radio).clearRadioOverride(); + LOG_INF("Radio params restored"); + } + + if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { + acl.save(_store->getFS()); + dirty_contacts_expiry = 0; + } + +#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) + /* MQTT (re)connected — publish the initial "online" status here on the main + * thread (the CONNACK callback only sets this flag, off-main). */ + if (atomic_cas(&_uplink_connect_pending, 1, 0)) { + publishUplinkStatus("online"); + } + if (_uplink_next_status_at && millisHasNowPassed(_uplink_next_status_at)) { + publishUplinkStatus("online"); + _uplink_next_status_at = futureMillis(UPLINK_STATUS_INTERVAL_MS); + } + if (atomic_cas(&s_uplink_sntp_pending, 1, 0)) { + /* SNTP set the clock (trusted) — arm suppression + drift envelope. */ + _timesync.noteManualSync((uint32_t)(k_uptime_get() / 1000)); + } +#endif + + timeSyncTick(); + + uint32_t now = k_uptime_get(); + uptime_millis += now - last_millis; + last_millis = now; +} + +// ZEPHCORE: no hasPendingWork(). Upstream's main.cpp gates sleep on it; our +// event loop sleeps in k_event_wait() and wakes on deadlines (lld/07b). + + +/* ---- ZEPHCORE: methods upstream does not have ---- */ + +double RepeaterMesh::getNodeLat() const { + struct gps_position pos; + if (gps_get_last_known_position(&pos)) { + return pos.latitude_ndeg / 1e9; + } + return _prefs.node_lat; +} + +double RepeaterMesh::getNodeLon() const { + struct gps_position pos; + if (gps_get_last_known_position(&pos)) { + return pos.longitude_ndeg / 1e9; + } + return _prefs.node_lon; +} + +bool RepeaterMesh::setGpsEnabled(bool enabled) { + if (!gps_is_available()) return false; + gps_enable(enabled); + return true; +} + +bool RepeaterMesh::isGpsEnabled() const { + return gps_is_enabled(); +} + +void RepeaterMesh::formatGpsStatsReply(char* reply) { + if (!gps_is_enabled()) { + strcpy(reply, "off"); + return; + } + + struct gps_state_info gsi; + gps_get_state_info(&gsi); + + static const char* const state_str[] = { "off", "standby", "acquiring" }; + const char* state = gsi.state < 3 ? state_str[gsi.state] : "unknown"; + + struct gps_position pos; + bool has_pos = gps_get_last_known_position(&pos); + + if (has_pos) { + snprintf(reply, CLI_REPLY_SIZE, + "on state=%s sats=%u fix=%us ago lat=%.6f lon=%.6f", + state, gsi.satellites, gsi.last_fix_age_s, + pos.latitude_ndeg / 1e9, pos.longitude_ndeg / 1e9); + } else if (gsi.next_search_s > 0) { + snprintf(reply, CLI_REPLY_SIZE, + "on state=%s sats=%u no fix next=%us", + state, gsi.satellites, gsi.next_search_s); + } else { + snprintf(reply, CLI_REPLY_SIZE, + "on state=%s sats=%u no fix", + state, gsi.satellites); + } + + /* Per-constellation tally is deliberately NOT appended here — "get gps" + * has to fit a LoRa reply. It lives in "get gps diag" instead. */ +} + +void RepeaterMesh::savePrefs() { + if (_store) { + _store->savePrefs(_prefs); + } +} + +void RepeaterMesh::freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { + auto& radio = getRadioDriver(_radio); + if (!radio.hasRadioOverride()) { + /* Holds the radio on the preset it is ALREADY running while _prefs + * move ahead of it, so the learned CAD offset still applies — see + * setRadioOverride(). */ + radio.setRadioOverride(freq, bw, sf, cr, /*visiting_new_preset=*/false); + } } void RepeaterMesh::eraseLogFile() { - // Logging to file not implemented in Zephyr version - LOG_INF("Log erased"); -} - -void RepeaterMesh::dumpLogFile() { - // Logging to file not implemented in Zephyr version - LOG_INF("Log dump not implemented"); -} - -void RepeaterMesh::setTxPower(int8_t power_dbm) { - radio_set_tx_power(power_dbm); -} - -bool RepeaterMesh::setRxBoostedGain(bool enable) { - return getRadioDriver(_radio).setRxBoost(enable); + // Logging to file not implemented in Zephyr version + LOG_INF("Log erased"); } bool RepeaterMesh::setFemRxGain(bool enable) { - return getRadioDriver(_radio).setFemRxEnable(enable); -} - -bool RepeaterMesh::configSideDetectors(const uint8_t* sfs, uint8_t num) { - return getRadioDriver(_radio).configSideDetectors(sfs, num); -} - -void RepeaterMesh::formatNeighborsReply(char* reply) { - char* dp = reply; - -#if MAX_NEIGHBOURS > 0 - int16_t neighbours_count = 0; - NeighbourInfo* sorted_neighbours[MAX_NEIGHBOURS]; - for (int i = 0; i < MAX_NEIGHBOURS; i++) { - if (neighbours[i].heard_timestamp > 0) { - sorted_neighbours[neighbours_count++] = &neighbours[i]; - } - } - - simple_sort(sorted_neighbours, (int)neighbours_count, - [](const NeighbourInfo* a, const NeighbourInfo* b) { - return a->heard_timestamp > b->heard_timestamp; - }); - - for (int i = 0; i < neighbours_count && dp - reply < 134; i++) { - NeighbourInfo* neighbour = sorted_neighbours[i]; - - if (i > 0) *dp++ = '\n'; - - char hex[10]; - mesh::Utils::toHex(hex, neighbour->id.pub_key, 4); - - uint32_t secs_ago = getRTCClock()->getCurrentTime() - neighbour->heard_timestamp; - sprintf(dp, "%s:%u:%d", hex, secs_ago, neighbour->snr); - while (*dp) dp++; - } -#endif - - if (dp == reply) { - strcpy(dp, "-none-"); - dp += 6; - } - *dp = 0; -} - -void RepeaterMesh::removeNeighbor(const uint8_t* pubkey, int key_len) { -#if MAX_NEIGHBOURS > 0 - for (int i = 0; i < MAX_NEIGHBOURS; i++) { - if (memcmp(neighbours[i].id.pub_key, pubkey, key_len) == 0) { - neighbours[i].clear(); - } - } -#endif -} - -void RepeaterMesh::formatStatsReply(char* reply) { - StatsFormatHelper::formatCoreStats(reply, _board, *_ms, _err_flags, _mgr); -} - -void RepeaterMesh::formatRadioStatsReply(char* reply) { - auto& radio_driver = getRadioDriver(_radio); - StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); -} - -void RepeaterMesh::formatPacketStatsReply(char* reply) { - auto& radio_driver = getRadioDriver(_radio); - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), - getNumRecvFlood(), getNumRecvDirect()); -} - -void RepeaterMesh::saveIdentity(const mesh::LocalIdentity& new_id) { - if (_store) { - _store->saveIdentity(new_id); - } -} - -void RepeaterMesh::clearStats() { - auto& radio_driver = getRadioDriver(_radio); - radio_driver.resetStats(); - radio_driver.resetDutyCycleTimeoutRestarts(); - resetStats(); - ((SimpleMeshTables *)getTables())->resetStats(); + return getRadioDriver(_radio).setFemRxEnable(enable); } uint32_t RepeaterMesh::getDutyCycleTimeoutRestarts() const { - return getRadioDriver(_radio).getDutyCycleTimeoutRestarts(); + return getRadioDriver(_radio).getDutyCycleTimeoutRestarts(); } void RepeaterMesh::resetDutyCycleTimeoutRestarts() { - getRadioDriver(_radio).resetDutyCycleTimeoutRestarts(); + getRadioDriver(_radio).resetDutyCycleTimeoutRestarts(); } -/* Region-def CLI (handleRegionLoadLine / handleRegionCommand) and its static - * parser helpers live in app/RepeaterRegionCLI.cpp. */ +/* A continuation line during `region load` (started by startRegionsLoad()). */ +void RepeaterMesh::handleRegionLoadLine(uint32_t sender_timestamp, char* command, char* reply) { + if (StrHelper::isBlank(command)) { + region_map = temp_map; + region_load_active = false; + sprintf(reply, "OK - loaded %d regions", region_map.getCount()); + } else { + char* np = command; + while (*np == ' ') np++; + int indent = np - command; -void RepeaterMesh::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { - if (region_load_active) { - handleRegionLoadLine(sender_timestamp, command, reply); - return; - } + /* An unindented, name-like line is a typed command, not a region row: + * real rows are indent >= 1 (load_stack[0] is the wildcard), and the + * one unindented line a client legitimately sends is the exported + * wildcard header "*", whose '*' is not a name char. Without this, + * `region load` is only escapable by a blank line -- which the USB + * reader discards (main_repeater.cpp) and a dead remote-admin client + * never sends, stranding the CLI until a reboot. Abort without + * committing temp_map and run the command. Must come BEFORE the + * name-terminator write below, which would truncate `set foo 1` to + * `set`. */ + if (indent == 0 && RegionMap::is_name_char((uint8_t)*np)) { + region_load_active = false; + handleCommand(sender_timestamp, command, reply); + return; + } - /* Blank line: nothing to run. The USB reader forwards these (see - * cli_rx_work_fn) because `region load` commits on one -- which is - * handled above, before this returns. */ - if (StrHelper::isBlank(command)) { reply[0] = 0; return; } + char* ep = np; + while (RegionMap::is_name_char(*ep)) ep++; + if (*ep) { *ep++ = 0; } - while (*command == ' ') command++; + while (*ep && *ep != 'F') ep++; - uint8_t hdr_used = 0; - if (strlen(command) > 4 && command[2] == '|') { - memcpy(reply, command, 3); - reply += 3; - command += 3; - hdr_used = 3; - } - /* Tell the shared CLI how much of the caller's buffer is already spent, so - * the self-limiting handlers do not write past temp[5 + CLI_REMOTE_REPLY_SIZE]. - * Set unconditionally: a stale value would shrink the next reply. */ - _cli.setReplyHeaderUsed(hdr_used); - -#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) - if (handleUplinkCommand(command, reply)) { - return; - } -#endif - - // ACL commands - supports BOTH formats for app compatibility: - // Old Arduino: setperm {pubkey-hex} {permissions} (pubkey is long, perms is short) - // MeshCore App: setperm {permissions} {pubkey-hex} (perms is short 2-char hex, pubkey is long) - // Detection: if first part is <= 2 chars, it's permissions; otherwise it's pubkey - if (memcmp(command, "setperm ", 8) == 0) { - char* first = &command[8]; - char* sp = strchr(first, ' '); - if (sp == nullptr) { - strcpy(reply, "Err - bad params"); - } else { - *sp++ = 0; // null terminate first part - char* second = sp; - - // Detect format: if first part is short (1-2 chars), it's permissions - int first_len = strlen(first); - char* hex; - uint8_t perms; - - if (first_len <= 2) { - // App format: setperm {perms} {pubkey} - perms = (uint8_t)strtol(first, nullptr, 16); - hex = second; - } else { - // Arduino format: setperm {pubkey} {perms} - hex = first; - perms = (uint8_t)atoi(second); - } - - uint8_t pubkey[PUB_KEY_SIZE]; - int hex_len = strlen(hex); - if (hex_len > PUB_KEY_SIZE * 2) hex_len = PUB_KEY_SIZE * 2; - if (mesh::Utils::fromHex(pubkey, hex_len / 2, hex)) { - if (acl.applyPermissions(self_id, pubkey, hex_len / 2, perms)) { - if (!dirty_contacts_expiry) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Err - invalid params"); - } - } else { - strcpy(reply, "Err - bad pubkey"); - } - } - } else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) { - LOG_INF("ACL:"); - for (int i = 0; i < acl.getNumClients(); i++) { - auto c = acl.getClientByIdx(i); - if (c->permissions == 0) continue; - char hex[PUB_KEY_SIZE * 2 + 1]; - mesh::Utils::toHex(hex, c->id.pub_key, PUB_KEY_SIZE); - LOG_INF(" %02X %s", c->permissions, hex); - } - reply[0] = 0; - } else if (memcmp(command, "region", 6) == 0) { - handleRegionCommand(command, reply); - } else if (memcmp(command, "discover.neighbors", 18) == 0) { - const char* sub = command + 18; - while (*sub == ' ') sub++; - if (*sub != 0) { - strcpy(reply, "Err - discover.neighbors has no options"); - } else { - sendNodeDiscoverReq(); - strcpy(reply, "OK - Discover sent"); - } - } else { - _cli.handleCommand(sender_timestamp, command, reply); - } -} - -/* MQTT uplink methods (saveUplinkCreds / handleUplinkCommand / - * publishUplinkPacket / publishUplinkStatus) live in app/RepeaterUplink.cpp, - * compiled only when CONFIG_ZEPHCORE_REPEATER_UPLINK && CONFIG_MQTT_LIB. - * The uplink init (WiFi/MQTT start + topic strings) stays in begin() above. */ - -void RepeaterMesh::loop() { - mesh::Mesh::loop(); - - if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { - mesh::Packet* pkt = createSelfAdvert(); - if (pkt) sendFloodScoped(default_scope, pkt, (uint32_t)0, _prefs.path_hash_mode + 1); - updateFloodAdvertTimer(); - updateAdvertTimer(); - } else if (next_local_advert && millisHasNowPassed(next_local_advert)) { - mesh::Packet* pkt = createSelfAdvert(); - if (pkt) sendZeroHop(pkt); - updateAdvertTimer(); - } - - if (set_radio_at && millisHasNowPassed(set_radio_at)) { - set_radio_at = 0; - getRadioDriver(_radio).setRadioOverride(pending_freq, pending_bw, pending_sf, pending_cr); - LOG_INF("Temp radio params applied"); - } - - if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { - revert_radio_at = 0; - getRadioDriver(_radio).clearRadioOverride(); - LOG_INF("Radio params restored"); - } - - if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { - acl.save(_store->getAclPath()); - dirty_contacts_expiry = 0; - } - -#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) - /* MQTT (re)connected — publish the initial "online" status here on the main - * thread (the CONNACK callback only sets this flag, off-main). */ - if (atomic_cas(&_uplink_connect_pending, 1, 0)) { - publishUplinkStatus("online"); - } - if (_uplink_next_status_at && millisHasNowPassed(_uplink_next_status_at)) { - publishUplinkStatus("online"); - _uplink_next_status_at = futureMillis(UPLINK_STATUS_INTERVAL_MS); - } - if (atomic_cas(&s_uplink_sntp_pending, 1, 0)) { - /* SNTP set the clock (trusted) — arm suppression + drift envelope. */ - _timesync.noteManualSync((uint32_t)(k_uptime_get() / 1000)); - } -#endif - - timeSyncTick(); - - uint32_t now = k_uptime_get(); - uptime_millis += now - last_millis; - last_millis = now; + if (indent > 0 && indent < 8 && strlen(np) > 0) { + auto parent = load_stack[indent - 1]; + if (parent) { + auto old = region_map.findByName(np); + auto nw = temp_map.putRegion(np, parent->id, old ? old->id : 0); + if (nw) { + nw->flags = old ? old->flags : (*ep == 'F' ? 0 : REGION_DENY_FLOOD); + load_stack[indent] = nw; + } + } + } + reply[0] = 0; + } } uint32_t RepeaterMesh::msUntilNextMaintenance() { - uint32_t now = (uint32_t)_ms->getMillis(); - uint32_t next = mesh::Mesh::msUntilNextMaintenance(); + uint32_t now = (uint32_t)_ms->getMillis(); + uint32_t next = mesh::Mesh::msUntilNextMaintenance(); - /* Advert timers. 0 means "disabled" for both — flood defaults to 47 h, - * periodic local advert is off unless configured. */ - if (next_flood_advert) { - next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, next_flood_advert)); - } - if (next_local_advert) { - next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, next_local_advert)); - } + /* Advert timers. 0 means "disabled" for both — flood defaults to 47 h, + * periodic local advert is off unless configured. */ + if (next_flood_advert) { + next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, next_flood_advert)); + } + if (next_local_advert) { + next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, next_local_advert)); + } - /* Temporary radio params: apply, then revert. Both CLI-armed, both 0 when idle. */ - if (set_radio_at) { - next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, set_radio_at)); - } - if (revert_radio_at) { - next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, revert_radio_at)); - } + /* Temporary radio params: apply, then revert. Both CLI-armed, both 0 when idle. */ + if (set_radio_at) { + next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, set_radio_at)); + } + if (revert_radio_at) { + next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, revert_radio_at)); + } - /* Deferred ACL write-back. */ - if (dirty_contacts_expiry) { - next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, dirty_contacts_expiry)); - } + /* Deferred ACL write-back. */ + if (dirty_contacts_expiry) { + next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, dirty_contacts_expiry)); + } #if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB) - if (_uplink_next_status_at) { - next = mesh::maintenanceSooner(next, - mesh::maintenanceUntil(now, _uplink_next_status_at)); - } + if (_uplink_next_status_at) { + next = mesh::maintenanceSooner(next, + mesh::maintenanceUntil(now, _uplink_next_status_at)); + } #endif - /* Mesh time sync evaluates at most every 15 min, and only when enabled. */ - if (_prefs.meshtimesync) { - next = mesh::maintenanceSooner( - next, _timesync.msUntilNextEval((uint32_t)(k_uptime_get() / 1000))); - } + /* Mesh time sync evaluates at most every 15 min, and only when enabled. */ + if (_prefs.meshtimesync) { + next = mesh::maintenanceSooner( + next, _timesync.msUntilNextEval((uint32_t)(k_uptime_get() / 1000))); + } - return next; + return next; } void RepeaterMesh::timeSyncTick() { - if (!_prefs.meshtimesync) return; - if (!_timesync.runTick(*getRTCClock())) return; + if (!_prefs.meshtimesync) return; + if (!_timesync.runTick(*getRTCClock())) return; - /* Step applied — wall-clock-anchored bookkeeping must move with it, or a - * backward step underflows the unsigned "seconds ago" math. 0 = unset - * sentinel. */ - int64_t delta = _timesync.lastStepDelta(); + /* Step applied — wall-clock-anchored bookkeeping must move with it, or a + * backward step underflows the unsigned "seconds ago" math. 0 = unset + * sentinel. */ + int64_t delta = _timesync.lastStepDelta(); #if MAX_NEIGHBOURS > 0 - for (int i = 0; i < MAX_NEIGHBOURS; i++) { - if (neighbours[i].heard_timestamp == 0) continue; - int64_t shifted = (int64_t)neighbours[i].heard_timestamp + delta; - neighbours[i].heard_timestamp = (shifted > 0) ? (uint32_t)shifted : 1; - } + for (int i = 0; i < MAX_NEIGHBOURS; i++) { + if (neighbours[i].heard_timestamp == 0) continue; + int64_t shifted = (int64_t)neighbours[i].heard_timestamp + delta; + neighbours[i].heard_timestamp = (shifted > 0) ? (uint32_t)shifted : 1; + } #endif - for (int i = 0; i < acl.getNumClients(); i++) { - ClientInfo* c = acl.getClientByIdx(i); - if (c->last_activity == 0) continue; - int64_t shifted = (int64_t)c->last_activity + delta; - c->last_activity = (shifted > 0) ? (uint32_t)shifted : 1; - } - /* A backward step would otherwise wedge these shut until wall-clock - * catch-up. */ - discover_limiter.reset(); - anon_limiter.reset(); - login_fail_limiter.reset(); -} - -bool RepeaterMesh::hasPendingWork() const { - return _mgr->getOutboundTotal() > 0; + for (int i = 0; i < acl.getNumClients(); i++) { + ClientInfo* c = acl.getClientByIdx(i); + if (c->last_activity == 0) continue; + int64_t shifted = (int64_t)c->last_activity + delta; + c->last_activity = (shifted > 0) ? (uint32_t)shifted : 1; + } + /* A backward step would otherwise wedge these shut until wall-clock + * catch-up. */ + discover_limiter.reset(); + anon_limiter.reset(); + login_fail_limiter.reset(); } diff --git a/zephcore/app/RepeaterMesh.h b/zephcore/app/RepeaterMesh.h index 9d95fdc..46ef8c1 100644 --- a/zephcore/app/RepeaterMesh.h +++ b/zephcore/app/RepeaterMesh.h @@ -1,307 +1,313 @@ -/* - * SPDX-License-Identifier: MIT - * RepeaterMesh - LoRa mesh repeater implementation - * - * Extends mesh::Mesh with: - * - ACL-based client authentication - * - Region-based flood filtering - * - Neighbor tracking - * - CLI commands (via USB serial) - * - Protocol handlers (login, status, telemetry, etc.) - */ - #pragma once -#include +//#include #include +// ZEPHCORE: Zephyr platform headers instead of RTClib, target.h and the +// per-platform file systems; storage goes through RepeaterDataStore. +#include #include #include -#include -#include #include -#include -#include -#include -#include -#include #include #include "RepeaterDataStore.h" #if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) #include "observer_creds.h" #endif -#ifndef FIRMWARE_VERSION - // Real version injected by CMakeLists.txt (-DFIRMWARE_VERSION); this fallback - // only applies to builds that bypass that injection and should never surface. - #define FIRMWARE_VERSION "v0.0.0-dev" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct RepeaterStats { + uint16_t batt_milli_volts; + uint16_t curr_tx_queue_len; + int16_t noise_floor; + int16_t last_rssi; + uint32_t n_packets_recv; + uint32_t n_packets_sent; + uint32_t total_air_time_secs; + uint32_t total_up_time_secs; + uint32_t n_sent_flood, n_sent_direct; + uint32_t n_recv_flood, n_recv_direct; + uint16_t err_events; // was 'n_full_events' + int16_t last_snr; // x 4 + uint16_t n_direct_dups, n_flood_dups; + uint32_t total_rx_air_time_secs; + uint32_t n_recv_errors; +}; + +struct NeighbourInfo { + mesh::Identity id; + uint32_t advert_timestamp; + uint32_t heard_timestamp; + int8_t snr; // multiplied by 4, user should divide to get float value + + // ZEPHCORE: in place of upstream's NeighbourInfo() value-initialisation. + void clear() { + id = mesh::Identity(); + advert_timestamp = 0; + heard_timestamp = 0; + snr = 0; + } +}; + +// ZEPHCORE: the version is injected by CMakeLists.txt; these fallbacks only +// apply to builds that bypass it. +#ifndef FIRMWARE_BUILD_DATE + #define FIRMWARE_BUILD_DATE __DATE__ #endif -#ifndef FIRMWARE_BUILD_DATE - #define FIRMWARE_BUILD_DATE __DATE__ +#ifndef FIRMWARE_VERSION + #define FIRMWARE_VERSION "v0.0.0-dev" #endif #define FIRMWARE_ROLE "repeater" +#define PACKET_LOG_FILE "/packet_log" + +// ZEPHCORE: table size from Kconfig. #ifndef MAX_NEIGHBOURS - #ifdef CONFIG_ZEPHCORE_MAX_NEIGHBOURS - #define MAX_NEIGHBOURS CONFIG_ZEPHCORE_MAX_NEIGHBOURS - #else - #define MAX_NEIGHBOURS 50 - #endif + #ifdef CONFIG_ZEPHCORE_MAX_NEIGHBOURS + #define MAX_NEIGHBOURS CONFIG_ZEPHCORE_MAX_NEIGHBOURS + #else + #define MAX_NEIGHBOURS 50 + #endif #endif -struct NeighbourInfo { - mesh::Identity id; - uint32_t advert_timestamp; - uint32_t heard_timestamp; - int8_t snr; // multiplied by 4 - - void clear() { - id = mesh::Identity(); - advert_timestamp = 0; - heard_timestamp = 0; - snr = 0; - } -}; - -struct RepeaterStats { - uint16_t batt_milli_volts; - uint16_t curr_tx_queue_len; - int16_t noise_floor; - int16_t last_rssi; - uint32_t n_packets_recv; - uint32_t n_packets_sent; - uint32_t total_air_time_secs; - uint32_t total_up_time_secs; - uint32_t n_sent_flood, n_sent_direct; - uint32_t n_recv_flood, n_recv_direct; - uint16_t err_events; - int16_t last_snr; // x 4 - uint16_t n_direct_dups, n_flood_dups; - uint32_t total_rx_air_time_secs; - uint32_t n_recv_errors; -}; - class RepeaterMesh : public mesh::Mesh, public CommonCLICallbacks { - mesh::MainBoard& _board; - RepeaterDataStore* _store; - uint32_t last_millis; - uint64_t uptime_millis; - unsigned long next_local_advert, next_flood_advert; - bool _logging; - NodePrefs _prefs; - ClientACL acl; - CommonCLI _cli; - uint8_t reply_data[MAX_PACKET_PAYLOAD]; - uint8_t reply_path[MAX_PATH_SIZE]; - uint8_t reply_path_len; - TransportKeyStore key_store; - RegionMap region_map, temp_map; - RegionEntry* load_stack[8]; - RegionEntry* recv_pkt_region; - /* A null recv_pkt_region has two meanings — a DIRECT request (no transport - * codes at all) and an un-scoped flood our wildcard Region denies — and - * sendFloodReply() must treat them differently, so record the route type - * rather than inferring it from the pointer. */ - bool recv_pkt_unscoped_flood; - TransportKey default_scope; - RateLimiter discover_limiter, anon_limiter, login_fail_limiter; - uint32_t pending_discover_tag; - unsigned long pending_discover_until; - bool region_load_active; - unsigned long dirty_contacts_expiry; -#if MAX_NEIGHBOURS > 0 - NeighbourInfo neighbours[MAX_NEIGHBOURS]; + // ZEPHCORE: our board handle and datastore instead of FILESYSTEM* _fs. + mesh::MainBoard& _board; + RepeaterDataStore* _store; + uint32_t last_millis; + uint64_t uptime_millis; + unsigned long next_local_advert, next_flood_advert; + bool _logging; + NodePrefs _prefs; + ClientACL acl; + CommonCLI _cli; + uint8_t reply_data[MAX_PACKET_PAYLOAD]; + uint8_t reply_path[MAX_PATH_SIZE]; + uint8_t reply_path_len; + TransportKeyStore key_store; + RegionMap region_map, temp_map; + RegionEntry* load_stack[8]; + RegionEntry* recv_pkt_region; + // ZEPHCORE: a null recv_pkt_region means either a DIRECT request or an + // un-scoped flood our wildcard denies, and sendFloodReply() treats them + // differently, so the route type is recorded (UPSTREAM_TRACKER fad11c90). + bool recv_pkt_unscoped_flood; + TransportKey default_scope; + RateLimiter discover_limiter, anon_limiter; + RateLimiter login_fail_limiter; // ZEPHCORE: wrong-password attempts (MeshCore#2556) + uint32_t pending_discover_tag; + unsigned long pending_discover_until; + bool region_load_active; + unsigned long dirty_contacts_expiry; +#if MAX_NEIGHBOURS + NeighbourInfo neighbours[MAX_NEIGHBOURS]; #endif - unsigned long set_radio_at, revert_radio_at; - float pending_freq; - float pending_bw; - uint8_t pending_sf; - uint8_t pending_cr; - int matching_peer_indexes[MAX_CLIENTS]; - MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH}; + unsigned long set_radio_at, revert_radio_at; + float pending_freq; + float pending_bw; + uint8_t pending_sf; + uint8_t pending_cr; + int matching_peer_indexes[MAX_CLIENTS]; + // ZEPHCORE: mesh time sync and the MQTT uplink. + MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH}; #if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) - ObserverCreds _uplink_creds; - bool _uplink_reboot_required; - char _uplink_pubkey_hex[PUB_KEY_SIZE * 2 + 1]; - char _uplink_packets_topic[160]; - char _uplink_status_topic[160]; - float _uplink_last_score; - float _uplink_last_rssi; - uint8_t _uplink_last_raw[MAX_TRANS_UNIT]; - int _uplink_last_raw_len; - unsigned long _uplink_next_status_at; - /* Set by the MQTT CONNACK callback (runs on the MQTT publisher thread); - * drained on the main thread in maintenanceLoop() so the "online" status - * publish never runs off-main (avoids the shared static-JSON-buffer race - * and off-main battery-ADC regulator toggling). */ - atomic_t _uplink_connect_pending; + ObserverCreds _uplink_creds; + bool _uplink_reboot_required; + char _uplink_pubkey_hex[PUB_KEY_SIZE * 2 + 1]; + char _uplink_packets_topic[160]; + char _uplink_status_topic[160]; + float _uplink_last_score; + float _uplink_last_rssi; + uint8_t _uplink_last_raw[MAX_TRANS_UNIT]; + int _uplink_last_raw_len; + unsigned long _uplink_next_status_at; + // Set by the MQTT CONNACK callback (publisher thread), drained on the main + // thread in maintenanceLoop(). + atomic_t _uplink_connect_pending; #endif - void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); - void timeSyncTick(); - uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); - uint8_t handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); - uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); - uint8_t handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); - int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); - mesh::Packet* createSelfAdvert(); - void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); - void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); + void putNeighbour(const mesh::Identity& id, uint32_t timestamp, float snr); + uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); + // ZEPHCORE: each takes data_len so the reply path is bounded by the bytes received. + uint8_t handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); + uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); + uint8_t handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data, size_t data_len); + int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); + mesh::Packet* createSelfAdvert(); - /* Region-definition CLI (defined in app/RepeaterRegionCLI.cpp). - * handleRegionLoadLine: a continuation line during `region load`. - * handleRegionCommand: a `region ...` command. */ - void handleRegionLoadLine(uint32_t sender_timestamp, char* command, char* reply); - void handleRegionCommand(char* command, char* reply); + bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); + + void timeSyncTick(); // ZEPHCORE + void handleRegionLoadLine(uint32_t sender_timestamp, char* command, char* reply); // ZEPHCORE protected: - uint8_t getDutyCyclePercent() const override { - /* Arduino formula: duty% = 100 / (af + 1). af=0 → 100%, af=9 → 10%. */ - return (uint8_t)(100.0f / (_prefs.airtime_factor + 1.0f) + 0.5f); - } + // ZEPHCORE: our Dispatcher asks for a duty-cycle percentage. + uint8_t getDutyCyclePercent() const override { + // Arduino formula: duty% = 100 / (af + 1). af=0 -> 100%, af=9 -> 10%. + return (uint8_t)(100.0f / (_prefs.airtime_factor + 1.0f) + 0.5f); + } - bool allowPacketForward(const mesh::Packet* packet) override; - bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); - const char* getLogDateTime() override; + bool allowPacketForward(const mesh::Packet* packet) override; + const char* getLogDateTime() override; + void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; - void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; - void logRx(mesh::Packet* pkt, int len, float score) override; - void logTx(mesh::Packet* pkt, int len) override; - void logTxFail(mesh::Packet* pkt, int len) override; - uint32_t getRetransmitDelay(const mesh::Packet* packet) override; - uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; + void logRx(mesh::Packet* pkt, int len, float score) override; + void logTx(mesh::Packet* pkt, int len) override; + void logTxFail(mesh::Packet* pkt, int len) override; - int getInterferenceThreshold() const override { - return _prefs.interference_threshold; - } - uint8_t getExtraAckTransmitCount() const override { - return _prefs.multi_acks; - } + uint32_t getRetransmitDelay(const mesh::Packet* packet) override; + uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; - /* Adaptive CAD */ - int formatFreqErrorStatus(char* buf, int cap) override { - return _radio->formatFreqErrorStatus(buf, cap); - } - int formatCadStatus(char* buf, int cap) override { - return _radio->formatCadStatus(buf, cap); - } - void applyCadPrefs() override { - _radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset, - _prefs.probe_interval, _prefs.cad_busycap, - _prefs.cad_base); - _prefs.cad_offset = _radio->getCadOffset(); - _prefs.cad_base = _radio->cadBasePeak(); - } - void resetCadStats() override { - _radio->resetCadStats(); - } - void onCadOffsetChanged(int8_t offset) override { - /* Staircase steps are hours apart — persisting immediately is - * fine for flash wear and survives unexpected reboots. */ - _prefs.cad_offset = offset; - savePrefs(); - } + int getInterferenceThreshold() const override { + return _prefs.interference_threshold; + } + uint8_t getExtraAckTransmitCount() const override { + return _prefs.multi_acks; + } - mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; + // ZEPHCORE: adaptive CAD. + int formatFreqErrorStatus(char* buf, int cap) override { + return _radio->formatFreqErrorStatus(buf, cap); + } + int formatCadStatus(char* buf, int cap) override { + return _radio->formatCadStatus(buf, cap); + } + void applyCadPrefs() override { + _radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset, + _prefs.probe_interval, _prefs.cad_busycap, + _prefs.cad_base); + _prefs.cad_offset = _radio->getCadOffset(); + _prefs.cad_base = _radio->cadBasePeak(); + } + void resetCadStats() override { + _radio->resetCadStats(); + } + void onCadOffsetChanged(int8_t offset) override { + // Staircase steps are hours apart: persisting at once is fine for wear. + _prefs.cad_offset = offset; + savePrefs(); + } + + mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; + + void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; + int searchPeersByHash(const uint8_t* hash) override; + void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len); + void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; + bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; + void onControlDataRecv(mesh::Packet* packet) override; + + void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); - void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; - int searchPeersByHash(const uint8_t* hash) override; - void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; - void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len); - void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; - bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; - void onControlDataRecv(mesh::Packet* packet) override; #if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) - bool handleUplinkCommand(const char *command, char *reply); - void markUplinkRebootRequired() { _uplink_reboot_required = true; } - bool isUplinkEnabled() const { return (_uplink_creds._reserved[0] & 0x01) != 0; } - void setUplinkEnabled(bool en) { - if (en) { - _uplink_creds._reserved[0] |= 0x01; - } else { - _uplink_creds._reserved[0] &= (uint8_t)~0x01; - } - } - bool saveUplinkCreds(); - void publishUplinkPacket(mesh::Packet *pkt); - void publishUplinkStatus(const char *status); + // ZEPHCORE: MQTT uplink (app/RepeaterUplink.cpp). + bool handleUplinkCommand(const char *command, char *reply); + void markUplinkRebootRequired() { _uplink_reboot_required = true; } + bool isUplinkEnabled() const { return (_uplink_creds._reserved[0] & 0x01) != 0; } + void setUplinkEnabled(bool en) { + if (en) { + _uplink_creds._reserved[0] |= 0x01; + } else { + _uplink_creds._reserved[0] &= (uint8_t)~0x01; + } + } + bool saveUplinkCreds(); + void publishUplinkPacket(mesh::Packet *pkt); + void publishUplinkStatus(const char *status); #endif public: - RepeaterMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, - mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); + RepeaterMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); - void begin(RepeaterDataStore* store); + void begin(RepeaterDataStore* store); // ZEPHCORE: was begin(FILESYSTEM* fs) + void sendNodeDiscoverReq(); + const char* getFirmwareVer() override { return FIRMWARE_VERSION; } + const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } + const char* getRole() override { return FIRMWARE_ROLE; } + const char* getNodeName() { return _prefs.node_name; } + NodePrefs* getNodePrefs() { + return &_prefs; + } - void sendNodeDiscoverReq(); + void savePrefs() override; // ZEPHCORE: through RepeaterDataStore - /* CommonCLICallbacks */ - const char* getFirmwareVer() override { return FIRMWARE_VERSION; } - const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } - const char* getRole() override { return FIRMWARE_ROLE; } - double getNodeLat() const override; - double getNodeLon() const override; - bool setGpsEnabled(bool enabled) override; - bool isGpsEnabled() const override; - void formatGpsStatsReply(char* reply) override; - uint32_t getDefaultGpsIntervalSec() const override { return CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC; } - const char* getNodeName() { return _prefs.node_name; } - NodePrefs* getNodePrefs() { return &_prefs; } + void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); - void savePrefs() override; - void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; - void freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) override; - bool formatFileSystem() override; - void sendSelfAdvertisement(int delay_millis, bool flood) override; - void updateAdvertTimer() override; - void updateFloodAdvertTimer() override; - void setLoggingOn(bool enable) override { _logging = enable; } - void eraseLogFile() override; - void dumpLogFile() override; - void setTxPower(int8_t power_dbm) override; - bool setRxBoostedGain(bool enable) override; - bool setFemRxGain(bool enable) override; - bool configSideDetectors(const uint8_t* sfs, uint8_t num) override; - void formatNeighborsReply(char* reply) override; - void removeNeighbor(const uint8_t* pubkey, int key_len) override; - void formatStatsReply(char* reply) override; - void formatRadioStatsReply(char* reply) override; - void formatPacketStatsReply(char* reply) override; + // CommonCLICallbacks + void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; + bool formatFileSystem() override; + void sendSelfAdvertisement(int delay_millis, bool flood) override; + void updateAdvertTimer() override; + void updateFloodAdvertTimer() override; - mesh::LocalIdentity& getSelfId() override { return self_id; } - void saveIdentity(const mesh::LocalIdentity& new_id) override; - void clearStats() override; + void setLoggingOn(bool enable) override { _logging = enable; } - /* Mesh time sync */ - MeshTimeSync* getMeshTimeSync() override { return &_timesync; } - void noteGPSTimeSync() { _timesync.noteGPSSync((uint32_t)(k_uptime_get() / 1000)); } + void eraseLogFile() override; // ZEPHCORE: no log file - /* Adaptive contention window callbacks */ - float getContentionEstimate() const override { - return getContentionTracker().getContentionEstimate(); - } - float getFloodDelayFactor() const override { - return getContentionTracker().getFloodDelayFactor(); - } - void setBackoffMultiplier(float m) override { - getContentionTracker().setBackoffMultiplier(m); - } + void dumpLogFile() override; + void setTxPower(int8_t power_dbm) override; + void formatNeighborsReply(char *reply) override; + void removeNeighbor(const uint8_t* pubkey, int key_len) override; + void formatStatsReply(char *reply) override; + void formatRadioStatsReply(char *reply) override; + void formatPacketStatsReply(char *reply) override; + void startRegionsLoad() override; + bool saveRegions() override; + void onDefaultRegionChanged(const RegionEntry* r) override; - /* Duty-cycle preamble false-positive stats (SX126x only; - * other radios return 0 from the base class). */ - uint32_t getDutyCycleTimeoutRestarts() const override; - void resetDutyCycleTimeoutRestarts() override; + mesh::LocalIdentity& getSelfId() override { return self_id; } - void handleCommand(uint32_t sender_timestamp, char* command, char* reply); - void loop(); + void saveIdentity(const mesh::LocalIdentity& new_id) override; + void clearStats() override; - /* Folds this role's own time-based work in loop() — advert timers, - * tempradio apply/revert, ACL flush, uplink status, mesh time sync — into - * the base maintenance deadline, so the event loop can arm one wake that - * covers both loop() and maintenanceLoop(). Every item here is an - * absolute deadline already; none of them was ever a poll. */ - uint32_t msUntilNextMaintenance() override; + void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + void loop(); - bool hasPendingWork() const; + // ZEPHCORE: no hasPendingWork(); the event loop never polls for it. + + bool setRxBoostedGain(bool enable) override; + + // ZEPHCORE: every radio family, not only LR2021 (the others report unsupported). + bool configSideDetectors(const uint8_t* sfs, uint8_t num) override; + + // ZEPHCORE: CommonCLICallbacks additions. + double getNodeLat() const override; + double getNodeLon() const override; + bool setGpsEnabled(bool enabled) override; + bool isGpsEnabled() const override; + void formatGpsStatsReply(char* reply) override; + uint32_t getDefaultGpsIntervalSec() const override { return CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC; } + void freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) override; + bool setFemRxGain(bool enable) override; + MeshTimeSync* getMeshTimeSync() override { return &_timesync; } + void noteGPSTimeSync() { _timesync.noteGPSSync((uint32_t)(k_uptime_get() / 1000)); } + float getContentionEstimate() const override { + return getContentionTracker().getContentionEstimate(); + } + float getFloodDelayFactor() const override { + return getContentionTracker().getFloodDelayFactor(); + } + void setBackoffMultiplier(float m) override { + getContentionTracker().setBackoffMultiplier(m); + } + // Duty-cycle false-preamble re-arms (SX126x only; others return 0). + uint32_t getDutyCycleTimeoutRestarts() const override; + void resetDutyCycleTimeoutRestarts() override; + + // ZEPHCORE: folds this role's own deadlines (adverts, tempradio, ACL flush, + // uplink status, time sync) into the base maintenance deadline, so the event + // loop arms one wake for loop() and maintenanceLoop(). + uint32_t msUntilNextMaintenance() override; }; diff --git a/zephcore/app/RepeaterRegionCLI.cpp b/zephcore/app/RepeaterRegionCLI.cpp deleted file mode 100644 index 7d4f284..0000000 --- a/zephcore/app/RepeaterRegionCLI.cpp +++ /dev/null @@ -1,250 +0,0 @@ -/* - * SPDX-License-Identifier: MIT - * RepeaterMesh region-definition CLI — the `region ...` command family and the - * `region load` continuation-line parser. - * - * Split out of RepeaterMesh.cpp for readability. Defines two RepeaterMesh - * methods invoked from RepeaterMesh::handleCommand(); the file-static parser - * helpers below are used only by this command family. - */ - -#include "RepeaterMesh.h" -#include -#include - -#include -#include - -/* ---------- region def helpers ---------- */ -static char* skipSpaces(char* s) { while (*s == ' ') s++; return s; } -static void rtrimSpaces(char* s) { char* e = s + strlen(s); while (e > s && e[-1] == ' ') *--e = '\0'; } -static char* takeToken(char** cursor) { - char* p = skipSpaces(*cursor); - if (*p == '\0') { *cursor = p; return nullptr; } - char* tok = p; - while (*p && *p != ' ') p++; - if (*p) *p++ = '\0'; - *cursor = p; - return tok; -} -static char* splitNameJump(char* tok) { - for (char* q = tok; *q; q++) { - if (*q == '|' || *q == ',') { - *q = '\0'; - char* jump = skipSpaces(q + 1); - rtrimSpaces(jump); - return jump; - } - } - return nullptr; -} -static bool processRegionDefSegment(RegionMap* map, char* tok, RegionEntry** cursor, char* reply) { - char* jump = splitNameJump(tok); - char* name = skipSpaces(tok); - if (*name == '\0') { snprintf(reply, 160, "Err - empty name"); return false; } - if (jump && *jump == '\0') { snprintf(reply, 160, "Err - empty jump"); return false; } - RegionEntry* r = map->putRegion(name, (*cursor)->id); - if (r == NULL) { snprintf(reply, 160, "Err - put failed: %s", name); return false; } - r->flags = 0; - if (jump) { - RegionEntry* j = map->findByNamePrefix(jump); - if (j == NULL) { snprintf(reply, 160, "Err - unknown jump: %s", jump); return false; } - *cursor = j; - } else { - *cursor = r; - } - return true; -} -/* ---------------------------------------- */ - -void RepeaterMesh::handleRegionLoadLine(uint32_t sender_timestamp, char* command, char* reply) { - if (StrHelper::isBlank(command)) { - region_map = temp_map; - region_load_active = false; - sprintf(reply, "OK - loaded %d regions", region_map.getCount()); - } else { - char* np = command; - while (*np == ' ') np++; - int indent = np - command; - - /* An unindented, name-like line is a typed command, not a region row: - * real rows are indent >= 1 (load_stack[0] is the wildcard), and the - * one unindented line a client legitimately sends is the exported - * wildcard header "*", whose '*' is not a name char. Without this, - * `region load` is only escapable by a blank line -- which the USB - * reader discards (main_repeater.cpp) and a dead remote-admin client - * never sends, stranding the CLI until a reboot. Abort without - * committing temp_map and run the command. Must come BEFORE the - * name-terminator write below, which would truncate `set foo 1` to - * `set`. */ - if (indent == 0 && RegionMap::is_name_char((uint8_t)*np)) { - region_load_active = false; - handleCommand(sender_timestamp, command, reply); - return; - } - - char* ep = np; - while (RegionMap::is_name_char(*ep)) ep++; - if (*ep) { *ep++ = 0; } - - while (*ep && *ep != 'F') ep++; - - if (indent > 0 && indent < 8 && strlen(np) > 0) { - auto parent = load_stack[indent - 1]; - if (parent) { - auto old = region_map.findByName(np); - auto nw = temp_map.putRegion(np, parent->id, old ? old->id : 0); - if (nw) { - nw->flags = old ? old->flags : (*ep == 'F' ? 0 : REGION_DENY_FLOOD); - load_stack[indent] = nw; - } - } - } - reply[0] = 0; - } -} - -void RepeaterMesh::handleRegionCommand(char* command, char* reply) { - reply[0] = 0; - - // `region def`: cursor-walk bulk region builder — must run before parseTextParts - // mutates and truncates the buffer to 4 segments. - char* cmd = skipSpaces(command); - if (strncmp(cmd, "region def", 10) == 0 && (cmd[10] == ' ' || cmd[10] == '\0')) { - char* payload = skipSpaces(cmd + 10); - rtrimSpaces(payload); - if (*payload == '\0') { snprintf(reply, 160, "Err - empty def"); goto region_done; } - RegionEntry* cursor = ®ion_map.getWildcard(); - for (char* tok; (tok = takeToken(&payload)) != nullptr; ) { - if (!processRegionDefSegment(®ion_map, tok, &cursor, reply)) goto region_done; - } - region_map.exportTo(reply, 160); - goto region_done; - } - - { - const char* parts[4]; - int n = mesh::Utils::parseTextParts(command, parts, 4, ' '); - - if (n == 1) { - region_map.exportTo(reply, 160); - } else if (n >= 2 && strcmp(parts[1], "load") == 0) { - temp_map.resetFrom(region_map); - memset(load_stack, 0, sizeof(load_stack)); - load_stack[0] = &temp_map.getWildcard(); - region_load_active = true; - } else if (n >= 2 && strcmp(parts[1], "save") == 0) { - _prefs.discovery_mod_timestamp = getRTCClock()->getCurrentTime(); - savePrefs(); - bool success = region_map.save(_store->getRegionsPath()); - strcpy(reply, success ? "OK" : "Err - save failed"); - } else if (n >= 3 && strcmp(parts[1], "allowf") == 0) { - auto region = region_map.findByNamePrefix(parts[2]); - if (region) { - region->flags &= ~REGION_DENY_FLOOD; - strcpy(reply, "OK"); - } else { - strcpy(reply, "Err - unknown region"); - } - } else if (n >= 3 && strcmp(parts[1], "denyf") == 0) { - auto region = region_map.findByNamePrefix(parts[2]); - if (region) { - region->flags |= REGION_DENY_FLOOD; - strcpy(reply, "OK"); - } else { - strcpy(reply, "Err - unknown region"); - } - } else if (n >= 3 && strcmp(parts[1], "get") == 0) { - auto region = region_map.findByNamePrefix(parts[2]); - if (region) { - auto parent = region_map.findById(region->parent); - if (parent && parent->id != 0) { - sprintf(reply, " %s (%s) %s", region->name, parent->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F"); - } else { - sprintf(reply, " %s %s", region->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F"); - } - } else { - strcpy(reply, "Err - unknown region"); - } - } else if (n >= 3 && strcmp(parts[1], "home") == 0) { - auto home = region_map.findByNamePrefix(parts[2]); - if (home) { - region_map.setHomeRegion(home); - sprintf(reply, " home is now %s", home->name); - } else { - strcpy(reply, "Err - unknown region"); - } - } else if (n == 2 && strcmp(parts[1], "home") == 0) { - auto home = region_map.getHomeRegion(); - sprintf(reply, " home is %s", home ? home->name : "*"); - } else if (n >= 3 && strcmp(parts[1], "default") == 0) { - if (strcmp(parts[2], "") == 0) { - region_map.setDefaultRegion(nullptr); - memset(default_scope.key, 0, sizeof(default_scope.key)); - region_map.save(_store->getRegionsPath()); // persist in one atomic step - sprintf(reply, " default scope is now "); - } else { - auto def = region_map.findByNamePrefix(parts[2]); - if (def == nullptr) { - def = region_map.putRegion(parts[2], 0); // auto-create the default region - } - if (def) { - def->flags = 0; // make sure allow flood enabled - region_map.setDefaultRegion(def); - region_map.getTransportKeysFor(*def, &default_scope, 1); - region_map.save(_store->getRegionsPath()); // persist in one atomic step - sprintf(reply, " default scope is now %s", def->name); - } else { - strcpy(reply, "Err - region table full"); - } - } - } else if (n == 2 && strcmp(parts[1], "default") == 0) { - auto def = region_map.getDefaultRegion(); - sprintf(reply, " default scope is %s", def ? def->name : ""); - } else if (n >= 3 && strcmp(parts[1], "put") == 0) { - auto parent = n >= 4 ? region_map.findByNamePrefix(parts[3]) : ®ion_map.getWildcard(); - if (parent == nullptr) { - strcpy(reply, "Err - unknown parent"); - } else { - auto region = region_map.putRegion(parts[2], parent->id); - if (region == nullptr) { - strcpy(reply, "Err - unable to put"); - } else { - region->flags = 0; // New default: enable flood - strcpy(reply, "OK - (flood allowed)"); - } - } - } else if (n >= 3 && strcmp(parts[1], "remove") == 0) { - auto region = region_map.findByName(parts[2]); - if (region) { - if (region_map.removeRegion(*region)) { - strcpy(reply, "OK"); - } else { - strcpy(reply, "Err - not empty"); - } - } else { - strcpy(reply, "Err - not found"); - } - } else if (n >= 3 && strcmp(parts[1], "list") == 0) { - uint8_t mask = 0; - bool invert = false; - if (strcmp(parts[2], "allowed") == 0) { - mask = REGION_DENY_FLOOD; - invert = false; - } else if (strcmp(parts[2], "denied") == 0) { - mask = REGION_DENY_FLOOD; - invert = true; - } else { - strcpy(reply, "Err - use 'allowed' or 'denied'"); - return; - } - int len = region_map.exportNamesTo(reply, 160, mask, invert); - if (len == 0) { - strcpy(reply, "-none-"); - } - } else { - strcpy(reply, "Err - ??"); - } - } // end parseTextParts scope - region_done:; -} diff --git a/zephcore/app/RoomServerMesh.cpp b/zephcore/app/RoomServerMesh.cpp index 3048ded..bb232bb 100644 --- a/zephcore/app/RoomServerMesh.cpp +++ b/zephcore/app/RoomServerMesh.cpp @@ -1,17 +1,8 @@ -/* - * SPDX-License-Identifier: MIT - * RoomServerMesh - LoRa mesh shared-room (BBS) server - * - * A store-and-forward shared message room. Clients log in with an admin or - * guest password and post messages; the server pushes each new post to all - * other logged-in clients (round-robin, per-client sync cursor, ACK + retry). - * Structured as a near-clone of RepeaterMesh (shared ACL/region/CLI/adverts) - * with the post buffer + push engine added. Ported from upstream MeshCore's - * simple_room_server. - */ - #include "RoomServerMesh.h" + +// ZEPHCORE: platform includes and helpers. #include +#include #include #include #include @@ -32,11 +23,13 @@ /* Helper to get radio driver for stats — uses LoRaRadio (works for SX126x and LR1110) */ static inline mesh::LoRaRadio& getRadioDriver(mesh::Radio* radio) { - return *static_cast(radio); + return *static_cast(radio); } LOG_MODULE_REGISTER(zephcore_room, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); +// ZEPHCORE: our constants (protocol level 2 for the owner-info request; +// faster post push and sync than upstream's 2000 ms / 6 s). /* Protocol constants */ #define FIRMWARE_VER_LEVEL 2 @@ -63,1152 +56,1330 @@ LOG_MODULE_REGISTER(zephcore_room, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #define PUSH_ACK_TIMEOUT_FACTOR 2000 #define POST_SYNC_DELAY_SECS 2 -/* Stats blob returned for REQ_TYPE_GET_STATUS on a room server. Reports - * posted/pushed counts in the trailing two fields (matches upstream - * MeshCore's ServerStats wire layout). */ -struct ServerStats { - uint16_t batt_milli_volts; - uint16_t curr_tx_queue_len; - int16_t noise_floor; - int16_t last_rssi; - uint32_t n_packets_recv; - uint32_t n_packets_sent; - uint32_t total_air_time_secs; - uint32_t total_up_time_secs; - uint32_t n_sent_flood, n_sent_direct; - uint32_t n_recv_flood, n_recv_direct; - uint16_t err_events; - int16_t last_snr; - uint16_t n_direct_dups, n_flood_dups; - uint16_t n_posted, n_post_push; -}; /* Helper: futureMillis */ static inline unsigned long futureMillis(uint32_t delta_ms) { - return k_uptime_get() + delta_ms; + return k_uptime_get() + delta_ms; } static inline bool millisHasNowPassed(unsigned long target) { - return (int64_t)k_uptime_get() >= (int64_t)target; + return (int64_t)k_uptime_get() >= (int64_t)target; } static void radio_set_tx_power(uint8_t power_dbm) { - /* TX power is configured as part of lora_config() in radio_set_params - * The Zephyr LoRa driver doesn't have a separate lora_set_tx_power API. - * Instead, we log that TX power setting is requested. The actual power - * is set in the board defconfig via CONFIG_LORA_TX_POWER. */ - LOG_INF("TX power %d dBm requested (configured via board defconfig)", power_dbm); + /* TX power is configured as part of lora_config() in radio_set_params + * The Zephyr LoRa driver doesn't have a separate lora_set_tx_power API. + * Instead, we log that TX power setting is requested. The actual power + * is set in the board defconfig via CONFIG_LORA_TX_POWER. */ + LOG_INF("TX power %d dBm requested (configured via board defconfig)", power_dbm); } -int RoomServerMesh::handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len) { - memcpy(reply_data, &sender_timestamp, 4); +// ZEPHCORE: upstream's variant-global rtc_clock is the mesh's own clock here. +#define rtc_clock (*getRTCClock()) - if (payload[0] == REQ_TYPE_GET_STATUS) { - auto& radio_driver = getRadioDriver(_radio); - ServerStats stats; - stats.batt_milli_volts = _board.getBattMilliVolts(); - stats.curr_tx_queue_len = _mgr->getOutboundTotal(); - stats.noise_floor = (int16_t)_radio->getNoiseFloor(); - stats.last_rssi = (int16_t)radio_driver.getLastRSSI(); - stats.n_packets_recv = radio_driver.getPacketsRecv(); - stats.n_packets_sent = radio_driver.getPacketsSent(); - stats.total_air_time_secs = getTotalAirTime() / 1000; - stats.total_up_time_secs = uptime_millis / 1000; - stats.n_sent_flood = getNumSentFlood(); - stats.n_sent_direct = getNumSentDirect(); - stats.n_recv_flood = getNumRecvFlood(); - stats.n_recv_direct = getNumRecvDirect(); - stats.err_events = _err_flags; - stats.last_snr = (int16_t)(radio_driver.getLastSNR() * 4); - stats.n_direct_dups = ((SimpleMeshTables *)getTables())->getNumDirectDups(); - stats.n_flood_dups = ((SimpleMeshTables *)getTables())->getNumFloodDups(); - stats.n_posted = _num_posted; - stats.n_post_push = _num_post_pushes; - memcpy(&reply_data[4], &stats, sizeof(stats)); - return 4 + sizeof(stats); - } +struct ServerStats { + uint16_t batt_milli_volts; + uint16_t curr_tx_queue_len; + int16_t noise_floor; + int16_t last_rssi; + uint32_t n_packets_recv; + uint32_t n_packets_sent; + uint32_t total_air_time_secs; + uint32_t total_up_time_secs; + uint32_t n_sent_flood, n_sent_direct; + uint32_t n_recv_flood, n_recv_direct; + uint16_t err_events; // was 'n_full_events' + int16_t last_snr; // x 4 + uint16_t n_direct_dups, n_flood_dups; + uint16_t n_posted, n_post_push; +}; - if (payload[0] == REQ_TYPE_GET_TELEMETRY_DATA) { - /* CayenneLPP telemetry response using SimpleLPP encoder */ - SimpleLPP lpp(&reply_data[4], sizeof(reply_data) - 4); - - /* Battery voltage — channel 1 = TELEM_CHANNEL_SELF (matches Arduino) */ - const uint8_t CH_SELF = 1; - uint16_t batt_mv = _board.getBattMilliVolts(); - lpp.addVoltage(CH_SELF, batt_mv / 1000.0f); - - /* Environment sensors — prefer external, fallback to MCU die temp */ - struct env_data env; - if (env_sensors_read(&env) == 0) { - if (env.has_temperature) { - lpp.addTemperature(CH_SELF, env.temperature_c); - } else if (env.has_mcu_temperature) { - lpp.addTemperature(CH_SELF, env.mcu_temperature_c); - } else { - /* Last resort: MCU temp from board API */ - float mcu_temp = _board.getMCUTemperature(); - if (!isnan(mcu_temp)) { - lpp.addTemperature(CH_SELF, mcu_temp); - } - } - if (env.has_humidity) { - lpp.addRelativeHumidity(CH_SELF, env.humidity_pct); - } - if (env.has_pressure) { - lpp.addBarometricPressure(CH_SELF, env.pressure_hpa); - } - if (env.has_luminosity) { - lpp.addLuminosity(CH_SELF, env.luminosity); - } - } else { - /* No env sensors at all — try MCU temp directly */ - float mcu_temp = _board.getMCUTemperature(); - if (!isnan(mcu_temp)) { - lpp.addTemperature(CH_SELF, mcu_temp); - } - } - - /* Power monitors (INA219/INA3221/ina2xx) */ - if (power_sensors_available()) { - struct power_data pwr; - if (power_sensors_read(&pwr) == 0) { - uint8_t ch = CH_SELF + 1; - for (int j = 0; j < pwr.num_channels; j++) { - if (pwr.channels[j].valid) { - lpp.addVoltage(ch, pwr.channels[j].voltage_v); - lpp.addCurrent(ch, pwr.channels[j].current_a); - lpp.addPower(ch, pwr.channels[j].power_w); - ch++; - } - } - } - } - - /* GPS precise position — admin-only, and only via telemetry, never - * adverts. Guests get the rest of the LPP payload but no position: - * adverts already publish the operator-set prefs coordinates, so - * there is no reason to hand a guest login the live fix as well. */ - if (sender->isAdmin()) { - struct gps_position gpos; - if (gps_get_last_known_position(&gpos)) { - lpp.addGPS(CH_SELF, - (float)(gpos.latitude_ndeg / 1e9), - (float)(gpos.longitude_ndeg / 1e9), - gpos.altitude_mm / 1000.0f); - } - - /* Wake GPS / extend acquire window so the next telemetry poll has - * a fresher fix. In repeater mode GPS is normally off between the - * 48h time-sync cycles — this opportunistically rearms acquire - * when someone actually cares about our position. No-op if GPS - * is disabled in prefs. Gated with the position itself so a guest - * can't hold the GPS awake by polling. */ - if (gps_is_available() && gps_is_enabled()) { - gps_request_fresh_fix(); - } - } - - return 4 + lpp.getSize(); - } - - if (payload[0] == REQ_TYPE_GET_ACCESS_LIST && sender->isAdmin()) { - uint8_t res1 = payload[1]; - uint8_t res2 = payload[2]; - if (res1 == 0 && res2 == 0) { - uint8_t ofs = 4; - for (int i = 0; i < acl.getNumClients() && (size_t)(ofs + 7) <= sizeof(reply_data) - 4; i++) { - auto c = acl.getClientByIdx(i); - if (c->permissions == 0) continue; - memcpy(&reply_data[ofs], c->id.pub_key, 6); - ofs += 6; - reply_data[ofs++] = c->permissions; - } - return ofs; - } - } - - if (payload[0] == REQ_TYPE_GET_OWNER_INFO) { - sprintf((char*)&reply_data[4], "%s\n%s\n%s", FIRMWARE_VERSION, _prefs.node_name, _prefs.owner_info); - return 4 + strlen((char*)&reply_data[4]); - } - - return 0; +void RoomServerMesh::addPost(ClientInfo *client, const char *postData) { + storePost(client->id, postData); } -mesh::Packet* RoomServerMesh::createSelfAdvert() { - uint8_t app_data[MAX_ADVERT_DATA_SIZE]; - uint8_t app_data_len = _cli.buildAdvertData(ADV_TYPE_ROOM, app_data); - return createAdvert(self_id, app_data, app_data_len); -} - -/* ---- Room server: shared-post buffer + push-to-client sync ---- */ - -void RoomServerMesh::addPost(ClientInfo* client, const char* postData) { - storePost(client->id, postData); -} - -/* Post authored by the server itself (admin "room.post" command). */ -void RoomServerMesh::addSystemPost(const char* postData) { - if (!postData || postData[0] == 0) return; - - storePost(self_id, postData); +void RoomServerMesh::addSystemPost(const char *postData) { + if (!postData || postData[0] == 0) return; + + MESH_DEBUG_PRINTLN("room.post: addSystemPost: %s", postData); + + storePost(self_id, postData); } +// ZEPHCORE: ours. Keeps the full MAX_POST_TEXT_LEN characters (upstream's StrHelper copy keeps one fewer). void RoomServerMesh::storePost(const mesh::Identity& author, const char* postData) { - posts[next_post_idx].author = author; - strncpy(posts[next_post_idx].text, postData, MAX_POST_TEXT_LEN); - posts[next_post_idx].text[MAX_POST_TEXT_LEN] = '\0'; - posts[next_post_idx].post_timestamp = getRTCClock()->getCurrentTimeUnique(); - next_post_idx = (next_post_idx + 1) % MAX_UNSYNCED_POSTS; + posts[next_post_idx].author = author; + strncpy(posts[next_post_idx].text, postData, MAX_POST_TEXT_LEN); + posts[next_post_idx].text[MAX_POST_TEXT_LEN] = '\0'; + posts[next_post_idx].post_timestamp = getRTCClock()->getCurrentTimeUnique(); + next_post_idx = (next_post_idx + 1) % MAX_UNSYNCED_POSTS; - next_push = futureMillis(PUSH_NOTIFY_DELAY_MILLIS); - _num_posted++; + next_push = futureMillis(PUSH_NOTIFY_DELAY_MILLIS); + _num_posted++; } -void RoomServerMesh::pushPostToClient(ClientInfo* client, PostInfo& post) { - int len = 0; - memcpy(&reply_data[len], &post.post_timestamp, 4); - len += 4; +void RoomServerMesh::pushPostToClient(ClientInfo *client, PostInfo &post) { + MESH_DEBUG_PRINTLN("room.post: pushPostToClient text=%s", post.text); + int len = 0; + memcpy(&reply_data[len], &post.post_timestamp, 4); + len += 4; // this is a PAST timestamp... but should be accepted by client - uint8_t attempt; - getRNG()->random(&attempt, 1); // vary the packet hash (and ACK) across retries - reply_data[len++] = (TXT_TYPE_SIGNED_PLAIN << 2) | (attempt & 3); + uint8_t attempt; + getRNG()->random(&attempt, 1); // need this for re-tries, so packet hash (and ACK) will be different + reply_data[len++] = (TXT_TYPE_SIGNED_PLAIN << 2) | (attempt & 3); // 'signed' plain text - memcpy(&reply_data[len], post.author.pub_key, 4); // author prefix - len += 4; + // encode prefix of post.author.pub_key + memcpy(&reply_data[len], post.author.pub_key, 4); + len += 4; // just first 4 bytes - int text_len = strlen(post.text); - memcpy(&reply_data[len], post.text, text_len); - len += text_len; + int text_len = strlen(post.text); + memcpy(&reply_data[len], post.text, text_len); + len += text_len; - /* Expected ACK = sha256(pushed message) keyed by the client's pubkey. */ - mesh::Utils::sha256((uint8_t*)&client->extra.room.pending_ack, 4, reply_data, len, - client->id.pub_key, PUB_KEY_SIZE); - client->extra.room.push_post_timestamp = post.post_timestamp; + // calc expected ACK reply + mesh::Utils::sha256((uint8_t *)&client->extra.room.pending_ack, 4, reply_data, len, client->id.pub_key, PUB_KEY_SIZE); + client->extra.room.push_post_timestamp = post.post_timestamp; - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len); - if (reply) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { - sendFloodScoped(default_scope, reply, (uint32_t)0, _prefs.path_hash_mode + 1); - client->extra.room.ack_timeout = futureMillis(PUSH_ACK_TIMEOUT_FLOOD); - } else { - sendDirect(reply, client->out_path, client->out_path_len); - uint8_t path_hash_count = client->out_path_len & 63; - client->extra.room.ack_timeout = - futureMillis(PUSH_TIMEOUT_BASE + PUSH_ACK_TIMEOUT_FACTOR * (path_hash_count + 1)); - } - _num_post_pushes++; - } else { - client->extra.room.pending_ack = 0; - LOG_DBG("Unable to push post to client"); - } + auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, client->shared_secret, reply_data, len); + if (reply) { + if (client->out_path_len == OUT_PATH_UNKNOWN) { + unsigned long delay_millis = 0; + sendFloodScoped(default_scope, reply, delay_millis, _prefs.path_hash_mode + 1); // REVISIT + client->extra.room.ack_timeout = futureMillis(PUSH_ACK_TIMEOUT_FLOOD); + } else { + sendDirect(reply, client->out_path, client->out_path_len); + + uint8_t path_hash_count = client->out_path_len & 63; + client->extra.room.ack_timeout = futureMillis(PUSH_TIMEOUT_BASE + PUSH_ACK_TIMEOUT_FACTOR * (path_hash_count + 1)); + } + _num_post_pushes++; // stats + } else { + client->extra.room.pending_ack = 0; + MESH_DEBUG_PRINTLN("Unable to push post to client"); + } } -uint8_t RoomServerMesh::getUnsyncedCount(ClientInfo* client) { - uint8_t count = 0; - for (int k = 0; k < MAX_UNSYNCED_POSTS; k++) { - if (posts[k].post_timestamp > client->extra.room.sync_since && - !posts[k].author.matches(client->id)) { - count++; - } - } - return count; +uint8_t RoomServerMesh::getUnsyncedCount(ClientInfo *client) { + uint8_t count = 0; + for (int k = 0; k < MAX_UNSYNCED_POSTS; k++) { + if (posts[k].post_timestamp > client->extra.room.sync_since // is new post for this Client? + && !posts[k].author.matches(client->id)) { // don't push posts to the author + count++; + } + } + return count; } -bool RoomServerMesh::processAck(const uint8_t* data) { - for (int i = 0; i < acl.getNumClients(); i++) { - ClientInfo* client = acl.getClientByIdx(i); - if (client->extra.room.pending_ack && memcmp(data, &client->extra.room.pending_ack, 4) == 0) { - client->extra.room.pending_ack = 0; - client->extra.room.push_failures = 0; - client->extra.room.sync_since = client->extra.room.push_post_timestamp; // advance cursor - return true; - } - } - return false; +bool RoomServerMesh::processAck(const uint8_t *data) { + for (int i = 0; i < acl.getNumClients(); i++) { + auto client = acl.getClientByIdx(i); + if (client->extra.room.pending_ack && memcmp(data, &client->extra.room.pending_ack, 4) == 0) { // got an ACK from Client! + client->extra.room.pending_ack = 0; // clear this, so next push can happen + client->extra.room.push_failures = 0; + client->extra.room.sync_since = client->extra.room.push_post_timestamp; // advance Client's SINCE timestamp, to sync next post + return true; + } + } + return false; } -void RoomServerMesh::onAckRecv(mesh::Packet* packet, uint32_t ack_crc) { - if (processAck((uint8_t*)&ack_crc)) { - packet->markDoNotRetransmit(); // this ACK was for us - } +mesh::Packet *RoomServerMesh::createSelfAdvert() { + uint8_t app_data[MAX_ADVERT_DATA_SIZE]; + uint8_t app_data_len = _cli.buildAdvertData(ADV_TYPE_ROOM, app_data); + + return createAdvert(self_id, app_data, app_data_len); } -bool RoomServerMesh::saveFilter(ClientInfo* client) { - return client->isAdmin(); // only persist admins; guests/read-write re-login +// ZEPHCORE: no openAppend() here: no log file on flash + +// ZEPHCORE: ours. ZephCore telemetry, stats and ACL paging. +int RoomServerMesh::handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len) { + memcpy(reply_data, &sender_timestamp, 4); + + if (payload[0] == REQ_TYPE_GET_STATUS) { + auto& radio_driver = getRadioDriver(_radio); + ServerStats stats; + stats.batt_milli_volts = _board.getBattMilliVolts(); + stats.curr_tx_queue_len = _mgr->getOutboundTotal(); + stats.noise_floor = (int16_t)_radio->getNoiseFloor(); + stats.last_rssi = (int16_t)radio_driver.getLastRSSI(); + stats.n_packets_recv = radio_driver.getPacketsRecv(); + stats.n_packets_sent = radio_driver.getPacketsSent(); + stats.total_air_time_secs = getTotalAirTime() / 1000; + stats.total_up_time_secs = uptime_millis / 1000; + stats.n_sent_flood = getNumSentFlood(); + stats.n_sent_direct = getNumSentDirect(); + stats.n_recv_flood = getNumRecvFlood(); + stats.n_recv_direct = getNumRecvDirect(); + stats.err_events = _err_flags; + stats.last_snr = (int16_t)(radio_driver.getLastSNR() * 4); + stats.n_direct_dups = ((SimpleMeshTables *)getTables())->getNumDirectDups(); + stats.n_flood_dups = ((SimpleMeshTables *)getTables())->getNumFloodDups(); + stats.n_posted = _num_posted; + stats.n_post_push = _num_post_pushes; + memcpy(&reply_data[4], &stats, sizeof(stats)); + return 4 + sizeof(stats); + } + + if (payload[0] == REQ_TYPE_GET_TELEMETRY_DATA) { + /* CayenneLPP telemetry response using SimpleLPP encoder */ + SimpleLPP lpp(&reply_data[4], sizeof(reply_data) - 4); + + /* Battery voltage — channel 1 = TELEM_CHANNEL_SELF (matches Arduino) */ + const uint8_t CH_SELF = 1; + uint16_t batt_mv = _board.getBattMilliVolts(); + lpp.addVoltage(CH_SELF, batt_mv / 1000.0f); + + /* Environment sensors — prefer external, fallback to MCU die temp */ + struct env_data env; + if (env_sensors_read(&env) == 0) { + if (env.has_temperature) { + lpp.addTemperature(CH_SELF, env.temperature_c); + } else if (env.has_mcu_temperature) { + lpp.addTemperature(CH_SELF, env.mcu_temperature_c); + } else { + /* Last resort: MCU temp from board API */ + float mcu_temp = _board.getMCUTemperature(); + if (!isnan(mcu_temp)) { + lpp.addTemperature(CH_SELF, mcu_temp); + } + } + if (env.has_humidity) { + lpp.addRelativeHumidity(CH_SELF, env.humidity_pct); + } + if (env.has_pressure) { + lpp.addBarometricPressure(CH_SELF, env.pressure_hpa); + } + if (env.has_luminosity) { + lpp.addLuminosity(CH_SELF, env.luminosity); + } + } else { + /* No env sensors at all — try MCU temp directly */ + float mcu_temp = _board.getMCUTemperature(); + if (!isnan(mcu_temp)) { + lpp.addTemperature(CH_SELF, mcu_temp); + } + } + + /* Power monitors (INA219/INA3221/ina2xx) */ + if (power_sensors_available()) { + struct power_data pwr; + if (power_sensors_read(&pwr) == 0) { + uint8_t ch = CH_SELF + 1; + for (int j = 0; j < pwr.num_channels; j++) { + if (pwr.channels[j].valid) { + lpp.addVoltage(ch, pwr.channels[j].voltage_v); + lpp.addCurrent(ch, pwr.channels[j].current_a); + lpp.addPower(ch, pwr.channels[j].power_w); + ch++; + } + } + } + } + + /* GPS precise position — admin-only, and only via telemetry, never + * adverts. Guests get the rest of the LPP payload but no position: + * adverts already publish the operator-set prefs coordinates, so + * there is no reason to hand a guest login the live fix as well. */ + if (sender->isAdmin()) { + struct gps_position gpos; + if (gps_get_last_known_position(&gpos)) { + lpp.addGPS(CH_SELF, + (float)(gpos.latitude_ndeg / 1e9), + (float)(gpos.longitude_ndeg / 1e9), + gpos.altitude_mm / 1000.0f); + } + + /* Wake GPS / extend acquire window so the next telemetry poll has + * a fresher fix. In repeater mode GPS is normally off between the + * 48h time-sync cycles — this opportunistically rearms acquire + * when someone actually cares about our position. No-op if GPS + * is disabled in prefs. Gated with the position itself so a guest + * can't hold the GPS awake by polling. */ + if (gps_is_available() && gps_is_enabled()) { + gps_request_fresh_fix(); + } + } + + return 4 + lpp.getSize(); + } + + if (payload[0] == REQ_TYPE_GET_ACCESS_LIST && sender->isAdmin()) { + uint8_t res1 = payload[1]; + uint8_t res2 = payload[2]; + if (res1 == 0 && res2 == 0) { + uint8_t ofs = 4; + for (int i = 0; i < acl.getNumClients() && (size_t)(ofs + 7) <= sizeof(reply_data) - 4; i++) { + auto c = acl.getClientByIdx(i); + if (c->permissions == 0) continue; + memcpy(&reply_data[ofs], c->id.pub_key, 6); + ofs += 6; + reply_data[ofs++] = c->permissions; + } + return ofs; + } + } + + if (payload[0] == REQ_TYPE_GET_OWNER_INFO) { + sprintf((char*)&reply_data[4], "%s\n%s\n%s", FIRMWARE_VERSION, _prefs.node_name, _prefs.owner_info); + return 4 + strlen((char*)&reply_data[4]); + } + + return 0; } -void RoomServerMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { - if (scope.isNull()) { - sendFlood(pkt, delay_millis, path_hash_size); - } else { - uint16_t codes[2]; - codes[0] = scope.calcTransportCode(pkt); - codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? - sendFlood(pkt, codes, delay_millis, path_hash_size); - } +// ZEPHCORE: ours. Packet logging through helpers/PacketLog.h. +void RoomServerMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { +#if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING) + /* Arduino-compatible RAW packet hex dump */ + static char hex_buf[MAX_TRANS_UNIT * 2 + 1]; + mesh::Utils::toHex(hex_buf, raw, len); + printk("%s RAW: %s\n", getLogDateTime(), hex_buf); +#endif + (void)snr; + (void)rssi; + (void)raw; + (void)len; } -void RoomServerMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { - TransportKey req_scope; - bool req_scope_known = recv_pkt_region != nullptr && !recv_pkt_region->isWildcard() - && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; - - switch (mesh::chooseReplyScope(req_scope_known, recv_pkt_unscoped_flood, !default_scope.isNull())) { - case mesh::REPLY_SCOPE_REQUEST: - sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // same scope as the request - break; - case mesh::REPLY_SCOPE_DEFAULT: - // requester's scope is unknown: a DIRECT request (no transport codes), or a - // code that matched no Region. Un-scoped would be dropped at hop 0 by every - // repeater running flood.max.unscoped=0. - sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); - break; - case mesh::REPLY_SCOPE_NONE: - sendFlood(packet, delay_millis, path_hash_size); // send un-scoped - break; - } +// ZEPHCORE: ours. Packet logging through helpers/PacketLog.h. +void RoomServerMesh::logRx(mesh::Packet* pkt, int len, float score) { + packet_log_rx(getLogDateTime(), pkt, _radio->getLastRSSI(), score, _radio->getEstAirtimeFor(len)); + if (_logging) { + LOG_INF("RX len=%d type=%d route=%s payload_len=%d SNR=%d RSSI=%d", + len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", + pkt->payload_len, (int)_radio->getLastSNR(), (int)_radio->getLastRSSI()); + } + (void)score; +} +// ZEPHCORE: ours. Packet logging through helpers/PacketLog.h. +void RoomServerMesh::logTx(mesh::Packet* pkt, int len) { + packet_log_tx(getLogDateTime(), pkt); + if (_logging) { + LOG_INF("TX len=%d type=%d route=%s payload_len=%d", + len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", + pkt->payload_len); + } +} +// ZEPHCORE: ours. Packet logging through helpers/PacketLog.h. +void RoomServerMesh::logTxFail(mesh::Packet* pkt, int len) { + if (_logging) { + LOG_WRN("TX FAIL len=%d type=%d route=%s payload_len=%d", + len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", + pkt->payload_len); + } } +// ZEPHCORE: no calcRxDelay() here: rx delay is replaced by the adaptive contention window + +// ZEPHCORE: ours. Zephyr clock formatting. +const char* RoomServerMesh::getLogDateTime() { + static char tmp[48]; + uint32_t now = getRTCClock()->getCurrentTime(); + /* Match Arduino format: "HH:MM:SS - D/M/YYYY U" */ + time_t t = (time_t)now; + struct tm* tm = gmtime(&t); + if (tm) { + snprintf(tmp, sizeof(tmp), "%02d:%02d:%02d - %d/%d/%d U", + tm->tm_hour, tm->tm_min, tm->tm_sec, + tm->tm_mday, tm->tm_mon + 1, tm->tm_year + 1900); + } else { + snprintf(tmp, sizeof(tmp), "%u", now); + } + return tmp; +} + +// ZEPHCORE: ours. Adaptive contention window. +uint32_t RoomServerMesh::getRetransmitDelay(const mesh::Packet* packet) { + return computeAdaptiveFloodDelay(packet); +} +// ZEPHCORE: ours. Adaptive contention window. +uint32_t RoomServerMesh::getDirectRetransmitDelay(const mesh::Packet* packet) { + return computeAdaptiveDirectDelay(packet); +} + +// ZEPHCORE: ours. The room server never forwards (UPSTREAM_TRACKER fad11c90). /* A room server is an endpoint, not a repeater: it never forwards other nodes' * transit traffic (no flood relaying, no neighbour/loop-detect machinery). Its * own replies still go out via sendDirect()/sendFloodReply(); this only governs * relaying of pass-through packets. */ bool RoomServerMesh::allowPacketForward(const mesh::Packet* /*packet*/) { - return false; -} - -const char* RoomServerMesh::getLogDateTime() { - static char tmp[48]; - uint32_t now = getRTCClock()->getCurrentTime(); - /* Match Arduino format: "HH:MM:SS - D/M/YYYY U" */ - time_t t = (time_t)now; - struct tm* tm = gmtime(&t); - if (tm) { - snprintf(tmp, sizeof(tmp), "%02d:%02d:%02d - %d/%d/%d U", - tm->tm_hour, tm->tm_min, tm->tm_sec, - tm->tm_mday, tm->tm_mon + 1, tm->tm_year + 1900); - } else { - snprintf(tmp, sizeof(tmp), "%u", now); - } - return tmp; -} - -void RoomServerMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { -#if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING) - /* Arduino-compatible RAW packet hex dump */ - static char hex_buf[MAX_TRANS_UNIT * 2 + 1]; - mesh::Utils::toHex(hex_buf, raw, len); - printk("%s RAW: %s\n", getLogDateTime(), hex_buf); -#endif - (void)snr; - (void)rssi; - (void)raw; - (void)len; -} - -void RoomServerMesh::logRx(mesh::Packet* pkt, int len, float score) { - packet_log_rx(getLogDateTime(), pkt, _radio->getLastRSSI(), score, _radio->getEstAirtimeFor(len)); - if (_logging) { - LOG_INF("RX len=%d type=%d route=%s payload_len=%d SNR=%d RSSI=%d", - len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", - pkt->payload_len, (int)_radio->getLastSNR(), (int)_radio->getLastRSSI()); - } - (void)score; -} - -void RoomServerMesh::logTx(mesh::Packet* pkt, int len) { - packet_log_tx(getLogDateTime(), pkt); - if (_logging) { - LOG_INF("TX len=%d type=%d route=%s payload_len=%d", - len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", - pkt->payload_len); - } -} - -void RoomServerMesh::logTxFail(mesh::Packet* pkt, int len) { - if (_logging) { - LOG_WRN("TX FAIL len=%d type=%d route=%s payload_len=%d", - len, pkt->getPayloadType(), pkt->isRouteDirect() ? "D" : "F", - pkt->payload_len); - } -} - -uint32_t RoomServerMesh::getRetransmitDelay(const mesh::Packet* packet) { - return computeAdaptiveFloodDelay(packet); -} - -uint32_t RoomServerMesh::getDirectRetransmitDelay(const mesh::Packet* packet) { - return computeAdaptiveDirectDelay(packet); + return false; } +// ZEPHCORE: ours. Also records whether the packet was an unscoped flood. mesh::DispatcherAction RoomServerMesh::onRecvPacket(mesh::Packet* pkt) { - // Determine the request packet's region so sendFloodReply() can echo the same - // scope. Runs for every packet (not just floods) so recv_pkt_region is cleared - // for direct packets instead of inheriting the last flood's region. - recv_pkt_unscoped_flood = (pkt->getRouteType() == ROUTE_TYPE_FLOOD); - if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { - recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); - } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { - if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { - recv_pkt_region = nullptr; - } else { - recv_pkt_region = ®ion_map.getWildcard(); - } - } else { - recv_pkt_region = nullptr; - } - return Mesh::onRecvPacket(pkt); + // Determine the request packet's region so sendFloodReply() can echo the same + // scope. Runs for every packet (not just floods) so recv_pkt_region is cleared + // for direct packets instead of inheriting the last flood's region. + recv_pkt_unscoped_flood = (pkt->getRouteType() == ROUTE_TYPE_FLOOD); + if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { + recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); + } else if (pkt->getRouteType() == ROUTE_TYPE_FLOOD) { + if (region_map.getWildcard().flags & REGION_DENY_FLOOD) { + recv_pkt_region = nullptr; + } else { + recv_pkt_region = ®ion_map.getWildcard(); + } + } else { + recv_pkt_region = nullptr; + } + return Mesh::onRecvPacket(pkt); } +// ZEPHCORE: ours. Constant-time password compare and a failed-login rate limit. void RoomServerMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) { - if (packet->getPayloadType() != PAYLOAD_TYPE_ANON_REQ) return; + if (packet->getPayloadType() != PAYLOAD_TYPE_ANON_REQ) return; - /* Room login request layout: [timestamp(4)][sync_since(4)][password...]. - * (This differs from the repeater's ANON_REQ, which has no sync_since.) */ - uint32_t sender_timestamp, sender_sync_since; - memcpy(&sender_timestamp, data, 4); - memcpy(&sender_sync_since, &data[4], 4); - data[len] = 0; // null-terminate the password + /* Room login request layout: [timestamp(4)][sync_since(4)][password...]. + * (This differs from the repeater's ANON_REQ, which has no sync_since.) */ + uint32_t sender_timestamp, sender_sync_since; + memcpy(&sender_timestamp, data, 4); + memcpy(&sender_sync_since, &data[4], 4); + data[len] = 0; // null-terminate the password - ClientInfo* client = nullptr; - if (data[8] == 0) { // blank password -> must already be a known client - client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); - } + ClientInfo* client = nullptr; + if (data[8] == 0) { // blank password -> must already be a known client + client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); + } - if (client == nullptr) { - /* Constant-time compare against both stored passwords. Admin grants - * ADMIN; the guest/room password grants READ_WRITE (so guests may - * post); allow_read_only downgrades any other login to GUEST. - * - * Zero-pad BOTH operands into cleared buffers first: the CLI's - * StrHelper::strncpy null-terminates but does NOT clear the rest of - * the 16-byte buffer, so a password set over a longer previous value - * leaves trailing garbage. Comparing the raw stored buffer full-width - * against the (zero-padded) received bytes would then fail to match a - * correct password. Copy only up to the NUL so the compare reflects - * the actual string while staying constant-time over the full width. */ - uint8_t received[sizeof(_prefs.password)] = {0}; - uint8_t admin_pw[sizeof(_prefs.password)] = {0}; - uint8_t guest_pw[sizeof(_prefs.guest_password)] = {0}; - size_t r_len = strnlen((const char*)&data[8], sizeof(received) - 1); - memcpy(received, &data[8], r_len); - memcpy(admin_pw, _prefs.password, strnlen(_prefs.password, sizeof(admin_pw) - 1)); - memcpy(guest_pw, _prefs.guest_password, strnlen(_prefs.guest_password, sizeof(guest_pw) - 1)); - bool admin_match = mesh::Utils::constantTimeEqual(received, admin_pw, sizeof(received)); - bool guest_match = mesh::Utils::constantTimeEqual(received, guest_pw, sizeof(received)); + if (client == nullptr) { + /* Constant-time compare against both stored passwords. Admin grants + * ADMIN; the guest/room password grants READ_WRITE (so guests may + * post); allow_read_only downgrades any other login to GUEST. + * + * Zero-pad BOTH operands into cleared buffers first: the CLI's + * StrHelper::strncpy null-terminates but does NOT clear the rest of + * the 16-byte buffer, so a password set over a longer previous value + * leaves trailing garbage. Comparing the raw stored buffer full-width + * against the (zero-padded) received bytes would then fail to match a + * correct password. Copy only up to the NUL so the compare reflects + * the actual string while staying constant-time over the full width. */ + uint8_t received[sizeof(_prefs.password)] = {0}; + uint8_t admin_pw[sizeof(_prefs.password)] = {0}; + uint8_t guest_pw[sizeof(_prefs.guest_password)] = {0}; + size_t r_len = strnlen((const char*)&data[8], sizeof(received) - 1); + memcpy(received, &data[8], r_len); + memcpy(admin_pw, _prefs.password, strnlen(_prefs.password, sizeof(admin_pw) - 1)); + memcpy(guest_pw, _prefs.guest_password, strnlen(_prefs.guest_password, sizeof(guest_pw) - 1)); + bool admin_match = mesh::Utils::constantTimeEqual(received, admin_pw, sizeof(received)); + bool guest_match = mesh::Utils::constantTimeEqual(received, guest_pw, sizeof(received)); - /* An empty stored guest password disables guest access (as - * CONFIG_ZEPHCORE_GUEST_PASSWORD documents) rather than matching an - * empty submitted password and granting read+write to anyone. Both - * compares above still run unconditionally, so timing is unchanged. - * allow_read_only below remains the intended way to run an open room. */ - if (_prefs.guest_password[0] == 0) guest_match = false; + /* An empty stored guest password disables guest access (as + * CONFIG_ZEPHCORE_GUEST_PASSWORD documents) rather than matching an + * empty submitted password and granting read+write to anyone. Both + * compares above still run unconditionally, so timing is unchanged. + * allow_read_only below remains the intended way to run an open room. */ + if (_prefs.guest_password[0] == 0) guest_match = false; - uint8_t perms; - if (admin_match) { - perms = PERM_ACL_ADMIN; - } else if (guest_match) { - perms = PERM_ACL_READ_WRITE; - } else if (_prefs.allow_read_only) { - perms = PERM_ACL_GUEST; - } else { - if (!login_fail_limiter.allow(getRTCClock()->getCurrentTime())) { - LOG_WRN("Room login rate-limited"); - } else { - LOG_WRN("Incorrect room password"); - } - return; - } + uint8_t perms; + if (admin_match) { + perms = PERM_ACL_ADMIN; + } else if (guest_match) { + perms = PERM_ACL_READ_WRITE; + } else if (_prefs.allow_read_only) { + perms = PERM_ACL_GUEST; + } else { + if (!login_fail_limiter.allow(getRTCClock()->getCurrentTime())) { + LOG_WRN("Room login rate-limited"); + } else { + LOG_WRN("Incorrect room password"); + } + return; + } - client = acl.putClient(sender, 0); - if (sender_timestamp <= client->last_timestamp) { - LOG_WRN("Possible login replay attack!"); - return; - } + client = acl.putClient(sender, 0); + if (sender_timestamp <= client->last_timestamp) { + LOG_WRN("Possible login replay attack!"); + return; + } - LOG_INF("Room login success"); - client->last_timestamp = sender_timestamp; - client->extra.room.sync_since = sender_sync_since; - client->extra.room.pending_ack = 0; - client->extra.room.push_failures = 0; - client->last_activity = getRTCClock()->getCurrentTime(); - client->permissions &= ~0x03; - client->permissions |= perms; - memcpy(client->shared_secret, secret, PUB_KEY_SIZE); + LOG_INF("Room login success"); + client->last_timestamp = sender_timestamp; + client->extra.room.sync_since = sender_sync_since; + client->extra.room.pending_ack = 0; + client->extra.room.push_failures = 0; + client->last_activity = getRTCClock()->getCurrentTime(); + client->permissions &= ~0x03; + client->permissions |= perms; + memcpy(client->shared_secret, secret, PUB_KEY_SIZE); - if (perms != PERM_ACL_GUEST) { - if (!dirty_contacts_expiry) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - } - } + if (perms != PERM_ACL_GUEST) { + if (!dirty_contacts_expiry) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + } + } - if (packet->isRouteFlood()) { - client->out_path_len = OUT_PATH_UNKNOWN; // need to rediscover the path - } + if (packet->isRouteFlood()) { + client->out_path_len = OUT_PATH_UNKNOWN; // need to rediscover the path + } - uint32_t now = getRTCClock()->getCurrentTimeUnique(); - memcpy(reply_data, &now, 4); - reply_data[4] = RESP_SERVER_LOGIN_OK; - reply_data[5] = 0; // legacy: recommended keep-alive interval - reply_data[6] = (client->isAdmin() ? 1 : (client->permissions == 0 ? 2 : 0)); - reply_data[7] = client->permissions; - getRNG()->random(&reply_data[8], 4); - reply_data[12] = FIRMWARE_VER_LEVEL; + uint32_t now = getRTCClock()->getCurrentTimeUnique(); + memcpy(reply_data, &now, 4); + reply_data[4] = RESP_SERVER_LOGIN_OK; + reply_data[5] = 0; // legacy: recommended keep-alive interval + reply_data[6] = (client->isAdmin() ? 1 : (client->permissions == 0 ? 2 : 0)); + reply_data[7] = client->permissions; + getRNG()->random(&reply_data[8], 4); + reply_data[12] = FIRMWARE_VER_LEVEL; - next_push = futureMillis(PUSH_NOTIFY_DELAY_MILLIS); // let the RESPONSE land before pushing + next_push = futureMillis(PUSH_NOTIFY_DELAY_MILLIS); // let the RESPONSE land before pushing - if (packet->isRouteFlood()) { - mesh::Packet* path = createPathReturn(sender, client->shared_secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, 13); - if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, client->shared_secret, reply_data, 13); - if (reply) { - if (client->out_path_len != OUT_PATH_UNKNOWN) { - sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); - } else { - sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } - } - } + if (packet->isRouteFlood()) { + mesh::Packet* path = createPathReturn(sender, client->shared_secret, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, 13); + if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + } else { + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, sender, client->shared_secret, reply_data, 13); + if (reply) { + if (client->out_path_len != OUT_PATH_UNKNOWN) { + sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); + } else { + sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + } + } + } } -int RoomServerMesh::searchPeersByHash(const uint8_t* hash) { - int n = 0; - for (int i = 0; i < acl.getNumClients(); i++) { - if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { - matching_peer_indexes[n++] = i; - } - } - return n; +int RoomServerMesh::searchPeersByHash(const uint8_t *hash) { + int n = 0; + for (int i = 0; i < acl.getNumClients(); i++) { + if (acl.getClientByIdx(i)->id.isHashMatch(hash)) { + matching_peer_indexes[n++] = i; // store the INDEXES of matching contacts (for subsequent 'peer' methods) + } + } + return n; } -void RoomServerMesh::getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) { - int i = matching_peer_indexes[peer_idx]; - if (i >= 0 && i < acl.getNumClients()) { - memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); - } +void RoomServerMesh::getPeerSharedSecret(uint8_t *dest_secret, int peer_idx) { + int i = matching_peer_indexes[peer_idx]; + if (i >= 0 && i < acl.getNumClients()) { + // lookup pre-calculated shared_secret + memcpy(dest_secret, acl.getClientByIdx(i)->shared_secret, PUB_KEY_SIZE); + } else { + MESH_DEBUG_PRINTLN("getPeerSharedSecret: Invalid peer idx: %d", i); + } } +// ZEPHCORE: ours. Activity clock and CLI reply delay. void RoomServerMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, - const uint8_t* secret, uint8_t* data, size_t len) { - int i = matching_peer_indexes[sender_idx]; - if (i < 0 || i >= acl.getNumClients()) { - LOG_WRN("onPeerDataRecv: invalid peer idx: %d", i); - return; - } - ClientInfo* client = acl.getClientByIdx(i); + const uint8_t* secret, uint8_t* data, size_t len) { + int i = matching_peer_indexes[sender_idx]; + if (i < 0 || i >= acl.getNumClients()) { + LOG_WRN("onPeerDataRecv: invalid peer idx: %d", i); + return; + } + ClientInfo* client = acl.getClientByIdx(i); - if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { // a CLI command or a new post - uint32_t sender_timestamp; - memcpy(&sender_timestamp, data, 4); - uint8_t flags = (data[4] >> 2); + if (type == PAYLOAD_TYPE_TXT_MSG && len > 5) { // a CLI command or a new post + uint32_t sender_timestamp; + memcpy(&sender_timestamp, data, 4); + uint8_t flags = (data[4] >> 2); - /* TXT_TYPE_CLI_COMMAND (v1.18+) is handled exactly like TXT_TYPE_CLI_DATA - * here: both stay behind client->isAdmin() below, and both are covered - * by the monotonic sender_timestamp / is_retry gates. */ - if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA || - flags == TXT_TYPE_CLI_COMMAND)) { - LOG_DBG("onPeerDataRecv: unsupported text type: flags=%02x", flags); - } else if (sender_timestamp >= client->last_timestamp) { - bool is_retry = (sender_timestamp == client->last_timestamp); - client->last_timestamp = sender_timestamp; - client->last_activity = getRTCClock()->getCurrentTime(); - client->extra.room.push_failures = 0; // peer is alive -> resume pushes + /* TXT_TYPE_CLI_COMMAND (v1.18+) is handled exactly like TXT_TYPE_CLI_DATA + * here: both stay behind client->isAdmin() below, and both are covered + * by the monotonic sender_timestamp / is_retry gates. */ + if (!(flags == TXT_TYPE_PLAIN || flags == TXT_TYPE_CLI_DATA || + flags == TXT_TYPE_CLI_COMMAND)) { + LOG_DBG("onPeerDataRecv: unsupported text type: flags=%02x", flags); + } else if (sender_timestamp >= client->last_timestamp) { + bool is_retry = (sender_timestamp == client->last_timestamp); + client->last_timestamp = sender_timestamp; + client->last_activity = getRTCClock()->getCurrentTime(); + client->extra.room.push_failures = 0; // peer is alive -> resume pushes - data[len] = 0; // null-terminate the text + data[len] = 0; // null-terminate the text - /* ACK proves to the sender we received the message. */ - uint32_t ack_hash; - mesh::Utils::sha256((uint8_t*)&ack_hash, 4, data, 5 + strlen((char*)&data[5]), - client->id.pub_key, PUB_KEY_SIZE); + /* ACK proves to the sender we received the message. */ + uint32_t ack_hash; + mesh::Utils::sha256((uint8_t*)&ack_hash, 4, data, 5 + strlen((char*)&data[5]), + client->id.pub_key, PUB_KEY_SIZE); - uint8_t temp[5 + CLI_REMOTE_REPLY_SIZE]; - bool send_ack; - if (flags == TXT_TYPE_CLI_DATA || flags == TXT_TYPE_CLI_COMMAND) { // admin CLI over the air - if (client->isAdmin()) { - if (is_retry) { - temp[5] = 0; - } else { - handleCommand(sender_timestamp, (char*)&data[5], (char*)&temp[5]); - temp[4] = (TXT_TYPE_CLI_DATA << 2); - } - } else { - temp[5] = 0; // non-admin: no CLI reply - } - send_ack = false; // CLI replies are sent as text, not ACKed - } else { // TXT_TYPE_PLAIN -> a post - if ((client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { - temp[5] = 0; // read-only guests can't post - send_ack = false; - } else { - if (!is_retry) addPost(client, (const char*)&data[5]); - temp[5] = 0; // the ACK is the only reply - send_ack = true; - } - } + uint8_t temp[5 + CLI_REMOTE_REPLY_SIZE]; + bool send_ack; + if (flags == TXT_TYPE_CLI_DATA || flags == TXT_TYPE_CLI_COMMAND) { // admin CLI over the air + if (client->isAdmin()) { + if (is_retry) { + temp[5] = 0; + } else { + handleCommand(sender_timestamp, (char*)&data[5], (char*)&temp[5]); + temp[4] = (TXT_TYPE_CLI_DATA << 2); + } + } else { + temp[5] = 0; // non-admin: no CLI reply + } + send_ack = false; // CLI replies are sent as text, not ACKed + } else { // TXT_TYPE_PLAIN -> a post + if ((client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { + temp[5] = 0; // read-only guests can't post + send_ack = false; + } else { + if (!is_retry) addPost(client, (const char*)&data[5]); + temp[5] = 0; // the ACK is the only reply + send_ack = true; + } + } - uint32_t delay_millis; - if (send_ack) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { - mesh::Packet* ack = createAck(ack_hash); - if (ack) sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize()); - delay_millis = TXT_ACK_DELAY + CLI_REPLY_DELAY_MILLIS; - } else { - uint32_t d = TXT_ACK_DELAY; - if (getExtraAckTransmitCount() > 0) { - mesh::Packet* a1 = createMultiAck(ack_hash, 1); - if (a1) sendDirect(a1, client->out_path, client->out_path_len, d); - d += 300; - } - mesh::Packet* a2 = createAck(ack_hash); - if (a2) sendDirect(a2, client->out_path, client->out_path_len, d); - delay_millis = d + CLI_REPLY_DELAY_MILLIS; - } - } else { - delay_millis = 0; - } + uint32_t delay_millis; + if (send_ack) { + if (client->out_path_len == OUT_PATH_UNKNOWN) { + mesh::Packet* ack = createAck(ack_hash); + if (ack) sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize()); + delay_millis = TXT_ACK_DELAY + CLI_REPLY_DELAY_MILLIS; + } else { + uint32_t d = TXT_ACK_DELAY; + if (getExtraAckTransmitCount() > 0) { + mesh::Packet* a1 = createMultiAck(ack_hash, 1); + if (a1) sendDirect(a1, client->out_path, client->out_path_len, d); + d += 300; + } + mesh::Packet* a2 = createAck(ack_hash); + if (a2) sendDirect(a2, client->out_path, client->out_path_len, d); + delay_millis = d + CLI_REPLY_DELAY_MILLIS; + } + } else { + delay_millis = 0; + } - int text_len = strlen((char*)&temp[5]); - if (text_len > 0) { // a CLI reply to send back - uint32_t now = getRTCClock()->getCurrentTimeUnique(); - if (now == sender_timestamp) now++; - memcpy(temp, &now, 4); + int text_len = strlen((char*)&temp[5]); + if (text_len > 0) { // a CLI reply to send back + uint32_t now = getRTCClock()->getCurrentTimeUnique(); + if (now == sender_timestamp) now++; + memcpy(temp, &now, 4); - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); - if (reply) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { - sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } else { - sendDirect(reply, client->out_path, client->out_path_len, delay_millis + SERVER_RESPONSE_DELAY); - } - } - } - } else { - LOG_DBG("onPeerDataRecv: possible replay attack"); - } - } else if (type == PAYLOAD_TYPE_REQ && len >= 5) { - uint32_t sender_timestamp; - memcpy(&sender_timestamp, data, 4); - if (sender_timestamp < client->last_timestamp) { - LOG_DBG("onPeerDataRecv: possible replay attack"); - } else { - client->last_timestamp = sender_timestamp; - client->last_activity = getRTCClock()->getCurrentTime(); - client->extra.room.push_failures = 0; + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); + if (reply) { + if (client->out_path_len == OUT_PATH_UNKNOWN) { + sendFloodReply(reply, delay_millis + SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + } else { + sendDirect(reply, client->out_path, client->out_path_len, delay_millis + SERVER_RESPONSE_DELAY); + } + } + } + } else { + LOG_DBG("onPeerDataRecv: possible replay attack"); + } + } else if (type == PAYLOAD_TYPE_REQ && len >= 5) { + uint32_t sender_timestamp; + memcpy(&sender_timestamp, data, 4); + if (sender_timestamp < client->last_timestamp) { + LOG_DBG("onPeerDataRecv: possible replay attack"); + } else { + client->last_timestamp = sender_timestamp; + client->last_activity = getRTCClock()->getCurrentTime(); + client->extra.room.push_failures = 0; - if (data[4] == REQ_TYPE_KEEP_ALIVE && packet->isRouteDirect()) { - uint32_t forceSince = 0; - if (len >= 9) { - memcpy(&forceSince, &data[5], 4); // optional: client's last-seen post ts - } else { - memcpy(&data[5], &forceSince, 4); // zero-fill for the ack hash below - } - if (forceSince > 0) { - client->extra.room.sync_since = forceSince; - } - client->extra.room.pending_ack = 0; + if (data[4] == REQ_TYPE_KEEP_ALIVE && packet->isRouteDirect()) { + uint32_t forceSince = 0; + if (len >= 9) { + memcpy(&forceSince, &data[5], 4); // optional: client's last-seen post ts + } else { + memcpy(&data[5], &forceSince, 4); // zero-fill for the ack hash below + } + if (forceSince > 0) { + client->extra.room.sync_since = forceSince; + } + client->extra.room.pending_ack = 0; - /* Keep-alive is only answered DIRECT, with the unsynced count - * appended to the ACK so the client knows posts are waiting. */ - if (client->out_path_len != OUT_PATH_UNKNOWN) { - uint32_t ack_hash; - mesh::Utils::sha256((uint8_t*)&ack_hash, 4, data, 9, client->id.pub_key, PUB_KEY_SIZE); - mesh::Packet* reply = createAck(ack_hash); - if (reply) { - reply->payload[reply->payload_len++] = getUnsyncedCount(client); - sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); - } - } - } else { - int reply_len = handleRequest(client, sender_timestamp, &data[4], len - 4); - if (reply_len > 0) { - if (packet->isRouteFlood()) { - mesh::Packet* path = createPathReturn(client->id, secret, packet->path, packet->path_len, - PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); - if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } else { - mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); - if (reply) { - if (client->out_path_len != OUT_PATH_UNKNOWN) { - sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); - } else { - sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); - } - } - } - } - } - } - } + /* Keep-alive is only answered DIRECT, with the unsynced count + * appended to the ACK so the client knows posts are waiting. */ + if (client->out_path_len != OUT_PATH_UNKNOWN) { + uint32_t ack_hash; + mesh::Utils::sha256((uint8_t*)&ack_hash, 4, data, 9, client->id.pub_key, PUB_KEY_SIZE); + mesh::Packet* reply = createAck(ack_hash); + if (reply) { + reply->payload[reply->payload_len++] = getUnsyncedCount(client); + sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); + } + } + } else { + int reply_len = handleRequest(client, sender_timestamp, &data[4], len - 4); + if (reply_len > 0) { + if (packet->isRouteFlood()) { + mesh::Packet* path = createPathReturn(client->id, secret, packet->path, packet->path_len, + PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); + if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + } else { + mesh::Packet* reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); + if (reply) { + if (client->out_path_len != OUT_PATH_UNKNOWN) { + sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); + } else { + sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + } + } + } + } + } + } + } } -bool RoomServerMesh::onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, - uint8_t* path, uint8_t path_len, uint8_t extra_type, - uint8_t* extra, uint8_t extra_len) { - int i = matching_peer_indexes[sender_idx]; - if (i >= 0 && i < acl.getNumClients()) { - LOG_DBG("PATH to client, path_len=%d", path_len); - auto client = acl.getClientByIdx(i); - /* path source bounded by upstream packet parser; client->out_path - * is MAX_PATH_SIZE-sized. */ - client->out_path_len = mesh::Packet::copyPath(client->out_path, path, MAX_PATH_SIZE, path_len); - client->last_activity = getRTCClock()->getCurrentTime(); - } - return false; +bool RoomServerMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t *secret, uint8_t *path, + uint8_t path_len, uint8_t extra_type, uint8_t *extra, uint8_t extra_len) { + // TODO: prevent replay attacks + int i = matching_peer_indexes[sender_idx]; + + if (i >= 0 && i < acl.getNumClients()) { // get from our known_clients table (sender SHOULD already be known in this context) + MESH_DEBUG_PRINTLN("PATH to client, path_len=%d", (uint32_t)path_len); + auto client = acl.getClientByIdx(i); + client->out_path_len = mesh::Packet::copyPath(client->out_path, path, path_len); // store a copy of path, for sendDirect() + client->last_activity = getRTCClock()->getCurrentTime(); + } else { + MESH_DEBUG_PRINTLN("onPeerPathRecv: invalid peer idx: %d", i); + } + + if (extra_type == PAYLOAD_TYPE_ACK && extra_len >= 4) { + // also got an encoded ACK! + processAck(extra); + } + + // NOTE: no reciprocal path send!! + return false; } +void RoomServerMesh::onAckRecv(mesh::Packet *packet, uint32_t ack_crc) { + if (processAck((uint8_t *)&ack_crc)) { + packet->markDoNotRetransmit(); // ACK was for this node, so don't retransmit + } +} + +// ZEPHCORE: ours. Zephyr datastore and ZephCore members. RoomServerMesh::RoomServerMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, - mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables) - : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(), tables), - _board(board), - _cli(board, rtc, acl, &_prefs, this), - region_map(key_store), temp_map(key_store), - /* Failed-login rate limit: 4 wrong-password attempts per 180s. Global - * rate (not per-sender) — trade-off documented in CRYPTO_AUDIT_INDEX.md - * Phase 4 (mitigation for upstream MeshCore#2556). */ - login_fail_limiter(4, 180) { + mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables) + : mesh::Mesh(radio, ms, rng, rtc, *new StaticPoolPacketManager(), tables), + _board(board), + _cli(board, rtc, ®ion_map, &acl, &_prefs, this), + region_map(key_store), temp_map(key_store), + /* Failed-login rate limit: 4 wrong-password attempts per 180s. Global + * rate (not per-sender) — trade-off documented in CRYPTO_AUDIT_INDEX.md + * Phase 4 (mitigation for upstream MeshCore#2556). */ + login_fail_limiter(4, 180) { - _store = nullptr; - last_millis = 0; - uptime_millis = 0; - next_local_advert = next_flood_advert = 0; - dirty_contacts_expiry = 0; - set_radio_at = revert_radio_at = 0; - _logging = false; - region_load_active = false; - recv_pkt_region = nullptr; - recv_pkt_unscoped_flood = false; - memset(default_scope.key, 0, sizeof(default_scope.key)); + _store = nullptr; + last_millis = 0; + uptime_millis = 0; + next_local_advert = next_flood_advert = 0; + dirty_contacts_expiry = 0; + set_radio_at = revert_radio_at = 0; + _logging = false; + region_load_active = false; + recv_pkt_region = nullptr; + recv_pkt_unscoped_flood = false; + memset(default_scope.key, 0, sizeof(default_scope.key)); - initNodePrefs(&_prefs); - strcpy(_prefs.node_name, "Room"); - _prefs.advert_loc_policy = ADVERT_LOC_PREFS; // advertise prefs coordinates - /* path_hash_mode = 1 moved into initNodePrefs() -- see RepeaterMesh. */ - _prefs.disable_fwd = 1; // a room server is an endpoint, never repeats + initNodePrefs(&_prefs); + strcpy(_prefs.node_name, "Room"); + _prefs.advert_loc_policy = ADVERT_LOC_PREFS; // advertise prefs coordinates + /* path_hash_mode = 1 moved into initNodePrefs() -- see RepeaterMesh. */ + _prefs.disable_fwd = 1; // a room server is an endpoint, never repeats + _prefs.gps_interval = CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC; // ZEPHCORE: as the repeater - /* Room server: circular post buffer + round-robin push state */ - next_post_idx = 0; - next_client_idx = 0; - next_push = 0; - _num_posted = _num_post_pushes = 0; - for (int i = 0; i < MAX_UNSYNCED_POSTS; i++) { - posts[i].clear(); - } + /* Room server: circular post buffer + round-robin push state */ + next_post_idx = 0; + next_client_idx = 0; + next_push = 0; + _num_posted = _num_post_pushes = 0; + for (int i = 0; i < MAX_UNSYNCED_POSTS; i++) { + posts[i].clear(); + } } +// ZEPHCORE: ours. Zephyr storage and radio setup. void RoomServerMesh::begin(RepeaterDataStore* store) { - _store = store; + _store = store; - /* Prefs and identity are loaded by the caller (main_room_server.cpp) before - * begin() — the radio reads freq/bw/sf/cr through _prefs during - * Mesh::begin() → Dispatcher::begin() → Radio::begin(). */ - mesh::Mesh::begin(); - _contention.setBackoffMultiplier(_prefs.backoff_multiplier); - acl.load(_store->getAclPath(), self_id); - region_map.load(_store->getRegionsPath()); + /* Prefs and identity are loaded by the caller (main_room_server.cpp) before + * begin() — the radio reads freq/bw/sf/cr through _prefs during + * Mesh::begin() → Dispatcher::begin() → Radio::begin(). */ + mesh::Mesh::begin(); + _contention.setBackoffMultiplier(_prefs.backoff_multiplier); + acl.load(_store->getFS(), self_id); + region_map.load(_store->getFS()); - // establish default-scope from persisted default region (if any) - { - RegionEntry* r = region_map.getDefaultRegion(); - if (r) { - region_map.getTransportKeysFor(*r, &default_scope, 1); - } - } + // establish default-scope from persisted default region (if any) + { + RegionEntry* r = region_map.getDefaultRegion(); + if (r) { + region_map.getTransportKeysFor(*r, &default_scope, 1); + } + } - updateAdvertTimer(); - updateFloodAdvertTimer(); + updateAdvertTimer(); + updateFloodAdvertTimer(); - _board.setAdcMultiplier(_prefs.adc_multiplier); + _board.setAdcMultiplier(_prefs.adc_multiplier); - LOG_INF("RoomServerMesh started: %s (freq=%.2f bw=%.0f sf=%d cr=%d)", - _prefs.node_name, (double)_prefs.freq, (double)_prefs.bw, _prefs.sf, _prefs.cr); + LOG_INF("RoomServerMesh started: %s (freq=%.2f bw=%.0f sf=%d cr=%d)", + _prefs.node_name, (double)_prefs.freq, (double)_prefs.bw, _prefs.sf, _prefs.cr); } -double RoomServerMesh::getNodeLat() const { - struct gps_position pos; - if (gps_get_last_known_position(&pos)) { - return pos.latitude_ndeg / 1e9; - } - return _prefs.node_lat; +void RoomServerMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { + if (scope.isNull()) { + sendFlood(pkt, delay_millis, path_hash_size); + } else { + uint16_t codes[2]; + codes[0] = scope.calcTransportCode(pkt); + codes[1] = 0; // REVISIT: set to 'home' Region, for sender/return region? + sendFlood(pkt, codes, delay_millis, path_hash_size); + } } -double RoomServerMesh::getNodeLon() const { - struct gps_position pos; - if (gps_get_last_known_position(&pos)) { - return pos.longitude_ndeg / 1e9; - } - return _prefs.node_lon; -} +// ZEPHCORE: ours. An explicit unscoped-flood flag instead of isWildcard() (UPSTREAM_TRACKER fad11c90). +void RoomServerMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { + TransportKey req_scope; + bool req_scope_known = recv_pkt_region != nullptr && !recv_pkt_region->isWildcard() + && region_map.getTransportKeysFor(*recv_pkt_region, &req_scope, 1) > 0; -bool RoomServerMesh::setGpsEnabled(bool enabled) { - if (!gps_is_available()) return false; - gps_enable(enabled); - return true; -} - -bool RoomServerMesh::isGpsEnabled() const { - return gps_is_enabled(); -} - -void RoomServerMesh::formatGpsStatsReply(char* reply) { - if (!gps_is_enabled()) { - strcpy(reply, "off"); - return; - } - - struct gps_state_info gsi; - gps_get_state_info(&gsi); - - static const char* const state_str[] = { "off", "standby", "acquiring" }; - const char* state = gsi.state < 3 ? state_str[gsi.state] : "unknown"; - - struct gps_position pos; - bool has_pos = gps_get_last_known_position(&pos); - - if (has_pos) { - snprintf(reply, CLI_REPLY_SIZE, - "on state=%s sats=%u fix=%us ago lat=%.6f lon=%.6f", - state, gsi.satellites, gsi.last_fix_age_s, - pos.latitude_ndeg / 1e9, pos.longitude_ndeg / 1e9); - } else if (gsi.next_search_s > 0) { - snprintf(reply, CLI_REPLY_SIZE, - "on state=%s sats=%u no fix next=%us", - state, gsi.satellites, gsi.next_search_s); - } else { - snprintf(reply, CLI_REPLY_SIZE, - "on state=%s sats=%u no fix", - state, gsi.satellites); - } -} - -void RoomServerMesh::savePrefs() { - if (_store) { - _store->savePrefs(_prefs); - } + switch (mesh::chooseReplyScope(req_scope_known, recv_pkt_unscoped_flood, !default_scope.isNull())) { + case mesh::REPLY_SCOPE_REQUEST: + sendFloodScoped(req_scope, packet, delay_millis, path_hash_size); // same scope as the request + break; + case mesh::REPLY_SCOPE_DEFAULT: + // requester's scope is unknown: a DIRECT request (no transport codes), or a + // code that matched no Region. Un-scoped would be dropped at hop 0 by every + // repeater running flood.max.unscoped=0. + sendFloodScoped(default_scope, packet, delay_millis, path_hash_size); + break; + case mesh::REPLY_SCOPE_NONE: + sendFlood(packet, delay_millis, path_hash_size); // send un-scoped + break; + } } void RoomServerMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { - set_radio_at = futureMillis(2000); - pending_freq = freq; - pending_bw = bw; - pending_sf = sf; - pending_cr = cr; - revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); -} - -void RoomServerMesh::freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { - auto& radio = getRadioDriver(_radio); - if (!radio.hasRadioOverride()) { - /* Holds the radio on the preset it is ALREADY running while _prefs - * move ahead of it, so the learned CAD offset still applies — see - * setRadioOverride(). */ - radio.setRadioOverride(freq, bw, sf, cr, /*visiting_new_preset=*/false); - } + set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params + pending_freq = freq; + pending_bw = bw; + pending_sf = sf; + pending_cr = cr; + + revert_radio_at = futureMillis(2000 + timeout_mins * 60 * 1000); // schedule when to revert radio params } +// ZEPHCORE: ours. Zephyr storage. bool RoomServerMesh::formatFileSystem() { - if (_store) { - return _store->formatFileSystem(); - } - return false; + if (_store) { + return _store->formatFileSystem(); + } + return false; } void RoomServerMesh::sendSelfAdvertisement(int delay_millis, bool flood) { - mesh::Packet* pkt = createSelfAdvert(); - if (pkt) { - if (flood) { - sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); - } else { - sendZeroHop(pkt, delay_millis); - } - } else { - LOG_ERR("Unable to create advertisement packet"); - } + mesh::Packet *pkt = createSelfAdvert(); + if (pkt) { + if (flood) { + sendFloodScoped(default_scope, pkt, delay_millis, _prefs.path_hash_mode + 1); + } else { + sendZeroHop(pkt, delay_millis); + } + } else { + MESH_DEBUG_PRINTLN("ERROR: unable to create advertisement packet!"); + } } void RoomServerMesh::updateAdvertTimer() { - if (_prefs.advert_interval > 0) { - next_local_advert = futureMillis(((uint32_t)_prefs.advert_interval) * 2 * 60 * 1000); - } else { - next_local_advert = 0; - } + if (_prefs.advert_interval > 0) { // schedule local advert timer + next_local_advert = futureMillis((uint32_t)_prefs.advert_interval * 2 * 60 * 1000); + } else { + next_local_advert = 0; // stop the timer + } +} +void RoomServerMesh::updateFloodAdvertTimer() { + if (_prefs.flood_advert_interval > 0) { // schedule flood advert timer + next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); + } else { + next_flood_advert = 0; // stop the timer + } } -void RoomServerMesh::updateFloodAdvertTimer() { - if (_prefs.flood_advert_interval > 0) { - next_flood_advert = futureMillis(((uint32_t)_prefs.flood_advert_interval) * 60 * 60 * 1000); - } else { - next_flood_advert = 0; - } +// ZEPHCORE: ours. No log file. +void RoomServerMesh::dumpLogFile() { + // Logging to file not implemented in Zephyr version + LOG_INF("Log dump not implemented"); +} + +// ZEPHCORE: ours. No runtime TX-power API. +void RoomServerMesh::setTxPower(int8_t power_dbm) { + radio_set_tx_power(power_dbm); +} + +// ZEPHCORE: ours. Through the LoRa adapter. +bool RoomServerMesh::setRxBoostedGain(bool enable) { + return getRadioDriver(_radio).setRxBoost(enable); +} + +// ZEPHCORE: ours. Through RepeaterDataStore. +void RoomServerMesh::saveIdentity(const mesh::LocalIdentity& new_id) { + if (_store) { + _store->saveIdentity(new_id); + } +} + +void RoomServerMesh::startRegionsLoad() { + temp_map.resetFrom(region_map); // rebuild regions in a temp instance + memset(load_stack, 0, sizeof(load_stack)); + load_stack[0] = &temp_map.getWildcard(); + region_load_active = true; +} + +// ZEPHCORE: ours. Through RepeaterDataStore. +bool RoomServerMesh::saveRegions() { + return region_map.save(_store->getFS()); +} + +void RoomServerMesh::onDefaultRegionChanged(const RegionEntry* r) { + if (r) { + region_map.getTransportKeysFor(*r, &default_scope, 1); + } else { + memset(default_scope.key, 0, sizeof(default_scope.key)); + } +} + +// ZEPHCORE: ours. Also clears the radio adapter counters. +void RoomServerMesh::clearStats() { + auto& radio_driver = getRadioDriver(_radio); + radio_driver.resetStats(); + radio_driver.resetDutyCycleTimeoutRestarts(); + resetStats(); + ((SimpleMeshTables *)getTables())->resetStats(); +} + +// ZEPHCORE: ours. Our board handle. +void RoomServerMesh::formatStatsReply(char* reply) { + StatsFormatHelper::formatCoreStats(reply, _board, *_ms, _err_flags, _mgr); +} + +// ZEPHCORE: ours. StatsFormatHelper over the LoRa adapter. +void RoomServerMesh::formatRadioStatsReply(char* reply) { + auto& radio_driver = getRadioDriver(_radio); + StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); +} + +// ZEPHCORE: ours. StatsFormatHelper over the LoRa adapter. +void RoomServerMesh::formatPacketStatsReply(char* reply) { + auto& radio_driver = getRadioDriver(_radio); + StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), + getNumRecvFlood(), getNumRecvDirect()); +} + +// ZEPHCORE: ours. Reply-header budget, blank lines, room.post. +void RoomServerMesh::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { + if (region_load_active) { + handleRegionLoadLine(sender_timestamp, command, reply); + return; + } + + /* Blank line: nothing to run. The USB reader forwards these (see + * cli_rx_work_fn) because `region load` commits on one -- which is + * handled above, before this returns. */ + if (StrHelper::isBlank(command)) { reply[0] = 0; return; } + + while (*command == ' ') command++; + + uint8_t hdr_used = 0; + if (strlen(command) > 4 && command[2] == '|') { + memcpy(reply, command, 3); + reply += 3; + command += 3; + hdr_used = 3; + } + /* Tell the shared CLI how much of the caller's buffer is already spent, so + * the self-limiting handlers do not write past temp[5 + CLI_REMOTE_REPLY_SIZE]. + * Set unconditionally: a stale value would shrink the next reply. */ + _cli.setReplyHeaderUsed(hdr_used); + + // ACL commands - supports BOTH formats for app compatibility: + // Old Arduino: setperm {pubkey-hex} {permissions} (pubkey is long, perms is short) + // MeshCore App: setperm {permissions} {pubkey-hex} (perms is short 2-char hex, pubkey is long) + // Detection: if first part is <= 2 chars, it's permissions; otherwise it's pubkey + if (memcmp(command, "setperm ", 8) == 0) { + char* first = &command[8]; + char* sp = strchr(first, ' '); + if (sp == nullptr) { + strcpy(reply, "Err - bad params"); + } else { + *sp++ = 0; // null terminate first part + char* second = sp; + + // Detect format: if first part is short (1-2 chars), it's permissions + int first_len = strlen(first); + char* hex; + uint8_t perms; + + if (first_len <= 2) { + // App format: setperm {perms} {pubkey} + perms = (uint8_t)strtol(first, nullptr, 16); + hex = second; + } else { + // Arduino format: setperm {pubkey} {perms} + hex = first; + perms = (uint8_t)atoi(second); + } + + uint8_t pubkey[PUB_KEY_SIZE]; + int hex_len = strlen(hex); + if (hex_len > PUB_KEY_SIZE * 2) hex_len = PUB_KEY_SIZE * 2; + if (mesh::Utils::fromHex(pubkey, hex_len / 2, hex)) { + if (acl.applyPermissions(self_id, pubkey, hex_len / 2, perms)) { + if (!dirty_contacts_expiry) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - invalid params"); + } + } else { + strcpy(reply, "Err - bad pubkey"); + } + } + } else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) { + Serial.println("ACL:"); + for (int i = 0; i < acl.getNumClients(); i++) { + auto c = acl.getClientByIdx(i); + if (c->permissions == 0) continue; // skip deleted (or guest) entries + + Serial.printf("%02X ", c->permissions); + mesh::Utils::printHex(Serial, c->id.pub_key, PUB_KEY_SIZE); + Serial.printf("\n"); + } + reply[0] = 0; + } else if (memcmp(command, "room.post", 9) == 0) { + char* msg = command + 9; + while (*msg == ' ') msg++; + if (*msg == 0) { + strcpy(reply, "Err - empty message"); + } else { + addSystemPost(msg); + strcpy(reply, "OK"); + } + } else { + _cli.handleCommand(sender_timestamp, command, reply); + } +} + +bool RoomServerMesh::saveFilter(ClientInfo* client) { + return client->isAdmin(); // only save Admins +} + +// ZEPHCORE: ours. Deadline-driven maintenance. +void RoomServerMesh::loop() { + mesh::Mesh::loop(); + + /* Room server: round-robin push of unsynced posts to logged-in clients. */ + if (millisHasNowPassed(next_push) && acl.getNumClients() > 0) { + /* Expire any in-flight pushes that never got ACKed. */ + for (int i = 0; i < acl.getNumClients(); i++) { + ClientInfo* c = acl.getClientByIdx(i); + if (c->extra.room.pending_ack && millisHasNowPassed(c->extra.room.ack_timeout)) { + c->extra.room.push_failures++; + c->extra.room.pending_ack = 0; + } + } + /* Service one client per tick (round robin). */ + ClientInfo* client = acl.getClientByIdx(next_client_idx); + bool did_push = false; + if (client->extra.room.pending_ack == 0 && client->last_activity != 0 && + client->extra.room.push_failures < 3) { + uint32_t now = getRTCClock()->getCurrentTime(); + for (int k = 0, idx = next_post_idx; k < MAX_UNSYNCED_POSTS; k++) { + PostInfo* p = &posts[idx]; + if (p->post_timestamp != 0 && + now >= p->post_timestamp + POST_SYNC_DELAY_SECS && + p->post_timestamp > client->extra.room.sync_since && + !p->author.matches(client->id)) { + pushPostToClient(client, *p); + did_push = true; + break; + } + idx = (idx + 1) % MAX_UNSYNCED_POSTS; + } + } + next_client_idx = (next_client_idx + 1) % acl.getNumClients(); + next_push = did_push ? futureMillis(SYNC_PUSH_INTERVAL) : futureMillis(SYNC_PUSH_INTERVAL / 8); + } + + if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { + mesh::Packet* pkt = createSelfAdvert(); + if (pkt) sendFloodScoped(default_scope, pkt, (uint32_t)0, _prefs.path_hash_mode + 1); + updateFloodAdvertTimer(); + updateAdvertTimer(); + } else if (next_local_advert && millisHasNowPassed(next_local_advert)) { + mesh::Packet* pkt = createSelfAdvert(); + if (pkt) sendZeroHop(pkt); + updateAdvertTimer(); + } + + if (set_radio_at && millisHasNowPassed(set_radio_at)) { + set_radio_at = 0; + getRadioDriver(_radio).setRadioOverride(pending_freq, pending_bw, pending_sf, pending_cr); + LOG_INF("Temp radio params applied"); + } + + if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { + revert_radio_at = 0; + getRadioDriver(_radio).clearRadioOverride(); + LOG_INF("Radio params restored"); + } + + if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { + acl.save(_store->getFS(), RoomServerMesh::saveFilter); + dirty_contacts_expiry = 0; + } + + timeSyncTick(); + + uint32_t now = k_uptime_get(); + uptime_millis += now - last_millis; + last_millis = now; +} + + +/* ---- ZEPHCORE: methods upstream does not have ---- */ + +double RoomServerMesh::getNodeLat() const { + struct gps_position pos; + if (gps_get_last_known_position(&pos)) { + return pos.latitude_ndeg / 1e9; + } + return _prefs.node_lat; +} + +double RoomServerMesh::getNodeLon() const { + struct gps_position pos; + if (gps_get_last_known_position(&pos)) { + return pos.longitude_ndeg / 1e9; + } + return _prefs.node_lon; +} + +bool RoomServerMesh::setGpsEnabled(bool enabled) { + if (!gps_is_available()) return false; + gps_enable(enabled); + return true; +} + +bool RoomServerMesh::isGpsEnabled() const { + return gps_is_enabled(); +} + +void RoomServerMesh::formatGpsStatsReply(char* reply) { + if (!gps_is_enabled()) { + strcpy(reply, "off"); + return; + } + + struct gps_state_info gsi; + gps_get_state_info(&gsi); + + static const char* const state_str[] = { "off", "standby", "acquiring" }; + const char* state = gsi.state < 3 ? state_str[gsi.state] : "unknown"; + + struct gps_position pos; + bool has_pos = gps_get_last_known_position(&pos); + + if (has_pos) { + snprintf(reply, CLI_REPLY_SIZE, + "on state=%s sats=%u fix=%us ago lat=%.6f lon=%.6f", + state, gsi.satellites, gsi.last_fix_age_s, + pos.latitude_ndeg / 1e9, pos.longitude_ndeg / 1e9); + } else if (gsi.next_search_s > 0) { + snprintf(reply, CLI_REPLY_SIZE, + "on state=%s sats=%u no fix next=%us", + state, gsi.satellites, gsi.next_search_s); + } else { + snprintf(reply, CLI_REPLY_SIZE, + "on state=%s sats=%u no fix", + state, gsi.satellites); + } +} + +void RoomServerMesh::savePrefs() { + if (_store) { + _store->savePrefs(_prefs); + } +} + +void RoomServerMesh::freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { + auto& radio = getRadioDriver(_radio); + if (!radio.hasRadioOverride()) { + /* Holds the radio on the preset it is ALREADY running while _prefs + * move ahead of it, so the learned CAD offset still applies — see + * setRadioOverride(). */ + radio.setRadioOverride(freq, bw, sf, cr, /*visiting_new_preset=*/false); + } } void RoomServerMesh::eraseLogFile() { - // Logging to file not implemented in Zephyr version - LOG_INF("Log erased"); -} - -void RoomServerMesh::dumpLogFile() { - // Logging to file not implemented in Zephyr version - LOG_INF("Log dump not implemented"); -} - -void RoomServerMesh::setTxPower(int8_t power_dbm) { - radio_set_tx_power(power_dbm); -} - -bool RoomServerMesh::setRxBoostedGain(bool enable) { - return getRadioDriver(_radio).setRxBoost(enable); + // Logging to file not implemented in Zephyr version + LOG_INF("Log erased"); } bool RoomServerMesh::setFemRxGain(bool enable) { - return getRadioDriver(_radio).setFemRxEnable(enable); + return getRadioDriver(_radio).setFemRxEnable(enable); } bool RoomServerMesh::configSideDetectors(const uint8_t* sfs, uint8_t num) { - return getRadioDriver(_radio).configSideDetectors(sfs, num); + return getRadioDriver(_radio).configSideDetectors(sfs, num); } /* A room server keeps no neighbour table (it is not a repeater). */ void RoomServerMesh::formatNeighborsReply(char* reply) { - strcpy(reply, "not supported"); -} - -void RoomServerMesh::formatStatsReply(char* reply) { - StatsFormatHelper::formatCoreStats(reply, _board, *_ms, _err_flags, _mgr); -} - -void RoomServerMesh::formatRadioStatsReply(char* reply) { - auto& radio_driver = getRadioDriver(_radio); - StatsFormatHelper::formatRadioStats(reply, _radio, radio_driver, getTotalAirTime(), getReceiveAirTime()); -} - -void RoomServerMesh::formatPacketStatsReply(char* reply) { - auto& radio_driver = getRadioDriver(_radio); - StatsFormatHelper::formatPacketStats(reply, radio_driver, getNumSentFlood(), getNumSentDirect(), - getNumRecvFlood(), getNumRecvDirect()); -} - -void RoomServerMesh::saveIdentity(const mesh::LocalIdentity& new_id) { - if (_store) { - _store->saveIdentity(new_id); - } -} - -void RoomServerMesh::clearStats() { - auto& radio_driver = getRadioDriver(_radio); - radio_driver.resetStats(); - radio_driver.resetDutyCycleTimeoutRestarts(); - resetStats(); - ((SimpleMeshTables *)getTables())->resetStats(); + strcpy(reply, "not supported"); } uint32_t RoomServerMesh::getDutyCycleTimeoutRestarts() const { - return getRadioDriver(_radio).getDutyCycleTimeoutRestarts(); + return getRadioDriver(_radio).getDutyCycleTimeoutRestarts(); } void RoomServerMesh::resetDutyCycleTimeoutRestarts() { - getRadioDriver(_radio).resetDutyCycleTimeoutRestarts(); + getRadioDriver(_radio).resetDutyCycleTimeoutRestarts(); } -/* Region-def CLI (handleRegionLoadLine / handleRegionCommand) and its static - * parser helpers live in app/RoomServerRegionCLI.cpp. */ +/* A continuation line during `region load` (started by startRegionsLoad()). */ +void RoomServerMesh::handleRegionLoadLine(uint32_t sender_timestamp, char* command, char* reply) { + if (StrHelper::isBlank(command)) { + region_map = temp_map; + region_load_active = false; + sprintf(reply, "OK - loaded %d regions", region_map.getCount()); + } else { + char* np = command; + while (*np == ' ') np++; + int indent = np - command; -void RoomServerMesh::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { - if (region_load_active) { - handleRegionLoadLine(sender_timestamp, command, reply); - return; - } + /* An unindented, name-like line is a typed command, not a region row: + * real rows are indent >= 1 (load_stack[0] is the wildcard), and the + * one unindented line a client legitimately sends is the exported + * wildcard header "*", whose '*' is not a name char. Without this, + * `region load` is only escapable by a blank line -- which the USB + * reader discards (main_repeater.cpp) and a dead remote-admin client + * never sends, stranding the CLI until a reboot. Abort without + * committing temp_map and run the command. Must come BEFORE the + * name-terminator write below, which would truncate `set foo 1` to + * `set`. */ + if (indent == 0 && RegionMap::is_name_char((uint8_t)*np)) { + region_load_active = false; + handleCommand(sender_timestamp, command, reply); + return; + } - /* Blank line: nothing to run. The USB reader forwards these (see - * cli_rx_work_fn) because `region load` commits on one -- which is - * handled above, before this returns. */ - if (StrHelper::isBlank(command)) { reply[0] = 0; return; } + char* ep = np; + while (RegionMap::is_name_char(*ep)) ep++; + if (*ep) { *ep++ = 0; } - while (*command == ' ') command++; + while (*ep && *ep != 'F') ep++; - uint8_t hdr_used = 0; - if (strlen(command) > 4 && command[2] == '|') { - memcpy(reply, command, 3); - reply += 3; - command += 3; - hdr_used = 3; - } - /* Tell the shared CLI how much of the caller's buffer is already spent, so - * the self-limiting handlers do not write past temp[5 + CLI_REMOTE_REPLY_SIZE]. - * Set unconditionally: a stale value would shrink the next reply. */ - _cli.setReplyHeaderUsed(hdr_used); - - // ACL commands - supports BOTH formats for app compatibility: - // Old Arduino: setperm {pubkey-hex} {permissions} (pubkey is long, perms is short) - // MeshCore App: setperm {permissions} {pubkey-hex} (perms is short 2-char hex, pubkey is long) - // Detection: if first part is <= 2 chars, it's permissions; otherwise it's pubkey - if (memcmp(command, "setperm ", 8) == 0) { - char* first = &command[8]; - char* sp = strchr(first, ' '); - if (sp == nullptr) { - strcpy(reply, "Err - bad params"); - } else { - *sp++ = 0; // null terminate first part - char* second = sp; - - // Detect format: if first part is short (1-2 chars), it's permissions - int first_len = strlen(first); - char* hex; - uint8_t perms; - - if (first_len <= 2) { - // App format: setperm {perms} {pubkey} - perms = (uint8_t)strtol(first, nullptr, 16); - hex = second; - } else { - // Arduino format: setperm {pubkey} {perms} - hex = first; - perms = (uint8_t)atoi(second); - } - - uint8_t pubkey[PUB_KEY_SIZE]; - int hex_len = strlen(hex); - if (hex_len > PUB_KEY_SIZE * 2) hex_len = PUB_KEY_SIZE * 2; - if (mesh::Utils::fromHex(pubkey, hex_len / 2, hex)) { - if (acl.applyPermissions(self_id, pubkey, hex_len / 2, perms)) { - if (!dirty_contacts_expiry) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Err - invalid params"); - } - } else { - strcpy(reply, "Err - bad pubkey"); - } - } - } else if (sender_timestamp == 0 && strcmp(command, "get acl") == 0) { - LOG_INF("ACL:"); - for (int i = 0; i < acl.getNumClients(); i++) { - auto c = acl.getClientByIdx(i); - if (c->permissions == 0) continue; - char hex[PUB_KEY_SIZE * 2 + 1]; - mesh::Utils::toHex(hex, c->id.pub_key, PUB_KEY_SIZE); - LOG_INF(" %02X %s", c->permissions, hex); - } - reply[0] = 0; - } else if (memcmp(command, "region", 6) == 0) { - handleRegionCommand(command, reply); - } else if (memcmp(command, "room.post", 9) == 0) { - char* msg = command + 9; - while (*msg == ' ') msg++; - if (*msg == 0) { - strcpy(reply, "Err - empty message"); - } else { - addSystemPost(msg); - strcpy(reply, "OK"); - } - } else { - _cli.handleCommand(sender_timestamp, command, reply); - } -} - -void RoomServerMesh::loop() { - mesh::Mesh::loop(); - - /* Room server: round-robin push of unsynced posts to logged-in clients. */ - if (millisHasNowPassed(next_push) && acl.getNumClients() > 0) { - /* Expire any in-flight pushes that never got ACKed. */ - for (int i = 0; i < acl.getNumClients(); i++) { - ClientInfo* c = acl.getClientByIdx(i); - if (c->extra.room.pending_ack && millisHasNowPassed(c->extra.room.ack_timeout)) { - c->extra.room.push_failures++; - c->extra.room.pending_ack = 0; - } - } - /* Service one client per tick (round robin). */ - ClientInfo* client = acl.getClientByIdx(next_client_idx); - bool did_push = false; - if (client->extra.room.pending_ack == 0 && client->last_activity != 0 && - client->extra.room.push_failures < 3) { - uint32_t now = getRTCClock()->getCurrentTime(); - for (int k = 0, idx = next_post_idx; k < MAX_UNSYNCED_POSTS; k++) { - PostInfo* p = &posts[idx]; - if (p->post_timestamp != 0 && - now >= p->post_timestamp + POST_SYNC_DELAY_SECS && - p->post_timestamp > client->extra.room.sync_since && - !p->author.matches(client->id)) { - pushPostToClient(client, *p); - did_push = true; - break; - } - idx = (idx + 1) % MAX_UNSYNCED_POSTS; - } - } - next_client_idx = (next_client_idx + 1) % acl.getNumClients(); - next_push = did_push ? futureMillis(SYNC_PUSH_INTERVAL) : futureMillis(SYNC_PUSH_INTERVAL / 8); - } - - if (next_flood_advert && millisHasNowPassed(next_flood_advert)) { - mesh::Packet* pkt = createSelfAdvert(); - if (pkt) sendFloodScoped(default_scope, pkt, (uint32_t)0, _prefs.path_hash_mode + 1); - updateFloodAdvertTimer(); - updateAdvertTimer(); - } else if (next_local_advert && millisHasNowPassed(next_local_advert)) { - mesh::Packet* pkt = createSelfAdvert(); - if (pkt) sendZeroHop(pkt); - updateAdvertTimer(); - } - - if (set_radio_at && millisHasNowPassed(set_radio_at)) { - set_radio_at = 0; - getRadioDriver(_radio).setRadioOverride(pending_freq, pending_bw, pending_sf, pending_cr); - LOG_INF("Temp radio params applied"); - } - - if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { - revert_radio_at = 0; - getRadioDriver(_radio).clearRadioOverride(); - LOG_INF("Radio params restored"); - } - - if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { - acl.save(_store->getAclPath(), RoomServerMesh::saveFilter); - dirty_contacts_expiry = 0; - } - - timeSyncTick(); - - uint32_t now = k_uptime_get(); - uptime_millis += now - last_millis; - last_millis = now; + if (indent > 0 && indent < 8 && strlen(np) > 0) { + auto parent = load_stack[indent - 1]; + if (parent) { + auto old = region_map.findByName(np); + auto nw = temp_map.putRegion(np, parent->id, old ? old->id : 0); + if (nw) { + nw->flags = old ? old->flags : (*ep == 'F' ? 0 : REGION_DENY_FLOOD); + load_stack[indent] = nw; + } + } + } + reply[0] = 0; + } } void RoomServerMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, - uint32_t timestamp, const uint8_t* app_data, - size_t app_data_len) { - (void)app_data; (void)app_data_len; - /* Signature already verified by mesh::Mesh before this hook fires. - * Skip share rebroadcasts (transport codes {0,0}) — they replay stale - * stored adverts and would churn the original sender's tenure. */ - if (packet->hasTransportCodes() && - packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0) { - return; - } - _timesync.onAdvertHeard(id.pub_key, timestamp, packet->getPathHashCount(), - (uint32_t)(k_uptime_get() / 1000)); + uint32_t timestamp, const uint8_t* app_data, + size_t app_data_len) { + (void)app_data; (void)app_data_len; + /* Signature already verified by mesh::Mesh before this hook fires. + * Skip share rebroadcasts (transport codes {0,0}) — they replay stale + * stored adverts and would churn the original sender's tenure. */ + if (packet->hasTransportCodes() && + packet->transport_codes[0] == 0 && packet->transport_codes[1] == 0) { + return; + } + _timesync.onAdvertHeard(id.pub_key, timestamp, packet->getPathHashCount(), + (uint32_t)(k_uptime_get() / 1000)); } void RoomServerMesh::timeSyncTick() { - if (!_prefs.meshtimesync) return; - if (!_timesync.runTick(*getRTCClock())) return; + if (!_prefs.meshtimesync) return; + if (!_timesync.runTick(*getRTCClock())) return; - /* Step applied (forward-only enforced in runTick) — shift wall-clock- - * anchored bookkeeping with it. */ - int64_t delta = _timesync.lastStepDelta(); - for (int i = 0; i < acl.getNumClients(); i++) { - ClientInfo* c = acl.getClientByIdx(i); - if (c->last_activity == 0) continue; - int64_t shifted = (int64_t)c->last_activity + delta; - c->last_activity = (shifted > 0) ? (uint32_t)shifted : 1; - } - login_fail_limiter.reset(); + /* Step applied (forward-only enforced in runTick) — shift wall-clock- + * anchored bookkeeping with it. */ + int64_t delta = _timesync.lastStepDelta(); + for (int i = 0; i < acl.getNumClients(); i++) { + ClientInfo* c = acl.getClientByIdx(i); + if (c->last_activity == 0) continue; + int64_t shifted = (int64_t)c->last_activity + delta; + c->last_activity = (shifted > 0) ? (uint32_t)shifted : 1; + } + login_fail_limiter.reset(); } -bool RoomServerMesh::hasPendingWork() const { - return _mgr->getOutboundTotal() > 0; +/* When the push engine in loop() next has work. The engine acts only once + * next_push has passed, so this is the later of next_push and the moment some + * client has something for it: a push can go now, an in-flight push reaches + * its ack_timeout, or a post clears POST_SYNC_DELAY_SECS (an RTC-seconds gate). + * Idle when no client has anything, so a room with logged-in clients and no + * new posts costs no wakes. Mirrors the eligibility test in loop(). */ +uint32_t RoomServerMesh::msUntilNextPush() { + uint32_t now = (uint32_t)k_uptime_get(); + uint32_t rtc_now = getRTCClock()->getCurrentTime(); + uint32_t work = mesh::MAINTENANCE_IDLE; + + for (int i = 0; i < acl.getNumClients() && work > 0; i++) { + ClientInfo* c = acl.getClientByIdx(i); + if (c->extra.room.pending_ack) { + work = mesh::maintenanceSooner(work, mesh::maintenanceUntil(now, c->extra.room.ack_timeout)); + continue; + } + if (c->last_activity == 0 || c->extra.room.push_failures >= 3) continue; + for (int k = 0; k < MAX_UNSYNCED_POSTS; k++) { + PostInfo& p = posts[k]; + if (p.post_timestamp == 0 || p.post_timestamp <= c->extra.room.sync_since || + p.author.matches(c->id)) { + continue; + } + uint32_t eligible_at = p.post_timestamp + POST_SYNC_DELAY_SECS; + if (rtc_now >= eligible_at) { + work = 0; + break; + } + work = mesh::maintenanceSooner(work, (eligible_at - rtc_now) * 1000); + } + } + if (work == mesh::MAINTENANCE_IDLE) return work; + + uint32_t gate = mesh::maintenanceUntil(now, next_push); + return (work > gate) ? work : gate; +} + +uint32_t RoomServerMesh::msUntilNextMaintenance() { + uint32_t now = (uint32_t)_ms->getMillis(); + uint32_t next = mesh::Mesh::msUntilNextMaintenance(); + + /* Advert timers. 0 means "disabled" for both. */ + if (next_flood_advert) { + next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, next_flood_advert)); + } + if (next_local_advert) { + next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, next_local_advert)); + } + + /* Temporary radio params: apply, then revert. Both CLI-armed, both 0 when idle. */ + if (set_radio_at) { + next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, set_radio_at)); + } + if (revert_radio_at) { + next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, revert_radio_at)); + } + + /* Deferred ACL write-back. */ + if (dirty_contacts_expiry) { + next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, dirty_contacts_expiry)); + } + + /* Mesh time sync evaluates at most every 15 min, and only when enabled. */ + if (_prefs.meshtimesync) { + next = mesh::maintenanceSooner( + next, _timesync.msUntilNextEval((uint32_t)(k_uptime_get() / 1000))); + } + + return mesh::maintenanceSooner(next, msUntilNextPush()); } diff --git a/zephcore/app/RoomServerMesh.h b/zephcore/app/RoomServerMesh.h index 198de2f..4a2a2c7 100644 --- a/zephcore/app/RoomServerMesh.h +++ b/zephcore/app/RoomServerMesh.h @@ -1,253 +1,261 @@ -/* - * SPDX-License-Identifier: MIT - * RoomServerMesh - LoRa mesh shared-room (BBS) server - * - * Extends mesh::Mesh with: - * - ACL-based client authentication (admin / room / guest) - * - Region-based flood scoping - * - Shared-post buffer + round-robin push/sync to logged-in clients - * - CLI commands (via USB serial) - * - * A room server is an endpoint, not a repeater: it does not forward other - * nodes' traffic and keeps no neighbour / node-discovery state. - */ - #pragma once +//#include #include +// ZEPHCORE: Zephyr platform headers instead of RTClib, target.h and the +// per-platform file systems; storage goes through RepeaterDataStore. #include #include +#include +#include +#include "RepeaterDataStore.h" + +#include #include #include -#include +#include +#include +#include #include #include #include #include -#include -#include -#include "RepeaterDataStore.h" -#ifndef FIRMWARE_VERSION - // Real version injected by CMakeLists.txt (-DFIRMWARE_VERSION); this fallback - // only applies to builds that bypass that injection and should never surface. - #define FIRMWARE_VERSION "v0.0.0-dev" +// ZEPHCORE: the version is injected by CMakeLists.txt; these fallbacks only +// apply to builds that bypass it. +#ifndef FIRMWARE_BUILD_DATE + #define FIRMWARE_BUILD_DATE __DATE__ #endif -#ifndef FIRMWARE_BUILD_DATE - #define FIRMWARE_BUILD_DATE __DATE__ +#ifndef FIRMWARE_VERSION + #define FIRMWARE_VERSION "v0.0.0-dev" +#endif + +// ZEPHCORE: post buffer size from Kconfig. +#if !defined(MAX_UNSYNCED_POSTS) && defined(CONFIG_ZEPHCORE_MAX_UNSYNCED_POSTS) + #define MAX_UNSYNCED_POSTS CONFIG_ZEPHCORE_MAX_UNSYNCED_POSTS +#endif + +#ifndef MAX_UNSYNCED_POSTS + #define MAX_UNSYNCED_POSTS 32 +#endif + +#ifndef SERVER_RESPONSE_DELAY + #define SERVER_RESPONSE_DELAY 300 +#endif + +#ifndef TXT_ACK_DELAY + #define TXT_ACK_DELAY 200 #endif #define FIRMWARE_ROLE "room_server" -#ifndef MAX_UNSYNCED_POSTS - #ifdef CONFIG_ZEPHCORE_MAX_UNSYNCED_POSTS - #define MAX_UNSYNCED_POSTS CONFIG_ZEPHCORE_MAX_UNSYNCED_POSTS - #else - #define MAX_UNSYNCED_POSTS 32 - #endif -#endif +#define PACKET_LOG_FILE "/packet_log" -/* Post text budget: matches upstream MeshCore (160-byte payload minus a - * 9-byte header = 4-byte timestamp + 1-byte type + 4-byte author prefix). */ -#define MAX_POST_TEXT_LEN (160 - 9) +#define MAX_POST_TEXT_LEN (160-9) -/* A single shared-room post held in the server's circular buffer. */ struct PostInfo { - mesh::Identity author; - uint32_t post_timestamp; // by OUR clock - char text[MAX_POST_TEXT_LEN + 1]; + mesh::Identity author; + uint32_t post_timestamp; // by OUR clock + char text[MAX_POST_TEXT_LEN+1]; - void clear() { - author = mesh::Identity(); - post_timestamp = 0; - memset(text, 0, sizeof(text)); - } + // ZEPHCORE: in place of upstream's memset (Identity has a ctor). + void clear() { + author = mesh::Identity(); + post_timestamp = 0; + memset(text, 0, sizeof(text)); + } }; class RoomServerMesh : public mesh::Mesh, public CommonCLICallbacks { - mesh::MainBoard& _board; - RepeaterDataStore* _store; - uint32_t last_millis; - uint64_t uptime_millis; - unsigned long next_local_advert, next_flood_advert; - bool _logging; - NodePrefs _prefs; - ClientACL acl; - CommonCLI _cli; - uint8_t reply_data[MAX_PACKET_PAYLOAD]; - TransportKeyStore key_store; - RegionMap region_map, temp_map; - RegionEntry* load_stack[8]; - RegionEntry* recv_pkt_region; - /* A null recv_pkt_region has two meanings — a DIRECT request (no transport - * codes at all) and an un-scoped flood our wildcard Region denies — and - * sendFloodReply() must treat them differently, so record the route type - * rather than inferring it from the pointer. */ - bool recv_pkt_unscoped_flood; - TransportKey default_scope; - RateLimiter login_fail_limiter; - bool region_load_active; - unsigned long dirty_contacts_expiry; - /* Room server: circular post buffer + round-robin push state */ - unsigned long next_push; - uint16_t _num_posted, _num_post_pushes; - int next_client_idx; - int next_post_idx; - PostInfo posts[MAX_UNSYNCED_POSTS]; - unsigned long set_radio_at, revert_radio_at; - float pending_freq; - float pending_bw; - uint8_t pending_sf; - uint8_t pending_cr; - int matching_peer_indexes[MAX_CLIENTS]; - /* Forward-only: post timestamps feed client sync_since ordering, so a - * backward step would corrupt message sync. */ - MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH, true}; + // ZEPHCORE: our board handle and datastore instead of FILESYSTEM* _fs. + mesh::MainBoard& _board; + RepeaterDataStore* _store; + uint32_t last_millis; + uint64_t uptime_millis; + unsigned long next_local_advert, next_flood_advert; + bool _logging; + // ZEPHCORE: members from here on are in our order, not upstream's (_prefs + // before key_store, the post buffer after the region maps), so the object + // layout, and the proof that this file's code is unchanged, stay intact. + NodePrefs _prefs; + ClientACL acl; + CommonCLI _cli; + uint8_t reply_data[MAX_PACKET_PAYLOAD]; + TransportKeyStore key_store; + RegionMap region_map, temp_map; + RegionEntry* load_stack[8]; + RegionEntry* recv_pkt_region; + // A null recv_pkt_region means either a DIRECT request or an un-scoped flood + // our wildcard denies, and sendFloodReply() treats them differently, so the + // route type is recorded (UPSTREAM_TRACKER fad11c90). + bool recv_pkt_unscoped_flood; + TransportKey default_scope; + RateLimiter login_fail_limiter; // wrong-password attempts (MeshCore#2556) + bool region_load_active; + unsigned long dirty_contacts_expiry; + unsigned long next_push; + uint16_t _num_posted, _num_post_pushes; + int next_client_idx; // for round-robin polling + int next_post_idx; + PostInfo posts[MAX_UNSYNCED_POSTS]; // cyclic queue + unsigned long set_radio_at, revert_radio_at; + float pending_freq; + float pending_bw; + uint8_t pending_sf; + uint8_t pending_cr; + int matching_peer_indexes[MAX_CLIENTS]; + // Forward-only mesh time sync; post timestamps order client sync. + MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH, true}; - int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); - mesh::Packet* createSelfAdvert(); - void timeSyncTick(); + void addPost(ClientInfo* client, const char* postData); + void storePost(const mesh::Identity& author, const char* postData); + void pushPostToClient(ClientInfo* client, PostInfo& post); + uint8_t getUnsyncedCount(ClientInfo* client); + bool processAck(const uint8_t *data); + mesh::Packet* createSelfAdvert(); + int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); - /* Room server: shared-post buffer + push-to-client sync */ - void addPost(ClientInfo* client, const char* postData); - void storePost(const mesh::Identity& author, const char* postData); - void pushPostToClient(ClientInfo* client, PostInfo& post); - uint8_t getUnsyncedCount(ClientInfo* client); - bool processAck(const uint8_t* data); - static bool saveFilter(ClientInfo* client); - - void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); - void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); - - /* Region-definition CLI (defined in app/RoomServerRegionCLI.cpp). - * handleRegionLoadLine: a continuation line during `region load`. - * handleRegionCommand: a `region ...` command. */ - void handleRegionLoadLine(uint32_t sender_timestamp, char* command, char* reply); - void handleRegionCommand(char* command, char* reply); + void timeSyncTick(); // ZEPHCORE + uint32_t msUntilNextPush(); // ZEPHCORE + void handleRegionLoadLine(uint32_t sender_timestamp, char* command, char* reply); // ZEPHCORE protected: - uint8_t getDutyCyclePercent() const override { - /* Arduino formula: duty% = 100 / (af + 1). af=0 → 100%, af=9 → 10%. */ - return (uint8_t)(100.0f / (_prefs.airtime_factor + 1.0f) + 0.5f); - } + // ZEPHCORE: our Dispatcher asks for a duty-cycle percentage. + uint8_t getDutyCyclePercent() const override { + // Arduino formula: duty% = 100 / (af + 1). af=0 -> 100%, af=9 -> 10%. + return (uint8_t)(100.0f / (_prefs.airtime_factor + 1.0f) + 0.5f); + } - bool allowPacketForward(const mesh::Packet* packet) override; - const char* getLogDateTime() override; + void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; + void logRx(mesh::Packet* pkt, int len, float score) override; + void logTx(mesh::Packet* pkt, int len) override; + void logTxFail(mesh::Packet* pkt, int len) override; - void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; - void logRx(mesh::Packet* pkt, int len, float score) override; - void logTx(mesh::Packet* pkt, int len) override; - void logTxFail(mesh::Packet* pkt, int len) override; - uint32_t getRetransmitDelay(const mesh::Packet* packet) override; - uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; + const char* getLogDateTime() override; + uint32_t getRetransmitDelay(const mesh::Packet* packet) override; + uint32_t getDirectRetransmitDelay(const mesh::Packet* packet) override; - int getInterferenceThreshold() const override { - return _prefs.interference_threshold; - } - uint8_t getExtraAckTransmitCount() const override { - return _prefs.multi_acks; - } + int getInterferenceThreshold() const override { + return _prefs.interference_threshold; + } + uint8_t getExtraAckTransmitCount() const override { + return _prefs.multi_acks; + } - /* Adaptive CAD */ - int formatFreqErrorStatus(char* buf, int cap) override { - return _radio->formatFreqErrorStatus(buf, cap); - } - int formatCadStatus(char* buf, int cap) override { - return _radio->formatCadStatus(buf, cap); - } - void applyCadPrefs() override { - _radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset, - _prefs.probe_interval, _prefs.cad_busycap, - _prefs.cad_base); - _prefs.cad_offset = _radio->getCadOffset(); - _prefs.cad_base = _radio->cadBasePeak(); - } - void resetCadStats() override { - _radio->resetCadStats(); - } - void onCadOffsetChanged(int8_t offset) override { - _prefs.cad_offset = offset; - savePrefs(); - } + // ZEPHCORE: adaptive CAD. + int formatFreqErrorStatus(char* buf, int cap) override { + return _radio->formatFreqErrorStatus(buf, cap); + } + int formatCadStatus(char* buf, int cap) override { + return _radio->formatCadStatus(buf, cap); + } + void applyCadPrefs() override { + _radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset, + _prefs.probe_interval, _prefs.cad_busycap, + _prefs.cad_base); + _prefs.cad_offset = _radio->getCadOffset(); + _prefs.cad_base = _radio->cadBasePeak(); + } + void resetCadStats() override { + _radio->resetCadStats(); + } + void onCadOffsetChanged(int8_t offset) override { + _prefs.cad_offset = offset; + savePrefs(); + } - mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; + mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; - void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; - void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; - int searchPeersByHash(const uint8_t* hash) override; - void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; - void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; - bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; - void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; + bool allowPacketForward(const mesh::Packet* packet) override; + void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; + int searchPeersByHash(const uint8_t* hash) override ; + void getPeerSharedSecret(uint8_t* dest_secret, int peer_idx) override; + void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override; + bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override; + void onAckRecv(mesh::Packet* packet, uint32_t ack_crc) override; + // ZEPHCORE: feeds the mesh time sync. + void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; + + void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); public: - RoomServerMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, - mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); + RoomServerMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); - void begin(RepeaterDataStore* store); + void begin(RepeaterDataStore* store); // ZEPHCORE: was begin(FILESYSTEM* fs) + void addSystemPost(const char* postData); - /* Post authored by the server itself (admin "room.post " command). */ - void addSystemPost(const char* postData); + const char* getFirmwareVer() override { return FIRMWARE_VERSION; } + const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } + const char* getRole() override { return FIRMWARE_ROLE; } + const char* getNodeName() { return _prefs.node_name; } + NodePrefs* getNodePrefs() { + return &_prefs; + } - /* CommonCLICallbacks */ - const char* getFirmwareVer() override { return FIRMWARE_VERSION; } - const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } - const char* getRole() override { return FIRMWARE_ROLE; } - double getNodeLat() const override; - double getNodeLon() const override; - bool setGpsEnabled(bool enabled) override; - bool isGpsEnabled() const override; - void formatGpsStatsReply(char* reply) override; - uint32_t getDefaultGpsIntervalSec() const override { return CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC; } - const char* getNodeName() { return _prefs.node_name; } - NodePrefs* getNodePrefs() { return &_prefs; } + void savePrefs() override; // ZEPHCORE: through RepeaterDataStore - void savePrefs() override; - void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; - void freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) override; - bool formatFileSystem() override; - void sendSelfAdvertisement(int delay_millis, bool flood) override; - void updateAdvertTimer() override; - void updateFloodAdvertTimer() override; - void setLoggingOn(bool enable) override { _logging = enable; } - void eraseLogFile() override; - void dumpLogFile() override; - void setTxPower(int8_t power_dbm) override; - bool setRxBoostedGain(bool enable) override; - bool setFemRxGain(bool enable) override; - bool configSideDetectors(const uint8_t* sfs, uint8_t num) override; - void formatNeighborsReply(char* reply) override; - void formatStatsReply(char* reply) override; - void formatRadioStatsReply(char* reply) override; - void formatPacketStatsReply(char* reply) override; + void sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size); - mesh::LocalIdentity& getSelfId() override { return self_id; } - void saveIdentity(const mesh::LocalIdentity& new_id) override; - void clearStats() override; + // CommonCLICallbacks + void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) override; + bool formatFileSystem() override; + void sendSelfAdvertisement(int delay_millis, bool flood) override; + void updateAdvertTimer() override; + void updateFloodAdvertTimer() override; - /* Mesh time sync */ - MeshTimeSync* getMeshTimeSync() override { return &_timesync; } - void noteGPSTimeSync() { _timesync.noteGPSSync((uint32_t)(k_uptime_get() / 1000)); } + void setLoggingOn(bool enable) override { _logging = enable; } - /* Adaptive contention window callbacks */ - float getContentionEstimate() const override { - return getContentionTracker().getContentionEstimate(); - } - float getFloodDelayFactor() const override { - return getContentionTracker().getFloodDelayFactor(); - } - void setBackoffMultiplier(float m) override { - getContentionTracker().setBackoffMultiplier(m); - } + void eraseLogFile() override; // ZEPHCORE: no log file - /* Duty-cycle preamble false-positive stats (SX126x only; - * other radios return 0 from the base class). */ - uint32_t getDutyCycleTimeoutRestarts() const override; - void resetDutyCycleTimeoutRestarts() override; + void dumpLogFile() override; + void setTxPower(int8_t power_dbm) override; + bool setRxBoostedGain(bool enable) override; - void handleCommand(uint32_t sender_timestamp, char* command, char* reply); - void loop(); + void formatNeighborsReply(char *reply) override; // ZEPHCORE: defined in the .cpp + void formatStatsReply(char *reply) override; + void formatRadioStatsReply(char *reply) override; + void formatPacketStatsReply(char *reply) override; + void startRegionsLoad() override; + bool saveRegions() override; + void onDefaultRegionChanged(const RegionEntry* r) override; - bool hasPendingWork() const; + mesh::LocalIdentity& getSelfId() override { return self_id; } + + static bool saveFilter(ClientInfo* client); + + void saveIdentity(const mesh::LocalIdentity& new_id) override; + void clearStats() override; + void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + void loop(); + + // ZEPHCORE: CommonCLICallbacks additions. + double getNodeLat() const override; + double getNodeLon() const override; + bool setGpsEnabled(bool enabled) override; + bool isGpsEnabled() const override; + void formatGpsStatsReply(char* reply) override; + uint32_t getDefaultGpsIntervalSec() const override { return CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC; } + void freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) override; + bool setFemRxGain(bool enable) override; + bool configSideDetectors(const uint8_t* sfs, uint8_t num) override; + MeshTimeSync* getMeshTimeSync() override { return &_timesync; } + void noteGPSTimeSync() { _timesync.noteGPSSync((uint32_t)(k_uptime_get() / 1000)); } + float getContentionEstimate() const override { + return getContentionTracker().getContentionEstimate(); + } + float getFloodDelayFactor() const override { + return getContentionTracker().getFloodDelayFactor(); + } + void setBackoffMultiplier(float m) override { + getContentionTracker().setBackoffMultiplier(m); + } + // Duty-cycle false-preamble re-arms (SX126x only; others return 0). + uint32_t getDutyCycleTimeoutRestarts() const override; + void resetDutyCycleTimeoutRestarts() override; + + // ZEPHCORE: folds this role's own deadlines (adverts, tempradio, ACL flush, + // time sync, the post push engine) into the base maintenance deadline, so the + // event loop arms one wake for loop() and maintenanceLoop(). + uint32_t msUntilNextMaintenance() override; }; diff --git a/zephcore/app/RoomServerRegionCLI.cpp b/zephcore/app/RoomServerRegionCLI.cpp deleted file mode 100644 index db5869b..0000000 --- a/zephcore/app/RoomServerRegionCLI.cpp +++ /dev/null @@ -1,250 +0,0 @@ -/* - * SPDX-License-Identifier: MIT - * RoomServerMesh region-definition CLI — the `region ...` command family and the - * `region load` continuation-line parser. - * - * Split out of RoomServerMesh.cpp for readability. Defines two RoomServerMesh - * methods invoked from RoomServerMesh::handleCommand(); the file-static parser - * helpers below are used only by this command family. - */ - -#include "RoomServerMesh.h" -#include -#include - -#include -#include - -/* ---------- region def helpers ---------- */ -static char* skipSpaces(char* s) { while (*s == ' ') s++; return s; } -static void rtrimSpaces(char* s) { char* e = s + strlen(s); while (e > s && e[-1] == ' ') *--e = '\0'; } -static char* takeToken(char** cursor) { - char* p = skipSpaces(*cursor); - if (*p == '\0') { *cursor = p; return nullptr; } - char* tok = p; - while (*p && *p != ' ') p++; - if (*p) *p++ = '\0'; - *cursor = p; - return tok; -} -static char* splitNameJump(char* tok) { - for (char* q = tok; *q; q++) { - if (*q == '|' || *q == ',') { - *q = '\0'; - char* jump = skipSpaces(q + 1); - rtrimSpaces(jump); - return jump; - } - } - return nullptr; -} -static bool processRegionDefSegment(RegionMap* map, char* tok, RegionEntry** cursor, char* reply) { - char* jump = splitNameJump(tok); - char* name = skipSpaces(tok); - if (*name == '\0') { snprintf(reply, 160, "Err - empty name"); return false; } - if (jump && *jump == '\0') { snprintf(reply, 160, "Err - empty jump"); return false; } - RegionEntry* r = map->putRegion(name, (*cursor)->id); - if (r == NULL) { snprintf(reply, 160, "Err - put failed: %s", name); return false; } - r->flags = 0; - if (jump) { - RegionEntry* j = map->findByNamePrefix(jump); - if (j == NULL) { snprintf(reply, 160, "Err - unknown jump: %s", jump); return false; } - *cursor = j; - } else { - *cursor = r; - } - return true; -} -/* ---------------------------------------- */ - -void RoomServerMesh::handleRegionLoadLine(uint32_t sender_timestamp, char* command, char* reply) { - if (StrHelper::isBlank(command)) { - region_map = temp_map; - region_load_active = false; - sprintf(reply, "OK - loaded %d regions", region_map.getCount()); - } else { - char* np = command; - while (*np == ' ') np++; - int indent = np - command; - - /* An unindented, name-like line is a typed command, not a region row: - * real rows are indent >= 1 (load_stack[0] is the wildcard), and the - * one unindented line a client legitimately sends is the exported - * wildcard header "*", whose '*' is not a name char. Without this, - * `region load` is only escapable by a blank line -- which the USB - * reader discards (main_repeater.cpp) and a dead remote-admin client - * never sends, stranding the CLI until a reboot. Abort without - * committing temp_map and run the command. Must come BEFORE the - * name-terminator write below, which would truncate `set foo 1` to - * `set`. */ - if (indent == 0 && RegionMap::is_name_char((uint8_t)*np)) { - region_load_active = false; - handleCommand(sender_timestamp, command, reply); - return; - } - - char* ep = np; - while (RegionMap::is_name_char(*ep)) ep++; - if (*ep) { *ep++ = 0; } - - while (*ep && *ep != 'F') ep++; - - if (indent > 0 && indent < 8 && strlen(np) > 0) { - auto parent = load_stack[indent - 1]; - if (parent) { - auto old = region_map.findByName(np); - auto nw = temp_map.putRegion(np, parent->id, old ? old->id : 0); - if (nw) { - nw->flags = old ? old->flags : (*ep == 'F' ? 0 : REGION_DENY_FLOOD); - load_stack[indent] = nw; - } - } - } - reply[0] = 0; - } -} - -void RoomServerMesh::handleRegionCommand(char* command, char* reply) { - reply[0] = 0; - - // `region def`: cursor-walk bulk region builder — must run before parseTextParts - // mutates and truncates the buffer to 4 segments. - char* cmd = skipSpaces(command); - if (strncmp(cmd, "region def", 10) == 0 && (cmd[10] == ' ' || cmd[10] == '\0')) { - char* payload = skipSpaces(cmd + 10); - rtrimSpaces(payload); - if (*payload == '\0') { snprintf(reply, 160, "Err - empty def"); goto region_done; } - RegionEntry* cursor = ®ion_map.getWildcard(); - for (char* tok; (tok = takeToken(&payload)) != nullptr; ) { - if (!processRegionDefSegment(®ion_map, tok, &cursor, reply)) goto region_done; - } - region_map.exportTo(reply, 160); - goto region_done; - } - - { - const char* parts[4]; - int n = mesh::Utils::parseTextParts(command, parts, 4, ' '); - - if (n == 1) { - region_map.exportTo(reply, 160); - } else if (n >= 2 && strcmp(parts[1], "load") == 0) { - temp_map.resetFrom(region_map); - memset(load_stack, 0, sizeof(load_stack)); - load_stack[0] = &temp_map.getWildcard(); - region_load_active = true; - } else if (n >= 2 && strcmp(parts[1], "save") == 0) { - _prefs.discovery_mod_timestamp = getRTCClock()->getCurrentTime(); - savePrefs(); - bool success = region_map.save(_store->getRegionsPath()); - strcpy(reply, success ? "OK" : "Err - save failed"); - } else if (n >= 3 && strcmp(parts[1], "allowf") == 0) { - auto region = region_map.findByNamePrefix(parts[2]); - if (region) { - region->flags &= ~REGION_DENY_FLOOD; - strcpy(reply, "OK"); - } else { - strcpy(reply, "Err - unknown region"); - } - } else if (n >= 3 && strcmp(parts[1], "denyf") == 0) { - auto region = region_map.findByNamePrefix(parts[2]); - if (region) { - region->flags |= REGION_DENY_FLOOD; - strcpy(reply, "OK"); - } else { - strcpy(reply, "Err - unknown region"); - } - } else if (n >= 3 && strcmp(parts[1], "get") == 0) { - auto region = region_map.findByNamePrefix(parts[2]); - if (region) { - auto parent = region_map.findById(region->parent); - if (parent && parent->id != 0) { - sprintf(reply, " %s (%s) %s", region->name, parent->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F"); - } else { - sprintf(reply, " %s %s", region->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F"); - } - } else { - strcpy(reply, "Err - unknown region"); - } - } else if (n >= 3 && strcmp(parts[1], "home") == 0) { - auto home = region_map.findByNamePrefix(parts[2]); - if (home) { - region_map.setHomeRegion(home); - sprintf(reply, " home is now %s", home->name); - } else { - strcpy(reply, "Err - unknown region"); - } - } else if (n == 2 && strcmp(parts[1], "home") == 0) { - auto home = region_map.getHomeRegion(); - sprintf(reply, " home is %s", home ? home->name : "*"); - } else if (n >= 3 && strcmp(parts[1], "default") == 0) { - if (strcmp(parts[2], "") == 0) { - region_map.setDefaultRegion(nullptr); - memset(default_scope.key, 0, sizeof(default_scope.key)); - region_map.save(_store->getRegionsPath()); // persist in one atomic step - sprintf(reply, " default scope is now "); - } else { - auto def = region_map.findByNamePrefix(parts[2]); - if (def == nullptr) { - def = region_map.putRegion(parts[2], 0); // auto-create the default region - } - if (def) { - def->flags = 0; // make sure allow flood enabled - region_map.setDefaultRegion(def); - region_map.getTransportKeysFor(*def, &default_scope, 1); - region_map.save(_store->getRegionsPath()); // persist in one atomic step - sprintf(reply, " default scope is now %s", def->name); - } else { - strcpy(reply, "Err - region table full"); - } - } - } else if (n == 2 && strcmp(parts[1], "default") == 0) { - auto def = region_map.getDefaultRegion(); - sprintf(reply, " default scope is %s", def ? def->name : ""); - } else if (n >= 3 && strcmp(parts[1], "put") == 0) { - auto parent = n >= 4 ? region_map.findByNamePrefix(parts[3]) : ®ion_map.getWildcard(); - if (parent == nullptr) { - strcpy(reply, "Err - unknown parent"); - } else { - auto region = region_map.putRegion(parts[2], parent->id); - if (region == nullptr) { - strcpy(reply, "Err - unable to put"); - } else { - region->flags = 0; // New default: enable flood - strcpy(reply, "OK - (flood allowed)"); - } - } - } else if (n >= 3 && strcmp(parts[1], "remove") == 0) { - auto region = region_map.findByName(parts[2]); - if (region) { - if (region_map.removeRegion(*region)) { - strcpy(reply, "OK"); - } else { - strcpy(reply, "Err - not empty"); - } - } else { - strcpy(reply, "Err - not found"); - } - } else if (n >= 3 && strcmp(parts[1], "list") == 0) { - uint8_t mask = 0; - bool invert = false; - if (strcmp(parts[2], "allowed") == 0) { - mask = REGION_DENY_FLOOD; - invert = false; - } else if (strcmp(parts[2], "denied") == 0) { - mask = REGION_DENY_FLOOD; - invert = true; - } else { - strcpy(reply, "Err - use 'allowed' or 'denied'"); - return; - } - int len = region_map.exportNamesTo(reply, 160, mask, invert); - if (len == 0) { - strcpy(reply, "-none-"); - } - } else { - strcpy(reply, "Err - ??"); - } - } // end parseTextParts scope - region_done:; -} diff --git a/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index 8922b68..f797223 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -40,6 +40,7 @@ LOG_MODULE_REGISTER(zephcore_observer_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include "observer_creds.h" #include +#include /* ========== LED (optional) ========== */ @@ -51,10 +52,9 @@ static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios); /* ========== Event loop bits ========== */ -#define MESH_EVENT_LORA_RX BIT(0) -#define MESH_EVENT_CLI_RX BIT(1) -#define MESH_EVENT_STATUS BIT(2) -#define MESH_EVENT_MQTT_CONNECT BIT(3) /* MQTT (re)connected — publish status+self-advert on main */ +/* The shared bits are in mesh_events.h; these are the observer's own. */ +#define MESH_EVENT_STATUS BIT(MESH_EVENT_ROLE_BASE) /* 300 s status publish */ +#define MESH_EVENT_MQTT_CONNECT BIT(MESH_EVENT_ROLE_BASE + 1) /* MQTT (re)connected — publish status+self-advert on main */ #define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_CLI_RX | MESH_EVENT_STATUS | MESH_EVENT_MQTT_CONNECT) static struct k_event mesh_events; @@ -354,8 +354,8 @@ int main(void) NodePrefs *prefs = observer_mesh.getNodePrefs(); /* "Observer" is deliberately NOT in this list: it is a name a user can set, * and regenerating it here made `set name Observer` silently revert on every - * reboot. "Repeater" is the name the shared store writes on first boot, so - * it still counts as unset. */ + * reboot. "Repeater" is the name the shared store wrote on first boot + * until 2026-09, so it still counts as unset. */ if (strlen(prefs->node_name) == 0 || strcmp(prefs->node_name, "Repeater") == 0) { /* Use first 4 bytes of pubkey for uniqueness */ @@ -392,7 +392,7 @@ int main(void) print_banner(); /* Start WiFi (non-blocking — MQTT thread waits for WIFI_READY_BIT) */ - zc_wifi_station_start(&s_creds, time_sync_cb); + zc_wifi_station_start(s_creds.wifi_ssid, s_creds.wifi_psk, time_sync_cb); /* Start MQTT publisher thread */ char client_id[64]; diff --git a/zephcore/boards/common/debug.conf b/zephcore/boards/common/debug.conf index 110108d..f761567 100644 --- a/zephcore/boards/common/debug.conf +++ b/zephcore/boards/common/debug.conf @@ -59,5 +59,8 @@ CONFIG_LOG_PROCESS_THREAD_STACK_SIZE=2048 # retention list produces. Two SPI reads per received packet, so debug only. CONFIG_ZEPHCORE_LORA_RETENTION_DEBUG=y +# Event-loop wake counter: `get loop.wakes`. +CONFIG_ZEPHCORE_LOOP_WAKE_STATS=y + # Silence noisy subsystems CONFIG_CFB_LOG_LEVEL_ERR=y diff --git a/zephcore/boards/common/esp32_common.conf b/zephcore/boards/common/esp32_common.conf index c97ec50..61a6e91 100644 --- a/zephcore/boards/common/esp32_common.conf +++ b/zephcore/boards/common/esp32_common.conf @@ -68,7 +68,18 @@ CONFIG_BT_PRIVACY=y # Zephyr 2026-Q1 added a synchronous sc_store() on the RX WQ in the # identity-resolved path (commit 2c6c80ddf65) which pushes the LittleFS # settings write onto the SMP callback chain. Default 2048 overflowed. -CONFIG_BT_TX_PROCESSOR_STACK_SIZE=4096 +# The RX WQ keeps 4096: that path (a phone pairing with a resolvable private +# address) is not reproducible on the bench. +# +# TX processor: measured 2026-09-24 on a XIAO ESP32-S3 companion with WiFi +# (CONFIG_ZEPHCORE_MEM_STATS, devdocs/tools/mem_load.py: fresh pairing, bonded +# reconnects, protocol golden over BLE and TCP, bulk contact dump), peak 724 B, +# so 2048 (2.8x). Was 4096. +# The controller task is not trimmable here: on S3/C3 the HAL hardcodes its +# stack to 4096 (components/bt/controller/esp32c3/bt.c) and +# ESP32_BT_CONTROLLER_STACK_SIZE is only a lower-bound check; on classic ESP32 +# it is the real size but that controller (BTDM) was not measured. +CONFIG_BT_TX_PROCESSOR_STACK_SIZE=2048 CONFIG_BT_RX_STACK_SIZE=4096 # Stack-overflow detection — xtensa has no MPU stack guard in Zephyr, so diff --git a/zephcore/boards/common/serial_companion.conf b/zephcore/boards/common/serial_companion.conf index b0bb594..6a1a095 100644 --- a/zephcore/boards/common/serial_companion.conf +++ b/zephcore/boards/common/serial_companion.conf @@ -11,8 +11,8 @@ # -DEXTRA_CONF_FILE="boards/common/serial_companion.conf" # # BLE stays on (default for the companion role), so this is the full hybrid: -# BLE and the UART companion arbitrate first-come-first-served, and a human -# typing over the same UART gets the text CLI. +# BLE and the UART companion are both served (MultiSerialInterface), and a +# human typing over the same UART gets the text CLI. CONFIG_ZEPHCORE_COMPANION_SERIAL=y diff --git a/zephcore/boards/common/wifi_companion.conf b/zephcore/boards/common/wifi_companion.conf new file mode 100644 index 0000000..7abfc05 --- /dev/null +++ b/zephcore/boards/common/wifi_companion.conf @@ -0,0 +1,35 @@ +# WiFi companion: the app reaches the node over TCP on the saved network +# (set wifi.ssid / wifi.pwd, reboot), beside BLE and USB. +# +# Added by CMake to companion builds of boards whose zephcore.yml declares +# `capabilities: wifi: true`; pass it in EXTRA_CONF_FILE to try another board. +# On a PSRAM board the WiFi heap and the network buffers go to PSRAM (see +# ZEPHCORE_COMPANION_WIFI in Kconfig). + +CONFIG_ZEPHCORE_COMPANION_WIFI=y + +# IPv4 station: DHCP for the address, TCP for the app. No DNS, no SNTP (the +# app sets the clock), no TLS. +CONFIG_NETWORKING=y +CONFIG_NET_IPV4=y +CONFIG_NET_IPV6=n +CONFIG_NET_TCP=y +CONFIG_NET_UDP=y +CONFIG_NET_DHCPV4=y +CONFIG_NET_SOCKETS=y +CONFIG_NET_MAX_CONTEXTS=6 +CONFIG_NET_MGMT=y +CONFIG_NET_MGMT_EVENT=y +CONFIG_NET_MGMT_EVENT_INFO=y +CONFIG_NET_L2_ETHERNET=y +CONFIG_WIFI=y +CONFIG_NET_L2_WIFI_MGMT=y + +# System heap: 72 KB. On a PSRAM board the Kconfig ADD_SIZE sum is only ~55 KB +# (the WiFi heap itself is in PSRAM), and measured 2026-09-24 on a XIAO ESP32-S3 +# the companion idles at 50 KB and peaks at 54.1 KB under load (BLE pairing, +# protocol golden over BLE and TCP, bulk dump), before any WiFi traffic burst; +# the WiFi driver's DMA buffers still come from here. Zephyr uses the larger of +# this and the ADD_SIZE sum, so no-PSRAM boards (sum ~81 KB) keep theirs unless +# Kconfig sets HEAP_MEM_POOL_IGNORE_MIN for them (ESP32-S3 without PSRAM). +CONFIG_HEAP_MEM_POOL_SIZE=73728 diff --git a/zephcore/boards/common/zephcore_common.conf b/zephcore/boards/common/zephcore_common.conf index 5370f51..fd5e03f 100644 --- a/zephcore/boards/common/zephcore_common.conf +++ b/zephcore/boards/common/zephcore_common.conf @@ -6,7 +6,7 @@ # contain overrides and additions unique to that platform. # ========== Core Settings ========== -CONFIG_MAIN_STACK_SIZE=8192 +# CONFIG_MAIN_STACK_SIZE: per role, set in Kconfig (companion 6144, servers 8192). # System workqueue: measured 2026-09-19 (CONFIG_ZEPHCORE_MEM_STATS, Zephyr main # 3e8f38faf93) at peak 808 B on nRF52 (Wio L1 SX1262 + T1000-E LR1110, # companion and repeater, phone BLE session with contact sync, TX/RX + flood diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf index 6c633d4..e75c74c 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf @@ -47,3 +47,10 @@ CONFIG_POWER_DOMAIN=n # board.overlay): the default 500ms double-press confirm window (shutdown/ # DFU/offgrid) is too short for two ~1s holds, see Kconfig help text. CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 + +# WiFi + BLE companion without PSRAM (`capabilities: wifi` in zephcore.yml): +# contacts are what gives. 140 is the largest count that links with ~2 KB DRAM +# to spare (188 B per contact; measured 2026-09-24, see +# devdocs/lld/08-connectivity.md §14). The offline queue stays at 256. An +# existing contacts file holding more is truncated to 140 on first load. +CONFIG_ZEPHCORE_MAX_CONTACTS=140 diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml b/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml index 2ece04c..5471959 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml @@ -1,6 +1,7 @@ # ZephCore board manifest — schema in zephcore/scripts/board_manifest.py target: heltec_wifi_lora32_v3/esp32s3/procpu capabilities: + wifi: true light_sleep: true release: roles: [companion, repeater] diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml b/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml index ae2c438..4e55c95 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml @@ -1,6 +1,7 @@ # ZephCore board manifest — schema in zephcore/scripts/board_manifest.py target: heltec_wifi_lora32_v4/esp32s3/procpu capabilities: + wifi: true light_sleep: true release: roles: [companion, repeater] diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml b/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml index 8ba02e1..9fd6950 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml @@ -1,6 +1,7 @@ # ZephCore board manifest — schema in zephcore/scripts/board_manifest.py target: heltec_wifi_lora32_v43/esp32s3/procpu capabilities: + wifi: true light_sleep: true release: roles: [companion, repeater] diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/zephcore.yml b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/zephcore.yml index 1cd878d..19a739a 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/zephcore.yml @@ -1,5 +1,7 @@ # ZephCore board manifest — schema in zephcore/scripts/board_manifest.py target: heltec_wifi_lora32_v4_r8/esp32s3/procpu +capabilities: + wifi: true # No light_sleep capability on purpose: the wiring qualifies (DIO1=GPIO14, # NSS=GPIO8, RESET=GPIO12, button on gpio0) but CONFIG_PM was never validated on # this board. Declare it once pm_esp32.conf's three checks pass on hardware. diff --git a/zephcore/boards/esp32/heltec_wireless_tracker/board.conf b/zephcore/boards/esp32/heltec_wireless_tracker/board.conf index d5bae2b..0341046 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker/board.conf +++ b/zephcore/boards/esp32/heltec_wireless_tracker/board.conf @@ -30,3 +30,10 @@ CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y # board.overlay): the default 500ms double-press confirm window (shutdown/ # DFU/offgrid) is too short for two ~1s holds, see Kconfig help text. CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 + +# WiFi + BLE companion without PSRAM (`capabilities: wifi` in zephcore.yml): +# contacts are what gives. 130 is the largest count that links with ~2 KB DRAM +# to spare (188 B per contact; measured 2026-09-24, see +# devdocs/lld/08-connectivity.md §14). The offline queue stays at 256. An +# existing contacts file holding more is truncated to 130 on first load. +CONFIG_ZEPHCORE_MAX_CONTACTS=130 diff --git a/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml b/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml index 3b94055..e108f00 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml @@ -1,6 +1,7 @@ # ZephCore board manifest — schema in zephcore/scripts/board_manifest.py target: heltec_wireless_tracker/esp32s3/procpu capabilities: + wifi: true light_sleep: true release: roles: [companion, repeater] diff --git a/zephcore/boards/esp32/lilygo_t3s3/zephcore.yml b/zephcore/boards/esp32/lilygo_t3s3/zephcore.yml index d427b14..6a5c914 100644 --- a/zephcore/boards/esp32/lilygo_t3s3/zephcore.yml +++ b/zephcore/boards/esp32/lilygo_t3s3/zephcore.yml @@ -1,5 +1,7 @@ # ZephCore board manifest — schema in zephcore/scripts/board_manifest.py target: lilygo_t3s3/esp32s3/procpu +capabilities: + wifi: true release: roles: [companion, repeater] # Catalog: MeshCore splits this board by radio -- "(SX126x)" and "(SX127x)" are diff --git a/zephcore/boards/esp32/lilygo_tlora_c6/board.conf b/zephcore/boards/esp32/lilygo_tlora_c6/board.conf index 111c675..5fe05de 100644 --- a/zephcore/boards/esp32/lilygo_tlora_c6/board.conf +++ b/zephcore/boards/esp32/lilygo_tlora_c6/board.conf @@ -15,3 +15,9 @@ CONFIG_ZEPHCORE_BOARD_NAME="LilyGo TLoRa C6" # Device Information Service model name CONFIG_BT_DIS_MODEL_NUMBER_STR="LilyGo TLoRa C6" +# WiFi + BLE companion without PSRAM (`capabilities: wifi` in zephcore.yml): +# contacts are what gives. 280 is the largest count that links with ~2 KB DRAM +# to spare (188 B per contact; measured 2026-09-24, see +# devdocs/lld/08-connectivity.md §14). The offline queue stays at 256. An +# existing contacts file holding more is truncated to 280 on first load. +CONFIG_ZEPHCORE_MAX_CONTACTS=280 diff --git a/zephcore/boards/esp32/lilygo_tlora_c6/zephcore.yml b/zephcore/boards/esp32/lilygo_tlora_c6/zephcore.yml index 4cb9545..37c4dc5 100644 --- a/zephcore/boards/esp32/lilygo_tlora_c6/zephcore.yml +++ b/zephcore/boards/esp32/lilygo_tlora_c6/zephcore.yml @@ -1,5 +1,7 @@ # ZephCore board manifest — schema in zephcore/scripts/board_manifest.py target: lilygo_tlora_c6/esp32c6/hpcore +capabilities: + wifi: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/meshnology_w12/zephcore.yml b/zephcore/boards/esp32/meshnology_w12/zephcore.yml index 8fbd72a..f1c7f8d 100644 --- a/zephcore/boards/esp32/meshnology_w12/zephcore.yml +++ b/zephcore/boards/esp32/meshnology_w12/zephcore.yml @@ -1,5 +1,7 @@ # ZephCore board manifest — schema in zephcore/scripts/board_manifest.py target: meshnology_w12/esp32s3/procpu +capabilities: + wifi: true release: roles: [companion, repeater] # Catalog: MeshCore firmware has a meshnology_w12 variant but their flasher config diff --git a/zephcore/boards/esp32/station_g2/zephcore.yml b/zephcore/boards/esp32/station_g2/zephcore.yml index 744fd22..de8eafa 100644 --- a/zephcore/boards/esp32/station_g2/zephcore.yml +++ b/zephcore/boards/esp32/station_g2/zephcore.yml @@ -1,5 +1,7 @@ # ZephCore board manifest — schema in zephcore/scripts/board_manifest.py target: station_g2/esp32s3/procpu +capabilities: + wifi: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/thinknode_m9/zephcore.yml b/zephcore/boards/esp32/thinknode_m9/zephcore.yml index 2b08ebc..f37a708 100644 --- a/zephcore/boards/esp32/thinknode_m9/zephcore.yml +++ b/zephcore/boards/esp32/thinknode_m9/zephcore.yml @@ -4,3 +4,5 @@ # release: {roles: [companion, repeater]} # catalog: {device: "Elecrow ThinkNode M9", maker: elecrow} target: thinknode_m9/esp32s3/procpu +capabilities: + wifi: true diff --git a/zephcore/boards/esp32/xiao_esp32c6/board.conf b/zephcore/boards/esp32/xiao_esp32c6/board.conf index 1bd0d2e..34ca023 100644 --- a/zephcore/boards/esp32/xiao_esp32c6/board.conf +++ b/zephcore/boards/esp32/xiao_esp32c6/board.conf @@ -15,3 +15,9 @@ CONFIG_ZEPHCORE_BOARD_NAME="XIAO ESP32-C6" # Device Information Service model name CONFIG_BT_DIS_MODEL_NUMBER_STR="XIAO ESP32-C6" +# WiFi + BLE companion without PSRAM (`capabilities: wifi` in zephcore.yml): +# contacts are what gives. 280 is the largest count that links with ~2 KB DRAM +# to spare (188 B per contact; measured 2026-09-24, see +# devdocs/lld/08-connectivity.md §14). The offline queue stays at 256. An +# existing contacts file holding more is truncated to 280 on first load. +CONFIG_ZEPHCORE_MAX_CONTACTS=280 diff --git a/zephcore/boards/esp32/xiao_esp32c6/zephcore.yml b/zephcore/boards/esp32/xiao_esp32c6/zephcore.yml index f87715a..f03e488 100644 --- a/zephcore/boards/esp32/xiao_esp32c6/zephcore.yml +++ b/zephcore/boards/esp32/xiao_esp32c6/zephcore.yml @@ -1,5 +1,7 @@ # ZephCore board manifest — schema in zephcore/scripts/board_manifest.py target: xiao_esp32c6/esp32c6/hpcore +capabilities: + wifi: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml b/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml index 32f29e6..0391df8 100644 --- a/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml +++ b/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml @@ -1,5 +1,7 @@ # ZephCore board manifest — schema in zephcore/scripts/board_manifest.py target: xiao_esp32s3/esp32s3/procpu +capabilities: + wifi: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 727b9b4..ea4b2cd 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -198,9 +198,11 @@ STM32WL caveats — different from every other ZephCore platform: `CONFIG_BT=n`. The console/CLI and the companion protocol both run over **USART1**, bridged to USB-C by the onboard USB-UART chip. - **Repeater** uses the USART CLI (add `repeater.conf`). The **companion** speaks - MeshCore serial framing over the same UART via `SerialCompanionTransport.c` - (a drop-in `zephcore_ble_*` provider, auto-selected because `CONFIG_BT=n`) — - no BLE pairing, the official serial client connects directly. + MeshCore serial framing over the same UART through the wired companion + transport (`ZephyrCompanionUSB.cpp`, UART backend: the board's + `zephcore,companion-uart` chosen node selects `CONFIG_ZEPHCORE_COMPANION_SERIAL` + when there is no Bluetooth) — no BLE pairing, the official serial client + connects directly, and a terminal gets the text CLI. - **RAM-bound, not flash-bound:** 64KB SRAM. The companion's contact/queue arrays are capped hard in `board.conf` (`MAX_CONTACTS=24`, `OFFLINE_QUEUE_SIZE=8`). AES tables live in ROM (`MBEDTLS_AES_ROM_TABLES`) to reclaim ~8KB SRAM. diff --git a/zephcore/boards/linux_native/linux_common.conf b/zephcore/boards/linux_native/linux_common.conf index cf39ce4..47e3250 100644 --- a/zephcore/boards/linux_native/linux_common.conf +++ b/zephcore/boards/linux_native/linux_common.conf @@ -32,7 +32,7 @@ CONFIG_GPIO_NATIVE_LINUX=y # ========== TCP companion transport ========== CONFIG_ZEPHCORE_TRANSPORT_TCP=y -CONFIG_ZEPHCORE_LINUX_TCP_PORT=5000 +CONFIG_ZEPHCORE_TCP_PORT=5000 # NSOS: native_sim socket offload — Zephyr zsock_* calls go straight to # host Linux sockets. No native IP stack, no TAP device, no root needed. diff --git a/zephcore/boards/stm32wl/lora_e5_mini/board.overlay b/zephcore/boards/stm32wl/lora_e5_mini/board.overlay index f18e615..9a861c8 100644 --- a/zephcore/boards/stm32wl/lora_e5_mini/board.overlay +++ b/zephcore/boards/stm32wl/lora_e5_mini/board.overlay @@ -66,3 +66,12 @@ /* LittleFS auto-mount at /lfs (defines the &lfs fstab node) */ #include "../../common/filesystem.dtsi" + +/* The companion link: the app talks to the node over USART1, bridged to USB-C + * by the onboard USB-UART chip (no Bluetooth, no native USB). Selects + * CONFIG_ZEPHCORE_COMPANION_SERIAL. */ +/ { + chosen { + zephcore,companion-uart = &usart1; + }; +}; diff --git a/zephcore/cmake/zephcore_board.cmake b/zephcore/cmake/zephcore_board.cmake index 4a1b6e2..141bd95 100644 --- a/zephcore/cmake/zephcore_board.cmake +++ b/zephcore/cmake/zephcore_board.cmake @@ -10,6 +10,7 @@ # ZEPHCORE_BOARD_DIR boards//, or "" if none # ZEPHCORE_PLATFORM nrf52 | nrf54l | esp32 | mg24 | stm32wl | linux | "" # ZEPHCORE_BOARD_LIGHT_SLEEP TRUE if the board manifest declares it +# ZEPHCORE_BOARD_WIFI TRUE if the board manifest declares it # zephcore_board_file( ) # absolute path of inside the board dir, or "". @@ -82,10 +83,15 @@ endif() # this runs before Zephyr has set up Python; `board_manifest.py check` enforces # the exact two-space-indented ` : true|false` form this relies on. set(ZEPHCORE_BOARD_LIGHT_SLEEP FALSE) +set(ZEPHCORE_BOARD_WIFI FALSE) zephcore_board_file(_zb_manifest zephcore.yml) if(_zb_manifest) file(STRINGS "${_zb_manifest}" _zb_ls REGEX "^ light_sleep: true[ \t]*$") if(_zb_ls) set(ZEPHCORE_BOARD_LIGHT_SLEEP TRUE) endif() + file(STRINGS "${_zb_manifest}" _zb_wifi REGEX "^ wifi: true[ \t]*$") + if(_zb_wifi) + set(ZEPHCORE_BOARD_WIFI TRUE) + endif() endif() diff --git a/zephcore/cmake/zephyr_patches.cmake b/zephcore/cmake/zephyr_patches.cmake index 9ed3cca..bf994f2 100644 --- a/zephcore/cmake/zephyr_patches.cmake +++ b/zephcore/cmake/zephyr_patches.cmake @@ -238,3 +238,13 @@ if(EXISTS ${ZEPHCORE_SOURCE_DIR}/patches/modules/loramac-node) "loramac-node" ) endif() + +# Apply patches to hal_espressif module +if(EXISTS ${ZEPHCORE_SOURCE_DIR}/patches/modules/hal_espressif) + message(STATUS "Applying ZephCore patches to hal_espressif...") + zephcore_apply_patches( + "${ZEPHCORE_SOURCE_DIR}/patches/modules/hal_espressif" + "${ZEPHCORE_MODULES_DIR}/hal/espressif" + "hal_espressif" + ) +endif() diff --git a/zephcore/helpers/BaseSerialInterface.h b/zephcore/helpers/BaseSerialInterface.h new file mode 100644 index 0000000..23933fc --- /dev/null +++ b/zephcore/helpers/BaseSerialInterface.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#define MAX_FRAME_SIZE 176 // +4 for transport codes (region scoping) + +class BaseSerialInterface { +protected: + BaseSerialInterface() { } + +public: + virtual void enable() = 0; + virtual void disable() = 0; + virtual bool isEnabled() const = 0; + + virtual bool isConnected() const = 0; + virtual void loop() {}; + + virtual bool isWriteBusy() const = 0; + virtual size_t writeFrame(const uint8_t src[], size_t len) = 0; + virtual size_t checkRecvFrame(uint8_t dest[]) = 0; +}; diff --git a/zephcore/helpers/ClientACL.cpp b/zephcore/helpers/ClientACL.cpp index a529478..a44dd58 100644 --- a/zephcore/helpers/ClientACL.cpp +++ b/zephcore/helpers/ClientACL.cpp @@ -1,179 +1,153 @@ -/* - * SPDX-License-Identifier: MIT - * ClientACL - Access Control List for repeater clients - */ - #include "ClientACL.h" -#include -#include -LOG_MODULE_REGISTER(zephcore_acl, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); +// ZEPHCORE: our ACL file is /acl, not /s_contacts (kept for existing installs). +#define ACL_FILE "/acl" -void ClientACL::load(const char* path, const mesh::LocalIdentity& self_id) { - _path = path; - num_clients = 0; - - struct fs_file_t file; - fs_file_t_init(&file); - - if (fs_open(&file, path, FS_O_READ) < 0) { - LOG_DBG("No ACL file at %s", path); - return; - } - - bool full = false; - while (!full) { - ClientInfo c; - uint8_t pub_key[32]; - uint8_t unused[2]; - - c.clear(); - - bool success = (fs_read(&file, pub_key, 32) == 32); - success = success && (fs_read(&file, &c.permissions, 1) == 1); - success = success && (fs_read(&file, &c.extra.room.sync_since, 4) == 4); - success = success && (fs_read(&file, unused, 2) == 2); - success = success && (fs_read(&file, &c.out_path_len, 1) == 1); - success = success && (fs_read(&file, c.out_path, 64) == 64); - success = success && (fs_read(&file, c.shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE); - - if (!success) break; // EOF - - /* out_path_len is the one byte in this file that steers routing, and - * it is used without a later bounds check: anything that is not - * OUT_PATH_UNKNOWN is taken as a usable path. A value with the - * reserved hash size 4 (0xC0-0xFE) passes that test, then copyPath() - * rejects the encoding inside sendDirect() and the reply leaves as a - * zero-hop direct -- undeliverable, and never retried, because the - * repeater considers the request answered. Fall back to "unknown", - * which costs one flooded reply and re-learns the real path. */ - if (c.out_path_len != OUT_PATH_UNKNOWN && - !mesh::Packet::isValidPathLen(c.out_path_len)) { - LOG_WRN("load: client %d has invalid out_path_len 0x%02x, discarding path", - num_clients, c.out_path_len); - c.out_path_len = OUT_PATH_UNKNOWN; - } - - c.id = mesh::Identity(pub_key); - self_id.calcSharedSecret(c.shared_secret, pub_key); // recalculate in case key changed - - if (num_clients < MAX_CLIENTS) { - clients[num_clients++] = c; - } else { - full = true; - } - } - fs_close(&file); - LOG_INF("Loaded %d clients from %s", num_clients, path); +static File openWrite(FILESYSTEM* _fs, const char* filename) { + #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + _fs->remove(filename); + return _fs->open(filename, FILE_O_WRITE); + #elif defined(RP2040_PLATFORM) + return _fs->open(filename, "w"); + #else + return _fs->open(filename, "w", true); + #endif } -void ClientACL::save(const char* path, bool (*filter)(ClientInfo*)) { - _path = path; +void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) { + _fs = fs; + num_clients = 0; + if (_fs->exists(ACL_FILE)) { + #if defined(RP2040_PLATFORM) + File file = _fs->open(ACL_FILE, "r"); + #else + File file = _fs->open(ACL_FILE); + #endif + if (file) { + bool full = false; + while (!full) { + ClientInfo c; + uint8_t pub_key[32]; + uint8_t unused[2]; - // Remove old file first (LittleFS doesn't overwrite well) - fs_unlink(path); + c.clear(); // ZEPHCORE: was memset - struct fs_file_t file; - fs_file_t_init(&file); + bool success = (file.read(pub_key, 32) == 32); + success = success && (file.read((uint8_t *) &c.permissions, 1) == 1); + success = success && (file.read((uint8_t *) &c.extra.room.sync_since, 4) == 4); + success = success && (file.read(unused, 2) == 2); + success = success && (file.read((uint8_t *)&c.out_path_len, 1) == 1); + success = success && (file.read(c.out_path, 64) == 64); + success = success && (file.read(c.shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE); // will be recalculated below - if (fs_open(&file, path, FS_O_CREATE | FS_O_WRITE) < 0) { - LOG_ERR("Failed to open %s for write", path); - return; - } + if (!success) break; // EOF - uint8_t unused[2] = {0, 0}; - int saved = 0; + // ZEPHCORE: a reserved-hash-size out_path_len (0xC0-0xFE) would pass as a + // usable path, then be rejected in sendDirect(), and the reply would go out + // zero-hop and never be retried. Fall back to unknown: one flooded reply. + if (c.out_path_len != OUT_PATH_UNKNOWN && !mesh::Packet::isValidPathLen(c.out_path_len)) { + c.out_path_len = OUT_PATH_UNKNOWN; + } - for (int i = 0; i < num_clients; i++) { - auto c = &clients[i]; - if (c->permissions == 0 || (filter && !filter(c))) continue; // skip deleted or filtered + c.id = mesh::Identity(pub_key); + self_id.calcSharedSecret(c.shared_secret, pub_key); // recalculate shared secrets in case our private key changed + if (num_clients < MAX_CLIENTS) { + clients[num_clients++] = c; + } else { + full = true; + } + } + file.close(); + } + } +} - bool success = (fs_write(&file, c->id.pub_key, 32) == 32); - success = success && (fs_write(&file, &c->permissions, 1) == 1); - success = success && (fs_write(&file, &c->extra.room.sync_since, 4) == 4); - success = success && (fs_write(&file, unused, 2) == 2); - success = success && (fs_write(&file, &c->out_path_len, 1) == 1); - success = success && (fs_write(&file, c->out_path, 64) == 64); - success = success && (fs_write(&file, c->shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE); +void ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) { + _fs = fs; + File file = openWrite(_fs, ACL_FILE); + if (file) { + uint8_t unused[2]; + memset(unused, 0, sizeof(unused)); - if (!success) { - LOG_ERR("Write failed at client %d", i); - break; - } - saved++; - } - fs_close(&file); - LOG_INF("Saved %d clients to %s", saved, path); + for (int i = 0; i < num_clients; i++) { + auto c = &clients[i]; + if (c->permissions == 0 || (filter && !filter(c))) continue; // skip deleted entries, or by filter function + + bool success = (file.write(c->id.pub_key, 32) == 32); + success = success && (file.write((uint8_t *) &c->permissions, 1) == 1); + success = success && (file.write((uint8_t *) &c->extra.room.sync_since, 4) == 4); + success = success && (file.write(unused, 2) == 2); + success = success && (file.write((uint8_t *)&c->out_path_len, 1) == 1); + success = success && (file.write(c->out_path, 64) == 64); + success = success && (file.write(c->shared_secret, PUB_KEY_SIZE) == PUB_KEY_SIZE); + + if (!success) break; // write failed + } + file.close(); + } } bool ClientACL::clear() { - if (_path) { - fs_unlink(_path); - } - for (int i = 0; i < MAX_CLIENTS; i++) { - clients[i].clear(); - } - num_clients = 0; - return true; + if (!_fs) return false; // no filesystem, nothing to clear + if (_fs->exists(ACL_FILE)) { + _fs->remove(ACL_FILE); + } + for (int i = 0; i < MAX_CLIENTS; i++) clients[i].clear(); // ZEPHCORE: was memset + num_clients = 0; + return true; } ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) { - for (int i = 0; i < num_clients; i++) { - if (memcmp(pubkey, clients[i].id.pub_key, key_len) == 0) { - return &clients[i]; // found - } - } - return nullptr; // not found + for (int i = 0; i < num_clients; i++) { + if (memcmp(pubkey, clients[i].id.pub_key, key_len) == 0) return &clients[i]; // already known + } + return NULL; // not found } ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) { - uint32_t min_time = 0xFFFFFFFF; - ClientInfo* oldest = &clients[MAX_CLIENTS - 1]; + uint32_t min_time = 0xFFFFFFFF; + ClientInfo* oldest = &clients[MAX_CLIENTS - 1]; + for (int i = 0; i < num_clients; i++) { + if (id.matches(clients[i].id)) return &clients[i]; // already known + if (!clients[i].isAdmin() && clients[i].last_activity < min_time) { + oldest = &clients[i]; + min_time = oldest->last_activity; + } + } - for (int i = 0; i < num_clients; i++) { - if (id.matches(clients[i].id)) { - return &clients[i]; // already known - } - if (!clients[i].isAdmin() && clients[i].last_activity < min_time) { - oldest = &clients[i]; - min_time = oldest->last_activity; - } - } - - ClientInfo* c; - if (num_clients < MAX_CLIENTS) { - c = &clients[num_clients++]; - } else { - c = oldest; // evict least active contact - } - c->clear(); - c->permissions = init_perms; - c->id = id; - c->out_path_len = OUT_PATH_UNKNOWN; // initially out_path is unknown - return c; + ClientInfo* c; + if (num_clients < MAX_CLIENTS) { + c = &clients[num_clients++]; + } else { + c = oldest; // evict least active contact + } + c->clear(); // ZEPHCORE: was memset + c->permissions = init_perms; + c->id = id; + c->out_path_len = OUT_PATH_UNKNOWN; + return c; } bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms) { - ClientInfo* c; - if ((perms & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { - // guest role is not persisted in contacts - c = getClient(pubkey, key_len); - if (c == nullptr) return false; // partial pubkey not found + ClientInfo* c; + if ((perms & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { // guest role is not persisted in contacts + c = getClient(pubkey, key_len); + if (c == NULL) return false; // partial pubkey not found - num_clients--; // delete from contacts[] - int i = c - clients; - while (i < num_clients) { - clients[i] = clients[i + 1]; - i++; - } - } else { - if (key_len < PUB_KEY_SIZE) return false; // need complete pubkey when adding/modifying + num_clients--; // delete from contacts[] + int i = c - clients; + while (i < num_clients) { + clients[i] = clients[i + 1]; + i++; + } + } else { + if (key_len < PUB_KEY_SIZE) return false; // need complete pubkey when adding/modifying - mesh::Identity id(pubkey); - c = putClient(id, 0); + mesh::Identity id(pubkey); + c = putClient(id, 0); - c->permissions = perms; // update their permissions - self_id.calcSharedSecret(c->shared_secret, pubkey); - } - return true; + c->permissions = perms; // update their permissions + self_id.calcSharedSecret(c->shared_secret, pubkey); + } + return true; } diff --git a/zephcore/helpers/ClientACL.h b/zephcore/helpers/ClientACL.h index ac01e0e..b12c7ab 100644 --- a/zephcore/helpers/ClientACL.h +++ b/zephcore/helpers/ClientACL.h @@ -1,10 +1,6 @@ -/* - * SPDX-License-Identifier: MIT - * ClientACL - Access Control List for repeater clients - */ - #pragma once +//#include // needed for PlatformIO #include #include @@ -17,72 +13,77 @@ #define OUT_PATH_UNKNOWN 0xFF struct ClientInfo { - mesh::Identity id; - uint8_t permissions; - uint8_t out_path_len; - uint8_t out_path[MAX_PATH_SIZE]; - uint8_t shared_secret[PUB_KEY_SIZE]; - uint32_t last_timestamp; // by THEIR clock (transient) - uint32_t last_activity; // by OUR clock (transient) - union { - struct { - uint32_t sync_since; // sync messages SINCE this timestamp (by OUR clock) - uint32_t pending_ack; - uint32_t push_post_timestamp; - unsigned long ack_timeout; - uint8_t push_failures; - } room; - } extra; + mesh::Identity id; + uint8_t permissions; + uint8_t out_path_len; + uint8_t out_path[MAX_PATH_SIZE]; + uint8_t shared_secret[PUB_KEY_SIZE]; + uint32_t last_timestamp; // by THEIR clock (transient) + uint32_t last_activity; // by OUR clock (transient) + union { + struct { + uint32_t sync_since; // sync messages SINCE this timestamp (by OUR clock) + uint32_t pending_ack; + uint32_t push_post_timestamp; + unsigned long ack_timeout; + uint8_t push_failures; + } room; +#if 0 // ZEPHCORE: no sensor role; dropping it saves 20+ bytes of RAM per client + struct { + uint32_t expiry_timestamp; // epoch seconds + uint32_t push_tag; + uint16_t scope_region_id; // scope to use when sending telemetry to this client/subscriber + uint8_t min_deltas_len; + uint8_t min_deltas[14]; // LPP encoded + uint8_t prev_telem[14]; // LPP encoded + } sensor; +#endif + } extra; - bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN; } + bool isAdmin() const { return (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN; } - /* Clear all fields properly */ - void clear() { - id = mesh::Identity(); - permissions = 0; - /* NOT 0 -- that is a valid path_len meaning "zero-hop direct", so a - * cleared slot would claim a usable route and every reply to that - * client would go out DIRECT with an empty path, reaching only its - * immediate neighbours. Silent: the sender believes it answered. - * putClient() happens to overwrite this today, so nothing currently - * observes the 0; the correct default belongs here regardless. */ - out_path_len = OUT_PATH_UNKNOWN; - memset(out_path, 0, sizeof(out_path)); - memset(shared_secret, 0, sizeof(shared_secret)); - last_timestamp = 0; - last_activity = 0; - memset(&extra, 0, sizeof(extra)); - } + // ZEPHCORE: stands in for upstream's memset (Identity has a ctor, so memset + // trips -Wclass-memaccess). out_path_len is OUT_PATH_UNKNOWN, not 0: 0 is a + // valid "zero-hop direct" path, so a cleared slot would claim a usable route. + void clear() { + id = mesh::Identity(); + permissions = 0; + out_path_len = OUT_PATH_UNKNOWN; + memset(out_path, 0, sizeof(out_path)); + memset(shared_secret, 0, sizeof(shared_secret)); + last_timestamp = 0; + last_activity = 0; + memset(&extra, 0, sizeof(extra)); + } }; +// ZEPHCORE: table size from Kconfig. +#ifdef CONFIG_ZEPHCORE_MAX_CLIENTS + #define MAX_CLIENTS CONFIG_ZEPHCORE_MAX_CLIENTS +#endif + #ifndef MAX_CLIENTS - #ifdef CONFIG_ZEPHCORE_MAX_CLIENTS - #define MAX_CLIENTS CONFIG_ZEPHCORE_MAX_CLIENTS - #else - #define MAX_CLIENTS 32 - #endif + #define MAX_CLIENTS 32 #endif class ClientACL { - const char* _path; - ClientInfo clients[MAX_CLIENTS]; - int num_clients; + FILESYSTEM* _fs; + ClientInfo clients[MAX_CLIENTS]; + int num_clients; public: - ClientACL() : _path(nullptr), num_clients(0) { - for (int i = 0; i < MAX_CLIENTS; i++) { - clients[i].clear(); - } - } + ClientACL() { + for (int i = 0; i < MAX_CLIENTS; i++) clients[i].clear(); // ZEPHCORE: was memset + num_clients = 0; + } + void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id); + void save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL); + bool clear(); - void load(const char* path, const mesh::LocalIdentity& self_id); - void save(const char* path, bool (*filter)(ClientInfo*) = nullptr); - bool clear(); + ClientInfo* getClient(const uint8_t* pubkey, int key_len); + ClientInfo* putClient(const mesh::Identity& id, uint8_t init_perms); + bool applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms); - ClientInfo* getClient(const uint8_t* pubkey, int key_len); - ClientInfo* putClient(const mesh::Identity& id, uint8_t init_perms); - bool applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms); - - int getNumClients() const { return num_clients; } - ClientInfo* getClientByIdx(int idx) { return &clients[idx]; } + int getNumClients() const { return num_clients; } + ClientInfo* getClientByIdx(int idx) { return &clients[idx]; } }; diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index aaa4ea4..3fec6e1 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -12,6 +12,7 @@ #include #endif #include +#include #include #include #include @@ -358,6 +359,14 @@ void CommonCLI::scheduleReboot(uint8_t type) * the comparison sites so argument bytes are never touched. */ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, char* reply) { + if (handleRadioCmd(sender_timestamp, command, reply)) { // is a radio CLI command? + return; + } + // hook for variant-specific CLI processing + if (_board->handleCommand(command, sender_timestamp, reply)) { + return; + } + if (strcmp(command, "start dfu") == 0) { /* Reboot into the chip's own firmware-update mode. nRF52: the Adafruit * UF2 bootloader. ESP32-S3: the ROM download mode, which is the only @@ -464,6 +473,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { strcpy(reply, "(ERR: clock cannot go backwards)"); } +#ifndef ZEPHCORE_COMPANION /* no region map on a companion */ + } else if (_region_map && memcmp(command, "region", 6) == 0) { + handleRegionCmdCopy(sender_timestamp, command, reply); +#endif } else if (memcmp(command, "neighbors", 9) == 0) { _callbacks->formatNeighborsReply(reply); } else if (memcmp(command, "neighbor.remove ", 16) == 0) { @@ -505,1022 +518,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch * GET commands */ } else if (memcmp(command, "get ", 4) == 0) { - const char* config = &command[4]; - if (memcmp(config, "dutycycle", 9) == 0) { - float dc = 100.0f / (_prefs->airtime_factor + 1.0f); - int dc_int = (int)dc; - int dc_frac = (int)((dc - dc_int) * 10.0f + 0.5f); - snprintf(reply, CLI_REPLY_SIZE, "> %d.%d%%", dc_int, dc_frac); - } else if (memcmp(config, "af", 2) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %.2f", (double)_prefs->airtime_factor); - } else if (memcmp(config, "int.thresh", 10) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->interference_threshold); - /* MUST stay above the "leds" branch: that one compares only the first - * four characters, so "leds.radio" and "leds.hb" both match it and - * would otherwise return the master switch instead. */ - } else if (memcmp(config, "leds.radio", 10) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s%s", - cliModeName(LEDS_RADIO_NAMES, 4, _prefs->leds_radio_mode), - CLI_HAS_RADIO_LED ? "" : " (no radio LED on this board)"); - } else if (memcmp(config, "leds.hb", 7) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s%s", - cliModeName(LEDS_HB_NAMES, 4, _prefs->leds_hb_mode), - CLI_HAS_HB_LED ? "" : " (no heartbeat LED on this board)"); - } else if (memcmp(config, "leds", 4) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->leds_disabled ? "off" : "on"); -#ifndef ZEPHCORE_REPEATER - } else if (memcmp(config, "buzzer", 6) == 0) { - uint8_t mode = zephcore_buzzer_mode_from_prefs(_prefs->buzzer_quiet); - snprintf(reply, CLI_REPLY_SIZE, "> %u (%s)", mode, - zephcore_buzzer_mode_name(mode)); -#endif - } else if (memcmp(config, "agc.reset.interval", 18) == 0) { - strcpy(reply, "Removed - Automatic AGC reset is on"); - } else if (memcmp(config, "multi.acks", 10) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->multi_acks); -#ifdef CONFIG_ZEPHCORE_ROLE_ROOM_SERVER - /* Room server only. RoomServerMesh.cpp is the sole consumer of - * allow_read_only; RepeaterMesh.cpp never reads it, so on a repeater - * this advertised a setting that silently did nothing. Note this is a - * deliberate divergence from Arduino MeshCore, whose shared CommonCLI - * exposes the knob on every role for the same reason ours used to. - * - * The pref itself stays unconditional -- it is byte 114 of the on-flash - * prefs layout, identical to Arduino's, so dropping it would shift every - * field after it and invalidate existing prefs files. */ - } else if (memcmp(config, "allow.read.only", 15) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->allow_read_only ? "on" : "off"); -#endif - } else if (memcmp(config, "flood.advert.interval", 21) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_advert_interval); - } else if (memcmp(config, "advert.interval", 15) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %u", ((uint32_t)_prefs->advert_interval) * 2); - } else if (memcmp(config, "guest.password", 14) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->guest_password); - } else if (sender_timestamp == 0 && memcmp(config, "prv.key", 7) == 0) { - uint8_t prv_key[PRV_KEY_SIZE]; - int len = _callbacks->getSelfId().writeTo(prv_key, PRV_KEY_SIZE); - mesh::Utils::toHex(tmp, prv_key, len); - snprintf(reply, CLI_REPLY_SIZE, "> %s", tmp); - } else if (memcmp(config, "name", 4) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->node_name); - } else if (memcmp(config, "repeat", 6) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->disable_fwd ? "off" : "on"); - } else if (memcmp(config, "lat", 3) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %.6f", _prefs->node_lat); - } else if (memcmp(config, "lon", 3) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %.6f", _prefs->node_lon); - } else if (memcmp(config, "radio.fem.rxgain", 16) == 0) { - /* on/off, not 0/1 -- MeshCore apps parse this as a boolean word. */ - snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->fem_rxgain ? "on" : "off"); - } else if (memcmp(config, "radio.rxgain", 12) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->rx_boost ? "on" : "off"); - } else if (memcmp(config, "radio", 5) == 0) { - /* Arduino renders bw with a trailing-zero-stripped formatter, so - * 250 kHz prints as "250", not "250.0". Match it. */ - char bw[16]; - snprintf(bw, sizeof(bw), "%.3f", (double)_prefs->bw); - StrHelper::stripTrailingZeros(bw); - snprintf(reply, CLI_REPLY_SIZE, "> %.3f,%s,%u,%u", - (double)_prefs->freq, bw, - (uint32_t)_prefs->sf, (uint32_t)_prefs->cr); - } else if (memcmp(config, "rxdelay", 7) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> adaptive (rxdelay deprecated)"); - } else if (memcmp(config, "txdelay", 7) == 0) { - float est = _callbacks->getContentionEstimate(); - float ff = _callbacks->getFloodDelayFactor(); - snprintf(reply, CLI_REPLY_SIZE, "> adaptive (est=%.1f flood=%.2f)", - (double)est, (double)ff); - } else if (memcmp(config, "flood.max.advert", 16) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_max_advert); - } else if (memcmp(config, "flood.max.unscoped", 18) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_max_unscoped); - } else if (memcmp(config, "flood.max", 9) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_max); - } else if (memcmp(config, "direct.txdelay", 14) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> adaptive (direct.txdelay deprecated)"); - } else if (memcmp(config, "backoff.multiplier", 18) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %.2f", (double)_prefs->backoff_multiplier); - } else if (memcmp(config, "owner.info", 10) == 0) { - *reply++ = '>'; - *reply++ = ' '; - const char* sp = _prefs->owner_info; - while (*sp) { - *reply++ = (*sp == '\n') ? '|' : *sp; - sp++; - } - *reply = 0; - } else if (memcmp(config, "path.hash.mode", 14) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %d", (uint32_t)_prefs->path_hash_mode); - } else if (memcmp(config, "loop.detect", 11) == 0) { - if (_prefs->loop_detect == LOOP_DETECT_OFF) { - strcpy(reply, "> off"); - } else if (_prefs->loop_detect == LOOP_DETECT_MINIMAL) { - strcpy(reply, "> minimal"); - } else if (_prefs->loop_detect == LOOP_DETECT_MODERATE) { - strcpy(reply, "> moderate"); - } else { - strcpy(reply, "> strict"); - } - } else if (strcmp(config, "tx") == 0) { - /* Plain number, matching upstream Arduino MeshCore's "> %d". */ - snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->tx_power_dbm); - } else if (memcmp(config, "freqerr", 7) == 0) { - /* MUST stay above "freq" — that is a 4-char prefix match and - * would swallow this one. - * - * Carrier frequency error measured on received packets, LR2021 - * only. Diagnostic: nothing acts on it. The mean approximates - * THIS node's reference error only once averaged over many - * different peers (theirs cancel, ours does not), which is why - * the spread and packet count are shown alongside it. */ - int n = snprintf(reply, CLI_REPLY_SIZE, "> "); - if (_callbacks->formatFreqErrorStatus(reply + n, - CLI_REPLY_SIZE - n) == 0) { - strcpy(reply, "not available"); - } - } else if (memcmp(config, "freq", 4) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %.3f", (double)_prefs->freq); - } else if (memcmp(config, "public.key", 10) == 0) { - strcpy(reply, "> "); - mesh::Utils::toHex(&reply[2], _callbacks->getSelfId().pub_key, PUB_KEY_SIZE); - } else if (memcmp(config, "role", 4) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s", _callbacks->getRole()); - } else if (memcmp(config, "bootloader.ver", 14) == 0) { - char ver[32]; - if (_board->getBootloaderVersion(ver, sizeof(ver))) { - snprintf(reply, CLI_REPLY_SIZE, "> %s", ver); - } else { - strcpy(reply, "> unknown"); - } - } else if (memcmp(config, "adc.multiplier", 14) == 0) { - float adc_mult = _board->getAdcMultiplier(); - if (adc_mult == 0.0f) { - strcpy(reply, "Error: unsupported by this board"); - } else { - uint16_t mv = _board->getBattMilliVolts(); - uint16_t target_mv = battery_curve_default.ocv_mv[0]; - if (mv > 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %.3f (%u mV, target >= %u mV for 100%%)", - (double)adc_mult, mv, target_mv); - } else { - snprintf(reply, CLI_REPLY_SIZE, "> %.3f (no ADC reading)", (double)adc_mult); - } - } - } else if (memcmp(config, "rxduty", 6) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->rx_duty_cycle); - } else if (memcmp(config, "display.rotate", 14) == 0) { -#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) && MC_DISPLAY_ROTATE_SUPPORTED - // Report the live panel state, not the stored byte: they only - // differ if a rotation was refused, and that is exactly the case - // worth seeing. - snprintf(reply, CLI_REPLY_SIZE, "> %d", mc_display_is_rotated() ? 1 : 0); -#else - strcpy(reply, "> unsupported (panel cannot rotate)"); -#endif - } else if (memcmp(config, "input.rotate", 12) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %d", zephcore_input_is_flipped() ? 1 : 0); - } else if (memcmp(config, "tz.offset", 9) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->tz_offset); - } else if (memcmp(config, "gps diag", 8) == 0) { - /* What the last module-configuration attempt actually did. - * Self-limits on the remote path like cad.stats/meshtimesync: - * gps_get_diag_report() bounds itself by the length it is - * handed, and the full report (long module version + wide - * counters + the no-GPS_SAT_DIAG tail) can exceed the - * caller's packet buffer. */ - size_t cap = replyCap(sender_timestamp); - reply[0] = '>'; reply[1] = ' '; - gps_get_diag_report(reply + 2, cap - 2); - } else if (memcmp(config, "gps duty", 8) == 0) { - uint32_t s = gps_get_poll_interval_sec(); // now-effective value - if (s == 0) strcpy(reply, "> always on (0)"); - else snprintf(reply, CLI_REPLY_SIZE, "> %u", (unsigned)s); -#if IS_ENABLED(CONFIG_ZEPHCORE_MEM_STATS) - } else if (memcmp(config, "mem", 3) == 0) { - memStatsLine(atoi(&config[3]), reply); -#endif - } else if (memcmp(config, "dc.restarts", 11) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %u", - (uint32_t)_callbacks->getDutyCycleTimeoutRestarts()); - } else if (memcmp(config, "probe.interval", 14) == 0) { - /* Seconds between periodic radio measurements — the noise-floor - * sample and the CAD probe that consumes it. 0 = probing off. */ - snprintf(reply, CLI_REPLY_SIZE, "> %u", - (uint32_t)_prefs->probe_interval); - } else if (memcmp(config, "cad.stats", 9) == 0) { - /* Runtime state + per-level probe stats live in the radio. - * ZephCore-only; must stay ahead of the "cad" prefix match below. - * Remote replies get the truncated buffer like meshtimesync. */ - size_t cap = replyCap(sender_timestamp); - int n = snprintf(reply, cap, "> "); - if (_callbacks->formatCadStatus(reply + n, (int)cap - n) == 0) { - strcpy(reply, "not available"); - } - } else if (memcmp(config, "cad.auto", 8) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s", - _prefs->cad_auto ? "on" : "off"); - } else if (memcmp(config, "cad.offset", 10) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->cad_offset); - } else if (memcmp(config, "cad.busycap", 11) == 0) { - if (_prefs->cad_busycap == 0) { - strcpy(reply, "> 0 (off)"); - } else { - snprintf(reply, CLI_REPLY_SIZE, "> %u", - (unsigned)_prefs->cad_busycap); - } - } else if (strcmp(config, "cad") == 0) { - /* Arduino exposes this as a boolean knob; ZephCore always does CAD, - * so the answer is a constant "on". Apps parse the word. - * - * EXACT match, not the 3-byte prefix this used to be. Every branch - * in this chain is a memcmp prefix test, so "cad" matched every - * `get cad.*` that had no branch of its own and answered "on" for - * all of them -- a percentage, a signed offset and an action all - * came back as a boolean. cad.stats escaped only by being ordered - * above it, which is a fragile way to stay correct. Anchoring this - * one fixes the whole family at once: anything under cad. without a - * getter now falls through to the honest "??:" reply instead of - * being answered wrongly. */ - strcpy(reply, "> on"); - } else if (memcmp(config, "extra.sf", 8) == 0) { - /* No "> " prefix, and the empty case is a sentence -- both match - * Arduino MeshCore exactly. */ - char* dp = reply; - int shown = 0; - for (int i = 0; i < EXTRA_SF_MAX && _prefs->extra_sf[i] != 0; i++) { - dp += sprintf(dp, "%s%u", shown++ ? "," : "", - (unsigned)_prefs->extra_sf[i]); - } - if (shown == 0) { - strcpy(reply, "No extra SF configured"); - } - } else if (memcmp(config, "meshtimesync", 12) == 0) { - MeshTimeSync* ts = _callbacks->getMeshTimeSync(); - if (ts == nullptr) { - strcpy(reply, "not available"); - } else { - /* Only the local USB CLI (sender_timestamp == 0) gets the - * full evidence table; remote replies are truncated to the - * packet buffer. */ - size_t cap = replyCap(sender_timestamp); - ts->formatStatus(reply, cap, getRTCClock()->getCurrentTime(), - (uint32_t)(k_uptime_get() / 1000), - _prefs->meshtimesync != 0); - } - } else { - snprintf(reply, CLI_REPLY_SIZE, "??: %s", config); - } + handleGetCmd(sender_timestamp, command, reply); /* * SET commands */ } else if (memcmp(command, "set ", 4) == 0) { - const char* config = &command[4]; - if (memcmp(config, "dutycycle ", 10) == 0) { - /* Floor is 10%, not Arduino's 1%: this writes airtime_factor as - * (100/dc)-1, and sanitizeNodePrefs() clamps that field to 0..9. - * A 1% duty cycle stores af=99, survives until the next load, and - * is then silently rewritten to 9 (=10%) — the node would run at - * one duty cycle and boot into another. 10% is the lowest value - * that round-trips, and it matches the 0..9 bound `set af` now - * enforces directly. */ - float dc; - if (!cliFloat(&config[10], 100.0f / (cliDefaults()->airtime_factor + 1.0f), &dc)) { - strcpy(reply, "ERROR: dutycycle must be 10-100, or default"); - } else if (dc < 10 || dc > 100) { - strcpy(reply, "ERROR: dutycycle must be 10-100"); - } else { - _prefs->airtime_factor = (100.0f / dc) - 1.0f; - savePrefs(); - float actual = 100.0f / (_prefs->airtime_factor + 1.0f); - int a_int = (int)actual; - int a_frac = (int)((actual - a_int) * 10.0f + 0.5f); - snprintf(reply, CLI_REPLY_SIZE, "OK - %d.%d%%", a_int, a_frac); - } - } else if (memcmp(config, "af ", 3) == 0) { - /* Range-checked (upstream f722794e), and not only for tidiness: - * every role computes its duty cycle as 100/(af+1), so af=-1 is a - * live divide-by-zero and af<-1 yields a negative percentage that - * wraps through uint8_t. sanitizeNodePrefs() bounds the field to - * 0..9 but only on load, so an out-of-range value set at runtime - * is in force until the next reboot. */ - float af; - if (!cliFloat(&config[3], cliDefaults()->airtime_factor, &af)) { - strcpy(reply, "Error: expected a number or default"); - } else if (af < 0.0f || af > 9.0f) { - strcpy(reply, "Error: af must be 0-9"); - } else { - _prefs->airtime_factor = af; - savePrefs(); - strcpy(reply, "OK"); - } - } else if (memcmp(config, "int.thresh ", 11) == 0) { - /* Companion runtime never reads this (getInterferenceThreshold is - * only overridden in Repeater/RoomServer) — reject instead of a - * false OK. */ - if (strcmp(_callbacks->getRole(), "companion") == 0) { - strcpy(reply, "Error: not supported on companion"); - } else { - long v; - if (!cliNum(&config[11], cliDefaults()->interference_threshold, &v)) { - strcpy(reply, "Error: expected a number or default"); - } else { - _prefs->interference_threshold = (uint8_t)v; - savePrefs(); - strcpy(reply, "OK"); - } - } - } else if (memcmp(config, "leds.radio ", 11) == 0) { - /* What the LoRa activity LED reacts to. Below the master switch: - * "set leds off" keeps it dark whatever this says. */ - int m = cliModeValue(LEDS_RADIO_NAMES, 4, &config[11], - cliDefaults()->leds_radio_mode); - if (m < 0) { - strcpy(reply, "Error: must be tx, rx, all, off or default"); - } else { - _prefs->leds_radio_mode = (uint8_t)m; - zephcore_leds_set_radio_mode((uint8_t)m); - savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, "OK%s", - CLI_HAS_RADIO_LED ? "" : " (no radio LED on this board)"); - } - } else if (memcmp(config, "leds.hb ", 8) == 0) { - /* What the heartbeat LED reacts to. "unread" is the same cycle - * widening its pulse, not a separate blink — see led_gate.h. */ - int m = cliModeValue(LEDS_HB_NAMES, 4, &config[8], - cliDefaults()->leds_hb_mode); - if (m < 0) { - strcpy(reply, "Error: must be hb, unread, all, off or default"); - } else { - _prefs->leds_hb_mode = (uint8_t)m; - zephcore_leds_set_hb_mode((uint8_t)m); - savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, "OK%s", - CLI_HAS_HB_LED ? "" : " (no heartbeat LED on this board)"); - } - } else if (memcmp(config, "leds ", 5) == 0) { - /* Master switch for every LED on the node: heartbeat, unread-message - * and LoRa TX activity, plus the message and shutdown flashes. Not - * the display backlight — that has its own UI brightness setting. */ - int on = cliOnOff(&config[5], cliDefaults()->leds_disabled ? 0 : 1); - if (on < 0) { - strcpy(reply, "Error: must be on, off or default"); - } else { - _prefs->leds_disabled = on ? 0 : 1; - zephcore_leds_set_disabled(_prefs->leds_disabled != 0); - savePrefs(); - strcpy(reply, "OK"); - } -#ifndef ZEPHCORE_REPEATER - } else if (memcmp(config, "buzzer ", 7) == 0) { - /* 0 = silent, 1 = sound + vibration, 2 = vibration only, - * 3 = sound only. Modes 2 and 3 only mean something on a board - * with a vibration motor, which most boards don't have. */ - const char* val = &config[7]; - int mode; - if (memcmp(val, "vibrate", 7) == 0 || val[0] == '2') { - mode = ZEPHCORE_BUZZER_VIBRATE; - } else if (memcmp(val, "sound", 5) == 0 || val[0] == '3') { - mode = ZEPHCORE_BUZZER_SOUND; - } else if (memcmp(val, "on", 2) == 0 || val[0] == '1') { - mode = ZEPHCORE_BUZZER_ON; - } else if (memcmp(val, "off", 3) == 0 || val[0] == '0') { - mode = ZEPHCORE_BUZZER_OFF; - } else if (cliIsDefault(val)) { - mode = zephcore_buzzer_mode_from_prefs(cliDefaults()->buzzer_quiet); - } else { - mode = -1; - } - if (mode < 0) { - strcpy(reply, "Error: 0 (silent), 1 (sound+vib), 2 (vibrate), 3 (sound) or default"); - } else if ((mode == ZEPHCORE_BUZZER_VIBRATE || mode == ZEPHCORE_BUZZER_SOUND) && - !zephcore_buzzer_has_vibrate()) { - strcpy(reply, "Error: no vibration motor on this board - use 0 or 1"); - } else { - _prefs->buzzer_quiet = zephcore_buzzer_prefs_from_mode((uint8_t)mode); - zephcore_buzzer_set_mode((uint8_t)mode, false); - ui_set_buzzer_mode((uint8_t)mode); - savePrefs(); - strcpy(reply, "OK"); - } -#endif - } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { - /* Periodic AGC recalibration was removed: it reset the noise floor - * to its unseeded sentinel on every fire, forcing a fresh seed and - * a full EMA warmup, and it was already forced off under RX duty - * cycle. RX duty cycle is the supported way to cut RX current. - * The prefs BYTE is retained (read/written, never acted on) — the - * on-disk layout is byte-exact and shifting it would corrupt every - * existing node's prefs. */ - strcpy(reply, "Removed - Automatic AGC reset is on"); - } else if (memcmp(config, "cad.auto ", 9) == 0) { - int on = cliOnOff(&config[9], cliDefaults()->cad_auto); - if (on >= 0) { - _prefs->cad_auto = (uint8_t)on; - _callbacks->applyCadPrefs(); - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: must be on, off or default"); - } - } else if (memcmp(config, "cad.offset ", 11) == 0) { - long val; - if (!cliNum(&config[11], cliDefaults()->cad_offset, &val)) { - snprintf(reply, CLI_REPLY_SIZE, "Error: expected %d..%d or default", - CAD_OFFSET_MIN, CAD_OFFSET_MAX); - } else if (val < CAD_OFFSET_MIN || val > CAD_OFFSET_MAX) { - snprintf(reply, CLI_REPLY_SIZE, "Error: offset range is %d..%d", - CAD_OFFSET_MIN, CAD_OFFSET_MAX); - } else { - _prefs->cad_offset = (int8_t)val; - _callbacks->applyCadPrefs(); - savePrefs(); - strcpy(reply, "OK"); - } - /* Governs every periodic radio measurement, not just CAD — the - * noise-floor sampler and the CAD probe share one reading. */ - } else if (memcmp(config, "probe.interval ", 15) == 0) { - long val; - if (!cliNum(&config[15], cliDefaults()->probe_interval, &val)) { - strcpy(reply, "Error: interval is 0 (probing off), 10-255 seconds, or default"); - } else if (val != 0 && (val < 10 || val > 255)) { - strcpy(reply, "Error: interval is 0 (probing off) or 10-255 seconds"); - } else { - _prefs->probe_interval = (uint8_t)val; - _callbacks->applyCadPrefs(); - savePrefs(); - strcpy(reply, "OK"); - } - } else if (memcmp(config, "cad.busycap ", 12) == 0) { - long val; - if (!cliNum(&config[12], cliDefaults()->cad_busycap, &val)) { - strcpy(reply, "Error: busycap is 0 (off), 10-90 percent, or default"); - } else if (val != 0 && (val < 10 || val > 90)) { - strcpy(reply, "Error: busycap is 0 (off) or 10-90 percent"); - } else { - _prefs->cad_busycap = (uint8_t)val; - _callbacks->applyCadPrefs(); - savePrefs(); - strcpy(reply, "OK"); - } - } else if (memcmp(config, "cad.reset", 9) == 0) { - /* Clearing the probe statistics alone left the node re-converging - * FROM wherever the staircase had already walked detPeak, with no - * evidence left to justify sitting there — the opposite of a reset, - * and useless for the one job this command has (recovering after a - * base-table change). - * - * preset_pending = false: the radio is running the preset this - * reset is for, so the base applyCadPrefs() stamps is the right - * one to keep. */ - resetCadState(false); - snprintf(reply, CLI_REPLY_SIZE, - "OK - CAD probe stats cleared, detPeak offset reset to %d", - (int)_prefs->cad_offset); - } else if (memcmp(config, "extra.sf ", 9) == 0) { - /* LR2021 side detectors: up to 3 extra SFs received alongside - * `sf`. "0" / "off" clears the set. The chip-side constraints - * (each > sf, distinct, spread <= 4, BW>=500 caps the count) are - * enforced in the driver, so an accepted set is a valid one. */ - char tmp[32]; - const char* parts[EXTRA_SF_MAX + 1]; - uint8_t sfs[EXTRA_SF_MAX] = {0}; - StrHelper::strncpy(tmp, &config[9], sizeof(tmp)); - int num = mesh::Utils::parseTextParts(tmp, parts, EXTRA_SF_MAX + 1, ' '); - if (num == 1 && (strcmp(parts[0], "0") == 0 || strcmp(parts[0], "off") == 0)) { - num = 0; - } - if (num > EXTRA_SF_MAX) { - sprintf(reply, "Error: at most %d extra SFs", EXTRA_SF_MAX); - } else { - for (int i = 0; i < num; i++) sfs[i] = (uint8_t)atoi(parts[i]); - if (_callbacks->configSideDetectors(sfs, (uint8_t)num)) { - memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf)); - for (int i = 0; i < num; i++) _prefs->extra_sf[i] = sfs[i]; - savePrefs(); - strcpy(reply, num ? "OK - extra SFs set" : "OK - extra SFs cleared"); - } else { - strcpy(reply, "Error: unsupported or invalid extra SF config"); - } - } - } else if (memcmp(config, "multi.acks ", 11) == 0) { - long val; - if (cliNum(&config[11], cliDefaults()->multi_acks, &val) && - (val == 0 || val == 1)) { - _prefs->multi_acks = (uint8_t)val; - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: must be 0, 1 or default"); - } -#ifdef CONFIG_ZEPHCORE_ROLE_ROOM_SERVER - /* Room server only -- see the matching guard on the `get` side. */ - } else if (memcmp(config, "allow.read.only ", 16) == 0) { - int on = cliOnOff(&config[16], cliDefaults()->allow_read_only); - if (on >= 0) { - _prefs->allow_read_only = (uint8_t)on; - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: must be on, off or default"); - } -#endif - } else if (memcmp(config, "flood.advert.interval ", 22) == 0) { - long hours; - if (!cliNum(&config[22], cliDefaults()->flood_advert_interval, &hours)) { - strcpy(reply, "Error: expected 0, 3-168 hours, or default"); - } else if ((hours > 0 && hours < 3) || (hours > 168)) { - strcpy(reply, "Error: interval range is 3-168 hours"); - } else { - _prefs->flood_advert_interval = (uint8_t)hours; - _callbacks->updateFloodAdvertTimer(); - savePrefs(); - strcpy(reply, "OK"); - } - } else if (memcmp(config, "advert.interval ", 16) == 0) { - long mins; - if (!cliNum(&config[16], (long)cliDefaults()->advert_interval * 2, &mins)) { - snprintf(reply, CLI_REPLY_SIZE, - "Error: expected 0, %d-240 minutes, or default", - MIN_LOCAL_ADVERT_INTERVAL); - } else if ((mins > 0 && mins < MIN_LOCAL_ADVERT_INTERVAL) || (mins > 240)) { - snprintf(reply, CLI_REPLY_SIZE, "Error: interval range is %d-240 minutes", MIN_LOCAL_ADVERT_INTERVAL); - } else { - _prefs->advert_interval = (uint8_t)(mins / 2); - _callbacks->updateAdvertTimer(); - savePrefs(); - strcpy(reply, "OK"); - } - } else if (memcmp(config, "guest.password ", 15) == 0) { - StrHelper::strzcpy(_prefs->guest_password, &config[15], sizeof(_prefs->guest_password)); - savePrefs(); - strcpy(reply, "OK"); - } else if (memcmp(config, "prv.key ", 8) == 0) { - uint8_t prv_key[PRV_KEY_SIZE]; - bool success = mesh::Utils::fromHex(prv_key, PRV_KEY_SIZE, &config[8]); - if (success && mesh::LocalIdentity::validatePrivateKey(prv_key)) { - mesh::LocalIdentity new_id; - new_id.readFrom(prv_key, PRV_KEY_SIZE); - _callbacks->saveIdentity(new_id); - strcpy(reply, "OK, reboot to apply! New pubkey: "); - mesh::Utils::toHex(&reply[33], new_id.pub_key, PUB_KEY_SIZE); - } else { - strcpy(reply, "Error, bad key"); - } - } else if (memcmp(config, "name ", 5) == 0) { - if (isValidName(&config[5])) { - StrHelper::strncpy(_prefs->node_name, &config[5], sizeof(_prefs->node_name)); - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: name cannot contain [ ] \\ : , ? *"); - } - } else if (memcmp(config, "repeat ", 7) == 0) { - if (memcmp(&config[7], "on", 2) == 0) { - _prefs->disable_fwd = 0; - savePrefs(); - strcpy(reply, "OK - repeat is now ON"); - } else if (memcmp(&config[7], "off", 3) == 0) { - _prefs->disable_fwd = 1; - savePrefs(); - strcpy(reply, "OK - repeat is now OFF"); - } else { - strcpy(reply, "Error: must be on or off"); - } - } else if (memcmp(config, "radio ", 6) == 0) { - snprintf(tmp, sizeof(tmp), "%.*s", (int)(sizeof(tmp) - 1), &config[6]); - const char* parts[4]; - int num = mesh::Utils::parseTextParts(tmp, parts, 4); - /* "set radio default" restores all four together — they are one - * interop-critical set and resetting them piecemeal can leave a - * node on a combination no other node uses. */ - bool want_def = (num == 1 && cliIsDefault(parts[0])); - float freq = want_def ? cliDefaults()->freq : (num > 0 ? strtof(parts[0], nullptr) : 0.0f); - float bw = want_def ? cliDefaults()->bw : (num > 1 ? strtof(parts[1], nullptr) : 0.0f); - uint8_t sf = want_def ? cliDefaults()->sf : (num > 2 ? (uint8_t)atoi(parts[2]) : 0); - uint8_t cr = want_def ? cliDefaults()->cr : (num > 3 ? (uint8_t)atoi(parts[3]) : 0); - if (freq >= ZC_RADIO_FREQ_MIN_MHZ && freq <= ZC_RADIO_FREQ_MAX_MHZ && sf >= 5 && sf <= 12 && - cr >= 5 && cr <= 8 && bw >= ZC_RADIO_BW_MIN_KHZ && bw <= ZC_RADIO_BW_MAX_KHZ) { - /* Snapshot old params, then mutate _prefs and save so later - * savePrefs() calls (set af, set name, ...) don't clobber - * the new values with stale RAM. Freeze the running radio on - * the old params via override so the on-air config doesn't - * change until reboot. */ - float old_freq = _prefs->freq; - float old_bw = _prefs->bw; - uint8_t old_sf = _prefs->sf; - uint8_t old_cr = _prefs->cr; - _prefs->freq = freq; - _prefs->bw = bw; - _prefs->sf = sf; - _prefs->cr = cr; - _callbacks->savePrefs(); - _callbacks->freezeRadioParams(old_freq, old_bw, old_sf, old_cr); - /* Anything that moves the channel or the detPeak base table - * invalidates the learned CAD offset and every statistic - * behind it — see resetCadState(). Coding rate does neither - * (it changes airtime, not the threshold or the band), so a - * cr-only edit keeps a converged offset. */ - if (freq != old_freq || bw != old_bw || sf != old_sf) { - resetCadState(true); - strcpy(reply, "OK - reboot to apply (CAD reset)"); - } else { - strcpy(reply, "OK - reboot to apply"); - } - } else { - strcpy(reply, "Error: freq 150-2500, bw 7-500, sf 5-12, cr 5-8, or default"); - } - } else if (memcmp(config, "lat ", 4) == 0) { - _prefs->node_lat = cliIsDefault(&config[4]) ? cliDefaults()->node_lat - : atof(&config[4]); - savePrefs(); - strcpy(reply, "OK"); - } else if (memcmp(config, "lon ", 4) == 0) { - _prefs->node_lon = cliIsDefault(&config[4]) ? cliDefaults()->node_lon - : atof(&config[4]); - savePrefs(); - strcpy(reply, "OK"); - } else if (memcmp(config, "rxdelay ", 8) == 0) { - _prefs->rx_delay_base = cliIsDefault(&config[8]) ? cliDefaults()->rx_delay_base - : (float)atof(&config[8]); - savePrefs(); - strcpy(reply, "OK (ignored: rxdelay is now adaptive)"); - } else if (memcmp(config, "txdelay ", 8) == 0) { - _prefs->tx_delay_factor = cliIsDefault(&config[8]) ? cliDefaults()->tx_delay_factor - : (float)atof(&config[8]); - savePrefs(); - strcpy(reply, "OK (ignored: txdelay is now adaptive)"); - } else if (memcmp(config, "flood.max.advert ", 17) == 0) { - long m; - if (cliNum(&config[17], cliDefaults()->flood_max_advert, &m) && m >= 0 && m <= 64) { - _prefs->flood_max_advert = (uint8_t)m; - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: range 0-64, or default"); - } - } else if (memcmp(config, "flood.max.unscoped ", 19) == 0) { - long m; - if (cliNum(&config[19], cliDefaults()->flood_max_unscoped, &m) && m >= 0 && m <= 64) { - _prefs->flood_max_unscoped = (uint8_t)m; - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: range 0-64, or default"); - } - } else if (memcmp(config, "flood.max ", 10) == 0) { - long m; - if (cliNum(&config[10], cliDefaults()->flood_max, &m) && m >= 0 && m <= 64) { - _prefs->flood_max = (uint8_t)m; - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: range 0-64, or default"); - } - } else if (memcmp(config, "direct.txdelay ", 15) == 0) { - _prefs->direct_tx_delay_factor = cliIsDefault(&config[15]) - ? cliDefaults()->direct_tx_delay_factor - : (float)atof(&config[15]); - savePrefs(); - strcpy(reply, "OK (ignored: direct.txdelay is now adaptive)"); - } else if (memcmp(config, "backoff.multiplier ", 19) == 0) { - /* Companion's setBackoffMultiplier callback is the base-class - * no-op and the value isn't restored at boot — reject instead of - * a false OK. */ - if (strcmp(_callbacks->getRole(), "companion") == 0) { - strcpy(reply, "Error: not supported on companion"); - } else { - /* initNodePrefs() leaves this at 0.0 (memset) even though the - * live default is ContentionTracker::DEFAULT_BACKOFF_MULT and - * RepeaterDataStore migrates 0.0 -> 0.2 on load, so `default` - * uses the real value rather than the prefs blank. */ - float f; - if (!cliFloat(&config[19], 0.2f, &f)) { - strcpy(reply, "Error, range 0.0-2.0, or default"); - } else if (f >= 0.0f && f <= 2.0f) { - _prefs->backoff_multiplier = f; - _callbacks->setBackoffMultiplier(f); - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, range 0.0-2.0"); - } - } - } else if (memcmp(config, "owner.info ", 11) == 0) { - config += 11; - char* dp = _prefs->owner_info; - while (*config && dp - _prefs->owner_info < (int)sizeof(_prefs->owner_info) - 1) { - *dp++ = (*config == '|') ? '\n' : *config; - config++; - } - *dp = 0; - savePrefs(); - strcpy(reply, "OK"); - } else if (memcmp(config, "path.hash.mode ", 15) == 0) { - config += 15; - long mode; - if (cliNum(config, cliDefaults()->path_hash_mode, &mode) && - mode >= 0 && mode < 3) { - _prefs->path_hash_mode = (uint8_t)mode; - savePrefs(); - strcpy(reply, "OK"); - } else { - strcpy(reply, "Error, must be 0, 1, 2 or default"); - } - } else if (memcmp(config, "loop.detect ", 12) == 0) { - /* Loop detection runs only in the Repeater/RoomServer forward - * path — companions never consult loop_detect. */ - if (strcmp(_callbacks->getRole(), "companion") == 0) { - strcpy(reply, "Error: not supported on companion"); - return; - } - config += 12; - uint8_t mode; - if (memcmp(config, "off", 3) == 0) { - mode = LOOP_DETECT_OFF; - } else if (memcmp(config, "minimal", 7) == 0) { - mode = LOOP_DETECT_MINIMAL; - } else if (memcmp(config, "moderate", 8) == 0) { - mode = LOOP_DETECT_MODERATE; - } else if (memcmp(config, "strict", 6) == 0) { - mode = LOOP_DETECT_STRICT; - } else if (cliIsDefault(config)) { - mode = cliDefaults()->loop_detect; - } else { - mode = 0xFF; - strcpy(reply, "Error, must be: off, minimal, moderate, strict, or default"); - } - if (mode != 0xFF) { - _prefs->loop_detect = mode; - savePrefs(); - strcpy(reply, "OK"); - } - } else if (memcmp(config, "tx ", 3) == 0) { - long parsed; - int max_tx = 30; -#ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM - max_tx = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; -#endif - if (!cliNum(&config[3], cliDefaults()->tx_power_dbm, &parsed) || - parsed < -9 || parsed > max_tx) { - snprintf(reply, CLI_REPLY_SIZE, "Error: range -9 to %d dBm, or default", max_tx); - } else { - _prefs->tx_power_dbm = (int8_t)parsed; - savePrefs(); - _callbacks->setTxPower(_prefs->tx_power_dbm); - snprintf(reply, CLI_REPLY_SIZE, "OK - tx power=%d dBm", - (int)_prefs->tx_power_dbm); - } - } else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) { - float f; - if (cliFloat(&config[5], cliDefaults()->freq, &f) && - f >= 150.0f && f <= 2500.0f) { - float old_freq = _prefs->freq; - _prefs->freq = f; - savePrefs(); - /* Keep _prefs->freq = f in RAM so a later savePrefs() (from any - * other "set" command before reboot) can't rewrite the old freq - * back; freeze the running radio on the old freq until reboot, - * mirroring the "set radio" handler above. */ - _callbacks->freezeRadioParams(old_freq, _prefs->bw, _prefs->sf, _prefs->cr); - /* New channel, new interference: the offset and the statistics - * behind it were learned somewhere else. See resetCadState(). */ - if (f != old_freq) { - resetCadState(true); - strcpy(reply, "OK - reboot to apply (CAD reset)"); - } else { - strcpy(reply, "OK - reboot to apply"); - } - } else { - strcpy(reply, "Error: range 150-2500 MHz, or default"); - } - } else if (strcmp(config, "adc.multiplier target") == 0) { - strcpy(reply, "Error: need mV target (e.g. set adc.multiplier target 4173)"); - } else if (memcmp(config, "adc.multiplier target ", 22) == 0) { - /* Calibrate against a known voltage measured with a multimeter. */ - uint16_t target_mv = (uint16_t)atoi(&config[22]); - uint16_t current_mv = _board->getBattMilliVolts(); - if (current_mv == 0) { - strcpy(reply, "Error: no ADC reading on this board"); - } else if (target_mv < 3000 || target_mv > 4400) { - strcpy(reply, "Error: target out of range (3000-4400 mV)"); - } else { - float current_mult = _board->getAdcMultiplier(); - float new_mult = current_mult * (float)target_mv / (float)current_mv; - _prefs->adc_multiplier = new_mult; - if (_board->setAdcMultiplier(new_mult)) { - savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, - "OK - multiplier %.3f -> %.3f (%u -> %u mV)", - (double)current_mult, (double)new_mult, - current_mv, target_mv); - } else { - _prefs->adc_multiplier = 0.0f; - strcpy(reply, "Error: unsupported by this board"); - } - } - } else if (memcmp(config, "adc.multiplier full", 19) == 0) { - /* Calibrate: board must be on a full charge. Scales the current - * multiplier so the ADC reads the board's curve 100% point. */ - uint16_t current_mv = _board->getBattMilliVolts(); - if (current_mv == 0) { - strcpy(reply, "Error: no ADC reading on this board"); - } else { - uint16_t target_mv = battery_curve_default.ocv_mv[0]; - float current_mult = _board->getAdcMultiplier(); - float new_mult = current_mult * (float)target_mv / (float)current_mv; - _prefs->adc_multiplier = new_mult; - if (_board->setAdcMultiplier(new_mult)) { - savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, - "OK - multiplier %.3f -> %.3f (%u -> %u mV)", - (double)current_mult, (double)new_mult, - current_mv, target_mv); - } else { - _prefs->adc_multiplier = 0.0f; - strcpy(reply, "Error: unsupported by this board"); - } - } - } else if (memcmp(config, "adc.multiplier ", 15) == 0) { - const char *arg = &config[15]; - /* 0 is valid (resets to the DTS default). cliFloat() rejects the - * non-numeric input that atof() used to fold into a 0 here, so the - * old "distinguish from literal 0" dance is gone. Upper bound - * covers all real divider/reference combinations with margin. */ - float val; - bool bad = !cliFloat(arg, cliDefaults()->adc_multiplier, &val); - if (!bad) bad = (val != 0.0f && val < 100.0f) || val > 30000.0f || val < 0.0f; - if (bad) { - strcpy(reply, "Error: invalid multiplier (0 to reset, 100-30000, or default)"); - } else if (_board->setAdcMultiplier(val)) { - _prefs->adc_multiplier = val; - savePrefs(); - if (val == 0.0f) { - strcpy(reply, "OK - using default board multiplier"); - } else { - snprintf(reply, CLI_REPLY_SIZE, "OK - multiplier set to %.3f", (double)val); - } - } else { - strcpy(reply, "Error: unsupported by this board"); - } - } else if (memcmp(config, "radio.fem.rxgain ", 17) == 0) { - int val = cliOnOff(&config[17], cliDefaults()->fem_rxgain); - if (val == 0 || val == 1) { - /* Same shape as radio.rxgain: always save, then apply live and - * report when the radio driver has no FEM gate. */ - _prefs->fem_rxgain = (uint8_t)val; - savePrefs(); - if (_callbacks->setFemRxGain(val == 1)) { - snprintf(reply, CLI_REPLY_SIZE, "OK - radio.fem.rxgain=%d", _prefs->fem_rxgain); - } else { - strcpy(reply, "Error: unsupported"); - } - } else { - strcpy(reply, "Error: must be 0, 1, on, or off"); - } - } else if (memcmp(config, "radio.rxgain ", 13) == 0) { - int val = cliOnOff(&config[13], cliDefaults()->rx_boost); - if (val == 0 || val == 1) { - /* Always save (upstream f3d4d8cd), then apply live and - * report when the radio has no RX boost feature. */ - _prefs->rx_boost = (uint8_t)val; - savePrefs(); - if (_callbacks->setRxBoostedGain(val == 1)) { - snprintf(reply, CLI_REPLY_SIZE, "OK - radio.rxgain=%d", _prefs->rx_boost); - } else { - strcpy(reply, "Error: unsupported"); - } - } else { - strcpy(reply, "Error: must be 0, 1, on, or off"); - } - } else if (memcmp(config, "rxduty ", 7) == 0) { - int val = cliOnOff(&config[7], cliDefaults()->rx_duty_cycle); - if (val == 0 || val == 1) { - _prefs->rx_duty_cycle = (uint8_t)val; - savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, "OK - rxduty=%d (reboot to apply)", _prefs->rx_duty_cycle); - } else { - strcpy(reply, "Error: must be 0, 1, on, or off"); - } - } else if (memcmp(config, "display.rotate ", 15) == 0) { - // Rotate the panel 180 degrees for cases that mount it upside down. - // Applied immediately -- the SSD1306/SH1106 remap is two bytes on - // the wire and the next frame comes out flipped, no redraw needed. - int val = cliOnOff(&config[15], cliDefaults()->display_rotate); - if (val != 0 && val != 1) { - strcpy(reply, "Error: must be 0, 1, on, or off"); - } else { -#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) - int ret = mc_display_set_rotated(val == 1); - if (ret == 0) { - // Persist only what the panel actually did, so a stored - // value can never disagree with what the screen shows. - _prefs->display_rotate = (uint8_t)val; - savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, "OK - display.rotate=%d", val); - } else if (ret == -ENOTSUP) { - strcpy(reply, "Error: this panel cannot rotate"); - } else { - snprintf(reply, CLI_REPLY_SIZE, "Error: rotate failed (%d)", ret); - } -#else - strcpy(reply, "Error: no display"); -#endif - } - } else if (memcmp(config, "input.rotate ", 13) == 0) { - // Swap the joystick/D-pad axes to match an upside-down mount. - // Deliberately separate from display.rotate: a case can flip the - // screen without flipping the stick, and boards whose panel cannot - // rotate can still need the axis swap. - int val = cliOnOff(&config[13], cliDefaults()->input_rotate); - if (val == 0 || val == 1) { - zephcore_input_set_flipped(val == 1); - _prefs->input_rotate = (uint8_t)val; - savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, "OK - input.rotate=%d", val); - } else { - strcpy(reply, "Error: must be 0, 1, on, or off"); - } - } else if (memcmp(config, "tz.offset ", 10) == 0) { - // Whole-hour offset from UTC for the ON-DEVICE CLOCK DISPLAY only. - // The RTC, `clock` and `time ` all stay UTC: they round-trip - // with each other, apps parse them, and a timezone that reached the - // clock would look like a backward jump to every timestamp consumer - // on the node (see NodePrefs::tz_offset). - // - // Whole hours, matching upstream MeshCore's command of the same - // name, so an app that speaks to both trees behaves identically. - // Half-hour zones (+5:30, -3:30) cannot be expressed. - long val; - if (!cliNum(&config[10], cliDefaults()->tz_offset, &val)) { - snprintf(reply, CLI_REPLY_SIZE, "Error: expected %d..%d or default", - TZ_OFFSET_MIN, TZ_OFFSET_MAX); - } else if (val < TZ_OFFSET_MIN || val > TZ_OFFSET_MAX) { - snprintf(reply, CLI_REPLY_SIZE, "Error: offset range is %d..%d", - TZ_OFFSET_MIN, TZ_OFFSET_MAX); - } else { - _prefs->tz_offset = (int8_t)val; - savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, "OK - tz.offset=%d", (int)val); - } - } else if (memcmp(config, "gps diag", 8) == 0) { - // set gps diag <0|1|on|off> — arm module-configuration reporting. - // Not persisted: clears on reboot, by design. - /* Not persisted, so "default" means the boot state: off. */ - int val = cliOnOff(config + 8, 0); - if (val == 0 || val == 1) { - gps_set_diag(val == 1); - if (val == 1) { - strcpy(reply, "OK - gps diag on; run 'gps off' then 'gps on', " - "then 'get gps diag'"); - } else { - strcpy(reply, "OK - gps diag off"); - } - } else { - strcpy(reply, "usage: set gps diag <0|1|on|off>"); - } - } else if (memcmp(config, "gps duty", 8) == 0) { - // set gps duty | default (0 = always on) - const char* arg = config + 8; - while (*arg == ' ') arg++; - uint32_t val = 0; - bool ok = true; - if (*arg == '\0') { - ok = false; - } else if (strcmp(arg, "default") == 0) { - val = _callbacks->getDefaultGpsIntervalSec(); - } else { - char* end = NULL; - unsigned long parsed = strtoul(arg, &end, 10); - // reject non-numeric, fractions, or trailing garbage - if (end == arg || *end != '\0') ok = false; - else val = (uint32_t)parsed; - } - if (!ok) { - strcpy(reply, "usage: set gps duty | default (0 = always on)"); - } else { - if (val > 604800UL) val = 604800UL; // cap at 1 week - else if (val != 0 && val < 10) val = 10; // floor 10s (0 = always on) - _prefs->gps_interval = val; - gps_set_poll_interval_sec(val); // apply live - savePrefs(); - if (val == 0) strcpy(reply, "OK - gps duty=0 (always on)"); - else snprintf(reply, CLI_REPLY_SIZE, "OK - gps duty=%u s", (unsigned)val); - } - } else if (memcmp(config, "meshtimesync ", 13) == 0) { - int on = cliOnOff(&config[13], cliDefaults()->meshtimesync); - if (_callbacks->getMeshTimeSync() == nullptr) { - strcpy(reply, "not available"); - } else if (on < 0) { - strcpy(reply, "Error: must be on, off or default"); - } else { - _prefs->meshtimesync = (uint8_t)on; - savePrefs(); - snprintf(reply, CLI_REPLY_SIZE, "OK - meshtimesync %s", on ? "on" : "off"); - } - } else { - snprintf(reply, CLI_REPLY_SIZE, "unknown config: %.230s", config); - } + handleSetCmd(sender_timestamp, command, reply); } else if (sender_timestamp == 0 && strcmp(command, "erase") == 0) { bool s = _callbacks->formatFileSystem(); if (s) { @@ -1653,3 +656,1258 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch strcpy(reply, "Unknown command"); } } + +/* Radio keys, first, as upstream's CommonRadioPrefs::handleCommand. The + * branches keep their relative order: several match on a prefix (radio vs + * radio.rxgain, freq vs freqerr). */ +bool CommonCLI::handleRadioCmd(uint32_t sender_timestamp, const char* command, char* reply) { + if (memcmp(command, "get ", 4) == 0) { + const char* config = &command[4]; + if (memcmp(config, "dutycycle", 9) == 0) { + float dc = 100.0f / (_prefs->airtime_factor + 1.0f); + int dc_int = (int)dc; + int dc_frac = (int)((dc - dc_int) * 10.0f + 0.5f); + snprintf(reply, CLI_REPLY_SIZE, "> %d.%d%%", dc_int, dc_frac); + } else if (memcmp(config, "af", 2) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %.2f", (double)_prefs->airtime_factor); + } else if (memcmp(config, "int.thresh", 10) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->interference_threshold); + } else if (memcmp(config, "agc.reset.interval", 18) == 0) { + strcpy(reply, "Removed - Automatic AGC reset is on"); + } else if (memcmp(config, "multi.acks", 10) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->multi_acks); + } else if (memcmp(config, "radio.fem.rxgain", 16) == 0) { + /* on/off, not 0/1 -- MeshCore apps parse this as a boolean word. */ + snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->fem_rxgain ? "on" : "off"); + } else if (memcmp(config, "radio.rxgain", 12) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->rx_boost ? "on" : "off"); + } else if (memcmp(config, "radio", 5) == 0) { + /* Arduino renders bw with a trailing-zero-stripped formatter, so + * 250 kHz prints as "250", not "250.0". Match it. */ + char bw[16]; + snprintf(bw, sizeof(bw), "%.3f", (double)_prefs->bw); + StrHelper::stripTrailingZeros(bw); + snprintf(reply, CLI_REPLY_SIZE, "> %.3f,%s,%u,%u", + (double)_prefs->freq, bw, + (uint32_t)_prefs->sf, (uint32_t)_prefs->cr); + } else if (memcmp(config, "rxdelay", 7) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> adaptive (rxdelay deprecated)"); + } else if (memcmp(config, "txdelay", 7) == 0) { + float est = _callbacks->getContentionEstimate(); + float ff = _callbacks->getFloodDelayFactor(); + snprintf(reply, CLI_REPLY_SIZE, "> adaptive (est=%.1f flood=%.2f)", + (double)est, (double)ff); + } else if (memcmp(config, "direct.txdelay", 14) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> adaptive (direct.txdelay deprecated)"); + } else if (memcmp(config, "path.hash.mode", 14) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %d", (uint32_t)_prefs->path_hash_mode); + } else if (strcmp(config, "tx") == 0) { + /* Plain number, matching upstream Arduino MeshCore's "> %d". */ + snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->tx_power_dbm); + } else if (memcmp(config, "freqerr", 7) == 0) { + /* MUST stay above "freq" — that is a 4-char prefix match and + * would swallow this one. + * + * Carrier frequency error measured on received packets, LR2021 + * only. Diagnostic: nothing acts on it. The mean approximates + * THIS node's reference error only once averaged over many + * different peers (theirs cancel, ours does not), which is why + * the spread and packet count are shown alongside it. */ + int n = snprintf(reply, CLI_REPLY_SIZE, "> "); + if (_callbacks->formatFreqErrorStatus(reply + n, + CLI_REPLY_SIZE - n) == 0) { + strcpy(reply, "not available"); + } + } else if (memcmp(config, "freq", 4) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %.3f", (double)_prefs->freq); + } else { + return false; + } + return true; + } + if (memcmp(command, "set ", 4) == 0) { + const char* config = &command[4]; + if (memcmp(config, "dutycycle ", 10) == 0) { + /* Floor is 10%, not Arduino's 1%: this writes airtime_factor as + * (100/dc)-1, and sanitizeNodePrefs() clamps that field to 0..9. + * A 1% duty cycle stores af=99, survives until the next load, and + * is then silently rewritten to 9 (=10%) — the node would run at + * one duty cycle and boot into another. 10% is the lowest value + * that round-trips, and it matches the 0..9 bound `set af` now + * enforces directly. */ + float dc; + if (!cliFloat(&config[10], 100.0f / (cliDefaults()->airtime_factor + 1.0f), &dc)) { + strcpy(reply, "ERROR: dutycycle must be 10-100, or default"); + } else if (dc < 10 || dc > 100) { + strcpy(reply, "ERROR: dutycycle must be 10-100"); + } else { + _prefs->airtime_factor = (100.0f / dc) - 1.0f; + savePrefs(); + float actual = 100.0f / (_prefs->airtime_factor + 1.0f); + int a_int = (int)actual; + int a_frac = (int)((actual - a_int) * 10.0f + 0.5f); + snprintf(reply, CLI_REPLY_SIZE, "OK - %d.%d%%", a_int, a_frac); + } + } else if (memcmp(config, "af ", 3) == 0) { + /* Range-checked (upstream f722794e), and not only for tidiness: + * every role computes its duty cycle as 100/(af+1), so af=-1 is a + * live divide-by-zero and af<-1 yields a negative percentage that + * wraps through uint8_t. sanitizeNodePrefs() bounds the field to + * 0..9 but only on load, so an out-of-range value set at runtime + * is in force until the next reboot. */ + float af; + if (!cliFloat(&config[3], cliDefaults()->airtime_factor, &af)) { + strcpy(reply, "Error: expected a number or default"); + } else if (af < 0.0f || af > 9.0f) { + strcpy(reply, "Error: af must be 0-9"); + } else { + _prefs->airtime_factor = af; + savePrefs(); + strcpy(reply, "OK"); + } + } else if (memcmp(config, "int.thresh ", 11) == 0) { + /* Companion runtime never reads this (getInterferenceThreshold is + * only overridden in Repeater/RoomServer) — reject instead of a + * false OK. */ + if (strcmp(_callbacks->getRole(), "companion") == 0) { + strcpy(reply, "Error: not supported on companion"); + } else { + long v; + if (!cliNum(&config[11], cliDefaults()->interference_threshold, &v)) { + strcpy(reply, "Error: expected a number or default"); + } else { + _prefs->interference_threshold = (uint8_t)v; + savePrefs(); + strcpy(reply, "OK"); + } + } + } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { + /* Periodic AGC recalibration was removed: it reset the noise floor + * to its unseeded sentinel on every fire, forcing a fresh seed and + * a full EMA warmup, and it was already forced off under RX duty + * cycle. RX duty cycle is the supported way to cut RX current. + * The prefs BYTE is retained (read/written, never acted on) — the + * on-disk layout is byte-exact and shifting it would corrupt every + * existing node's prefs. */ + strcpy(reply, "Removed - Automatic AGC reset is on"); + } else if (memcmp(config, "multi.acks ", 11) == 0) { + long val; + if (cliNum(&config[11], cliDefaults()->multi_acks, &val) && + (val == 0 || val == 1)) { + _prefs->multi_acks = (uint8_t)val; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: must be 0, 1 or default"); + } + } else if (memcmp(config, "radio ", 6) == 0) { + snprintf(tmp, sizeof(tmp), "%.*s", (int)(sizeof(tmp) - 1), &config[6]); + const char* parts[4]; + int num = mesh::Utils::parseTextParts(tmp, parts, 4); + /* "set radio default" restores all four together — they are one + * interop-critical set and resetting them piecemeal can leave a + * node on a combination no other node uses. */ + bool want_def = (num == 1 && cliIsDefault(parts[0])); + float freq = want_def ? cliDefaults()->freq : (num > 0 ? strtof(parts[0], nullptr) : 0.0f); + float bw = want_def ? cliDefaults()->bw : (num > 1 ? strtof(parts[1], nullptr) : 0.0f); + uint8_t sf = want_def ? cliDefaults()->sf : (num > 2 ? (uint8_t)atoi(parts[2]) : 0); + uint8_t cr = want_def ? cliDefaults()->cr : (num > 3 ? (uint8_t)atoi(parts[3]) : 0); + if (freq >= ZC_RADIO_FREQ_MIN_MHZ && freq <= ZC_RADIO_FREQ_MAX_MHZ && sf >= 5 && sf <= 12 && + cr >= 5 && cr <= 8 && bw >= ZC_RADIO_BW_MIN_KHZ && bw <= ZC_RADIO_BW_MAX_KHZ) { + /* Snapshot old params, then mutate _prefs and save so later + * savePrefs() calls (set af, set name, ...) don't clobber + * the new values with stale RAM. Freeze the running radio on + * the old params via override so the on-air config doesn't + * change until reboot. */ + float old_freq = _prefs->freq; + float old_bw = _prefs->bw; + uint8_t old_sf = _prefs->sf; + uint8_t old_cr = _prefs->cr; + _prefs->freq = freq; + _prefs->bw = bw; + _prefs->sf = sf; + _prefs->cr = cr; + _callbacks->savePrefs(); + _callbacks->freezeRadioParams(old_freq, old_bw, old_sf, old_cr); + /* Anything that moves the channel or the detPeak base table + * invalidates the learned CAD offset and every statistic + * behind it — see resetCadState(). Coding rate does neither + * (it changes airtime, not the threshold or the band), so a + * cr-only edit keeps a converged offset. */ + if (freq != old_freq || bw != old_bw || sf != old_sf) { + resetCadState(true); + strcpy(reply, "OK - reboot to apply (CAD reset)"); + } else { + strcpy(reply, "OK - reboot to apply"); + } + } else { + strcpy(reply, "Error: freq 150-2500, bw 7-500, sf 5-12, cr 5-8, or default"); + } + } else if (memcmp(config, "rxdelay ", 8) == 0) { + _prefs->rx_delay_base = cliIsDefault(&config[8]) ? cliDefaults()->rx_delay_base + : (float)atof(&config[8]); + savePrefs(); + strcpy(reply, "OK (ignored: rxdelay is now adaptive)"); + } else if (memcmp(config, "txdelay ", 8) == 0) { + _prefs->tx_delay_factor = cliIsDefault(&config[8]) ? cliDefaults()->tx_delay_factor + : (float)atof(&config[8]); + savePrefs(); + strcpy(reply, "OK (ignored: txdelay is now adaptive)"); + } else if (memcmp(config, "direct.txdelay ", 15) == 0) { + _prefs->direct_tx_delay_factor = cliIsDefault(&config[15]) + ? cliDefaults()->direct_tx_delay_factor + : (float)atof(&config[15]); + savePrefs(); + strcpy(reply, "OK (ignored: direct.txdelay is now adaptive)"); + } else if (memcmp(config, "path.hash.mode ", 15) == 0) { + config += 15; + long mode; + if (cliNum(config, cliDefaults()->path_hash_mode, &mode) && + mode >= 0 && mode < 3) { + _prefs->path_hash_mode = (uint8_t)mode; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, must be 0, 1, 2 or default"); + } + } else if (memcmp(config, "tx ", 3) == 0) { + long parsed; + int max_tx = 30; +#ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM + max_tx = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; +#endif + if (!cliNum(&config[3], cliDefaults()->tx_power_dbm, &parsed) || + parsed < -9 || parsed > max_tx) { + snprintf(reply, CLI_REPLY_SIZE, "Error: range -9 to %d dBm, or default", max_tx); + } else { + _prefs->tx_power_dbm = (int8_t)parsed; + savePrefs(); + _callbacks->setTxPower(_prefs->tx_power_dbm); + snprintf(reply, CLI_REPLY_SIZE, "OK - tx power=%d dBm", + (int)_prefs->tx_power_dbm); + } + } else if (sender_timestamp == 0 && memcmp(config, "freq ", 5) == 0) { + float f; + if (cliFloat(&config[5], cliDefaults()->freq, &f) && + f >= 150.0f && f <= 2500.0f) { + float old_freq = _prefs->freq; + _prefs->freq = f; + savePrefs(); + /* Keep _prefs->freq = f in RAM so a later savePrefs() (from any + * other "set" command before reboot) can't rewrite the old freq + * back; freeze the running radio on the old freq until reboot, + * mirroring the "set radio" handler above. */ + _callbacks->freezeRadioParams(old_freq, _prefs->bw, _prefs->sf, _prefs->cr); + /* New channel, new interference: the offset and the statistics + * behind it were learned somewhere else. See resetCadState(). */ + if (f != old_freq) { + resetCadState(true); + strcpy(reply, "OK - reboot to apply (CAD reset)"); + } else { + strcpy(reply, "OK - reboot to apply"); + } + } else { + strcpy(reply, "Error: range 150-2500 MHz, or default"); + } + } else if (memcmp(config, "radio.fem.rxgain ", 17) == 0) { + int val = cliOnOff(&config[17], cliDefaults()->fem_rxgain); + if (val == 0 || val == 1) { + /* Same shape as radio.rxgain: always save, then apply live and + * report when the radio driver has no FEM gate. */ + _prefs->fem_rxgain = (uint8_t)val; + savePrefs(); + if (_callbacks->setFemRxGain(val == 1)) { + snprintf(reply, CLI_REPLY_SIZE, "OK - radio.fem.rxgain=%d", _prefs->fem_rxgain); + } else { + strcpy(reply, "Error: unsupported"); + } + } else { + strcpy(reply, "Error: must be 0, 1, on, or off"); + } + } else if (memcmp(config, "radio.rxgain ", 13) == 0) { + int val = cliOnOff(&config[13], cliDefaults()->rx_boost); + if (val == 0 || val == 1) { + /* Always save (upstream f3d4d8cd), then apply live and + * report when the radio has no RX boost feature. */ + _prefs->rx_boost = (uint8_t)val; + savePrefs(); + if (_callbacks->setRxBoostedGain(val == 1)) { + snprintf(reply, CLI_REPLY_SIZE, "OK - radio.rxgain=%d", _prefs->rx_boost); + } else { + strcpy(reply, "Error: unsupported"); + } + } else { + strcpy(reply, "Error: must be 0, 1, on, or off"); + } + } else { + return false; + } + return true; + } + return false; +} + +void CommonCLI::handleGetCmd(uint32_t sender_timestamp, const char* command, char* reply) { + const char* config = &command[4]; + /* MUST stay above the "leds" branch: that one compares only the first + * four characters, so "leds.radio" and "leds.hb" both match it and + * would otherwise return the master switch instead. */ + if (memcmp(config, "leds.radio", 10) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s%s", + cliModeName(LEDS_RADIO_NAMES, 4, _prefs->leds_radio_mode), + CLI_HAS_RADIO_LED ? "" : " (no radio LED on this board)"); + } else if (memcmp(config, "leds.hb", 7) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s%s", + cliModeName(LEDS_HB_NAMES, 4, _prefs->leds_hb_mode), + CLI_HAS_HB_LED ? "" : " (no heartbeat LED on this board)"); + } else if (memcmp(config, "leds", 4) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->leds_disabled ? "off" : "on"); +#ifndef ZEPHCORE_REPEATER + } else if (memcmp(config, "buzzer", 6) == 0) { + uint8_t mode = zephcore_buzzer_mode_from_prefs(_prefs->buzzer_quiet); + snprintf(reply, CLI_REPLY_SIZE, "> %u (%s)", mode, + zephcore_buzzer_mode_name(mode)); +#endif +#ifdef CONFIG_ZEPHCORE_ROLE_ROOM_SERVER + /* Room server only. RoomServerMesh.cpp is the sole consumer of + * allow_read_only; RepeaterMesh.cpp never reads it, so on a repeater + * this advertised a setting that silently did nothing. Note this is a + * deliberate divergence from Arduino MeshCore, whose shared CommonCLI + * exposes the knob on every role for the same reason ours used to. + * + * The pref itself stays unconditional -- it is byte 114 of the on-flash + * prefs layout, identical to Arduino's, so dropping it would shift every + * field after it and invalidate existing prefs files. */ + } else if (memcmp(config, "allow.read.only", 15) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->allow_read_only ? "on" : "off"); +#endif + } else if (memcmp(config, "flood.advert.interval", 21) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_advert_interval); + } else if (memcmp(config, "advert.interval", 15) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %u", ((uint32_t)_prefs->advert_interval) * 2); + } else if (memcmp(config, "guest.password", 14) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->guest_password); + } else if (sender_timestamp == 0 && memcmp(config, "prv.key", 7) == 0) { + uint8_t prv_key[PRV_KEY_SIZE]; + int len = _callbacks->getSelfId().writeTo(prv_key, PRV_KEY_SIZE); + mesh::Utils::toHex(tmp, prv_key, len); + snprintf(reply, CLI_REPLY_SIZE, "> %s", tmp); + } else if (memcmp(config, "name", 4) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->node_name); + } else if (memcmp(config, "repeat", 6) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->disable_fwd ? "off" : "on"); + } else if (memcmp(config, "lat", 3) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %.6f", _prefs->node_lat); + } else if (memcmp(config, "lon", 3) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %.6f", _prefs->node_lon); + } else if (memcmp(config, "flood.max.advert", 16) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_max_advert); + } else if (memcmp(config, "flood.max.unscoped", 18) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_max_unscoped); + } else if (memcmp(config, "flood.max", 9) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->flood_max); + } else if (memcmp(config, "backoff.multiplier", 18) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %.2f", (double)_prefs->backoff_multiplier); + } else if (memcmp(config, "owner.info", 10) == 0) { + *reply++ = '>'; + *reply++ = ' '; + const char* sp = _prefs->owner_info; + while (*sp) { + *reply++ = (*sp == '\n') ? '|' : *sp; + sp++; + } + *reply = 0; + } else if (memcmp(config, "loop.detect", 11) == 0) { + if (_prefs->loop_detect == LOOP_DETECT_OFF) { + strcpy(reply, "> off"); + } else if (_prefs->loop_detect == LOOP_DETECT_MINIMAL) { + strcpy(reply, "> minimal"); + } else if (_prefs->loop_detect == LOOP_DETECT_MODERATE) { + strcpy(reply, "> moderate"); + } else { + strcpy(reply, "> strict"); + } + } else if (memcmp(config, "public.key", 10) == 0) { + strcpy(reply, "> "); + mesh::Utils::toHex(&reply[2], _callbacks->getSelfId().pub_key, PUB_KEY_SIZE); + } else if (memcmp(config, "role", 4) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _callbacks->getRole()); + } else if (memcmp(config, "bootloader.ver", 14) == 0) { + char ver[32]; + if (_board->getBootloaderVersion(ver, sizeof(ver))) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", ver); + } else { + strcpy(reply, "> unknown"); + } + } else if (memcmp(config, "adc.multiplier", 14) == 0) { + float adc_mult = _board->getAdcMultiplier(); + if (adc_mult == 0.0f) { + strcpy(reply, "Error: unsupported by this board"); + } else { + uint16_t mv = _board->getBattMilliVolts(); + uint16_t target_mv = battery_curve_default.ocv_mv[0]; + if (mv > 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %.3f (%u mV, target >= %u mV for 100%%)", + (double)adc_mult, mv, target_mv); + } else { + snprintf(reply, CLI_REPLY_SIZE, "> %.3f (no ADC reading)", (double)adc_mult); + } + } + } else if (memcmp(config, "rxduty", 6) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->rx_duty_cycle); + } else if (memcmp(config, "display.rotate", 14) == 0) { +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) && MC_DISPLAY_ROTATE_SUPPORTED + // Report the live panel state, not the stored byte: they only + // differ if a rotation was refused, and that is exactly the case + // worth seeing. + snprintf(reply, CLI_REPLY_SIZE, "> %d", mc_display_is_rotated() ? 1 : 0); +#else + strcpy(reply, "> unsupported (panel cannot rotate)"); +#endif + } else if (memcmp(config, "input.rotate", 12) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %d", zephcore_input_is_flipped() ? 1 : 0); + } else if (memcmp(config, "tz.offset", 9) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->tz_offset); + } else if (memcmp(config, "gps diag", 8) == 0) { + /* What the last module-configuration attempt actually did. + * Self-limits on the remote path like cad.stats/meshtimesync: + * gps_get_diag_report() bounds itself by the length it is + * handed, and the full report (long module version + wide + * counters + the no-GPS_SAT_DIAG tail) can exceed the + * caller's packet buffer. */ + size_t cap = replyCap(sender_timestamp); + reply[0] = '>'; reply[1] = ' '; + gps_get_diag_report(reply + 2, cap - 2); + } else if (memcmp(config, "gps duty", 8) == 0) { + uint32_t s = gps_get_poll_interval_sec(); // now-effective value + if (s == 0) strcpy(reply, "> always on (0)"); + else snprintf(reply, CLI_REPLY_SIZE, "> %u", (unsigned)s); +#if IS_ENABLED(CONFIG_ZEPHCORE_MEM_STATS) + } else if (memcmp(config, "mem", 3) == 0) { + memStatsLine(atoi(&config[3]), reply); +#endif +#if IS_ENABLED(CONFIG_ZEPHCORE_LOOP_WAKE_STATS) + } else if (memcmp(config, "loop.wakes", 10) == 0) { + loopWakeFormat(reply, replyCap(sender_timestamp)); +#endif + } else if (memcmp(config, "dc.restarts", 11) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %u", + (uint32_t)_callbacks->getDutyCycleTimeoutRestarts()); + } else if (memcmp(config, "probe.interval", 14) == 0) { + /* Seconds between periodic radio measurements — the noise-floor + * sample and the CAD probe that consumes it. 0 = probing off. */ + snprintf(reply, CLI_REPLY_SIZE, "> %u", + (uint32_t)_prefs->probe_interval); + } else if (memcmp(config, "cad.stats", 9) == 0) { + /* Runtime state + per-level probe stats live in the radio. + * ZephCore-only; must stay ahead of the "cad" prefix match below. + * Remote replies get the truncated buffer like meshtimesync. */ + size_t cap = replyCap(sender_timestamp); + int n = snprintf(reply, cap, "> "); + if (_callbacks->formatCadStatus(reply + n, (int)cap - n) == 0) { + strcpy(reply, "not available"); + } + } else if (memcmp(config, "cad.auto", 8) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", + _prefs->cad_auto ? "on" : "off"); + } else if (memcmp(config, "cad.offset", 10) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %d", (int)_prefs->cad_offset); + } else if (memcmp(config, "cad.busycap", 11) == 0) { + if (_prefs->cad_busycap == 0) { + strcpy(reply, "> 0 (off)"); + } else { + snprintf(reply, CLI_REPLY_SIZE, "> %u", + (unsigned)_prefs->cad_busycap); + } + } else if (strcmp(config, "cad") == 0) { + /* Arduino exposes this as a boolean knob; ZephCore always does CAD, + * so the answer is a constant "on". Apps parse the word. + * + * EXACT match, not the 3-byte prefix this used to be. Every branch + * in this chain is a memcmp prefix test, so "cad" matched every + * `get cad.*` that had no branch of its own and answered "on" for + * all of them -- a percentage, a signed offset and an action all + * came back as a boolean. cad.stats escaped only by being ordered + * above it, which is a fragile way to stay correct. Anchoring this + * one fixes the whole family at once: anything under cad. without a + * getter now falls through to the honest "??:" reply instead of + * being answered wrongly. */ + strcpy(reply, "> on"); + } else if (memcmp(config, "extra.sf", 8) == 0) { + /* No "> " prefix, and the empty case is a sentence -- both match + * Arduino MeshCore exactly. */ + char* dp = reply; + int shown = 0; + for (int i = 0; i < EXTRA_SF_MAX && _prefs->extra_sf[i] != 0; i++) { + dp += sprintf(dp, "%s%u", shown++ ? "," : "", + (unsigned)_prefs->extra_sf[i]); + } + if (shown == 0) { + strcpy(reply, "No extra SF configured"); + } + } else if (memcmp(config, "meshtimesync", 12) == 0) { + MeshTimeSync* ts = _callbacks->getMeshTimeSync(); + if (ts == nullptr) { + strcpy(reply, "not available"); + } else { + /* Only the local USB CLI (sender_timestamp == 0) gets the + * full evidence table; remote replies are truncated to the + * packet buffer. */ + size_t cap = replyCap(sender_timestamp); + ts->formatStatus(reply, cap, getRTCClock()->getCurrentTime(), + (uint32_t)(k_uptime_get() / 1000), + _prefs->meshtimesync != 0); + } + } else { + snprintf(reply, CLI_REPLY_SIZE, "??: %s", config); + } +} + +void CommonCLI::handleSetCmd(uint32_t sender_timestamp, const char* command, char* reply) { + const char* config = &command[4]; + if (memcmp(config, "leds.radio ", 11) == 0) { + /* What the LoRa activity LED reacts to. Below the master switch: + * "set leds off" keeps it dark whatever this says. */ + int m = cliModeValue(LEDS_RADIO_NAMES, 4, &config[11], + cliDefaults()->leds_radio_mode); + if (m < 0) { + strcpy(reply, "Error: must be tx, rx, all, off or default"); + } else { + _prefs->leds_radio_mode = (uint8_t)m; + zephcore_leds_set_radio_mode((uint8_t)m); + savePrefs(); + snprintf(reply, CLI_REPLY_SIZE, "OK%s", + CLI_HAS_RADIO_LED ? "" : " (no radio LED on this board)"); + } + } else if (memcmp(config, "leds.hb ", 8) == 0) { + /* What the heartbeat LED reacts to. "unread" is the same cycle + * widening its pulse, not a separate blink — see led_gate.h. */ + int m = cliModeValue(LEDS_HB_NAMES, 4, &config[8], + cliDefaults()->leds_hb_mode); + if (m < 0) { + strcpy(reply, "Error: must be hb, unread, all, off or default"); + } else { + _prefs->leds_hb_mode = (uint8_t)m; + zephcore_leds_set_hb_mode((uint8_t)m); + savePrefs(); + snprintf(reply, CLI_REPLY_SIZE, "OK%s", + CLI_HAS_HB_LED ? "" : " (no heartbeat LED on this board)"); + } + } else if (memcmp(config, "leds ", 5) == 0) { + /* Master switch for every LED on the node: heartbeat, unread-message + * and LoRa TX activity, plus the message and shutdown flashes. Not + * the display backlight — that has its own UI brightness setting. */ + int on = cliOnOff(&config[5], cliDefaults()->leds_disabled ? 0 : 1); + if (on < 0) { + strcpy(reply, "Error: must be on, off or default"); + } else { + _prefs->leds_disabled = on ? 0 : 1; + zephcore_leds_set_disabled(_prefs->leds_disabled != 0); + savePrefs(); + strcpy(reply, "OK"); + } +#ifndef ZEPHCORE_REPEATER + } else if (memcmp(config, "buzzer ", 7) == 0) { + /* 0 = silent, 1 = sound + vibration, 2 = vibration only, + * 3 = sound only. Modes 2 and 3 only mean something on a board + * with a vibration motor, which most boards don't have. */ + const char* val = &config[7]; + int mode; + if (memcmp(val, "vibrate", 7) == 0 || val[0] == '2') { + mode = ZEPHCORE_BUZZER_VIBRATE; + } else if (memcmp(val, "sound", 5) == 0 || val[0] == '3') { + mode = ZEPHCORE_BUZZER_SOUND; + } else if (memcmp(val, "on", 2) == 0 || val[0] == '1') { + mode = ZEPHCORE_BUZZER_ON; + } else if (memcmp(val, "off", 3) == 0 || val[0] == '0') { + mode = ZEPHCORE_BUZZER_OFF; + } else if (cliIsDefault(val)) { + mode = zephcore_buzzer_mode_from_prefs(cliDefaults()->buzzer_quiet); + } else { + mode = -1; + } + if (mode < 0) { + strcpy(reply, "Error: 0 (silent), 1 (sound+vib), 2 (vibrate), 3 (sound) or default"); + } else if ((mode == ZEPHCORE_BUZZER_VIBRATE || mode == ZEPHCORE_BUZZER_SOUND) && + !zephcore_buzzer_has_vibrate()) { + strcpy(reply, "Error: no vibration motor on this board - use 0 or 1"); + } else { + _prefs->buzzer_quiet = zephcore_buzzer_prefs_from_mode((uint8_t)mode); + zephcore_buzzer_set_mode((uint8_t)mode, false); + ui_set_buzzer_mode((uint8_t)mode); + savePrefs(); + strcpy(reply, "OK"); + } +#endif + } else if (memcmp(config, "cad.auto ", 9) == 0) { + int on = cliOnOff(&config[9], cliDefaults()->cad_auto); + if (on >= 0) { + _prefs->cad_auto = (uint8_t)on; + _callbacks->applyCadPrefs(); + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: must be on, off or default"); + } + } else if (memcmp(config, "cad.offset ", 11) == 0) { + long val; + if (!cliNum(&config[11], cliDefaults()->cad_offset, &val)) { + snprintf(reply, CLI_REPLY_SIZE, "Error: expected %d..%d or default", + CAD_OFFSET_MIN, CAD_OFFSET_MAX); + } else if (val < CAD_OFFSET_MIN || val > CAD_OFFSET_MAX) { + snprintf(reply, CLI_REPLY_SIZE, "Error: offset range is %d..%d", + CAD_OFFSET_MIN, CAD_OFFSET_MAX); + } else { + _prefs->cad_offset = (int8_t)val; + _callbacks->applyCadPrefs(); + savePrefs(); + strcpy(reply, "OK"); + } + /* Governs every periodic radio measurement, not just CAD — the + * noise-floor sampler and the CAD probe share one reading. */ + } else if (memcmp(config, "probe.interval ", 15) == 0) { + long val; + if (!cliNum(&config[15], cliDefaults()->probe_interval, &val)) { + strcpy(reply, "Error: interval is 0 (probing off), 10-255 seconds, or default"); + } else if (val != 0 && (val < 10 || val > 255)) { + strcpy(reply, "Error: interval is 0 (probing off) or 10-255 seconds"); + } else { + _prefs->probe_interval = (uint8_t)val; + _callbacks->applyCadPrefs(); + savePrefs(); + strcpy(reply, "OK"); + } + } else if (memcmp(config, "cad.busycap ", 12) == 0) { + long val; + if (!cliNum(&config[12], cliDefaults()->cad_busycap, &val)) { + strcpy(reply, "Error: busycap is 0 (off), 10-90 percent, or default"); + } else if (val != 0 && (val < 10 || val > 90)) { + strcpy(reply, "Error: busycap is 0 (off) or 10-90 percent"); + } else { + _prefs->cad_busycap = (uint8_t)val; + _callbacks->applyCadPrefs(); + savePrefs(); + strcpy(reply, "OK"); + } + } else if (memcmp(config, "cad.reset", 9) == 0) { + /* Clearing the probe statistics alone left the node re-converging + * FROM wherever the staircase had already walked detPeak, with no + * evidence left to justify sitting there — the opposite of a reset, + * and useless for the one job this command has (recovering after a + * base-table change). + * + * preset_pending = false: the radio is running the preset this + * reset is for, so the base applyCadPrefs() stamps is the right + * one to keep. */ + resetCadState(false); + snprintf(reply, CLI_REPLY_SIZE, + "OK - CAD probe stats cleared, detPeak offset reset to %d", + (int)_prefs->cad_offset); + } else if (memcmp(config, "extra.sf ", 9) == 0) { + /* LR2021 side detectors: up to 3 extra SFs received alongside + * `sf`. "0" / "off" clears the set. The chip-side constraints + * (each > sf, distinct, spread <= 4, BW>=500 caps the count) are + * enforced in the driver, so an accepted set is a valid one. */ + char tmp[32]; + const char* parts[EXTRA_SF_MAX + 1]; + uint8_t sfs[EXTRA_SF_MAX] = {0}; + StrHelper::strncpy(tmp, &config[9], sizeof(tmp)); + int num = mesh::Utils::parseTextParts(tmp, parts, EXTRA_SF_MAX + 1, ' '); + if (num == 1 && (strcmp(parts[0], "0") == 0 || strcmp(parts[0], "off") == 0)) { + num = 0; + } + if (num > EXTRA_SF_MAX) { + sprintf(reply, "Error: at most %d extra SFs", EXTRA_SF_MAX); + } else { + for (int i = 0; i < num; i++) sfs[i] = (uint8_t)atoi(parts[i]); + if (_callbacks->configSideDetectors(sfs, (uint8_t)num)) { + memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf)); + for (int i = 0; i < num; i++) _prefs->extra_sf[i] = sfs[i]; + savePrefs(); + strcpy(reply, num ? "OK - extra SFs set" : "OK - extra SFs cleared"); + } else { + strcpy(reply, "Error: unsupported or invalid extra SF config"); + } + } +#ifdef CONFIG_ZEPHCORE_ROLE_ROOM_SERVER + /* Room server only -- see the matching guard on the `get` side. */ + } else if (memcmp(config, "allow.read.only ", 16) == 0) { + int on = cliOnOff(&config[16], cliDefaults()->allow_read_only); + if (on >= 0) { + _prefs->allow_read_only = (uint8_t)on; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: must be on, off or default"); + } +#endif + } else if (memcmp(config, "flood.advert.interval ", 22) == 0) { + long hours; + if (!cliNum(&config[22], cliDefaults()->flood_advert_interval, &hours)) { + strcpy(reply, "Error: expected 0, 3-168 hours, or default"); + } else if ((hours > 0 && hours < 3) || (hours > 168)) { + strcpy(reply, "Error: interval range is 3-168 hours"); + } else { + _prefs->flood_advert_interval = (uint8_t)hours; + _callbacks->updateFloodAdvertTimer(); + savePrefs(); + strcpy(reply, "OK"); + } + } else if (memcmp(config, "advert.interval ", 16) == 0) { + long mins; + if (!cliNum(&config[16], (long)cliDefaults()->advert_interval * 2, &mins)) { + snprintf(reply, CLI_REPLY_SIZE, + "Error: expected 0, %d-240 minutes, or default", + MIN_LOCAL_ADVERT_INTERVAL); + } else if ((mins > 0 && mins < MIN_LOCAL_ADVERT_INTERVAL) || (mins > 240)) { + snprintf(reply, CLI_REPLY_SIZE, "Error: interval range is %d-240 minutes", MIN_LOCAL_ADVERT_INTERVAL); + } else { + _prefs->advert_interval = (uint8_t)(mins / 2); + _callbacks->updateAdvertTimer(); + savePrefs(); + strcpy(reply, "OK"); + } + } else if (memcmp(config, "guest.password ", 15) == 0) { + StrHelper::strzcpy(_prefs->guest_password, &config[15], sizeof(_prefs->guest_password)); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "prv.key ", 8) == 0) { + uint8_t prv_key[PRV_KEY_SIZE]; + bool success = mesh::Utils::fromHex(prv_key, PRV_KEY_SIZE, &config[8]); + if (success && mesh::LocalIdentity::validatePrivateKey(prv_key)) { + mesh::LocalIdentity new_id; + new_id.readFrom(prv_key, PRV_KEY_SIZE); + _callbacks->saveIdentity(new_id); + strcpy(reply, "OK, reboot to apply! New pubkey: "); + mesh::Utils::toHex(&reply[33], new_id.pub_key, PUB_KEY_SIZE); + } else { + strcpy(reply, "Error, bad key"); + } + } else if (memcmp(config, "name ", 5) == 0) { + if (isValidName(&config[5])) { + StrHelper::strncpy(_prefs->node_name, &config[5], sizeof(_prefs->node_name)); + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: name cannot contain [ ] \\ : , ? *"); + } + } else if (memcmp(config, "repeat ", 7) == 0) { + if (memcmp(&config[7], "on", 2) == 0) { + _prefs->disable_fwd = 0; + savePrefs(); + strcpy(reply, "OK - repeat is now ON"); + } else if (memcmp(&config[7], "off", 3) == 0) { + _prefs->disable_fwd = 1; + savePrefs(); + strcpy(reply, "OK - repeat is now OFF"); + } else { + strcpy(reply, "Error: must be on or off"); + } + } else if (memcmp(config, "lat ", 4) == 0) { + _prefs->node_lat = cliIsDefault(&config[4]) ? cliDefaults()->node_lat + : atof(&config[4]); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "lon ", 4) == 0) { + _prefs->node_lon = cliIsDefault(&config[4]) ? cliDefaults()->node_lon + : atof(&config[4]); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "flood.max.advert ", 17) == 0) { + long m; + if (cliNum(&config[17], cliDefaults()->flood_max_advert, &m) && m >= 0 && m <= 64) { + _prefs->flood_max_advert = (uint8_t)m; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: range 0-64, or default"); + } + } else if (memcmp(config, "flood.max.unscoped ", 19) == 0) { + long m; + if (cliNum(&config[19], cliDefaults()->flood_max_unscoped, &m) && m >= 0 && m <= 64) { + _prefs->flood_max_unscoped = (uint8_t)m; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: range 0-64, or default"); + } + } else if (memcmp(config, "flood.max ", 10) == 0) { + long m; + if (cliNum(&config[10], cliDefaults()->flood_max, &m) && m >= 0 && m <= 64) { + _prefs->flood_max = (uint8_t)m; + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: range 0-64, or default"); + } + } else if (memcmp(config, "backoff.multiplier ", 19) == 0) { + /* Companion's setBackoffMultiplier callback is the base-class + * no-op and the value isn't restored at boot — reject instead of + * a false OK. */ + if (strcmp(_callbacks->getRole(), "companion") == 0) { + strcpy(reply, "Error: not supported on companion"); + } else { + /* initNodePrefs() leaves this at 0.0 (memset) even though the + * live default is ContentionTracker::DEFAULT_BACKOFF_MULT and + * RepeaterDataStore migrates 0.0 -> 0.2 on load, so `default` + * uses the real value rather than the prefs blank. */ + float f; + if (!cliFloat(&config[19], 0.2f, &f)) { + strcpy(reply, "Error, range 0.0-2.0, or default"); + } else if (f >= 0.0f && f <= 2.0f) { + _prefs->backoff_multiplier = f; + _callbacks->setBackoffMultiplier(f); + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error, range 0.0-2.0"); + } + } + } else if (memcmp(config, "owner.info ", 11) == 0) { + config += 11; + char* dp = _prefs->owner_info; + while (*config && dp - _prefs->owner_info < (int)sizeof(_prefs->owner_info) - 1) { + *dp++ = (*config == '|') ? '\n' : *config; + config++; + } + *dp = 0; + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(config, "loop.detect ", 12) == 0) { + /* Loop detection runs only in the Repeater/RoomServer forward + * path — companions never consult loop_detect. */ + if (strcmp(_callbacks->getRole(), "companion") == 0) { + strcpy(reply, "Error: not supported on companion"); + return; + } + config += 12; + uint8_t mode; + if (memcmp(config, "off", 3) == 0) { + mode = LOOP_DETECT_OFF; + } else if (memcmp(config, "minimal", 7) == 0) { + mode = LOOP_DETECT_MINIMAL; + } else if (memcmp(config, "moderate", 8) == 0) { + mode = LOOP_DETECT_MODERATE; + } else if (memcmp(config, "strict", 6) == 0) { + mode = LOOP_DETECT_STRICT; + } else if (cliIsDefault(config)) { + mode = cliDefaults()->loop_detect; + } else { + mode = 0xFF; + strcpy(reply, "Error, must be: off, minimal, moderate, strict, or default"); + } + if (mode != 0xFF) { + _prefs->loop_detect = mode; + savePrefs(); + strcpy(reply, "OK"); + } + } else if (strcmp(config, "adc.multiplier target") == 0) { + strcpy(reply, "Error: need mV target (e.g. set adc.multiplier target 4173)"); + } else if (memcmp(config, "adc.multiplier target ", 22) == 0) { + /* Calibrate against a known voltage measured with a multimeter. */ + uint16_t target_mv = (uint16_t)atoi(&config[22]); + uint16_t current_mv = _board->getBattMilliVolts(); + if (current_mv == 0) { + strcpy(reply, "Error: no ADC reading on this board"); + } else if (target_mv < 3000 || target_mv > 4400) { + strcpy(reply, "Error: target out of range (3000-4400 mV)"); + } else { + float current_mult = _board->getAdcMultiplier(); + float new_mult = current_mult * (float)target_mv / (float)current_mv; + _prefs->adc_multiplier = new_mult; + if (_board->setAdcMultiplier(new_mult)) { + savePrefs(); + snprintf(reply, CLI_REPLY_SIZE, + "OK - multiplier %.3f -> %.3f (%u -> %u mV)", + (double)current_mult, (double)new_mult, + current_mv, target_mv); + } else { + _prefs->adc_multiplier = 0.0f; + strcpy(reply, "Error: unsupported by this board"); + } + } + } else if (memcmp(config, "adc.multiplier full", 19) == 0) { + /* Calibrate: board must be on a full charge. Scales the current + * multiplier so the ADC reads the board's curve 100% point. */ + uint16_t current_mv = _board->getBattMilliVolts(); + if (current_mv == 0) { + strcpy(reply, "Error: no ADC reading on this board"); + } else { + uint16_t target_mv = battery_curve_default.ocv_mv[0]; + float current_mult = _board->getAdcMultiplier(); + float new_mult = current_mult * (float)target_mv / (float)current_mv; + _prefs->adc_multiplier = new_mult; + if (_board->setAdcMultiplier(new_mult)) { + savePrefs(); + snprintf(reply, CLI_REPLY_SIZE, + "OK - multiplier %.3f -> %.3f (%u -> %u mV)", + (double)current_mult, (double)new_mult, + current_mv, target_mv); + } else { + _prefs->adc_multiplier = 0.0f; + strcpy(reply, "Error: unsupported by this board"); + } + } + } else if (memcmp(config, "adc.multiplier ", 15) == 0) { + const char *arg = &config[15]; + /* 0 is valid (resets to the DTS default). cliFloat() rejects the + * non-numeric input that atof() used to fold into a 0 here, so the + * old "distinguish from literal 0" dance is gone. Upper bound + * covers all real divider/reference combinations with margin. */ + float val; + bool bad = !cliFloat(arg, cliDefaults()->adc_multiplier, &val); + if (!bad) bad = (val != 0.0f && val < 100.0f) || val > 30000.0f || val < 0.0f; + if (bad) { + strcpy(reply, "Error: invalid multiplier (0 to reset, 100-30000, or default)"); + } else if (_board->setAdcMultiplier(val)) { + _prefs->adc_multiplier = val; + savePrefs(); + if (val == 0.0f) { + strcpy(reply, "OK - using default board multiplier"); + } else { + snprintf(reply, CLI_REPLY_SIZE, "OK - multiplier set to %.3f", (double)val); + } + } else { + strcpy(reply, "Error: unsupported by this board"); + } + } else if (memcmp(config, "rxduty ", 7) == 0) { + int val = cliOnOff(&config[7], cliDefaults()->rx_duty_cycle); + if (val == 0 || val == 1) { + _prefs->rx_duty_cycle = (uint8_t)val; + savePrefs(); + snprintf(reply, CLI_REPLY_SIZE, "OK - rxduty=%d (reboot to apply)", _prefs->rx_duty_cycle); + } else { + strcpy(reply, "Error: must be 0, 1, on, or off"); + } + } else if (memcmp(config, "display.rotate ", 15) == 0) { + // Rotate the panel 180 degrees for cases that mount it upside down. + // Applied immediately -- the SSD1306/SH1106 remap is two bytes on + // the wire and the next frame comes out flipped, no redraw needed. + int val = cliOnOff(&config[15], cliDefaults()->display_rotate); + if (val != 0 && val != 1) { + strcpy(reply, "Error: must be 0, 1, on, or off"); + } else { +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) + int ret = mc_display_set_rotated(val == 1); + if (ret == 0) { + // Persist only what the panel actually did, so a stored + // value can never disagree with what the screen shows. + _prefs->display_rotate = (uint8_t)val; + savePrefs(); + snprintf(reply, CLI_REPLY_SIZE, "OK - display.rotate=%d", val); + } else if (ret == -ENOTSUP) { + strcpy(reply, "Error: this panel cannot rotate"); + } else { + snprintf(reply, CLI_REPLY_SIZE, "Error: rotate failed (%d)", ret); + } +#else + strcpy(reply, "Error: no display"); +#endif + } + } else if (memcmp(config, "input.rotate ", 13) == 0) { + // Swap the joystick/D-pad axes to match an upside-down mount. + // Deliberately separate from display.rotate: a case can flip the + // screen without flipping the stick, and boards whose panel cannot + // rotate can still need the axis swap. + int val = cliOnOff(&config[13], cliDefaults()->input_rotate); + if (val == 0 || val == 1) { + zephcore_input_set_flipped(val == 1); + _prefs->input_rotate = (uint8_t)val; + savePrefs(); + snprintf(reply, CLI_REPLY_SIZE, "OK - input.rotate=%d", val); + } else { + strcpy(reply, "Error: must be 0, 1, on, or off"); + } + } else if (memcmp(config, "tz.offset ", 10) == 0) { + // Whole-hour offset from UTC for the ON-DEVICE CLOCK DISPLAY only. + // The RTC, `clock` and `time ` all stay UTC: they round-trip + // with each other, apps parse them, and a timezone that reached the + // clock would look like a backward jump to every timestamp consumer + // on the node (see NodePrefs::tz_offset). + // + // Whole hours, matching upstream MeshCore's command of the same + // name, so an app that speaks to both trees behaves identically. + // Half-hour zones (+5:30, -3:30) cannot be expressed. + long val; + if (!cliNum(&config[10], cliDefaults()->tz_offset, &val)) { + snprintf(reply, CLI_REPLY_SIZE, "Error: expected %d..%d or default", + TZ_OFFSET_MIN, TZ_OFFSET_MAX); + } else if (val < TZ_OFFSET_MIN || val > TZ_OFFSET_MAX) { + snprintf(reply, CLI_REPLY_SIZE, "Error: offset range is %d..%d", + TZ_OFFSET_MIN, TZ_OFFSET_MAX); + } else { + _prefs->tz_offset = (int8_t)val; + savePrefs(); + snprintf(reply, CLI_REPLY_SIZE, "OK - tz.offset=%d", (int)val); + } + } else if (memcmp(config, "gps diag", 8) == 0) { + // set gps diag <0|1|on|off> — arm module-configuration reporting. + // Not persisted: clears on reboot, by design. + /* Not persisted, so "default" means the boot state: off. */ + int val = cliOnOff(config + 8, 0); + if (val == 0 || val == 1) { + gps_set_diag(val == 1); + if (val == 1) { + strcpy(reply, "OK - gps diag on; run 'gps off' then 'gps on', " + "then 'get gps diag'"); + } else { + strcpy(reply, "OK - gps diag off"); + } + } else { + strcpy(reply, "usage: set gps diag <0|1|on|off>"); + } + } else if (memcmp(config, "gps duty", 8) == 0) { + // set gps duty | default (0 = always on) + const char* arg = config + 8; + while (*arg == ' ') arg++; + uint32_t val = 0; + bool ok = true; + if (*arg == '\0') { + ok = false; + } else if (strcmp(arg, "default") == 0) { + val = _callbacks->getDefaultGpsIntervalSec(); + } else { + char* end = NULL; + unsigned long parsed = strtoul(arg, &end, 10); + // reject non-numeric, fractions, or trailing garbage + if (end == arg || *end != '\0') ok = false; + else val = (uint32_t)parsed; + } + if (!ok) { + strcpy(reply, "usage: set gps duty | default (0 = always on)"); + } else { + if (val > 604800UL) val = 604800UL; // cap at 1 week + else if (val != 0 && val < 10) val = 10; // floor 10s (0 = always on) + _prefs->gps_interval = val; + gps_set_poll_interval_sec(val); // apply live + savePrefs(); + if (val == 0) strcpy(reply, "OK - gps duty=0 (always on)"); + else snprintf(reply, CLI_REPLY_SIZE, "OK - gps duty=%u s", (unsigned)val); + } + } else if (memcmp(config, "meshtimesync ", 13) == 0) { + int on = cliOnOff(&config[13], cliDefaults()->meshtimesync); + if (_callbacks->getMeshTimeSync() == nullptr) { + strcpy(reply, "not available"); + } else if (on < 0) { + strcpy(reply, "Error: must be on, off or default"); + } else { + _prefs->meshtimesync = (uint8_t)on; + savePrefs(); + snprintf(reply, CLI_REPLY_SIZE, "OK - meshtimesync %s", on ? "on" : "off"); + } + } else { + snprintf(reply, CLI_REPLY_SIZE, "unknown config: %.230s", config); + } +} + + +#ifndef ZEPHCORE_COMPANION /* the companion has no region map */ + +/* ---- region CLI (upstream CommonCLI::handleRegionCmd) ---- */ + +/* parseTextParts needs a writable command; ours arrives const. The copy lives + * here, not in handleCommand, so it costs stack only for a region command. */ +__attribute__((noinline)) void CommonCLI::handleRegionCmdCopy(uint32_t sender_timestamp, const char* command, char* reply) { + char buf[CLI_REPLY_SIZE]; + strncpy(buf, command, sizeof(buf) - 1); + buf[sizeof(buf) - 1] = 0; + handleRegionCmd(sender_timestamp, buf, reply); +} + +static char* skipSpaces(char* s) { + while (*s == ' ') s++; + return s; +} + +static void rtrimSpaces(char* s) { + char* e = s + strlen(s); + while (e > s && e[-1] == ' ') *--e = '\0'; +} + +static char* takeToken(char** cursor) { + char* p = skipSpaces(*cursor); + if (*p == '\0') { *cursor = p; return nullptr; } + char* tok = p; + while (*p && *p != ' ') p++; + if (*p) *p++ = '\0'; + *cursor = p; + return tok; +} + +static char* splitNameJump(char* tok) { + for (char* q = tok; *q; q++) { + if (*q == '|' || *q == ',') { + *q = '\0'; + char* jump = skipSpaces(q + 1); + rtrimSpaces(jump); + return jump; + } + } + return nullptr; +} + +static bool processRegionDefSegment(RegionMap* map, char* tok, RegionEntry** cursor, char* reply, int cap) { + char* jump = splitNameJump(tok); + char* name = skipSpaces(tok); + if (*name == '\0') { snprintf(reply, cap, "Err - empty name"); return false; } + if (jump && *jump == '\0') { snprintf(reply, cap, "Err - empty jump"); return false; } + + RegionEntry* r = map->putRegion(name, (*cursor)->id); + if (r == NULL) { snprintf(reply, cap, "Err - put failed: %s", name); return false; } + r->flags = 0; + + if (jump) { + RegionEntry* j = map->findByNamePrefix(jump); + if (j == NULL) { snprintf(reply, cap, "Err - unknown jump: %s", jump); return false; } + *cursor = j; + } else { + *cursor = r; + } + return true; +} + +void CommonCLI::handleRegionCmd(uint32_t sender_timestamp, char* command, char* reply) { + reply[0] = 0; + // ZEPHCORE: upstream writes up to 160 bytes; a remote reply behind a reflected + // "xx|" prefix has only CLI_REMOTE_REPLY_SIZE - 3, so a large region map + // overran the caller's buffer by 2 bytes. + const int cap = replyCap(sender_timestamp) < 160 ? (int)replyCap(sender_timestamp) : 160; + + // `region def`: must run before parseTextParts mutates the buffer + char* cmd = skipSpaces(command); + if (strncmp(cmd, "region def", 10) == 0 && (cmd[10] == ' ' || cmd[10] == '\0')) { + char* payload = skipSpaces(cmd + 10); + rtrimSpaces(payload); + if (*payload == '\0') { snprintf(reply, cap, "Err - empty def"); return; } + + RegionEntry* cursor = &_region_map->getWildcard(); + for (char* tok; (tok = takeToken(&payload)) != nullptr; ) { + if (!processRegionDefSegment(_region_map, tok, &cursor, reply, cap)) return; + } + _region_map->exportTo(reply, cap); + return; + } + + const char* parts[4]; + int n = mesh::Utils::parseTextParts(command, parts, 4, ' '); + if (n == 1) { + _region_map->exportTo(reply, cap); + } else if (n >= 2 && strcmp(parts[1], "load") == 0) { + _callbacks->startRegionsLoad(); + } else if (n >= 2 && strcmp(parts[1], "save") == 0) { + _prefs->discovery_mod_timestamp = getRTCClock()->getCurrentTime(); // this node is now 'modified' (for discovery info) + savePrefs(); + bool success = _callbacks->saveRegions(); + strcpy(reply, success ? "OK" : "Err - save failed"); + } else if (n >= 3 && strcmp(parts[1], "allowf") == 0) { + auto region = _region_map->findByNamePrefix(parts[2]); + if (region) { + region->flags &= ~REGION_DENY_FLOOD; + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - unknown region"); + } + } else if (n >= 3 && strcmp(parts[1], "denyf") == 0) { + auto region = _region_map->findByNamePrefix(parts[2]); + if (region) { + region->flags |= REGION_DENY_FLOOD; + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - unknown region"); + } + } else if (n >= 3 && strcmp(parts[1], "get") == 0) { + auto region = _region_map->findByNamePrefix(parts[2]); + if (region) { + auto parent = _region_map->findById(region->parent); + if (parent && parent->id != 0) { + sprintf(reply, " %s (%s) %s", region->name, parent->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F"); + } else { + sprintf(reply, " %s %s", region->name, (region->flags & REGION_DENY_FLOOD) ? "" : "F"); + } + } else { + strcpy(reply, "Err - unknown region"); + } + } else if (n >= 3 && strcmp(parts[1], "home") == 0) { + auto home = _region_map->findByNamePrefix(parts[2]); + if (home) { + _region_map->setHomeRegion(home); + sprintf(reply, " home is now %s", home->name); + } else { + strcpy(reply, "Err - unknown region"); + } + } else if (n == 2 && strcmp(parts[1], "home") == 0) { + auto home = _region_map->getHomeRegion(); + sprintf(reply, " home is %s", home ? home->name : "*"); + } else if (n >= 3 && strcmp(parts[1], "default") == 0) { + if (strcmp(parts[2], "") == 0) { + _region_map->setDefaultRegion(NULL); + _callbacks->onDefaultRegionChanged(NULL); + _callbacks->saveRegions(); // persist in one atomic step + sprintf(reply, " default scope is now "); + } else { + auto def = _region_map->findByNamePrefix(parts[2]); + if (def == NULL) { + def = _region_map->putRegion(parts[2], 0); // auto-create the default region + } + if (def) { + def->flags = 0; // make sure allow flood enabled + _region_map->setDefaultRegion(def); + _callbacks->onDefaultRegionChanged(def); + _callbacks->saveRegions(); // persist in one atomic step + sprintf(reply, " default scope is now %s", def->name); + } else { + strcpy(reply, "Err - region table full"); + } + } + } else if (n == 2 && strcmp(parts[1], "default") == 0) { + auto def = _region_map->getDefaultRegion(); + sprintf(reply, " default scope is %s", def ? def->name : ""); + } else if (n >= 3 && strcmp(parts[1], "put") == 0) { + auto parent = n >= 4 ? _region_map->findByNamePrefix(parts[3]) : &(_region_map->getWildcard()); + if (parent == NULL) { + strcpy(reply, "Err - unknown parent"); + } else { + auto region = _region_map->putRegion(parts[2], parent->id); + if (region == NULL) { + strcpy(reply, "Err - unable to put"); + } else { + region->flags = 0; // New default: enable flood + strcpy(reply, "OK - (flood allowed)"); + } + } + } else if (n >= 3 && strcmp(parts[1], "remove") == 0) { + auto region = _region_map->findByName(parts[2]); + if (region) { + if (_region_map->removeRegion(*region)) { + strcpy(reply, "OK"); + } else { + strcpy(reply, "Err - not empty"); + } + } else { + strcpy(reply, "Err - not found"); + } + } else if (n >= 3 && strcmp(parts[1], "list") == 0) { + uint8_t mask = 0; + bool invert = false; + + if (strcmp(parts[2], "allowed") == 0) { + mask = REGION_DENY_FLOOD; + invert = false; // list regions that DON'T have DENY flag + } else if (strcmp(parts[2], "denied") == 0) { + mask = REGION_DENY_FLOOD; + invert = true; // list regions that DO have DENY flag + } else { + strcpy(reply, "Err - use 'allowed' or 'denied'"); + return; + } + + int len = _region_map->exportNamesTo(reply, cap, mask, invert); + if (len == 0) { + strcpy(reply, "-none-"); + } + } else { + strcpy(reply, "Err - ??"); + } +} + +#endif /* ZEPHCORE_COMPANION */ diff --git a/zephcore/helpers/CommonCLI.h b/zephcore/helpers/CommonCLI.h index 1ea0379..9d41319 100644 --- a/zephcore/helpers/CommonCLI.h +++ b/zephcore/helpers/CommonCLI.h @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: MIT - * CommonCLI - Common CLI command handlers for repeaters + * CommonCLI - the text CLI shared by every role */ #pragma once @@ -10,17 +10,16 @@ #include #include #include +#include #include "NodePrefs.h" class MeshTimeSync; -/* CLI reply buffer size — callers must provide at least this many bytes */ +/* A local caller's reply buffer (sender_timestamp 0) */ #define CLI_REPLY_SIZE 256 -/* Remote-admin replies ride in the caller's LoRa packet buffer: - * RepeaterMesh/RoomServerMesh onPeerDataRecv declare temp[5 + this] with the - * reply text at offset 5. Handlers that can exceed this must self-limit - * whenever sender_timestamp != 0 (0 marks the local USB CLI). */ +/* A remote reply rides in the caller's packet buffer, temp[5 + this] with the + * text at offset 5; see replyCap(). */ #define CLI_REMOTE_REPLY_SIZE 161 /* Deferred reboot types */ @@ -43,30 +42,23 @@ public: virtual void eraseLogFile() = 0; virtual void dumpLogFile() = 0; virtual void setTxPower(int8_t power_dbm) = 0; - /* Apply RX boosted gain live; returns false when the radio has no - * RX boost feature (upstream PR #2844 semantics). */ + /* The radio knobs below return false when the radio lacks the feature. */ virtual bool setRxBoostedGain(bool enable) { (void)enable; return false; } - /* Gate an external FEM/LNA in the RX direction, live. Same semantics: - * false means the radio driver has no such knob. On a supported radio - * whose board has no FEM wired it returns true and does nothing. */ + /* External FEM/LNA in RX. True and a no-op on a supporting radio whose + * board has no FEM wired. */ virtual bool setFemRxGain(bool enable) { (void)enable; return false; } - /* Configure LR2021 side detectors (multi-SF receive); num = 0 disables. - * Returns false when the radio has no side detectors, or when the set - * violates a chip constraint — the driver is the validator. */ + /* LR2021 multi-SF receive; num 0 disables. The driver validates the set. */ virtual bool configSideDetectors(const uint8_t* sfs, uint8_t num) { (void)sfs; (void)num; return false; } - /* Repeater-specific — default replies keep companion builds clean. - * Repeater overrides all four; companions get "not available". */ + /* Repeater only; other roles answer "not available". */ virtual void formatNeighborsReply(char* reply) { strcpy(reply, "not available"); } virtual void removeNeighbor(const uint8_t* pubkey, int key_len) { (void)pubkey; (void)key_len; } - /* True when the companion transport has nothing left to deliver: TX queue - * drained AND no delivery-ack still held back. Reboot-class commands poll - * this before resetting so the reply and its ack are not cut off by the - * reset they just scheduled. Default true — the repeater's LoRa reply is - * covered by the fixed pre-reboot delay instead. */ + /* Reboot-class commands wait for this so the reply (and a companion's held + * delivery ack) is not cut off by the reset. The server roles rely on the + * fixed pre-reboot delay. */ virtual bool transportTxIdle() { return true; } virtual void formatStatsReply(char* reply) { strcpy(reply, "not available"); } virtual void formatRadioStatsReply(char* reply) { strcpy(reply, "not available"); } @@ -75,10 +67,14 @@ public: virtual void saveIdentity(const mesh::LocalIdentity& new_id) = 0; virtual void clearStats() = 0; virtual void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) = 0; - /* Freeze the live radio on the given (old) params via radio override so the - * caller can mutate _prefs to new values, savePrefs(), and have any later - * savePrefs() call write the new values without clobbering the running radio. - * No-op if an override is already active (tempradio takes precedence). */ + + /* Region CLI hooks, as upstream: `region load` starts a multi-line load in + * the role, `region save` / `region default` persist through it. */ + virtual void startRegionsLoad() {} + virtual bool saveRegions() { return false; } + virtual void onDefaultRegionChanged(const RegionEntry* r) { (void)r; } + /* Keep the live radio on these (old) params through an override while + * _prefs takes the new ones for the next boot. No-op under tempradio. */ virtual void freezeRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { (void)freq; (void)bw; (void)sf; (void)cr; } // Adaptive contention window @@ -92,8 +88,8 @@ public: // Adaptive CAD (LBT detPeak calibration) virtual int formatCadStatus(char* buf, int cap) { (void)buf; (void)cap; return 0; } - /* Carrier frequency error accumulated from received packets; 0 = this - * radio cannot measure it (only the LR2021 does today). */ + /* Carrier frequency error of received packets; 0 = not measurable (only + * the LR2021 measures it). */ virtual int formatFreqErrorStatus(char* buf, int cap) { (void)buf; (void)cap; return 0; } virtual void applyCadPrefs() {} virtual void resetCadStats() {} @@ -107,8 +103,7 @@ public: virtual bool setGpsEnabled(bool enabled) { return false; } virtual bool isGpsEnabled() const { return false; } virtual void formatGpsStatsReply(char* reply) { strcpy(reply, "off"); } - /* Role default for "set gps duty default". Companion default (300s); the - * repeater/room overrides return ZEPHCORE_REPEATER_GPS_INTERVAL_SEC. */ + /* "set gps duty default"; the server roles override it. */ virtual uint32_t getDefaultGpsIntervalSec() const { return CONFIG_ZEPHCORE_GPS_POLL_INTERVAL_SEC; } virtual int getNumSensorSettings() const { return 0; } virtual const char* getSensorSettingName(int idx) const { return nullptr; } @@ -118,29 +113,26 @@ public: }; class CommonCLI { - /* Members ordered to match constructor initialization order */ mesh::MainBoard* _board; mesh::RTCClock* _rtc; - ClientACL* _acl; + RegionMap* _region_map; /* nullptr on the companion: no region CLI */ + ClientACL* _acl; /* nullptr on the companion (it has no clients) */ NodePrefs* _prefs; CommonCLICallbacks* _callbacks; char tmp[PRV_KEY_SIZE * 2 + 4]; - /* Deferred reboot - lets LoRa reply be sent before rebooting */ + /* Deferred reboot, so the reply goes out first */ struct k_work_delayable _reboot_work; uint8_t _pending_reboot; - /* Uptime (ms) past which the reboot goes ahead even if the transport is - * still busy — a stalled or dropped link must not wedge the reset. */ + /* Uptime (ms) after which the reboot goes ahead even if the transport is + * still busy: a stalled link must not wedge the reset. */ int64_t _reboot_deadline_ms; static void rebootWorkHandler(struct k_work *work); - /* Bytes the caller already consumed at the head of the reply buffer before - * handing it to us — see setReplyHeaderUsed(). */ - uint8_t _reply_hdr_used; + uint8_t _reply_hdr_used; /* see setReplyHeaderUsed() */ - /* Space a handler may write at `reply`, terminator included. Local USB - * gets the full buffer; a remote reply rides in the caller's packet buffer - * and must also leave room for whatever the caller wrote ahead of us. */ + /* Bytes a handler may write at `reply`, terminator included: the whole + * local buffer, or what is left of a remote one after the caller's header. */ size_t replyCap(uint32_t sender_timestamp) const { return (sender_timestamp == 0) ? CLI_REPLY_SIZE : (CLI_REMOTE_REPLY_SIZE - _reply_hdr_used); @@ -149,30 +141,30 @@ class CommonCLI { mesh::RTCClock* getRTCClock() { return _rtc; } void savePrefs(); void scheduleReboot(uint8_t type); + bool handleRadioCmd(uint32_t sender_timestamp, const char* command, char* reply); + void handleGetCmd(uint32_t sender_timestamp, const char* command, char* reply); + void handleSetCmd(uint32_t sender_timestamp, const char* command, char* reply); + void handleRegionCmd(uint32_t sender_timestamp, char* command, char* reply); + void handleRegionCmdCopy(uint32_t sender_timestamp, const char* command, char* reply); public: - CommonCLI(mesh::MainBoard& board, mesh::RTCClock& rtc, ClientACL& acl, + CommonCLI(mesh::MainBoard& board, mesh::RTCClock& rtc, RegionMap* region_map, ClientACL* acl, NodePrefs* prefs, CommonCLICallbacks* callbacks) - : _board(&board), _rtc(&rtc), _acl(&acl), _prefs(prefs), _callbacks(callbacks), + : _board(&board), _rtc(&rtc), _region_map(region_map), _acl(acl), _prefs(prefs), _callbacks(callbacks), _pending_reboot(REBOOT_NONE), _reboot_deadline_ms(0), _reply_hdr_used(0) { k_work_init_delayable(&_reboot_work, rebootWorkHandler); } - /* Roles that strip the `XX|` request-tag prefix echo those 3 bytes into the - * head of the reply buffer and advance `reply` past them before calling us. - * Their buffer is still only 5 + CLI_REMOTE_REPLY_SIZE, so the self-limiting - * handlers have to subtract what was already written or they overrun it. - * Call this on EVERY dispatch, with 0 when no prefix was stripped. */ + /* Bytes of the reply buffer the caller already used (a reflected "xx|" + * prefix). Set on every dispatch, 0 when none. */ void setReplyHeaderUsed(uint8_t n) { _reply_hdr_used = n; } void handleCommand(uint32_t sender_timestamp, const char* command, char* reply); uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data); - /* Full adaptive-CAD reset: the learned detPeak offset, the probe statistics - * that justified it, and the recorded base anchor, all dropped together. - * Public so the companion's app-driven radio change (CMD_SET_RADIO_PARAMS, - * which applies live rather than at reboot) can reach the same code path - * instead of open-coding it. See the definition for `preset_pending`. */ + /* Drop the learned CAD offset, its probe statistics and base anchor + * together. Public for the companion's live radio change. See the + * definition for `preset_pending`. */ void resetCadState(bool preset_pending); }; diff --git a/zephcore/helpers/ConfigSerializer.cpp b/zephcore/helpers/ConfigSerializer.cpp new file mode 100644 index 0000000..f662d98 --- /dev/null +++ b/zephcore/helpers/ConfigSerializer.cpp @@ -0,0 +1,335 @@ +#include "ConfigSerializer.h" +#include // atoi/atol/atof (Arduino.h pulls this in on-device, native builds do not) + +bool ConfigSerializer::saveSerial(Stream& s) { + Context context(&s, OP::WRITE); + _context = &context; // set the context for structure() call + s.print("{"); // root object + _first = true; + structure(); + if (s.print("}") != 1) context.success = false; // failure detect + _context = NULL; + return context.success; +} + +#define TOK_ERROR -1 +#define TOK_EOF 0 +#define TOK_KEY 1 +#define TOK_VALUE 2 +#define TOK_START_OBJ 3 +#define TOK_END_OBJ 4 +#define TOK_WHITESPACE 5 + +static bool is_whitespace(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} +static bool is_key_char(char c) { + // digits are allowed: keys like gr_1hop contain one, otherwise loadSerial + // fails at that key and prefs stop reloading after a reboot + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; +} +static bool is_value_char(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || c == '-' || c == '.'; +} + +#define EXPECT_OPEN_BRACE 0 +#define EXPECT_KEY 1 +#define EXPECT_VAL_OR_OBJ 2 +#define EXPECT_STRING_VAL 3 +#define EXPECT_STRING_ESCAPE 4 +#define EXPECT_COMMA_OR_CLOSE 5 +#define EXPECT_COMMA_OR_KEY 6 +#define EXPECT_COMMA_OR_KEY_OR_CLOSE 7 + +int ConfigSerializer::Context::readNext() { + char c; + if (pending) { + c = pending; + pending = 0; + } else { + if (_f->available() == 0) return TOK_EOF; + + int n = _f->read(); + if (n < 0) return TOK_EOF; + c = (char)n; + } + + switch (rd_mode) { + case EXPECT_OPEN_BRACE: + if (c == '{') { rd_mode = EXPECT_KEY; return TOK_START_OBJ; } + if (is_whitespace(c)) return TOK_WHITESPACE; + return TOK_ERROR; + + case EXPECT_COMMA_OR_KEY_OR_CLOSE: + if (c == '}') { rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; return TOK_END_OBJ; } + case EXPECT_COMMA_OR_KEY: + if (c == ',') { rd_mode = EXPECT_KEY; return TOK_WHITESPACE; } + case EXPECT_KEY: + if (rd_len == 0 && c == '}') { rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; return TOK_END_OBJ; } // empty object, eg. 'custom:{}' + if (rd_len > 0 && c == ':') { rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_VAL_OR_OBJ; return TOK_KEY; } + if (rd_len == 0 && is_whitespace(c)) return TOK_WHITESPACE; + if (rd_len < CONFIG_MAX_KEYLEN-1 && is_key_char(c)) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; } + return TOK_ERROR; + + case EXPECT_VAL_OR_OBJ: + if (rd_len == 0 && is_whitespace(c)) return TOK_WHITESPACE; + if (rd_len == 0 && c == '"') { rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; } + if (rd_len == 0 && c == '{') { rd_mode = EXPECT_KEY; return TOK_START_OBJ; } + if (is_value_char(c) && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; } + if (rd_len > 0 && (c == ',' || c == '}' || is_whitespace(c))) { pending = c; rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_COMMA_OR_CLOSE; return TOK_VALUE; } + return TOK_ERROR; + + case EXPECT_STRING_ESCAPE: + if ((c == 'n') && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = '\n'; rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; } + if ((c == 'r') && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = '\r'; rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; } + if ((c == '"' || c == '\\' || c == '/') && rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = c; rd_mode = EXPECT_STRING_VAL; return TOK_WHITESPACE; } + return TOK_ERROR; // unsupport escape + + case EXPECT_STRING_VAL: + if (c == '"') { rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_COMMA_OR_CLOSE; return TOK_VALUE; } + if (c == '\\') { rd_mode = EXPECT_STRING_ESCAPE; return TOK_WHITESPACE; } + if (rd_len < CONFIG_MAX_TOKEN_LEN-1) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; } + return TOK_ERROR; + + case EXPECT_COMMA_OR_CLOSE: + if (c == ',') { rd_mode = EXPECT_KEY; return TOK_WHITESPACE; } + if (c == '}') { rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; return TOK_END_OBJ; } + if (is_whitespace(c)) return TOK_WHITESPACE; + return TOK_ERROR; + } + return TOK_ERROR; // unknown mode +} + +bool ConfigSerializer::loadSerial(Stream& s) { + Context context(&s, OP::READ); + _context = &context; // set the context for structure() call + uint8_t sp = 0; // object nesting stack pointer + int next_tok; + + // parse the Json file + while ((next_tok = context.readNext()) > TOK_EOF) { + if (next_tok == TOK_KEY) { + context.setKey(sp, context.getToken()); + } else if (next_tok == TOK_VALUE) { + _depth = 1; // re-run the structure() hierarchy again (looking for specific key, at specific depth) + structure(); + } else if (next_tok == TOK_START_OBJ) { + if (sp < CONFIG_MAX_DEPTH - 1) { + sp++; + } else { + //Serial.printf("Error: max nesting reached"); // TODO: debug logging + context.success = false; + break; + } + } else if (next_tok == TOK_END_OBJ) { + if (sp > 0) { + sp--; + } else { + //Serial.printf("Error: too many closing '}'"); // TODO: debug logging + context.success = false; + break; + } + } + } + if (sp != 0 || next_tok == TOK_ERROR) { + context.success = false; // unmatched { }, or other parse error + } + _context = NULL; + return context.success; +} + +void ConfigSerializer::writeComma() { + if (_first) { + _first = false; + } else { + _context->file()->print(","); // comma separated properties + } +} + +#include + +void ConfigSerializer::def(const char* key, void* value, size_t len) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":\""); + mesh::Utils::printHex(*_context->file(), (uint8_t*) value, len); + _context->file()->print("\""); + } else { + if (_context->keyMatch(_depth, key)) { + memset(value, 0, len); + mesh::Utils::fromHex((uint8_t *)value, len, _context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, char* value, size_t max_len) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":\""); + char c; + while ((c = *value++) != 0) { // TODO: handle UTF-8 encoding + if (c == '"') { + _context->file()->print("\\\""); + } else if (c == '\\') { + _context->file()->print("\\\\"); + } else if (c == '\n') { + _context->file()->print("\\n"); + } else if (c == '\r') { + _context->file()->print("\\r"); + } else { + _context->file()->print(c); + } + } + _context->file()->print("\""); + } else { + if (_context->keyMatch(_depth, key)) { + strncpy(value, _context->getToken(), max_len - 1); + value[max_len - 1] = 0; + } + } +} + +void ConfigSerializer::def(const char* key, int32_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print(value); + } else { + if (_context->keyMatch(_depth, key)) { + value = atol(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, uint32_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print(value); + } else { + if (_context->keyMatch(_depth, key)) { + value = atol(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, int16_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print((int32_t) value, 10); + } else { + if (_context->keyMatch(_depth, key)) { + value = atol(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, uint16_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print((uint32_t) value, 10); + } else { + if (_context->keyMatch(_depth, key)) { + value = atoi(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, uint8_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print((uint32_t) value, 10); + } else { + if (_context->keyMatch(_depth, key)) { + value = atoi(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, int8_t& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print((int32_t) value, 10); + } else { + if (_context->keyMatch(_depth, key)) { + value = atoi(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, bool& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + _context->file()->print(value ? "true" : "false"); + } else { + if (_context->keyMatch(_depth, key)) { + value = strcmp(_context->getToken(), "true") == 0 || atoi(_context->getToken()) != 0; // 'true' or a non-zero number + } + } +} + +void ConfigSerializer::def(const char* key, double& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + if (value == 0.0) { + _context->file()->print("0"); // shorter encoding + } else { + _context->file()->print(value, 6); // REVISIT: how many dec places? + } + } else { + if (_context->keyMatch(_depth, key)) { + value = atof(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, float& value) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":"); + if (value == 0.0f) { + _context->file()->print("0"); // shorter encoding + } else { + _context->file()->print(value, 4); // REVISIT: how many dec places? + } + } else { + if (_context->keyMatch(_depth, key)) { + value = (float) atof(_context->getToken()); + } + } +} + +void ConfigSerializer::def(const char* key, ConfigSerializer& sub_obj) { + if (_context->op() == OP::WRITE) { + writeComma(); + _context->file()->print(key); + _context->file()->print(":{"); + sub_obj._context = _context; // inherit the Context + sub_obj._first = true; + sub_obj.structure(); // recurse into sub object + if (_context->file()->print("}") != 1) _context->success = false; // failure detect + } else { + if (_context->keyMatch(_depth, key)) { + sub_obj._context = _context; // inherit the Context + sub_obj._depth = _depth + 1; + sub_obj.structure(); // recurse into sub object + } + } +} diff --git a/zephcore/helpers/ConfigSerializer.h b/zephcore/helpers/ConfigSerializer.h new file mode 100644 index 0000000..e55b1b1 --- /dev/null +++ b/zephcore/helpers/ConfigSerializer.h @@ -0,0 +1,76 @@ +#pragma once + +#include + +#ifndef CONFIG_MAX_DEPTH + #define CONFIG_MAX_DEPTH 8 +#endif + +#ifndef CONFIG_MAX_KEYLEN + #define CONFIG_MAX_KEYLEN 16 +#endif + +#ifndef CONFIG_MAX_TOKEN_LEN + #define CONFIG_MAX_TOKEN_LEN 128 +#endif + +class ConfigSerializer { + bool _first; + int8_t _depth; + bool _dirty = false; + +protected: + enum OP { READ, WRITE }; + + class Context { + Stream* _f; + OP _op; + uint8_t rd_len; + uint8_t rd_mode; + char pending; + char rd_buf[CONFIG_MAX_TOKEN_LEN]; + char _keys[CONFIG_MAX_DEPTH][CONFIG_MAX_KEYLEN]; + + public: + bool success = true; + Context(Stream* f, OP op) : _f(f), _op(op) { rd_buf[rd_len = 0] = 0; rd_mode = 0; pending = 0; } + OP op() const { return _op; } + Stream* file() const { return _f; } + int readNext(); + const char* getToken() const { return rd_buf; } + bool keyMatch(int8_t depth, const char* key) { return strcmp(key, _keys[depth]) == 0; } + void setKey(uint8_t depth, const char* key) { strcpy(_keys[depth], key); } + const char* getKey(uint8_t depth) { return _keys[depth]; } + }; + + Context* _context = NULL; + int8_t getDepth() const { return _depth; } + + void writeComma(); + + ConfigSerializer() { } + + void def(const char* key, char* value, size_t max_len); // max_len inclusive of null + void def(const char* key, void* value, size_t len); // binary blob (encoded in hex) + void def(const char* key, int32_t& value); + void def(const char* key, int16_t& value); + void def(const char* key, int8_t& value); + void def(const char* key, uint32_t& value); + void def(const char* key, uint16_t& value); + void def(const char* key, uint8_t& value); + void def(const char* key, float& value); + void def(const char* key, double& value); + void def(const char* key, bool& value); + void def(const char* key, ConfigSerializer& sub_obj); + + virtual void structure() = 0; + + void markDirty() { _dirty = true; } + +public: + bool loadSerial(Stream& s); + bool saveSerial(Stream& s); + + virtual bool isDirty() const { return _dirty; } + virtual void clearDirty() { _dirty = false; } +}; diff --git a/zephcore/helpers/IdentityStore.h b/zephcore/helpers/IdentityStore.h index 8368d65..a147f8d 100644 --- a/zephcore/helpers/IdentityStore.h +++ b/zephcore/helpers/IdentityStore.h @@ -1,9 +1,14 @@ /* * SPDX-License-Identifier: MIT - * IdentityStore.h - Re-exports identity/crypto constants from MeshCore + * IdentityStore.h - Re-exports identity/crypto constants from MeshCore, and + * FILESYSTEM for storage code ported from upstream (which defines it here as + * fs::FS or Adafruit_LittleFS). */ #pragma once #include #include +#include + +#define FILESYSTEM ZephyrFS diff --git a/zephcore/helpers/LoopWakeStats.h b/zephcore/helpers/LoopWakeStats.h new file mode 100644 index 0000000..2120b04 --- /dev/null +++ b/zephcore/helpers/LoopWakeStats.h @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: MIT + * LoopWakeStats - how often a role's mesh thread leaves k_event_wait(), and + * for which event bits (CONFIG_ZEPHCORE_LOOP_WAKE_STATS, `get loop.wakes`). + */ + +#pragma once + +#include +#include +#include + +#if IS_ENABLED(CONFIG_ZEPHCORE_LOOP_WAKE_STATS) + +struct LoopWakeStats { + uint32_t wakes; + uint32_t bits[16]; /* per event bit; one wake may carry several */ +}; + +inline LoopWakeStats loop_wake_stats; + +/* Call with the events k_event_wait() returned. */ +static inline void loopWakeNote(uint32_t events) +{ + loop_wake_stats.wakes++; + events &= 0xFFFF; + while (events) { + int b = __builtin_ctz(events); + loop_wake_stats.bits[b]++; + events &= events - 1; + } +} + +/* "> wakes=N up=Ss b0=.. b3=..": counts since boot, non-zero bits only. The + * bit numbers are the role's MESH_EVENT_* bits. */ +static inline void loopWakeFormat(char *reply, size_t cap) +{ + int n = snprintf(reply, cap, "> wakes=%u up=%us", (unsigned)loop_wake_stats.wakes, + (unsigned)(k_uptime_get() / 1000)); + for (int b = 0; b < 16 && n > 0 && (size_t)n < cap; b++) { + if (loop_wake_stats.bits[b]) { + n += snprintf(reply + n, cap - n, " b%d=%u", b, (unsigned)loop_wake_stats.bits[b]); + } + } +} + +#else + +static inline void loopWakeNote(uint32_t events) { (void)events; } + +#endif diff --git a/zephcore/helpers/MultiSerialInterface.h b/zephcore/helpers/MultiSerialInterface.h new file mode 100644 index 0000000..6333315 --- /dev/null +++ b/zephcore/helpers/MultiSerialInterface.h @@ -0,0 +1,205 @@ +#pragma once + +#include "BaseSerialInterface.h" + +#ifndef MAX_INTERFACES + // ble, usb, wifi, ethernet + #define MAX_INTERFACES 4 +#endif + +enum class InterfaceType : uint8_t { + NONE, + Bluetooth, + USB, + WiFi, + Ethernet, + HardwareSerial +}; + +class MultiSerialInterface : public BaseSerialInterface { +private: + + struct RegisteredInterface { + InterfaceType type = InterfaceType::NONE; + BaseSerialInterface* instance = nullptr; + }; + + bool _enabled = false; + RegisteredInterface _interfaces[MAX_INTERFACES] = {}; + +public: + bool addInterface(InterfaceType type, BaseSerialInterface* iface) { + // make sure an interface was provided + if(iface == nullptr){ + return false; + } + + // put it in the first free slot + for(int i = 0; i < MAX_INTERFACES; i++){ + if(_interfaces[i].instance == nullptr){ + _interfaces[i].instance = iface; + _interfaces[i].type = type; + return true; + } + } + + // no free slots available + return false; + } + + bool removeInterface(BaseSerialInterface* iface) { + // make sure an interface was provided + if(iface == nullptr){ + return false; + } + + // find and remove interface + for(int i = 0; i < MAX_INTERFACES; i++){ + if(_interfaces[i].instance == iface){ + _interfaces[i] = {}; + return true; + } + } + + // interface not found + return false; + } + + void enableBluetooth() { + for(auto iface : _interfaces){ + if(iface.instance && iface.type == InterfaceType::Bluetooth){ + iface.instance->enable(); + } + } + } + + void disableBluetooth() { + for(auto iface : _interfaces){ + if(iface.instance && iface.type == InterfaceType::Bluetooth){ + iface.instance->disable(); + } + } + } + + bool isBluetoothEnabled() { + for(auto iface : _interfaces){ + if(iface.instance && iface.type == InterfaceType::Bluetooth){ + return iface.instance->isEnabled(); + } + } + return false; + } + + // enable all interfaces + void enable() override { + _enabled = true; + for(auto iface : _interfaces){ + if(iface.instance){ + iface.instance->enable(); + } + } + } + + // disable all interfaces + void disable() override { + _enabled = false; + for(auto iface : _interfaces){ + if(iface.instance){ + iface.instance->disable(); + } + } + } + + bool isEnabled() const override { + return _enabled; + } + + bool isConnected() const override { + // not connected when disabled + if(!_enabled){ + return false; + } + + // check if any interface is connected + for(auto iface : _interfaces){ + if(iface.instance && iface.instance->isConnected()) { + return true; + } + } + + // nothing connected + return false; + } + + // loop all interfaces + void loop() override { + for(auto iface : _interfaces){ + if(iface.instance){ + iface.instance->loop(); + } + } + } + + bool isWriteBusy() const override { + // not busy when disabled + if(!_enabled){ + return false; + } + + // check if any interface is busy + for(auto iface : _interfaces){ + if(iface.instance && iface.instance->isEnabled() && iface.instance->isWriteBusy()){ + return true; + } + } + + // nothing busy + return false; + } + + size_t writeFrame(const uint8_t src[], size_t len) override { + // don't write when disabled or nothing provided + if(!_enabled || len == 0){ + return 0; + } + + // write frame to all enabled interfaces + bool allSuccessful = true; + bool anyConnected = false; // ZEPHCORE + for(auto iface : _interfaces){ + // ZEPHCORE: connected ones only. An enabled but idle BLE/WiFi interface + // returns 0, which failed every write, and the caller retries lossless + // replies on 0. + if(iface.instance && iface.instance->isEnabled() && iface.instance->isConnected()){ + anyConnected = true; // ZEPHCORE + if(iface.instance->writeFrame(src, len) != len){ + allSuccessful = false; + } + } + } + + // report success if all writes completed successfully + return (allSuccessful && anyConnected) ? len : 0; // ZEPHCORE: 0 when nobody is listening + } + + size_t checkRecvFrame(uint8_t dest[]) override { + // don't read when disabled + if(!_enabled){ + return 0; + } + + // try to read a frame from any enabled interface + for(auto iface : _interfaces){ + if(iface.instance && iface.instance->isEnabled()){ + size_t frameSize = iface.instance->checkRecvFrame(dest); + if(frameSize > 0){ + return frameSize; + } + } + } + + // no frame received + return 0; + } + +}; diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 22a0779..9cf2096 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -142,8 +142,8 @@ struct NodePrefs { uint8_t rx_duty_cycle; // 1 = RX duty cycle, 0 = continuous RX /* RESERVED — formerly apc_enabled / apc_margin (Adaptive Power Control, * removed in 1.16.6). These two bytes are still read and written at their - * original offsets in all three prefs serializers (companion new_prefs 94/95, - * repeater prefs 292/293, RepeaterDataStore) because every field after them + * original offsets in both prefs layouts (companion new_prefs 94/95, server + * prefs 292/293; helpers/PrefsCodec.cpp) because every field after them * is positional: dropping them would shift the rest of the layout and make * every already-deployed node misparse its saved prefs on upgrade. * Do not reuse for a new setting — an upgraded node still has the old APC @@ -240,6 +240,12 @@ struct NodePrefs { * upper bits are telemetry permissions. Kept here so a toggle survives * reconnects and reboots instead of being echoed back as 0. */ uint8_t v_contact_flags; + /* The WiFi companion's network (upstream's wifi_ssid / wifi_pwd / + * wifi_enabled). Stored by every companion build, WiFi or not, so moving + * between firmware never drops a saved network. */ + char wifi_ssid[33]; + char wifi_pwd[64]; + uint8_t wifi_enabled; // 1 = join wifi_ssid when set (upstream default) }; /* Range guards for prefs that came off flash. @@ -252,7 +258,7 @@ struct NodePrefs { * fixed-size blocks with no terminator in the file format, so an unterminated * one runs every later %s off the end of the struct. * - * Called at the end of each role's loadPrefs(). Fields whose whole range is + * Called by both prefs decoders (PrefsCodec.cpp). Fields whose whole range is * legal (autoadd_config bitmask, discovery_mod_timestamp, the v_contact_* * sentinels) are deliberately left alone. */ template @@ -282,6 +288,9 @@ static inline void sanitizeNodePrefs(NodePrefs* p) { p->guest_password[sizeof(p->guest_password) - 1] = '\0'; p->owner_info[sizeof(p->owner_info) - 1] = '\0'; p->default_scope_name[sizeof(p->default_scope_name) - 1] = '\0'; + p->wifi_ssid[sizeof(p->wifi_ssid) - 1] = '\0'; + p->wifi_pwd[sizeof(p->wifi_pwd) - 1] = '\0'; + p->wifi_enabled = saneBool(p->wifi_enabled, (uint8_t)1); p->airtime_factor = sanePrefFloat(p->airtime_factor, 0.0f, 9.0f, 9.0f); p->rx_delay_base = sanePrefFloat(p->rx_delay_base, 0.0f, 3600.0f, 0.0f); @@ -450,12 +459,13 @@ static inline void initNodePrefs(NodePrefs* prefs) { prefs->input_rotate = 0; // Default OFF — joystick axes as the board wires them prefs->v_contact_enabled = 1; // Default ON — v-contact loopback admin chat (companion) prefs->v_battery_alert_mv = 0xFFFF; // Sentinel: derive from board auto-shutdown threshold + prefs->wifi_enabled = 1; // as upstream: WiFi still stays off until an SSID is set /* Companion-only feature, and main_companion.cpp used to assign this by * hand right after calling us — so no node ever ran without it. It lives * here now because a default listed only at one call site is invisible to * every other caller of initNodePrefs(), which is the exact drift that * zeroed probe_interval and cad_auto in the past. Matches what - * loadPrefs()'s absent-field fallback and sanitizeNodePrefs() already use. + * sanitizeNodePrefs() already uses. * 0 means disabled and is a legal stored value, so sanitize passes it * through untouched — this default only applies to a fresh prefs struct. */ #ifdef CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS diff --git a/zephcore/helpers/PrefsCodec.cpp b/zephcore/helpers/PrefsCodec.cpp new file mode 100644 index 0000000..a22984d --- /dev/null +++ b/zephcore/helpers/PrefsCodec.cpp @@ -0,0 +1,375 @@ +/* + * SPDX-License-Identifier: MIT + * PrefsCodec - byte codecs for the two binary prefs files. See PrefsCodec.h. + */ + +#include "PrefsCodec.h" +#include + +namespace { + +/* Sequential writer / reader over a byte buffer. Rd::get() mirrors fs_read() + * on a short file, as the server store used to read it: a field that straddles + * the end gets the bytes that exist. Rd::take() reads whole fields only. Past + * the end, both leave the field as it was. */ +struct Wr { + uint8_t *b; + size_t off; + void put(const void *src, size_t n) { memcpy(b + off, src, n); off += n; } + void zeros(size_t n) { memset(b + off, 0, n); off += n; } +}; + +struct Rd { + const uint8_t *b; + size_t len; + size_t off; + void get(void *dst, size_t n) { + size_t k = off < len ? (len - off < n ? len - off : n) : 0; + memcpy(dst, b + off, k); + off += n; + } + void skip(size_t n) { off += n; } + /* Whole fields only: one that does not fit keeps its value. */ + void take(void *dst, size_t n) { + if (off + n <= len) { + memcpy(dst, b + off, n); + } + off += n; + } +}; + +} // namespace + +/* ── Companion: /lfs/new_prefs ─────────────────────────────────────── */ + +size_t companionPrefsEncode(const NodePrefs &p, uint8_t *buf, size_t cap) +{ + if (cap < COMPANION_PREFS_SIZE) { + return 0; + } + Wr w = { buf, 0 }; + + /* 0-91: the layout upstream's new_prefs had */ + w.put(&p.airtime_factor, 4); + w.put(p.node_name, 32); + w.zeros(4); + w.put(&p.node_lat, 8); + w.put(&p.node_lon, 8); + w.put(&p.freq, 4); + w.put(&p.sf, 1); + w.put(&p.cr, 1); + w.put(&p.client_repeat, 1); + w.put(&p.manual_add_contacts, 1); + w.put(&p.bw, 4); + w.put(&p.tx_power_dbm, 1); + w.put(&p.telemetry_mode_base, 1); + w.put(&p.telemetry_mode_loc, 1); + w.put(&p.telemetry_mode_env, 1); + w.put(&p.rx_delay_base, 4); + w.put(&p.advert_loc_policy, 1); + w.put(&p.multi_acks, 1); + w.put(&p.path_hash_mode, 1); + w.zeros(1); + w.put(&p.ble_pin, 4); + w.put(&p.buzzer_quiet, 1); + w.put(&p.gps_enabled, 1); + w.put(&p.gps_interval, 4); + w.put(&p.autoadd_config, 1); + w.put(&p.autoadd_max_hops, 1); + /* 92-: ZephCore. Upstream put default_scope_name at 93. */ + w.put(&p.rx_boost, 1); + w.put(&p.leds_disabled, 1); + w.put(&p._reserved_apc_enabled, 1); /* 94-95: APC, removed in 1.16.6 */ + w.put(&p._reserved_apc_margin, 1); + w.put(p.default_scope_name, 31); + w.put(p.default_scope_key, 16); + w.put(&p.ble_disabled, 1); + w.put(&p.display_brightness, 1); + w.put(&p.wake_on_msg, 1); + w.put(&p.screen_off_secs, 2); + w.put(&p.auto_shutdown_mv, 2); + w.put(&p.rx_duty_cycle, 1); + w.put(&p.meshtimesync, 1); + w.put(&p.v_contact_enabled, 1); + w.put(&p.v_battery_alert_mv, 2); + w.put(&p.cad_auto, 1); + w.put(&p.cad_offset, 1); + w.put(&p.probe_interval, 1); + w.put(&p.cad_busycap, 1); + w.put(&p.adc_multiplier, 4); + w.put(p.extra_sf, EXTRA_SF_MAX); + w.put(&p.v_contact_flags, 1); + w.put(&p.fem_rxgain, 1); + w.put(&p.display_rotate, 1); + w.put(&p.input_rotate, 1); + w.put(&p.cad_base, 1); + w.put(&p.tz_offset, 1); + w.put(&p.leds_radio_mode, 1); + w.put(&p.leds_hb_mode, 1); + w.put(&p.wifi_enabled, 1); + w.put(p.wifi_ssid, sizeof(p.wifi_ssid)); + w.put(p.wifi_pwd, sizeof(p.wifi_pwd)); + return w.off; +} + +bool companionPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) +{ + /* gps_interval ends at 90: anything shorter is not a prefs file */ + if (len < 90) { + return false; + } + /* Committed only if the radio preset is plausible: a file in some other + * layout must not become our radio settings. */ + NodePrefs p = prefs; + Rd r = { buf, len, 0 }; + + r.take(&p.airtime_factor, 4); + r.take(p.node_name, 32); + r.skip(4); + r.take(&p.node_lat, 8); + r.take(&p.node_lon, 8); + r.take(&p.freq, 4); + r.take(&p.sf, 1); + r.take(&p.cr, 1); + r.take(&p.client_repeat, 1); + r.take(&p.manual_add_contacts, 1); + r.take(&p.bw, 4); + if (p.freq < ZC_RADIO_FREQ_MIN_MHZ || p.freq > ZC_RADIO_FREQ_MAX_MHZ || + p.sf < 5 || p.sf > 12 || + p.bw < ZC_RADIO_BW_MIN_KHZ || p.bw > ZC_RADIO_BW_MAX_KHZ) { + return false; + } + r.take(&p.tx_power_dbm, 1); + r.take(&p.telemetry_mode_base, 1); + r.take(&p.telemetry_mode_loc, 1); + r.take(&p.telemetry_mode_env, 1); + r.take(&p.rx_delay_base, 4); + r.take(&p.advert_loc_policy, 1); + r.take(&p.multi_acks, 1); + r.take(&p.path_hash_mode, 1); + r.skip(1); + r.take(&p.ble_pin, 4); + r.take(&p.buzzer_quiet, 1); + r.take(&p.gps_enabled, 1); + r.take(&p.gps_interval, 4); + r.take(&p.autoadd_config, 1); + r.take(&p.autoadd_max_hops, 1); + /* Each field below is newer than some deployed files: one past the end + * keeps the caller's initNodePrefs() default. */ + r.take(&p.rx_boost, 1); + r.take(&p.leds_disabled, 1); + r.take(&p._reserved_apc_enabled, 1); + r.take(&p._reserved_apc_margin, 1); + r.take(p.default_scope_name, 31); + r.take(p.default_scope_key, 16); + r.take(&p.ble_disabled, 1); + r.take(&p.display_brightness, 1); + r.take(&p.wake_on_msg, 1); + r.take(&p.screen_off_secs, 2); + r.take(&p.auto_shutdown_mv, 2); + r.take(&p.rx_duty_cycle, 1); + r.take(&p.meshtimesync, 1); + r.take(&p.v_contact_enabled, 1); + r.take(&p.v_battery_alert_mv, 2); + r.take(&p.cad_auto, 1); + r.take(&p.cad_offset, 1); + r.take(&p.probe_interval, 1); + r.take(&p.cad_busycap, 1); + r.take(&p.adc_multiplier, 4); + r.take(p.extra_sf, EXTRA_SF_MAX); + r.take(&p.v_contact_flags, 1); + r.take(&p.fem_rxgain, 1); + r.take(&p.display_rotate, 1); + r.take(&p.input_rotate, 1); + /* cad_base 0 = "no base recorded": setCadParams() then leaves the stored + * offset alone. */ + r.take(&p.cad_base, 1); + r.take(&p.tz_offset, 1); + r.take(&p.leds_radio_mode, 1); + r.take(&p.leds_hb_mode, 1); + r.take(&p.wifi_enabled, 1); + r.take(p.wifi_ssid, sizeof(p.wifi_ssid)); + r.take(p.wifi_pwd, sizeof(p.wifi_pwd)); + + /* The one validator: bounds, NaNs, and the unterminated char fields */ + sanitizeNodePrefs(&p); + prefs = p; + return true; +} + +/* ── Server roles: /lfs/repeater/prefs ─────────────────────────────── */ + +size_t serverPrefsEncode(const NodePrefs &prefs, uint8_t *buf, size_t cap) +{ + if (cap < SERVER_PREFS_SIZE) { + return 0; + } + Wr w = { buf, 0 }; + + /* Same order as Arduino CommonCLI up to offset 290 */ + w.put(&prefs.airtime_factor, sizeof(prefs.airtime_factor)); + w.put(&prefs.node_name, sizeof(prefs.node_name)); + w.zeros(4); + w.put(&prefs.node_lat, sizeof(prefs.node_lat)); + w.put(&prefs.node_lon, sizeof(prefs.node_lon)); + w.put(&prefs.password, sizeof(prefs.password)); + w.put(&prefs.freq, sizeof(prefs.freq)); + w.put(&prefs.tx_power_dbm, sizeof(prefs.tx_power_dbm)); + w.put(&prefs.disable_fwd, sizeof(prefs.disable_fwd)); + w.put(&prefs.advert_interval, sizeof(prefs.advert_interval)); + w.zeros(1); + w.put(&prefs.rx_delay_base, sizeof(prefs.rx_delay_base)); + w.put(&prefs.tx_delay_factor, sizeof(prefs.tx_delay_factor)); + w.put(&prefs.guest_password, sizeof(prefs.guest_password)); + w.put(&prefs.direct_tx_delay_factor, sizeof(prefs.direct_tx_delay_factor)); + w.put(&prefs.backoff_multiplier, sizeof(prefs.backoff_multiplier)); + w.put(&prefs.sf, sizeof(prefs.sf)); + w.put(&prefs.cr, sizeof(prefs.cr)); + w.put(&prefs.allow_read_only, sizeof(prefs.allow_read_only)); + w.put(&prefs.multi_acks, sizeof(prefs.multi_acks)); + w.put(&prefs.bw, sizeof(prefs.bw)); + /* 120: leds_disabled, magic-encoded (was agc_reset_interval). */ + { + uint8_t leds_byte = prefs.leds_disabled ? LEDS_PREF_OFF : LEDS_PREF_ON; + w.put(&leds_byte, sizeof(leds_byte)); + } + w.put(&prefs.path_hash_mode, sizeof(prefs.path_hash_mode)); + w.put(&prefs.loop_detect, sizeof(prefs.loop_detect)); + w.zeros(1); + w.put(&prefs.flood_max, sizeof(prefs.flood_max)); + w.put(&prefs.flood_advert_interval, sizeof(prefs.flood_advert_interval)); + w.put(&prefs.interference_threshold, sizeof(prefs.interference_threshold)); + w.zeros(25); // bridge settings + w.put(&prefs.powersaving_enabled, sizeof(prefs.powersaving_enabled)); + w.zeros(3); + w.put(&prefs.gps_enabled, sizeof(prefs.gps_enabled)); + w.put(&prefs.gps_interval, sizeof(prefs.gps_interval)); + w.put(&prefs.advert_loc_policy, sizeof(prefs.advert_loc_policy)); + w.put(&prefs.discovery_mod_timestamp, sizeof(prefs.discovery_mod_timestamp)); + w.put(&prefs.adc_multiplier, sizeof(prefs.adc_multiplier)); + w.put(prefs.owner_info, sizeof(prefs.owner_info)); + /* ZephCore extensions */ + w.put(&prefs.rx_boost, sizeof(prefs.rx_boost)); + w.put(&prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle)); + /* RESERVED — formerly apc_enabled / apc_margin (removed in 1.16.6). + * Written back unchanged to hold the layout. */ + w.put(&prefs._reserved_apc_enabled, sizeof(prefs._reserved_apc_enabled)); + w.put(&prefs._reserved_apc_margin, sizeof(prefs._reserved_apc_margin)); + /* Flood hop-ceiling extensions (offsets 294-295) */ + w.put(&prefs.flood_max_unscoped, sizeof(prefs.flood_max_unscoped)); + w.put(&prefs.flood_max_advert, sizeof(prefs.flood_max_advert)); + /* Mesh time sync on/off (offset 296) */ + w.put(&prefs.meshtimesync, sizeof(prefs.meshtimesync)); + /* Adaptive CAD (offsets 297-300) */ + w.put(&prefs.cad_auto, sizeof(prefs.cad_auto)); + w.put(&prefs.cad_offset, sizeof(prefs.cad_offset)); + w.put(&prefs.probe_interval, sizeof(prefs.probe_interval)); + w.put(&prefs.cad_busycap, sizeof(prefs.cad_busycap)); + /* LR2021 side-detector SFs (offsets 301-303) */ + w.put(prefs.extra_sf, sizeof(prefs.extra_sf)); + /* External FEM RX gain (offset 304) */ + w.put(&prefs.fem_rxgain, sizeof(prefs.fem_rxgain)); + /* Mounting orientation (offsets 305-306) */ + w.put(&prefs.display_rotate, sizeof(prefs.display_rotate)); + w.put(&prefs.input_rotate, sizeof(prefs.input_rotate)); + /* Family base detPeak cad_offset was learned against (offset 307) */ + w.put(&prefs.cad_base, sizeof(prefs.cad_base)); + /* Display timezone offset (offset 308) — signed whole hours from UTC, + * applied only when formatting the on-device clock */ + w.put(&prefs.tz_offset, sizeof(prefs.tz_offset)); + /* LED activity/heartbeat modes (offsets 309-310) */ + w.put(&prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); + w.put(&prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); + return w.off; +} + +void serverPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) +{ + Rd r = { buf, len, 0 }; + + /* Same order as Arduino CommonCLI up to offset 290 */ + r.get(&prefs.airtime_factor, sizeof(prefs.airtime_factor)); + r.get(&prefs.node_name, sizeof(prefs.node_name)); + r.skip(4); + r.get(&prefs.node_lat, sizeof(prefs.node_lat)); + r.get(&prefs.node_lon, sizeof(prefs.node_lon)); + r.get(&prefs.password, sizeof(prefs.password)); + r.get(&prefs.freq, sizeof(prefs.freq)); + r.get(&prefs.tx_power_dbm, sizeof(prefs.tx_power_dbm)); + r.get(&prefs.disable_fwd, sizeof(prefs.disable_fwd)); + r.get(&prefs.advert_interval, sizeof(prefs.advert_interval)); + r.skip(1); + r.get(&prefs.rx_delay_base, sizeof(prefs.rx_delay_base)); + r.get(&prefs.tx_delay_factor, sizeof(prefs.tx_delay_factor)); + r.get(&prefs.guest_password, sizeof(prefs.guest_password)); + r.get(&prefs.direct_tx_delay_factor, sizeof(prefs.direct_tx_delay_factor)); + r.get(&prefs.backoff_multiplier, sizeof(prefs.backoff_multiplier)); + r.get(&prefs.sf, sizeof(prefs.sf)); + r.get(&prefs.cr, sizeof(prefs.cr)); + r.get(&prefs.allow_read_only, sizeof(prefs.allow_read_only)); + r.get(&prefs.multi_acks, sizeof(prefs.multi_acks)); + r.get(&prefs.bw, sizeof(prefs.bw)); + /* 120: leds_disabled, magic-encoded. Formerly agc_reset_interval — see the + * LEDS_PREF_* comment in NodePrefs.h for why this is not a bare 0/1. + * leds_byte stays 0 (→ LEDs on) if the file is short. */ + uint8_t leds_byte = 0; + r.get(&leds_byte, sizeof(leds_byte)); + r.get(&prefs.path_hash_mode, sizeof(prefs.path_hash_mode)); + r.get(&prefs.loop_detect, sizeof(prefs.loop_detect)); + r.skip(1); + r.get(&prefs.flood_max, sizeof(prefs.flood_max)); + r.get(&prefs.flood_advert_interval, sizeof(prefs.flood_advert_interval)); + r.get(&prefs.interference_threshold, sizeof(prefs.interference_threshold)); + r.skip(25); // bridge settings + r.get(&prefs.powersaving_enabled, sizeof(prefs.powersaving_enabled)); + r.skip(3); + r.get(&prefs.gps_enabled, sizeof(prefs.gps_enabled)); + r.get(&prefs.gps_interval, sizeof(prefs.gps_interval)); + r.get(&prefs.advert_loc_policy, sizeof(prefs.advert_loc_policy)); + r.get(&prefs.discovery_mod_timestamp, sizeof(prefs.discovery_mod_timestamp)); + r.get(&prefs.adc_multiplier, sizeof(prefs.adc_multiplier)); + r.get(prefs.owner_info, sizeof(prefs.owner_info)); + /* ZephCore extensions — absent in old files; a field past the end keeps + * the caller's initNodePrefs() default. */ + r.get(&prefs.rx_boost, sizeof(prefs.rx_boost)); + r.get(&prefs.rx_duty_cycle, sizeof(prefs.rx_duty_cycle)); + /* RESERVED — formerly apc_enabled / apc_margin (APC, removed in 1.16.6). + * Still consumed so the fields after them stay at their stored offsets. */ + r.get(&prefs._reserved_apc_enabled, sizeof(prefs._reserved_apc_enabled)); + r.get(&prefs._reserved_apc_margin, sizeof(prefs._reserved_apc_margin)); + r.get(&prefs.flood_max_unscoped, sizeof(prefs.flood_max_unscoped)); + r.get(&prefs.flood_max_advert, sizeof(prefs.flood_max_advert)); + r.get(&prefs.meshtimesync, sizeof(prefs.meshtimesync)); + r.get(&prefs.cad_auto, sizeof(prefs.cad_auto)); + r.get(&prefs.cad_offset, sizeof(prefs.cad_offset)); + r.get(&prefs.probe_interval, sizeof(prefs.probe_interval)); + r.get(&prefs.cad_busycap, sizeof(prefs.cad_busycap)); + r.get(prefs.extra_sf, sizeof(prefs.extra_sf)); + r.get(&prefs.fem_rxgain, sizeof(prefs.fem_rxgain)); + r.get(&prefs.display_rotate, sizeof(prefs.display_rotate)); + r.get(&prefs.input_rotate, sizeof(prefs.input_rotate)); + /* cad_base 0 = "no base recorded": setCadParams() then leaves the stored + * offset alone, since a node upgrading across a table change cannot know + * which base its offset came from. */ + r.get(&prefs.cad_base, sizeof(prefs.cad_base)); + r.get(&prefs.tz_offset, sizeof(prefs.tz_offset)); + r.get(&prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); + r.get(&prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); + + /* Only the explicit "off" magic disables LEDs; a legacy AGC interval or an + * unwritten byte both mean "on". */ + prefs.leds_disabled = (leds_byte == LEDS_PREF_OFF) ? 1 : 0; + + if (prefs.freq < ZC_RADIO_FREQ_MIN_MHZ || prefs.freq > ZC_RADIO_FREQ_MAX_MHZ || + prefs.sf < 5 || prefs.sf > 12 || + prefs.bw < ZC_RADIO_BW_MIN_KHZ || prefs.bw > ZC_RADIO_BW_MAX_KHZ) { + prefs.freq = mesh::LoRaConfig::FREQ_MHZ; + prefs.bw = mesh::LoRaConfig::BANDWIDTH_KHZ; + prefs.sf = mesh::LoRaConfig::SPREADING_FACTOR; + prefs.cr = mesh::LoRaConfig::CODING_RATE; + prefs.tx_power_dbm = mesh::LoRaConfig::TX_POWER_DBM; + } + /* Bounds, NaNs, and the char fields, which the file stores without + * terminators. */ + sanitizeNodePrefs(&prefs); +} diff --git a/zephcore/helpers/PrefsCodec.h b/zephcore/helpers/PrefsCodec.h new file mode 100644 index 0000000..f65745c --- /dev/null +++ b/zephcore/helpers/PrefsCodec.h @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: MIT + * PrefsCodec - byte codecs for the two binary prefs files. + * + * Companion /lfs/new_prefs and server /lfs/repeater/prefs are serialized field + * by field, not as struct dumps; the offsets are the file layout (byte maps in + * memory prefs-format.md). Zephyr-free so the host tests can pin both layouts. + * The stores only move bytes between these and the filesystem. + */ + +#pragma once + +#include +#include +#include "NodePrefs.h" + +#define COMPANION_PREFS_SIZE 272 +#define SERVER_PREFS_SIZE 311 + +/* Companion file layout, including the fields shared with upstream's former + * new_prefs up to offset 92. Returns the byte count (COMPANION_PREFS_SIZE), + * or 0 if cap is too small. */ +size_t companionPrefsEncode(const NodePrefs &p, uint8_t *buf, size_t cap); + +/* Returns false and leaves p untouched when the data cannot be ours: shorter + * than 90 bytes, or radio parameters outside ZC_RADIO_*. Fields past len keep + * their value in p, which the caller has filled with initNodePrefs(). */ +bool companionPrefsDecode(NodePrefs &p, const uint8_t *buf, size_t len); + +/* Repeater / room server / observer layout (upstream CommonCLI order to 290, + * then ZephCore's tail). Returns SERVER_PREFS_SIZE, or 0 if cap is too small. */ +size_t serverPrefsEncode(const NodePrefs &p, uint8_t *buf, size_t cap); + +/* Fields past len keep their value in p. Radio parameters outside ZC_RADIO_* + * are replaced by the LoRaConfig defaults; everything is sanitized. */ +void serverPrefsDecode(NodePrefs &p, const uint8_t *buf, size_t len); diff --git a/zephcore/helpers/PrefsJson.cpp b/zephcore/helpers/PrefsJson.cpp new file mode 100644 index 0000000..867b90a --- /dev/null +++ b/zephcore/helpers/PrefsJson.cpp @@ -0,0 +1,339 @@ +/* + * SPDX-License-Identifier: MIT + * PrefsJson - prefs.json for both roles. See PrefsJson.h. + */ + +#include "PrefsJson.h" + +namespace { + +/* radio{} and gps{}: the same keys in both of upstream's trees. Not written: + * "cad" (ZephCore always runs CAD), "agc_int" (periodic AGC reset removed), + * "fem_txgain" (no such setting here). */ +class RadioJson : public ConfigSerializer { + NodePrefs *_p; + +protected: + void structure() override + { + def("freq", _p->freq); + def("bw", _p->bw); + def("sf", _p->sf); + def("cr", _p->cr); + def("int_thr", _p->interference_threshold); + def("rxgain", _p->rx_boost); + def("fem_rxgain", _p->fem_rxgain); + def("tx", _p->tx_power_dbm); + def("af", _p->airtime_factor); + def("rxdelay", _p->rx_delay_base); + def("f_txdelay", _p->tx_delay_factor); + def("d_txdelay", _p->direct_tx_delay_factor); + def("hash_mode", _p->path_hash_mode); + def("multi_ack", _p->multi_acks); + } + +public: + explicit RadioJson(NodePrefs *p) : _p(p) {} +}; + +class GpsJson : public ConfigSerializer { + NodePrefs *_p; + +protected: + void structure() override + { + def("en", _p->gps_enabled); + def("int", _p->gps_interval); + def("adv_loc", _p->advert_loc_policy); + } + +public: + explicit GpsJson(NodePrefs *p) : _p(p) {} +}; + +/* zc{}: ZephCore's own settings, common to both roles */ +class ZcCommonJson : public ConfigSerializer { +protected: + NodePrefs *_p; + + void structure() override + { + def("leds_off", _p->leds_disabled); + def("leds_radio", _p->leds_radio_mode); + def("leds_hb", _p->leds_hb_mode); + def("rx_dc", _p->rx_duty_cycle); + def("mts", _p->meshtimesync); + def("cad_auto", _p->cad_auto); + def("cad_off", _p->cad_offset); + def("cad_base", _p->cad_base); + def("probe_int", _p->probe_interval); + def("busycap", _p->cad_busycap); + def("xsf", (void *)_p->extra_sf, EXTRA_SF_MAX); + def("disp_rot", _p->display_rotate); + def("in_rot", _p->input_rotate); + } + +public: + explicit ZcCommonJson(NodePrefs *p) : _p(p) {} +}; + +/* ── Companion: upstream examples/companion_radio/NodePrefs.h ──────── */ + +class RepeatDisableJson : public ConfigSerializer { +protected: + void structure() override { def("disable", disable); } + +public: + uint8_t disable = 1; +}; + +class CompJson : public ConfigSerializer { + NodePrefs *_p; + +protected: + void structure() override + { + def("auto_max", _p->autoadd_max_hops); + def("defs_nm", _p->default_scope_name, sizeof(_p->default_scope_name)); + def("defs_key", (void *)_p->default_scope_key, sizeof(_p->default_scope_key)); + def("pin", _p->ble_pin); + def("buzz_q", _p->buzzer_quiet); + def("auto_add", _p->autoadd_config); + def("man_add", _p->manual_add_contacts); + def("tel_base", _p->telemetry_mode_base); + def("tel_loc", _p->telemetry_mode_loc); + def("tel_env", _p->telemetry_mode_env); + def("tz_offset", _p->tz_offset); + } + +public: + explicit CompJson(NodePrefs *p) : _p(p) {} +}; + +class WifiJson : public ConfigSerializer { + NodePrefs *_p; + +protected: + void structure() override + { + def("ssid", _p->wifi_ssid, sizeof(_p->wifi_ssid)); + def("pwd", _p->wifi_pwd, sizeof(_p->wifi_pwd)); + def("enabled", _p->wifi_enabled); + } + +public: + explicit WifiJson(NodePrefs *p) : _p(p) {} +}; + +class ZcCompanionJson : public ZcCommonJson { +protected: + void structure() override + { + ZcCommonJson::structure(); + def("ble_off", _p->ble_disabled); + def("bright", _p->display_brightness); + def("wake", _p->wake_on_msg); + def("scr_off", _p->screen_off_secs); + def("auto_off_mv", _p->auto_shutdown_mv); + def("vc_en", _p->v_contact_enabled); + def("vc_flags", _p->v_contact_flags); + def("vbat_mv", _p->v_battery_alert_mv); + def("adc_mult", _p->adc_multiplier); + } + +public: + explicit ZcCompanionJson(NodePrefs *p) : ZcCommonJson(p) {} +}; + +class CompanionJson : public ConfigSerializer { + NodePrefs *_p; + RadioJson _radio; + GpsJson _gps; + RepeatDisableJson _repeat; + CompJson _comp; + WifiJson _wifi; + ZcCompanionJson _zc; + +protected: + void structure() override + { + def("name", _p->node_name, sizeof(_p->node_name)); + def("lat", _p->node_lat); + def("lon", _p->node_lon); + def("radio", _radio); + def("gps", _gps); + def("repeat", _repeat); + def("comp", _comp); + def("wifi", _wifi); + def("zc", _zc); + } + +public: + explicit CompanionJson(NodePrefs *p) + : _p(p), _radio(p), _gps(p), _comp(p), _wifi(p), _zc(p) + { + /* upstream: repeat.disable; ours: client_repeat (1 = forward) */ + _repeat.disable = p->client_repeat ? 0 : 1; + } + + bool save(Stream &s) { return saveSerial(s); } + bool load(Stream &s) + { + bool ok = loadSerial(s); + + _p->client_repeat = _repeat.disable ? 0 : 1; + return ok; + } +}; + +/* ── Servers: upstream helpers/CommonCLI.h ──────────────────────────── */ + +class RepeatJson : public ConfigSerializer { + NodePrefs *_p; + +protected: + void structure() override + { + def("disable", _p->disable_fwd); + def("f_max", _p->flood_max); + def("f_max_uns", _p->flood_max_unscoped); + def("f_max_adv", _p->flood_max_advert); + def("loop", _p->loop_detect); + } + +public: + explicit RepeatJson(NodePrefs *p) : _p(p) {} +}; + +class RoomJson : public ConfigSerializer { + NodePrefs *_p; + +protected: + void structure() override { def("rd_only", _p->allow_read_only); } + +public: + explicit RoomJson(NodePrefs *p) : _p(p) {} +}; + +class PowerJson : public ConfigSerializer { + NodePrefs *_p; + +protected: + void structure() override + { + def("adc_mult", _p->adc_multiplier); + def("pwr_sav_en", _p->powersaving_enabled); + } + +public: + explicit PowerJson(NodePrefs *p) : _p(p) {} +}; + +class ZcServerJson : public ZcCommonJson { +protected: + void structure() override + { + ZcCommonJson::structure(); + def("tz_offset", _p->tz_offset); + def("backoff", _p->backoff_multiplier); + def("disc_ts", _p->discovery_mod_timestamp); + } + +public: + explicit ZcServerJson(NodePrefs *p) : ZcCommonJson(p) {} +}; + +class ServerJson : public ConfigSerializer { + NodePrefs *_p; + RadioJson _radio; + GpsJson _gps; + RepeatJson _repeat; + RoomJson _room; + PowerJson _power; + ZcServerJson _zc; + +protected: + void structure() override + { + def("name", _p->node_name, sizeof(_p->node_name)); + def("pass", _p->password, sizeof(_p->password)); + def("guest", _p->guest_password, sizeof(_p->guest_password)); + def("owner", _p->owner_info, sizeof(_p->owner_info)); + def("adv_int", _p->advert_interval); + def("f_adv_int", _p->flood_advert_interval); + def("lat", _p->node_lat); + def("lon", _p->node_lon); + def("radio", _radio); + def("gps", _gps); + def("repeat", _repeat); + def("room", _room); + def("power", _power); + def("zc", _zc); + } + +public: + explicit ServerJson(NodePrefs *p) + : _p(p), _radio(p), _gps(p), _repeat(p), _room(p), _power(p), _zc(p) {} + + bool save(Stream &s) { return saveSerial(s); } + bool load(Stream &s) { return loadSerial(s); } +}; + +void saneRadio(NodePrefs &p) +{ + if (p.freq < ZC_RADIO_FREQ_MIN_MHZ || p.freq > ZC_RADIO_FREQ_MAX_MHZ || + p.sf < 5 || p.sf > 12 || + p.bw < ZC_RADIO_BW_MIN_KHZ || p.bw > ZC_RADIO_BW_MAX_KHZ) { + p.freq = mesh::LoRaConfig::FREQ_MHZ; + p.bw = mesh::LoRaConfig::BANDWIDTH_KHZ; + p.sf = mesh::LoRaConfig::SPREADING_FACTOR; + p.cr = mesh::LoRaConfig::CODING_RATE; + p.tx_power_dbm = mesh::LoRaConfig::TX_POWER_DBM; + } +} + +} // namespace + +/* Writing only reads the struct; the serializer API takes references. */ + +bool companionPrefsToJson(const NodePrefs &p, Stream &out) +{ + CompanionJson j(const_cast(&p)); + + return j.save(out); +} + +bool serverPrefsToJson(const NodePrefs &p, Stream &out) +{ + ServerJson j(const_cast(&p)); + + return j.save(out); +} + +bool companionPrefsFromJson(NodePrefs &p, Stream &in) +{ + NodePrefs t = p; + CompanionJson j(&t); + + if (!j.load(in)) { + return false; + } + saneRadio(t); + sanitizeNodePrefs(&t); + p = t; + return true; +} + +bool serverPrefsFromJson(NodePrefs &p, Stream &in) +{ + NodePrefs t = p; + ServerJson j(&t); + + if (!j.load(in)) { + return false; + } + saneRadio(t); + sanitizeNodePrefs(&t); + p = t; + return true; +} diff --git a/zephcore/helpers/PrefsJson.h b/zephcore/helpers/PrefsJson.h new file mode 100644 index 0000000..306a4db --- /dev/null +++ b/zephcore/helpers/PrefsJson.h @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: MIT + * PrefsJson - prefs.json for both roles, through upstream's ConfigSerializer. + * + * Fields whose meaning matches upstream's use its keys and nesting + * (examples/companion_radio/NodePrefs.h for the companion, CommonCLI.h for + * the server roles), so a file is readable by either tree for those fields. + * ZephCore's own fields live under "zc". Unknown keys are ignored on load, + * missing keys keep the value already in the struct. + */ + +#pragma once + +#include "ConfigSerializer.h" +#include "NodePrefs.h" + +bool companionPrefsToJson(const NodePrefs &p, Stream &out); +bool serverPrefsToJson(const NodePrefs &p, Stream &out); + +/* false on a parse error, and p is left untouched. On success p is + * sanitized, and a radio preset outside ZC_RADIO_* is replaced by the + * LoRaConfig defaults. */ +bool companionPrefsFromJson(NodePrefs &p, Stream &in); +bool serverPrefsFromJson(NodePrefs &p, Stream &in); diff --git a/zephcore/helpers/RegionMap.cpp b/zephcore/helpers/RegionMap.cpp index 8425f2c..02a6c94 100644 --- a/zephcore/helpers/RegionMap.cpp +++ b/zephcore/helpers/RegionMap.cpp @@ -1,328 +1,381 @@ -/* - * SPDX-License-Identifier: MIT - * RegionMap - Region-based flood filtering for repeaters - */ - #include "RegionMap.h" #include -#include -#include -#include -#include +#include -LOG_MODULE_REGISTER(zephcore_regions, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); +// helper class for region map exporter, we emulate Stream with a safe buffer writer. + +class BufStream : public Stream { +public: + BufStream(char *buf, size_t max_len) + : _buf(buf), _max_len(max_len), _pos(0) { + if (_max_len > 0) _buf[0] = 0; + } + + size_t write(uint8_t c) override { + if (_pos + 1 >= _max_len) return 0; + _buf[_pos++] = c; + _buf[_pos] = 0; + return 1; + } + + size_t write(const uint8_t *buffer, size_t size) override { + size_t written = 0; + while (written < size) { + if (!write(buffer[written])) break; + written++; + } + return written; + } + + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + void flush() override {} + + size_t length() const { return _pos; } + +private: + char *_buf; + size_t _max_len; + size_t _pos; +}; -static const char* skip_hash(const char* name) { - return *name == '#' ? name + 1 : name; -} RegionMap::RegionMap(TransportKeyStore& store) : _store(&store) { - next_id = 1; - num_regions = 0; - default_id = home_id = 0; - wildcard.id = 0; - wildcard.parent = 0; - wildcard.flags = 0; // default behaviour, allow flood and direct - strcpy(wildcard.name, "*"); + next_id = 1; num_regions = 0; + default_id = home_id = 0; + wildcard.id = wildcard.parent = 0; + wildcard.flags = 0; // default behaviour, allow flood and direct + strcpy(wildcard.name, "*"); } bool RegionMap::is_name_char(uint8_t c) { - // accept all alpha-num or accented characters, but exclude most punctuation chars - return c == '-' || c == '$' || c == '#' || (c >= '0' && c <= '9') || c >= 'A'; + // accept all alpha-num or accented characters, but exclude most punctuation chars + return c == '-' || c == '$' || c == '#' || (c >= '0' && c <= '9') || c >= 'A'; } -bool RegionMap::load(const char* path) { - const char* filepath = path; - - struct fs_file_t file; - fs_file_t_init(&file); - - if (fs_open(&file, filepath, FS_O_READ) < 0) { - LOG_DBG("No regions file at %s", filepath); - return false; - } - - uint8_t pad[128]; - num_regions = 0; - next_id = 1; - default_id = home_id = 0; - - bool success = fs_read(&file, pad, 3) == 3; // reserved header - success = success && fs_read(&file, &default_id, sizeof(default_id)) == sizeof(default_id); - success = success && fs_read(&file, &home_id, sizeof(home_id)) == sizeof(home_id); - success = success && fs_read(&file, &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); - success = success && fs_read(&file, &next_id, sizeof(next_id)) == sizeof(next_id); - - if (success) { - while (num_regions < MAX_REGION_ENTRIES) { - auto r = ®ions[num_regions]; - - ssize_t n = fs_read(&file, &r->id, sizeof(r->id)); - if (n == 0) break; // clean EOF - success = (n == sizeof(r->id)); - success = success && fs_read(&file, &r->parent, sizeof(r->parent)) == sizeof(r->parent); - success = success && fs_read(&file, r->name, sizeof(r->name)) == sizeof(r->name); - success = success && fs_read(&file, &r->flags, sizeof(r->flags)) == sizeof(r->flags); - success = success && fs_read(&file, pad, sizeof(pad)) == sizeof(pad); - - if (!success) break; // partial read or corruption - - if (r->id >= next_id) { // make sure next_id is valid - next_id = r->id + 1; - } - num_regions++; - } - } - fs_close(&file); - LOG_INF("Loaded %d regions from %s", num_regions, filepath); - return success; +static const char* skip_hash(const char* name) { + return *name == '#' ? name + 1 : name; } -bool RegionMap::save(const char* path) { - const char* filepath = path; +static File openWrite(FILESYSTEM* _fs, const char* filename) { + #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + _fs->remove(filename); + return _fs->open(filename, FILE_O_WRITE); + #elif defined(RP2040_PLATFORM) + return _fs->open(filename, "w"); + #else + return _fs->open(filename, "w", true); + #endif +} - // Remove old file first - fs_unlink(filepath); +bool RegionMap::load(FILESYSTEM* _fs, const char* path) { + if (_fs->exists(path ? path : "/regions2")) { + #if defined(RP2040_PLATFORM) + File file = _fs->open(path ? path : "/regions2", "r"); + #else + File file = _fs->open(path ? path : "/regions2"); + #endif - struct fs_file_t file; - fs_file_t_init(&file); + if (file) { + uint8_t pad[128]; - if (fs_open(&file, filepath, FS_O_CREATE | FS_O_WRITE) < 0) { - LOG_ERR("Failed to open %s for write", filepath); - return false; - } + num_regions = 0; next_id = 1; + default_id = home_id = 0; - uint8_t pad[128]; - memset(pad, 0, sizeof(pad)); + bool success = file.read(pad, 3) == 3; // reserved header + success = success && file.read((uint8_t *) &default_id, sizeof(default_id)) == sizeof(default_id); + success = success && file.read((uint8_t *) &home_id, sizeof(home_id)) == sizeof(home_id); + success = success && file.read((uint8_t *) &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); + success = success && file.read((uint8_t *) &next_id, sizeof(next_id)) == sizeof(next_id); - bool success = fs_write(&file, pad, 3) == 3; // reserved header - success = success && fs_write(&file, &default_id, sizeof(default_id)) == sizeof(default_id); - success = success && fs_write(&file, &home_id, sizeof(home_id)) == sizeof(home_id); - success = success && fs_write(&file, &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); - success = success && fs_write(&file, &next_id, sizeof(next_id)) == sizeof(next_id); + if (success) { + while (num_regions < MAX_REGION_ENTRIES) { + auto r = ®ions[num_regions]; - if (success) { - for (int i = 0; i < num_regions; i++) { - auto r = ®ions[i]; + int n = file.read((uint8_t *) &r->id, sizeof(r->id)); + if (n == 0) break; // clean EOF + success = (n == sizeof(r->id)); + success = success && file.read((uint8_t *) &r->parent, sizeof(r->parent)) == sizeof(r->parent); + success = success && file.read((uint8_t *) r->name, sizeof(r->name)) == sizeof(r->name); + success = success && file.read((uint8_t *) &r->flags, sizeof(r->flags)) == sizeof(r->flags); + success = success && file.read(pad, sizeof(pad)) == sizeof(pad); - success = fs_write(&file, &r->id, sizeof(r->id)) == sizeof(r->id); - success = success && fs_write(&file, &r->parent, sizeof(r->parent)) == sizeof(r->parent); - success = success && fs_write(&file, r->name, sizeof(r->name)) == sizeof(r->name); - success = success && fs_write(&file, &r->flags, sizeof(r->flags)) == sizeof(r->flags); - success = success && fs_write(&file, pad, sizeof(pad)) == sizeof(pad); + if (!success) break; // partial read or corruption - if (!success) break; // write failed - } - } - fs_close(&file); - LOG_INF("Saved %d regions to %s", num_regions, filepath); - return success; + if (r->id >= next_id) { // make sure next_id is valid + next_id = r->id + 1; + } + num_regions++; + } + } + file.close(); + return success; + } + } + return false; // failed +} + +bool RegionMap::save(FILESYSTEM* _fs, const char* path) { + File file = openWrite(_fs, path ? path : "/regions2"); + if (file) { + uint8_t pad[128]; + memset(pad, 0, sizeof(pad)); + + bool success = file.write(pad, 3) == 3; // reserved header + success = success && file.write((uint8_t *) &default_id, sizeof(default_id)) == sizeof(default_id); + success = success && file.write((uint8_t *) &home_id, sizeof(home_id)) == sizeof(home_id); + success = success && file.write((uint8_t *) &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags); + success = success && file.write((uint8_t *) &next_id, sizeof(next_id)) == sizeof(next_id); + + if (success) { + for (int i = 0; i < num_regions; i++) { + auto r = ®ions[i]; + + success = file.write((uint8_t *) &r->id, sizeof(r->id)) == sizeof(r->id); + success = success && file.write((uint8_t *) &r->parent, sizeof(r->parent)) == sizeof(r->parent); + success = success && file.write((uint8_t *) r->name, sizeof(r->name)) == sizeof(r->name); + success = success && file.write((uint8_t *) &r->flags, sizeof(r->flags)) == sizeof(r->flags); + success = success && file.write(pad, sizeof(pad)) == sizeof(pad); + if (!success) break; // write failed + } + } + file.close(); + return success; + } + return false; // failed } RegionEntry* RegionMap::putRegion(const char* name, uint16_t parent_id, uint16_t id) { - const char* sp = name; // check for illegal name chars - while (*sp) { - if (!is_name_char(*sp)) return nullptr; // error - sp++; - } + const char* sp = name; // check for illegal name chars + while (*sp) { + if (!is_name_char(*sp)) return NULL; // error + sp++; + } - auto region = findByName(name); - if (region) { - if (region->id == parent_id) return nullptr; // ERROR: invalid parent! - region->parent = parent_id; // re-parent / move this region in the hierarchy - } else { - if (id == 0 && num_regions >= MAX_REGION_ENTRIES) return nullptr; // full! + auto region = findByName(name); + if (region) { + if (region->id == parent_id) return NULL; // ERROR: invalid parent! - region = ®ions[num_regions++]; // alloc new RegionEntry - region->flags = REGION_DENY_FLOOD; // DENY by default - region->id = id == 0 ? next_id++ : id; - StrHelper::strncpy(region->name, name, sizeof(region->name)); - region->parent = parent_id; - } - return region; + region->parent = parent_id; // re-parent / move this region in the hierarchy + } else { + if (id == 0 && num_regions >= MAX_REGION_ENTRIES) return NULL; // full! + + region = ®ions[num_regions++]; // alloc new RegionEntry + region->flags = REGION_DENY_FLOOD; // DENY by default + region->id = id == 0 ? next_id++ : id; + StrHelper::strncpy(region->name, name, sizeof(region->name)); + region->parent = parent_id; + } + return region; } int RegionMap::getTransportKeysFor(const RegionEntry& src, TransportKey dest[], int max_num) { - int num; - if (src.name[0] == '$') { // private region - num = _store->loadKeysFor(src.id, dest, max_num); - } else if (src.name[0] == '#') { // auto hashtag region - _store->getAutoKeyFor(src.id, src.name, dest[0]); - num = 1; - } else { // new: implicit auto hashtag region - char tmp[sizeof(src.name) + 1]; - tmp[0] = '#'; - memcpy(&tmp[1], src.name, sizeof(src.name) - 1); - tmp[sizeof(src.name)] = '\0'; - _store->getAutoKeyFor(src.id, tmp, dest[0]); - num = 1; - } - return num; + int num; + if (src.name[0] == '$') { // private region + num = _store->loadKeysFor(src.id, dest, max_num); + } else if (src.name[0] == '#') { // auto hashtag region + _store->getAutoKeyFor(src.id, src.name, dest[0]); + num = 1; + } else { // new: implicit auto hashtag region + char tmp[sizeof(src.name)+1]; + tmp[0] = '#'; + memcpy(&tmp[1], src.name, sizeof(src.name) - 1); // ZEPHCORE: bounded, the name + tmp[sizeof(src.name)] = 0; // may fill all 31 bytes + _store->getAutoKeyFor(src.id, tmp, dest[0]); + num = 1; + } + return num; } RegionEntry* RegionMap::findMatch(mesh::Packet* packet, uint8_t mask) { - for (int i = 0; i < num_regions; i++) { - auto region = ®ions[i]; - if ((region->flags & mask) == 0) { // does region allow this? (per 'mask' param) - TransportKey keys[4]; - int num = getTransportKeysFor(*region, keys, 4); - for (int j = 0; j < num; j++) { - uint16_t code = keys[j].calcTransportCode(packet); - if (packet->transport_codes[0] == code) { // a match!! - return region; - } - } - } - } - return nullptr; // no matches + for (int i = 0; i < num_regions; i++) { + auto region = ®ions[i]; + if ((region->flags & mask) == 0) { // does region allow this? (per 'mask' param) + TransportKey keys[4]; + int num = getTransportKeysFor(*region, keys, 4); + for (int j = 0; j < num; j++) { + uint16_t code = keys[j].calcTransportCode(packet); + if (packet->transport_codes[0] == code) { // a match!! + return region; + } + } + } + } + return NULL; // no matches } RegionEntry* RegionMap::findByName(const char* name) { - if (strcmp(name, "*") == 0) return &wildcard; + if (strcmp(name, "*") == 0) return &wildcard; - if (*name == '#') { name++; } // ignore the '#' when matching by name - for (int i = 0; i < num_regions; i++) { - auto region = ®ions[i]; - if (strcmp(name, skip_hash(region->name)) == 0) return region; - } - return nullptr; // not found + if (*name == '#') { name++; } // ignore the '#' when matching by name + for (int i = 0; i < num_regions; i++) { + auto region = ®ions[i]; + if (strcmp(name, skip_hash(region->name)) == 0) return region; + } + return NULL; // not found } RegionEntry* RegionMap::findByNamePrefix(const char* prefix) { - if (strcmp(prefix, "*") == 0) return &wildcard; + if (strcmp(prefix, "*") == 0) return &wildcard; - if (*prefix == '#') { prefix++; } // ignore the '#' when matching by name - RegionEntry* partial = nullptr; - for (int i = 0; i < num_regions; i++) { - auto region = ®ions[i]; - if (strcmp(prefix, skip_hash(region->name)) == 0) return region; // complete match - if (memcmp(prefix, skip_hash(region->name), strlen(prefix)) == 0) { - partial = region; - } - } - return partial; + if (*prefix == '#') { prefix++; } // ignore the '#' when matching by name + RegionEntry* partial = NULL; + for (int i = 0; i < num_regions; i++) { + auto region = ®ions[i]; + if (strcmp(prefix, skip_hash(region->name)) == 0) return region; // is a complete match, preference this one + if (memcmp(prefix, skip_hash(region->name), strlen(prefix)) == 0) { + partial = region; + } + } + return partial; } RegionEntry* RegionMap::findById(uint16_t id) { - if (id == 0) return &wildcard; // special root Region + if (id == 0) return &wildcard; // special root Region - for (int i = 0; i < num_regions; i++) { - auto region = ®ions[i]; - if (region->id == id) return region; - } - return nullptr; // not found + for (int i = 0; i < num_regions; i++) { + auto region = ®ions[i]; + if (region->id == id) return region; + } + return NULL; // not found } RegionEntry* RegionMap::getHomeRegion() { - return findById(home_id); + return findById(home_id); } void RegionMap::setHomeRegion(const RegionEntry* home) { - home_id = home ? home->id : 0; + home_id = home ? home->id : 0; } RegionEntry* RegionMap::getDefaultRegion() { - return default_id == 0 ? nullptr : findById(default_id); + return default_id == 0 ? NULL : findById(default_id); } void RegionMap::setDefaultRegion(const RegionEntry* def) { - default_id = def ? def->id : 0; + default_id = def ? def->id : 0; } bool RegionMap::removeRegion(const RegionEntry& region) { - if (region.id == 0) return false; // cannot remove wildcard + if (region.id == 0) return false; // failed (cannot remove the wildcard Region) - // first check region has no child regions - for (int i = 0; i < num_regions; i++) { - if (regions[i].parent == region.id) return false; // must remove children first - } + int i; // first check region has no child regions + for (i = 0; i < num_regions; i++) { + if (regions[i].parent == region.id) return false; // failed (must remove child Regions first) + } - int i = 0; - while (i < num_regions) { - if (region.id == regions[i].id) break; - i++; - } - if (i >= num_regions) return false; // not found + i = 0; + while (i < num_regions) { + if (region.id == regions[i].id) break; + i++; + } + if (i >= num_regions) return false; // failed (not found) - num_regions--; // remove from regions array - while (i < num_regions) { - regions[i] = regions[i + 1]; - i++; - } - return true; + num_regions--; // remove from regions array + while (i < num_regions) { + regions[i] = regions[i + 1]; + i++; + } + return true; // success } bool RegionMap::clear() { - num_regions = 0; - return true; + num_regions = 0; + return true; // success } -void RegionMap::printChildRegions(int indent, const RegionEntry* parent, char* buf, int& pos, int max_len) const { - // Print indentation - for (int i = 0; i < indent && pos < max_len - 1; i++) { - buf[pos++] = ' '; - } +void RegionMap::printChildRegions(int indent, const RegionEntry* parent, Stream& out) const { + for (int i = 0; i < indent; i++) { + out.print(' '); + } - // Print region info - int written; - if (parent->flags & REGION_DENY_FLOOD) { - written = snprintf(&buf[pos], max_len - pos, "%s%s\n", - skip_hash(parent->name), - parent->id == home_id ? "^" : ""); - } else { - written = snprintf(&buf[pos], max_len - pos, "%s%s F\n", - skip_hash(parent->name), - parent->id == home_id ? "^" : ""); - } - if (written > 0 && pos + written < max_len) { - pos += written; - } + if (parent->flags & REGION_DENY_FLOOD) { + out.printf("%s%s\n", skip_hash(parent->name), parent->id == home_id ? "^" : ""); + } else { + out.printf("%s%s F\n", skip_hash(parent->name), parent->id == home_id ? "^" : ""); + } - // Print children recursively - for (int i = 0; i < num_regions; i++) { - auto r = ®ions[i]; - if (r->parent == parent->id) { - printChildRegions(indent + 1, r, buf, pos, max_len); - } - } + for (int i = 0; i < num_regions; i++) { + auto r = ®ions[i]; + if (r->parent == parent->id) { + printChildRegions(indent + 1, r, out); + } + } } -size_t RegionMap::exportTo(char* dest, size_t max_len) const { - if (!dest || max_len == 0) return 0; - - int pos = 0; - printChildRegions(0, &wildcard, dest, pos, (int)max_len); - return (size_t)pos; +void RegionMap::exportTo(Stream& out) const { + printChildRegions(0, &wildcard, out); // recursive } -int RegionMap::exportNamesTo(char* dest, int max_len, uint8_t mask, bool invert) { - char* dp = dest; +// ZEPHCORE: whole lines only. Upstream's BufStream cuts the last line mid-name +// when the map overflows the buffer; a cut name pasted back into `region load` +// would define a wrong region. A line is written only if it fits entirely. +class LineBufStream : public Stream { +public: + LineBufStream(char *buf, size_t max_len) : _buf(buf), _max_len(max_len), _pos(0), _line_len(0) { + if (_max_len > 0) _buf[0] = 0; + } + size_t write(uint8_t c) override { + if (_line_len < sizeof(_line)) _line[_line_len++] = c; + if (c == '\n') { + if (_pos + _line_len < _max_len) { + memcpy(&_buf[_pos], _line, _line_len); + _pos += _line_len; + _buf[_pos] = 0; + } + _line_len = 0; + } + return 1; + } + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + size_t length() const { return _pos; } +private: + char *_buf; + size_t _max_len; + size_t _pos; + char _line[48]; + size_t _line_len; +}; - // Check wildcard region - bool wildcard_matches = invert ? (wildcard.flags & mask) : !(wildcard.flags & mask); - if (wildcard_matches) { - *dp++ = '*'; - *dp++ = ','; - } +size_t RegionMap::exportTo(char *dest, size_t max_len) const { + if (!dest || max_len == 0) return 0; - for (int i = 0; i < num_regions; i++) { - auto region = ®ions[i]; - - // Check if region matches the filter criteria - bool region_matches = invert ? (region->flags & mask) : !(region->flags & mask); - - if (region_matches) { - int len = strlen(skip_hash(region->name)); - if ((dp - dest) + len + 2 < max_len) { // only append if name will fit - memcpy(dp, skip_hash(region->name), len); - dp += len; - *dp++ = ','; - } - } - } - - if (dp > dest) { dp--; } // don't include trailing comma - - *dp = 0; // set null terminator - return dp - dest; + LineBufStream bs(dest, max_len); // ZEPHCORE: was BufStream + exportTo(bs); // ← reuse existing logic + return bs.length(); +} + +int RegionMap::exportNamesTo(char *dest, int max_len, uint8_t mask, bool invert) { + char *dp = dest; + + // Check wildcard region + bool wildcard_matches = invert ? (wildcard.flags & mask) : !(wildcard.flags & mask); + if (wildcard_matches) { + *dp++ = '*'; + *dp++ = ','; + } + + for (int i = 0; i < num_regions; i++) { + auto region = ®ions[i]; + + // Check if region matches the filter criteria + bool region_matches = invert ? (region->flags & mask) : !(region->flags & mask); + + if (region_matches) { + int len = strlen(skip_hash(region->name)); + if ((dp - dest) + len + 2 < max_len) { // only append if name will fit + memcpy(dp, skip_hash(region->name), len); + dp += len; + *dp++ = ','; + } + } + } + + if (dp > dest) { dp--; } // don't include trailing comma + + *dp = 0; // set null terminator + return dp - dest; // return length } diff --git a/zephcore/helpers/RegionMap.h b/zephcore/helpers/RegionMap.h index 4b132b4..a286d51 100644 --- a/zephcore/helpers/RegionMap.h +++ b/zephcore/helpers/RegionMap.h @@ -1,72 +1,68 @@ -/* - * SPDX-License-Identifier: MIT - * RegionMap - Region-based flood filtering for repeaters - */ - #pragma once +//#include // needed for PlatformIO #include +#include // ZEPHCORE: helpers/compat (Arduino.h provides it upstream) #include "TransportKeyStore.h" -#include -#include + +// ZEPHCORE: table size from Kconfig. +#if !defined(MAX_REGION_ENTRIES) && defined(CONFIG_ZEPHCORE_MAX_REGION_ENTRIES) + #define MAX_REGION_ENTRIES CONFIG_ZEPHCORE_MAX_REGION_ENTRIES +#endif #ifndef MAX_REGION_ENTRIES - #ifdef CONFIG_ZEPHCORE_MAX_REGION_ENTRIES - #define MAX_REGION_ENTRIES CONFIG_ZEPHCORE_MAX_REGION_ENTRIES - #else - #define MAX_REGION_ENTRIES 32 - #endif + #define MAX_REGION_ENTRIES 32 #endif #define REGION_DENY_FLOOD 0x01 #define REGION_DENY_DIRECT 0x02 // reserved for future struct RegionEntry { - uint16_t id; - uint16_t parent; - uint8_t flags; - char name[31]; + uint16_t id; + uint16_t parent; + uint8_t flags; + char name[31]; - bool isWildcard() const { return id == 0; } + bool isWildcard() const { return id == 0; } }; class RegionMap { - TransportKeyStore* _store; - uint16_t next_id; - uint16_t home_id; - uint16_t default_id; - uint16_t num_regions; - RegionEntry regions[MAX_REGION_ENTRIES]; - RegionEntry wildcard; + TransportKeyStore* _store; + uint16_t next_id, home_id, default_id; + uint16_t num_regions; + RegionEntry regions[MAX_REGION_ENTRIES]; + RegionEntry wildcard; - void printChildRegions(int indent, const RegionEntry* parent, char* buf, int& pos, int max_len) const; + void printChildRegions(int indent, const RegionEntry* parent, Stream& out) const; public: - RegionMap(TransportKeyStore& store); + RegionMap(TransportKeyStore& store); - static bool is_name_char(uint8_t c); + static bool is_name_char(uint8_t c); - bool load(const char* path = nullptr); - bool save(const char* path = nullptr); + bool load(FILESYSTEM* _fs, const char* path=NULL); + bool save(FILESYSTEM* _fs, const char* path=NULL); - RegionEntry* putRegion(const char* name, uint16_t parent_id, uint16_t id = 0); - RegionEntry* findMatch(mesh::Packet* packet, uint8_t mask); - RegionEntry& getWildcard() { return wildcard; } - RegionEntry* findByName(const char* name); - RegionEntry* findByNamePrefix(const char* prefix); - RegionEntry* findById(uint16_t id); - RegionEntry* getHomeRegion(); // NOTE: can be NULL - void setHomeRegion(const RegionEntry* home); - RegionEntry* getDefaultRegion(); // NOTE: can be NULL - void setDefaultRegion(const RegionEntry* def); - bool removeRegion(const RegionEntry& region); - bool clear(); - void resetFrom(const RegionMap& src) { num_regions = 0; next_id = src.next_id; } - int getCount() const { return num_regions; } - const RegionEntry* getByIdx(int i) const { return ®ions[i]; } - const RegionEntry* getRoot() const { return &wildcard; } - int exportNamesTo(char* dest, int max_len, uint8_t mask, bool invert = false); - int getTransportKeysFor(const RegionEntry& src, TransportKey dest[], int max_num); + RegionEntry* putRegion(const char* name, uint16_t parent_id, uint16_t id = 0); + RegionEntry* findMatch(mesh::Packet* packet, uint8_t mask); + RegionEntry& getWildcard() { return wildcard; } + RegionEntry* findByName(const char* name); + RegionEntry* findByNamePrefix(const char* prefix); + RegionEntry* findById(uint16_t id); + RegionEntry* getHomeRegion(); // NOTE: can be NULL + void setHomeRegion(const RegionEntry* home); + RegionEntry* getDefaultRegion(); // NOTE: can be NULL + void setDefaultRegion(const RegionEntry* def); + bool removeRegion(const RegionEntry& region); + bool clear(); + void resetFrom(const RegionMap& src) { num_regions = 0; next_id = src.next_id; } + int getCount() const { return num_regions; } + const RegionEntry* getByIdx(int i) const { return ®ions[i]; } + const RegionEntry* getRoot() const { return &wildcard; } + int exportNamesTo(char *dest, int max_len, uint8_t mask, bool invert = false); + int getTransportKeysFor(const RegionEntry& src, TransportKey dest[], int max_num); - size_t exportTo(char* dest, size_t max_len) const; + void exportTo(Stream& out) const; + size_t exportTo(char *dest, size_t max_len) const; + }; diff --git a/zephcore/helpers/TransportKeyStore.cpp b/zephcore/helpers/TransportKeyStore.cpp index 1e3f919..d4458da 100644 --- a/zephcore/helpers/TransportKeyStore.cpp +++ b/zephcore/helpers/TransportKeyStore.cpp @@ -1,124 +1,92 @@ -/* - * SPDX-License-Identifier: MIT - * TransportKeyStore - Transport key cache for region filtering - */ - #include "TransportKeyStore.h" -#include -#include +#include uint16_t TransportKey::calcTransportCode(const mesh::Packet* packet) const { - /* HMAC-SHA256 using PSA Crypto */ - uint8_t hmac[32]; - uint8_t type = packet->getPayloadType(); - - /* Build message: type + payload */ - uint8_t msg[MAX_PACKET_PAYLOAD + 1]; - msg[0] = type; - memcpy(&msg[1], packet->payload, packet->payload_len); - size_t msg_len = 1 + packet->payload_len; - - /* Import HMAC key */ - psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; - psa_set_key_type(&attr, PSA_KEY_TYPE_HMAC); - psa_set_key_bits(&attr, sizeof(key) * 8); - psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_SIGN_MESSAGE); - psa_set_key_algorithm(&attr, PSA_ALG_HMAC(PSA_ALG_SHA_256)); - - psa_key_id_t key_id; - psa_status_t status = psa_import_key(&attr, key, sizeof(key), &key_id); - if (status != PSA_SUCCESS) { - return 0; - } - - size_t out_len; - status = psa_mac_compute(key_id, PSA_ALG_HMAC(PSA_ALG_SHA_256), - msg, msg_len, hmac, sizeof(hmac), &out_len); - psa_destroy_key(key_id); - - if (status != PSA_SUCCESS) { - return 0; - } - - /* Extract first 2 bytes as transport code (little-endian, matching Arduino SHA256 finalizeHMAC behavior) */ - uint16_t code = (uint16_t)hmac[0] | ((uint16_t)hmac[1] << 8); - if (code == 0) { // reserve codes 0000 and FFFF - code++; - } else if (code == 0xFFFF) { - code--; - } - return code; + uint16_t code; + SHA256 sha; + sha.resetHMAC(key, sizeof(key)); + uint8_t type = packet->getPayloadType(); + sha.update(&type, 1); + sha.update(packet->payload, packet->payload_len); + sha.finalizeHMAC(key, sizeof(key), &code, 2); + if (code == 0) { // reserve codes 0000 and FFFF + code++; + } else if (code == 0xFFFF) { + code--; + } + return code; } bool TransportKey::isNull() const { - for (size_t i = 0; i < sizeof(key); i++) { - if (key[i]) return false; - } - return true; // key is all zeroes + for (size_t i = 0; i < sizeof(key); i++) { // ZEPHCORE: -Wsign-compare + if (key[i]) return false; + } + return true; // key is all zeroes } void TransportKeyStore::putCache(uint16_t id, const TransportKey& key) { - if (num_cache < MAX_TKS_ENTRIES) { - cache_ids[num_cache] = id; - cache_keys[num_cache] = key; - num_cache++; - } else { - // TODO: evict oldest cache entry - } + if (num_cache < MAX_TKS_ENTRIES) { + cache_ids[num_cache] = id; + cache_keys[num_cache] = key; + num_cache++; + } else { + // TODO: evict oldest cache entry + } } void TransportKeyStore::getAutoKeyFor(uint16_t id, const char* name, TransportKey& dest) { - // first, check cache - for (int i = 0; i < num_cache; i++) { - if (cache_ids[i] == id) { // cache hit! - dest = cache_keys[i]; - return; - } - } + for (int i = 0; i < num_cache; i++) { // first, check cache + if (cache_ids[i] == id) { // cache hit! + dest = cache_keys[i]; + return; + } + } + // calc key for publicly-known hashtag region name + SHA256 sha; + sha.update(name, strlen(name)); + sha.finalize(&dest.key, sizeof(dest.key)); - // calc key for publicly-known hashtag region name (SHA256 hash, first 16 bytes) - uint8_t hash[32]; - size_t out_len; - psa_hash_compute(PSA_ALG_SHA_256, (const uint8_t*)name, strlen(name), - hash, sizeof(hash), &out_len); - memcpy(dest.key, hash, sizeof(dest.key)); - - putCache(id, dest); + putCache(id, dest); } int TransportKeyStore::loadKeysFor(uint16_t id, TransportKey keys[], int max_num) { - int n = 0; - // first, check cache - for (int i = 0; i < num_cache && n < max_num; i++) { - if (cache_ids[i] == id) { - keys[n++] = cache_keys[i]; - } - } - if (n > 0) return n; // cache hit! + int n = 0; + for (int i = 0; i < num_cache && n < max_num; i++) { // first, check cache + if (cache_ids[i] == id) { + keys[n++] = cache_keys[i]; + } + } + if (n > 0) return n; // cache hit! - // TODO: retrieve from hardware keystore + // TODO: retrieve from difficult-to-copy keystore - // store in cache (if room) - for (int i = 0; i < n; i++) { - putCache(id, keys[i]); - } - return n; + // store in cache (if room) + for (int i = 0; i < n; i++) { + putCache(id, keys[i]); + } + return n; } bool TransportKeyStore::saveKeysFor(uint16_t id, const TransportKey keys[], int num) { - invalidateCache(); - // TODO: update hardware keystore - return false; // failed + invalidateCache(); + + // TODO: update hardware keystore + + return false; // failed } bool TransportKeyStore::removeKeys(uint16_t id) { - invalidateCache(); - // TODO: remove from hardware keystore - return false; // failed + invalidateCache(); + + // TODO: remove from hardware keystore + + return false; // failed } bool TransportKeyStore::clear() { - invalidateCache(); - // TODO: clear hardware keystore - return false; // failed + invalidateCache(); + + // TODO: clear hardware keystore + + return false; // failed } diff --git a/zephcore/helpers/TransportKeyStore.h b/zephcore/helpers/TransportKeyStore.h index ae6bb8f..59957a0 100644 --- a/zephcore/helpers/TransportKeyStore.h +++ b/zephcore/helpers/TransportKeyStore.h @@ -1,38 +1,31 @@ -/* - * SPDX-License-Identifier: MIT - * TransportKeyStore - Transport key cache for region filtering - */ - #pragma once +//#include // needed for PlatformIO #include #include -#include -#include struct TransportKey { - uint8_t key[16]; + uint8_t key[16]; - uint16_t calcTransportCode(const mesh::Packet* packet) const; - bool isNull() const; + uint16_t calcTransportCode(const mesh::Packet* packet) const; + bool isNull() const; }; #define MAX_TKS_ENTRIES 16 class TransportKeyStore { - uint16_t cache_ids[MAX_TKS_ENTRIES]; - TransportKey cache_keys[MAX_TKS_ENTRIES]; - int num_cache; + uint16_t cache_ids[MAX_TKS_ENTRIES]; + TransportKey cache_keys[MAX_TKS_ENTRIES]; + int num_cache; - void putCache(uint16_t id, const TransportKey& key); - void invalidateCache() { num_cache = 0; } + void putCache(uint16_t id, const TransportKey& key); + void invalidateCache() { num_cache = 0; } public: - TransportKeyStore() : num_cache(0) {} - - void getAutoKeyFor(uint16_t id, const char* name, TransportKey& dest); - int loadKeysFor(uint16_t id, TransportKey keys[], int max_num); - bool saveKeysFor(uint16_t id, const TransportKey keys[], int num); - bool removeKeys(uint16_t id); - bool clear(); + TransportKeyStore() { num_cache = 0; } + void getAutoKeyFor(uint16_t id, const char* name, TransportKey& dest); + int loadKeysFor(uint16_t id, TransportKey keys[], int max_num); + bool saveKeysFor(uint16_t id, const TransportKey keys[], int num); + bool removeKeys(uint16_t id); + bool clear(); }; diff --git a/zephcore/helpers/compat/Arduino.h b/zephcore/helpers/compat/Arduino.h new file mode 100644 index 0000000..07087ad --- /dev/null +++ b/zephcore/helpers/compat/Arduino.h @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: MIT + * The that code ported verbatim from upstream MeshCore includes: + * the fixed-width types, string functions and Print/Stream it relies on. + */ + +#pragma once + +#include +#include +#include +#include "Stream.h" diff --git a/zephcore/helpers/compat/SHA256.cpp b/zephcore/helpers/compat/SHA256.cpp new file mode 100644 index 0000000..6f94696 --- /dev/null +++ b/zephcore/helpers/compat/SHA256.cpp @@ -0,0 +1,72 @@ +/* + * SPDX-License-Identifier: MIT + */ + +#include "SHA256.h" +#include + +SHA256::SHA256() : _hash(PSA_HASH_OPERATION_INIT), _mac(PSA_MAC_OPERATION_INIT), + _key(PSA_KEY_ID_NULL), _hmac(false) +{ + reset(); +} + +void SHA256::clear() +{ + psa_hash_abort(&_hash); + psa_mac_abort(&_mac); + if (_key != PSA_KEY_ID_NULL) { + psa_destroy_key(_key); + _key = PSA_KEY_ID_NULL; + } + _hmac = false; +} + +void SHA256::reset() +{ + clear(); + psa_hash_setup(&_hash, PSA_ALG_SHA_256); +} + +void SHA256::update(const void *data, size_t len) +{ + if (_hmac) { + psa_mac_update(&_mac, (const uint8_t *)data, len); + } else { + psa_hash_update(&_hash, (const uint8_t *)data, len); + } +} + +void SHA256::finalize(void *hash, size_t len) +{ + uint8_t out[32] = {0}; + size_t out_len; + psa_hash_finish(&_hash, out, sizeof(out), &out_len); + memcpy(hash, out, len < sizeof(out) ? len : sizeof(out)); + clear(); +} + +void SHA256::resetHMAC(const void *key, size_t keyLen) +{ + clear(); + psa_key_attributes_t attr = PSA_KEY_ATTRIBUTES_INIT; + psa_set_key_type(&attr, PSA_KEY_TYPE_HMAC); + psa_set_key_bits(&attr, keyLen * 8); + psa_set_key_usage_flags(&attr, PSA_KEY_USAGE_SIGN_MESSAGE); + psa_set_key_algorithm(&attr, PSA_ALG_HMAC(PSA_ALG_SHA_256)); + if (psa_import_key(&attr, (const uint8_t *)key, keyLen, &_key) == PSA_SUCCESS) { + psa_mac_sign_setup(&_mac, _key, PSA_ALG_HMAC(PSA_ALG_SHA_256)); + } + _hmac = true; +} + +void SHA256::finalizeHMAC(const void *key, size_t keyLen, void *hash, size_t hashLen) +{ + (void)key; + (void)keyLen; + uint8_t out[32] = {0}; + size_t out_len; + psa_mac_sign_finish(&_mac, out, sizeof(out), &out_len); + memcpy(hash, out, hashLen < sizeof(out) ? hashLen : sizeof(out)); + clear(); +} diff --git a/zephcore/helpers/compat/SHA256.h b/zephcore/helpers/compat/SHA256.h new file mode 100644 index 0000000..fbdc003 --- /dev/null +++ b/zephcore/helpers/compat/SHA256.h @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: MIT + * The Arduino Crypto library's SHA256 interface (reset / update / finalize, + * and the HMAC variants) over PSA Crypto, for code ported verbatim from + * upstream MeshCore. + */ + +#pragma once + +#include +#include +#include + +class SHA256 { + psa_hash_operation_t _hash; + psa_mac_operation_t _mac; + psa_key_id_t _key; + bool _hmac; + +public: + SHA256(); + ~SHA256() { clear(); } + + void reset(); + void update(const void *data, size_t len); + void finalize(void *hash, size_t len); + + void resetHMAC(const void *key, size_t keyLen); + void finalizeHMAC(const void *key, size_t keyLen, void *hash, size_t hashLen); + + void clear(); +}; diff --git a/zephcore/helpers/compat/Serial.h b/zephcore/helpers/compat/Serial.h new file mode 100644 index 0000000..04833c3 --- /dev/null +++ b/zephcore/helpers/compat/Serial.h @@ -0,0 +1,23 @@ +/* + * SPDX-License-Identifier: MIT + * Arduino's `Serial`, for code ported verbatim from upstream MeshCore: the + * server roles' USB console. Output only. + */ + +#pragma once + +#include "Stream.h" + +class ConsoleSerial : public Stream { +public: + 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 available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } +}; + +/* Defined by the server roles' composition root (server_main_common.cpp). */ +extern ConsoleSerial Serial; diff --git a/zephcore/helpers/compat/Stream.h b/zephcore/helpers/compat/Stream.h new file mode 100644 index 0000000..e882dd7 --- /dev/null +++ b/zephcore/helpers/compat/Stream.h @@ -0,0 +1,115 @@ +/* + * SPDX-License-Identifier: MIT + * The slice of Arduino's Print / Stream API that code ported verbatim from + * upstream MeshCore uses, so it compiles unchanged. + */ + +#pragma once + +#include +#include +#include +#include +#include + +class Print { +public: + virtual ~Print() {} + + virtual size_t write(uint8_t c) = 0; + virtual size_t write(const uint8_t *buffer, size_t size) + { + size_t n = 0; + while (n < size && write(buffer[n])) { + n++; + } + return n; + } + size_t write(const char *str) { return str ? write((const uint8_t *)str, strlen(str)) : 0; } + + size_t print(char c) { return write((uint8_t)c); } + size_t print(const char *str) { return write(str); } + size_t println(const char *str) { return write(str) + write("\r\n"); } + + /* Numbers, as Arduino prints them (ConfigSerializer). int32_t is long on + * some targets and int on others, so both widths are here. */ + size_t print(long n, int base = 10) + { + char buf[24]; + int len = (base == 16) ? snprintf(buf, sizeof(buf), "%lx", (unsigned long)n) + : snprintf(buf, sizeof(buf), "%ld", n); + return write((const uint8_t *)buf, (size_t)len); + } + size_t print(unsigned long n, int base = 10) + { + char buf[24]; + int len = snprintf(buf, sizeof(buf), (base == 16) ? "%lx" : "%lu", n); + return write((const uint8_t *)buf, (size_t)len); + } + size_t print(int n, int base = 10) { return print((long)n, base); } + size_t print(unsigned int n, int base = 10) { return print((unsigned long)n, base); } + + /* Arduino's Print::printFloat, digit for digit, so a value is written as + * upstream would write it, without relying on libc float formatting. */ + size_t print(double number, int digits = 2) + { + if (number != number) { + return print("nan"); + } + if (number > 4294967040.0) { + return print("ovf"); + } + if (number < -4294967040.0) { + return print("-ovf"); + } + size_t n = 0; + + if (number < 0.0) { + n += print('-'); + number = -number; + } + double rounding = 0.5; + + for (int i = 0; i < digits; ++i) { + rounding /= 10.0; + } + number += rounding; + unsigned long int_part = (unsigned long)number; + double remainder = number - (double)int_part; + + n += print(int_part); + if (digits > 0) { + n += print('.'); + } + while (digits-- > 0) { + remainder *= 10.0; + unsigned int to_print = (unsigned int)remainder; + + n += print(to_print); + remainder -= to_print; + } + return n; + } + + /* Longer output is truncated; the ported callers print short lines. */ + size_t printf(const char *format, ...) __attribute__((format(printf, 2, 3))) + { + char buf[160]; + va_list args; + va_start(args, format); + int n = vsnprintf(buf, sizeof(buf), format, args); + va_end(args); + if (n < 0) { + return 0; + } + return write((const uint8_t *)buf, (size_t)n < sizeof(buf) ? (size_t)n : sizeof(buf) - 1); + } +}; + +class Stream : public Print { +public: + virtual int available() = 0; + virtual int read() = 0; + virtual int peek() = 0; + virtual void flush() {} +}; diff --git a/zephcore/helpers/compat/ZephyrFS.cpp b/zephcore/helpers/compat/ZephyrFS.cpp new file mode 100644 index 0000000..da0df08 --- /dev/null +++ b/zephcore/helpers/compat/ZephyrFS.cpp @@ -0,0 +1,112 @@ +/* + * SPDX-License-Identifier: MIT + */ + +#include "ZephyrFS.h" +#include +#include + +bool File::openPath(const char *path, fs_mode_t flags) +{ + close(); + fs_file_t_init(&_file); + _open = fs_open(&_file, path, flags) == 0; + return _open; +} + +size_t File::read(uint8_t *buf, size_t size) +{ + if (!_open) { + return 0; + } + ssize_t n = fs_read(&_file, buf, size); + return n > 0 ? (size_t)n : 0; +} + +size_t File::write(const uint8_t *buf, size_t size) +{ + if (!_open) { + return 0; + } + ssize_t n = fs_write(&_file, buf, size); + return n > 0 ? (size_t)n : 0; +} + +int File::read() +{ + uint8_t c; + return read(&c, 1) == 1 ? c : -1; +} + +int File::peek() +{ + int c = read(); + if (c >= 0) { + fs_seek(&_file, -1, FS_SEEK_CUR); + } + return c; +} + +int File::available() +{ + if (!_open) { + return 0; + } + off_t pos = fs_tell(&_file); + if (pos < 0 || fs_seek(&_file, 0, FS_SEEK_END) < 0) { + return 0; + } + off_t end = fs_tell(&_file); + fs_seek(&_file, pos, FS_SEEK_SET); + return end > pos ? (int)(end - pos) : 0; +} + +void File::close() +{ + if (_open) { + fs_close(&_file); + _open = false; + } +} + +void ZephyrFS::fullPath(char *dest, size_t dest_len, const char *path) const +{ + snprintf(dest, dest_len, "%s%s%s", _root, path[0] == '/' ? "" : "/", path); +} + +bool ZephyrFS::exists(const char *path) +{ + char full[64]; + fullPath(full, sizeof(full), path); + struct fs_dirent entry; + return fs_stat(full, &entry) == 0; +} + +File ZephyrFS::open(const char *path, const char *mode, bool create) +{ + char full[64]; + fullPath(full, sizeof(full), path); + File file; + if (mode[0] == 'w') { + fs_unlink(full); /* truncate: LittleFS rewrites a file in place otherwise */ + file.openPath(full, FS_O_CREATE | FS_O_WRITE); + } else { + file.openPath(full, FS_O_READ); + } + (void)create; + return file; +} + +bool ZephyrFS::remove(const char *path) +{ + char full[64]; + fullPath(full, sizeof(full), path); + return fs_unlink(full) == 0; +} + +bool ZephyrFS::mkdir(const char *path) +{ + char full[64]; + fullPath(full, sizeof(full), path); + return fs_mkdir(full) == 0; +} diff --git a/zephcore/helpers/compat/ZephyrFS.h b/zephcore/helpers/compat/ZephyrFS.h new file mode 100644 index 0000000..f88cedd --- /dev/null +++ b/zephcore/helpers/compat/ZephyrFS.h @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: MIT + * Arduino-style FILESYSTEM / File over the Zephyr fs API, so storage code + * ported verbatim from upstream MeshCore compiles unchanged. A ZephyrFS is + * rooted at a directory: upstream's "/regions2" opens /regions2. + */ + +#pragma once + +#include "Stream.h" +#include + +class File : public Stream { + struct fs_file_t _file; + bool _open; + +public: + File() : _open(false) { fs_file_t_init(&_file); } + File(File &&other) : _file(other._file), _open(other._open) { other._open = false; } + File(const File &) = delete; + File &operator=(const File &) = delete; + ~File() { close(); } + + bool openPath(const char *path, fs_mode_t flags); + + explicit operator bool() const { return _open; } + + size_t read(uint8_t *buf, size_t size); + size_t write(const uint8_t *buf, size_t size) override; + size_t write(uint8_t c) override { return write(&c, 1); } + using Print::write; + + int read() override; + int peek() override; + int available() override; + void close(); +}; + +class ZephyrFS { + const char *_root; + + void fullPath(char *dest, size_t dest_len, const char *path) const; + +public: + explicit ZephyrFS(const char *root) : _root(root) {} + + bool exists(const char *path); + /* "r" (default): read. "w": truncate or create, then write. */ + File open(const char *path, const char *mode = "r", bool create = false); + bool remove(const char *path); + bool mkdir(const char *path); +}; diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index ccf270a..c1ccabc 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -119,7 +119,9 @@ extern "C" void mesh_gps_set_enabled(bool enable) extern "C" void mesh_ble_set_enabled(bool enable) { +#if IS_ENABLED(CONFIG_BT) zephcore_ble_set_enabled(enable); +#endif atomic_set(&pending_ble_disabled, enable ? 0 : 1); atomic_or(&pending_ui_actions, UI_ACTION_BLE_TOGGLE); k_event_post(s_mesh_events, s_mesh_event_ui_action); diff --git a/zephcore/include/mesh/Utils.h b/zephcore/include/mesh/Utils.h index 8e70d0a..3b4ba65 100644 --- a/zephcore/include/mesh/Utils.h +++ b/zephcore/include/mesh/Utils.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -66,7 +67,10 @@ public: */ static bool fromHex(uint8_t* dest, int dest_size, const char *src_hex); - // ZEPHCORE: no printHex(Stream&) (Arduino Stream). + /** + * \brief Prints the hexadecimal representation of 'src' bytes of given length, to Stream 's'. + */ + static void printHex(Stream& s, const uint8_t* src, size_t len); /** * \brief parse 'text' into parts separated by 'separator' char. diff --git a/zephcore/patches/modules/hal_espressif/0001-heap-adapter-psram-realloc.patch b/zephcore/patches/modules/hal_espressif/0001-heap-adapter-psram-realloc.patch new file mode 100644 index 0000000..f304b6c --- /dev/null +++ b/zephcore/patches/modules/hal_espressif/0001-heap-adapter-psram-realloc.patch @@ -0,0 +1,41 @@ +Backport of hal_espressif af97fb02df (upstream main, 2026-09-23): +"zephyr: port: fix realloc in the psram heap adapters". + +With the Wi-Fi or BT heap in PSRAM, os_wpa_realloc_func() and +esp_bt_realloc_func() freed the old block and returned a new, uninitialized +one: wpabuf_resize() lost the buffer contents, and on allocation failure the +old block was already freed while the caller still owned it. + +ZephCore: drop this patch at the first west update whose hal_espressif pin +contains af97fb02df (Zephyr main still pins 064142dfcd, 2026-09-18). + +diff --git a/zephyr/port/include/esp_heap_adapter.h b/zephyr/port/include/esp_heap_adapter.h +index be7e293da5..5015e50d52 100644 +--- a/zephyr/port/include/esp_heap_adapter.h ++++ b/zephyr/port/include/esp_heap_adapter.h +@@ -62,10 +62,10 @@ static inline void* os_wpa_malloc_func(size_t _size) + + static inline void* os_wpa_realloc_func(void *_ptr, size_t _size) + { +- if (_ptr) { +- esp_wifi_free_func(_ptr); ++ if (esp_ptr_in_dram(_ptr)) { ++ return k_realloc(_ptr, _size); + } +- return os_wpa_malloc_func(_size); ++ return shared_multi_heap_realloc(SMH_REG_ATTR_EXTERNAL, _ptr, _size); + } + + static inline void* os_wpa_calloc_func(size_t _nmemb, size_t _size) +@@ -121,10 +121,7 @@ static inline void* esp_bt_calloc_func(size_t _nmemb, size_t _size) + + static inline void* esp_bt_realloc_func(void *_ptr, size_t _size) + { +- if (_ptr) { +- shared_multi_heap_free(_ptr); +- } +- return esp_bt_malloc_func(_size); ++ return shared_multi_heap_realloc(SMH_REG_ATTR_EXTERNAL, _ptr, _size); + } + + static inline void esp_bt_free_func(void *_mem) diff --git a/zephcore/patches/zephyr/0017-wifi-esp32-rx-never-block.patch b/zephcore/patches/zephyr/0017-wifi-esp32-rx-never-block.patch new file mode 100644 index 0000000..431ee7c --- /dev/null +++ b/zephcore/patches/zephyr/0017-wifi-esp32-rx-never-block.patch @@ -0,0 +1,49 @@ +ESP32 Wi-Fi: never block the Wi-Fi task in the station/AP RX callback. + +eth_esp32_rx() / wifi_esp32_ap_iface_rx() run on the Wi-Fi task and waited +up to 100 ms for a net buffer. Under unicast traffic the node answers (ICMP +port unreachable at 200 frames/s is enough), the RX thread is slowed by those +sends, the RX pool runs dry, every further frame costs the Wi-Fi task 100 ms, +the Wi-Fi heap fills with queued frames and transmit allocation fails with +-ENOBUFS: a livelock the LAN's background traffic sustains until reboot +(companion answered nothing over Wi-Fi, BLE/USB unaffected). ESP-IDF's own +netif glue drops the frame when no buffer is free; K_NO_WAIT does the same. + +Found and bench-verified 2026-09-24 on a XIAO ESP32-S3 companion +(devdocs/lld/08-connectivity.md, "Wi-Fi flood wedge"). Not fixed on Zephyr +main as of 15723416fdd (2026-09-24). + +diff --git a/drivers/wifi/esp32/src/esp_wifi_drv.c b/drivers/wifi/esp32/src/esp_wifi_drv.c +index 1744959e0c8..02f00683b60 100644 +--- a/drivers/wifi/esp32/src/esp_wifi_drv.c ++++ b/drivers/wifi/esp32/src/esp_wifi_drv.c +@@ -325,7 +325,17 @@ static esp_err_t eth_esp32_rx(void *buffer, uint16_t len, void *eb) + { + struct net_pkt *pkt; + +- pkt = net_pkt_rx_alloc_with_buffer(esp32_wifi_iface, len, NET_AF_UNSPEC, 0, K_MSEC(100)); ++ /* ZephCore: never block here. This runs on the Wi-Fi task, so waiting ++ * for a net buffer (was K_MSEC(100)) stalls the Wi-Fi task itself while ++ * it holds received frames in the Wi-Fi heap. Under unicast traffic the ++ * node answers (e.g. ICMP port unreachable at 200 frames/s), the RX ++ * thread is slowed by those sends, the RX pool runs dry, every frame ++ * then costs the Wi-Fi task 100 ms, the Wi-Fi heap fills with queued ++ * frames and transmit allocation fails (-ENOBUFS): a livelock that ++ * the LAN's background traffic sustains until reboot. ESP-IDF's own ++ * netif glue drops the frame when no buffer is free; so do we. ++ */ ++ pkt = net_pkt_rx_alloc_with_buffer(esp32_wifi_iface, len, NET_AF_UNSPEC, 0, K_NO_WAIT); + if (pkt == NULL) { + LOG_ERR("Failed to allocate net buffer"); + esp_wifi_internal_free_rx_buffer(eb); +@@ -379,8 +389,9 @@ static esp_err_t wifi_esp32_ap_iface_rx(void *buffer, uint16_t len, void *eb) + { + struct net_pkt *pkt; + ++ /* ZephCore: see eth_esp32_rx(). */ + pkt = net_pkt_rx_alloc_with_buffer(esp32_wifi_iface_ap, len, +- NET_AF_UNSPEC, 0, K_MSEC(100)); ++ NET_AF_UNSPEC, 0, K_NO_WAIT); + if (pkt == NULL) { + esp_wifi_internal_free_rx_buffer(eb); + LOG_ERR("Failed to get net buffer"); diff --git a/zephcore/patches/zephyr/0018-shared-multi-heap-lock.patch b/zephcore/patches/zephyr/0018-shared-multi-heap-lock.patch new file mode 100644 index 0000000..53ebd79 --- /dev/null +++ b/zephcore/patches/zephyr/0018-shared-multi-heap-lock.patch @@ -0,0 +1,108 @@ +shared_multi_heap: lock the pool. + +sys_heap is not internally synchronized and shared_multi_heap added no lock, +although it is a system-wide pool for drivers and applications. On ESP32 with +CONFIG_ESP_WIFI_HEAP_SPIRAM (every PSRAM Wi-Fi companion) the Wi-Fi adapter +(hal_espressif esp_heap_adapter.h) allocates and frees from it on several +threads at once; under Wi-Fi traffic that corrupted the heap ("heap +corruption (free list linkage)" / "double free?") and panicked the Wi-Fi +task. A spinlock around every pool operation, as k_heap does around the same +allocator. + +Found and bench-verified 2026-09-24 on a XIAO ESP32-S3 companion +(devdocs/lld/08-connectivity.md, "Wi-Fi flood wedge"). Not fixed on Zephyr +main as of 15723416fdd (2026-09-24). + +diff --git a/lib/heap/shared_multi_heap.c b/lib/heap/shared_multi_heap.c +index c6a4fe50d58..6af29745d5c 100644 +--- a/lib/heap/shared_multi_heap.c ++++ b/lib/heap/shared_multi_heap.c +@@ -13,6 +13,18 @@ + + static struct sys_multi_heap shared_multi_heap; + ++/* ++ * ZephCore: sys_heap is not internally synchronized ("All locking must be ++ * provided by the user", sys_heap.h), and neither was this pool, although it ++ * is a system-wide service shared by drivers and applications. On ESP32 with ++ * CONFIG_ESP_WIFI_HEAP_SPIRAM the Wi-Fi adapter allocates and frees from here ++ * on several threads at once (the Wi-Fi task, its event thread and every ++ * transmitting thread); under traffic that corrupted the heap ("double ++ * free?" / "free list linkage") and panicked the Wi-Fi task. A spinlock, as ++ * k_heap uses around the same allocator. ++ */ ++static struct k_spinlock smh_lock; ++ + static struct { + struct sys_heap heap_pool[MAX_MULTI_HEAPS]; + unsigned int heap_cnt; +@@ -66,6 +78,8 @@ int shared_multi_heap_add(struct shared_multi_heap_region *region, void *user_da + return -ENOMEM; + } + ++ k_spinlock_key_t key = k_spin_lock(&smh_lock); ++ + slot = smh_data[attr].heap_cnt; + h = &smh_data[attr].heap_pool[slot]; + +@@ -74,12 +88,17 @@ int shared_multi_heap_add(struct shared_multi_heap_region *region, void *user_da + + smh_data[attr].heap_cnt++; + ++ k_spin_unlock(&smh_lock, key); ++ + return 0; + } + + void shared_multi_heap_free(void *block) + { ++ k_spinlock_key_t key = k_spin_lock(&smh_lock); ++ + sys_multi_heap_free(&shared_multi_heap, block); ++ k_spin_unlock(&smh_lock, key); + } + + void *shared_multi_heap_alloc(enum shared_multi_heap_attr attr, size_t bytes) +@@ -88,7 +107,11 @@ void *shared_multi_heap_alloc(enum shared_multi_heap_attr attr, size_t bytes) + return NULL; + } + +- return sys_multi_heap_alloc(&shared_multi_heap, (void *)(long) attr, bytes); ++ k_spinlock_key_t key = k_spin_lock(&smh_lock); ++ void *block = sys_multi_heap_alloc(&shared_multi_heap, (void *)(long) attr, bytes); ++ ++ k_spin_unlock(&smh_lock, key); ++ return block; + } + + void *shared_multi_heap_realloc(enum shared_multi_heap_attr attr, void *ptr, size_t bytes) +@@ -97,8 +120,12 @@ void *shared_multi_heap_realloc(enum shared_multi_heap_attr attr, void *ptr, siz + return NULL; + } + +- return sys_multi_heap_aligned_realloc(&shared_multi_heap, (void *)(long) attr, +- ptr, 0, bytes); ++ k_spinlock_key_t key = k_spin_lock(&smh_lock); ++ void *block = sys_multi_heap_aligned_realloc(&shared_multi_heap, (void *)(long) attr, ++ ptr, 0, bytes); ++ ++ k_spin_unlock(&smh_lock, key); ++ return block; + } + + void *shared_multi_heap_aligned_alloc(enum shared_multi_heap_attr attr, +@@ -108,8 +135,12 @@ void *shared_multi_heap_aligned_alloc(enum shared_multi_heap_attr attr, + return NULL; + } + +- return sys_multi_heap_aligned_alloc(&shared_multi_heap, (void *)(long) attr, +- align, bytes); ++ k_spinlock_key_t key = k_spin_lock(&smh_lock); ++ void *block = sys_multi_heap_aligned_alloc(&shared_multi_heap, (void *)(long) attr, ++ align, bytes); ++ ++ k_spin_unlock(&smh_lock, key); ++ return block; + } + + int shared_multi_heap_pool_init(void) diff --git a/zephcore/scripts/board_manifest.py b/zephcore/scripts/board_manifest.py index 39c4b56..a4f0f01 100644 --- a/zephcore/scripts/board_manifest.py +++ b/zephcore/scripts/board_manifest.py @@ -8,6 +8,7 @@ native-Linux preset boards/linux_native/.conf carries a sibling target: rak4631 # REQUIRED west -b string, with qualifiers capabilities: + wifi: true # WiFi companion: RAM for WiFi + BLE together light_sleep: true # DIO1 on an RTC-wake-capable GPIO AND # validated on hardware (ESP32 repeaters) release: # absent = not published (bring-up) @@ -27,9 +28,9 @@ native-Linux preset boards/linux_native/.conf carries a sibling cross_compile: /usr/bin/arm-linux-gnueabihf- Readers: - - zephcore/CMakeLists.txt reads capabilities.light_sleep with a line match - (it runs before Zephyr has set up Python), so `check` enforces the literal - two-space-indented ` light_sleep: true|false` form. + - cmake/zephcore_board.cmake reads the capabilities with a line match (it + runs before Zephyr has set up Python), so `check` enforces the literal + two-space-indented ` : true|false` form. - build.sh: board_manifest.py matrix [companions|repeaters] - gen_provider_catalog.py imports load_boards(). - docs: board_manifest.py docs (rewrites the generated lists in docs/supported_boards.md) @@ -49,7 +50,7 @@ PLATFORM_DIRS = {"nrf52840": "nrf52", "nrf54l": "nrf54l", "esp32": "esp32", "mg24": "mg24", "stm32wl": "stm32wl"} ROLES = ("companion", "repeater", "room_server", "observer") KEYS = {"target", "capabilities", "release", "catalog", "linux"} -CAPS = {"light_sleep"} +CAPS = {"light_sleep", "wifi"} CATALOG_KEYS = {"device", "maker", "new", "img", "own_img", "subtitle"} CAP_LINE = re.compile(r"^ (\w+): (true|false)\s*$", re.M) diff --git a/zephcore/src/Utils.cpp b/zephcore/src/Utils.cpp index 6140f56..74af387 100644 --- a/zephcore/src/Utils.cpp +++ b/zephcore/src/Utils.cpp @@ -255,6 +255,16 @@ void Utils::toHex(char *dest, const uint8_t *src, size_t len) *dest = 0; } +void Utils::printHex(Stream &s, const uint8_t *src, size_t len) +{ + while (len > 0) { + uint8_t b = *src++; + s.print(hex_chars[b >> 4]); + s.print(hex_chars[b & 0x0F]); + len--; + } +} + bool Utils::fromHex(uint8_t *dest, int dest_size, const char *src_hex) { size_t len = strlen(src_hex); diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 6f3aa2b..8d87fcc 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -23,6 +23,8 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include #include +#include +#include "mesh_events.h" #include #include #include @@ -38,20 +40,16 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include "buzzer.h" #endif -#include - -/* The wired-companion stack (ZephyrCompanionUSB) is compiled when either - * logging needs the CDC console (debug builds), the native-USB companion is - * enabled (CONFIG_ZEPHCORE_COMPANION_USB, default-y on USB-capable boards), or - * the plain-UART companion is enabled (CONFIG_ZEPHCORE_COMPANION_SERIAL, for - * USB-UART-bridge / no-USB boards). All three share the same frame parser, - * TX ring, CLI, and BLE arbitration — only the byte backend differs. */ -#define ZEPHCORE_USB_STACK \ - (IS_ENABLED(CONFIG_LOG) || IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_USB) || \ - IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_SERIAL)) +/* The companion transports (BLE, USB/UART, TCP) as upstream + * BaseSerialInterfaces; also defines ZEPHCORE_USB_STACK. */ +#include +#include +#include +#if IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_WIFI) +#include +#endif #if ZEPHCORE_USB_STACK -#include #include #endif @@ -65,8 +63,8 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #ifdef ZEPHCORE_LORA #include #include -#include #include +#include #include #endif @@ -81,17 +79,13 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) } #endif -/* The mesh thread blocks on these; ISRs and callbacks post them. */ -#define MESH_EVENT_LORA_RX BIT(0) /* LoRa packet received */ -#define MESH_EVENT_LORA_TX_DONE BIT(1) /* LoRa TX complete */ -#define MESH_EVENT_BLE_RX BIT(2) /* BLE/USB frame received */ -#define MESH_EVENT_HOUSEKEEPING BIT(3) /* Periodic housekeeping (noise floor, etc.) */ -#define MESH_EVENT_UI_ACTION BIT(4) /* Button action from UI (deferred to mesh thread) */ -#define MESH_EVENT_GPS_ACTION BIT(5) /* GPS state change (main thread only) */ -#define MESH_EVENT_TX_DRAIN BIT(6) /* Outbound packet delay expired, run checkSend */ -#define MESH_EVENT_PREFS_DIRTY BIT(8) /* Prefs mutated off-main; main flushes to flash */ -#define MESH_EVENT_RTC_SAVE BIT(9) /* Hardware-RTC write requested off-main */ -#define MESH_EVENT_CONTACT_ITER BIT(10) /* Continue contact-dump iteration on main thread */ +/* The mesh thread blocks on these; ISRs and callbacks post them. The shared + * bits are in mesh_events.h; these are the companion's own. */ +#define MESH_EVENT_UI_ACTION BIT(MESH_EVENT_ROLE_BASE) /* Button action from UI (deferred to mesh thread) */ +#define MESH_EVENT_JOYSTICK_LOOP BIT(MESH_EVENT_ROLE_BASE + 1) /* Run the joystick UI loop (on input, notify or its timers) */ +#define MESH_EVENT_PREFS_DIRTY BIT(MESH_EVENT_ROLE_BASE + 2) /* Prefs mutated off-main; main flushes to flash */ +#define MESH_EVENT_CONTACT_ITER BIT(MESH_EVENT_ROLE_BASE + 3) /* Continue contact-dump iteration on main thread */ +#define MESH_EVENT_LINK BIT(MESH_EVENT_ROLE_BASE + 4) /* A transport connected or disconnected */ #ifdef ZEPHCORE_LORA static void save_prefs_to_flash(void); @@ -107,9 +101,8 @@ static atomic_t pending_rtc_epoch = ATOMIC_INIT(0); #define MESH_EVENT_BASE (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | \ MESH_EVENT_BLE_RX | MESH_EVENT_HOUSEKEEPING | MESH_EVENT_UI_ACTION | \ MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_PREFS_DIRTY | \ - MESH_EVENT_RTC_SAVE | MESH_EVENT_CONTACT_ITER) + MESH_EVENT_RTC_SAVE | MESH_EVENT_CONTACT_ITER | MESH_EVENT_LINK) #if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) -#define MESH_EVENT_JOYSTICK_LOOP BIT(7) /* Joystick UI loop tick (50 ms) */ #define MESH_EVENT_ALL (MESH_EVENT_BASE | MESH_EVENT_JOYSTICK_LOOP) #else #define MESH_EVENT_ALL MESH_EVENT_BASE @@ -126,7 +119,7 @@ static void request_rtc_save(uint32_t epoch) k_event_post(&mesh_events, MESH_EVENT_RTC_SAVE); } -static void process_companion_rx(void); /* runs on MAIN thread (see ble_on_rx_frame) */ +static void process_companion_rx(void); /* runs on MAIN thread (see link_on_rx) */ static void run_contact_iteration(void); /* runs on MAIN thread (see MESH_EVENT_CONTACT_ITER) */ static void housekeeping_timer_fn(struct k_timer *timer); #if ZEPHCORE_USB_STACK @@ -160,100 +153,70 @@ struct companion_cli_line { char buf[CLI_LINE_BUF_SIZE]; }; K_MSGQ_DEFINE(companion_cli_queue, sizeof(struct companion_cli_line), 4, 4); #endif -#if ZEPHCORE_USB_STACK -/* USB TX ring drained (CDC TX interrupt context): resume the contact dump. */ -static void usb_on_tx_drain(void) -{ - k_event_post(&mesh_events, MESH_EVENT_CONTACT_ITER); -} -#endif - K_TIMER_DEFINE(housekeeping_timer, housekeeping_timer_fn, NULL); #ifdef ZEPHCORE_LORA static CompanionMesh *companion_mesh_ptr; #endif -/* ========== BLE callbacks → main ========== */ +/* ========== Companion transports ========== + * + * As upstream's companion_radio/main.cpp: every transport this build has is a + * BaseSerialInterface in one MultiSerialInterface, and CompanionMesh sees only + * that (through CompanionSerial, which keeps a lossless reply the transports + * could not take). Every connected transport gets every frame; a command from + * any of them is served. */ +static MultiSerialInterface interface_manager; +static CompanionSerial companion_serial(interface_manager); +#if IS_ENABLED(CONFIG_BT) +static ZephyrBLEInterface ble_interface; +#endif +#if ZEPHCORE_USB_STACK +static ZephyrSerialInterface usb_interface; +#endif +#if IS_ENABLED(CONFIG_ZEPHCORE_TRANSPORT_TCP) +static ZephyrTcpInterface tcp_interface; +#endif -static void ble_on_rx_frame(const uint8_t *data, uint16_t len) +/* The transports' callbacks run on their own threads (BT, sysworkq, ISR, the + * TCP listener), so they only post; the work happens in the event loop. */ +static void link_on_rx(void) { - struct { - uint16_t len; - uint8_t buf[MAX_FRAME_SIZE]; - } f; - - if (len == 0 || len > MAX_FRAME_SIZE) { - return; - } - - f.len = len; - memcpy(f.buf, data, len); - - if (k_msgq_put(zephcore_ble_get_recv_queue(), &f, K_NO_WAIT) != 0) { - LOG_WRN("recv queue full"); - return; - } - k_event_post(&mesh_events, MESH_EVENT_BLE_RX); } -/* BLE TX queue empty: resume the contact dump and the TX drain. */ -static void ble_on_tx_idle(void) +/* A transport's TX drained: resume the contact dump and the TX drain. */ +static void link_on_tx_idle(void) { k_event_post(&mesh_events, MESH_EVENT_CONTACT_ITER); k_event_post(&mesh_events, MESH_EVENT_TX_DRAIN); } -static void ble_on_connected(void) +/* Counted, so the loop sees every edge even when several coalesce into one + * MESH_EVENT_LINK. */ +static atomic_t link_ups; +static atomic_t link_downs; + +static void link_on_connected(void) { - ui_notify(UI_EVENT_BLE_CONNECTED); + atomic_inc(&link_ups); + k_event_post(&mesh_events, MESH_EVENT_LINK); } -/* One deferred reply is enough: RX pauses until the transport has accepted - * it. A msgq also lets the disconnect path purge it. */ -struct pending_reply_frame { - uint16_t len; - uint8_t buf[MAX_FRAME_SIZE]; +static void link_on_disconnected(void) +{ + atomic_inc(&link_downs); + k_event_post(&mesh_events, MESH_EVENT_LINK); +} + +static const struct companion_link_cbs link_cbs = { + .on_rx = link_on_rx, + .on_tx_idle = link_on_tx_idle, + .on_connected = link_on_connected, + .on_disconnected = link_on_disconnected, }; -K_MSGQ_DEFINE(pending_reply, sizeof(struct pending_reply_frame), 1, 4); - -/* Session end (BLE disconnect or USB close): drop the contact dump (a stale - * PACKET_CONTACT_END would confuse the next session), keep the un-ACKed - * message queued for re-send, and free an abandoned sign buffer. */ -static void companion_session_cleanup(void) -{ - k_msgq_purge(&pending_reply); -#ifdef ZEPHCORE_LORA - companion_mesh_ptr->cancelContactIterator(); - companion_mesh_ptr->cancelSyncPending(); - companion_mesh_ptr->cleanupSignState(); -#endif -} - -#if ZEPHCORE_USB_STACK -/* A USB session shows as "connected" too, as upstream does for serial. */ -static void usb_on_session_start(void) -{ - ui_notify(UI_EVENT_BLE_CONNECTED); -} - -static void usb_on_session_end(void) -{ - companion_session_cleanup(); - ui_notify(UI_EVENT_BLE_DISCONNECTED); -} -#endif - -static void ble_on_disconnected(void) -{ -#if ZEPHCORE_USB_STACK - zephcore_usb_companion_reset_rx(); -#endif - companion_session_cleanup(); - ui_notify(UI_EVENT_BLE_DISCONNECTED); -} +#if IS_ENABLED(CONFIG_BT) #if IS_ENABLED(CONFIG_ZEPHCORE_BLE_DFU) static void ble_on_dfu_request(void) { @@ -262,169 +225,90 @@ static void ble_on_dfu_request(void) #endif static const struct ble_callbacks ble_cbs = { - .on_rx_frame = ble_on_rx_frame, - .on_tx_idle = ble_on_tx_idle, - .on_connected = ble_on_connected, - .on_disconnected = ble_on_disconnected, + .link = link_cbs, #if IS_ENABLED(CONFIG_ZEPHCORE_BLE_DFU) .on_dfu_request = ble_on_dfu_request, #endif }; - -/* ========== Frame send/receive (mesh ↔ BLE/USB) ========== */ - -static size_t transport_write_frame(const uint8_t *src, size_t len) -{ -#if ZEPHCORE_USB_STACK - /* While USB owns the interface the BLE queue is never drained. */ - if (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) { - /* A text-CLI session gets no binary frames; report success so the - * caller does not retry one we dropped on purpose. */ - if (zephcore_usb_companion_is_text_session()) { - return len; - } - return zephcore_usb_companion_write_frame(src, len); - } #endif - return zephcore_ble_send(src, (uint16_t)len); -} -static size_t write_frame(const uint8_t *src, size_t len) +/* The last client went away: drop the contact dump (a stale + * PACKET_CONTACT_END would confuse the next session), keep the un-ACKed + * message queued for re-send, free an abandoned sign buffer, and drop a held + * reply nobody will read. Not while another transport is still connected: + * it shares the dump and the sync. */ +static void companion_session_cleanup(void) { - if (len == 0 || len > MAX_FRAME_SIZE || - k_msgq_num_used_get(&pending_reply) != 0) { - return 0; - } - if (transport_write_frame(src, len) == len) { - return len; - } - /* Only replies are retained (pushes stay lossy). Success once we hold a - * copy, so retrying callers (contact/message sync) do not send it twice. */ - if (src[0] < 0x80) { - struct pending_reply_frame f; - f.len = len; - memcpy(f.buf, src, len); - if (k_msgq_put(&pending_reply, &f, K_NO_WAIT) == 0) { - return len; - } - } - return 0; + companion_serial.drop(); +#ifdef ZEPHCORE_LORA + companion_mesh_ptr->cancelContactIterator(); + companion_mesh_ptr->cancelSyncPending(); + companion_mesh_ptr->cleanupSignState(); +#endif } -static void retry_pending_reply(void) +/* MESH_EVENT_LINK: a transport (USB and TCP sessions included) connected or + * disconnected. */ +static void process_link_change(void) { - struct pending_reply_frame f; - if (k_msgq_peek(&pending_reply, &f) == 0 && - transport_write_frame(f.buf, f.len) == f.len) { - (void)k_msgq_get(&pending_reply, &f, K_NO_WAIT); + if (atomic_set(&link_ups, 0) > 0) { + ui_notify(UI_EVENT_BLE_CONNECTED); + } + if (atomic_set(&link_downs, 0) > 0 && !companion_serial.isConnected()) { + companion_session_cleanup(); + ui_notify(UI_EVENT_BLE_DISCONNECTED); } } -/* A binary companion client is listening: BLE, or a USB session that is not - * the text CLI (zephcore_ble_is_connected() alone dropped pushes over USB). */ +/* A binary companion client is listening on some transport (a USB text-CLI + * session is not one). */ static bool companion_transport_up(void) { - bool up = zephcore_ble_is_connected(); -#if ZEPHCORE_USB_STACK - up = up || (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB && - !zephcore_usb_companion_is_text_session()); -#endif - return up; -} - -/* [push_code][data...] */ -static void push_callback(uint8_t code, const uint8_t *data, size_t len) -{ - if (!companion_transport_up()) return; - - uint8_t push_buf[1 + MAX_FRAME_SIZE - 1]; - size_t total_len = 1 + len; - - if (total_len > MAX_FRAME_SIZE) { - LOG_WRN("data too long %u", (unsigned)len); - total_len = MAX_FRAME_SIZE; - len = MAX_FRAME_SIZE - 1; - } - - push_buf[0] = code; - if (len > 0 && data != nullptr) { - memcpy(&push_buf[1], data, len); - } - - LOG_DBG("code=0x%02x len=%u (total frame)", code, (unsigned)total_len); - write_frame(push_buf, total_len); + return companion_serial.isConnected(); } /* Inbound binary frames, on MESH_EVENT_BLE_RX. */ static void process_companion_rx(void) { - struct { - uint16_t len; - uint8_t buf[MAX_FRAME_SIZE]; - } f; + uint8_t buf[MAX_FRAME_SIZE]; + size_t len; bool handled = false; - /* Process all queued frames */ - while (k_msgq_num_used_get(&pending_reply) == 0 && - k_msgq_get(zephcore_ble_get_recv_queue(), &f, K_NO_WAIT) == 0) { + /* CompanionSerial reads nothing while it holds a reply */ + while ((len = companion_serial.checkRecvFrame(buf)) > 0) { handled = true; #ifdef ZEPHCORE_LORA /* A contact dump survives commands in between (the app talks to us * mid-sync); only CMD_APP_START or a disconnect ends it. */ - if (!companion_mesh_ptr->handleCmdFrame(f.buf, f.len)) { - LOG_DBG("rx_process: unknown cmd 0x%02x len=%u", f.buf[0], (unsigned)f.len); + if (!companion_mesh_ptr->handleCmdFrame(buf, len)) { + LOG_DBG("rx_process: unknown cmd 0x%02x len=%u", buf[0], (unsigned)len); uint8_t err_rsp[] = { 0x01, 0x01 }; /* PACKET_ERROR, ERR_UNSUPPORTED */ - write_frame(err_rsp, sizeof(err_rsp)); + companion_serial.writeFrame(err_rsp, sizeof(err_rsp)); } #endif } - ARG_UNUSED(handled); -#if ZEPHCORE_USB_STACK - /* Over USB nothing else kicks the pump that paces multi-frame responses - * (the contact dump), so kick it here. Only after a handled frame: over - * USB the kick always posts MESH_EVENT_CONTACT_ITER, which calls this - * function, and an unconditional kick spun the main thread at 100% CPU - * and starved the LoRa TX-wait thread. */ - if (handled && zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) { - zephcore_ble_kick_tx(); + /* A command may have started a multi-frame response (the contact dump); + * pump it. Only after a handled frame: CONTACT_ITER also runs this + * function, and an unconditional post spun the main thread at 100% CPU. */ + if (handled) { + k_event_post(&mesh_events, MESH_EVENT_CONTACT_ITER); } -#endif } -/* The contact dump, as far as TX has room (MESH_EVENT_CONTACT_ITER). */ +/* The contact dump, as far as every connected transport has room + * (MESH_EVENT_CONTACT_ITER): BLE and TCP below their 2/3 high-water mark and + * not congested, USB with room for a whole frame. A transport's TX idle + * brings us back. */ static void run_contact_iteration(void) { #ifdef ZEPHCORE_LORA if (!companion_mesh_ptr) { return; } - -#if ZEPHCORE_USB_STACK - if (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) { - /* Fill the USB TX ring, then stop; usb_on_tx_drain() brings us back - * when it is empty. tx_has_space() guarantees the next frame fits. */ - while (zephcore_usb_companion_tx_has_space(CONTACT_FRAME_SIZE)) { - if (!companion_mesh_ptr->continueContactIteration()) { - break; /* iteration complete */ - } - } - return; - } -#endif - - /* Not while BLE TX is congested, and not above a 2/3 high-water mark. */ - if (zephcore_ble_is_congested()) { - return; - } - - uint32_t used = k_msgq_num_used_get(zephcore_ble_get_send_queue()); - uint32_t queue_size = CONFIG_ZEPHCORE_BLE_QUEUE_SIZE; - bool has_space = (used < (queue_size * 2 / 3)); - - if (has_space && companion_mesh_ptr) { - if (companion_mesh_ptr->continueContactIteration()) { - zephcore_ble_kick_tx(); /* more to send; the drain brings us back */ + while (!companion_serial.isWriteBusy()) { + if (!companion_mesh_ptr->continueContactIteration()) { + break; /* iteration complete */ } } #endif @@ -441,10 +325,15 @@ static void mesh_event_loop(void) for (;;) { uint32_t events = k_event_wait(&mesh_events, MESH_EVENT_ALL, false, K_FOREVER); k_event_clear(&mesh_events, events); + loopWakeNote(events); /* TX-idle resumes both the retained reply and commands left in RX. * Housekeeping is a fallback if a transport loses its drain kick. */ - retry_pending_reply(); + companion_serial.retry(); + + if (events & MESH_EVENT_LINK) { + process_link_change(); + } #ifdef ZEPHCORE_LORA /* UI button actions (advert, pref saves) */ @@ -475,10 +364,13 @@ static void mesh_event_loop(void) run_contact_iteration(); } - /* Packet processing only on radio/transport/TX events */ + /* Radio/transport/TX events, and housekeeping for loop()'s own + * deadlines (lazy contact/channel flush, send timeout, keep-alive), + * which would otherwise wait for unrelated traffic. */ if (companion_mesh_ptr && (events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | - MESH_EVENT_BLE_RX | MESH_EVENT_TX_DRAIN))) { + MESH_EVENT_BLE_RX | MESH_EVENT_TX_DRAIN | + MESH_EVENT_HOUSEKEEPING))) { companion_mesh_ptr->loop(); } @@ -511,12 +403,14 @@ static void mesh_event_loop(void) companion_mesh_ptr->msgWaitingWatchdog(); } +#if IS_ENABLED(CONFIG_BT) /* Advertising watchdog: a transient bt_le_adv_start failure * otherwise leaves the node undiscoverable until reboot. */ if (zephcore_ble_is_enabled() && !zephcore_ble_is_connected() && !zephcore_ble_is_advertising()) { LOG_WRN("BLE adv watchdog: not advertising, re-enabling"); zephcore_ble_set_enabled(true); } +#endif mesh_housekeeping_ui_refresh(); @@ -617,8 +511,17 @@ static mesh::ZephyrMillisecondClock ms_clock; static mesh::ZephyrRNG zephyr_rng; static SimpleMeshTables mesh_tables; static StaticPoolPacketManager packet_mgr; -static CompanionMesh companion_mesh(lora_radio, ms_clock, zephyr_rng, rtc_clock, - packet_mgr, mesh_tables, data_store); +/* A WiFi companion on a PSRAM board keeps this object (its contacts table and + * offline queue are ~110 KB) in PSRAM, so WiFi and BLE fit in internal DRAM. + * Safe: only the main thread touches it, never an ISR or a flash operation, + * and the SoC boot zeroes .ext_ram.bss before any constructor runs. */ +#if IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_WIFI) && IS_ENABLED(CONFIG_ESP_SPIRAM) +#define COMPANION_MESH_SECTION __attribute__((section(".ext_ram.bss.companion_mesh"))) +#else +#define COMPANION_MESH_SECTION +#endif +static CompanionMesh companion_mesh COMPANION_MESH_SECTION (lora_radio, ms_clock, zephyr_rng, + rtc_clock, packet_mgr, mesh_tables, data_store); static void save_prefs_to_flash(void) { @@ -632,180 +535,34 @@ static void save_prefs_to_flash(void) * the v-contact chat, and over-the-air TXT_TYPE_CLI_COMMAND. Not gated on * ZEPHCORE_USB_STACK. */ -class CompanionCLICallbacks : public CommonCLICallbacks { -public: - void savePrefs() override { - data_store.savePrefs(companion_mesh.prefs); +/* No reply held, and every transport's TX drained. */ +static bool companion_transport_tx_idle(void) +{ + if (companion_serial.holding()) { + return false; } - const char* getFirmwareVer() override { return FIRMWARE_VERSION; } - const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } - const char* getRole() override { return "companion"; } - /* CLI `erase`. Re-stamp the init marker as factoryReset() does, or the - * next boot sees no marker and formats a second time. */ - bool formatFileSystem() override { - bool ok = data_store.formatFileSystem(); - if (ok) { - data_store.writeInitMarker(); - } - return ok; +#if IS_ENABLED(CONFIG_BT) + if (!ble_interface.txIdle()) { + return false; } - - /* Same path as the app's advert command; delay_millis is unused. */ - void sendSelfAdvertisement(int delay_millis, bool flood) override { - (void)delay_millis; - companion_mesh.sendSelfAdvert(flood); - } - void updateAdvertTimer() override {} - void updateFloodAdvertTimer() override {} - - /* Reboot gate: true once the app has everything we owe it, including a - * held-back v-contact ack, so a `reboot` typed there waits for its ack. */ - bool transportTxIdle() override { - if (companion_mesh.vcontactConfirmPending() || - k_msgq_num_used_get(&pending_reply) != 0) { - return false; - } -#if ZEPHCORE_USB_STACK - if (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB) { - return zephcore_usb_companion_tx_idle(); - } #endif - return zephcore_ble_tx_idle(); +#if ZEPHCORE_USB_STACK + if (!usb_interface.txIdle()) { + return false; } +#endif +#if IS_ENABLED(CONFIG_ZEPHCORE_TRANSPORT_TCP) + if (!tcp_interface.txIdle()) { + return false; + } +#endif + return true; +} - /* No log file on a companion. */ - void setLoggingOn(bool enable) override { (void)enable; } - void eraseLogFile() override {} - void dumpLogFile() override {} - - /* No runtime TX-power API in the LoRa driver; log only, as the repeater. */ - void setTxPower(int8_t power_dbm) override { - LOG_INF("TX power %d dBm requested (reboot to apply)", power_dbm); - } - - bool setRxBoostedGain(bool enable) override { - return lora_radio.setRxBoost(enable); - } - - bool setFemRxGain(bool enable) override { - return lora_radio.setFemRxEnable(enable); - } - - bool configSideDetectors(const uint8_t* sfs, uint8_t num) override { - return lora_radio.configSideDetectors(sfs, num); - } - - /* Adaptive CAD */ - int formatFreqErrorStatus(char* buf, int cap) override { - return lora_radio.formatFreqErrorStatus(buf, cap); - } - int formatCadStatus(char* buf, int cap) override { - return lora_radio.formatCadStatus(buf, cap); - } - void applyCadPrefs() override { - lora_radio.setCadParams(companion_mesh.prefs.cad_auto != 0, - companion_mesh.prefs.cad_offset, - companion_mesh.prefs.probe_interval, - companion_mesh.prefs.cad_busycap, - companion_mesh.prefs.cad_base); - companion_mesh.prefs.cad_offset = lora_radio.getCadOffset(); - companion_mesh.prefs.cad_base = lora_radio.cadBasePeak(); - } - void resetCadStats() override { - lora_radio.resetCadStats(); - } - - mesh::LocalIdentity& getSelfId() override { return companion_mesh.self_id; } - - void saveIdentity(const mesh::LocalIdentity& new_id) override { - companion_mesh.self_id = new_id; - data_store.saveMainIdentity(new_id); - } - - void clearStats() override { - lora_radio.resetStats(); - lora_radio.resetDutyCycleTimeoutRestarts(); - companion_mesh.resetStats(); - } - - /* Duty-cycle false-preamble re-arm count. Without these the - * CommonCLICallbacks default answers 0 forever, so `get dc.restarts` - * read clean on every companion regardless of what the radio was doing - * — and the companion is the role the duty cycle actually runs in. - * Mirrors RepeaterMesh::getDutyCycleTimeoutRestarts(). */ - uint32_t getDutyCycleTimeoutRestarts() const override { - return lora_radio.getDutyCycleTimeoutRestarts(); - } - - void resetDutyCycleTimeoutRestarts() override { - lora_radio.resetDutyCycleTimeoutRestarts(); - } - - /* Temp radio params — deferred; stub for now. */ - void applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, - int timeout_mins) override { - (void)freq; (void)bw; (void)sf; (void)cr; (void)timeout_mins; - } - - MeshTimeSync* getMeshTimeSync() override { - return companion_mesh.getMeshTimeSync(); - } - - /* GPS, through the GPS manager as on the repeater. */ - bool setGpsEnabled(bool enabled) override { - if (!gps_is_available()) return false; - gps_enable(enabled); - return true; - } - bool isGpsEnabled() const override { - return gps_is_enabled(); - } - void formatGpsStatsReply(char* reply) override { - if (!gps_is_enabled()) { - strcpy(reply, "off"); - return; - } - struct gps_state_info gsi; - gps_get_state_info(&gsi); - static const char* const state_str[] = { "off", "standby", "acquiring" }; - const char* state = gsi.state < 3 ? state_str[gsi.state] : "unknown"; - struct gps_position pos; - bool has_pos = gps_get_last_known_position(&pos); - if (has_pos) { - snprintf(reply, CLI_REPLY_SIZE, - "on state=%s sats=%u fix=%us ago lat=%.6f lon=%.6f", - state, gsi.satellites, gsi.last_fix_age_s, - pos.latitude_ndeg / 1e9, pos.longitude_ndeg / 1e9); - } else if (gsi.next_search_s > 0) { - snprintf(reply, CLI_REPLY_SIZE, - "on state=%s sats=%u no fix next=%us", - state, gsi.satellites, gsi.next_search_s); - } else { - snprintf(reply, CLI_REPLY_SIZE, - "on state=%s sats=%u no fix", - state, gsi.satellites); - } - } - - /* stats-core / stats-radio / stats-packets, as the repeater prints them. */ - void formatStatsReply(char* reply) override { - StatsFormatHelper::formatCoreStats(reply, zephyr_board, ms_clock, - companion_mesh.getErrFlags(), &packet_mgr); - } - void formatRadioStatsReply(char* reply) override { - StatsFormatHelper::formatRadioStats(reply, &lora_radio, lora_radio, - companion_mesh.getTotalAirTime(), companion_mesh.getReceiveAirTime()); - } - void formatPacketStatsReply(char* reply) override { - StatsFormatHelper::formatPacketStats(reply, lora_radio, - companion_mesh.getNumSentFlood(), companion_mesh.getNumSentDirect(), - companion_mesh.getNumRecvFlood(), companion_mesh.getNumRecvDirect()); - } -}; - -static CompanionCLICallbacks companion_cli_cbs; -static ClientACL companion_acl; /* unused by CommonCLI but required by constructor */ -static CommonCLI companion_cli(zephyr_board, rtc_clock, companion_acl, +static CompanionCLICallbacks companion_cli_cbs(data_store, companion_mesh, lora_radio, zephyr_board, + ms_clock, packet_mgr, companion_transport_tx_idle); +/* No region map and no client ACL: a companion has neither (nullptr both). */ +static CommonCLI companion_cli(zephyr_board, rtc_clock, nullptr, nullptr, &companion_mesh.prefs, &companion_cli_cbs); /* CMD_SET_RADIO_PARAMS / CMD_SET_RADIO_TX_POWER (needs companion_cli, hence @@ -990,12 +747,7 @@ static bool companion_shutdown_hook(int reason) ? "Powering off: low battery" : "Powering off"; - bool connected = zephcore_ble_is_connected(); -#if ZEPHCORE_USB_STACK - connected = connected || - (zephcore_ble_get_active_iface() == ZEPHCORE_IFACE_USB && - !zephcore_usb_companion_is_text_session()); -#endif + bool connected = companion_transport_up(); if (connected && companion_mesh.isVContactEnabled()) { companion_mesh.vcontactNotify(msg); @@ -1022,6 +774,11 @@ static void companion_cli_exec(const char *line, uint32_t sender_timestamp, if (handle_autoshutdown_cli(line, reply)) { return; } +#endif +#if IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_WIFI) + if (companion_wifi_cli(line, companion_mesh.prefs, save_prefs_to_flash, reply)) { + return; + } #endif companion_cli.setReplyHeaderUsed(reply_hdr_used); companion_cli.handleCommand(sender_timestamp, line, reply); @@ -1180,14 +937,13 @@ static void bt_ready(int err) return; } + /* Advertises only if still enabled: the ble_disabled pref was applied + * before bt_enable() (disableBluetooth() is recorded until start). */ #ifdef ZEPHCORE_LORA zephcore_ble_start(companion_mesh.getDeviceName()); #else zephcore_ble_start(NULL); #endif - if (companion_mesh.prefs.ble_disabled) { - zephcore_ble_set_enabled(false); - } } #endif /* CONFIG_BT */ @@ -1238,42 +994,8 @@ int main(void) } } - /* First-boot migration: fix NVS (BLE bonds) before bt_enable() runs. - * - * nRF52 stores BLE bonds in storage_partition (NVS) at 0xD0000. UF2 - * flashing only writes pages covered by the binary, leaving whatever was - * there before. Old firmware (Arduino MeshCore, ZephCore ≤1.16.1) used - * that region as app code; if those bytes accidentally pass Zephyr NVS - * sector validation, settings_load() hangs and BLE never advertises. - * - * A marker file /lfs/_zc_init is written after the first clean boot. - * If absent, we are on the first run of this ZephCore build: - * - * • No prefs, or Arduino prefs (layout-incompatible): full format. - * Covers fresh installs and Arduino MeshCore migrations. - * - * • Valid ZephCore prefs + /lfs/settings present: NVS erase only. - * The old file-based bonds backend (ZephCore ≤1.16.1) left this file; - * 0xD0000 is old app code → must erase. Identity/prefs/contacts - * are preserved; re-pair required (bonds were in /lfs/settings which - * the NVS backend ignores anyway). - * - * • Valid ZephCore prefs + no /lfs/settings: NVS was already initialized - * by ZephCore ≥1.16.2 — skip format entirely, bonds survive. */ - if (!data_store.hasInitMarker()) { - if (!data_store.hasPrefs() || data_store.prefsLookLikeArduino()) { - LOG_WRN("First ZephCore boot (%s) — formatting LFS + NVS", - data_store.hasPrefs() ? "Arduino prefs" : "no prefs"); - data_store.formatFileSystem(); - data_store.begin(); - } else if (data_store.hasOldSettingsFile()) { - LOG_WRN("Pre-NVS ZephCore upgrade (found /lfs/settings) — erasing NVS"); - data_store.formatNVSOnly(); - } else { - LOG_INF("ZephCore upgrade with valid NVS — skipping format, bonds preserved"); - } - data_store.writeInitMarker(); - } + /* Before bt_enable(): a first boot may have to erase the BLE bond store. */ + data_store.adoptVolume(); sensor_manager_init(); @@ -1316,10 +1038,12 @@ int main(void) data_store.loadPrefs(companion_mesh.prefs); /* Saved BLE PIN (0 = Kconfig default) */ +#if IS_ENABLED(CONFIG_BT) if (companion_mesh.prefs.ble_pin >= 100000 && companion_mesh.prefs.ble_pin <= 999999) { zephcore_ble_set_passkey(companion_mesh.prefs.ble_pin); LOG_INF("BLE passkey loaded from prefs: %06u", companion_mesh.prefs.ble_pin); } +#endif /* The radio reads its preset through this from Radio::begin() on. */ lora_radio.setPrefs(&companion_mesh.prefs); @@ -1355,8 +1079,6 @@ int main(void) self_identity.pub_key[2], self_identity.pub_key[3]); } - companion_mesh.setWriteFrameCallback(write_frame); - companion_mesh.setPushCallback(push_callback); companion_mesh.setBatteryCallback(get_battery_mv); ui_set_battery_provider(get_battery_mv); ui_set_power_source_provider([]() { return zephyr_board.isExternalPowered(); }); @@ -1364,7 +1086,11 @@ int main(void) ui_set_shutdown_hook(companion_shutdown_hook); companion_mesh.setRadioReconfigureCallback(radio_reconfigure); companion_mesh.setPinChangeCallback([](uint32_t new_pin) { +#if IS_ENABLED(CONFIG_BT) zephcore_ble_set_passkey(new_pin); +#else + ARG_UNUSED(new_pin); +#endif }); companion_mesh.setCLICallback(companion_cli_exec); companion_mesh_ptr = &companion_mesh; @@ -1497,25 +1223,43 @@ int main(void) #endif #endif + /* Register every transport this build has, as upstream's main.cpp does. */ +#if IS_ENABLED(CONFIG_BT) zephcore_ble_init(&ble_cbs); - + interface_manager.addInterface(InterfaceType::Bluetooth, &ble_interface); +#endif #if ZEPHCORE_USB_STACK - zephcore_usb_companion_init(&mesh_events, MESH_EVENT_BLE_RX, - &zephyr_board); - zephcore_usb_companion_set_session_start_cb(usb_on_session_start); - zephcore_usb_companion_set_session_end_cb(usb_on_session_end); - zephcore_usb_companion_set_tx_drain_cb(usb_on_tx_drain); + zephcore_usb_companion_init(&link_cbs); /* The text CLI starts when the first byte is not '<'. */ zephcore_usb_companion_set_cli_line_cb(companion_cli_dispatch); + interface_manager.addInterface(InterfaceType::USB, &usb_interface); +#endif +#if IS_ENABLED(CONFIG_ZEPHCORE_TRANSPORT_TCP) + tcp_companion_init(&link_cbs); + interface_manager.addInterface(InterfaceType::WiFi, &tcp_interface); #endif + /* Enables every interface; the ble_disabled pref then turns BLE back off + * before the stack starts advertising. */ +#ifdef ZEPHCORE_LORA + companion_mesh.startInterface(companion_serial); + if (companion_mesh.prefs.ble_disabled) { + interface_manager.disableBluetooth(); + } +#else + companion_serial.enable(); +#endif + +#if IS_ENABLED(CONFIG_ZEPHCORE_TRANSPORT_TCP) + tcp_companion_start(CONFIG_ZEPHCORE_TCP_PORT); +#endif +#if IS_ENABLED(CONFIG_ZEPHCORE_COMPANION_WIFI) && defined(ZEPHCORE_LORA) + companion_wifi_start(companion_mesh.prefs); +#endif #if IS_ENABLED(CONFIG_BT) if (bt_enable(bt_ready) != 0) { LOG_ERR("bt_enable failed"); } -#else - /* No BLE controller: LinuxTCPTransport.c provides zephcore_ble_start(). */ - zephcore_ble_start(companion_mesh.getDeviceName()); #endif /* The main thread becomes the mesh event loop (see MESH_EVENT_*). */ diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index 1f4ae3c..381e81c 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -1,856 +1,34 @@ /* * SPDX-License-Identifier: MIT - * ZephCore - Repeater (USB CLI, Event-Driven) + * ZephCore - Repeater * - * This is the main entry point for the repeater role. - * Repeaters use USB serial CLI for configuration (no BLE). + * The repeater role's entry point: its mesh object, and the ServerRole that + * hands it to the shared server code (server_main_common.cpp). */ -#include -#include -#include -#include - -#include -LOG_MODULE_REGISTER(zephcore_repeater_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); - -#include -#include -#include -#include -#include -#include -#include -#include "oled_power.h" -#include "boot_prefs.h" - -/* BLE controller assert handler — BT is compiled even for repeater (via zephcore_common.conf) */ -#if IS_ENABLED(CONFIG_BT_CTLR_ASSERT_HANDLER) -extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) -{ - LOG_ERR("!!! BLE CONTROLLER ASSERT: %s:%u !!!", file ? file : "?", line); - k_sleep(K_MSEC(100)); - sys_reboot(SYS_REBOOT_COLD); -} -#endif - -/* USB CDC ACM init + 1200-baud DFU + DTR callbacks (shared with companion). - * - * Gate on the CDC-ACM class driver, NOT on DT_HAS_COMPAT_STATUS_OKAY alone: a - * board overlay may expose a cdc_acm_uart DT node unconditionally (the shared - * esp32s3_usb_otg.dtsi does), so the node can be present while the class driver - * — and therefore zephcore_usbd_* and the device object — is not compiled - * (e.g. an ESP32-S3 repeater built without esp32s3_usb.conf). This mirrors the - * repeater CMake condition that compiles ZephyrUSBCDC.cpp. */ -#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 "../adapters/datastore/ZephyrFsFormat.h" +#include "server_main_common.h" #include -#include -#include -#include - -/* UI subsystem (display, buttons, buzzer) */ -#include "ui_task.h" -#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) -#include "display.h" -#endif - -/* Headless repeaters link the weak no-op ui_* stubs (ui_headless_stubs.c), so - * the periodic UI refresh in the maintenance pass is pure work for nothing on - * them. Guard the hot path on a real ui_* implementation being linked; the - * one-shot calls at init and in the CLI reply path stay unguarded, matching - * the rest of the file. - * - * This MUST mirror the CMake condition that selects the stubs, not the display - * devicetree node: ZEPHCORE_UI_DESIGN_BUTTON is enabled by BUTTONS *or* - * DISPLAY *or* BUZZER, so a board with buttons/buzzer and no panel still links - * the real UI and still needs these updates. */ -#define ZEPHCORE_HAS_UI (IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_BUTTON) || \ - IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK)) - -/* Radio + mesh includes (shared header selects LR1110 or SX126x) */ -#include - -#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA) -#include "wifi_ota.h" -#endif - -/* LED configuration */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) -#define LED0_NODE DT_ALIAS(led0) -static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios); -#endif -#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) -#define LED1_NODE DT_ALIAS(led1) -static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(LED1_NODE, gpios); -#endif - -/* USB CLI configuration */ -#define USB_RING_BUF_SIZE 512 -#define CLI_LINE_BUF_SIZE 256 - -/* - * Event-driven mesh loop - replaces 50ms polling with true event signaling. - * Events are signaled from ISR/callbacks, mesh loop wakes immediately. - */ -#define MESH_EVENT_LORA_RX BIT(0) /* LoRa packet received */ -#define MESH_EVENT_LORA_TX_DONE BIT(1) /* LoRa TX complete */ -#define MESH_EVENT_CLI_RX BIT(2) /* CLI command received */ -#define MESH_EVENT_MAINTENANCE BIT(3) /* A maintenance deadline came due */ -#define MESH_EVENT_GPS_ACTION BIT(4) /* GPS state change (must run on main thread!) */ -#define MESH_EVENT_TX_DRAIN BIT(5) /* Outbound packet delay expired, run checkSend */ -#define MESH_EVENT_RTC_SAVE BIT(6) /* Hardware-RTC write requested off-main */ -#define MESH_EVENT_INIT_ADVERT BIT(7) /* Deferred boot advert — send on main thread */ -#define MESH_EVENT_WAKE BIT(8) /* Off-main state set; run loop() promptly */ -#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | MESH_EVENT_MAINTENANCE | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_RTC_SAVE | MESH_EVENT_INIT_ADVERT | MESH_EVENT_WAKE) - -/* Maintenance is deadline-driven, not periodic: after every pass the loop asks - * the mesh when its soonest pending deadline is (msUntilNextMaintenance) and - * arms a single one-shot wake for exactly that moment. An idle repeater with - * nothing scheduled therefore sleeps until its next real deadline instead of - * waking on a fixed cadence. - * - * MAINTENANCE_BACKSTOP_MS bounds that: it caps how long we will go without a - * pass even when everything reports idle, so a deadline that is missed or - * mis-reported degrades to the old behaviour instead of wedging. - * MAINTENANCE_MIN_MS floors it, so an item that is due-but-blocked (radio - * mid-packet, duty-cycle sleep window) re-arms shortly rather than spinning. */ -#define MAINTENANCE_BACKSTOP_MS CONFIG_ZEPHCORE_MAINTENANCE_BACKSTOP_MS -#define MAINTENANCE_MIN_MS 50 - -/* Event object for mesh loop */ -static struct k_event mesh_events; - -/* Pending epoch for a deferred zephcore_rtc_save() — gps_fix_callback runs on - * the GNSS modem_chat worker thread, where the RTC's blocking I2C transactions - * would stall NMEA ingest. Stash the latest epoch and let the main thread - * perform the write; concurrent posts coalesce into one save. */ -static atomic_t pending_rtc_epoch = ATOMIC_INIT(0); - -static void request_rtc_save(uint32_t epoch) -{ - atomic_set(&pending_rtc_epoch, (atomic_val_t)epoch); - k_event_post(&mesh_events, MESH_EVENT_RTC_SAVE); -} - -/* USB CDC state */ -static const struct device *usb_dev; -static uint8_t usb_ring_buf_data[USB_RING_BUF_SIZE]; -static struct ring_buf usb_ring_buf; -static char cli_line_buf[CLI_LINE_BUF_SIZE]; -static char cli_reply_buf[256]; -static uint16_t cli_line_idx; -/* Last byte seen by cli_rx_work_fn, for collapsing CRLF/LFCR pairs. Persists - * across work-item invocations, so a pair split across two USB packets still - * compares correctly. */ -static uint8_t cli_prev_byte; - -/* Completed CLI lines are handed to the MAIN thread for execution. Byte - * assembly + echo (cli_rx_work_fn) runs on sysworkq and touches no mesh - * state, but handleCommand() mutates the lock-free packet pool / dispatcher - * that loop() also touches — running it on sysworkq races the main loop. - * So we queue the finished line and let the event loop run handleCommand(). */ -struct cli_cmd_line { char buf[CLI_LINE_BUF_SIZE]; }; -K_MSGQ_DEFINE(cli_cmd_queue, sizeof(struct cli_cmd_line), 4, 4); - -/* Work items for event-driven processing */ -static void cli_rx_work_fn(struct k_work *work); -static void maintenance_timer_fn(struct k_timer *timer); -static void tx_drain_work_fn(struct k_work *work); -static void initial_advert_work_fn(struct k_work *work); -K_WORK_DEFINE(cli_rx_work, cli_rx_work_fn); -K_WORK_DELAYABLE_DEFINE(tx_drain_work, tx_drain_work_fn); -K_WORK_DELAYABLE_DEFINE(initial_advert_work, initial_advert_work_fn); - -/* One-shot maintenance wake, re-armed after every loop pass (see - * arm_maintenance_wake). Not periodic — the period is the deadline. */ -K_TIMER_DEFINE(maintenance_timer, maintenance_timer_fn, NULL); - -/* Forward declarations */ -#ifdef ZEPHCORE_LORA -static RepeaterMesh *repeater_mesh_ptr; -static void refresh_repeater_ui_radio_state(void); -#endif - -/* Print string to USB serial */ -static void cli_print(const char *str) -{ - if (!usb_dev) return; - while (*str) { - uart_poll_out(usb_dev, *str++); - } -} - -/* USB CDC UART interrupt callback */ -static void cli_uart_isr(const struct device *dev, void *user_data) -{ - ARG_UNUSED(user_data); - - for (;;) { - uart_irq_update(dev); - if (uart_irq_is_pending(dev) <= 0) { - break; - } - - if (uart_irq_rx_ready(dev)) { - uint8_t buf[64]; - int recv_len = uart_fifo_read(dev, buf, sizeof(buf)); - if (recv_len > 0) { - ring_buf_put(&usb_ring_buf, buf, recv_len); - k_work_submit(&cli_rx_work); - } - } - } -} - -/* CLI RX work - processes line-based CLI commands - * Matches Arduino behavior: echo each char, then " -> reply" on enter - */ -static void cli_rx_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - uint8_t byte; - - while (ring_buf_get(&usb_ring_buf, &byte, 1) == 1) { - const uint8_t prev = cli_prev_byte; - cli_prev_byte = byte; - - /* Process command on \r OR \n. Accepting both is what makes a piped - * `echo "cmd" > /dev/ttyACM0` (LF only) work. The cost is that a CRLF - * terminal sends two terminator bytes per Enter, so collapse the pair - * here: skip a terminator that directly follows a *different* one. - * "\r\r" and "\n\n" (two genuine blank lines) still dispatch twice. - * Blank lines ARE dispatched -- `region load` commits on one, and - * handleCommand() no-ops a blank line otherwise. The previous - * de-duplicator keyed on an empty line buffer instead: it ate every - * blank line (stranding `region load` until a reboot) and still let - * the CRLF tail fall through and emit a stray newline of its own. */ - if (byte == '\r' || byte == '\n') { - if ((prev == '\r' || prev == '\n') && byte != prev) { - continue; /* tail of a CRLF/LFCR pair */ - } - cli_line_buf[cli_line_idx] = '\0'; - - /* Debug: log received command */ - if (cli_line_idx > 0) { - LOG_INF("CLI cmd len=%d: %.40s%s", cli_line_idx, - cli_line_buf, cli_line_idx > 40 ? "..." : ""); - } - - /* Hand the command to the main thread (see cli_cmd_queue). - * The reply + trailing newline are emitted there, exactly - * matching the previous inline output order. */ - struct cli_cmd_line c; - strncpy(c.buf, cli_line_buf, sizeof(c.buf) - 1); - c.buf[sizeof(c.buf) - 1] = '\0'; - if (k_msgq_put(&cli_cmd_queue, &c, K_NO_WAIT) == 0) { - k_event_post(&mesh_events, MESH_EVENT_CLI_RX); - } else { - cli_print("\r\n -> busy\r\n"); - } - cli_line_idx = 0; - } else if (byte == 0x7F || byte == 0x08) { - /* Backspace - echo backspace sequence */ - if (cli_line_idx > 0) { - cli_line_idx--; - if (usb_dev) { - uart_poll_out(usb_dev, '\b'); - uart_poll_out(usb_dev, ' '); - uart_poll_out(usb_dev, '\b'); - } - } - } else if (cli_line_idx < sizeof(cli_line_buf) - 1) { - /* Echo character back (like Arduino) */ - if (usb_dev) { - uart_poll_out(usb_dev, byte); - } - cli_line_buf[cli_line_idx++] = (char)byte; - } - } -} #ifdef ZEPHCORE_LORA -/* Run queued CLI commands on the MAIN thread (see cli_cmd_queue). Output - * order matches the old inline path exactly: "\r\n -> reply" when there is - * a reply, then a trailing "\r\n". */ -static void process_cli_commands(void) -{ - struct cli_cmd_line c; - while (repeater_mesh_ptr && k_msgq_get(&cli_cmd_queue, &c, K_NO_WAIT) == 0) { - cli_reply_buf[0] = '\0'; - repeater_mesh_ptr->handleCommand(0, c.buf, cli_reply_buf); - refresh_repeater_ui_radio_state(); - if (cli_reply_buf[0] != '\0') { - cli_print("\r\n -> "); - cli_print(cli_reply_buf); - } - cli_print("\r\n"); - } -} -#endif - -/* Maintenance deadline expired — wake the mesh loop to run the pass. */ -static void maintenance_timer_fn(struct k_timer *timer) -{ - ARG_UNUSED(timer); - k_event_post(&mesh_events, MESH_EVENT_MAINTENANCE); -} - -/* Ask the mesh for its soonest pending deadline and arm the one-shot for it. - * Called after every loop pass, whatever woke us: any event may have created - * or cleared a deadline (a queued advert, a CLI tempradio command, a CAD probe - * that just ran), so the schedule is recomputed from scratch each time rather - * than tracked incrementally. - * - * The backstop below is an unconditional CEILING on the wait, not a fallback - * used only when everything reports idle. That means it must stay well above - * the shortest legitimate recurring deadline (the noise-floor sampler, and the - * CAD probe) or it becomes the effective period and the deadline scheduling - * buys nothing — see ZEPHCORE_MAINTENANCE_BACKSTOP_MS. */ -static void arm_maintenance_wake(void) -{ - uint32_t delay = MAINTENANCE_BACKSTOP_MS; - uint32_t next = MAINTENANCE_BACKSTOP_MS; - -#ifdef ZEPHCORE_LORA - if (repeater_mesh_ptr) { - next = repeater_mesh_ptr->msUntilNextMaintenance(); - - if (next < delay) { - delay = next; - } - } -#endif - - if (delay < MAINTENANCE_MIN_MS) { - delay = MAINTENANCE_MIN_MS; - } - - /* Both figures, because which one is binding is the whole diagnosis: - * next=15000 armed=15000 — a real deadline won; working as intended - * next=15000 armed=5000 — the backstop is clamping (it must stay - * above the noise-floor/CAD intervals) - * next=0 armed=50 — a deadline reports due but its state is - * not advancing; repeated = spin - * mesh::MAINTENANCE_IDLE (0x7FFFFFFF) as `next` means nothing pending. */ - LOG_DBG("maint: next=%u armed=%u", (unsigned)next, (unsigned)delay); - - k_timer_start(&maintenance_timer, K_MSEC(delay), K_NO_WAIT); -} - -#ifdef ZEPHCORE_LORA -/* LoRa RX callback - called from ISR context when packet received */ -static void lora_rx_callback(void *user_data) -{ - ARG_UNUSED(user_data); - k_event_post(&mesh_events, MESH_EVENT_LORA_RX); -} - -/* LoRa TX complete callback */ -static void lora_tx_done_callback(void *user_data) -{ - ARG_UNUSED(user_data); - k_event_post(&mesh_events, MESH_EVENT_LORA_TX_DONE); -} - -/* TX drain — Dispatcher queued a packet with a delay. - * Schedule a precise wake so checkSend runs when the delay expires. */ -static void tx_drain_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - k_event_post(&mesh_events, MESH_EVENT_TX_DRAIN); -} - -/* Deferred initial advertisement — gives GPS time to get a fix at boot. - * Runs on sysworkq (the 10 s delay timer), so it must NOT send the advert - * here: sendSelfAdvertisement() mutates the packet pool / dispatcher shared - * with loop(). Post an event and let the main thread send it. */ -static void initial_advert_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - k_event_post(&mesh_events, MESH_EVENT_INIT_ADVERT); -} - -static void tx_queued_callback(uint32_t delay_ms, void *user_data) -{ - ARG_UNUSED(user_data); - k_work_reschedule(&tx_drain_work, K_MSEC(delay_ms)); -} - -/* Off-main code (MQTT CONNACK, SNTP) set state that loop() must drain. Post - * only — this runs on the MQTT publisher / WiFi thread, and k_event_post is - * the sole thing safe to do from there. */ -static void wake_callback(void *user_data) -{ - ARG_UNUSED(user_data); - k_event_post(&mesh_events, MESH_EVENT_WAKE); -} -#endif - -/* Global instances */ -static mesh::ZephyrRTCClock rtc_clock; - -/* GPS event callback - called when GPS work handlers need the main thread - * to process a state transition (wake from standby, fix done, timeout). - * Runs from system work queue context — just posts an event, no blocking. */ -static void gps_event_callback(void) -{ - k_event_post(&mesh_events, MESH_EVENT_GPS_ACTION); -} - -static RepeaterDataStore data_store; - -/* GPS fix callback - syncs RTC from GPS time. - * Repeaters do NOT update prefs lat/lon from GPS — prefs coordinates are the - * user's manually-set position used for adverts. Precise GPS position is - * served only via telemetry requests (gps_get_last_known_position). */ -static void gps_fix_callback(double lat, double lon, int64_t utc_time) -{ - if (utc_time > 0) { - LOG_INF("GPS fix: RTC sync time=%lld", utc_time); - rtc_clock.setCurrentTime((uint32_t)utc_time); - /* Defer the hardware-RTC write to the main thread — blocking I2C - * here would stall NMEA ingest (gps_fix_callback runs in the - * GNSS modem_chat worker context). */ - request_rtc_save((uint32_t)utc_time); - } - - int lat_deg = (int)lat; - int lon_deg = (int)lon; - int lat_frac = (int)((lat - lat_deg) * 1000000); - int lon_frac = (int)((lon - lon_deg) * 1000000); - if (lat_frac < 0) lat_frac = -lat_frac; - if (lon_frac < 0) lon_frac = -lon_frac; - LOG_INF("GPS fix: lat=%d.%06d lon=%d.%06d (telemetry only)", - lat_deg, lat_frac, lon_deg, lon_frac); -} - -#ifdef ZEPHCORE_LORA -static mesh::ZephyrBoard zephyr_board; - -static uint16_t get_battery_mv(void) -{ - return zephyr_board.getBattMilliVolts(); -} - -/* Radio is constructed with no prefs pointer; main() binds it to - * repeater_mesh._prefs via setPrefs() before repeater_mesh.begin(). */ - -static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); -static mesh::LoRaRadio lora_radio(lora_dev, zephyr_board); - -static mesh::ZephyrMillisecondClock ms_clock; -static mesh::ZephyrRNG zephyr_rng; -static SimpleMeshTables mesh_tables; - /* RepeaterMesh requires: board, radio, ms_clock, rng, rtc, tables */ static RepeaterMesh repeater_mesh(zephyr_board, lora_radio, ms_clock, zephyr_rng, rtc_clock, mesh_tables); - -/* Strong override of the weak stub in ui_mesh_actions_stubs.c: keep the prefs - * copy in step when the LEDs page toggles, so "get leds" reports what the node - * is actually doing. RAM only — a repeater has no deferred-save path off the UI - * thread, so a UI toggle lasts until reboot; "set leds" is what persists. */ -extern "C" void mesh_set_leds_disabled(bool disabled) -{ - if (repeater_mesh_ptr) { - repeater_mesh_ptr->getNodePrefs()->leds_disabled = disabled ? 1 : 0; - } -} - -static void refresh_repeater_ui_radio_state(void) -{ - if (!repeater_mesh_ptr) { - return; - } - - ui_set_radio_params( - lora_radio.getActiveFrequencyHz(), - lora_radio.getActiveSpreadingFactor(), - lora_radio.getActiveBandwidthKHzX10(), - lora_radio.getActiveCodingRate(), - lora_radio.getConfiguredTxPower(), - lora_radio.getNoiseFloor()); - - ui_set_radio_runtime( - lora_radio.getActiveSyncWord(), - lora_radio.getActivePreambleLength(), - lora_radio.isRxDutyCycleEnabled(), - lora_radio.isRadioReady(), - lora_radio.isInRecvMode(), - lora_radio.isTxActive()); - - ui_set_radio_stats( - lora_radio.getPacketsRecv(), - lora_radio.getPacketsSent(), - lora_radio.getPacketsRecvErrors()); -} #endif -/* Repeater event loop */ -static void repeater_event_loop(void) -{ - LOG_INF("starting event-driven loop"); - - /* Print startup banner (no prompt - Arduino style) */ - cli_print("\r\n=== ZephCore Repeater ===\r\n"); - - /* Arm the first maintenance wake; every pass below re-arms it. */ - arm_maintenance_wake(); - - for (;;) { - /* Wait for any mesh event - blocks until signaled */ - uint32_t events = k_event_wait(&mesh_events, MESH_EVENT_ALL, false, K_FOREVER); - k_event_clear(&mesh_events, events); - - /* GPS state transitions must run on main thread (GNSS driver - * modem_chat blocks on system work queue semaphore). */ - if (events & MESH_EVENT_GPS_ACTION) { - gps_process_event(); - } - +static const ServerRole repeater_role = { + .name = "Repeater", + .name_prefix = "Repeater", #ifdef ZEPHCORE_LORA - /* Run queued CLI commands here (main thread) BEFORE loop() drains - * any outbound packets they enqueued — keeps all mesh-state - * mutation on the main thread (see cli_cmd_queue). */ - if (events & MESH_EVENT_CLI_RX) { - process_cli_commands(); - } - - /* Deferred boot advert — sent on the main thread (see - * initial_advert_work_fn); loop() below drains the queued packet. */ - if (repeater_mesh_ptr && (events & MESH_EVENT_INIT_ADVERT)) { - LOG_INF("Sending deferred initial advertisement"); - repeater_mesh_ptr->sendSelfAdvertisement(500, false); - } - - /* Packet processing — only on radio/CLI/TX events */ - if (repeater_mesh_ptr && - (events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | - MESH_EVENT_CLI_RX | MESH_EVENT_TX_DRAIN | - MESH_EVENT_WAKE))) { - repeater_mesh_ptr->loop(); - } + .mesh = &repeater_mesh, + .callbacks = &repeater_mesh, + .prefs = repeater_mesh.getNodePrefs(), + .begin = [](RepeaterDataStore *store) { repeater_mesh.begin(store); }, + .loop = [] { repeater_mesh.loop(); }, + .handle_command = [](char *command, char *reply) { repeater_mesh.handleCommand(0, command, reply); }, + .note_gps_time_sync = [] { repeater_mesh.noteGPSTimeSync(); }, #endif - -#ifdef ZEPHCORE_LORA - /* Radio maintenance runs OPPORTUNISTICALLY, on every pass, whatever - * woke us — not only when its own timer fired. - * - * Every item inside is deadline-gated internally, so this is nearly - * free when nothing is due. The point is what it does to a busy - * node: a hilltop repeater is already waking constantly for packets, - * so maintenance rides along on those wakes, its deadlines advance, - * and arm_maintenance_wake() below keeps pushing the timer out — the - * maintenance timer then almost never fires and costs no wakes at - * all. Running it only on its own event (as this did originally) - * inverted that: the busiest nodes, which can least afford it, paid - * the full timer cadence on top of their packet wakes, and every - * blocked sample burned a retry against a channel that is busy for - * sustained reasons (real traffic in isReceiving(), the RSSI - * prefilter) rather than the transient duty-cycle sleep window the - * retries were sized for. - * - * Ordering matters: this sits AFTER packet processing so an inbound - * frame is handled before we spend SPI time on an RSSI sweep. */ - if (repeater_mesh_ptr) { - repeater_mesh_ptr->maintenanceLoop(); - } -#endif - - /* A maintenance deadline came due — run the time-based work too. */ - if (events & MESH_EVENT_MAINTENANCE) { -#ifdef ZEPHCORE_LORA - /* Drive loop() so time-based actions (advert timers, - * tempradio set/revert, contacts flush, uplink status) - * still fire when no LoRa/CLI traffic wakes the loop. */ - if (repeater_mesh_ptr) { - repeater_mesh_ptr->loop(); - } -#endif - -#if ZEPHCORE_HAS_UI - ui_set_clock(rtc_clock.getCurrentTime()); - -#ifdef ZEPHCORE_LORA - /* Refresh live radio state (noise floor, TX power - * reduction, RX/TX mode, packet counters). Headless - * repeaters compile this out entirely — every ui_set_* - * below it is a weak no-op there (ui_headless_stubs.c), - * so the whole block was pure work for nothing. */ - refresh_repeater_ui_radio_state(); - - /* Battery is now refreshed lazily from ui_pages_render() with - * a 30 s freshness guard — no periodic ADC fire here. */ -#endif -#endif /* ZEPHCORE_HAS_UI */ - } - - /* Off-main RTC write request (gps_fix_callback runs in modem_chat - * context — see request_rtc_save()). Perform the blocking I2C - * write here on the main thread instead. */ - if (events & MESH_EVENT_RTC_SAVE) { - zephcore_rtc_save((uint32_t)atomic_get(&pending_rtc_epoch)); -#ifdef ZEPHCORE_LORA - /* GPS just set the clock — arm the mesh time-sync drift envelope. */ - if (repeater_mesh_ptr) { - repeater_mesh_ptr->noteGPSTimeSync(); - } -#endif - } - - /* Re-arm for the soonest deadline this pass left behind. Done for - * every wake, not just maintenance ones: a CLI command, an inbound - * packet or a GPS fix can all create or clear a deadline. */ - arm_maintenance_wake(); - } -} +}; int main(void) { -#ifdef ZEPHCORE_LORA - /* Clear any stale bootloader magic from previous sessions. - * Prevents nRF52 boards from re-entering bootloader after reboot. */ - zephyr_board.clearBootloaderMagic(); -#endif - - /* USB CDC init up front so the host can enumerate, then wait for the - * host to open the port (DTR asserted) — event-driven via the usbd - * message callback. Unplugged → 2 s timeout, no banner; attached → - * banner reaches the user the moment the port opens. */ -#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) && \ - !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) && \ - (IS_ENABLED(CONFIG_USB_CDC_ACM) || IS_ENABLED(CONFIG_USBD_CDC_ACM_CLASS)) - zephcore_usbd_init(); - zephcore_usbd_wait_dtr(2000); -#endif - LOG_INF("=== ZephCore Repeater starting ==="); - -#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA) - /* Confirm MCUboot image early — if we just booted after OTA, - * this marks the image as good so MCUboot keeps it. */ - wifi_ota_confirm_image(); -#endif - - /* Configure LEDs */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) - if (gpio_is_ready_dt(&led0)) { - gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE); - } -#endif -#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) - if (gpio_is_ready_dt(&led1)) { - gpio_pin_configure_dt(&led1, GPIO_OUTPUT_INACTIVE); - } -#endif - - /* First boot on a volume that is not this role's - a fresh chip, a - * companion, or a node that was running Arduino MeshCore, whose nRF52 - * filesystems overlap our lfs_partition - * (devdocs/HANDOVER_lfs_arduino_overlap.md). Erase everything so we - * start from a known state: Zephyr's automount only - * auto-formats the LittleFS volume when it fails to mount, and never - * touches storage_partition (BLE bonds NVS) or QSPI. - * - * Self-limiting, so it needs no "done" marker: the identity is generated - * and saved a few lines below, and loadPrefs() persists defaults on the - * same boot, so the next boot sees this role's data and skips this. */ - 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"); - } - } - - /* Initialize repeater data store */ - if (!data_store.begin()) { - LOG_ERR("RepeaterDataStore init failed"); - } - - /* Initialize sensor manager */ - sensor_manager_init(); - - /* Restore wall-clock time from a battery-backed hardware RTC if present - * (shown tagged "L" until the next GPS/CLI sync; no-op if no RTC). */ - { - uint32_t rtc_epoch; - if (zephcore_rtc_restore(&rtc_epoch)) { - rtc_clock.setCurrentTime(rtc_epoch); - } - } - - /* GPS callbacks only. Duty interval and repeater mode are applied by - * apply_boot_prefs() after loadPrefs() below. */ - if (gps_is_available()) { - gps_set_fix_callback(gps_fix_callback); - gps_set_event_callback(gps_event_callback); - } - - /* Initialize UI (display + buttons). Shows splash screen, then auto- - * transitions to STATUS page. Display sleeps after auto-off timeout; - * user button is the only wake source for repeater. */ - ui_init(); -#if !IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) - oled_sleep(); -#endif - - /* Log environment sensor availability */ - if (env_sensors_available()) { - LOG_INF("Environment sensors available"); - } - -#ifdef ZEPHCORE_LORA - repeater_mesh_ptr = &repeater_mesh; - - /* Initialize mesh event object BEFORE begin() — radio callbacks post events */ - k_event_init(&mesh_events); - - /* Set LoRa callbacks for event-driven packet processing */ - lora_radio.setRxCallback(lora_rx_callback, nullptr); - lora_radio.setTxDoneCallback(lora_tx_done_callback, nullptr); - repeater_mesh.setTxQueuedCallback(tx_queued_callback, nullptr); - repeater_mesh.setWakeCallback(wake_callback, nullptr); - - /* Load or generate identity BEFORE begin(). First-boot keygen runs - * the layered entropy mixer + Ed25519 derive + reserved-prefix - * guard inside ZephyrRNG::generateFirstBootIdentity. */ - mesh::LocalIdentity self_identity; - if (!data_store.loadIdentity(self_identity)) { - LOG_INF("No identity found, generating new keypair..."); - mesh::ZephyrRNG::generateFirstBootIdentity(self_identity); - data_store.saveIdentity(self_identity); - LOG_INF("New identity saved"); - } - repeater_mesh.self_id = self_identity; - - /* Log repeater ID (first 8 bytes of public key) */ - LOG_INF("Repeater ID: %02x%02x%02x%02x%02x%02x%02x%02x...", - self_identity.pub_key[0], self_identity.pub_key[1], - self_identity.pub_key[2], self_identity.pub_key[3], - self_identity.pub_key[4], self_identity.pub_key[5], - self_identity.pub_key[6], self_identity.pub_key[7]); - - /* Load persisted prefs and bind the radio to _prefs BEFORE begin() — the - * radio reads freq/bw/sf/cr through this pointer during Mesh::begin() → - * Dispatcher::begin() → Radio::begin(). Without this, the radio would - * configure on NodePrefs defaults (869.618 MHz) regardless of saved - * settings: CLI readback looked correct but the hardware stayed on EU. - * Every role binds this way; begin() refuses to start without it. */ - data_store.loadPrefs(*repeater_mesh.getNodePrefs()); - lora_radio.setPrefs(repeater_mesh.getNodePrefs()); - - /* LED gate and GPS duty from prefs. After loadPrefs() and ui_init(), - * before begin() -- see boot_prefs.h. */ - apply_boot_prefs(repeater_mesh.getNodePrefs(), true); - - /* Start mesh with data store - loads ACL, regions */ - repeater_mesh.begin(&data_store); - - /* Generate default node name from hardware device ID if not set */ - NodePrefs* prefs = repeater_mesh.getNodePrefs(); - if (strlen(prefs->node_name) == 0 || strcmp(prefs->node_name, "Repeater") == 0) { - uint8_t dev_id[8]; - ssize_t id_len = hwinfo_get_device_id(dev_id, sizeof(dev_id)); - if (id_len >= 4) { - snprintf(prefs->node_name, sizeof(prefs->node_name), - "Repeater-%02X%02X%02X%02X", dev_id[0], dev_id[1], dev_id[2], dev_id[3]); - } - } - - /* Apply RX boost and duty cycle from prefs */ - lora_radio.setRxBoost(prefs->rx_boost != 0); - lora_radio.setFemRxEnable(prefs->fem_rxgain != 0); - lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0); - lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset, - prefs->probe_interval, prefs->cad_busycap, - prefs->cad_base); - /* Write back the (offset, base) actually in force. setCadParams() may - * have re-anchored the offset across a base-table change, and the pair - * has to be persisted for the NEXT upgrade to have an anchor of its own. - * Guarded so a node whose base has not moved never writes flash at boot. */ - if (prefs->cad_base != lora_radio.cadBasePeak() || - prefs->cad_offset != lora_radio.getCadOffset()) { - prefs->cad_offset = lora_radio.getCadOffset(); - prefs->cad_base = lora_radio.cadBasePeak(); - repeater_mesh.savePrefs(); - } - - /* LR2021 side detectors (multi-SF RX) from prefs. extra_sf is a - * zero-terminated list; a rejected set (SF/BW changed since it was - * saved) just leaves the feature off, and non-LR2021 radios report it - * unsupported. */ - { - uint8_t n = 0; - while (n < EXTRA_SF_MAX && prefs->extra_sf[n] != 0) n++; - if (n > 0 && !lora_radio.configSideDetectors(prefs->extra_sf, n)) { - LOG_WRN("extra.sf %u SFs rejected for current SF/BW — side detectors off", n); - } - } - - /* Physical mounting orientation, from prefs. Both are immediate and - * cheap: the panel rotation is a two-byte SEGMENT_MAP/COM_SCAN remap and - * the axis swap is a flag the input callbacks read per event. A panel - * that cannot rotate logs a warning and stays in its native orientation - * rather than failing the boot. */ - zephcore_input_set_flipped(prefs->input_rotate != 0); -#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) - if (prefs->display_rotate) { - mc_display_set_rotated(true); - } -#endif - - /* Feed initial UI state from loaded prefs */ - ui_set_node_name(prefs->node_name); - refresh_repeater_ui_radio_state(); - ui_set_battery_provider(get_battery_mv); - ui_set_battery(zephyr_board.getBattMilliVolts(), 0); - ui_set_gps_available(gps_is_available()); - - /* Defer initial advertisement by 10s — gives GPS time for a quick fix. - * Advert payload (including coords) is built when the work fires. */ - LOG_INF("Initial advertisement scheduled in 10s"); - k_work_schedule(&initial_advert_work, K_SECONDS(10)); -#endif - - /* USB CDC was initialized earlier (right after clearBootloaderMagic). - * Just acquire the device handle for the CLI's UART IRQ binding below. */ -#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) - usb_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart); -#else - /* No CDC ACM class driver (e.g. ESP32 usb_serial, or an ESP32-S3 repeater - * built without esp32s3_usb.conf where the cdc_acm DT node exists but the - * class isn't compiled) — use the chosen console UART. */ - usb_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console)); -#endif - if (device_is_ready(usb_dev)) { - LOG_INF("USB CDC device ready: %s", usb_dev->name); - ring_buf_init(&usb_ring_buf, sizeof(usb_ring_buf_data), usb_ring_buf_data); - - /* Set up UART interrupt callback */ - uart_irq_callback_set(usb_dev, cli_uart_isr); - uart_irq_rx_enable(usb_dev); - } else { - LOG_ERR("USB CDC device not ready"); - usb_dev = NULL; - } - - /* - * Event-driven architecture: main thread runs repeater event loop. - * No BLE - all configuration via USB serial CLI. - */ -#ifdef ZEPHCORE_LORA - repeater_event_loop(); /* Never returns */ -#else - for (;;) { - k_sleep(K_FOREVER); - } -#endif - - return 0; + return server_main(repeater_role); } diff --git a/zephcore/src/main_room_server.cpp b/zephcore/src/main_room_server.cpp index 0489265..d305b56 100644 --- a/zephcore/src/main_room_server.cpp +++ b/zephcore/src/main_room_server.cpp @@ -1,735 +1,34 @@ /* * SPDX-License-Identifier: MIT - * ZephCore - Room Server (USB CLI, Event-Driven) + * ZephCore - Room Server * - * This is the main entry point for the room server (shared BBS) role. - * Room servers use USB serial CLI for configuration (no BLE). + * The room server (shared BBS) role's entry point: its mesh object, and the + * ServerRole that hands it to the shared server code (server_main_common.cpp). */ -#include -#include -#include -#include - -#include -LOG_MODULE_REGISTER(zephcore_room_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); - -#include -#include -#include -#include -#include -#include -#include -#include "oled_power.h" -#include "boot_prefs.h" - -/* BLE controller assert handler — BT is compiled even for repeater (via zephcore_common.conf) */ -#if IS_ENABLED(CONFIG_BT_CTLR_ASSERT_HANDLER) -extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) -{ - LOG_ERR("!!! BLE CONTROLLER ASSERT: %s:%u !!!", file ? file : "?", line); - k_sleep(K_MSEC(100)); - sys_reboot(SYS_REBOOT_COLD); -} -#endif - -/* USB CDC ACM init + 1200-baud DFU + DTR callbacks (shared with companion). - * - * Gate on the CDC-ACM class driver, NOT on DT_HAS_COMPAT_STATUS_OKAY alone: a - * board overlay may expose a cdc_acm_uart DT node unconditionally (the shared - * esp32s3_usb_otg.dtsi does), so the node can be present while the class driver - * — and therefore zephcore_usbd_* and the device object — is not compiled - * (e.g. an ESP32-S3 build without esp32s3_usb.conf). This mirrors the CMake - * condition that compiles ZephyrUSBCDC.cpp. */ -#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 "../adapters/datastore/ZephyrFsFormat.h" +#include "server_main_common.h" #include -#include -#include -#include - -/* UI subsystem (display, buttons, buzzer) */ -#include "ui_task.h" - -/* Radio + mesh includes (shared header selects LR1110 or SX126x) */ -#include - -#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA) -#include "wifi_ota.h" -#endif - -/* LED configuration */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) -#define LED0_NODE DT_ALIAS(led0) -static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios); -#endif -#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) -#define LED1_NODE DT_ALIAS(led1) -static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(LED1_NODE, gpios); -#endif - -/* USB CLI configuration */ -#define USB_RING_BUF_SIZE 512 -#define CLI_LINE_BUF_SIZE 256 - -/* - * Event-driven mesh loop - replaces 50ms polling with true event signaling. - * Events are signaled from ISR/callbacks, mesh loop wakes immediately. - */ -#define MESH_EVENT_LORA_RX BIT(0) /* LoRa packet received */ -#define MESH_EVENT_LORA_TX_DONE BIT(1) /* LoRa TX complete */ -#define MESH_EVENT_CLI_RX BIT(2) /* CLI command received */ -#define MESH_EVENT_HOUSEKEEPING BIT(3) /* Periodic housekeeping (noise floor, etc.) */ -#define MESH_EVENT_GPS_ACTION BIT(4) /* GPS state change (must run on main thread!) */ -#define MESH_EVENT_TX_DRAIN BIT(5) /* Outbound packet delay expired, run checkSend */ -#define MESH_EVENT_PUSH_TICK BIT(6) /* Room server: drive the post-sync push engine */ -#define MESH_EVENT_RTC_SAVE BIT(7) /* Hardware-RTC write requested off-main */ -#define MESH_EVENT_INIT_ADVERT BIT(8) /* Deferred boot advert — send on main thread */ -#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | MESH_EVENT_HOUSEKEEPING | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_PUSH_TICK | MESH_EVENT_RTC_SAVE | MESH_EVENT_INIT_ADVERT) - -/* Housekeeping interval - infrequent to preserve power savings */ -#define HOUSEKEEPING_INTERVAL_MS CONFIG_ZEPHCORE_HOUSEKEEPING_INTERVAL_MS - -/* Event object for mesh loop */ -static struct k_event mesh_events; - -/* Pending epoch for a deferred zephcore_rtc_save() — gps_fix_callback runs on - * the GNSS modem_chat worker thread, where the RTC's blocking I2C transactions - * would stall NMEA ingest. Stash the latest epoch and let the main thread - * perform the write; concurrent posts coalesce into one save. */ -static atomic_t pending_rtc_epoch = ATOMIC_INIT(0); - -static void request_rtc_save(uint32_t epoch) -{ - atomic_set(&pending_rtc_epoch, (atomic_val_t)epoch); - k_event_post(&mesh_events, MESH_EVENT_RTC_SAVE); -} - -/* USB CDC state */ -static const struct device *usb_dev; -static uint8_t usb_ring_buf_data[USB_RING_BUF_SIZE]; -static struct ring_buf usb_ring_buf; -static char cli_line_buf[CLI_LINE_BUF_SIZE]; -static char cli_reply_buf[256]; -static uint16_t cli_line_idx; -/* Last byte seen by cli_rx_work_fn, for collapsing CRLF/LFCR pairs. Persists - * across work-item invocations, so a pair split across two USB packets still - * compares correctly. */ -static uint8_t cli_prev_byte; - -/* Completed CLI lines are handed to the MAIN thread for execution. Byte - * assembly + echo (cli_rx_work_fn) runs on sysworkq and touches no mesh - * state, but handleCommand() mutates the lock-free packet pool / dispatcher - * that loop() also touches — running it on sysworkq races the main loop. - * So we queue the finished line and let the event loop run handleCommand(). */ -struct cli_cmd_line { char buf[CLI_LINE_BUF_SIZE]; }; -K_MSGQ_DEFINE(cli_cmd_queue, sizeof(struct cli_cmd_line), 4, 4); - -/* Work items for event-driven processing */ -static void cli_rx_work_fn(struct k_work *work); -static void housekeeping_timer_fn(struct k_timer *timer); -static void tx_drain_work_fn(struct k_work *work); -static void initial_advert_work_fn(struct k_work *work); -K_WORK_DEFINE(cli_rx_work, cli_rx_work_fn); -K_WORK_DELAYABLE_DEFINE(tx_drain_work, tx_drain_work_fn); -K_WORK_DELAYABLE_DEFINE(initial_advert_work, initial_advert_work_fn); - -/* Housekeeping timer for periodic tasks (noise floor calibration, etc.) */ -K_TIMER_DEFINE(housekeeping_timer, housekeeping_timer_fn, NULL); - -/* Room server push timer — wakes loop() at PUSH_TICK_INTERVAL_MS so the - * post-sync push engine advances at its intended ~1.2 s cadence instead of - * the 5 s housekeeping tick. Keeps post delivery snappy and TX smooth under - * load (without running radio maintenance that often). */ -#define PUSH_TICK_INTERVAL_MS 500 -static void push_timer_fn(struct k_timer *timer) -{ - ARG_UNUSED(timer); - k_event_post(&mesh_events, MESH_EVENT_PUSH_TICK); -} -K_TIMER_DEFINE(push_timer, push_timer_fn, NULL); - -/* Forward declarations */ -#ifdef ZEPHCORE_LORA -static RoomServerMesh *room_mesh_ptr; -#endif - -/* Print string to USB serial */ -static void cli_print(const char *str) -{ - if (!usb_dev) return; - while (*str) { - uart_poll_out(usb_dev, *str++); - } -} - -/* USB CDC UART interrupt callback */ -static void cli_uart_isr(const struct device *dev, void *user_data) -{ - ARG_UNUSED(user_data); - - for (;;) { - uart_irq_update(dev); - if (uart_irq_is_pending(dev) <= 0) { - break; - } - - if (uart_irq_rx_ready(dev)) { - uint8_t buf[64]; - int recv_len = uart_fifo_read(dev, buf, sizeof(buf)); - if (recv_len > 0) { - ring_buf_put(&usb_ring_buf, buf, recv_len); - k_work_submit(&cli_rx_work); - } - } - } -} - -/* CLI RX work - processes line-based CLI commands - * Matches Arduino behavior: echo each char, then " -> reply" on enter - */ -static void cli_rx_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - uint8_t byte; - - while (ring_buf_get(&usb_ring_buf, &byte, 1) == 1) { - const uint8_t prev = cli_prev_byte; - cli_prev_byte = byte; - - /* Process command on \r OR \n. Accepting both is what makes a piped - * `echo "cmd" > /dev/ttyACM0` (LF only) work. The cost is that a CRLF - * terminal sends two terminator bytes per Enter, so collapse the pair - * here: skip a terminator that directly follows a *different* one. - * "\r\r" and "\n\n" (two genuine blank lines) still dispatch twice. - * Blank lines ARE dispatched -- `region load` commits on one, and - * handleCommand() no-ops a blank line otherwise. The previous - * de-duplicator keyed on an empty line buffer instead: it ate every - * blank line (stranding `region load` until a reboot) and still let - * the CRLF tail fall through and emit a stray newline of its own. */ - if (byte == '\r' || byte == '\n') { - if ((prev == '\r' || prev == '\n') && byte != prev) { - continue; /* tail of a CRLF/LFCR pair */ - } - cli_line_buf[cli_line_idx] = '\0'; - - /* Debug: log received command */ - if (cli_line_idx > 0) { - LOG_INF("CLI cmd len=%d: %.40s%s", cli_line_idx, - cli_line_buf, cli_line_idx > 40 ? "..." : ""); - } - - /* Hand the command to the main thread (see cli_cmd_queue). - * The reply + trailing newline are emitted there, exactly - * matching the previous inline output order. */ - struct cli_cmd_line c; - strncpy(c.buf, cli_line_buf, sizeof(c.buf) - 1); - c.buf[sizeof(c.buf) - 1] = '\0'; - if (k_msgq_put(&cli_cmd_queue, &c, K_NO_WAIT) == 0) { - k_event_post(&mesh_events, MESH_EVENT_CLI_RX); - } else { - cli_print("\r\n -> busy\r\n"); - } - cli_line_idx = 0; - } else if (byte == 0x7F || byte == 0x08) { - /* Backspace - echo backspace sequence */ - if (cli_line_idx > 0) { - cli_line_idx--; - if (usb_dev) { - uart_poll_out(usb_dev, '\b'); - uart_poll_out(usb_dev, ' '); - uart_poll_out(usb_dev, '\b'); - } - } - } else if (cli_line_idx < sizeof(cli_line_buf) - 1) { - /* Echo character back (like Arduino) */ - if (usb_dev) { - uart_poll_out(usb_dev, byte); - } - cli_line_buf[cli_line_idx++] = (char)byte; - } - } -} #ifdef ZEPHCORE_LORA -/* Run queued CLI commands on the MAIN thread (see cli_cmd_queue). Output - * order matches the old inline path exactly: "\r\n -> reply" when there is - * a reply, then a trailing "\r\n". */ -static void process_cli_commands(void) -{ - struct cli_cmd_line c; - while (room_mesh_ptr && k_msgq_get(&cli_cmd_queue, &c, K_NO_WAIT) == 0) { - cli_reply_buf[0] = '\0'; - room_mesh_ptr->handleCommand(0, c.buf, cli_reply_buf); - if (cli_reply_buf[0] != '\0') { - cli_print("\r\n -> "); - cli_print(cli_reply_buf); - } - cli_print("\r\n"); - } -} -#endif - -/* Housekeeping timer callback - signals event to wake mesh loop periodically */ -static void housekeeping_timer_fn(struct k_timer *timer) -{ - ARG_UNUSED(timer); - k_event_post(&mesh_events, MESH_EVENT_HOUSEKEEPING); -} - -#ifdef ZEPHCORE_LORA -/* LoRa RX callback - called from ISR context when packet received */ -static void lora_rx_callback(void *user_data) -{ - ARG_UNUSED(user_data); - k_event_post(&mesh_events, MESH_EVENT_LORA_RX); -} - -/* LoRa TX complete callback */ -static void lora_tx_done_callback(void *user_data) -{ - ARG_UNUSED(user_data); - k_event_post(&mesh_events, MESH_EVENT_LORA_TX_DONE); -} - -/* TX drain — Dispatcher queued a packet with a delay. - * Schedule a precise wake so checkSend runs when the delay expires. */ -static void tx_drain_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - k_event_post(&mesh_events, MESH_EVENT_TX_DRAIN); -} - -/* Deferred initial advertisement — gives GPS time to get a fix at boot. - * Runs on sysworkq (the 10 s delay timer), so it must NOT send the advert - * here: sendSelfAdvertisement() mutates the packet pool / dispatcher shared - * with loop(). Post an event and let the main thread send it. */ -static void initial_advert_work_fn(struct k_work *work) -{ - ARG_UNUSED(work); - k_event_post(&mesh_events, MESH_EVENT_INIT_ADVERT); -} - -static void tx_queued_callback(uint32_t delay_ms, void *user_data) -{ - ARG_UNUSED(user_data); - k_work_reschedule(&tx_drain_work, K_MSEC(delay_ms)); -} -#endif - -/* Global instances */ -static mesh::ZephyrRTCClock rtc_clock; - -/* GPS event callback - called when GPS work handlers need the main thread - * to process a state transition (wake from standby, fix done, timeout). - * Runs from system work queue context — just posts an event, no blocking. */ -static void gps_event_callback(void) -{ - k_event_post(&mesh_events, MESH_EVENT_GPS_ACTION); -} - -static RepeaterDataStore data_store; - -/* GPS fix callback - syncs RTC from GPS time. - * Repeaters do NOT update prefs lat/lon from GPS — prefs coordinates are the - * user's manually-set position used for adverts. Precise GPS position is - * served only via telemetry requests (gps_get_last_known_position). */ -static void gps_fix_callback(double lat, double lon, int64_t utc_time) -{ - if (utc_time > 0) { - LOG_INF("GPS fix: RTC sync time=%lld", utc_time); - rtc_clock.setCurrentTime((uint32_t)utc_time); - /* Defer the hardware-RTC write to the main thread — blocking I2C - * here would stall NMEA ingest (gps_fix_callback runs in the - * GNSS modem_chat worker context). */ - request_rtc_save((uint32_t)utc_time); - } - - int lat_deg = (int)lat; - int lon_deg = (int)lon; - int lat_frac = (int)((lat - lat_deg) * 1000000); - int lon_frac = (int)((lon - lon_deg) * 1000000); - if (lat_frac < 0) lat_frac = -lat_frac; - if (lon_frac < 0) lon_frac = -lon_frac; - LOG_INF("GPS fix: lat=%d.%06d lon=%d.%06d (telemetry only)", - lat_deg, lat_frac, lon_deg, lon_frac); -} - -#ifdef ZEPHCORE_LORA -static mesh::ZephyrBoard zephyr_board; - -static uint16_t get_battery_mv(void) -{ - return zephyr_board.getBattMilliVolts(); -} - -/* Radio is constructed with no prefs pointer; main() binds it to - * room_mesh._prefs via setPrefs() before room_mesh.begin(). */ - -static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); -static mesh::LoRaRadio lora_radio(lora_dev, zephyr_board); - -static mesh::ZephyrMillisecondClock ms_clock; -static mesh::ZephyrRNG zephyr_rng; -static SimpleMeshTables mesh_tables; - /* RoomServerMesh requires: board, radio, ms_clock, rng, rtc, tables */ static RoomServerMesh room_mesh(zephyr_board, lora_radio, ms_clock, zephyr_rng, rtc_clock, mesh_tables); #endif -/* Strong override of the weak stub in ui_mesh_actions_stubs.c: keep the prefs - * copy in step when the LEDs page toggles, so "get leds" reports what the node - * is actually doing. RAM only — there is no deferred-save path off the UI - * thread, so a UI toggle lasts until reboot; "set leds" is what persists. */ -extern "C" void mesh_set_leds_disabled(bool disabled) -{ - if (room_mesh_ptr) { - room_mesh_ptr->getNodePrefs()->leds_disabled = disabled ? 1 : 0; - } -} - -/* Repeater event loop */ -static void room_event_loop(void) -{ - LOG_INF("starting event-driven loop"); - - /* Print startup banner (no prompt - Arduino style) */ - cli_print("\r\n=== ZephCore Room Server ===\r\n"); - - /* Start housekeeping timer for periodic maintenance tasks */ - k_timer_start(&housekeeping_timer, K_MSEC(HOUSEKEEPING_INTERVAL_MS), - K_MSEC(HOUSEKEEPING_INTERVAL_MS)); - - /* Start the room-server push timer (drives post sync between clients). */ - k_timer_start(&push_timer, K_MSEC(PUSH_TICK_INTERVAL_MS), - K_MSEC(PUSH_TICK_INTERVAL_MS)); - - for (;;) { - /* Wait for any mesh event - blocks until signaled */ - uint32_t events = k_event_wait(&mesh_events, MESH_EVENT_ALL, false, K_FOREVER); - k_event_clear(&mesh_events, events); - - /* GPS state transitions must run on main thread (GNSS driver - * modem_chat blocks on system work queue semaphore). */ - if (events & MESH_EVENT_GPS_ACTION) { - gps_process_event(); - } - +static const ServerRole room_role = { + .name = "Room Server", + .name_prefix = "Room", #ifdef ZEPHCORE_LORA - /* Run queued CLI commands here (main thread) BEFORE loop() drains - * any outbound packets they enqueued — keeps all mesh-state - * mutation on the main thread (see cli_cmd_queue). */ - if (events & MESH_EVENT_CLI_RX) { - process_cli_commands(); - } - - /* Deferred boot advert — sent on the main thread (see - * initial_advert_work_fn); loop() below drains the queued packet. */ - if (room_mesh_ptr && (events & MESH_EVENT_INIT_ADVERT)) { - LOG_INF("Sending deferred initial advertisement"); - room_mesh_ptr->sendSelfAdvertisement(500, false); - } - - /* Packet processing — only on radio/CLI/TX events */ - if (room_mesh_ptr && - (events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | - MESH_EVENT_CLI_RX | MESH_EVENT_TX_DRAIN | - MESH_EVENT_PUSH_TICK))) { - room_mesh_ptr->loop(); - } + .mesh = &room_mesh, + .callbacks = &room_mesh, + .prefs = room_mesh.getNodePrefs(), + .begin = [](RepeaterDataStore *store) { room_mesh.begin(store); }, + .loop = [] { room_mesh.loop(); }, + .handle_command = [](char *command, char *reply) { room_mesh.handleCommand(0, command, reply); }, + .note_gps_time_sync = [] { room_mesh.noteGPSTimeSync(); }, #endif - - /* Periodic housekeeping — maintenance + display refresh */ - if (events & MESH_EVENT_HOUSEKEEPING) { -#ifdef ZEPHCORE_LORA - /* Radio maintenance: noise floor calibration, adaptive-CAD - * probe, RX watchdog. Separated from loop() so these - * never run on packet-driven events. */ - if (room_mesh_ptr) { - room_mesh_ptr->maintenanceLoop(); - /* Also drive loop() so time-based actions (advert - * timers, tempradio set/revert, contacts flush, - * uplink status) still fire when no LoRa/CLI - * traffic wakes the event loop. */ - room_mesh_ptr->loop(); - } -#endif - - ui_set_clock(rtc_clock.getCurrentTime()); - -#ifdef ZEPHCORE_LORA - /* Refresh radio params (noise floor changes from calibration) */ - if (room_mesh_ptr) { - NodePrefs *p = room_mesh_ptr->getNodePrefs(); - ui_set_radio_params( - (uint32_t)(p->freq * 1000000.0f + 0.5f), - p->sf, - (uint16_t)(p->bw * 10.0f + 0.5f), - p->cr, - p->tx_power_dbm, - lora_radio.getNoiseFloor()); - } - - /* Battery is now refreshed lazily from ui_pages_render() with - * a 30 s freshness guard — no periodic ADC fire here. */ -#endif - } - - /* Off-main RTC write request (gps_fix_callback runs in modem_chat - * context — see request_rtc_save()). Perform the blocking I2C - * write here on the main thread instead. */ - if (events & MESH_EVENT_RTC_SAVE) { - zephcore_rtc_save((uint32_t)atomic_get(&pending_rtc_epoch)); -#ifdef ZEPHCORE_LORA - /* GPS just set the clock — arm the mesh time-sync drift envelope. */ - if (room_mesh_ptr) { - room_mesh_ptr->noteGPSTimeSync(); - } -#endif - } - } -} +}; int main(void) { -#ifdef ZEPHCORE_LORA - /* Clear any stale bootloader magic from previous sessions. - * Prevents nRF52 boards from re-entering bootloader after reboot. */ - zephyr_board.clearBootloaderMagic(); -#endif - - /* USB CDC init up front so the host can enumerate, then wait for the - * host to open the port (DTR asserted) — event-driven via the usbd - * message callback. Unplugged → 2 s timeout, no banner; attached → - * banner reaches the user the moment the port opens. */ -#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) && \ - !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) && \ - (IS_ENABLED(CONFIG_USB_CDC_ACM) || IS_ENABLED(CONFIG_USBD_CDC_ACM_CLASS)) - zephcore_usbd_init(); - zephcore_usbd_wait_dtr(2000); -#endif - LOG_INF("=== ZephCore Room Server starting ==="); - -#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA) - /* Confirm MCUboot image early — if we just booted after OTA, - * this marks the image as good so MCUboot keeps it. */ - wifi_ota_confirm_image(); -#endif - - /* Configure LEDs */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) - if (gpio_is_ready_dt(&led0)) { - gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE); - } -#endif -#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) - if (gpio_is_ready_dt(&led1)) { - gpio_pin_configure_dt(&led1, GPIO_OUTPUT_INACTIVE); - } -#endif - - /* First boot on a volume that is not this role's - a fresh chip, a - * companion, or a node that was running Arduino MeshCore, whose nRF52 - * filesystems overlap our lfs_partition - * (devdocs/HANDOVER_lfs_arduino_overlap.md). Erase everything so we - * start from a known state: Zephyr's automount only - * auto-formats the LittleFS volume when it fails to mount, and never - * touches storage_partition (BLE bonds NVS) or QSPI. - * - * Self-limiting, so it needs no "done" marker: the identity is generated - * and saved a few lines below, and loadPrefs() persists defaults on the - * same boot, so the next boot sees this role's data and skips this. */ - 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"); - } - } - - /* Initialize repeater data store */ - if (!data_store.begin()) { - LOG_ERR("RepeaterDataStore init failed"); - } - - /* Initialize sensor manager */ - sensor_manager_init(); - - /* Restore wall-clock time from a battery-backed hardware RTC if present - * (shown tagged "L" until the next GPS/CLI sync; no-op if no RTC). */ - { - uint32_t rtc_epoch; - if (zephcore_rtc_restore(&rtc_epoch)) { - rtc_clock.setCurrentTime(rtc_epoch); - } - } - - /* GPS callbacks only. Duty interval and repeater mode are applied by - * apply_boot_prefs() after loadPrefs() below. */ - if (gps_is_available()) { - gps_set_fix_callback(gps_fix_callback); - gps_set_event_callback(gps_event_callback); - } - - /* Initialize UI (display + buttons). Shows splash screen, then auto- - * transitions to STATUS page. Display sleeps after auto-off timeout; - * user button is the only wake source for repeater. */ - ui_init(); -#if !IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) - oled_sleep(); -#endif - - /* Log environment sensor availability */ - if (env_sensors_available()) { - LOG_INF("Environment sensors available"); - } - -#ifdef ZEPHCORE_LORA - room_mesh_ptr = &room_mesh; - - /* Initialize mesh event object BEFORE begin() — radio callbacks post events */ - k_event_init(&mesh_events); - - /* Set LoRa callbacks for event-driven packet processing */ - lora_radio.setRxCallback(lora_rx_callback, nullptr); - lora_radio.setTxDoneCallback(lora_tx_done_callback, nullptr); - room_mesh.setTxQueuedCallback(tx_queued_callback, nullptr); - - /* Load or generate identity BEFORE begin(). First-boot keygen runs - * the layered entropy mixer + Ed25519 derive + reserved-prefix - * guard inside ZephyrRNG::generateFirstBootIdentity. */ - mesh::LocalIdentity self_identity; - if (!data_store.loadIdentity(self_identity)) { - LOG_INF("No identity found, generating new keypair..."); - mesh::ZephyrRNG::generateFirstBootIdentity(self_identity); - data_store.saveIdentity(self_identity); - LOG_INF("New identity saved"); - } - room_mesh.self_id = self_identity; - - /* Log repeater ID (first 8 bytes of public key) */ - LOG_INF("Room ID: %02x%02x%02x%02x%02x%02x%02x%02x...", - self_identity.pub_key[0], self_identity.pub_key[1], - self_identity.pub_key[2], self_identity.pub_key[3], - self_identity.pub_key[4], self_identity.pub_key[5], - self_identity.pub_key[6], self_identity.pub_key[7]); - - /* Load persisted prefs and bind the radio to _prefs BEFORE begin() — the - * radio reads freq/bw/sf/cr through this pointer during Mesh::begin() → - * Dispatcher::begin() → Radio::begin(). Without this, the radio would - * configure on NodePrefs defaults (869.618 MHz) regardless of saved - * settings: CLI readback looked correct but the hardware stayed on EU. - * Every role binds this way; begin() refuses to start without it. */ - data_store.loadPrefs(*room_mesh.getNodePrefs()); - lora_radio.setPrefs(room_mesh.getNodePrefs()); - - /* LED gate and GPS duty from prefs. After loadPrefs() and ui_init(), - * before begin() -- see boot_prefs.h. */ - apply_boot_prefs(room_mesh.getNodePrefs(), true); - - /* Start mesh with data store - loads ACL, regions */ - room_mesh.begin(&data_store); - - /* Generate default node name from hardware device ID if not set */ - NodePrefs* prefs = room_mesh.getNodePrefs(); - if (strlen(prefs->node_name) == 0 || strcmp(prefs->node_name, "Room") == 0) { - uint8_t dev_id[8]; - ssize_t id_len = hwinfo_get_device_id(dev_id, sizeof(dev_id)); - if (id_len >= 4) { - snprintf(prefs->node_name, sizeof(prefs->node_name), - "Room-%02X%02X%02X%02X", dev_id[0], dev_id[1], dev_id[2], dev_id[3]); - } - } - - /* Apply RX boost and duty cycle from prefs */ - lora_radio.setRxBoost(prefs->rx_boost != 0); - lora_radio.setFemRxEnable(prefs->fem_rxgain != 0); - lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0); - lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset, - prefs->probe_interval, prefs->cad_busycap, - prefs->cad_base); - /* Write back the (offset, base) actually in force. setCadParams() may - * have re-anchored the offset across a base-table change, and the pair - * has to be persisted for the NEXT upgrade to have an anchor of its own. - * Guarded so a node whose base has not moved never writes flash at boot. */ - if (prefs->cad_base != lora_radio.cadBasePeak() || - prefs->cad_offset != lora_radio.getCadOffset()) { - prefs->cad_offset = lora_radio.getCadOffset(); - prefs->cad_base = lora_radio.cadBasePeak(); - room_mesh.savePrefs(); - } - - /* LR2021 side detectors (multi-SF RX) from prefs. extra_sf is a - * zero-terminated list; a rejected set (SF/BW changed since it was - * saved) just leaves the feature off, and non-LR2021 radios report it - * unsupported. */ - { - uint8_t n = 0; - while (n < EXTRA_SF_MAX && prefs->extra_sf[n] != 0) n++; - if (n > 0 && !lora_radio.configSideDetectors(prefs->extra_sf, n)) { - LOG_WRN("extra.sf %u SFs rejected for current SF/BW — side detectors off", n); - } - } - - /* Feed initial UI state from loaded prefs */ - ui_set_node_name(prefs->node_name); - ui_set_radio_params( - (uint32_t)(prefs->freq * 1000000.0f + 0.5f), /* MHz → Hz */ - prefs->sf, - (uint16_t)(prefs->bw * 10.0f + 0.5f), /* kHz → 0.1 kHz */ - prefs->cr, - prefs->tx_power_dbm, - lora_radio.getNoiseFloor()); - ui_set_battery_provider(get_battery_mv); - ui_set_battery(zephyr_board.getBattMilliVolts(), 0); - ui_set_gps_available(gps_is_available()); - - /* Defer initial advertisement by 10s — gives GPS time for a quick fix. - * Advert payload (including coords) is built when the work fires. */ - LOG_INF("Initial advertisement scheduled in 10s"); - k_work_schedule(&initial_advert_work, K_SECONDS(10)); -#endif - - /* USB CDC was initialized earlier (right after clearBootloaderMagic). - * Just acquire the device handle for the CLI's UART IRQ binding below. */ -#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) - usb_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart); -#else - /* No CDC ACM class driver (e.g. ESP32 usb_serial, or an ESP32-S3 build - * without esp32s3_usb.conf where the cdc_acm DT node exists but the class - * isn't compiled) — use the chosen console UART. */ - usb_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console)); -#endif - if (device_is_ready(usb_dev)) { - LOG_INF("USB CDC device ready: %s", usb_dev->name); - ring_buf_init(&usb_ring_buf, sizeof(usb_ring_buf_data), usb_ring_buf_data); - - /* Set up UART interrupt callback */ - uart_irq_callback_set(usb_dev, cli_uart_isr); - uart_irq_rx_enable(usb_dev); - } else { - LOG_ERR("USB CDC device not ready"); - usb_dev = NULL; - } - - /* - * Event-driven architecture: main thread runs repeater event loop. - * No BLE - all configuration via USB serial CLI. - */ -#ifdef ZEPHCORE_LORA - room_event_loop(); /* Never returns */ -#else - for (;;) { - k_sleep(K_FOREVER); - } -#endif - - return 0; + return server_main(room_role); } diff --git a/zephcore/src/mesh_events.h b/zephcore/src/mesh_events.h new file mode 100644 index 0000000..432b7bf --- /dev/null +++ b/zephcore/src/mesh_events.h @@ -0,0 +1,30 @@ +/* + * SPDX-License-Identifier: MIT + * Event bits of the mesh thread's k_event, shared by every role. + * + * Each role's main loop blocks in k_event_wait() on these; ISR and work-queue + * callbacks post them. Bits 0-6 mean the same thing in every role that has + * them; bits 2 and 3 are the role's transport input and time tick under + * role-specific names. Role-only bits start at MESH_EVENT_ROLE_BASE. + */ + +#pragma once + +#include + +#define MESH_EVENT_LORA_RX BIT(0) /* LoRa packet received */ +#define MESH_EVENT_LORA_TX_DONE BIT(1) /* LoRa TX complete */ + +/* Transport input */ +#define MESH_EVENT_BLE_RX BIT(2) /* companion: BLE/USB frame received */ +#define MESH_EVENT_CLI_RX BIT(2) /* servers, observer: USB CLI line received */ + +/* Time tick */ +#define MESH_EVENT_HOUSEKEEPING BIT(3) /* companion: periodic housekeeping */ +#define MESH_EVENT_MAINTENANCE BIT(3) /* servers: a maintenance deadline came due */ + +#define MESH_EVENT_GPS_ACTION BIT(4) /* GPS state change (must run on the main thread) */ +#define MESH_EVENT_TX_DRAIN BIT(5) /* outbound packet delay expired, run checkSend */ +#define MESH_EVENT_RTC_SAVE BIT(6) /* hardware-RTC write requested off-main */ + +#define MESH_EVENT_ROLE_BASE 7 /* first role-only bit */ diff --git a/zephcore/src/server_main_common.cpp b/zephcore/src/server_main_common.cpp new file mode 100644 index 0000000..411f728 --- /dev/null +++ b/zephcore/src/server_main_common.cpp @@ -0,0 +1,865 @@ +/* + * SPDX-License-Identifier: MIT + * ZephCore - shared composition root of the server roles (USB CLI, Event-Driven) + * + * The repeater and the room server both run from here: server_main() is + * called by main_repeater.cpp / main_room_server.cpp with the role's + * ServerRole. Servers use the USB serial CLI for configuration (no BLE). + */ + +#include +#include +#include +#include + +#include +LOG_MODULE_REGISTER(zephcore_server_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); + +#include +#include +#include +#include +#include +#include +#include +#include "oled_power.h" +#include "boot_prefs.h" + +/* BLE controller assert handler — BT is compiled even for the servers (via zephcore_common.conf) */ +#if IS_ENABLED(CONFIG_BT_CTLR_ASSERT_HANDLER) +extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) +{ + LOG_ERR("!!! BLE CONTROLLER ASSERT: %s:%u !!!", file ? file : "?", line); + k_sleep(K_MSEC(100)); + sys_reboot(SYS_REBOOT_COLD); +} +#endif + +/* USB CDC ACM init + 1200-baud DFU + DTR callbacks (shared with companion). + * + * Gate on the CDC-ACM class driver, NOT on DT_HAS_COMPAT_STATUS_OKAY alone: a + * board overlay may expose a cdc_acm_uart DT node unconditionally (the shared + * esp32s3_usb_otg.dtsi does), so the node can be present while the class driver + * — and therefore zephcore_usbd_* and the device object — is not compiled + * (e.g. an ESP32-S3 server built without esp32s3_usb.conf). This mirrors the + * CMake condition that compiles ZephyrUSBCDC.cpp. */ +#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 "server_main_common.h" +#include "mesh_events.h" +#include "../adapters/datastore/ZephyrFsFormat.h" +#include +#include +#include +#include + +/* UI subsystem (display, buttons, buzzer) */ +#include "ui_task.h" +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) +#include "display.h" +#endif + +/* Headless servers link the weak no-op ui_* stubs (ui_headless_stubs.c), so + * the periodic UI refresh in the maintenance pass is pure work for nothing on + * them. Guard the hot path on a real ui_* implementation being linked; the + * one-shot calls at init and in the CLI reply path stay unguarded, matching + * the rest of the file. + * + * This MUST mirror the CMake condition that selects the stubs, not the display + * devicetree node: ZEPHCORE_UI_DESIGN_BUTTON is enabled by BUTTONS *or* + * DISPLAY *or* BUZZER, so a board with buttons/buzzer and no panel still links + * the real UI and still needs these updates. */ +#define ZEPHCORE_HAS_UI (IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_BUTTON) || \ + IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK)) + +#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA) +#include "wifi_ota.h" +#endif + +/* LED configuration */ +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) +#define LED0_NODE DT_ALIAS(led0) +static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios); +#endif +#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) +#define LED1_NODE DT_ALIAS(led1) +static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(LED1_NODE, gpios); +#endif + +/* USB CLI configuration */ +#define USB_RING_BUF_SIZE 512 +#define CLI_LINE_BUF_SIZE 256 + +/* Event bits: the shared ones are in mesh_events.h. */ +#define MESH_EVENT_INIT_ADVERT BIT(MESH_EVENT_ROLE_BASE) /* Deferred boot advert — send on main thread */ +#define MESH_EVENT_WAKE BIT(MESH_EVENT_ROLE_BASE + 1) /* Off-main state set; run loop() promptly */ +#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | MESH_EVENT_MAINTENANCE | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_RTC_SAVE | MESH_EVENT_INIT_ADVERT | MESH_EVENT_WAKE) + +/* Maintenance is deadline-driven, not periodic: after every pass the loop asks + * the mesh when its soonest pending deadline is (msUntilNextMaintenance) and + * arms a single one-shot wake for exactly that moment. An idle node with + * nothing scheduled therefore sleeps until its next real deadline instead of + * waking on a fixed cadence. + * + * MAINTENANCE_BACKSTOP_MS bounds that: it caps how long we will go without a + * pass even when everything reports idle, so a deadline that is missed or + * mis-reported degrades to the old behaviour instead of wedging. + * MAINTENANCE_MIN_MS floors it, so an item that is due-but-blocked (radio + * mid-packet, duty-cycle sleep window) re-arms shortly rather than spinning. */ +#define MAINTENANCE_BACKSTOP_MS CONFIG_ZEPHCORE_MAINTENANCE_BACKSTOP_MS +#define MAINTENANCE_MIN_MS 50 + +/* Event object for mesh loop */ +static struct k_event mesh_events; + +/* Pending epoch for a deferred zephcore_rtc_save() — gps_fix_callback runs on + * the GNSS modem_chat worker thread, where the RTC's blocking I2C transactions + * would stall NMEA ingest. Stash the latest epoch and let the main thread + * perform the write; concurrent posts coalesce into one save. */ +static atomic_t pending_rtc_epoch = ATOMIC_INIT(0); + +static void request_rtc_save(uint32_t epoch) +{ + atomic_set(&pending_rtc_epoch, (atomic_val_t)epoch); + k_event_post(&mesh_events, MESH_EVENT_RTC_SAVE); +} + +/* USB CDC state */ +static const struct device *usb_dev; +static uint8_t usb_ring_buf_data[USB_RING_BUF_SIZE]; +static struct ring_buf usb_ring_buf; +static char cli_line_buf[CLI_LINE_BUF_SIZE]; +static char cli_reply_buf[256]; +static uint16_t cli_line_idx; +/* Last byte seen by cli_rx_work_fn, for collapsing CRLF/LFCR pairs. Persists + * across work-item invocations, so a pair split across two USB packets still + * compares correctly. */ +static uint8_t cli_prev_byte; + +/* Completed CLI lines are handed to the MAIN thread for execution. Byte + * assembly + echo (cli_rx_work_fn) runs on sysworkq and touches no mesh + * state, but handleCommand() mutates the lock-free packet pool / dispatcher + * that loop() also touches — running it on sysworkq races the main loop. + * So we queue the finished line and let the event loop run handleCommand(). */ +struct cli_cmd_line { char buf[CLI_LINE_BUF_SIZE]; }; +K_MSGQ_DEFINE(cli_cmd_queue, sizeof(struct cli_cmd_line), 4, 4); + +/* Work items for event-driven processing */ +static void cli_rx_work_fn(struct k_work *work); +static void maintenance_timer_fn(struct k_timer *timer); +static void tx_drain_work_fn(struct k_work *work); +static void initial_advert_work_fn(struct k_work *work); +K_WORK_DEFINE(cli_rx_work, cli_rx_work_fn); +K_WORK_DELAYABLE_DEFINE(tx_drain_work, tx_drain_work_fn); +K_WORK_DELAYABLE_DEFINE(initial_advert_work, initial_advert_work_fn); + +/* One-shot maintenance wake, re-armed after every loop pass (see + * arm_maintenance_wake). Not periodic — the period is the deadline. */ +K_TIMER_DEFINE(maintenance_timer, maintenance_timer_fn, NULL); + +/* The running role; set by server_main() once the mesh is wired up, and the + * guard for every path that can run before that. */ +#ifdef ZEPHCORE_LORA +static const ServerRole *server_role; +static void refresh_ui_radio_state(void); +#endif + +/* Print string to USB serial */ +static void cli_print(const char *str) +{ + if (!usb_dev) return; + while (*str) { + uart_poll_out(usb_dev, *str++); + } +} + +/* Arduino's `Serial` for upstream-text CLI output (compat/Serial.h). */ +ConsoleSerial Serial; + +size_t ConsoleSerial::write(const uint8_t *buffer, size_t size) +{ + if (!usb_dev) return 0; + for (size_t i = 0; i < size; i++) { + uart_poll_out(usb_dev, buffer[i]); + } + return size; +} + +/* USB CDC UART interrupt callback */ +static void cli_uart_isr(const struct device *dev, void *user_data) +{ + ARG_UNUSED(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]; + int recv_len = uart_fifo_read(dev, buf, sizeof(buf)); + if (recv_len > 0) { + ring_buf_put(&usb_ring_buf, buf, recv_len); + k_work_submit(&cli_rx_work); + } + } + } +} + +/* CLI RX work - processes line-based CLI commands + * Matches Arduino behavior: echo each char, then " -> reply" on enter + */ +static void cli_rx_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + uint8_t byte; + + while (ring_buf_get(&usb_ring_buf, &byte, 1) == 1) { + const uint8_t prev = cli_prev_byte; + cli_prev_byte = byte; + + /* Process command on \r OR \n. Accepting both is what makes a piped + * `echo "cmd" > /dev/ttyACM0` (LF only) work. The cost is that a CRLF + * terminal sends two terminator bytes per Enter, so collapse the pair + * here: skip a terminator that directly follows a *different* one. + * "\r\r" and "\n\n" (two genuine blank lines) still dispatch twice. + * Blank lines ARE dispatched -- `region load` commits on one, and + * handleCommand() no-ops a blank line otherwise. The previous + * de-duplicator keyed on an empty line buffer instead: it ate every + * blank line (stranding `region load` until a reboot) and still let + * the CRLF tail fall through and emit a stray newline of its own. */ + if (byte == '\r' || byte == '\n') { + if ((prev == '\r' || prev == '\n') && byte != prev) { + continue; /* tail of a CRLF/LFCR pair */ + } + cli_line_buf[cli_line_idx] = '\0'; + + /* Debug: log received command */ + if (cli_line_idx > 0) { + LOG_INF("CLI cmd len=%d: %.40s%s", cli_line_idx, + cli_line_buf, cli_line_idx > 40 ? "..." : ""); + } + + /* Hand the command to the main thread (see cli_cmd_queue). + * The reply + trailing newline are emitted there, exactly + * matching the previous inline output order. */ + struct cli_cmd_line c; + strncpy(c.buf, cli_line_buf, sizeof(c.buf) - 1); + c.buf[sizeof(c.buf) - 1] = '\0'; + if (k_msgq_put(&cli_cmd_queue, &c, K_NO_WAIT) == 0) { + k_event_post(&mesh_events, MESH_EVENT_CLI_RX); + } else { + cli_print("\r\n -> busy\r\n"); + } + cli_line_idx = 0; + } else if (byte == 0x7F || byte == 0x08) { + /* Backspace - echo backspace sequence */ + if (cli_line_idx > 0) { + cli_line_idx--; + if (usb_dev) { + uart_poll_out(usb_dev, '\b'); + uart_poll_out(usb_dev, ' '); + uart_poll_out(usb_dev, '\b'); + } + } + } else if (cli_line_idx < sizeof(cli_line_buf) - 1) { + /* Echo character back (like Arduino) */ + if (usb_dev) { + uart_poll_out(usb_dev, byte); + } + cli_line_buf[cli_line_idx++] = (char)byte; + } + } +} + +#ifdef ZEPHCORE_LORA +/* Run queued CLI commands on the MAIN thread (see cli_cmd_queue). Output is + * "\r\n", then " -> reply\r\n" when there is a reply: the same bytes as the + * old inline path, but the echoed line is ended BEFORE the command runs, so + * one that prints straight to the console (get acl) starts on its own line. */ +static void process_cli_commands(void) +{ + struct cli_cmd_line c; + while (server_role && k_msgq_get(&cli_cmd_queue, &c, K_NO_WAIT) == 0) { + cli_reply_buf[0] = '\0'; + cli_print("\r\n"); + server_role->handle_command(c.buf, cli_reply_buf); + refresh_ui_radio_state(); + if (cli_reply_buf[0] != '\0') { + cli_print(" -> "); + cli_print(cli_reply_buf); + cli_print("\r\n"); + } + } +} +#endif + +/* Maintenance deadline expired — wake the mesh loop to run the pass. */ +static void maintenance_timer_fn(struct k_timer *timer) +{ + ARG_UNUSED(timer); + k_event_post(&mesh_events, MESH_EVENT_MAINTENANCE); +} + +/* Ask the mesh for its soonest pending deadline and arm the one-shot for it. + * Called after every loop pass, whatever woke us: any event may have created + * or cleared a deadline (a queued advert, a CLI tempradio command, a CAD probe + * that just ran), so the schedule is recomputed from scratch each time rather + * than tracked incrementally. + * + * The backstop below is an unconditional CEILING on the wait, not a fallback + * used only when everything reports idle. That means it must stay well above + * the shortest legitimate recurring deadline (the noise-floor sampler, and the + * CAD probe) or it becomes the effective period and the deadline scheduling + * buys nothing — see ZEPHCORE_MAINTENANCE_BACKSTOP_MS. */ +static void arm_maintenance_wake(void) +{ + uint32_t delay = MAINTENANCE_BACKSTOP_MS; + uint32_t next = MAINTENANCE_BACKSTOP_MS; + +#ifdef ZEPHCORE_LORA + if (server_role) { + next = server_role->mesh->msUntilNextMaintenance(); + + if (next < delay) { + delay = next; + } + } +#endif + + if (delay < MAINTENANCE_MIN_MS) { + delay = MAINTENANCE_MIN_MS; + } + + /* Both figures, because which one is binding is the whole diagnosis: + * next=15000 armed=15000 — a real deadline won; working as intended + * next=15000 armed=5000 — the backstop is clamping (it must stay + * above the noise-floor/CAD intervals) + * next=0 armed=50 — a deadline reports due but its state is + * not advancing; repeated = spin + * mesh::MAINTENANCE_IDLE (0x7FFFFFFF) as `next` means nothing pending. */ + LOG_DBG("maint: next=%u armed=%u", (unsigned)next, (unsigned)delay); + + k_timer_start(&maintenance_timer, K_MSEC(delay), K_NO_WAIT); +} + +#ifdef ZEPHCORE_LORA +/* LoRa RX callback - called from ISR context when packet received */ +static void lora_rx_callback(void *user_data) +{ + ARG_UNUSED(user_data); + k_event_post(&mesh_events, MESH_EVENT_LORA_RX); +} + +/* LoRa TX complete callback */ +static void lora_tx_done_callback(void *user_data) +{ + ARG_UNUSED(user_data); + k_event_post(&mesh_events, MESH_EVENT_LORA_TX_DONE); +} + +/* TX drain — Dispatcher queued a packet with a delay. + * Schedule a precise wake so checkSend runs when the delay expires. */ +static void tx_drain_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + k_event_post(&mesh_events, MESH_EVENT_TX_DRAIN); +} + +/* Deferred initial advertisement — gives GPS time to get a fix at boot. + * Runs on sysworkq (the 10 s delay timer), so it must NOT send the advert + * here: sendSelfAdvertisement() mutates the packet pool / dispatcher shared + * with loop(). Post an event and let the main thread send it. */ +static void initial_advert_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + k_event_post(&mesh_events, MESH_EVENT_INIT_ADVERT); +} + +static void tx_queued_callback(uint32_t delay_ms, void *user_data) +{ + ARG_UNUSED(user_data); + k_work_reschedule(&tx_drain_work, K_MSEC(delay_ms)); +} + +/* Off-main code (MQTT CONNACK, SNTP) set state that loop() must drain. Post + * only — this runs on the MQTT publisher / WiFi thread, and k_event_post is + * the sole thing safe to do from there. */ +static void wake_callback(void *user_data) +{ + ARG_UNUSED(user_data); + k_event_post(&mesh_events, MESH_EVENT_WAKE); +} +#endif + +/* Global instances */ +mesh::ZephyrRTCClock rtc_clock; + +/* GPS event callback - called when GPS work handlers need the main thread + * to process a state transition (wake from standby, fix done, timeout). + * Runs from system work queue context — just posts an event, no blocking. */ +static void gps_event_callback(void) +{ + k_event_post(&mesh_events, MESH_EVENT_GPS_ACTION); +} + +static RepeaterDataStore data_store; + +/* GPS fix callback - syncs RTC from GPS time. + * Servers do NOT update prefs lat/lon from GPS — prefs coordinates are the + * user's manually-set position used for adverts. Precise GPS position is + * served only via telemetry requests (gps_get_last_known_position). */ +static void gps_fix_callback(double lat, double lon, int64_t utc_time) +{ + if (utc_time > 0) { + LOG_INF("GPS fix: RTC sync time=%lld", utc_time); + rtc_clock.setCurrentTime((uint32_t)utc_time); + /* Defer the hardware-RTC write to the main thread — blocking I2C + * here would stall NMEA ingest (gps_fix_callback runs in the + * GNSS modem_chat worker context). */ + request_rtc_save((uint32_t)utc_time); + } + + int lat_deg = (int)lat; + int lon_deg = (int)lon; + int lat_frac = (int)((lat - lat_deg) * 1000000); + int lon_frac = (int)((lon - lon_deg) * 1000000); + if (lat_frac < 0) lat_frac = -lat_frac; + if (lon_frac < 0) lon_frac = -lon_frac; + LOG_INF("GPS fix: lat=%d.%06d lon=%d.%06d (telemetry only)", + lat_deg, lat_frac, lon_deg, lon_frac); +} + +#ifdef ZEPHCORE_LORA +mesh::ZephyrBoard zephyr_board; + +static uint16_t get_battery_mv(void) +{ + return zephyr_board.getBattMilliVolts(); +} + +/* Radio is constructed with no prefs pointer; server_main() binds it to the + * role's _prefs via setPrefs() before the role's begin(). */ + +static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); +mesh::LoRaRadio lora_radio(lora_dev, zephyr_board); + +mesh::ZephyrMillisecondClock ms_clock; +mesh::ZephyrRNG zephyr_rng; +SimpleMeshTables mesh_tables; + +/* Strong override of the weak stub in ui_mesh_actions_stubs.c: keep the prefs + * copy in step when the LEDs page toggles, so "get leds" reports what the node + * is actually doing. RAM only — a server has no deferred-save path off the UI + * thread, so a UI toggle lasts until reboot; "set leds" is what persists. */ +extern "C" void mesh_set_leds_disabled(bool disabled) +{ + if (server_role) { + server_role->prefs->leds_disabled = disabled ? 1 : 0; + } +} + +static void refresh_ui_radio_state(void) +{ + if (!server_role) { + return; + } + + ui_set_radio_params( + lora_radio.getActiveFrequencyHz(), + lora_radio.getActiveSpreadingFactor(), + lora_radio.getActiveBandwidthKHzX10(), + lora_radio.getActiveCodingRate(), + lora_radio.getConfiguredTxPower(), + lora_radio.getNoiseFloor()); + + ui_set_radio_runtime( + lora_radio.getActiveSyncWord(), + lora_radio.getActivePreambleLength(), + lora_radio.isRxDutyCycleEnabled(), + lora_radio.isRadioReady(), + lora_radio.isInRecvMode(), + lora_radio.isTxActive()); + + ui_set_radio_stats( + lora_radio.getPacketsRecv(), + lora_radio.getPacketsSent(), + lora_radio.getPacketsRecvErrors()); +} +#endif + +/* Server event loop */ +static void server_event_loop(void) +{ + LOG_INF("starting event-driven loop"); + + /* Print startup banner (no prompt - Arduino style) */ + cli_print("\r\n=== ZephCore "); + cli_print(server_role->name); + cli_print(" ===\r\n"); + + /* Arm the first maintenance wake; every pass below re-arms it. */ + arm_maintenance_wake(); + + for (;;) { + /* Wait for any mesh event - blocks until signaled */ + uint32_t events = k_event_wait(&mesh_events, MESH_EVENT_ALL, false, K_FOREVER); + k_event_clear(&mesh_events, events); + loopWakeNote(events); + + /* GPS state transitions must run on main thread (GNSS driver + * modem_chat blocks on system work queue semaphore). */ + if (events & MESH_EVENT_GPS_ACTION) { + gps_process_event(); + } + +#ifdef ZEPHCORE_LORA + /* Run queued CLI commands here (main thread) BEFORE loop() drains + * any outbound packets they enqueued — keeps all mesh-state + * mutation on the main thread (see cli_cmd_queue). */ + if (events & MESH_EVENT_CLI_RX) { + process_cli_commands(); + } + + /* Deferred boot advert — sent on the main thread (see + * initial_advert_work_fn); loop() below drains the queued packet. */ + if (server_role && (events & MESH_EVENT_INIT_ADVERT)) { + LOG_INF("Sending deferred initial advertisement"); + server_role->callbacks->sendSelfAdvertisement(500, false); + } + + /* Packet processing — only on radio/CLI/TX events */ + if (server_role && + (events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | + MESH_EVENT_CLI_RX | MESH_EVENT_TX_DRAIN | + MESH_EVENT_WAKE))) { + server_role->loop(); + } +#endif + +#ifdef ZEPHCORE_LORA + /* Radio maintenance runs OPPORTUNISTICALLY, on every pass, whatever + * woke us — not only when its own timer fired. + * + * Every item inside is deadline-gated internally, so this is nearly + * free when nothing is due. The point is what it does to a busy + * node: a hilltop repeater is already waking constantly for packets, + * so maintenance rides along on those wakes, its deadlines advance, + * and arm_maintenance_wake() below keeps pushing the timer out — the + * maintenance timer then almost never fires and costs no wakes at + * all. Running it only on its own event (as this did originally) + * inverted that: the busiest nodes, which can least afford it, paid + * the full timer cadence on top of their packet wakes, and every + * blocked sample burned a retry against a channel that is busy for + * sustained reasons (real traffic in isReceiving(), the RSSI + * prefilter) rather than the transient duty-cycle sleep window the + * retries were sized for. + * + * Ordering matters: this sits AFTER packet processing so an inbound + * frame is handled before we spend SPI time on an RSSI sweep. */ + if (server_role) { + server_role->mesh->maintenanceLoop(); + } +#endif + + /* A maintenance deadline came due — run the time-based work too. */ + if (events & MESH_EVENT_MAINTENANCE) { +#ifdef ZEPHCORE_LORA + /* Drive loop() so time-based actions (advert timers, + * tempradio set/revert, contacts flush, uplink status) + * still fire when no LoRa/CLI traffic wakes the loop. */ + if (server_role) { + server_role->loop(); + } +#endif + +#if ZEPHCORE_HAS_UI + ui_set_clock(rtc_clock.getCurrentTime()); + +#ifdef ZEPHCORE_LORA + /* Refresh live radio state (noise floor, TX power + * reduction, RX/TX mode, packet counters). Headless + * servers compile this out entirely — every ui_set_* + * below it is a weak no-op there (ui_headless_stubs.c), + * so the whole block was pure work for nothing. */ + refresh_ui_radio_state(); + + /* Battery is now refreshed lazily from ui_pages_render() with + * a 30 s freshness guard — no periodic ADC fire here. */ +#endif +#endif /* ZEPHCORE_HAS_UI */ + } + + /* Off-main RTC write request (gps_fix_callback runs in modem_chat + * context — see request_rtc_save()). Perform the blocking I2C + * write here on the main thread instead. */ + if (events & MESH_EVENT_RTC_SAVE) { + zephcore_rtc_save((uint32_t)atomic_get(&pending_rtc_epoch)); +#ifdef ZEPHCORE_LORA + /* GPS just set the clock — arm the mesh time-sync drift envelope. */ + if (server_role) { + server_role->note_gps_time_sync(); + } +#endif + } + + /* Re-arm for the soonest deadline this pass left behind. Done for + * every wake, not just maintenance ones: a CLI command, an inbound + * packet or a GPS fix can all create or clear a deadline. */ + arm_maintenance_wake(); + } +} + +int server_main(const ServerRole &role) +{ +#ifdef ZEPHCORE_LORA + /* Clear any stale bootloader magic from previous sessions. + * Prevents nRF52 boards from re-entering bootloader after reboot. */ + zephyr_board.clearBootloaderMagic(); +#endif + + /* USB CDC init up front so the host can enumerate, then wait for the + * host to open the port (DTR asserted) — event-driven via the usbd + * message callback. Unplugged → 2 s timeout, no banner; attached → + * banner reaches the user the moment the port opens. */ +#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) && \ + !IS_ENABLED(CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT) && \ + (IS_ENABLED(CONFIG_USB_CDC_ACM) || IS_ENABLED(CONFIG_USBD_CDC_ACM_CLASS)) + zephcore_usbd_init(); + zephcore_usbd_wait_dtr(2000); +#endif + LOG_INF("=== ZephCore %s starting ===", role.name); + +#if IS_ENABLED(CONFIG_ZEPHCORE_WIFI_OTA) + /* Confirm MCUboot image early — if we just booted after OTA, + * this marks the image as good so MCUboot keeps it. */ + wifi_ota_confirm_image(); +#endif + + /* Configure LEDs */ +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) + if (gpio_is_ready_dt(&led0)) { + gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE); + } +#endif +#if DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) + if (gpio_is_ready_dt(&led1)) { + gpio_pin_configure_dt(&led1, GPIO_OUTPUT_INACTIVE); + } +#endif + + /* First boot on a volume that is not this role's - a fresh chip, a + * companion, or a node that was running Arduino MeshCore, whose nRF52 + * filesystems overlap our lfs_partition + * (devdocs/HANDOVER_lfs_arduino_overlap.md). Erase everything so we + * start from a known state: Zephyr's automount only + * auto-formats the LittleFS volume when it fails to mount, and never + * touches storage_partition (BLE bonds NVS) or QSPI. + * + * Self-limiting, so it needs no "done" marker: the identity is generated + * and saved a few lines below, and loadPrefs() persists defaults on the + * same boot, so the next boot sees this role's data and skips this. */ + 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"); + } + } + + /* Initialize the server data store */ + if (!data_store.begin()) { + LOG_ERR("RepeaterDataStore init failed"); + } + + /* Initialize sensor manager */ + sensor_manager_init(); + + /* Restore wall-clock time from a battery-backed hardware RTC if present + * (shown tagged "L" until the next GPS/CLI sync; no-op if no RTC). */ + { + uint32_t rtc_epoch; + if (zephcore_rtc_restore(&rtc_epoch)) { + rtc_clock.setCurrentTime(rtc_epoch); + } + } + + /* GPS callbacks only. Duty interval and repeater mode are applied by + * apply_boot_prefs() after loadPrefs() below. */ + if (gps_is_available()) { + gps_set_fix_callback(gps_fix_callback); + gps_set_event_callback(gps_event_callback); + } + + /* Initialize UI (display + buttons). Shows splash screen, then auto- + * transitions to STATUS page. Display sleeps after auto-off timeout; + * user button is the only wake source for a server. */ + ui_init(); +#if !IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) + oled_sleep(); +#endif + + /* Log environment sensor availability */ + if (env_sensors_available()) { + LOG_INF("Environment sensors available"); + } + +#ifdef ZEPHCORE_LORA + server_role = &role; + + /* Initialize mesh event object BEFORE begin() — radio callbacks post events */ + k_event_init(&mesh_events); + + /* Set LoRa callbacks for event-driven packet processing */ + lora_radio.setRxCallback(lora_rx_callback, nullptr); + lora_radio.setTxDoneCallback(lora_tx_done_callback, nullptr); + role.mesh->setTxQueuedCallback(tx_queued_callback, nullptr); + role.mesh->setWakeCallback(wake_callback, nullptr); + + /* Load or generate identity BEFORE begin(). First-boot keygen runs + * the layered entropy mixer + Ed25519 derive + reserved-prefix + * guard inside ZephyrRNG::generateFirstBootIdentity. */ + mesh::LocalIdentity self_identity; + if (!data_store.loadIdentity(self_identity)) { + LOG_INF("No identity found, generating new keypair..."); + mesh::ZephyrRNG::generateFirstBootIdentity(self_identity); + data_store.saveIdentity(self_identity); + LOG_INF("New identity saved"); + } + role.mesh->self_id = self_identity; + + /* Log the node ID (first 8 bytes of public key) */ + LOG_INF("%s ID: %02x%02x%02x%02x%02x%02x%02x%02x...", role.name, + self_identity.pub_key[0], self_identity.pub_key[1], + self_identity.pub_key[2], self_identity.pub_key[3], + self_identity.pub_key[4], self_identity.pub_key[5], + self_identity.pub_key[6], self_identity.pub_key[7]); + + /* Load persisted prefs and bind the radio to _prefs BEFORE begin() — the + * radio reads freq/bw/sf/cr through this pointer during Mesh::begin() → + * Dispatcher::begin() → Radio::begin(). Without this, the radio would + * configure on NodePrefs defaults (869.618 MHz) regardless of saved + * settings: CLI readback looked correct but the hardware stayed on EU. + * Every role binds this way; begin() refuses to start without it. */ + data_store.loadPrefs(*role.prefs); + lora_radio.setPrefs(role.prefs); + + /* LED gate and GPS duty from prefs. After loadPrefs() and ui_init(), + * before begin() -- see boot_prefs.h. */ + apply_boot_prefs(role.prefs, true); + + /* Start mesh with data store - loads ACL, regions */ + role.begin(&data_store); + + /* Generate default node name from hardware device ID if not set. The + * role's bare prefix (its constructor default) counts as unset, and so + * does "Repeater": the store wrote that for every server role until + * 2026-09, and deployed room servers still carry it. */ + NodePrefs* prefs = role.prefs; + if (strlen(prefs->node_name) == 0 || strcmp(prefs->node_name, "Repeater") == 0 || + strcmp(prefs->node_name, role.name_prefix) == 0) { + uint8_t dev_id[8]; + ssize_t id_len = hwinfo_get_device_id(dev_id, sizeof(dev_id)); + if (id_len >= 4) { + snprintf(prefs->node_name, sizeof(prefs->node_name), + "%s-%02X%02X%02X%02X", role.name_prefix, + dev_id[0], dev_id[1], dev_id[2], dev_id[3]); + } + } + + /* Apply RX boost and duty cycle from prefs */ + lora_radio.setRxBoost(prefs->rx_boost != 0); + lora_radio.setFemRxEnable(prefs->fem_rxgain != 0); + lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0); + lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset, + prefs->probe_interval, prefs->cad_busycap, + prefs->cad_base); + /* Write back the (offset, base) actually in force. setCadParams() may + * have re-anchored the offset across a base-table change, and the pair + * has to be persisted for the NEXT upgrade to have an anchor of its own. + * Guarded so a node whose base has not moved never writes flash at boot. */ + if (prefs->cad_base != lora_radio.cadBasePeak() || + prefs->cad_offset != lora_radio.getCadOffset()) { + prefs->cad_offset = lora_radio.getCadOffset(); + prefs->cad_base = lora_radio.cadBasePeak(); + role.callbacks->savePrefs(); + } + + /* LR2021 side detectors (multi-SF RX) from prefs. extra_sf is a + * zero-terminated list; a rejected set (SF/BW changed since it was + * saved) just leaves the feature off, and non-LR2021 radios report it + * unsupported. */ + { + uint8_t n = 0; + while (n < EXTRA_SF_MAX && prefs->extra_sf[n] != 0) n++; + if (n > 0 && !lora_radio.configSideDetectors(prefs->extra_sf, n)) { + LOG_WRN("extra.sf %u SFs rejected for current SF/BW — side detectors off", n); + } + } + + /* Physical mounting orientation, from prefs. Both are immediate and + * cheap: the panel rotation is a two-byte SEGMENT_MAP/COM_SCAN remap and + * the axis swap is a flag the input callbacks read per event. A panel + * that cannot rotate logs a warning and stays in its native orientation + * rather than failing the boot. */ + zephcore_input_set_flipped(prefs->input_rotate != 0); +#if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) + if (prefs->display_rotate) { + mc_display_set_rotated(true); + } +#endif + + /* Feed initial UI state from loaded prefs */ + ui_set_node_name(prefs->node_name); + refresh_ui_radio_state(); + ui_set_battery_provider(get_battery_mv); + ui_set_battery(zephyr_board.getBattMilliVolts(), 0); + ui_set_gps_available(gps_is_available()); + + /* Defer initial advertisement by 10s — gives GPS time for a quick fix. + * Advert payload (including coords) is built when the work fires. */ + LOG_INF("Initial advertisement scheduled in 10s"); + k_work_schedule(&initial_advert_work, K_SECONDS(10)); +#endif + + /* USB CDC was initialized earlier (right after clearBootloaderMagic). + * Just acquire the device handle for the CLI's UART IRQ binding below. */ +#if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) + usb_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart); +#else + /* No CDC ACM class driver (e.g. ESP32 usb_serial, or an ESP32-S3 server + * built without esp32s3_usb.conf where the cdc_acm DT node exists but the + * class isn't compiled) — use the chosen console UART. */ + usb_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_console)); +#endif + if (device_is_ready(usb_dev)) { + LOG_INF("USB CDC device ready: %s", usb_dev->name); + ring_buf_init(&usb_ring_buf, sizeof(usb_ring_buf_data), usb_ring_buf_data); + + /* Set up UART interrupt callback */ + uart_irq_callback_set(usb_dev, cli_uart_isr); + uart_irq_rx_enable(usb_dev); + } else { + LOG_ERR("USB CDC device not ready"); + usb_dev = NULL; + } + + /* + * Event-driven architecture: main thread runs the server event loop. + * No BLE - all configuration via USB serial CLI. + */ +#ifdef ZEPHCORE_LORA + server_event_loop(); /* Never returns */ +#else + for (;;) { + k_sleep(K_FOREVER); + } +#endif + + return 0; +} diff --git a/zephcore/src/server_main_common.h b/zephcore/src/server_main_common.h new file mode 100644 index 0000000..8dc3844 --- /dev/null +++ b/zephcore/src/server_main_common.h @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: MIT + * ZephCore - shared composition root of the server roles (repeater, room server) + * + * The USB text CLI, the radio/GPS/RTC glue, the deadline-driven event loop and + * the boot sequence are the same for both roles. Each role's main file owns + * its mesh object and hands server_main() a ServerRole describing it. + */ + +#pragma once + +#include +#include +#include +#include +#include + +/* Radio + mesh includes (shared header selects LR1110 or SX126x) */ +#include + +/* What the shared code needs from a role. The function pointers exist because + * loop(), begin() and handleCommand() are not virtual on the mesh classes. */ +struct ServerRole { + const char *name; /* "Repeater", "Room Server": banner and logs */ + const char *name_prefix; /* default node name is "-" */ +#ifdef ZEPHCORE_LORA + mesh::Mesh *mesh; + CommonCLICallbacks *callbacks; /* savePrefs(), sendSelfAdvertisement() */ + NodePrefs *prefs; + void (*begin)(RepeaterDataStore *store); + void (*loop)(void); + void (*handle_command)(char *command, char *reply); + void (*note_gps_time_sync)(void); +#endif +}; + +/* Runs the role; never returns. */ +int server_main(const ServerRole &role); + +/* The hardware singletons the role's mesh object is built from. */ +extern mesh::ZephyrRTCClock rtc_clock; +#ifdef ZEPHCORE_LORA +extern mesh::ZephyrBoard zephyr_board; +extern mesh::LoRaRadio lora_radio; +extern mesh::ZephyrMillisecondClock ms_clock; +extern mesh::ZephyrRNG zephyr_rng; +extern SimpleMeshTables mesh_tables; +#endif