From f5480bddbc356135fea4341fd497d05453ea89df Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:45:43 +0200 Subject: [PATCH 01/19] Add Heltec V3 board support for the LoRa trunk Make the SX1262 board wiring build-time selectable via Kconfig (menuconfig -> Bitle hardware) instead of a fixed XIAO-only pin map, and add the Heltec WiFi LoRa 32 V3 / Wireless Stick Lite V3 wiring (pins per the MeshCore heltec_v3 variant). Heltec gates the radio behind the Vext rail, so a new CONFIG_BITLE_LORA_VEXT_PIN is driven active-high before the boot probe (default 36 on Heltec, -1 elsewhere). The XIAO Wio-SX1262 remains the default and the esp32c3 build is unchanged; the 8 MB flash size moves to sdkconfig.defaults.esp32s3 so the 4 MB C3 target keeps its layout. Validated on hardware: two Heltec ESP32-S3 V3 boards trunking at 911.5 MHz SF10 with ARQ, while serving BitChat phones over BLE. --- main/Kconfig.projbuild | 36 ++++++++++++++++++++++++++++++++++++ main/bitle_lora.c | 29 ++++++++++++++++++++++++++--- sdkconfig.defaults.esp32s3 | 1 + 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 main/Kconfig.projbuild create mode 100644 sdkconfig.defaults.esp32s3 diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild new file mode 100644 index 0000000..5172062 --- /dev/null +++ b/main/Kconfig.projbuild @@ -0,0 +1,36 @@ +menu "Bitle hardware" + + choice BITLE_LORA_BOARD + prompt "SX1262 board wiring" + depends on IDF_TARGET_ESP32S3 + default BITLE_LORA_BOARD_XIAO_WIO_SX1262 + help + Pin map for the LoRa trunk radio. The SX1262 is probed at boot + (scratch-register check), so a wrong or absent radio degrades + the node to BLE-only rather than failing. + + XIAO_WIO_SX1262: Seeed XIAO ESP32-S3 + Wio-SX1262 (B2B connector), + pins per the board's shipping Meshtastic variant. + + HELTEC_V3: Heltec WiFi LoRa 32 V3 and Wireless Stick Lite V3 + (identical radio wiring), pins per the MeshCore heltec_v3 + variant. Also enables the Vext peripheral rail by default. + + config BITLE_LORA_BOARD_XIAO_WIO_SX1262 + bool "Seeed XIAO ESP32-S3 + Wio-SX1262" + + config BITLE_LORA_BOARD_HELTEC_V3 + bool "Heltec WiFi LoRa 32 V3 / Wireless Stick Lite V3" + endchoice + + config BITLE_LORA_VEXT_PIN + int "Peripheral power gate GPIO (-1 = none)" + depends on IDF_TARGET_ESP32S3 + default 36 if BITLE_LORA_BOARD_HELTEC_V3 + default -1 + help + Some boards (Heltec V3) gate the radio behind a switchable + peripheral rail ("Vext"). GPIO number that enables the rail, + driven active-high at boot; -1 when the radio is always powered. + +endmenu diff --git a/main/bitle_lora.c b/main/bitle_lora.c index 3a0c1a9..dadd131 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -3,6 +3,7 @@ #include #include +#include "driver/gpio.h" #include "esp_log.h" #include "esp_random.h" #include "esp_timer.h" @@ -41,9 +42,21 @@ static const char *TAG = "bitle_lora"; * shared channel and our own power budget regardless of relay volume. */ #define GOV_BURST_MS 5000.0 -/* Wio-SX1262 on XIAO ESP32S3 (B2B connector), per the board's shipping - * Meshtastic variant. Other targets have no default wiring. */ -#if CONFIG_IDF_TARGET_ESP32S3 +/* Board wiring for the LoRa trunk, selected via Kconfig (menuconfig → + * Bitle hardware → SX1262 board wiring). XIAO pins per the board's shipping + * Meshtastic variant; Heltec V3 pins per the MeshCore heltec_v3 variant. + * With no wiring selected the boot probe finds no radio and the node runs + * BLE-only. */ +#if CONFIG_BITLE_LORA_BOARD_HELTEC_V3 +#define LORA_PIN_SCK 9 +#define LORA_PIN_MISO 11 +#define LORA_PIN_MOSI 10 +#define LORA_PIN_CS 8 +#define LORA_PIN_RESET 12 +#define LORA_PIN_BUSY 13 +#define LORA_PIN_DIO1 14 +#define LORA_PIN_RXEN -1 /* DIO2 keys the RF switch; no board RXEN */ +#elif CONFIG_BITLE_LORA_BOARD_XIAO_WIO_SX1262 #define LORA_PIN_SCK 7 #define LORA_PIN_MISO 8 #define LORA_PIN_MOSI 9 @@ -733,6 +746,16 @@ esp_err_t bitle_lora_init(void) } } +#if defined(CONFIG_BITLE_LORA_VEXT_PIN) && CONFIG_BITLE_LORA_VEXT_PIN >= 0 + /* Boards like the Heltec V3 gate the radio behind a switchable + * peripheral rail ("Vext"); the SX1262 is inert until Vext is on. Give + * the rail a moment to settle before the scratch-register probe below. */ + gpio_reset_pin(CONFIG_BITLE_LORA_VEXT_PIN); + gpio_set_direction(CONFIG_BITLE_LORA_VEXT_PIN, GPIO_MODE_OUTPUT); + gpio_set_level(CONFIG_BITLE_LORA_VEXT_PIN, 1); + vTaskDelay(pdMS_TO_TICKS(20)); +#endif + if (!sx1262_detect(&cfg)) { ESP_LOGI(TAG, "no SX1262 radio; running BLE-only"); return ESP_OK; diff --git a/sdkconfig.defaults.esp32s3 b/sdkconfig.defaults.esp32s3 new file mode 100644 index 0000000..88b7b69 --- /dev/null +++ b/sdkconfig.defaults.esp32s3 @@ -0,0 +1 @@ +CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y From 5206f34474efe6f3950b35f631341ee1909a9589 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:45:43 +0200 Subject: [PATCH 02/19] docs: document LoRa board selection and Heltec V3 support --- README.md | 7 +++-- docs/LoRa-boards.md | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 docs/LoRa-boards.md diff --git a/README.md b/README.md index b127a1a..ede35cc 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,14 @@ A single node runs a genuinely simultaneous **dual-role BLE stack** on NimBLE. I On ESP32-S3 nodes with a Wio-SX1262 module, Bitle runs a second radio: a 915 MHz LoRa **trunk** that carries traffic between nodes over kilometer-scale hops. BLE stays the access layer phones connect to; LoRa is backhaul only — phones have no LoRa radio and never see it. A message travels `phone → BLE → node → LoRa → node → BLE → phone`, like a cell tower's short access hop plus a long backhaul. -- **One firmware, radio auto-detected.** `bitle_lora_init()` probes for the SX1262 at boot (scratch-register check). Found → the trunk comes up; absent (every C3, a bare S3) → the node runs BLE-only. Pin map and TCXO/RF-switch config match the Seeed Wio-SX1262 on the XIAO ESP32-S3. +- **One firmware, radio auto-detected.** `bitle_lora_init()` probes for the SX1262 at boot (scratch-register check). Found → the trunk comes up; absent (every C3, a bare S3) → the node runs BLE-only. Board wiring (pin map, Vext power gate) is selected at build time via Kconfig: Seeed XIAO ESP32-S3 + Wio-SX1262 (default) and Heltec WiFi LoRa 32 V3 / Wireless Stick Lite V3 are supported — see [docs/LoRa-boards.md](docs/LoRa-boards.md). - **Trunk framing + reassembly.** Encoded BitChat packets are fragmented over ≤255-byte LoRa frames under a 16-byte trunk header (`0xB7 0x1E` magic, version, ftype, src/dst tag, seq, frag idx/total). Foreign LoRa traffic fails the magic check and is dropped. The receiver reassembles by `(src, seq)` and injects the packet into the same mesh core the BLE path uses. - **Per-frame ARQ.** Every non-announce frame is acknowledged and retransmitted up to three times; acks jump the queue and go out-of-band so a bidirectional exchange can't deadlock. Announces are ack-free periodic discovery beacons. - **Padding strip.** Phones pad handshakes/DMs to 256 bytes for BLE traffic-analysis resistance — pure airtime waste over the trunk. Because the BitChat packet is self-describing and padding trails the signature, the trunk trims to the true length before fragmenting (never touching signed/encrypted bytes). A 256-byte handshake message becomes one LoRa frame instead of three, which is what keeps a Noise first-contact handshake inside BitChat's timeout at high spreading factors. - **Admission + airtime governor.** The trunk carries relayed mesh traffic and the node's own discovery beacon; the node's own session-init and gossip `requestSync` are suppressed for broadcast links (a LoRa neighbor is a relay peer, not a chat/sync endpoint). Announces are throttled per origin (1 / 30 s) and pass a token-bucket airtime governor (default 25 % duty); message traffic is never throttled but still debits, so a busy DM session naturally quiets the beacons rather than the reverse. OTA image chunks stay off the trunk unless explicitly enabled (NVS `lora/ota_trunk`). - **Spreading factor and range.** Default **SF10 / BW125 / +22 dBm** — field-validated to complete an interactive DM (Noise handshake, auto-reply, receipts) through heavy non-line-of-sight (~700 ft / 30–40 walls). SF10 reaches ~2–3 km NLOS / ~8 km LOS; higher SF extends range at the cost of airtime (and handshake latency), lower SF is faster/shorter. Configurable via NVS `lora/sf`, `lora/freq`, `lora/duty_pct`, `lora/enabled`. -Radio: **Semtech SX1262** (Seeed Wio-SX1262 + XIAO ESP32-S3), +22 dBm, 902–928 MHz, 125 kHz bandwidth, 2 dBi SMA antenna. +Radio: **Semtech SX1262** (Seeed Wio-SX1262 + XIAO ESP32-S3, or Heltec V3 boards), +22 dBm, 902–928 MHz, 125 kHz bandwidth, 2 dBi SMA antenna. ## Boot sequence @@ -104,6 +104,7 @@ Because OTA relies on this dual-slot layout, **nodes must be wire-flashed with t - **Bitle node (reference)** — Seeed Studio XIAO ESP32C3 with a 2.4 GHz antenna, solar charger, battery, and panel; the full parts list is at [bitle.org](https://bitle.org). - **Bitle-LR node (LoRa platform)** — the [Seeed Studio XIAO ESP32S3 & Wio-SX1262 kit](https://www.seeedstudio.com/Wio-SX1262-with-XIAO-ESP32S3-p-5982.html), also sold [with a 3D case, SMA antenna, and cable](https://www.seeedstudio.com/XIAO-ESP32S3-for-Meshtastic-LoRa-with-3D-Printed-Enclosure-p-6314.html). It ships pre-flashed with Meshtastic — run `idf.py erase-flash` before flashing Bitle. It runs the standard firmware as a full BLE relay node **and** brings up the SX1262 LoRa trunk between nodes (see [LoRa backhaul](#lora-backhaul)). Power parts (charger, battery, panel) are shared with the reference build. +- **Bitle-LR node (Heltec V3)** — Heltec WiFi LoRa 32 V3 or Wireless Stick Lite V3 (ESP32-S3 + SX1262; both models share the same radio wiring). Select `CONFIG_BITLE_LORA_BOARD_HELTEC_V3=y` before building — the Vext peripheral rail is powered automatically; details in [docs/LoRa-boards.md](docs/LoRa-boards.md). ## Building & flashing @@ -126,7 +127,7 @@ idf.py build idf.py -p /dev/cu.usbmodem101 flash monitor ``` -Adjust the serial port (`-p`) for your setup. `sdkconfig` is generated from `sdkconfig.defaults` on the first build. +Adjust the serial port (`-p`) for your setup. `sdkconfig` is generated from `sdkconfig.defaults` on the first build; for `esp32s3`, `sdkconfig.defaults.esp32s3` layers on top (8 MB flash). On `esp32s3` the LoRa board wiring defaults to the Seeed XIAO + Wio-SX1262 — for Heltec V3 boards, select it via `idf.py menuconfig` → **Bitle hardware** → **SX1262 board wiring** before the first build (see [docs/LoRa-boards.md](docs/LoRa-boards.md)). ## Firmware updates & owner key diff --git a/docs/LoRa-boards.md b/docs/LoRa-boards.md new file mode 100644 index 0000000..6b71449 --- /dev/null +++ b/docs/LoRa-boards.md @@ -0,0 +1,70 @@ +# LoRa board wiring + +The LoRa trunk (see [README](../README.md#lora-backhaul)) runs on ESP32-S3 +boards with a Semtech SX1262 radio. The radio architecture is identical +across supported boards — TCXO on DIO3 at 1.8 V, DIO2 as the TX/RX RF +switch — so only the pin map and the power gate differ. Both are selected +at build time via Kconfig; no source edits are needed. + +The SX1262 is probed at boot (scratch-register check). If the selected +wiring doesn't match the hardware, the probe simply finds no radio and the +node runs BLE-only — a misconfigured board cannot crash the firmware. + +## Supported boards + +| | Seeed XIAO ESP32-S3 + Wio-SX1262 (default) | Heltec WiFi LoRa 32 V3 / Wireless Stick Lite V3 | +|---|---|---| +| Kconfig symbol | `CONFIG_BITLE_LORA_BOARD_XIAO_WIO_SX1262` | `CONFIG_BITLE_LORA_BOARD_HELTEC_V3` | +| SCK | 7 | 9 | +| MISO | 8 | 11 | +| MOSI | 9 | 10 | +| CS/NSS | 41 | 8 | +| RESET | 42 | 12 | +| BUSY | 40 | 13 | +| DIO1 | 39 | 14 | +| RXEN | 38 | — (DIO2 switches RX/TX) | +| Vext power gate | — | 36 (active-high, powered at boot) | + +Heltec pinout source: the MeshCore `heltec_v3` board variant. Both Heltec +V3 models share the same radio wiring; the WiFi LoRa 32's OLED is unused. + +**Vext.** Heltec boards gate the radio (and OLED) behind a switchable +peripheral rail. The SX1262 is inert — no SPI response — until Vext is on. +`CONFIG_BITLE_LORA_VEXT_PIN` (default 36 for Heltec, -1 otherwise) is +driven active-high at boot before the radio probe, with a 20 ms settle +delay. + +## Switching boards + +With menuconfig (interactive): + +```bash +idf.py set-target esp32s3 +idf.py menuconfig # → Bitle hardware → SX1262 board wiring → Heltec V3 +idf.py build flash +``` + +Non-interactively, append to `sdkconfig.defaults` (or +`sdkconfig.defaults.esp32s3`) **before** the first build: + +```kconfig +CONFIG_BITLE_LORA_BOARD_HELTEC_V3=y +``` + +On an already-configured build tree, `sdkconfig` takes precedence: set the +symbol there (and clear `CONFIG_BITLE_LORA_BOARD_XIAO_WIO_SX1262`), then +`idf.py reconfigure build`. + +## Porting a new board + +1. Add a `config BITLE_LORA_BOARD_` entry to the choice in + `main/Kconfig.projbuild`. If the board gates radio power, also add a + `default if BITLE_LORA_BOARD_` line to + `CONFIG_BITLE_LORA_VEXT_PIN` (active-high; extend the code if a board + needs active-low). +2. Add the matching `#elif CONFIG_BITLE_LORA_BOARD_` pin block in + `main/bitle_lora.c`. Set `LORA_PIN_RXEN` to -1 when the board has no + GPIO-driven RX-enable (DIO2-as-RF-switch designs). +3. Verify: build, flash, and check the boot log for + `bitle_lora: trunk up: ...` (success) vs `no SX1262 radio; running + BLE-only` (wrong pins or power gate). From 154d145b3235241ebb04418bbefd28176514fff1 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:25:37 +0200 Subject: [PATCH 03/19] Add SSD1306 status display with portable renderer and host simulator Auto-detected 128x64 OLED (I2C 0x3C, Heltec WiFi LoRa 32 V3 built-in): boot splash, then a dashboard with node nickname, live BLE link count, discovered-peer count, trunk state and counters, plus a three-lane scrolling activity tape (BLE rx / trunk / forwarded) with age-decaying blips and an idle sweep. Auto-off after CONFIG_BITLE_DISPLAY_ON_SECS (default 10s); the user button (GPIO0/PRG, active low) wakes it again, mirroring MeshCore's power-saving behavior. Rendering lives in bitle_screen.c, free of ESP-IDF dependencies, so the host simulator tools/screen_sim.c draws byte-identical frames to PNG for visual verification. A new bitle_stats module feeds it: an event ring fed by the mesh relay/LoRa paths and a seen-peer table of unique announce senders. Getters added: bitchat_ble_link_count(), noise_get_nickname(), noise_verified_peer_count(), bitle_link_type_of(). Transport lessons validated on hardware: the whole frame must go out in one I2C transaction (STOP-separated chunks tear the image on clone controllers), contrast at max (the charge pump sags with many lit pixels), and a unit that NACKs frames persistently is disabled cleanly until reboot. Kconfig: BITLE_DISPLAY (default on for Heltec V3) with SDA/SCL/RST/button pins and wake time; the OLED shares the Vext rail and powers it even when the trunk is NVS-disabled. Absent display degrades to headless, same philosophy as the radio probe. --- .gitignore | 5 + main/CMakeLists.txt | 5 +- main/Kconfig.projbuild | 36 ++++++ main/bitchat_ble.c | 13 +++ main/bitchat_ble.h | 3 + main/bitle_display.c | 237 ++++++++++++++++++++++++++++++++++++++ main/bitle_display.h | 10 ++ main/bitle_font.h | 89 +++++++++++++++ main/bitle_link.c | 12 ++ main/bitle_link.h | 3 + main/bitle_lora.c | 2 + main/bitle_mesh.c | 11 ++ main/bitle_screen.c | 254 +++++++++++++++++++++++++++++++++++++++++ main/bitle_screen.h | 42 +++++++ main/bitle_stats.c | 124 ++++++++++++++++++++ main/bitle_stats.h | 40 +++++++ main/main.c | 7 ++ main/noise_handshake.c | 19 ++- main/noise_handshake.h | 6 + tools/boot.png | Bin 0 -> 703 bytes tools/screen_sim.c | 220 +++++++++++++++++++++++++++++++++++ 21 files changed, 1135 insertions(+), 3 deletions(-) create mode 100644 main/bitle_display.c create mode 100644 main/bitle_display.h create mode 100644 main/bitle_font.h create mode 100644 main/bitle_screen.c create mode 100644 main/bitle_screen.h create mode 100644 main/bitle_stats.c create mode 100644 main/bitle_stats.h create mode 100644 tools/boot.png create mode 100644 tools/screen_sim.c diff --git a/.gitignore b/.gitignore index e79ffd2..ad145f4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,11 @@ __pycache__/ # Logs and scratch *.log +# Display simulator outputs +tools/screen_sim +tools/frame_*.png +tools/font.png + # OTA: signed manifests are release artifacts; the owner private key must # never be committed (it lives outside the repo). *.bota diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 3097755..7daf894 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -14,8 +14,11 @@ idf_component_register( "bitle_store.c" "bitle_courier.c" "bitle_sync.c" + "bitle_stats.c" + "bitle_screen.c" + "bitle_display.c" INCLUDE_DIRS "." - REQUIRES bt nvs_flash noise_ref mbedtls bitchat_utils app_update esp_partition esp_driver_gpio esp_driver_spi + REQUIRES bt nvs_flash noise_ref mbedtls bitchat_utils app_update esp_partition esp_driver_gpio esp_driver_spi esp_driver_i2c ) target_include_directories(${COMPONENT_LIB} PRIVATE diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 5172062..e2a23d8 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -33,4 +33,40 @@ menu "Bitle hardware" peripheral rail ("Vext"). GPIO number that enables the rail, driven active-high at boot; -1 when the radio is always powered. + config BITLE_DISPLAY + bool "SSD1306 status display (auto-detected)" + depends on IDF_TARGET_ESP32S3 + default y if BITLE_LORA_BOARD_HELTEC_V3 + default n + help + 128x64 OLED showing nickname, link/peer counts and a live + activity tape. Probed on I2C at boot; with no display present + the node runs headless. The OLED shares the Vext rail with the + radio on Heltec boards and powers it the same way. + + config BITLE_DISPLAY_PIN_SDA + int "Display I2C SDA GPIO" + depends on BITLE_DISPLAY + default 17 + + config BITLE_DISPLAY_PIN_SCL + int "Display I2C SCL GPIO" + depends on BITLE_DISPLAY + default 18 + + config BITLE_DISPLAY_PIN_RST + int "Display reset GPIO (-1 = none)" + depends on BITLE_DISPLAY + default 21 + + config BITLE_DISPLAY_BUTTON_PIN + int "User/wake button GPIO, active low (-1 = none)" + depends on BITLE_DISPLAY + default 0 + + config BITLE_DISPLAY_ON_SECS + int "Display wake time after boot/button press (seconds)" + depends on BITLE_DISPLAY + default 10 + endmenu diff --git a/main/bitchat_ble.c b/main/bitchat_ble.c index c3119e8..6f158cb 100644 --- a/main/bitchat_ble.c +++ b/main/bitchat_ble.c @@ -685,6 +685,19 @@ bool bitchat_ble_conn_is_central(uint16_t conn_handle) return state && state->is_central; } +int bitchat_ble_link_count(void) +{ + int count = 0; + portENTER_CRITICAL(&s_conn_mux); + for (size_t i = 0; i < BITLE_BLE_MAX_CONNECTIONS; ++i) { + if (s_connections[i].in_use) { + count++; + } + } + portEXIT_CRITICAL(&s_conn_mux); + return count; +} + void bitchat_ble_disconnect(uint16_t conn_handle) { ble_conn_state_t *state = find_conn(conn_handle); diff --git a/main/bitchat_ble.h b/main/bitchat_ble.h index bab3ef3..5aacb3e 100644 --- a/main/bitchat_ble.h +++ b/main/bitchat_ble.h @@ -19,6 +19,9 @@ esp_err_t bitchat_ble_start(void); void bitchat_ble_poll(void); esp_err_t bitchat_ble_send(uint16_t conn_handle, const uint8_t *data, size_t len); bool bitchat_ble_conn_is_central(uint16_t conn_handle); + +/* Number of live BLE connections (phones plus node-to-node links). */ +int bitchat_ble_link_count(void); void bitchat_ble_disconnect(uint16_t conn_handle); #ifdef __cplusplus diff --git a/main/bitle_display.c b/main/bitle_display.c new file mode 100644 index 0000000..4cc1591 --- /dev/null +++ b/main/bitle_display.c @@ -0,0 +1,237 @@ +/* SSD1306 128x64 status display (Heltec WiFi LoRa 32 V3 built-in OLED). + * Auto-detected on I2C like the radio is probed: absent display -> the node + * runs headless. All drawing happens in the portable bitle_screen renderer; + * this file is only I2C transport plus the refresh task. */ + +#include "bitle_display.h" + +#include + +#include "driver/gpio.h" +#include "driver/i2c_master.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "bitle_lora.h" +#include "bitle_screen.h" +#include "bitle_stats.h" +#include "bitchat_ble.h" +#include "noise_handshake.h" + +#if CONFIG_BITLE_DISPLAY + +static const char *TAG = "bitle_display"; + +#define SSD1306_ADDR 0x3C +#define CTRL_CMD 0x00 +#define CTRL_DATA 0x40 +#define REFRESH_MS 125 +#define I2C_FREQ_HZ 200000 +#define WAKE_MS (CONFIG_BITLE_DISPLAY_ON_SECS * 1000U) +#define BOOT_SCREEN_MS 2500 + +static i2c_master_dev_handle_t s_dev; +static uint8_t s_fb[BITLE_SCREEN_FB_BYTES]; +static bool s_active; + +static esp_err_t ssd1306_cmds(const uint8_t *cmds, size_t len) +{ + uint8_t buf[32]; + if (len + 1 > sizeof(buf)) { + return ESP_ERR_INVALID_SIZE; + } + buf[0] = CTRL_CMD; + memcpy(buf + 1, cmds, len); + return i2c_master_transmit(s_dev, buf, len + 1, 100); +} + +static esp_err_t ssd1306_init_seq(void) +{ + static const uint8_t seq[] = { + 0xAE, /* display off */ + 0xD5, 0x80, /* clock divide */ + 0xA8, 0x3F, /* multiplex 64 */ + 0xD3, 0x00, /* display offset */ + 0x40, /* start line 0 */ + 0x8D, 0x14, /* charge pump on */ + 0x20, 0x00, /* horizontal addressing */ + 0xA1, /* segment remap (flip X) */ + 0xC8, /* COM scan descending (flip Y) */ + 0xDA, 0x12, /* COM pins config */ + 0x81, 0xFF, /* contrast: max (charge pump sags with many lit pixels) */ + 0xD9, 0xF1, /* pre-charge */ + 0xDB, 0x40, /* VCOMH level */ + 0xA4, /* display from RAM */ + 0xA6, /* normal (non-inverted) */ + 0xAF, /* display on */ + }; + return ssd1306_cmds(seq, sizeof(seq)); +} + +static esp_err_t ssd1306_push(const uint8_t *fb) +{ + static const uint8_t win[] = { 0x21, 0, 127, 0x22, 0, 7 }; + esp_err_t err = ssd1306_cmds(win, sizeof(win)); + if (err != ESP_OK) { + return err; + } + /* The whole frame in ONE transaction. Chunked writes with a STOP after + * each chunk visibly tear on these modules (the GDDRAM pointer is not + * reliably preserved across STOP/START on clone controllers), which is + * why every working library (Adafruit/u8g2) blasts 1025 bytes at once. */ + static uint8_t buf[BITLE_SCREEN_FB_BYTES + 1]; + buf[0] = CTRL_DATA; + memcpy(buf + 1, fb, BITLE_SCREEN_FB_BYTES); + return i2c_master_transmit(s_dev, buf, sizeof(buf), 100); +} + +static void display_task(void *arg) +{ + (void)arg; + int consecutive_failures = 0; + bool on = true; + bool btn_was_down = false; + uint64_t boot_until = esp_timer_get_time() / 1000ULL + BOOT_SCREEN_MS; + uint64_t off_at = esp_timer_get_time() / 1000ULL + WAKE_MS; + for (;;) { + uint64_t now = esp_timer_get_time() / 1000ULL; + +#if CONFIG_BITLE_DISPLAY_BUTTON_PIN >= 0 + /* User button (active low, two samples = debounce): wakes the + * display and re-arms the auto-off timer. */ + bool btn_down = gpio_get_level(CONFIG_BITLE_DISPLAY_BUTTON_PIN) == 0; + if (btn_down && !btn_was_down) { + if (!on) { + static const uint8_t disp_on[] = { 0xAF }; + if (ssd1306_cmds(disp_on, sizeof(disp_on)) == ESP_OK) { + on = true; + consecutive_failures = 0; + } + } + off_at = now + WAKE_MS; + } + btn_was_down = btn_down; +#endif + + if (on) { + bitle_stats_t st; + bitle_stats_get(&st); + + bitle_screen_model_t m = { 0 }; + const char *nick = noise_get_nickname(); + if (nick) { + snprintf(m.nickname, sizeof(m.nickname), "%s", nick); + } + m.ble_links = bitchat_ble_link_count(); + m.peers_seen = st.peers_seen; + m.trunk_up = bitle_lora_active(); + m.rx_total = st.rx_total; + m.tx_total = st.tx_total; + m.now_ms = (uint32_t)now; + m.event_count = st.event_count; + memcpy(m.events, st.events, sizeof(st.events)); + + if (now < boot_until) { + bitle_screen_render_boot(s_fb, &m); + } else { + bitle_screen_render(s_fb, &m); + } + esp_err_t err = ssd1306_push(s_fb); + if (err != ESP_OK) { + /* A display that NACKs mid-frame has a marginal rail or + * wiring (seen on one field unit). Don't spam the bus + * forever: give up until the next reboot. */ + if (++consecutive_failures >= 25) { + ESP_LOGE(TAG, "display not responding; disabling until reboot"); + vTaskDelete(NULL); + } + } else { + consecutive_failures = 0; + } + + if (now >= off_at) { + static const uint8_t disp_off[] = { 0xAE }; + if (ssd1306_cmds(disp_off, sizeof(disp_off)) == ESP_OK) { + on = false; + } + } + } + vTaskDelay(pdMS_TO_TICKS(REFRESH_MS)); + } +} + +esp_err_t bitle_display_init(void) +{ +#if defined(CONFIG_BITLE_LORA_VEXT_PIN) && CONFIG_BITLE_LORA_VEXT_PIN >= 0 + /* The OLED shares the Vext rail with the radio on Heltec boards; power + * it here too so the display works even with the trunk NVS-disabled. + * Idempotent when the LoRa init already drove it. */ + gpio_reset_pin(CONFIG_BITLE_LORA_VEXT_PIN); + gpio_set_direction(CONFIG_BITLE_LORA_VEXT_PIN, GPIO_MODE_OUTPUT); + gpio_set_level(CONFIG_BITLE_LORA_VEXT_PIN, 1); + vTaskDelay(pdMS_TO_TICKS(20)); +#endif + +#if CONFIG_BITLE_DISPLAY_PIN_RST >= 0 + gpio_reset_pin(CONFIG_BITLE_DISPLAY_PIN_RST); + gpio_set_direction(CONFIG_BITLE_DISPLAY_PIN_RST, GPIO_MODE_OUTPUT); + gpio_set_level(CONFIG_BITLE_DISPLAY_PIN_RST, 0); + vTaskDelay(pdMS_TO_TICKS(5)); + gpio_set_level(CONFIG_BITLE_DISPLAY_PIN_RST, 1); + vTaskDelay(pdMS_TO_TICKS(5)); +#endif + + i2c_master_bus_config_t bus_cfg = { + .i2c_port = I2C_NUM_0, + .sda_io_num = CONFIG_BITLE_DISPLAY_PIN_SDA, + .scl_io_num = CONFIG_BITLE_DISPLAY_PIN_SCL, + .clk_source = I2C_CLK_SRC_DEFAULT, + .glitch_ignore_cnt = 7, + .flags.enable_internal_pullup = true, + }; + i2c_master_bus_handle_t bus; + esp_err_t err = i2c_new_master_bus(&bus_cfg, &bus); + if (err != ESP_OK) { + ESP_LOGW(TAG, "I2C bus init failed: %s", esp_err_to_name(err)); + return err; + } + err = i2c_master_probe(bus, SSD1306_ADDR, 100); + if (err != ESP_OK) { + ESP_LOGI(TAG, "no SSD1306 display; running headless"); + i2c_del_master_bus(bus); + return ESP_OK; + } + i2c_device_config_t dev_cfg = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = SSD1306_ADDR, + .scl_speed_hz = I2C_FREQ_HZ, + }; + err = i2c_master_bus_add_device(bus, &dev_cfg, &s_dev); + if (err == ESP_OK) { + err = ssd1306_init_seq(); + } + if (err != ESP_OK) { + ESP_LOGW(TAG, "display init failed: %s", esp_err_to_name(err)); + return err; + } + s_active = true; +#if CONFIG_BITLE_DISPLAY_BUTTON_PIN >= 0 + gpio_reset_pin(CONFIG_BITLE_DISPLAY_BUTTON_PIN); + gpio_set_direction(CONFIG_BITLE_DISPLAY_BUTTON_PIN, GPIO_MODE_INPUT); + gpio_set_pull_mode(CONFIG_BITLE_DISPLAY_BUTTON_PIN, GPIO_PULLUP_ONLY); +#endif + xTaskCreate(display_task, "bitle_disp", 4096, NULL, tskIDLE_PRIORITY + 2, NULL); + ESP_LOGI(TAG, "display up: SSD1306 128x64 @0x%02X", SSD1306_ADDR); + return ESP_OK; +} + +#else /* !CONFIG_BITLE_DISPLAY */ + +esp_err_t bitle_display_init(void) +{ + return ESP_OK; +} + +#endif diff --git a/main/bitle_display.h b/main/bitle_display.h new file mode 100644 index 0000000..124500b --- /dev/null +++ b/main/bitle_display.h @@ -0,0 +1,10 @@ +#ifndef BITLE_DISPLAY_H +#define BITLE_DISPLAY_H + +#include "esp_err.h" + +/* Probes for the SSD1306 and starts the refresh task if found. Always + * returns ESP_OK when the display is absent (headless node). */ +esp_err_t bitle_display_init(void); + +#endif /* BITLE_DISPLAY_H */ diff --git a/main/bitle_font.h b/main/bitle_font.h new file mode 100644 index 0000000..a73f35b --- /dev/null +++ b/main/bitle_font.h @@ -0,0 +1,89 @@ +/* 5x7 uppercase bitmap font for the status display. Column-major: each + * glyph is 5 bytes, bit0 = top pixel. Glyphs cover A-Z, 0-9 and a small + * symbol set; anything unmapped renders as a filled box. Hand-drawn for + * Bitle (public domain). */ + +#ifndef BITLE_FONT_H_INCLUDED +#define BITLE_FONT_H_INCLUDED + +#include + +#define BITLE_FONT_W 5 +#define BITLE_FONT_H 7 + +typedef struct { + char ch; + uint8_t col[BITLE_FONT_W]; +} bitle_font_glyph_t; + +static const bitle_font_glyph_t BITLE_FONT[] = { + { ' ', { 0x00, 0x00, 0x00, 0x00, 0x00 } }, + { '0', { 0x3E, 0x51, 0x49, 0x45, 0x3E } }, + { '1', { 0x00, 0x42, 0x7F, 0x40, 0x00 } }, + { '2', { 0x42, 0x61, 0x51, 0x49, 0x46 } }, + { '3', { 0x21, 0x41, 0x45, 0x4B, 0x31 } }, + { '4', { 0x18, 0x14, 0x12, 0x7F, 0x10 } }, + { '5', { 0x27, 0x45, 0x45, 0x45, 0x39 } }, + { '6', { 0x3C, 0x4A, 0x49, 0x49, 0x30 } }, + { '7', { 0x01, 0x71, 0x09, 0x05, 0x03 } }, + { '8', { 0x36, 0x49, 0x49, 0x49, 0x36 } }, + { '9', { 0x06, 0x49, 0x49, 0x29, 0x1E } }, + { 'A', { 0x7E, 0x11, 0x11, 0x11, 0x7E } }, + { 'B', { 0x7F, 0x49, 0x49, 0x49, 0x36 } }, + { 'C', { 0x3E, 0x41, 0x41, 0x41, 0x22 } }, + { 'D', { 0x7F, 0x41, 0x41, 0x22, 0x1C } }, + { 'E', { 0x7F, 0x49, 0x49, 0x49, 0x41 } }, + { 'F', { 0x7F, 0x09, 0x09, 0x09, 0x01 } }, + { 'G', { 0x3E, 0x41, 0x49, 0x49, 0x7A } }, + { 'H', { 0x7F, 0x08, 0x08, 0x08, 0x7F } }, + { 'I', { 0x00, 0x41, 0x7F, 0x41, 0x00 } }, + { 'J', { 0x20, 0x40, 0x41, 0x3F, 0x01 } }, + { 'K', { 0x7F, 0x08, 0x14, 0x22, 0x41 } }, + { 'L', { 0x7F, 0x40, 0x40, 0x40, 0x40 } }, + { 'M', { 0x7F, 0x02, 0x0C, 0x02, 0x7F } }, + { 'N', { 0x7F, 0x04, 0x08, 0x10, 0x7F } }, + { 'O', { 0x3E, 0x41, 0x41, 0x41, 0x3E } }, + { 'P', { 0x7F, 0x09, 0x09, 0x09, 0x06 } }, + { 'Q', { 0x3E, 0x41, 0x51, 0x21, 0x5E } }, + { 'R', { 0x7F, 0x09, 0x19, 0x29, 0x46 } }, + { 'S', { 0x26, 0x49, 0x49, 0x49, 0x32 } }, + { 'T', { 0x01, 0x01, 0x7F, 0x01, 0x01 } }, + { 'U', { 0x3F, 0x40, 0x40, 0x40, 0x3F } }, + { 'V', { 0x1F, 0x20, 0x40, 0x20, 0x1F } }, + { 'W', { 0x3F, 0x40, 0x38, 0x40, 0x3F } }, + { 'X', { 0x63, 0x14, 0x08, 0x14, 0x63 } }, + { 'Y', { 0x07, 0x08, 0x70, 0x08, 0x07 } }, + { 'Z', { 0x61, 0x51, 0x49, 0x45, 0x43 } }, + { '-', { 0x08, 0x08, 0x08, 0x08, 0x08 } }, + { ':', { 0x00, 0x36, 0x36, 0x00, 0x00 } }, + { '.', { 0x00, 0x60, 0x60, 0x00, 0x00 } }, + { '/', { 0x20, 0x10, 0x08, 0x04, 0x02 } }, + { '+', { 0x08, 0x08, 0x3E, 0x08, 0x08 } }, + { '%', { 0x23, 0x13, 0x08, 0x64, 0x62 } }, + { '>', { 0x41, 0x22, 0x14, 0x08, 0x00 } }, + { '<', { 0x08, 0x14, 0x22, 0x41, 0x00 } }, + { '*', { 0x14, 0x08, 0x3E, 0x08, 0x14 } }, + { '_', { 0x40, 0x40, 0x40, 0x40, 0x40 } }, + { '=', { 0x14, 0x14, 0x14, 0x14, 0x14 } }, + { '!', { 0x00, 0x06, 0x5F, 0x06, 0x00 } }, + { '?', { 0x02, 0x01, 0x51, 0x09, 0x06 } }, +}; + +#define BITLE_FONT_GLYPH_COUNT (sizeof(BITLE_FONT) / sizeof(BITLE_FONT[0])) + +static inline const uint8_t *bitle_font_glyph(char ch) +{ + /* Fold lowercase to uppercase: the display charset is mono-case. */ + if (ch >= 'a' && ch <= 'z') { + ch -= ('a' - 'A'); + } + for (unsigned i = 0; i < BITLE_FONT_GLYPH_COUNT; ++i) { + if (BITLE_FONT[i].ch == ch) { + return BITLE_FONT[i].col; + } + } + static const uint8_t unknown[BITLE_FONT_W] = { 0x7F, 0x41, 0x41, 0x41, 0x7F }; + return unknown; +} + +#endif /* BITLE_FONT_H_INCLUDED */ diff --git a/main/bitle_link.c b/main/bitle_link.c index 4860691..876b443 100644 --- a/main/bitle_link.c +++ b/main/bitle_link.c @@ -81,6 +81,18 @@ bool bitle_link_ready(uint16_t handle) return ready; } +int bitle_link_type_of(uint16_t handle) +{ + int type = -1; + xSemaphoreTake(s_lock, portMAX_DELAY); + link_entry_t *e = find_locked(handle); + if (e) { + type = e->type; + } + xSemaphoreGive(s_lock); + return type; +} + esp_err_t bitle_link_send(uint16_t handle, const uint8_t *data, uint16_t len) { xSemaphoreTake(s_lock, portMAX_DELAY); diff --git a/main/bitle_link.h b/main/bitle_link.h index 1689853..1e496d8 100644 --- a/main/bitle_link.h +++ b/main/bitle_link.h @@ -49,6 +49,9 @@ esp_err_t bitle_link_register(uint16_t handle, bitle_link_type_t type, bitle_lin void bitle_link_unregister(uint16_t handle); bool bitle_link_ready(uint16_t handle); +/* Link type for a registered handle, or -1 when unknown. */ +int bitle_link_type_of(uint16_t handle); + esp_err_t bitle_link_send(uint16_t handle, const uint8_t *data, uint16_t len); /* Sends to every registered link except exclude_handle (BITLE_LINK_NONE to diff --git a/main/bitle_lora.c b/main/bitle_lora.c index dadd131..1e3bfe5 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -15,6 +15,7 @@ #include "bitchat_ble.h" #include "bitle_link.h" #include "bitle_mesh.h" +#include "bitle_stats.h" #include "noise_handshake.h" #include "packet_codec.h" #include "sx1262.h" @@ -340,6 +341,7 @@ static int lora_link_send(uint16_t handle, const uint8_t *data, uint16_t len) type == BITCHAT_MSG_NOISE_IDENTITY_ANNOUNCE); uint8_t total = (uint8_t)((len + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX); ESP_LOGI(TAG, "trunk TX type=0x%02X len=%u frags=%u arq=%d", type, len, total, want_ack); + bitle_stats_note_activity(BITLE_LANE_TRUNK, 1); /* Atomic across concurrent callers (NimBLE host, noise worker, lora_task * beacon): a duplicated seq would collide two packets into one remote * reassembly slot and corrupt both. Unique seq is sufficient — the diff --git a/main/bitle_mesh.c b/main/bitle_mesh.c index 893f151..df875c5 100644 --- a/main/bitle_mesh.c +++ b/main/bitle_mesh.c @@ -11,6 +11,7 @@ #include "bitchat_time.h" #include "bitle_link.h" #include "bitle_ota.h" +#include "bitle_stats.h" #include "bitle_sync.h" #include "noise_handshake.h" #include "packet_codec.h" @@ -290,6 +291,8 @@ static void relay_packet(uint16_t src_link, uint8_t *buffer, uint16_t len, const int forwarded = bitle_link_broadcast(src_link, buffer, len); if (forwarded > 0) { + bitle_stats_note_tx(); + bitle_stats_note_activity(BITLE_LANE_FWD, 1); ESP_LOGI(TAG, "Relayed type=0x%02X ttl=%u to %d link(s)", packet->type, buffer[2], forwarded); } } @@ -320,6 +323,14 @@ bool bitle_mesh_inbound(uint16_t link_handle, uint8_t *buffer, uint16_t len) bitchat_packet_free(&packet); return true; } + /* Display bookkeeping: unique arrivals by link, discovered announce + * senders (direct and relayed alike). */ + bitle_stats_note_rx(); + bitle_stats_note_activity( + bitle_link_type_of(link_handle) == BITLE_LINK_LORA ? BITLE_LANE_TRUNK : BITLE_LANE_BLE, 0); + if (packet.type == BITCHAT_MSG_ANNOUNCE) { + bitle_stats_note_peer(packet.sender_id); + } dispatch_packet(link_handle, &packet); /* Dead-drop: keep recent signed public packets so passing phones can * sync from us later (the module filters types and enforces budgets). */ diff --git a/main/bitle_screen.c b/main/bitle_screen.c new file mode 100644 index 0000000..c0cdffd --- /dev/null +++ b/main/bitle_screen.c @@ -0,0 +1,254 @@ +/* Status screen renderer for the SSD1306 128x64 OLED. Portable: no ESP-IDF + * dependencies — the same translation unit drives the firmware display task + * and the host-side simulator (tools/screen_sim.c), so what the simulator + * draws is byte-identical to what the device shows. + * + * Framebuffer layout matches the SSD1306 GDDRAM: 8 pages x 128 columns, + * each byte a vertical strip of 8 pixels, bit0 = topmost pixel. */ + +#include "bitle_screen.h" + +#include + +#include "bitle_font.h" + +/* Vertical layout */ +#define TITLE_Y 0 /* 2x nickname, 16 px tall */ +#define STATS_Y 18 /* link / seen line */ +#define TRUNK_Y 27 /* trunk state + counters */ +#define TAPE_Y 37 /* activity tape top */ +#define LANE_H 9 /* pixels per lane */ +#define LANE_GAP 1 +#define TAPE_LANES 3 + +#define GUTTER_W 7 /* lane label gutter on the left */ +#define TAPE_W (BITLE_SCREEN_W - GUTTER_W) + +/* Tape time scale: milliseconds of history per pixel column. */ +#define MS_PER_COL 115 + +static void px(uint8_t *fb, int x, int y, bool on) +{ + if (x < 0 || x >= BITLE_SCREEN_W || y < 0 || y >= BITLE_SCREEN_H) { + return; + } + uint8_t *b = &fb[(y >> 3) * BITLE_SCREEN_W + x]; + uint8_t mask = (uint8_t)(1u << (y & 7)); + if (on) { + *b |= mask; + } else { + *b &= (uint8_t)~mask; + } +} + +static void draw_char_ink(uint8_t *fb, int x, int y, char ch, int scale, bool ink) +{ + const uint8_t *g = bitle_font_glyph(ch); + for (int cx = 0; cx < BITLE_FONT_W; ++cx) { + for (int cy = 0; cy < BITLE_FONT_H; ++cy) { + if (g[cx] & (1u << cy)) { + for (int sx = 0; sx < scale; ++sx) { + for (int sy = 0; sy < scale; ++sy) { + px(fb, x + cx * scale + sx, y + cy * scale + sy, ink); + } + } + } + } + } +} + +static void draw_char(uint8_t *fb, int x, int y, char ch, int scale) +{ + draw_char_ink(fb, x, y, ch, scale, true); +} + +static void draw_text(uint8_t *fb, int x, int y, const char *s, int scale) +{ + int advance = (BITLE_FONT_W + 1) * scale; + while (*s) { + draw_char(fb, x, y, *s++, scale); + x += advance; + } +} + +/* Small 8x8 antenna glyph, top right. Waves drawn when the trunk is up, + * tip dot while a trunk event is fresh. */ +static void draw_trunk_glyph(uint8_t *fb, const bitle_screen_model_t *m, bool fresh) +{ + const int gx = BITLE_SCREEN_W - 9; + const int gy = 4; + static const uint8_t mast[8] = { 0x08, 0x08, 0x2A, 0x1C, 0x08, 0x08, 0x3E, 0x00 }; + for (int cx = 0; cx < 8; ++cx) { + for (int cy = 0; cy < 8; ++cy) { + if (mast[cx] & (1u << cy)) { + px(fb, gx + cx, gy + cy, true); + } + } + } + if (m->trunk_up) { + /* radiating arcs either side of the mast tip */ + px(fb, gx + 1, gy + 1, true); px(fb, gx + 6, gy + 1, true); + px(fb, gx + 0, gy + 2, true); px(fb, gx + 7, gy + 2, true); + } + if (fresh) { + px(fb, gx + 3, gy + 0, true); px(fb, gx + 4, gy + 0, true); + px(fb, gx + 3, gy + 1, true); px(fb, gx + 4, gy + 1, true); + } +} + +static void draw_tape(uint8_t *fb, const bitle_screen_model_t *m) +{ + static const char lane_label[TAPE_LANES] = { 'B', 'T', 'F' }; + + for (int lane = 0; lane < TAPE_LANES; ++lane) { + int top = TAPE_Y + lane * (LANE_H + LANE_GAP); + int mid = top + LANE_H / 2; + + draw_char(fb, 0, top + 1, lane_label[lane], 1); + + /* dotted baseline */ + for (int x = GUTTER_W; x < BITLE_SCREEN_W; x += 2) { + px(fb, x, mid, true); + } + } + + /* events: newest at the right edge, scrolling left with age */ + for (int i = 0; i < m->event_count; ++i) { + const bitle_screen_event_t *e = &m->events[i]; + if (e->lane >= TAPE_LANES) { + continue; + } + int off = (int)(e->age_ms / MS_PER_COL); + int x = BITLE_SCREEN_W - 1 - off; + if (x < GUTTER_W) { + continue; + } + int top = TAPE_Y + e->lane * (LANE_H + LANE_GAP); + int mid = top + LANE_H / 2; + /* blip: 2 columns wide; rx hangs below the baseline, tx rises above; + * height decays with age like a phosphor trace */ + int amp = 5 - (int)(e->age_ms / 3000); + if (amp < 2) { + amp = 2; + } + int y0 = e->dir ? mid - amp : mid; + int y1 = e->dir ? mid : mid + amp; + for (int y = y0; y <= y1; ++y) { + px(fb, x, y, true); + px(fb, x - 1, y, true); + } + } + + /* sweep cursor at the right edge; breathes when idle */ + bool idle = m->event_count == 0; + bool on = idle ? ((m->now_ms / 900) % 2 == 0) : true; + if (on) { + for (int y = TAPE_Y; y < TAPE_Y + TAPE_LANES * (LANE_H + LANE_GAP) - 1; y += 2) { + px(fb, BITLE_SCREEN_W - 1, y, true); + } + } +} + +static void draw_text_centered(uint8_t *fb, int y, const char *s, int scale) +{ + int w = (int)strlen(s) * (BITLE_FONT_W + 1) * scale - scale; + int x = (BITLE_SCREEN_W - w) / 2; + if (x < 0) { + x = 0; + } + draw_text(fb, x, y, s, scale); +} + +void bitle_screen_render_boot(uint8_t *fb, const bitle_screen_model_t *m) +{ + memset(fb, 0, BITLE_SCREEN_FB_BYTES); + draw_text_centered(fb, 8, "BITLE", 3); + draw_text_centered(fb, 40, m->nickname, 1); + draw_text_centered(fb, 52, m->trunk_up ? "TRUNK UP" : "BLE NODE", 1); +} + +void bitle_screen_render(uint8_t *fb, const bitle_screen_model_t *m) +{ + memset(fb, 0, BITLE_SCREEN_FB_BYTES); + + /* title: nickname, 2x (11px advance so 10 chars clear the trunk + * glyph). No inverted bar here: large lit areas sag the SSD1306 charge + * pump and dim the whole display. */ + char nick[11]; + size_t n = strlen(m->nickname); + if (n > 10) { + n = 10; + } + memcpy(nick, m->nickname, n); + nick[n] = '\0'; + { + int x = 1; + for (const char *s = nick; *s; ++s) { + draw_char_ink(fb, x, TITLE_Y + 1, *s, 2, true); + x += 11; + } + } + + bool trunk_fresh = false; + for (int i = 0; i < m->event_count; ++i) { + if (m->events[i].lane == BITLE_LANE_TRUNK && m->events[i].age_ms < 600) { + trunk_fresh = true; + break; + } + } + draw_trunk_glyph(fb, m, trunk_fresh); + + char line[24]; + const char *hex = "0123456789ABCDEF"; + + /* stats line: "LNK 2 SEEN 7" */ + char *p = line; + memcpy(p, "LNK ", 4); p += 4; + *p++ = (char)('0' + (m->ble_links % 10)); + *p++ = ' '; *p++ = ' '; + memcpy(p, "SEEN ", 5); p += 5; + if (m->peers_seen >= 100) { + *p++ = hex[(m->peers_seen / 100) % 16]; + } + if (m->peers_seen >= 10) { + *p++ = (char)('0' + (m->peers_seen / 10) % 10); + } + *p++ = (char)('0' + m->peers_seen % 10); + *p = '\0'; + draw_text(fb, 0, STATS_Y, line, 1); + + /* trunk line: "TRK UP RX 1234 TX 56" style */ + p = line; + memcpy(p, "TRK ", 4); p += 4; + if (m->trunk_up) { + memcpy(p, "UP ", 4); + } else { + memcpy(p, "-- ", 4); + } + p += 4; + memcpy(p, "RX ", 3); p += 3; + uint32_t v = m->rx_total; + char num[11]; + int ni = 0; + do { + num[ni++] = (char)('0' + v % 10); + v /= 10; + } while (v && ni < 10); + while (ni) { + *p++ = num[--ni]; + } + memcpy(p, " TX ", 4); p += 4; + v = m->tx_total; + ni = 0; + do { + num[ni++] = (char)('0' + v % 10); + v /= 10; + } while (v && ni < 10); + while (ni) { + *p++ = num[--ni]; + } + *p = '\0'; + draw_text(fb, 0, TRUNK_Y, line, 1); + + draw_tape(fb, m); +} diff --git a/main/bitle_screen.h b/main/bitle_screen.h new file mode 100644 index 0000000..47cecb9 --- /dev/null +++ b/main/bitle_screen.h @@ -0,0 +1,42 @@ +#ifndef BITLE_SCREEN_H +#define BITLE_SCREEN_H_INCLUDED + +#include +#include + +#define BITLE_SCREEN_W 128 +#define BITLE_SCREEN_H 64 +#define BITLE_SCREEN_FB_BYTES (BITLE_SCREEN_W * BITLE_SCREEN_H / 8) + +/* Activity tape lanes */ +#define BITLE_LANE_BLE 0 /* packets received over BLE */ +#define BITLE_LANE_TRUNK 1 /* LoRa trunk traffic, either direction */ +#define BITLE_LANE_FWD 2 /* packets relayed onward */ + +#define BITLE_SCREEN_MAX_EVENTS 48 + +typedef struct { + uint8_t lane; /* BITLE_LANE_* */ + uint8_t dir; /* 0 = inbound, 1 = outbound */ + uint8_t _pad[2]; + uint32_t age_ms; /* ms since the event, relative to model now_ms */ +} bitle_screen_event_t; + +typedef struct { + char nickname[24]; + int ble_links; /* direct BLE connections */ + int peers_seen; /* unique peers discovered via announces */ + bool trunk_up; + uint32_t rx_total; /* lifetime packet counters */ + uint32_t tx_total; + uint32_t now_ms; + int event_count; /* events[], newest first */ + bitle_screen_event_t events[BITLE_SCREEN_MAX_EVENTS]; +} bitle_screen_model_t; + +void bitle_screen_render(uint8_t *fb, const bitle_screen_model_t *model); + +/* Splash shown briefly at boot before the live dashboard. */ +void bitle_screen_render_boot(uint8_t *fb, const bitle_screen_model_t *model); + +#endif /* BITLE_SCREEN_H */ diff --git a/main/bitle_stats.c b/main/bitle_stats.c new file mode 100644 index 0000000..ab543c4 --- /dev/null +++ b/main/bitle_stats.c @@ -0,0 +1,124 @@ +#include "bitle_stats.h" + +#include + +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +typedef struct { + uint8_t peer_id[8]; + uint64_t last_ms; +} seen_peer_t; + +static SemaphoreHandle_t s_lock; +static uint32_t s_rx_total; +static uint32_t s_tx_total; + +static struct { + uint8_t lane; + uint8_t dir; + uint64_t at_ms; +} s_events[BITLE_STATS_MAX_EVENTS]; +static size_t s_events_head; /* next slot to overwrite (oldest) */ +static size_t s_events_used; + +static seen_peer_t s_seen[BITLE_STATS_SEEN_SLOTS]; + +static uint64_t now_ms(void) +{ + return esp_timer_get_time() / 1000ULL; +} + +void bitle_stats_init(void) +{ + s_lock = xSemaphoreCreateMutex(); +} + +void bitle_stats_note_rx(void) +{ + xSemaphoreTake(s_lock, portMAX_DELAY); + s_rx_total++; + xSemaphoreGive(s_lock); +} + +void bitle_stats_note_tx(void) +{ + xSemaphoreTake(s_lock, portMAX_DELAY); + s_tx_total++; + xSemaphoreGive(s_lock); +} + +void bitle_stats_note_activity(uint8_t lane, uint8_t dir) +{ + if (!s_lock) { + return; + } + xSemaphoreTake(s_lock, portMAX_DELAY); + s_events[s_events_head].lane = lane; + s_events[s_events_head].dir = dir; + s_events[s_events_head].at_ms = now_ms(); + s_events_head = (s_events_head + 1) % BITLE_STATS_MAX_EVENTS; + if (s_events_used < BITLE_STATS_MAX_EVENTS) { + s_events_used++; + } + xSemaphoreGive(s_lock); +} + +void bitle_stats_note_peer(const uint8_t peer_id[8]) +{ + if (!s_lock) { + return; + } + uint64_t now = now_ms(); + xSemaphoreTake(s_lock, portMAX_DELAY); + size_t slot = BITLE_STATS_SEEN_SLOTS; + size_t oldest = 0; + for (size_t i = 0; i < BITLE_STATS_SEEN_SLOTS; ++i) { + seen_peer_t *p = &s_seen[i]; + if (p->last_ms == 0 || now - p->last_ms > BITLE_STATS_SEEN_TTL_MS) { + if (slot == BITLE_STATS_SEEN_SLOTS) { + slot = i; /* reusable slot, but an exact match still wins */ + } + continue; + } + if (memcmp(p->peer_id, peer_id, 8) == 0) { + p->last_ms = now; + xSemaphoreGive(s_lock); + return; + } + if (p->last_ms < s_seen[oldest].last_ms) { + oldest = i; + } + } + if (slot == BITLE_STATS_SEEN_SLOTS) { + slot = oldest; + } + memcpy(s_seen[slot].peer_id, peer_id, 8); + s_seen[slot].last_ms = now; + xSemaphoreGive(s_lock); +} + +void bitle_stats_get(bitle_stats_t *out) +{ + uint64_t now = now_ms(); + memset(out, 0, sizeof(*out)); + xSemaphoreTake(s_lock, portMAX_DELAY); + out->rx_total = s_rx_total; + out->tx_total = s_tx_total; + for (size_t i = 0; i < BITLE_STATS_SEEN_SLOTS; ++i) { + if (s_seen[i].last_ms && now - s_seen[i].last_ms <= BITLE_STATS_SEEN_TTL_MS) { + out->peers_seen++; + } + } + /* unwind the ring newest-first */ + size_t n = s_events_used; + for (size_t k = 0; k < n; ++k) { + size_t idx = (s_events_head + BITLE_STATS_MAX_EVENTS - 1 - k) % BITLE_STATS_MAX_EVENTS; + out->events[k].lane = s_events[idx].lane; + out->events[k].dir = s_events[idx].dir; + out->events[k].age_ms = (uint32_t)(now - s_events[idx].at_ms); + } + out->event_count = (int)n; + xSemaphoreGive(s_lock); +} diff --git a/main/bitle_stats.h b/main/bitle_stats.h new file mode 100644 index 0000000..911028e --- /dev/null +++ b/main/bitle_stats.h @@ -0,0 +1,40 @@ +#ifndef BITLE_STATS_H +#define BITLE_STATS_H + +#include +#include + +#include "bitle_screen.h" + +/* Runtime activity bookkeeping for the status display: a ring of recent + * packet events and a table of unique peers seen announcing, fed by the + * mesh/relay paths. Cheap enough to keep unconditional (a few stores per + * packet); only the display task reads it. */ + +#define BITLE_STATS_MAX_EVENTS BITLE_SCREEN_MAX_EVENTS +#define BITLE_STATS_SEEN_SLOTS 32 +#define BITLE_STATS_SEEN_TTL_MS (10 * 60 * 1000) + +typedef struct { + uint32_t rx_total; + uint32_t tx_total; + int peers_seen; + int event_count; /* newest first */ + bitle_screen_event_t events[BITLE_STATS_MAX_EVENTS]; +} bitle_stats_t; + +void bitle_stats_init(void); + +/* lane: BITLE_LANE_*; dir: 0 = inbound, 1 = outbound */ +void bitle_stats_note_activity(uint8_t lane, uint8_t dir); + +/* Record a unique packet arrival (rx_total) or a relay onward (tx_total). */ +void bitle_stats_note_rx(void); +void bitle_stats_note_tx(void); + +/* Announce observed from this sender (direct or relayed). */ +void bitle_stats_note_peer(const uint8_t peer_id[8]); + +void bitle_stats_get(bitle_stats_t *out); + +#endif /* BITLE_STATS_H */ diff --git a/main/main.c b/main/main.c index 109fe2c..2b0c093 100644 --- a/main/main.c +++ b/main/main.c @@ -8,11 +8,13 @@ #include "bitchat_ble.h" #include "bitchat_time.h" #include "bitle_courier.h" +#include "bitle_display.h" #include "bitle_hash.h" #include "bitle_link.h" #include "bitle_lora.h" #include "bitle_mesh.h" #include "bitle_ota.h" +#include "bitle_stats.h" #include "bitle_sync.h" #include "noise_handshake.h" #include "packet_codec.h" @@ -73,6 +75,7 @@ void app_main(void) ESP_ERROR_CHECK(bitle_link_init()); ESP_ERROR_CHECK(bitle_mesh_init()); + bitle_stats_init(); /* Radio-optional: probes for an SX1262 and brings the LoRa trunk up * when present; C3 nodes and bare S3s continue BLE-only. */ ESP_ERROR_CHECK(bitle_lora_init()); @@ -86,5 +89,9 @@ void app_main(void) ESP_ERROR_CHECK(bitchat_ble_start()); #endif + /* Display-optional: probes for an SSD1306 OLED and starts the status + * screen when present; headless nodes continue without it. */ + ESP_ERROR_CHECK(bitle_display_init()); + xTaskCreate(bitle_main_task, "bitle_main", 8192, NULL, tskIDLE_PRIORITY + 5, NULL); } diff --git a/main/noise_handshake.c b/main/noise_handshake.c index 072f72b..96617a1 100644 --- a/main/noise_handshake.c +++ b/main/noise_handshake.c @@ -1756,9 +1756,24 @@ esp_err_t noise_send_packet(uint16_t conn_handle, bitchat_message_type_t type, c return bitle_link_send(conn_handle, buffer, (uint16_t)encoded_len); } -bool noise_get_peer_identity(uint16_t conn_handle, uint8_t noise_key[32], uint8_t sign_key[32], bool *verified) +const char *noise_get_nickname(void) { - noise_session_t *session = find_session(conn_handle); + return s_nickname; +} + +int noise_verified_peer_count(void) +{ + int count = 0; + for (size_t i = 0; i < NOISE_MAX_SESSIONS; ++i) { + if (s_identities[i].valid && s_identities[i].verified) { + count++; + } + } + return count; +} + +bool noise_get_peer_identity(uint16_t conn_handle, uint8_t noise_key[32], uint8_t sign_key[32], bool *verified) +{ noise_session_t *session = find_session(conn_handle); if (!session) { return false; } diff --git a/main/noise_handshake.h b/main/noise_handshake.h index 38fa9f6..9e47b01 100644 --- a/main/noise_handshake.h +++ b/main/noise_handshake.h @@ -101,6 +101,12 @@ esp_err_t noise_send_packet(uint16_t conn_handle, bitchat_message_type_t type, c * (used by the LoRa trunk for neighbor beacons). */ bool noise_announce_link(uint16_t link_handle); +/* Local node's nickname (e.g. "Bitle-9550"), for the status display. */ +const char *noise_get_nickname(void); + +/* Direct peers with a verified announced identity. */ +int noise_verified_peer_count(void); + /* Identity of the direct peer on a connection, learned from its announce. * Returns false until a direct announce has been parsed. *verified reflects * whether the announce's Ed25519 packet signature checked out. */ diff --git a/tools/boot.png b/tools/boot.png new file mode 100644 index 0000000000000000000000000000000000000000..ef0200713dd5a8a20d4f726ab3190d45f23ed176 GIT binary patch literal 703 zcmeAS@N?(olHy`uVBq!ia0y~yU;;83890C>!-L-wcLN35JY5_^DsH{K>s{1rAmHG5 z|No_mr?S6#1l*K&nXeS!3VEojA^GGyJ4g!>sF7kTULBJ@#yr$ z&(r$y52nLH;nn{)C*FsDmzMSa@?-B~VYn+^vtKAKt|*f+Z-QEoB<#4Esl(^Zm!6gB zF-zxjFfb}GT-(Db!@Hs6a)y!zG%Olw7_aChynME9t1M8S#i9C=s*$6~rweJ>K3g}& z-Rxr!0H*+`^KQhRf8@ROZ!~|>wYWPSI)7|FUC?B5VBl!@!TRg_q6_J_b^pL3)!`k( zIlU)WTNQu?I55~)3to|q$PRoy@2y%REaFdC3Wod^$}(?+1<4y}hc8aQ4d8A|S8Gbs l;d;9;0Oon9GZ+{ej_liL@tccrjr(eln5V0s%Q~loCIDNty08EM literal 0 HcmV?d00001 diff --git a/tools/screen_sim.c b/tools/screen_sim.c new file mode 100644 index 0000000..26add0b --- /dev/null +++ b/tools/screen_sim.c @@ -0,0 +1,220 @@ +/* Host-side simulator for the bitle status screen. Renders the exact + * framebuffer the firmware would produce and writes PNG frames for visual + * inspection. Build: + * cc -O2 -I../main screen_sim.c ../main/bitle_screen.c -lz -o screen_sim + * Usage: + * ./screen_sim writes frame_00.png ... (activity scenario) + * ./screen_sim font writes font.png (full glyph table) */ + +#include +#include +#include +#include + +#include "bitle_screen.h" +#include "bitle_font.h" + +#define SCALE 4 + +static uint32_t png_crc(const uint8_t *data, size_t len) +{ + uint32_t crc = 0xFFFFFFFF; + for (size_t i = 0; i < len; ++i) { + crc ^= data[i]; + for (int b = 0; b < 8; ++b) { + crc = (crc >> 1) ^ (0xEDB88320 & (0u - (crc & 1))); + } + } + return ~crc; +} + +static void png_chunk(FILE *f, const char type[4], const uint8_t *data, size_t len) +{ + uint8_t hdr[8]; + hdr[0] = (uint8_t)(len >> 24); hdr[1] = (uint8_t)(len >> 16); + hdr[2] = (uint8_t)(len >> 8); hdr[3] = (uint8_t)len; + memcpy(hdr + 4, type, 4); + fwrite(hdr, 1, 8, f); + if (len) { + fwrite(data, 1, len, f); + } + uint8_t *buf = malloc(len + 4); + memcpy(buf, type, 4); + memcpy(buf + 4, data, len); + uint32_t crc = png_crc(buf, len + 4); + free(buf); + uint8_t c[4] = { (uint8_t)(crc >> 24), (uint8_t)(crc >> 16), + (uint8_t)(crc >> 8), (uint8_t)crc }; + fwrite(c, 1, 4, f); +} + +static int write_png(const char *path, const uint8_t *fb) +{ + const int w = BITLE_SCREEN_W * SCALE, h = BITLE_SCREEN_H * SCALE; + uint8_t *raw = malloc((size_t)(w + 1) * h); + for (int y = 0; y < h; ++y) { + raw[y * (w + 1)] = 0; /* filter: none */ + for (int x = 0; x < w; ++x) { + int sx = x / SCALE, sy = y / SCALE; + bool on = fb[(sy >> 3) * BITLE_SCREEN_W + sx] & (1u << (sy & 7)); + raw[y * (w + 1) + 1 + x] = on ? 0xFF : 0x00; + } + } + uLongf clen = compressBound((uLong)((size_t)(w + 1) * h)); + uint8_t *comp = malloc(clen); + if (compress2(comp, &clen, raw, (uLong)((size_t)(w + 1) * h), 9) != Z_OK) { + return -1; + } + FILE *f = fopen(path, "wb"); + if (!f) { + return -1; + } + static const uint8_t sig[8] = { 137, 80, 78, 71, 13, 10, 26, 10 }; + fwrite(sig, 1, 8, f); + uint8_t ihdr[13]; + ihdr[0] = (uint8_t)(w >> 24); ihdr[1] = (uint8_t)(w >> 16); + ihdr[2] = (uint8_t)(w >> 8); ihdr[3] = (uint8_t)w; + ihdr[4] = (uint8_t)(h >> 24); ihdr[5] = (uint8_t)(h >> 16); + ihdr[6] = (uint8_t)(h >> 8); ihdr[7] = (uint8_t)h; + ihdr[8] = 8; ihdr[9] = 0; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; + png_chunk(f, "IHDR", ihdr, 13); + png_chunk(f, "IDAT", comp, clen); + png_chunk(f, "IEND", NULL, 0); + fclose(f); + free(raw); + free(comp); + return 0; +} + +/* --- scenario ------------------------------------------------------------- + * 30 simulated seconds: boot with no peers, phones connect, announces flow, + * a DM session bursts through the relay, trunk beacons tick. */ + +typedef struct { + uint32_t at_ms; + uint8_t lane; + uint8_t dir; +} script_event_t; + +static const script_event_t SCRIPT[] = { + { 1200, BITLE_LANE_BLE, 0 }, + { 1350, BITLE_LANE_FWD, 1 }, + { 2600, BITLE_LANE_BLE, 0 }, + { 2780, BITLE_LANE_FWD, 1 }, + { 5000, BITLE_LANE_TRUNK, 1 }, + { 6400, BITLE_LANE_TRUNK, 0 }, + { 6550, BITLE_LANE_FWD, 1 }, + { 8200, BITLE_LANE_BLE, 0 }, + { 8310, BITLE_LANE_BLE, 0 }, + { 8420, BITLE_LANE_BLE, 0 }, + { 8560, BITLE_LANE_FWD, 1 }, + { 8700, BITLE_LANE_FWD, 1 }, + { 10000, BITLE_LANE_TRUNK, 1 }, + { 11300, BITLE_LANE_BLE, 0 }, + { 11480, BITLE_LANE_TRUNK, 1 }, + { 13000, BITLE_LANE_TRUNK, 0 }, + { 13120, BITLE_LANE_BLE, 1 }, + { 15000, BITLE_LANE_TRUNK, 1 }, + { 16400, BITLE_LANE_BLE, 0 }, + { 16560, BITLE_LANE_FWD, 1 }, + { 16700, BITLE_LANE_FWD, 1 }, + { 18200, BITLE_LANE_TRUNK, 0 }, + { 20000, BITLE_LANE_TRUNK, 1 }, + { 21500, BITLE_LANE_BLE, 0 }, + { 21630, BITLE_LANE_BLE, 0 }, + { 21800, BITLE_LANE_FWD, 1 }, + { 23400, BITLE_LANE_TRUNK, 0 }, + { 23550, BITLE_LANE_TRUNK, 1 }, + { 25000, BITLE_LANE_TRUNK, 1 }, + { 26800, BITLE_LANE_BLE, 0 }, + { 26960, BITLE_LANE_FWD, 1 }, + { 28400, BITLE_LANE_BLE, 0 }, + { 28520, BITLE_LANE_FWD, 1 }, +}; + +static void build_model(bitle_screen_model_t *m, uint32_t now_ms) +{ + memset(m, 0, sizeof(*m)); + strcpy(m->nickname, "Bitle-9550"); + m->ble_links = now_ms < 2500 ? 0 : (now_ms < 8000 ? 1 : 2); + m->peers_seen = now_ms < 2500 ? 0 : (now_ms < 8000 ? 1 : (now_ms < 15000 ? 3 : 7)); + m->trunk_up = now_ms >= 4500; + m->now_ms = now_ms; + + uint32_t rx = 0, tx = 0; + int count = 0; + for (size_t i = 0; i < sizeof(SCRIPT) / sizeof(SCRIPT[0]); ++i) { + if (SCRIPT[i].at_ms > now_ms) { + break; + } + if (SCRIPT[i].dir) { tx++; } else { rx++; } + if (count < BITLE_SCREEN_MAX_EVENTS) { + /* newest first: iterate script backwards when filling */ + count++; + } + } + int filled = 0; + for (int i = (int)(sizeof(SCRIPT) / sizeof(SCRIPT[0])) - 1; i >= 0; --i) { + if (SCRIPT[i].at_ms <= now_ms && filled < count) { + m->events[filled].lane = SCRIPT[i].lane; + m->events[filled].dir = SCRIPT[i].dir; + m->events[filled].age_ms = now_ms - SCRIPT[i].at_ms; + filled++; + } + } + m->event_count = filled; + m->rx_total = rx; + m->tx_total = tx; +} + +int main(int argc, char **argv) +{ + static uint8_t fb[BITLE_SCREEN_FB_BYTES]; + + if (argc > 1 && strcmp(argv[1], "font") == 0) { + /* render the full glyph table across several rows */ + bitle_screen_model_t m; + memset(&m, 0, sizeof(m)); + memset(fb, 0, sizeof(fb)); + int x = 0, y = 0; + for (unsigned i = 0; i < BITLE_FONT_GLYPH_COUNT; ++i) { + x = (int)(i % 16) * 8; + y = (int)(i / 16) * 10; + const uint8_t *g = bitle_font_glyph(BITLE_FONT[i].ch); + for (int cx = 0; cx < BITLE_FONT_W; ++cx) { + for (int cy = 0; cy < BITLE_FONT_H; ++cy) { + if (g[cx] & (1u << cy)) { + fb[((y + cy) >> 3) * BITLE_SCREEN_W + (x + cx)] |= + (uint8_t)(1u << ((y + cy) & 7)); + } + } + } + } + return write_png("font.png", fb); + } + + static const uint32_t ticks[] = { 3000, 9000, 13000, 17500, 22000, 27500 }; + { + bitle_screen_model_t m; + build_model(&m, 1000); + bitle_screen_render_boot(fb, &m); + if (write_png("boot.png", fb) != 0) { + fprintf(stderr, "failed to write boot.png\n"); + return 1; + } + printf("wrote boot.png\n"); + } + for (size_t i = 0; i < sizeof(ticks) / sizeof(ticks[0]); ++i) { + bitle_screen_model_t m; + build_model(&m, ticks[i]); + bitle_screen_render(fb, &m); + char path[32]; + snprintf(path, sizeof(path), "frame_%02zu.png", i); + if (write_png(path, fb) != 0) { + fprintf(stderr, "failed to write %s\n", path); + return 1; + } + printf("wrote %s (t=%ums)\n", path, ticks[i]); + } + return 0; +} From d130a3a754c19f6f2cbdb454ecc41f318f7d0a64 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:29:19 +0200 Subject: [PATCH 04/19] test(lora): add reliability harness and baseline telemetry --- docs/LoRa-reliability-implementation-plan.md | 506 +++++++++++++++++++ docs/LoRa-testing.md | 57 +++ main/CMakeLists.txt | 1 + main/Kconfig.projbuild | 10 + main/bitle_lora.c | 136 ++++- main/bitle_lora.h | 22 + main/lora_airtime.c | 47 ++ main/lora_airtime.h | 32 ++ tests/test_lora_airtime.c | 29 ++ tests/test_lora_reliability_baseline.py | 73 +++ tools/lora_hardware_smoke.py | 92 ++++ tools/lora_reliability_sim.py | 191 +++++++ tools/run_lora_host_tests.sh | 16 + 13 files changed, 1195 insertions(+), 17 deletions(-) create mode 100644 docs/LoRa-reliability-implementation-plan.md create mode 100644 docs/LoRa-testing.md create mode 100644 main/lora_airtime.c create mode 100644 main/lora_airtime.h create mode 100644 tests/test_lora_airtime.c create mode 100644 tests/test_lora_reliability_baseline.py create mode 100755 tools/lora_hardware_smoke.py create mode 100755 tools/lora_reliability_sim.py create mode 100755 tools/run_lora_host_tests.sh diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md new file mode 100644 index 0000000..e623703 --- /dev/null +++ b/docs/LoRa-reliability-implementation-plan.md @@ -0,0 +1,506 @@ +# LoRa Reliability Implementation Plan + +## Milestone execution rule + +Work must proceed one milestone at a time. After a milestone satisfies all of +its success criteria: + +1. Update this implementation plan with the completed checklist, test evidence, + measurements, decisions, and any changes to later milestones. +2. Record the milestone status and the resulting Git commit hash in the + progress table below. +3. Commit the implementation, tests, documentation, and this updated plan + together as one focused Git commit. +4. Confirm the commit succeeded and the worktree contains no unexpected + changes. +5. Only then begin the next milestone. + +If a success criterion fails, remain in the current milestone, diagnose the +failure, and update the plan as necessary. Do not mark the milestone complete +or proceed to the next one until its gate passes. Milestone commits should +remain distinct so each reliability improvement can be tested, reviewed, and +reverted independently. + +## Program success criteria + +The implementation is complete only when all of the following are true: + +- Every supported LoRa profile can transmit the largest frame without hitting + the SX1262 TX timeout. This includes SF7 through SF12 at BW125. +- Under a deterministic host simulation with 30% independently injected data + and ACK loss, at least 95% of 520-byte packets are delivered within the + documented retry deadline, with zero corrupted or falsely completed packets. +- Queue saturation never produces a partially enqueued packet. A caller gets an + accurate accepted, deferred, or rejected result before any fragment is sent. +- A receiver never acknowledges successful packet delivery until it has a + complete, length-valid packet. Duplicate retransmissions do not cause + duplicate delivery to the BitChat mesh. +- An ACK from an unintended node can never satisfy an addressed transmission. + Broadcast traffic does not trigger simultaneous ACKs from every receiver. +- At 30% injected frame loss, 95% of nodes are discovered within three + configured beacon intervals. +- In a three-node line topology where the first and third nodes cannot hear each + other, at least 90% of 200 test packets cross two LoRa hops under 20% injected + per-hop loss, with no forwarding loops or duplicate application delivery. +- Regional radio profiles are explicit, validate their frequency ranges, apply + the correct image calibration, and survive reboot. At minimum, EU868 and + US915 profiles are covered. +- The implementation records actionable counters for TX attempts, retries, ACK + misses, CRC failures, queue pressure, reassembly expiry, duplicate suppression, + and completed packet delivery. +- Existing BLE-only behavior, Noise handling, packet codec behavior, courier, + sync, OTA admission policy, and supported ESP32 targets continue to build and + pass their existing checks. +- The README and LoRa documentation describe measured behavior and supported + configurations. Unverified range claims are removed or clearly labeled as + test-specific results. + +## Milestone progress + +| Milestone | Status | Commit | Evidence summary | +|---|---|---|---| +| 0. Baseline harness and observability | Complete | Pending checkpoint | Host baseline, both firmware targets, and two-board SF10 smoke passed | +| 1. Airtime-safe SX1262 operation | Pending | — | — | +| 2. Atomic packet scheduling and backpressure | Pending | — | — | +| 3. Addressed trunk protocol and migration | Pending | — | — | +| 4. Packet-level reliability and reassembly | Pending | — | — | +| 5. Discovery and shared-channel behavior | Pending | — | — | +| 6. LoRa multi-hop forwarding | Pending | — | — | +| 7. Link adaptation and PHY hardening | Pending | — | — | +| 8. End-to-end validation and rollout | Pending | — | — | + +## Milestone 0: Baseline harness and observability + +### Goal + +Create repeatable tests that reproduce the current failures before changing the +wire protocol, and add enough telemetry to distinguish RF loss from protocol +loss. + +### Checklist + +- [x] Extract or expose the airtime calculation so it can be tested without + hardware. +- [x] Add a deterministic host-side LoRa link simulator with configurable data + loss, ACK loss, delay, duplication, reordering, and queue capacity. +- [x] Model SF, bandwidth, coding rate, preamble, fragment size, CAD delay, + retry count, and radio timeout. +- [x] Add baseline tests that reproduce: + - [x] SF11 and SF12 full-frame TX timeout. + - [x] Reassembly expiry after weak-link retries. + - [x] Partial packet enqueue when the frame queue fills. + - [x] Acceptance of an ACK from the wrong source. + - [x] Colliding ACK behavior for broadcast frames. +- [x] Add runtime counters for raw RX frames, CRC errors, TX attempts, TX + timeouts, ACK RX/TX, ACK misses, retry exhaustion, queue full events, + reassembly expiry, and completed packets. +- [x] Add queue depth and high-water reporting. +- [x] Log RSSI and SNR for every valid raw trunk frame rather than only after + complete packet reassembly. +- [x] Capture the current two-node SF10 baseline for 1-, 2-, 3-, and 5-fragment + packets. +- [x] Document how to run the host simulation and the hardware smoke test. + +### Success criteria + +- The automated baseline suite deterministically demonstrates each known + failure without relying on physical RF conditions. +- Runtime logs and counters identify whether a loss occurred at TX, raw RX, + ACK, queueing, reassembly, or BitChat delivery. +- Instrumentation does not change the v2 wire format or materially alter the + baseline timing. +- Existing firmware targets still build. + +### Milestone record + +- Status: Complete +- Evidence: + - Portable C airtime tests and seven deterministic Python baseline tests + pass. The baseline reproduces the SF11/SF12 two-second TX timeout, fixed + reassembly expiry, partial enqueue, wrong-source ACK acceptance, and + four-receiver ACK implosion. + - ESP-IDF 6.0 clean builds pass for the Heltec V3 ESP32-S3 configuration and + the BLE-only ESP32-C3 target. + - A two-board SF10 capture transmitted diagnostic probes spanning one through + five fragments. One-, two-, and three-fragment probes completed. Four- and + five-fragment probes were transmitted and their raw frames/ACKs were + visible, but they did not complete amid concurrent traffic, reproducing the + v2 fixed-deadline failure on hardware. + - Runtime diagnostics now separate raw RX, CRC, TX/timeout, ACK, retry, + queue pressure, reassembly expiry, and completed-delivery stages. Raw valid + frames log RSSI and SNR. +- Commit: Pending checkpoint +- Suggested commit subject: `test(lora): add reliability harness and baseline telemetry` + +## Milestone 1: Airtime-safe SX1262 operation + +### Goal + +Remove deterministic PHY failures and make every declared radio profile safe +for the configured frame size. + +### Checklist + +- [ ] Replace the fixed two-second SX1262 TX timeout with a timeout derived from + calculated packet airtime plus bounded scheduling and radio margin. +- [ ] Clamp the converted SX1262 timeout to the command's valid 24-bit range. +- [ ] Add an independent software watchdog so a missing TX interrupt still + recovers the radio. +- [ ] Verify TX timeout handling distinguishes expected watchdog recovery from + an RF delivery failure. +- [ ] Propagate and check errors from radio configuration commands instead of + silently continuing after failed SPI operations. +- [ ] Add BUSY-stuck recovery and a bounded radio reinitialization path. +- [ ] Make preamble length a profile parameter and set the robust default to at + least 16 symbols. +- [ ] Apply the Heltec-compatible boosted RX gain setting and verify that it is + retained across radio mode changes. +- [ ] Explicitly configure and verify the PA current limit needed by each + supported board and TX power. +- [ ] Introduce explicit regional profiles, initially EU868 and US915. +- [ ] Select image calibration bytes from the active regional frequency rather + than hardcoding the US band. +- [ ] Reject invalid persisted combinations instead of silently mixing a + frequency with the wrong calibration or profile. +- [ ] Add airtime and timeout tests for minimum, typical, and maximum frames at + SF7 through SF12. + +### Success criteria + +- Maximum-size trunk frames complete TX at SF7 through SF12 without an SX1262 + timeout. +- A deliberately suppressed TX interrupt is recovered by the software watchdog + and returns the radio to continuous RX. +- EU868 and US915 select valid frequencies and the matching calibration. +- Radio configuration failures are visible to the caller and in diagnostics. +- A two-node hardware smoke test passes at SF10, SF11, and SF12. + +### Milestone record + +- Status: Pending +- Evidence: +- Commit: +- Suggested commit subject: `fix(lora): make SX1262 operation airtime safe` + +## Milestone 2: Atomic packet scheduling and backpressure + +### Goal + +Prevent queue pressure from creating packets that can never be reassembled and +give callers an honest transport result. + +### Checklist + +- [ ] Replace the frame queue with a whole-packet TX descriptor queue, or reserve + all required frame capacity atomically before enqueueing any fragment. +- [ ] Use a bounded static packet pool so queueing does not add heap + fragmentation to the hot path. +- [ ] Define explicit send results: accepted, deferred/backpressured, + policy-dropped, and transport-failed. +- [ ] Update `bitle_link` callers so policy drops are not reported as successful + transport delivery. +- [ ] Ensure a Noise nonce or higher-layer state is not irreversibly advanced + when the transport rejects a packet before transmission. +- [ ] Give control traffic, ACKs, discovery, and user data explicit priorities + with starvation bounds. +- [ ] Preserve packet ordering where the BitChat or Noise protocol requires it. +- [ ] Add queue admission metrics by packet type and priority. +- [ ] Add saturation tests with concurrent BLE relay, Noise response, beacon, + courier, and sync traffic. +- [ ] Add cancellation and cleanup tests for radio reset and link shutdown. + +### Success criteria + +- No test can produce a partial packet enqueue, including concurrent producers + and a queue with only one free slot. +- The caller receives a failure or backpressure result before any fragment of a + rejected packet is transmitted. +- Accepted packets preserve required ordering. +- Control traffic remains bounded, and user traffic cannot be starved + indefinitely. +- Queue and packet-pool resources return to baseline after retry exhaustion, + radio recovery, and link shutdown. + +### Milestone record + +- Status: Pending +- Evidence: +- Commit: +- Suggested commit subject: `refactor(lora): queue trunk packets atomically` + +## Milestone 3: Addressed trunk protocol and migration + +### Goal + +Introduce a new wire version whose addressing and acknowledgement semantics are +correct on a shared multi-node channel. + +### Checklist + +- [ ] Write the trunk v3 wire-format specification before implementing it. +- [ ] Use a sufficiently collision-resistant node identifier on the wire, or + define explicit collision detection and recovery for shortened tags. +- [ ] Give each packet a reboot-safe transfer identifier that cannot be confused + with another sender's sequence. +- [ ] Include source, destination, transfer identifier, fragment information, + capabilities, and integrity-covered flags in the v3 format. +- [ ] Build a bounded neighbor table from authenticated or otherwise + integrity-checked discovery information. +- [ ] Track which LoRa neighbor last advertised reachability for each BitChat + peer so directed BitChat traffic can select a trunk destination. +- [ ] Require addressed receivers to ACK only frames addressed to them. +- [ ] Require senders to validate ACK source, destination, transfer identifier, + and fragment state. +- [ ] Define broadcast behavior with no immediate all-receiver ACK storm. +- [ ] Define behavior when the destination or route is unknown. +- [ ] Decide and document the v2-to-v3 rollout policy: + - [ ] Version and capability advertisement. + - [ ] Mixed-firmware behavior. + - [ ] Whether v2 is read-only, optional fallback, or intentionally + unsupported after migration. + - [ ] A rollback path for deployed nodes. +- [ ] Add parser fuzz tests and malformed-frame tests for both supported wire + versions. + +### Success criteria + +- An ACK from any node other than the selected destination is rejected. +- Two senders using the same sequence value cannot corrupt or complete each + other's transfer. +- Broadcast reception by four nodes produces no simultaneous ACK burst. +- Malformed lengths, fragment counts, identifiers, flags, and versions are + rejected without memory corruption or state leakage. +- Mixed v2/v3 behavior matches the documented rollout policy. + +### Milestone record + +- Status: Pending +- Evidence: +- Commit: +- Suggested commit subject: `feat(lora): introduce addressed trunk protocol v3` + +## Milestone 4: Packet-level reliability and reassembly + +### Goal + +Replace per-fragment stop-and-wait behavior with bounded packet-level +reliability that remains correct under loss, duplicates, and reordering. + +### Checklist + +- [ ] Implement packet-level selective repeat with a received-fragment bitmap. +- [ ] Allow more than one useful fragment per round trip while keeping memory + and airtime bounded. +- [ ] Send bitmap ACKs only from the addressed destination. +- [ ] Define a final COMPLETE acknowledgement that is sent only after full + reassembly and length validation. +- [ ] Re-ACK duplicate fragments and duplicate completed transfers without + delivering the BitChat packet twice. +- [ ] Derive retry and reassembly deadlines from SF, bandwidth, coding rate, + fragment count, retry budget, and contention allowance. +- [ ] Refresh the progress deadline when a new fragment arrives, while enforcing + an absolute maximum transfer lifetime. +- [ ] Retain completed-transfer state long enough to answer a sender that missed + the final COMPLETE acknowledgement. +- [ ] Add explicit abort handling for retry exhaustion, invalid total length, + resource pressure, and radio reset. +- [ ] Ensure a dropped transfer releases all packet-pool and reassembly + resources. +- [ ] Test loss, ACK loss, duplication, reordering, delayed ACKs, restart, and + sequence wrap. +- [ ] Test 1- through 5-fragment packets at every supported SF. + +### Success criteria + +- At 30% injected data and ACK loss, at least 95% of 520-byte packets complete + within the documented deadline. +- No incomplete or invalid packet is reported as successfully delivered. +- Duplicate frames and final-ACK loss never cause duplicate BitChat delivery. +- Reassembly state remains bounded under a sustained stream of incomplete or + malicious transfers. +- SF and fragment count no longer rely on a fixed ten-second timeout. + +### Milestone record + +- Status: Pending +- Evidence: +- Commit: +- Suggested commit subject: `feat(lora): add packet-level selective repeat ARQ` + +## Milestone 5: Discovery and shared-channel behavior + +### Goal + +Make node discovery timely at weak signal levels and prevent synchronized +traffic from repeatedly colliding. + +### Checklist + +- [ ] Define a compact, single-frame trunk discovery beacon containing node ID, + protocol version, capabilities, regional profile, and a freshness value. +- [ ] Keep BitChat identity and signed application announces separate from + minimum viable trunk-neighbor discovery. +- [ ] Randomize first-beacon and periodic-beacon timing. +- [ ] Add controlled beacon repetition or another bounded reliability mechanism + that does not create ACK implosion. +- [ ] Make announce throttling aware of whether a previous announce was actually + admitted and transmitted. +- [ ] Scale contention windows to packet airtime rather than using a fixed + 30–150 ms interval. +- [ ] Recheck CAD immediately before TX after the backoff expires. +- [ ] Keep the radio in RX during software backoff whenever the SX1262 mode + permits it. +- [ ] Add ACK priority without allowing an unlimited ACK stream to starve data. +- [ ] Add randomized scheduling for relays that hear the same broadcast. +- [ ] Test hidden-terminal, simultaneous-beacon, bidirectional-data, and + four-receiver scenarios. + +### Success criteria + +- At 30% injected frame loss, 95% of nodes are discovered within three beacon + intervals. +- Four nodes booting simultaneously converge without a persistent collision + cycle. +- Two hidden senders eventually transmit successfully without synchronized + retry lockstep. +- Discovery and ACK traffic remain within the configured airtime budget. +- Discovery loss does not block an already-known addressed route. + +### Milestone record + +- Status: Pending +- Evidence: +- Commit: +- Suggested commit subject: `feat(lora): harden discovery and channel access` + +## Milestone 6: LoRa multi-hop forwarding + +### Goal + +Turn the current one-hop LoRa bus into an actual bounded mesh backhaul. + +### Checklist + +- [ ] Define hop-limit, deduplication, forwarding, and route-learning behavior + for broadcast and addressed traffic. +- [ ] Ensure a LoRa-received packet can be forwarded back onto LoRa without + passing through the generic link-exclusion rule that currently prevents + it. +- [ ] Add an expiring deduplication cache keyed by source and transfer identity. +- [ ] Add SNR- and airtime-aware randomized forwarding delay so the strongest or + earliest relay tends to win without synchronized rebroadcast. +- [ ] Suppress a scheduled rebroadcast when the same packet is overheard from a + better-positioned relay. +- [ ] Learn reverse paths from discovery and successfully received traffic. +- [ ] Use per-hop addressed reliability for directed forwarding. +- [ ] Define fallback behavior when a learned route expires or fails. +- [ ] Enforce hop limits before queue admission. +- [ ] Keep application-level BitChat deduplication as a second safety layer. +- [ ] Add line, triangle, ring, route-failure, node-reboot, and asymmetric-link + topology tests. + +### Success criteria + +- In a three-node line where endpoints cannot hear each other, at least 90% of + 200 packets cross two hops under 20% injected per-hop loss. +- Ring and triangle tests produce no persistent forwarding loops. +- A route failure triggers bounded rediscovery or fallback and releases stale + state. +- The final BitChat packet is delivered once even when multiple LoRa paths + exist. +- Hop-limit and deduplication bounds hold under malicious or repeated traffic. + +### Milestone record + +- Status: Pending +- Evidence: +- Commit: +- Suggested commit subject: `feat(lora): add bounded multi-hop forwarding` + +## Milestone 7: Link adaptation and PHY hardening + +### Goal + +Use measured link quality to improve reliability without allowing peers to +silently diverge onto incompatible radio parameters. + +### Checklist + +- [ ] Maintain per-neighbor EWMA metrics for RSSI, SNR, data success, ACK + success, retries, and recent delivery latency. +- [ ] Adapt fragment size and retry budget within documented bounds. +- [ ] Evaluate CR 4/6 and 4/7 against CR 4/5 using controlled airtime and + delivery measurements. +- [ ] Do not change frequency, SF, bandwidth, sync word, or coding rate for one + peer without an explicit coordinated profile-change protocol. +- [ ] If coordinated profile changes are implemented, define rendezvous, + confirmation, timeout, and rollback behavior. +- [ ] Persist only validated profile choices and recover safely from corrupt or + incompatible NVS values. +- [ ] Verify boosted RX gain, preamble length, PA setup, and regional frequency + on each supported board. +- [ ] Add board-specific smoke tests for the XIAO Wio-SX1262 and Heltec V3. +- [ ] Measure power and thermal impact of boosted gain, longer preamble, retries, + and high TX duty. + +### Success criteria + +- Weak links select smaller fragments or a higher retry budget without breaking + interoperability. +- Stable links recover throughput rather than remaining permanently in a + conservative mode. +- No automated change can strand two nodes on different PHY settings. +- Both supported boards pass the same packet-delivery and radio-recovery suite. +- Power, thermal, and airtime measurements remain within documented operating + limits. + +### Milestone record + +- Status: Pending +- Evidence: +- Commit: +- Suggested commit subject: `feat(lora): adapt trunk behavior to measured link quality` + +## Milestone 8: End-to-end validation and rollout + +### Goal + +Validate the complete system under realistic traffic and produce a safe, +documented deployment path. + +### Checklist + +- [ ] Run the complete host simulation matrix across SF, packet size, loss, + duplication, reordering, queue pressure, node count, and topology. +- [ ] Run two-node and three-node hardware tests with recorded RSSI, SNR, retry, + latency, queue, and delivery data. +- [ ] Test discovery, public messages, Noise handshake traffic, DMs, delivery + receipts, auto-replies, courier traffic, and permitted OTA control traffic. +- [ ] Test simultaneous traffic in both directions. +- [ ] Test reboot during TX, RX, reassembly, route learning, and profile change. +- [ ] Soak test for at least 24 hours with periodic traffic and fault injection. +- [ ] Confirm BLE-only devices and LoRa-disabled configurations are unchanged. +- [ ] Build every supported ESP32 target from a clean configuration. +- [ ] Document regional setup, antenna requirements, supported PHY profiles, + expected latency, airtime cost, retry behavior, and troubleshooting + counters. +- [ ] Replace unsupported range claims with results tied to hardware, antenna, + profile, environment, sample count, and measured link quality. +- [ ] Document v2-to-v3 upgrade, rollback, and mixed-firmware behavior. +- [ ] Produce a release checklist and retain raw acceptance-test results. + +### Success criteria + +- All program-level success criteria pass with archived evidence. +- The 24-hour soak has no deadlock, unreleased packet state, radio-stuck event + without recovery, or duplicate application delivery. +- Clean builds succeed for all supported targets. +- Deployment and rollback are documented and have been rehearsed on hardware. +- README claims match measured results. + +### Milestone record + +- Status: Pending +- Evidence: +- Commit: +- Suggested commit subject: `docs(lora): finalize reliability validation and rollout` diff --git a/docs/LoRa-testing.md b/docs/LoRa-testing.md new file mode 100644 index 0000000..02c722d --- /dev/null +++ b/docs/LoRa-testing.md @@ -0,0 +1,57 @@ +# LoRa reliability testing + +The LoRa reliability work has two test layers: a deterministic host suite and +an opt-in two-board smoke test. Run the host suite before every firmware build. + +## Host suite + +```bash +tools/run_lora_host_tests.sh +``` + +The suite compiles the same portable airtime implementation used by firmware +and runs the deterministic link simulator. Its legacy-v2 cases intentionally +assert the original failures: SF11/SF12 exceeding the fixed two-second timeout, +fixed reassembly expiry under retries, partial frame enqueue, acceptance of an +ACK from the wrong source, and broadcast ACK implosion. Those cases remain as +an executable historical baseline while later milestones add fixed-protocol +acceptance cases. + +The simulator controls data loss, ACK loss, delay, duplication, reordering, +queue capacity, radio profile, fragment size, retry count, CAD allowance, and +radio timeout from a fixed random seed. + +## Firmware build + +Activate ESP-IDF 6.0, then build: + +```bash +source ~/.espressif/python_env/idf6.0_py3.14_env/bin/activate +source ../esp-idf/export.sh +idf.py build +``` + +## Two-board SF10 smoke test + +The production default keeps `CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE` disabled. +For a controlled test, enable it on exactly one Heltec V3 build. That node +transmits one synthetic packet at each of 1, 2, 3, 4, and 5 fragments, spaced +12 seconds apart. Keep the receiving node on the normal build. + +Capture both serial streams concurrently: + +```bash +python tools/lora_hardware_smoke.py PORT_A PORT_B --seconds 90 \ + --json-out /tmp/bitle-lora-smoke.json +``` + +The capture summarizes TX and completed RX counts by fragment count and retains +per-frame RSSI/SNR. Raw serial output and JSON evidence may contain local device +details, so keep it outside the repository. + +Runtime firmware emits a one-minute diagnostic summary containing: + +- valid raw RX frames and CRC failures; +- TX attempts/timeouts, retries, ACK RX/TX/misses, and retry exhaustion; +- current/high-water queue depth and queue-full events; +- expired reassemblies and completed packets. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 7daf894..5132223 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -9,6 +9,7 @@ idf_component_register( "bitle_link.c" "bitle_mesh.c" "bitle_lora.c" + "lora_airtime.c" "sx1262.c" "bitle_ota.c" "bitle_store.c" diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index e2a23d8..ec604a0 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -69,4 +69,14 @@ menu "Bitle hardware" depends on BITLE_DISPLAY default 10 + config BITLE_LORA_DIAGNOSTIC_SMOKE + bool "Transmit LoRa diagnostic smoke packets" + depends on IDF_TARGET_ESP32S3 + default n + help + Test-only mode. After the trunk starts, transmit synthetic packets + spanning one through five v2 fragments. Flash exactly one test node + with this enabled and leave receiving nodes on the normal build. + Never enable it in deployed firmware. + endmenu diff --git a/main/bitle_lora.c b/main/bitle_lora.c index 1e3bfe5..fdc7398 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -16,6 +16,7 @@ #include "bitle_link.h" #include "bitle_mesh.h" #include "bitle_stats.h" +#include "lora_airtime.h" #include "noise_handshake.h" #include "packet_codec.h" #include "sx1262.h" @@ -133,6 +134,7 @@ static uint8_t s_sf; static uint32_t s_bw; static uint8_t s_cr; /* denominator 5..8 => 4/5..4/8 */ static bool s_ota_over_trunk; /* OTA image chunks on the trunk (default off) */ +static bitle_lora_diag_t s_diag; static portMUX_TYPE s_gov_mux = portMUX_INITIALIZER_UNLOCKED; static double s_gov_credit_ms; @@ -146,21 +148,36 @@ typedef struct { } throttle_t; static throttle_t s_throttle[THROTTLE_SLOTS]; -/* LoRa time-on-air (Semtech AN1200.13). Returns whole milliseconds. */ -static uint32_t lora_airtime_ms(uint16_t payload_len) +/* Firmware wrapper around the host-testable Semtech time-on-air calculation. */ +static uint32_t trunk_airtime_ms(uint16_t payload_len) { - double tsym = (double)(1u << s_sf) / (double)s_bw; /* seconds */ - double tpre = (8.0 + 4.25) * tsym; - int de = ((s_sf >= 11 && s_bw == 125000) || (s_sf >= 12 && s_bw == 250000)) ? 1 : 0; - int cr = s_cr - 4; /* 1..4 */ - double num = 8.0 * payload_len - 4.0 * s_sf + 28.0 + 16.0; /* CRC on, explicit header */ - double den = 4.0 * (s_sf - 2 * de); - double n = ceil(num / den) * (cr + 4); - if (n < 0) { - n = 0; - } - double t = tpre + (8.0 + n) * tsym; - return (uint32_t)(t * 1000.0 + 0.5); + lora_airtime_params_t params = { + .spreading_factor = s_sf, + .bandwidth_hz = s_bw, + .coding_rate = s_cr, + .preamble_symbols = 8, + .explicit_header = true, + .crc_enabled = true, + }; + return lora_airtime_ms(¶ms, payload_len); +} + +static void diag_inc(uint32_t *counter) +{ + taskENTER_CRITICAL(&s_gov_mux); + (*counter)++; + taskEXIT_CRITICAL(&s_gov_mux); +} + +static void diag_note_queue_depth(void) +{ + uint32_t depth = s_tx_queue ? uxQueueMessagesWaiting(s_tx_queue) : 0; + taskENTER_CRITICAL(&s_gov_mux); + s_diag.queue_depth = depth; + if (depth > s_diag.queue_high_water) { + s_diag.queue_high_water = depth; + } + taskEXIT_CRITICAL(&s_gov_mux); } /* Per-origin announce throttle (call inside the governor critical section). @@ -228,7 +245,7 @@ static bool trunk_admit(const uint8_t *data, uint16_t len) for (uint8_t i = 0; i < total; ++i) { uint16_t off = (uint16_t)i * TRUNK_CHUNK_TX; uint16_t chunk = len - off < TRUNK_CHUNK_TX ? len - off : TRUNK_CHUNK_TX; - airtime += lora_airtime_ms(TRUNK_HDR_LEN + chunk); + airtime += trunk_airtime_ms(TRUNK_HDR_LEN + chunk); } taskENTER_CRITICAL(&s_gov_mux); @@ -369,9 +386,12 @@ static int lora_link_send(uint16_t handle, const uint8_t *data, uint16_t len) frame.len = TRUNK_HDR_LEN + chunk; frame.want_ack = want_ack; if (xQueueSend(s_tx_queue, &frame, 0) != pdTRUE) { + diag_inc(&s_diag.queue_full); + diag_note_queue_depth(); ESP_LOGW(TAG, "TX queue full; dropping packet seq=%u", seq); return -1; } + diag_note_queue_depth(); } return 0; } @@ -425,6 +445,8 @@ static bool transmit_ack_now(void) s_ack_head = (s_ack_head + 1) % ACK_OUTBOX; s_ack_count--; if (sx1262_transmit(f, TRUNK_HDR_LEN) == ESP_OK) { + diag_inc(&s_diag.tx_attempts); + diag_inc(&s_diag.ack_tx); ESP_LOGI(TAG, "ack TX seq=%u idx=%u", a->seq, a->idx); return true; } @@ -441,6 +463,9 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) if (memcmp(f + 4, s_src_tag, 4) == 0) { return; /* our own transmission echoed */ } + diag_inc(&s_diag.raw_rx_frames); + ESP_LOGI(TAG, "trunk raw RX len=%u rssi=%d snr=%d ftype=0x%02X", + len, rssi, snr, f[3]); uint16_t seq = ((uint16_t)f[12] << 8) | f[13]; uint8_t idx = f[14], total = f[15]; @@ -450,6 +475,7 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) s_ack_seen = true; s_ack_seq = seq; s_ack_idx = idx; + diag_inc(&s_diag.ack_rx); ESP_LOGI(TAG, "ack RX seq=%u idx=%u", seq, idx); } return; @@ -475,6 +501,7 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) rx_slot_t *s = &s_rx_slots[i]; if (s->in_use && now - s->started_ms > RX_TIMEOUT_MS) { s->in_use = false; + diag_inc(&s_diag.reassembly_expiry); } if (s->in_use && s->seq == seq && memcmp(s->src, f + 4, 4) == 0) { slot = s; @@ -518,6 +545,7 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) } slot->in_use = false; + diag_inc(&s_diag.completed_packets); ESP_LOGI(TAG, "trunk RX packet len=%u rssi=%d snr=%d frags=%u", plen, rssi, snr, total); bitle_mesh_inbound(BITLE_LORA_LINK_HANDLE, packet, plen); } @@ -540,6 +568,12 @@ static void lora_task(void *arg) bool awaiting_ack = false; uint64_t ack_deadline = 0; uint64_t next_beacon_ms = BEACON_FIRST_MS; + uint64_t next_diag_ms = 60000ULL; +#if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE + static const uint16_t smoke_lengths[] = {100, 180, 300, 420, 520}; + size_t smoke_index = 0; + uint64_t next_smoke_ms = 12000ULL; +#endif while (true) { ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(50)); @@ -552,6 +586,52 @@ static void lora_task(void *arg) } } +#if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE + if (smoke_index < sizeof(smoke_lengths) / sizeof(smoke_lengths[0]) && + now >= next_smoke_ms) { + static uint8_t probe[BITCHAT_BLE_MAX_PACKET_SIZE]; + uint16_t probe_len = smoke_lengths[smoke_index]; + memset(probe, 0xA5, probe_len); + probe[0] = 1; + probe[1] = BITCHAT_MSG_MESSAGE; + probe[2] = 0; + probe[11] = 0; + uint16_t payload_len = probe_len - 22; + probe[12] = payload_len >> 8; + probe[13] = payload_len & 0xFF; + memcpy(probe + PKT_SENDER_OFF, noise_get_local_peer_id(), 8); + ESP_LOGI(TAG, "diagnostic smoke enqueue len=%u expected_frags=%u", + probe_len, (probe_len + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX); + lora_link_send(BITLE_LORA_LINK_HANDLE, probe, probe_len); + smoke_index++; + next_smoke_ms = now + 12000ULL; + } +#endif + + if (now >= next_diag_ms) { + bitle_lora_diag_t diag; + bitle_lora_get_diag(&diag); + ESP_LOGI(TAG, + "diag raw_rx=%lu crc=%lu tx=%lu tmo=%lu ack_rx=%lu ack_tx=%lu " + "ack_miss=%lu retry=%lu exhausted=%lu q_full=%lu q=%lu/%lu " + "rx_expire=%lu complete=%lu", + (unsigned long)diag.raw_rx_frames, + (unsigned long)diag.crc_errors, + (unsigned long)diag.tx_attempts, + (unsigned long)diag.tx_timeouts, + (unsigned long)diag.ack_rx, + (unsigned long)diag.ack_tx, + (unsigned long)diag.ack_misses, + (unsigned long)diag.retries, + (unsigned long)diag.retry_exhaustion, + (unsigned long)diag.queue_full, + (unsigned long)diag.queue_depth, + (unsigned long)diag.queue_high_water, + (unsigned long)diag.reassembly_expiry, + (unsigned long)diag.completed_packets); + next_diag_ms = now + 60000ULL; + } + sx1262_event_t evt; while ((evt = sx1262_poll_event()) != SX1262_EVT_NONE) { switch (evt) { @@ -573,14 +653,14 @@ static void lora_task(void *arg) * data frame's ack wait, so extend its deadline */ tx_is_ack = false; if (awaiting_ack) { - ack_deadline += lora_airtime_ms(TRUNK_HDR_LEN) + 300; + ack_deadline += trunk_airtime_ms(TRUNK_HDR_LEN) + 300; } } else if (have_pending && pending.want_ack) { /* wait long enough for the peer's ack (its airtime plus * CAD/scheduling slack) before retransmitting */ awaiting_ack = true; ack_deadline = esp_timer_get_time() / 1000ULL + - lora_airtime_ms(TRUNK_HDR_LEN) + ARQ_MARGIN_MS; + trunk_airtime_ms(TRUNK_HDR_LEN) + ARQ_MARGIN_MS; } else { have_pending = false; } @@ -595,6 +675,10 @@ static void lora_task(void *arg) } } else if (have_pending) { if (sx1262_transmit(pending.data, pending.len) == ESP_OK) { + diag_inc(&s_diag.tx_attempts); + if (arq_sends > 0) { + diag_inc(&s_diag.retries); + } awaiting_tx_done = true; arq_sends++; } else { @@ -618,12 +702,14 @@ static void lora_task(void *arg) } break; case SX1262_EVT_TIMEOUT: + diag_inc(&s_diag.tx_timeouts); awaiting_tx_done = false; have_pending = false; awaiting_ack = false; sx1262_resume_rx(); break; case SX1262_EVT_RX_CRC_ERROR: + diag_inc(&s_diag.crc_errors); /* Logged for link-quality visibility: a frame arrived but * was corrupted in flight. */ ESP_LOGI(TAG, "trunk RX CRC error"); @@ -652,6 +738,7 @@ static void lora_task(void *arg) awaiting_ack = false; have_pending = false; } else if (now2 >= ack_deadline) { + diag_inc(&s_diag.ack_misses); awaiting_ack = false; if (arq_sends < ARQ_TRIES) { cad_tries = 0; @@ -661,6 +748,7 @@ static void lora_task(void *arg) have_pending = false; } } else { + diag_inc(&s_diag.retry_exhaustion); ESP_LOGW(TAG, "trunk frame lost after %d sends seq=%u idx=%u", ARQ_TRIES, pseq, pidx); have_pending = false; @@ -683,6 +771,7 @@ static void lora_task(void *arg) if (!have_pending && !awaiting_tx_done && !awaiting_cad && !awaiting_ack && xQueueReceive(s_tx_queue, &pending, 0) == pdTRUE) { + diag_note_queue_depth(); have_pending = true; cad_tries = 0; arq_sends = 0; @@ -701,6 +790,19 @@ bool bitle_lora_active(void) return s_active; } +void bitle_lora_get_diag(bitle_lora_diag_t *out) +{ + if (!out) { + return; + } + taskENTER_CRITICAL(&s_gov_mux); + *out = s_diag; + taskEXIT_CRITICAL(&s_gov_mux); + if (s_tx_queue) { + out->queue_depth = uxQueueMessagesWaiting(s_tx_queue); + } +} + esp_err_t bitle_lora_init(void) { sx1262_config_t cfg = { diff --git a/main/bitle_lora.h b/main/bitle_lora.h index eb7380e..9f73684 100644 --- a/main/bitle_lora.h +++ b/main/bitle_lora.h @@ -17,6 +17,7 @@ #include "esp_err.h" #include +#include #ifdef __cplusplus extern "C" { @@ -30,6 +31,27 @@ esp_err_t bitle_lora_init(void); bool bitle_lora_active(void); +typedef struct { + uint32_t raw_rx_frames; + uint32_t crc_errors; + uint32_t tx_attempts; + uint32_t tx_timeouts; + uint32_t ack_rx; + uint32_t ack_tx; + uint32_t ack_misses; + uint32_t retries; + uint32_t retry_exhaustion; + uint32_t queue_full; + uint32_t reassembly_expiry; + uint32_t completed_packets; + uint32_t queue_depth; + uint32_t queue_high_water; +} bitle_lora_diag_t; + +/* Snapshot transport diagnostics. All counters are monotonic for the boot. + * Safe before radio initialization; the returned structure will be zeroed. */ +void bitle_lora_get_diag(bitle_lora_diag_t *out); + #ifdef __cplusplus } #endif diff --git a/main/lora_airtime.c b/main/lora_airtime.c new file mode 100644 index 0000000..ba0eff6 --- /dev/null +++ b/main/lora_airtime.c @@ -0,0 +1,47 @@ +#include "lora_airtime.h" + +#include + +bool lora_airtime_params_valid(const lora_airtime_params_t *params) +{ + if (!params || params->spreading_factor < 5 || params->spreading_factor > 12 || + params->coding_rate < 5 || params->coding_rate > 8 || + params->preamble_symbols < 4) { + return false; + } + return params->bandwidth_hz == 125000 || + params->bandwidth_hz == 250000 || + params->bandwidth_hz == 500000; +} + +uint32_t lora_airtime_ms(const lora_airtime_params_t *params, uint16_t payload_len) +{ + if (!lora_airtime_params_valid(params) || payload_len == 0 || payload_len > 255) { + return 0; + } + + const double sf = params->spreading_factor; + const double symbol_s = (double)(1u << params->spreading_factor) / + (double)params->bandwidth_hz; + const bool low_data_rate_optimize = + (params->spreading_factor >= 11 && params->bandwidth_hz == 125000) || + (params->spreading_factor >= 12 && params->bandwidth_hz == 250000); + const double numerator = + 8.0 * payload_len - + 4.0 * sf + + 28.0 + + (params->crc_enabled ? 16.0 : 0.0) - + (params->explicit_header ? 0.0 : 20.0); + const double denominator = + 4.0 * (sf - (low_data_rate_optimize ? 2.0 : 0.0)); + double payload_symbols = + ceil(numerator / denominator) * (params->coding_rate); + if (payload_symbols < 0.0) { + payload_symbols = 0.0; + } + payload_symbols += 8.0; + + const double preamble_s = (params->preamble_symbols + 4.25) * symbol_s; + const double total_ms = (preamble_s + payload_symbols * symbol_s) * 1000.0; + return (uint32_t)(total_ms + 0.5); +} diff --git a/main/lora_airtime.h b/main/lora_airtime.h new file mode 100644 index 0000000..52bddbc --- /dev/null +++ b/main/lora_airtime.h @@ -0,0 +1,32 @@ +#ifndef LORA_AIRTIME_H +#define LORA_AIRTIME_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Portable LoRa time-on-air inputs. The calculation is deliberately kept + * independent of ESP-IDF so the exact firmware math can be exercised by the + * host reliability suite. Coding rate is the denominator in 4/5 .. 4/8. */ +typedef struct { + uint8_t spreading_factor; + uint32_t bandwidth_hz; + uint8_t coding_rate; + uint16_t preamble_symbols; + bool explicit_header; + bool crc_enabled; +} lora_airtime_params_t; + +bool lora_airtime_params_valid(const lora_airtime_params_t *params); + +/* Returns rounded milliseconds, or 0 for invalid parameters/payloads. */ +uint32_t lora_airtime_ms(const lora_airtime_params_t *params, uint16_t payload_len); + +#ifdef __cplusplus +} +#endif + +#endif /* LORA_AIRTIME_H */ diff --git a/tests/test_lora_airtime.c b/tests/test_lora_airtime.c new file mode 100644 index 0000000..99a7c31 --- /dev/null +++ b/tests/test_lora_airtime.c @@ -0,0 +1,29 @@ +#include "lora_airtime.h" + +#include +#include + +int main(void) +{ + lora_airtime_params_t params = { + .spreading_factor = 10, + .bandwidth_hz = 125000, + .coding_rate = 5, + .preamble_symbols = 8, + .explicit_header = true, + .crc_enabled = true, + }; + assert(lora_airtime_params_valid(¶ms)); + assert(lora_airtime_ms(¶ms, 136) == 1313); + + const uint32_t expected_ms[] = {51, 93, 165, 330, 659, 1319}; + for (uint8_t sf = 7; sf <= 12; ++sf) { + params.spreading_factor = sf; + assert(lora_airtime_ms(¶ms, 16) == expected_ms[sf - 7]); + } + + params.spreading_factor = 13; + assert(!lora_airtime_params_valid(¶ms)); + assert(lora_airtime_ms(¶ms, 136) == 0); + return 0; +} diff --git a/tests/test_lora_reliability_baseline.py b/tests/test_lora_reliability_baseline.py new file mode 100644 index 0000000..9372e02 --- /dev/null +++ b/tests/test_lora_reliability_baseline.py @@ -0,0 +1,73 @@ +import unittest + +from tools.lora_reliability_sim import ( + DeterministicLink, + LegacyV2Config, + LegacyV2Model, + LinkConfig, + RadioProfile, + airtime_ms, +) + + +class AirtimeModelTests(unittest.TestCase): + def test_sf10_full_v2_frame_airtime(self): + self.assertEqual(airtime_ms(136, RadioProfile(sf=10)), 1313) + + def test_configuration_controls_are_deterministic(self): + config = LinkConfig( + data_loss=0.2, + ack_loss=0.3, + delay_ms=(5, 20), + duplication=0.4, + reorder_window=3, + queue_capacity=6, + seed=27, + ) + first = DeterministicLink(config).deliver(range(10)) + second = DeterministicLink(config).deliver(range(10)) + self.assertEqual(first, second) + self.assertTrue(all(delivery.frame_id < 6 for delivery in first)) + + +class LegacyV2FailureBaselineTests(unittest.TestCase): + def test_sf11_and_sf12_full_frames_exceed_fixed_tx_timeout(self): + for sf in (11, 12): + with self.subTest(sf=sf): + model = LegacyV2Model( + LegacyV2Config(profile=RadioProfile(sf=sf)) + ) + self.assertTrue(model.tx_times_out(136)) + + def test_weak_link_retries_outlive_fixed_reassembly_slot(self): + model = LegacyV2Model() + expired, elapsed_ms = model.reassembly_expires(520, {1, 3}) + self.assertTrue(expired) + self.assertGreater(elapsed_ms, 10_000) + + def test_frame_queue_can_accept_only_part_of_a_packet(self): + model = LegacyV2Model() + accepted, queued_fragments = model.enqueue_non_atomic(520, occupied_slots=10) + self.assertFalse(accepted) + self.assertEqual(queued_fragments, 2) + + def test_ack_from_wrong_source_is_accepted(self): + model = LegacyV2Model() + self.assertTrue( + model.accepts_ack( + 42, + 3, + 42, + 3, + ack_source=b"evil", + expected_source=b"peer", + ) + ) + + def test_broadcast_ack_request_causes_ack_implosion(self): + model = LegacyV2Model() + self.assertEqual(model.broadcast_ack_count(4), 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/lora_hardware_smoke.py b/tools/lora_hardware_smoke.py new file mode 100755 index 0000000..d94be0b --- /dev/null +++ b/tools/lora_hardware_smoke.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Capture concurrent LoRa diagnostics from two serial-connected nodes.""" + +from __future__ import annotations + +import argparse +from collections import Counter +import json +import queue +import re +import threading +import time + +import serial + + +PACKET_RE = re.compile(r"trunk RX packet len=(\d+).*frags=(\d+)") +RAW_RE = re.compile(r"trunk raw RX len=(\d+) rssi=(-?\d+) snr=(-?\d+)") +TX_RE = re.compile(r"trunk TX .*len=(\d+) frags=(\d+)") + + +def reader(label: str, port: str, output: queue.Queue[tuple[str, str]], stop: threading.Event): + with serial.Serial(port, 115200, timeout=0.2) as stream: + while not stop.is_set(): + line = stream.readline().decode("utf-8", errors="replace").rstrip() + if line: + output.put((label, line)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("ports", nargs=2, help="serial ports for the two nodes") + parser.add_argument("--seconds", type=int, default=75) + parser.add_argument("--json-out") + args = parser.parse_args() + + messages: queue.Queue[tuple[str, str]] = queue.Queue() + stop = threading.Event() + threads = [ + threading.Thread(target=reader, args=(f"node-{index + 1}", port, messages, stop)) + for index, port in enumerate(args.ports) + ] + for thread in threads: + thread.start() + + summary: dict[str, dict[str, object]] = { + "node-1": {"rx_fragments": Counter(), "tx_fragments": Counter(), "raw": []}, + "node-2": {"rx_fragments": Counter(), "tx_fragments": Counter(), "raw": []}, + } + deadline = time.monotonic() + args.seconds + try: + while time.monotonic() < deadline: + try: + label, line = messages.get(timeout=0.25) + except queue.Empty: + continue + print(f"[{label}] {line}") + if match := PACKET_RE.search(line): + summary[label]["rx_fragments"][match.group(2)] += 1 + if match := TX_RE.search(line): + summary[label]["tx_fragments"][match.group(2)] += 1 + if match := RAW_RE.search(line): + summary[label]["raw"].append( + { + "length": int(match.group(1)), + "rssi_dbm": int(match.group(2)), + "snr_db": int(match.group(3)), + } + ) + finally: + stop.set() + for thread in threads: + thread.join() + + serializable = { + label: { + "rx_fragments": dict(values["rx_fragments"]), + "tx_fragments": dict(values["tx_fragments"]), + "raw": values["raw"], + } + for label, values in summary.items() + } + print(json.dumps(serializable, indent=2, sort_keys=True)) + if args.json_out: + with open(args.json_out, "w", encoding="utf-8") as output: + json.dump(serializable, output, indent=2, sort_keys=True) + output.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/lora_reliability_sim.py b/tools/lora_reliability_sim.py new file mode 100755 index 0000000..1f09874 --- /dev/null +++ b/tools/lora_reliability_sim.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Deterministic host model for the Bitle LoRa reliability protocol. + +The legacy helpers intentionally preserve v2 behavior so baseline regressions +stay reproducible after the firmware is fixed. The generic link accepts loss, +delay, duplication, reordering, and queue-capacity controls used by later +milestones. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +import random +from typing import Iterable + + +@dataclass(frozen=True) +class RadioProfile: + sf: int = 10 + bandwidth_hz: int = 125_000 + coding_rate: int = 5 + preamble_symbols: int = 8 + crc: bool = True + explicit_header: bool = True + + +def airtime_ms(payload_len: int, profile: RadioProfile) -> int: + if ( + not 1 <= payload_len <= 255 + or not 5 <= profile.sf <= 12 + or profile.bandwidth_hz not in (125_000, 250_000, 500_000) + or not 5 <= profile.coding_rate <= 8 + or profile.preamble_symbols < 4 + ): + raise ValueError("invalid LoRa airtime parameters") + symbol_s = (1 << profile.sf) / profile.bandwidth_hz + low_data_rate = ( + (profile.sf >= 11 and profile.bandwidth_hz == 125_000) + or (profile.sf >= 12 and profile.bandwidth_hz == 250_000) + ) + numerator = ( + 8 * payload_len + - 4 * profile.sf + + 28 + + (16 if profile.crc else 0) + - (0 if profile.explicit_header else 20) + ) + denominator = 4 * (profile.sf - (2 if low_data_rate else 0)) + payload_symbols = 8 + max( + math.ceil(numerator / denominator) * profile.coding_rate, 0 + ) + total_s = ( + profile.preamble_symbols + 4.25 + payload_symbols + ) * symbol_s + return int(total_s * 1000 + 0.5) + + +@dataclass(frozen=True) +class LinkConfig: + data_loss: float = 0.0 + ack_loss: float = 0.0 + delay_ms: tuple[int, int] = (0, 0) + duplication: float = 0.0 + reorder_window: int = 0 + queue_capacity: int = 12 + seed: int = 1 + + +@dataclass(frozen=True) +class Delivery: + frame_id: int + at_ms: int + duplicate: bool = False + + +class DeterministicLink: + def __init__(self, config: LinkConfig): + if not 0.0 <= config.data_loss <= 1.0: + raise ValueError("data_loss must be between zero and one") + if not 0.0 <= config.ack_loss <= 1.0: + raise ValueError("ack_loss must be between zero and one") + if not 0.0 <= config.duplication <= 1.0: + raise ValueError("duplication must be between zero and one") + if config.delay_ms[0] < 0 or config.delay_ms[1] < config.delay_ms[0]: + raise ValueError("invalid delay range") + if config.queue_capacity < 0: + raise ValueError("queue capacity cannot be negative") + self.config = config + self.random = random.Random(config.seed) + + def deliver(self, frame_ids: Iterable[int], *, ack: bool = False) -> list[Delivery]: + loss = self.config.ack_loss if ack else self.config.data_loss + accepted = list(frame_ids)[: self.config.queue_capacity] + delivered: list[Delivery] = [] + clock = 0 + for frame_id in accepted: + if self.random.random() < loss: + continue + clock += self.random.randint(*self.config.delay_ms) + delivered.append(Delivery(frame_id, clock)) + if self.random.random() < self.config.duplication: + delivered.append(Delivery(frame_id, clock + 1, duplicate=True)) + window = self.config.reorder_window + if window > 1: + for start in range(0, len(delivered), window): + block = delivered[start : start + window] + self.random.shuffle(block) + delivered[start : start + window] = block + return delivered + + +@dataclass(frozen=True) +class LegacyV2Config: + profile: RadioProfile = RadioProfile() + chunk_size: int = 120 + header_size: int = 16 + tx_timeout_ms: int = 2_000 + cad_delay_ms: int = 0 + ack_margin_ms: int = 1_200 + retries: int = 3 + reassembly_timeout_ms: int = 10_000 + queue_capacity: int = 12 + + +class LegacyV2Model: + """Small executable specification of the failure-prone v2 semantics.""" + + def __init__(self, config: LegacyV2Config = LegacyV2Config()): + self.config = config + + def fragments(self, packet_len: int) -> list[int]: + if packet_len <= 0: + raise ValueError("packet length must be positive") + return [ + min(self.config.chunk_size, packet_len - offset) + for offset in range(0, packet_len, self.config.chunk_size) + ] + + def tx_times_out(self, frame_len: int) -> bool: + return airtime_ms(frame_len, self.config.profile) > self.config.tx_timeout_ms + + def enqueue_non_atomic( + self, packet_len: int, occupied_slots: int + ) -> tuple[bool, int]: + free = max(self.config.queue_capacity - occupied_slots, 0) + queued = min(len(self.fragments(packet_len)), free) + return queued == len(self.fragments(packet_len)), queued + + @staticmethod + def accepts_ack( + expected_seq: int, + expected_fragment: int, + ack_seq: int, + ack_fragment: int, + ack_source: bytes, + expected_source: bytes, + ) -> bool: + del ack_source, expected_source + return expected_seq == ack_seq and expected_fragment == ack_fragment + + @staticmethod + def broadcast_ack_count(receiver_count: int, ack_requested: bool = True) -> int: + return receiver_count if ack_requested else 0 + + def reassembly_expires( + self, packet_len: int, missed_first_attempt_fragments: set[int] + ) -> tuple[bool, int]: + """Return whether v2's fixed deadline expires and elapsed RX span. + + A lost first attempt consumes a full data airtime plus ACK deadline. + A successful attempt consumes data and ACK airtime. This is sufficient + to reproduce the deterministic timeout conflict without RF randomness. + """ + elapsed = 0 + first_rx_at: int | None = None + ack_airtime = airtime_ms(self.config.header_size, self.config.profile) + for index, chunk in enumerate(self.fragments(packet_len)): + data_airtime = airtime_ms( + self.config.header_size + chunk, self.config.profile + ) + elapsed += self.config.cad_delay_ms + if index in missed_first_attempt_fragments: + elapsed += data_airtime + ack_airtime + self.config.ack_margin_ms + elapsed += self.config.cad_delay_ms + elapsed += data_airtime + if first_rx_at is None: + first_rx_at = elapsed + elapsed += ack_airtime + span = elapsed - (first_rx_at or 0) + return span > self.config.reassembly_timeout_ms, span diff --git a/tools/run_lora_host_tests.sh b/tools/run_lora_host_tests.sh new file mode 100755 index 0000000..dca3841 --- /dev/null +++ b/tools/run_lora_host_tests.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-airtime.XXXXXX")" +trap 'rm -f "$test_bin"' EXIT + +cc -std=c11 -Wall -Wextra -Werror \ + -I"$repo_root/main" \ + "$repo_root/main/lora_airtime.c" \ + "$repo_root/tests/test_lora_airtime.c" \ + -lm -o "$test_bin" +"$test_bin" + +cd "$repo_root" +python3 -m unittest discover -s tests -p 'test_lora_*.py' -v From aedbd7d308b042a4c1aea16968f1a1c5b9a2642e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:29:35 +0200 Subject: [PATCH 05/19] docs(lora): record milestone 0 checkpoint --- docs/LoRa-reliability-implementation-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index e623703..97856c0 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -59,7 +59,7 @@ The implementation is complete only when all of the following are true: | Milestone | Status | Commit | Evidence summary | |---|---|---|---| -| 0. Baseline harness and observability | Complete | Pending checkpoint | Host baseline, both firmware targets, and two-board SF10 smoke passed | +| 0. Baseline harness and observability | Complete | `d130a3a` | Host baseline, both firmware targets, and two-board SF10 smoke passed | | 1. Airtime-safe SX1262 operation | Pending | — | — | | 2. Atomic packet scheduling and backpressure | Pending | — | — | | 3. Addressed trunk protocol and migration | Pending | — | — | @@ -129,7 +129,7 @@ loss. - Runtime diagnostics now separate raw RX, CRC, TX/timeout, ACK, retry, queue pressure, reassembly expiry, and completed-delivery stages. Raw valid frames log RSSI and SNR. -- Commit: Pending checkpoint +- Commit: `d130a3a` - Suggested commit subject: `test(lora): add reliability harness and baseline telemetry` ## Milestone 1: Airtime-safe SX1262 operation From 028511e81c84d7c9af304b7cf17539ebb5731ee6 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:59:37 +0200 Subject: [PATCH 06/19] fix(lora): make SX1262 operation airtime safe --- docs/LoRa-reliability-implementation-plan.md | 47 +- docs/LoRa-testing.md | 67 ++- main/CMakeLists.txt | 1 + main/Kconfig.projbuild | 33 ++ main/bitle_lora.c | 216 ++++++- main/bitle_lora.h | 6 + main/lora_airtime.c | 26 + main/lora_airtime.h | 11 + main/lora_region.c | 65 +++ main/lora_region.h | 35 ++ main/sx1262.c | 568 +++++++++++++------ main/sx1262.h | 25 +- tests/test_lora_airtime.c | 21 + tests/test_lora_region.c | 34 ++ tools/lora_hardware_smoke.py | 74 ++- tools/run_lora_host_tests.sh | 16 +- 16 files changed, 1020 insertions(+), 225 deletions(-) create mode 100644 main/lora_region.c create mode 100644 main/lora_region.h create mode 100644 tests/test_lora_region.c diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index 97856c0..70d575b 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -60,7 +60,7 @@ The implementation is complete only when all of the following are true: | Milestone | Status | Commit | Evidence summary | |---|---|---|---| | 0. Baseline harness and observability | Complete | `d130a3a` | Host baseline, both firmware targets, and two-board SF10 smoke passed | -| 1. Airtime-safe SX1262 operation | Pending | — | — | +| 1. Airtime-safe SX1262 operation | Complete | Pending checkpoint | Host/profile tests, both firmware targets, and SF10-SF12 two-board radio tests passed | | 2. Atomic packet scheduling and backpressure | Pending | — | — | | 3. Addressed trunk protocol and migration | Pending | — | — | | 4. Packet-level reliability and reassembly | Pending | — | — | @@ -141,28 +141,28 @@ for the configured frame size. ### Checklist -- [ ] Replace the fixed two-second SX1262 TX timeout with a timeout derived from +- [x] Replace the fixed two-second SX1262 TX timeout with a timeout derived from calculated packet airtime plus bounded scheduling and radio margin. -- [ ] Clamp the converted SX1262 timeout to the command's valid 24-bit range. -- [ ] Add an independent software watchdog so a missing TX interrupt still +- [x] Clamp the converted SX1262 timeout to the command's valid 24-bit range. +- [x] Add an independent software watchdog so a missing TX interrupt still recovers the radio. -- [ ] Verify TX timeout handling distinguishes expected watchdog recovery from +- [x] Verify TX timeout handling distinguishes expected watchdog recovery from an RF delivery failure. -- [ ] Propagate and check errors from radio configuration commands instead of +- [x] Propagate and check errors from radio configuration commands instead of silently continuing after failed SPI operations. -- [ ] Add BUSY-stuck recovery and a bounded radio reinitialization path. -- [ ] Make preamble length a profile parameter and set the robust default to at +- [x] Add BUSY-stuck recovery and a bounded radio reinitialization path. +- [x] Make preamble length a profile parameter and set the robust default to at least 16 symbols. -- [ ] Apply the Heltec-compatible boosted RX gain setting and verify that it is +- [x] Apply the Heltec-compatible boosted RX gain setting and verify that it is retained across radio mode changes. -- [ ] Explicitly configure and verify the PA current limit needed by each +- [x] Explicitly configure and verify the PA current limit needed by each supported board and TX power. -- [ ] Introduce explicit regional profiles, initially EU868 and US915. -- [ ] Select image calibration bytes from the active regional frequency rather +- [x] Introduce explicit regional profiles, initially EU868 and US915. +- [x] Select image calibration bytes from the active regional frequency rather than hardcoding the US band. -- [ ] Reject invalid persisted combinations instead of silently mixing a +- [x] Reject invalid persisted combinations instead of silently mixing a frequency with the wrong calibration or profile. -- [ ] Add airtime and timeout tests for minimum, typical, and maximum frames at +- [x] Add airtime and timeout tests for minimum, typical, and maximum frames at SF7 through SF12. ### Success criteria @@ -177,9 +177,24 @@ for the configured frame size. ### Milestone record -- Status: Pending +- Status: Complete - Evidence: -- Commit: + - Portable C tests cover 16-, 136-, and 255-byte frames at SF7 through SF12, + exact timeout margins, 24-bit SX1262 timeout conversion/clamping, and + US915/EU868 frequency, inference, mismatch, and calibration behavior. + - ESP-IDF 6.0 builds pass for the Heltec V3 ESP32-S3 configuration and the + BLE-only ESP32-C3 target. + - Two-board captures at SF10, SF11, and SF12 used the robust 16-symbol + preamble and received maximum 136-byte trunk frames at every profile. + Both radios reported zero SX1262 timeouts, command errors, and BUSY + timeouts at all three spreading factors. + - At every tested spreading factor, deliberately suppressing one TX_DONE + produced exactly one software-watchdog expiry and one successful bounded + radio recovery, with zero recovery failures and continued frame exchange. + - Radio initialization now verifies boosted RX gain and the 140 mA PA + over-current setting by register readback. Receive gain is rechecked every + time continuous RX is resumed. +- Commit: Pending checkpoint - Suggested commit subject: `fix(lora): make SX1262 operation airtime safe` ## Milestone 2: Atomic packet scheduling and backpressure diff --git a/docs/LoRa-testing.md b/docs/LoRa-testing.md index 02c722d..d2d405e 100644 --- a/docs/LoRa-testing.md +++ b/docs/LoRa-testing.md @@ -9,13 +9,18 @@ an opt-in two-board smoke test. Run the host suite before every firmware build. tools/run_lora_host_tests.sh ``` -The suite compiles the same portable airtime implementation used by firmware -and runs the deterministic link simulator. Its legacy-v2 cases intentionally -assert the original failures: SF11/SF12 exceeding the fixed two-second timeout, -fixed reassembly expiry under retries, partial frame enqueue, acceptance of an -ACK from the wrong source, and broadcast ACK implosion. Those cases remain as -an executable historical baseline while later milestones add fixed-protocol -acceptance cases. +The suite compiles the same portable airtime and regional-profile +implementations used by firmware and runs the deterministic link simulator. +It checks minimum (16-byte), typical (136-byte), and maximum (255-byte) radio +frames at SF7 through SF12, including timeout conversion and 24-bit clamping. +It also checks US915/EU868 boundaries, defaults, inference, profile mismatch +rejection, and image-calibration selection. + +The legacy-v2 simulator cases intentionally assert the original protocol +failures: fixed reassembly expiry under retries, partial frame enqueue, +acceptance of an ACK from the wrong source, and broadcast ACK implosion. The +historical SF11/SF12 case records why the former fixed two-second timeout was +unsafe; the firmware now uses the tested airtime-derived timeout instead. The simulator controls data loss, ACK loss, delay, duplication, reordering, queue capacity, radio profile, fragment size, retry count, CAD allowance, and @@ -31,7 +36,23 @@ source ../esp-idf/export.sh idf.py build ``` -## Two-board SF10 smoke test +## Radio profiles + +The build-time defaults are configured under **Bitle hardware**: + +- `CONFIG_BITLE_LORA_REGION_US915` or + `CONFIG_BITLE_LORA_REGION_EU868`; +- `CONFIG_BITLE_LORA_DEFAULT_SF`, from SF7 through SF12; +- `CONFIG_BITLE_LORA_PREAMBLE_SYMBOLS`, with a robust default of 16. + +The `lora` NVS namespace may override these defaults with `region` (`u8`, +US915 = 0, EU868 = 1), `freq` (`u32` Hz), and `sf` (`u8`). A frequency must +fall inside its explicit profile. A mismatched pair, out-of-profile frequency, +unknown profile, malformed value, or invalid spreading factor is rejected and +counted in `config_rejections`. A legacy store containing only `freq` infers +the profile when the frequency is unambiguous. + +## Two-board SF10/SF11/SF12 smoke test The production default keeps `CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE` disabled. For a controlled test, enable it on exactly one Heltec V3 build. That node @@ -41,17 +62,39 @@ transmits one synthetic packet at each of 1, 2, 3, 4, and 5 fragments, spaced Capture both serial streams concurrently: ```bash -python tools/lora_hardware_smoke.py PORT_A PORT_B --seconds 90 \ +python tools/lora_hardware_smoke.py PORT_A PORT_B --seconds 90 --reset --quiet \ --json-out /tmp/bitle-lora-smoke.json ``` The capture summarizes TX and completed RX counts by fragment count and retains -per-frame RSSI/SNR. Raw serial output and JSON evidence may contain local device -details, so keep it outside the repository. +per-frame RSSI/SNR, the active profile, watchdog/recovery events, and periodic +radio diagnostics. Raw serial output and JSON evidence may contain local +device details, so keep it outside the repository. + +Repeat with `CONFIG_BITLE_LORA_DEFAULT_SF` set to 10, 11, and 12 on both +boards. At each SF, verify that the receiver records 136-byte frames and both +nodes report zero `radio_timeouts`, `radio_command_errors`, and +`busy_timeouts`. + +To exercise the missing-interrupt path, additionally enable +`CONFIG_BITLE_LORA_DIAGNOSTIC_SUPPRESS_TX_DONE_ONCE` on the probe sender. It +depends on diagnostic smoke mode and suppresses exactly one task-level +`TX_DONE`. The sender must then report one `tx_done_suppressed`, one +`tx_watchdog_expired`, one successful `radio_recovered`, and zero recovery +failures while later traffic continues. Never deploy either diagnostic option. Runtime firmware emits a one-minute diagnostic summary containing: - valid raw RX frames and CRC failures; - TX attempts/timeouts, retries, ACK RX/TX/misses, and retry exhaustion; - current/high-water queue depth and queue-full events; -- expired reassemblies and completed packets. +- expired reassemblies and completed packets; +- rejected persisted configurations; +- low-level command/BUSY errors, radio recoveries/failures, and independent TX + watchdog recoveries. + +Milestone 1's two-board validation met those checks at SF10, SF11, and SF12 +with a 16-symbol preamble. Full 136-byte frames were received at every SF; all +three runs reported zero SX1262 timeouts, command errors, and BUSY timeouts. +Each injected run produced exactly one watchdog recovery with no recovery +failure, then continued exchanging frames. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 5132223..24bc0d5 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -10,6 +10,7 @@ idf_component_register( "bitle_mesh.c" "bitle_lora.c" "lora_airtime.c" + "lora_region.c" "sx1262.c" "bitle_ota.c" "bitle_store.c" diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index ec604a0..0c7a0f0 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -79,4 +79,37 @@ menu "Bitle hardware" with this enabled and leave receiving nodes on the normal build. Never enable it in deployed firmware. + choice BITLE_LORA_REGION + prompt "Default LoRa regional profile" + depends on IDF_TARGET_ESP32S3 + default BITLE_LORA_REGION_US915 + + config BITLE_LORA_REGION_US915 + bool "US915 (902-928 MHz)" + + config BITLE_LORA_REGION_EU868 + bool "EU868 (863-870 MHz)" + endchoice + + config BITLE_LORA_DEFAULT_SF + int "Default LoRa spreading factor" + depends on IDF_TARGET_ESP32S3 + range 7 12 + default 10 + + config BITLE_LORA_PREAMBLE_SYMBOLS + int "LoRa preamble symbols" + depends on IDF_TARGET_ESP32S3 + range 8 64 + default 16 + + config BITLE_LORA_DIAGNOSTIC_SUPPRESS_TX_DONE_ONCE + bool "Suppress one TX_DONE event to exercise recovery" + depends on BITLE_LORA_DIAGNOSTIC_SMOKE + default n + help + Test-only fault injection. The LoRa task ignores its first TX_DONE + event so the independent software watchdog must reset the radio and + return it to continuous RX. Never enable it in deployed firmware. + endmenu diff --git a/main/bitle_lora.c b/main/bitle_lora.c index fdc7398..42eed51 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -17,6 +17,7 @@ #include "bitle_mesh.h" #include "bitle_stats.h" #include "lora_airtime.h" +#include "lora_region.h" #include "noise_handshake.h" #include "packet_codec.h" #include "sx1262.h" @@ -78,6 +79,24 @@ static const char *TAG = "bitle_lora"; #define LORA_PIN_RXEN -1 #endif +#if defined(CONFIG_BITLE_LORA_REGION_EU868) && CONFIG_BITLE_LORA_REGION_EU868 +#define LORA_DEFAULT_REGION LORA_REGION_EU868 +#else +#define LORA_DEFAULT_REGION LORA_REGION_US915 +#endif + +#ifdef CONFIG_BITLE_LORA_DEFAULT_SF +#define LORA_DEFAULT_SF CONFIG_BITLE_LORA_DEFAULT_SF +#else +#define LORA_DEFAULT_SF 10 +#endif + +#ifdef CONFIG_BITLE_LORA_PREAMBLE_SYMBOLS +#define LORA_PREAMBLE_SYMBOLS CONFIG_BITLE_LORA_PREAMBLE_SYMBOLS +#else +#define LORA_PREAMBLE_SYMBOLS 16 +#endif + /* Trunk frame header v2 (16 bytes): * [0..1] magic 0xB7 0x1E [2] version 0x02 * [3] ftype: bit0 = ACK frame, bit1 = ack requested @@ -133,6 +152,7 @@ static uint8_t s_ack_idx; static uint8_t s_sf; static uint32_t s_bw; static uint8_t s_cr; /* denominator 5..8 => 4/5..4/8 */ +static uint16_t s_preamble_symbols; static bool s_ota_over_trunk; /* OTA image chunks on the trunk (default off) */ static bitle_lora_diag_t s_diag; @@ -155,7 +175,7 @@ static uint32_t trunk_airtime_ms(uint16_t payload_len) .spreading_factor = s_sf, .bandwidth_hz = s_bw, .coding_rate = s_cr, - .preamble_symbols = 8, + .preamble_symbols = s_preamble_symbols, .explicit_header = true, .crc_enabled = true, }; @@ -308,6 +328,28 @@ static void IRAM_ATTR dio1_isr(void *arg) portYIELD_FROM_ISR(woken); } +static bool recover_radio(const char *reason) +{ + ESP_LOGW(TAG, "radio recovery requested: %s", reason); + esp_err_t err = sx1262_recover(); + if (err == ESP_OK) { + return true; + } + s_active = false; + ESP_LOGE(TAG, "radio recovery failed: %s", esp_err_to_name(err)); + return false; +} + +static bool resume_rx_or_recover(const char *reason) +{ + esp_err_t err = sx1262_resume_rx(); + if (err == ESP_OK) { + return true; + } + ESP_LOGW(TAG, "resume RX failed: %s", esp_err_to_name(err)); + return recover_radio(reason); +} + /* True length of the self-describing BitChat packet, dropping any trailing * MessagePadding (phones pad handshakes/DMs to 256 B for BLE traffic-analysis * resistance — a pure BLE-MTU artifact that just bloats scarce LoRa airtime). @@ -450,7 +492,7 @@ static bool transmit_ack_now(void) ESP_LOGI(TAG, "ack TX seq=%u idx=%u", a->seq, a->idx); return true; } - sx1262_resume_rx(); + recover_radio("ACK transmit command"); return false; } @@ -566,6 +608,7 @@ static void lora_task(void *arg) bool tx_is_ack = false; bool awaiting_cad = false; bool awaiting_ack = false; + uint64_t tx_watchdog_deadline = 0; uint64_t ack_deadline = 0; uint64_t next_beacon_ms = BEACON_FIRST_MS; uint64_t next_diag_ms = 60000ULL; @@ -574,6 +617,9 @@ static void lora_task(void *arg) size_t smoke_index = 0; uint64_t next_smoke_ms = 12000ULL; #endif +#if CONFIG_BITLE_LORA_DIAGNOSTIC_SUPPRESS_TX_DONE_ONCE + bool suppressed_tx_done = false; +#endif while (true) { ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(50)); @@ -614,7 +660,8 @@ static void lora_task(void *arg) ESP_LOGI(TAG, "diag raw_rx=%lu crc=%lu tx=%lu tmo=%lu ack_rx=%lu ack_tx=%lu " "ack_miss=%lu retry=%lu exhausted=%lu q_full=%lu q=%lu/%lu " - "rx_expire=%lu complete=%lu", + "rx_expire=%lu complete=%lu cfg_reject=%lu radio_err=%lu " + "busy_tmo=%lu recover=%lu/%lu tx_watchdog=%lu", (unsigned long)diag.raw_rx_frames, (unsigned long)diag.crc_errors, (unsigned long)diag.tx_attempts, @@ -628,7 +675,13 @@ static void lora_task(void *arg) (unsigned long)diag.queue_depth, (unsigned long)diag.queue_high_water, (unsigned long)diag.reassembly_expiry, - (unsigned long)diag.completed_packets); + (unsigned long)diag.completed_packets, + (unsigned long)diag.config_rejections, + (unsigned long)diag.radio_command_errors, + (unsigned long)diag.radio_busy_timeouts, + (unsigned long)diag.radio_recoveries, + (unsigned long)diag.radio_recovery_failures, + (unsigned long)diag.tx_watchdog_recoveries); next_diag_ms = now + 60000ULL; } @@ -646,8 +699,16 @@ static void lora_task(void *arg) break; } case SX1262_EVT_TX_DONE: +#if CONFIG_BITLE_LORA_DIAGNOSTIC_SUPPRESS_TX_DONE_ONCE + if (!suppressed_tx_done) { + suppressed_tx_done = true; + ESP_LOGW(TAG, "diagnostic: suppressed one TX_DONE event"); + break; + } +#endif awaiting_tx_done = false; - sx1262_resume_rx(); + tx_watchdog_deadline = 0; + resume_rx_or_recover("TX_DONE resume RX"); if (tx_is_ack) { /* our ack went out; the interleave delayed any pending * data frame's ack wait, so extend its deadline */ @@ -672,6 +733,9 @@ static void lora_task(void *arg) if (transmit_ack_now()) { awaiting_tx_done = true; tx_is_ack = true; + tx_watchdog_deadline = + esp_timer_get_time() / 1000ULL + + sx1262_tx_watchdog_ms(TRUNK_HDR_LEN); } } else if (have_pending) { if (sx1262_transmit(pending.data, pending.len) == ESP_OK) { @@ -680,10 +744,14 @@ static void lora_task(void *arg) diag_inc(&s_diag.retries); } awaiting_tx_done = true; + tx_is_ack = false; + tx_watchdog_deadline = + esp_timer_get_time() / 1000ULL + + sx1262_tx_watchdog_ms(pending.len); arq_sends++; } else { have_pending = false; - sx1262_resume_rx(); + recover_radio("data transmit command"); } } break; @@ -694,19 +762,29 @@ static void lora_task(void *arg) (esp_random() % CAD_BACKOFF_SPAN))); if (sx1262_start_cad() == ESP_OK) { awaiting_cad = true; + } else { + have_pending = false; + recover_radio("CAD retry command"); } } else { /* channel persistently busy: drop the frame */ have_pending = false; - sx1262_resume_rx(); + resume_rx_or_recover("CAD busy resume RX"); } break; case SX1262_EVT_TIMEOUT: diag_inc(&s_diag.tx_timeouts); awaiting_tx_done = false; - have_pending = false; + tx_watchdog_deadline = 0; + tx_is_ack = false; awaiting_ack = false; - sx1262_resume_rx(); + /* A radio timeout is an expected, separately counted delivery + * failure. Re-enter RX and let an ack-requested data frame use + * its remaining ARQ attempts. */ + resume_rx_or_recover("radio TX timeout"); + if (!have_pending || !pending.want_ack || arq_sends >= ARQ_TRIES) { + have_pending = false; + } break; case SX1262_EVT_RX_CRC_ERROR: diag_inc(&s_diag.crc_errors); @@ -714,6 +792,16 @@ static void lora_task(void *arg) * was corrupted in flight. */ ESP_LOGI(TAG, "trunk RX CRC error"); break; + case SX1262_EVT_ERROR: + awaiting_tx_done = false; + awaiting_cad = false; + awaiting_ack = false; + tx_is_ack = false; + tx_watchdog_deadline = 0; + if (!recover_radio("IRQ status command")) { + have_pending = false; + } + break; default: break; } @@ -725,6 +813,26 @@ static void lora_task(void *arg) if (transmit_ack_now()) { awaiting_tx_done = true; tx_is_ack = true; + tx_watchdog_deadline = + esp_timer_get_time() / 1000ULL + + sx1262_tx_watchdog_ms(TRUNK_HDR_LEN); + } + } + + /* Independent of DIO1 and the radio's own timeout IRQ. If TX_DONE is + * lost, the task must not remain permanently stuck in its TX state. */ + uint64_t watchdog_now = esp_timer_get_time() / 1000ULL; + if (awaiting_tx_done && tx_watchdog_deadline != 0 && + watchdog_now >= tx_watchdog_deadline) { + diag_inc(&s_diag.tx_watchdog_recoveries); + ESP_LOGW(TAG, "TX watchdog expired; resetting radio"); + awaiting_tx_done = false; + awaiting_cad = false; + awaiting_ack = false; + tx_is_ack = false; + tx_watchdog_deadline = 0; + if (!recover_radio("software TX watchdog")) { + have_pending = false; } } @@ -746,6 +854,7 @@ static void lora_task(void *arg) awaiting_cad = true; /* retransmit */ } else { have_pending = false; + recover_radio("ARQ CAD command"); } } else { diag_inc(&s_diag.retry_exhaustion); @@ -766,6 +875,7 @@ static void lora_task(void *arg) awaiting_cad = true; } else { have_pending = false; + recover_radio("pending CAD command"); } } @@ -780,6 +890,7 @@ static void lora_task(void *arg) awaiting_cad = true; } else { have_pending = false; + recover_radio("dequeue CAD command"); } } } @@ -801,10 +912,18 @@ void bitle_lora_get_diag(bitle_lora_diag_t *out) if (s_tx_queue) { out->queue_depth = uxQueueMessagesWaiting(s_tx_queue); } + sx1262_diag_t radio_diag; + sx1262_get_diag(&radio_diag); + out->radio_command_errors = radio_diag.command_errors; + out->radio_busy_timeouts = radio_diag.busy_timeouts; + out->radio_recoveries = radio_diag.recoveries; + out->radio_recovery_failures = radio_diag.recovery_failures; } esp_err_t bitle_lora_init(void) { + const lora_region_profile_t *default_profile = + lora_region_profile(LORA_DEFAULT_REGION); sx1262_config_t cfg = { .pin_sck = LORA_PIN_SCK, .pin_miso = LORA_PIN_MISO, @@ -814,27 +933,77 @@ esp_err_t bitle_lora_init(void) .pin_busy = LORA_PIN_BUSY, .pin_dio1 = LORA_PIN_DIO1, .pin_rxen = LORA_PIN_RXEN, - .freq_hz = 911500000, - .sf = 10, + .freq_hz = default_profile->default_hz, + .sf = LORA_DEFAULT_SF, .bw_hz = 125000, .cr = 5, + .preamble_symbols = LORA_PREAMBLE_SYMBOLS, .tx_dbm = 22, + .region = LORA_DEFAULT_REGION, }; - /* NVS overrides (namespace "lora"): u32 freq, u8 sf, u8 enabled, - * u8 duty_pct (1..50, default 25), u8 ota_trunk (0/1, default 0). */ + /* NVS overrides (namespace "lora"): u8 region, u32 freq, u8 sf, + * u8 enabled, u8 duty_pct (1..50, default 25), and u8 ota_trunk. + * A region/frequency mismatch is rejected as a pair. Legacy stores with + * only a frequency infer their regional profile from that frequency. */ uint8_t duty_pct = 25; nvs_handle_t nvs; if (nvs_open("lora", NVS_READONLY, &nvs) == ESP_OK) { uint8_t enabled = 1; nvs_get_u8(nvs, "enabled", &enabled); + + uint8_t stored_region = 0; uint32_t freq = 0; - if (nvs_get_u32(nvs, "freq", &freq) == ESP_OK && freq >= 902000000 && freq <= 928000000) { - cfg.freq_hz = freq; + esp_err_t region_status = nvs_get_u8(nvs, "region", &stored_region); + esp_err_t freq_status = nvs_get_u32(nvs, "freq", &freq); + bool stored_config_readable = + (region_status == ESP_OK || region_status == ESP_ERR_NVS_NOT_FOUND) && + (freq_status == ESP_OK || freq_status == ESP_ERR_NVS_NOT_FOUND); + if (!stored_config_readable) { + diag_inc(&s_diag.config_rejections); + ESP_LOGE(TAG, "rejected malformed NVS LoRa region/frequency value"); + } else if (region_status == ESP_OK && freq_status == ESP_OK) { + if (stored_region < LORA_REGION_COUNT && + lora_region_frequency_valid((lora_region_id_t)stored_region, freq)) { + cfg.region = (lora_region_id_t)stored_region; + cfg.freq_hz = freq; + } else { + diag_inc(&s_diag.config_rejections); + ESP_LOGE(TAG, "rejected invalid NVS LoRa region/frequency pair"); + } + } else if (region_status == ESP_OK) { + const lora_region_profile_t *stored_profile = + lora_region_profile((lora_region_id_t)stored_region); + if (stored_profile) { + cfg.region = stored_profile->id; + cfg.freq_hz = stored_profile->default_hz; + } else { + diag_inc(&s_diag.config_rejections); + ESP_LOGE(TAG, "rejected invalid NVS LoRa region"); + } + } else if (freq_status == ESP_OK) { + lora_region_id_t inferred; + if (lora_region_infer(freq, &inferred)) { + cfg.region = inferred; + cfg.freq_hz = freq; + } else { + diag_inc(&s_diag.config_rejections); + ESP_LOGE(TAG, "rejected out-of-profile NVS LoRa frequency"); + } } + uint8_t sf = 0; - if (nvs_get_u8(nvs, "sf", &sf) == ESP_OK && sf >= 7 && sf <= 12) { - cfg.sf = sf; + esp_err_t sf_status = nvs_get_u8(nvs, "sf", &sf); + if (sf_status == ESP_OK) { + if (sf >= 7 && sf <= 12) { + cfg.sf = sf; + } else { + diag_inc(&s_diag.config_rejections); + ESP_LOGE(TAG, "rejected invalid NVS spreading factor"); + } + } else if (sf_status != ESP_ERR_NVS_NOT_FOUND) { + diag_inc(&s_diag.config_rejections); + ESP_LOGE(TAG, "rejected malformed NVS spreading factor"); } uint8_t d = 0; if (nvs_get_u8(nvs, "duty_pct", &d) == ESP_OK && d >= 1 && d <= 50) { @@ -872,6 +1041,7 @@ esp_err_t bitle_lora_init(void) s_sf = cfg.sf; s_bw = cfg.bw_hz; s_cr = cfg.cr; + s_preamble_symbols = cfg.preamble_symbols; s_gov_refill_frac = duty_pct / 100.0; s_gov_credit_ms = GOV_BURST_MS; s_gov_last_ms = esp_timer_get_time() / 1000ULL; @@ -887,13 +1057,21 @@ esp_err_t bitle_lora_init(void) esp_err_t err = sx1262_init(&cfg, dio1_isr, s_task); if (err != ESP_OK) { ESP_LOGE(TAG, "radio init failed: %s", esp_err_to_name(err)); + vTaskDelete(s_task); + s_task = NULL; + vQueueDelete(s_tx_queue); + s_tx_queue = NULL; return err; } s_active = true; bitle_link_register(BITLE_LORA_LINK_HANDLE, BITLE_LINK_LORA, lora_link_send); - ESP_LOGI(TAG, "trunk up: %.3f MHz SF%u BW%lu +%ddBm duty=%u%% ota_trunk=%d", - cfg.freq_hz / 1e6, cfg.sf, (unsigned long)cfg.bw_hz, cfg.tx_dbm, + const lora_region_profile_t *active_profile = lora_region_profile(cfg.region); + ESP_LOGI(TAG, + "trunk up: region=%s %.3f MHz SF%u BW%lu preamble=%u +%ddBm " + "duty=%u%% ota_trunk=%d", + active_profile->name, cfg.freq_hz / 1e6, cfg.sf, + (unsigned long)cfg.bw_hz, cfg.preamble_symbols, cfg.tx_dbm, duty_pct, s_ota_over_trunk); return ESP_OK; } diff --git a/main/bitle_lora.h b/main/bitle_lora.h index 9f73684..f28719a 100644 --- a/main/bitle_lora.h +++ b/main/bitle_lora.h @@ -46,6 +46,12 @@ typedef struct { uint32_t completed_packets; uint32_t queue_depth; uint32_t queue_high_water; + uint32_t config_rejections; + uint32_t radio_command_errors; + uint32_t radio_busy_timeouts; + uint32_t radio_recoveries; + uint32_t radio_recovery_failures; + uint32_t tx_watchdog_recoveries; } bitle_lora_diag_t; /* Snapshot transport diagnostics. All counters are monotonic for the boot. diff --git a/main/lora_airtime.c b/main/lora_airtime.c index ba0eff6..f292c55 100644 --- a/main/lora_airtime.c +++ b/main/lora_airtime.c @@ -1,5 +1,6 @@ #include "lora_airtime.h" +#include #include bool lora_airtime_params_valid(const lora_airtime_params_t *params) @@ -45,3 +46,28 @@ uint32_t lora_airtime_ms(const lora_airtime_params_t *params, uint16_t payload_l const double total_ms = (preamble_s + payload_symbols * symbol_s) * 1000.0; return (uint32_t)(total_ms + 0.5); } + +uint32_t lora_tx_timeout_ms(const lora_airtime_params_t *params, + uint16_t payload_len, + uint32_t minimum_margin_ms, + uint8_t margin_percent) +{ + uint32_t airtime = lora_airtime_ms(params, payload_len); + if (airtime == 0) { + return 0; + } + uint64_t percent_margin = ((uint64_t)airtime * margin_percent + 99u) / 100u; + uint64_t margin = percent_margin > minimum_margin_ms + ? percent_margin + : minimum_margin_ms; + uint64_t timeout = (uint64_t)airtime + margin; + return timeout > UINT32_MAX ? UINT32_MAX : (uint32_t)timeout; +} + +uint32_t lora_sx126x_timeout_units(uint32_t timeout_ms) +{ + uint64_t units = (uint64_t)timeout_ms * 64u; + return units > LORA_SX126X_TIMEOUT_UNITS_MAX + ? LORA_SX126X_TIMEOUT_UNITS_MAX + : (uint32_t)units; +} diff --git a/main/lora_airtime.h b/main/lora_airtime.h index 52bddbc..0ae8434 100644 --- a/main/lora_airtime.h +++ b/main/lora_airtime.h @@ -25,6 +25,17 @@ bool lora_airtime_params_valid(const lora_airtime_params_t *params); /* Returns rounded milliseconds, or 0 for invalid parameters/payloads. */ uint32_t lora_airtime_ms(const lora_airtime_params_t *params, uint16_t payload_len); +/* Derive a radio timeout from airtime plus the larger of a fixed or percentage + * margin. Returns zero for invalid inputs. */ +uint32_t lora_tx_timeout_ms(const lora_airtime_params_t *params, + uint16_t payload_len, + uint32_t minimum_margin_ms, + uint8_t margin_percent); + +/* SX126x timeout commands use 15.625 us units in a 24-bit field. */ +#define LORA_SX126X_TIMEOUT_UNITS_MAX 0xFFFFFFu +uint32_t lora_sx126x_timeout_units(uint32_t timeout_ms); + #ifdef __cplusplus } #endif diff --git a/main/lora_region.c b/main/lora_region.c new file mode 100644 index 0000000..8bf1d16 --- /dev/null +++ b/main/lora_region.c @@ -0,0 +1,65 @@ +#include "lora_region.h" + +#include + +static const lora_region_profile_t PROFILES[] = { + { + .id = LORA_REGION_US915, + .name = "US915", + .minimum_hz = 902000000, + .maximum_hz = 928000000, + .default_hz = 911500000, + .image_calibration = {0xE1, 0xE9}, + }, + { + .id = LORA_REGION_EU868, + .name = "EU868", + .minimum_hz = 863000000, + .maximum_hz = 870000000, + .default_hz = 869525000, + .image_calibration = {0xD7, 0xDB}, + }, +}; + +const lora_region_profile_t *lora_region_profile(lora_region_id_t id) +{ + for (size_t i = 0; i < sizeof(PROFILES) / sizeof(PROFILES[0]); ++i) { + if (PROFILES[i].id == id) { + return &PROFILES[i]; + } + } + return NULL; +} + +bool lora_region_frequency_valid(lora_region_id_t id, uint32_t frequency_hz) +{ + const lora_region_profile_t *profile = lora_region_profile(id); + return profile && frequency_hz >= profile->minimum_hz && + frequency_hz <= profile->maximum_hz; +} + +bool lora_region_infer(uint32_t frequency_hz, lora_region_id_t *out) +{ + for (size_t i = 0; i < sizeof(PROFILES) / sizeof(PROFILES[0]); ++i) { + if (frequency_hz >= PROFILES[i].minimum_hz && + frequency_hz <= PROFILES[i].maximum_hz) { + if (out) { + *out = PROFILES[i].id; + } + return true; + } + } + return false; +} + +bool lora_region_image_calibration(uint32_t frequency_hz, uint8_t out[2]) +{ + lora_region_id_t id; + if (!out || !lora_region_infer(frequency_hz, &id)) { + return false; + } + const lora_region_profile_t *profile = lora_region_profile(id); + out[0] = profile->image_calibration[0]; + out[1] = profile->image_calibration[1]; + return true; +} diff --git a/main/lora_region.h b/main/lora_region.h new file mode 100644 index 0000000..0fac9f6 --- /dev/null +++ b/main/lora_region.h @@ -0,0 +1,35 @@ +#ifndef LORA_REGION_H +#define LORA_REGION_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + LORA_REGION_US915 = 0, + LORA_REGION_EU868 = 1, + LORA_REGION_COUNT, +} lora_region_id_t; + +typedef struct { + lora_region_id_t id; + const char *name; + uint32_t minimum_hz; + uint32_t maximum_hz; + uint32_t default_hz; + uint8_t image_calibration[2]; +} lora_region_profile_t; + +const lora_region_profile_t *lora_region_profile(lora_region_id_t id); +bool lora_region_frequency_valid(lora_region_id_t id, uint32_t frequency_hz); +bool lora_region_infer(uint32_t frequency_hz, lora_region_id_t *out); +bool lora_region_image_calibration(uint32_t frequency_hz, uint8_t out[2]); + +#ifdef __cplusplus +} +#endif + +#endif /* LORA_REGION_H */ diff --git a/main/sx1262.c b/main/sx1262.c index bac2230..85d5eb6 100644 --- a/main/sx1262.c +++ b/main/sx1262.c @@ -9,6 +9,9 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "lora_airtime.h" +#include "lora_region.h" + static const char *TAG = "sx1262"; /* SX126x opcodes (datasheet ch. 13) */ @@ -38,127 +41,212 @@ static const char *TAG = "sx1262"; #define OP_READ_BUFFER 0x1E #define OP_GET_RX_BUFFER_STATUS 0x13 #define OP_GET_PACKET_STATUS 0x14 -#define OP_GET_STATUS 0xC0 #define IRQ_TX_DONE (1u << 0) #define IRQ_RX_DONE (1u << 1) +#define IRQ_CRC_ERR (1u << 6) #define IRQ_CAD_DONE (1u << 7) #define IRQ_CAD_DETECTED (1u << 8) -#define IRQ_CRC_ERR (1u << 6) #define IRQ_TIMEOUT (1u << 9) #define IRQ_ALL 0x03FF #define REG_LORA_SYNC_MSB 0x0740 +#define REG_RX_GAIN 0x08AC +#define REG_OCP 0x08E7 + /* Private-network sync word, distinct from Meshtastic/public (0x2444). */ -#define SYNC_MSB 0x14 -#define SYNC_LSB 0x24 +#define SYNC_MSB 0x14 +#define SYNC_LSB 0x24 +#define RX_GAIN_BOOSTED 0x96 +/* OCP is programmed in 2.5 mA steps: 0x38 = 140 mA. */ +#define OCP_140_MA 0x38 + +#define BUSY_POLL_US 10 +#define BUSY_TIMEOUT_US 40000 +#define RECOVERY_ATTEMPTS 2 +#define TX_MIN_MARGIN_MS 500 +#define TX_MARGIN_PERCENT 25 +#define TX_WATCHDOG_MARGIN_MS 1000 static spi_device_handle_t s_spi; static sx1262_config_t s_cfg; +static sx1262_diag_t s_diag; static bool s_ready; -static void busy_wait(void) +static void note_command_error(esp_err_t err, const char *operation) { - /* BUSY falls when the chip can accept the next command (<~100us typical, - * up to 3.5ms after TCXO-related ops). */ - for (int i = 0; i < 4000; ++i) { + s_diag.command_errors++; + ESP_LOGW(TAG, "%s failed: %s", operation, esp_err_to_name(err)); +} + +static esp_err_t busy_wait(void) +{ + /* BUSY falls when the chip can accept the next command (<~100 us + * typical, up to a few ms after TCXO-related operations). Never issue + * another SPI command while BUSY remains asserted. */ + for (int elapsed = 0; elapsed < BUSY_TIMEOUT_US; elapsed += BUSY_POLL_US) { if (gpio_get_level(s_cfg.pin_busy) == 0) { - return; + return ESP_OK; } - esp_rom_delay_us(10); + esp_rom_delay_us(BUSY_POLL_US); } - ESP_LOGW(TAG, "BUSY stuck high"); + s_diag.busy_timeouts++; + note_command_error(ESP_ERR_TIMEOUT, "BUSY wait"); + return ESP_ERR_TIMEOUT; } static esp_err_t xfer(const uint8_t *tx, uint8_t *rx, size_t len) { - spi_transaction_t t = { + spi_transaction_t transaction = { .length = len * 8, .tx_buffer = tx, .rx_buffer = rx, }; - return spi_device_transmit(s_spi, &t); + esp_err_t err = spi_device_transmit(s_spi, &transaction); + if (err != ESP_OK) { + note_command_error(err, "SPI transfer"); + } + return err; } -static esp_err_t cmd(uint8_t op, const uint8_t *args, size_t n) +static esp_err_t cmd(uint8_t op, const uint8_t *args, size_t count) { - uint8_t buf[16]; - buf[0] = op; - if (n) { - memcpy(buf + 1, args, n); + if (count > 15) { + return ESP_ERR_INVALID_SIZE; + } + uint8_t buffer[16] = {op}; + if (count) { + memcpy(buffer + 1, args, count); + } + esp_err_t err = busy_wait(); + if (err != ESP_OK) { + return err; } - busy_wait(); - return xfer(buf, NULL, n + 1); + return xfer(buffer, NULL, count + 1); } -static esp_err_t cmd_read(uint8_t op, uint8_t *out, size_t n) +static esp_err_t cmd_read(uint8_t op, uint8_t *out, size_t count) { + if (count > 14) { + return ESP_ERR_INVALID_SIZE; + } uint8_t tx[16] = {0}; uint8_t rx[16] = {0}; tx[0] = op; - busy_wait(); - esp_err_t err = xfer(tx, rx, n + 2); /* opcode + status + payload */ + esp_err_t err = busy_wait(); + if (err != ESP_OK) { + return err; + } + err = xfer(tx, rx, count + 2); /* opcode + status + payload */ if (err == ESP_OK) { - memcpy(out, rx + 2, n); + memcpy(out, rx + 2, count); } return err; } -static esp_err_t write_reg(uint16_t addr, const uint8_t *data, size_t n) +static esp_err_t write_reg(uint16_t address, const uint8_t *data, size_t count) { - uint8_t buf[20]; - buf[0] = OP_WRITE_REGISTER; - buf[1] = addr >> 8; - buf[2] = addr & 0xFF; - memcpy(buf + 3, data, n); - busy_wait(); - return xfer(buf, NULL, n + 3); + if (count > 17) { + return ESP_ERR_INVALID_SIZE; + } + uint8_t buffer[20] = {0}; + buffer[0] = OP_WRITE_REGISTER; + buffer[1] = address >> 8; + buffer[2] = address & 0xFF; + memcpy(buffer + 3, data, count); + esp_err_t err = busy_wait(); + if (err != ESP_OK) { + return err; + } + return xfer(buffer, NULL, count + 3); } -static esp_err_t read_reg(uint16_t addr, uint8_t *data, size_t n) +static esp_err_t read_reg(uint16_t address, uint8_t *data, size_t count) { + if (count > 16) { + return ESP_ERR_INVALID_SIZE; + } uint8_t tx[20] = {0}; uint8_t rx[20] = {0}; tx[0] = OP_READ_REGISTER; - tx[1] = addr >> 8; - tx[2] = addr & 0xFF; - busy_wait(); - esp_err_t err = xfer(tx, rx, n + 4); /* op + addr(2) + status + data */ + tx[1] = address >> 8; + tx[2] = address & 0xFF; + esp_err_t err = busy_wait(); + if (err != ESP_OK) { + return err; + } + err = xfer(tx, rx, count + 4); /* opcode + address + status + data */ if (err == ESP_OK) { - memcpy(data, rx + 4, n); + memcpy(data, rx + 4, count); } return err; } -static void chip_reset(void) +static esp_err_t chip_reset(void) { - gpio_set_level(s_cfg.pin_reset, 0); + esp_err_t err = gpio_set_level(s_cfg.pin_reset, 0); + if (err != ESP_OK) { + return err; + } vTaskDelay(pdMS_TO_TICKS(2)); - gpio_set_level(s_cfg.pin_reset, 1); + err = gpio_set_level(s_cfg.pin_reset, 1); + if (err != ESP_OK) { + return err; + } vTaskDelay(pdMS_TO_TICKS(10)); + return ESP_OK; +} + +static bool config_valid(const sx1262_config_t *cfg) +{ + return cfg && + cfg->pin_sck >= 0 && cfg->pin_miso >= 0 && cfg->pin_mosi >= 0 && + cfg->pin_cs >= 0 && cfg->pin_reset >= 0 && cfg->pin_busy >= 0 && + cfg->pin_dio1 >= 0 && + lora_region_frequency_valid(cfg->region, cfg->freq_hz) && + cfg->sf >= 7 && cfg->sf <= 12 && + (cfg->bw_hz == 125000 || cfg->bw_hz == 250000 || cfg->bw_hz == 500000) && + cfg->cr >= 5 && cfg->cr <= 8 && + cfg->preamble_symbols >= 8 && + cfg->tx_dbm >= -9 && cfg->tx_dbm <= 22; } static esp_err_t bus_init(const sx1262_config_t *cfg) { + if (!config_valid(cfg)) { + return ESP_ERR_INVALID_ARG; + } s_cfg = *cfg; - gpio_config_t out = { + gpio_config_t output = { .pin_bit_mask = (1ULL << cfg->pin_reset) | (cfg->pin_rxen >= 0 ? (1ULL << cfg->pin_rxen) : 0), .mode = GPIO_MODE_OUTPUT, }; - ESP_ERROR_CHECK(gpio_config(&out)); - gpio_set_level(cfg->pin_reset, 1); + esp_err_t err = gpio_config(&output); + if (err != ESP_OK) { + return err; + } + err = gpio_set_level(cfg->pin_reset, 1); + if (err != ESP_OK) { + return err; + } if (cfg->pin_rxen >= 0) { - gpio_set_level(cfg->pin_rxen, 1); + err = gpio_set_level(cfg->pin_rxen, 1); + if (err != ESP_OK) { + return err; + } } - gpio_config_t in = { + gpio_config_t input = { .pin_bit_mask = (1ULL << cfg->pin_busy) | (1ULL << cfg->pin_dio1), .mode = GPIO_MODE_INPUT, .pull_down_en = GPIO_PULLDOWN_ENABLE, }; - ESP_ERROR_CHECK(gpio_config(&in)); + err = gpio_config(&input); + if (err != ESP_OK) { + return err; + } if (!s_spi) { spi_bus_config_t bus = { @@ -169,17 +257,17 @@ static esp_err_t bus_init(const sx1262_config_t *cfg) .quadhd_io_num = -1, .max_transfer_sz = 300, }; - esp_err_t err = spi_bus_initialize(SPI2_HOST, &bus, SPI_DMA_CH_AUTO); + err = spi_bus_initialize(SPI2_HOST, &bus, SPI_DMA_CH_AUTO); if (err != ESP_OK) { return err; } - spi_device_interface_config_t dev = { + spi_device_interface_config_t device = { .clock_speed_hz = 8 * 1000 * 1000, .mode = 0, .spics_io_num = cfg->pin_cs, .queue_size = 4, }; - err = spi_bus_add_device(SPI2_HOST, &dev, &s_spi); + err = spi_bus_add_device(SPI2_HOST, &device, &s_spi); if (err != ESP_OK) { return err; } @@ -187,113 +275,217 @@ static esp_err_t bus_init(const sx1262_config_t *cfg) return ESP_OK; } -bool sx1262_detect(const sx1262_config_t *cfg) +static esp_err_t verify_reg(uint16_t address, uint8_t expected, const char *name) { - if (cfg->pin_cs < 0) { - return false; /* target has no radio wiring */ + uint8_t actual = 0; + esp_err_t err = read_reg(address, &actual, 1); + if (err != ESP_OK) { + return err; } - if (bus_init(cfg) != ESP_OK) { - return false; + if (actual != expected) { + s_diag.command_errors++; + ESP_LOGE(TAG, "%s verify failed: wrote 0x%02x read 0x%02x", + name, expected, actual); + return ESP_ERR_INVALID_RESPONSE; } - chip_reset(); + return ESP_OK; +} - /* Scratch write/read of the LoRa sync word register: a real SX126x - * echoes the value; floating or absent hardware does not. */ - uint8_t probe = 0xA7, got = 0; - if (write_reg(REG_LORA_SYNC_MSB, &probe, 1) != ESP_OK || - read_reg(REG_LORA_SYNC_MSB, &got, 1) != ESP_OK) { - return false; - } - if (got != probe) { - return false; +#define RETURN_ON_ERROR(expression) do { \ + esp_err_t return_on_error_result = (expression); \ + if (return_on_error_result != ESP_OK) { \ + return return_on_error_result; \ + } \ +} while (0) + +static esp_err_t set_packet_params(uint16_t payload_len) +{ + uint8_t params[6] = { + s_cfg.preamble_symbols >> 8, + s_cfg.preamble_symbols & 0xFF, + 0x00, /* explicit header */ + (uint8_t)payload_len, + 0x01, /* CRC on */ + 0x00, /* standard IQ */ + }; + return cmd(OP_SET_PACKET_PARAMS, params, sizeof(params)); +} + +static esp_err_t resume_rx_internal(void) +{ + if (s_cfg.pin_rxen >= 0) { + RETURN_ON_ERROR(gpio_set_level(s_cfg.pin_rxen, 1)); } - return true; + /* This register is expected to survive standby/TX transitions. Verify it + * whenever RX is resumed so a mode-transition regression is actionable. */ + RETURN_ON_ERROR(verify_reg(REG_RX_GAIN, RX_GAIN_BOOSTED, "RX gain")); + RETURN_ON_ERROR(set_packet_params(SX1262_MAX_PAYLOAD)); + uint8_t timeout[3] = {0xFF, 0xFF, 0xFF}; /* continuous RX */ + return cmd(OP_SET_RX, timeout, sizeof(timeout)); } -esp_err_t sx1262_init(const sx1262_config_t *cfg, void (*dio1_notify)(void *arg), void *arg) +static esp_err_t configure_radio(void) { - s_cfg = *cfg; - chip_reset(); + const lora_region_profile_t *profile = lora_region_profile(s_cfg.region); + if (!profile || !lora_region_frequency_valid(s_cfg.region, s_cfg.freq_hz)) { + return ESP_ERR_INVALID_ARG; + } uint8_t standby_rc = 0x00; - cmd(OP_SET_STANDBY, &standby_rc, 1); + RETURN_ON_ERROR(cmd(OP_SET_STANDBY, &standby_rc, 1)); uint8_t dcdc = 0x01; - cmd(OP_SET_REGULATOR_MODE, &dcdc, 1); + RETURN_ON_ERROR(cmd(OP_SET_REGULATOR_MODE, &dcdc, 1)); - /* TCXO on DIO3 at 1.8 V, 5 ms startup (board requirement). Follow with - * a full calibrate: enabling the TCXO invalidates prior calibration. */ + /* TCXO on DIO3 at 1.8 V, 5 ms startup (board requirement). Enabling the + * TCXO invalidates earlier calibration, so perform the full calibration + * and then the regional image calibration. */ uint8_t tcxo[4] = {0x02, 0x00, 0x01, 0x40}; - cmd(OP_SET_DIO3_TCXO, tcxo, 4); - uint8_t cal_all = 0x7F; - cmd(OP_CALIBRATE, &cal_all, 1); + RETURN_ON_ERROR(cmd(OP_SET_DIO3_TCXO, tcxo, sizeof(tcxo))); + uint8_t calibrate_all = 0x7F; + RETURN_ON_ERROR(cmd(OP_CALIBRATE, &calibrate_all, 1)); vTaskDelay(pdMS_TO_TICKS(5)); - /* Image calibration for the US 902-928 band. */ - uint8_t img[2] = {0xE1, 0xE9}; - cmd(OP_CALIBRATE_IMAGE, img, 2); + uint8_t image[2] = { + profile->image_calibration[0], + profile->image_calibration[1] + }; + RETURN_ON_ERROR(cmd(OP_CALIBRATE_IMAGE, image, sizeof(image))); - uint8_t dio2_sw = 0x01; - cmd(OP_SET_DIO2_RF_SWITCH, &dio2_sw, 1); + uint8_t dio2_switch = 0x01; + RETURN_ON_ERROR(cmd(OP_SET_DIO2_RF_SWITCH, &dio2_switch, 1)); - uint8_t pkt_lora = 0x01; - cmd(OP_SET_PACKET_TYPE, &pkt_lora, 1); + uint8_t packet_lora = 0x01; + RETURN_ON_ERROR(cmd(OP_SET_PACKET_TYPE, &packet_lora, 1)); - uint32_t frf = (uint32_t)(((uint64_t)cfg->freq_hz << 25) / 32000000ULL); - uint8_t freq[4] = {frf >> 24, frf >> 16, frf >> 8, frf}; - cmd(OP_SET_RF_FREQUENCY, freq, 4); + uint32_t frf = (uint32_t)(((uint64_t)s_cfg.freq_hz << 25) / 32000000ULL); + uint8_t frequency[4] = {frf >> 24, frf >> 16, frf >> 8, frf}; + RETURN_ON_ERROR(cmd(OP_SET_RF_FREQUENCY, frequency, sizeof(frequency))); - /* SX1262 +22 dBm PA config per datasheet table 13-21. */ + /* SX1262 +22 dBm PA profile and 140 mA over-current protection. The + * register readback catches marginal SPI/configuration failures at boot. */ uint8_t pa[4] = {0x04, 0x07, 0x00, 0x01}; - cmd(OP_SET_PA_CONFIG, pa, 4); - uint8_t txp[2] = {(uint8_t)cfg->tx_dbm, 0x04}; /* 200 us ramp */ - cmd(OP_SET_TX_PARAMS, txp, 2); + RETURN_ON_ERROR(cmd(OP_SET_PA_CONFIG, pa, sizeof(pa))); + uint8_t ocp = OCP_140_MA; + RETURN_ON_ERROR(write_reg(REG_OCP, &ocp, 1)); + RETURN_ON_ERROR(verify_reg(REG_OCP, ocp, "OCP")); + uint8_t tx_params[2] = {(uint8_t)s_cfg.tx_dbm, 0x04}; /* 200 us ramp */ + RETURN_ON_ERROR(cmd(OP_SET_TX_PARAMS, tx_params, sizeof(tx_params))); uint8_t base[2] = {0x00, 0x00}; - cmd(OP_SET_BUF_BASE, base, 2); - - uint8_t bw_code = cfg->bw_hz == 500000 ? 0x06 : cfg->bw_hz == 250000 ? 0x05 : 0x04; - /* Low-data-rate optimize when the symbol time exceeds 16 ms. */ - uint8_t ldro = (cfg->sf >= 11 && cfg->bw_hz == 125000) || - (cfg->sf >= 12 && cfg->bw_hz == 250000) ? 0x01 : 0x00; - uint8_t mod[4] = {cfg->sf, bw_code, (uint8_t)(cfg->cr - 4), ldro}; - cmd(OP_SET_MOD_PARAMS, mod, 4); + RETURN_ON_ERROR(cmd(OP_SET_BUF_BASE, base, sizeof(base))); + + uint8_t bandwidth_code = + s_cfg.bw_hz == 500000 ? 0x06 : + s_cfg.bw_hz == 250000 ? 0x05 : 0x04; + /* Low-data-rate optimize whenever symbol time exceeds 16 ms. */ + uint8_t ldro = (s_cfg.sf >= 11 && s_cfg.bw_hz == 125000) || + (s_cfg.sf >= 12 && s_cfg.bw_hz == 250000) ? 0x01 : 0x00; + uint8_t modulation[4] = { + s_cfg.sf, bandwidth_code, (uint8_t)(s_cfg.cr - 4), ldro + }; + RETURN_ON_ERROR(cmd(OP_SET_MOD_PARAMS, modulation, sizeof(modulation))); uint8_t sync[2] = {SYNC_MSB, SYNC_LSB}; - write_reg(REG_LORA_SYNC_MSB, sync, 2); + RETURN_ON_ERROR(write_reg(REG_LORA_SYNC_MSB, sync, sizeof(sync))); + + uint8_t rx_gain = RX_GAIN_BOOSTED; + RETURN_ON_ERROR(write_reg(REG_RX_GAIN, &rx_gain, 1)); + RETURN_ON_ERROR(verify_reg(REG_RX_GAIN, rx_gain, "RX gain")); - /* IRQs on DIO1: TX/RX done, CRC error, CAD done/detected, timeout. */ uint16_t mask = IRQ_TX_DONE | IRQ_RX_DONE | IRQ_CRC_ERR | IRQ_CAD_DONE | IRQ_CAD_DETECTED | IRQ_TIMEOUT; - uint8_t irq[8] = {mask >> 8, mask & 0xFF, mask >> 8, mask & 0xFF, 0, 0, 0, 0}; - cmd(OP_SET_DIO_IRQ_PARAMS, irq, 8); + uint8_t irq[8] = { + mask >> 8, mask & 0xFF, + mask >> 8, mask & 0xFF, + 0, 0, 0, 0 + }; + RETURN_ON_ERROR(cmd(OP_SET_DIO_IRQ_PARAMS, irq, sizeof(irq))); + uint8_t clear[2] = {IRQ_ALL >> 8, IRQ_ALL & 0xFF}; + RETURN_ON_ERROR(cmd(OP_CLR_IRQ_STATUS, clear, sizeof(clear))); + + /* CAD per Semtech AN1200.48: 4 symbols, detPeak follows SF, detMin 10. */ + uint8_t cad[7] = { + 0x02, /* 4 symbols */ + (uint8_t)(s_cfg.sf + 13), + 10, + 0x00, /* CAD only */ + 0, 0, 0 + }; + return cmd(OP_SET_CAD_PARAMS, cad, sizeof(cad)); +} - /* CAD per Semtech AN1200.48: 4 symbols, detPeak tracking the configured - * SF (SF + 13), detMin 10. */ - uint8_t cad[7] = {0x02 /*4 symbols*/, cfg->sf + 13, 10, 0x00 /*CAD only*/, 0, 0, 0}; - cmd(OP_SET_CAD_PARAMS, cad, 7); +bool sx1262_detect(const sx1262_config_t *cfg) +{ + if (!config_valid(cfg) || bus_init(cfg) != ESP_OK) { + return false; + } + if (chip_reset() != ESP_OK) { + return false; + } + + /* Scratch write/read of the LoRa sync word register: a real SX126x + * echoes the value; floating or absent hardware does not. Initialization + * resets the scratch value before normal use. */ + uint8_t probe = 0xA7; + uint8_t actual = 0; + return write_reg(REG_LORA_SYNC_MSB, &probe, 1) == ESP_OK && + read_reg(REG_LORA_SYNC_MSB, &actual, 1) == ESP_OK && + actual == probe; +} + +esp_err_t sx1262_init(const sx1262_config_t *cfg, + void (*dio1_notify)(void *arg), void *arg) +{ + s_ready = false; + RETURN_ON_ERROR(bus_init(cfg)); + RETURN_ON_ERROR(chip_reset()); + RETURN_ON_ERROR(configure_radio()); if (dio1_notify) { - gpio_install_isr_service(0); - gpio_set_intr_type(cfg->pin_dio1, GPIO_INTR_POSEDGE); - gpio_isr_handler_add(cfg->pin_dio1, dio1_notify, arg); - gpio_intr_enable(cfg->pin_dio1); + esp_err_t err = gpio_install_isr_service(0); + if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { + return err; + } + RETURN_ON_ERROR(gpio_set_intr_type(cfg->pin_dio1, GPIO_INTR_POSEDGE)); + RETURN_ON_ERROR(gpio_isr_handler_add(cfg->pin_dio1, dio1_notify, arg)); + RETURN_ON_ERROR(gpio_intr_enable(cfg->pin_dio1)); } s_ready = true; - return sx1262_resume_rx(); + esp_err_t err = resume_rx_internal(); + if (err != ESP_OK) { + s_ready = false; + } + return err; } -static void set_packet_params(uint16_t payload_len) +esp_err_t sx1262_recover(void) { - uint8_t pp[6] = { - 0x00, 0x08, /* 8-symbol preamble */ - 0x00, /* explicit header */ - (uint8_t)payload_len, - 0x01, /* CRC on */ - 0x00, /* standard IQ */ - }; - cmd(OP_SET_PACKET_PARAMS, pp, 6); + s_diag.recoveries++; + s_ready = false; + + esp_err_t last_error = ESP_FAIL; + for (int attempt = 1; attempt <= RECOVERY_ATTEMPTS; ++attempt) { + last_error = chip_reset(); + if (last_error == ESP_OK) { + last_error = configure_radio(); + } + if (last_error == ESP_OK) { + last_error = resume_rx_internal(); + } + if (last_error == ESP_OK) { + s_ready = true; + ESP_LOGW(TAG, "radio recovered on attempt %d", attempt); + return ESP_OK; + } + ESP_LOGW(TAG, "radio recovery attempt %d failed: %s", + attempt, esp_err_to_name(last_error)); + } + + s_diag.recovery_failures++; + ESP_LOGE(TAG, "radio recovery exhausted"); + return last_error; } sx1262_event_t sx1262_poll_event(void) @@ -301,19 +493,22 @@ sx1262_event_t sx1262_poll_event(void) if (!s_ready) { return SX1262_EVT_NONE; } - uint8_t st[2] = {0}; - if (cmd_read(OP_GET_IRQ_STATUS, st, 2) != ESP_OK) { - return SX1262_EVT_NONE; + uint8_t status[2] = {0}; + if (cmd_read(OP_GET_IRQ_STATUS, status, sizeof(status)) != ESP_OK) { + return SX1262_EVT_ERROR; } - uint16_t irq = ((uint16_t)st[0] << 8) | st[1]; + uint16_t irq = ((uint16_t)status[0] << 8) | status[1]; if (!irq) { return SX1262_EVT_NONE; } - uint8_t clr[2] = {irq >> 8, irq & 0xFF}; - cmd(OP_CLR_IRQ_STATUS, clr, 2); + uint8_t clear[2] = {irq >> 8, irq & 0xFF}; + if (cmd(OP_CLR_IRQ_STATUS, clear, sizeof(clear)) != ESP_OK) { + return SX1262_EVT_ERROR; + } if (irq & IRQ_CAD_DONE) { - return (irq & IRQ_CAD_DETECTED) ? SX1262_EVT_CAD_BUSY : SX1262_EVT_CAD_CLEAR; + return (irq & IRQ_CAD_DETECTED) ? + SX1262_EVT_CAD_BUSY : SX1262_EVT_CAD_CLEAR; } if (irq & IRQ_TX_DONE) { return SX1262_EVT_TX_DONE; @@ -330,30 +525,35 @@ sx1262_event_t sx1262_poll_event(void) return SX1262_EVT_NONE; } -uint16_t sx1262_read_packet(uint8_t *buf, uint16_t max_len, int16_t *rssi_dbm, int8_t *snr_db) +uint16_t sx1262_read_packet(uint8_t *buffer, uint16_t max_len, + int16_t *rssi_dbm, int8_t *snr_db) { - /* Read packet status before draining the FIFO: RssiPkt/SnrPkt latch at - * RX_DONE. ps = {RssiPkt, SnrPkt, SignalRssiPkt}; RSSI[dBm] = -RssiPkt/2, - * SNR[dB] = (int8)SnrPkt/4. Note: at very short range two +22 dBm radios - * overload each other's front end, pinning RssiPkt to 0 (0 dBm) while SNR - * still reports; RSSI reads normally once nodes are meaningfully apart. */ + if (!s_ready || !buffer) { + return 0; + } + + /* Packet status latches at RX_DONE. RSSI[dBm] = -RssiPkt/2 and + * SNR[dB] = (int8)SnrPkt/4. */ if (rssi_dbm || snr_db) { - uint8_t ps[3] = {0}; - bool ok = cmd_read(OP_GET_PACKET_STATUS, ps, 3) == ESP_OK; + uint8_t packet_status[3] = {0}; + if (cmd_read(OP_GET_PACKET_STATUS, packet_status, + sizeof(packet_status)) != ESP_OK) { + return 0; + } if (rssi_dbm) { - *rssi_dbm = ok ? -(int16_t)ps[0] / 2 : 0; + *rssi_dbm = -(int16_t)packet_status[0] / 2; } if (snr_db) { - *snr_db = ok ? (int8_t)ps[1] / 4 : 0; + *snr_db = (int8_t)packet_status[1] / 4; } } - uint8_t status[2] = {0}; - if (cmd_read(OP_GET_RX_BUFFER_STATUS, status, 2) != ESP_OK) { + uint8_t rx_status[2] = {0}; + if (cmd_read(OP_GET_RX_BUFFER_STATUS, rx_status, sizeof(rx_status)) != ESP_OK) { return 0; } - uint16_t len = status[0]; - uint8_t offset = status[1]; + uint16_t len = rx_status[0]; + uint8_t offset = rx_status[1]; if (len == 0 || len > max_len) { return 0; } @@ -362,38 +562,69 @@ uint16_t sx1262_read_packet(uint8_t *buf, uint16_t max_len, int16_t *rssi_dbm, i uint8_t rx[SX1262_MAX_PAYLOAD + 3] = {0}; tx[0] = OP_READ_BUFFER; tx[1] = offset; - busy_wait(); - if (xfer(tx, rx, len + 3) != ESP_OK) { + if (busy_wait() != ESP_OK || xfer(tx, rx, len + 3) != ESP_OK) { return 0; } - memcpy(buf, rx + 3, len); + memcpy(buffer, rx + 3, len); return len; } +static uint32_t tx_timeout_ms(uint16_t len) +{ + lora_airtime_params_t params = { + .spreading_factor = s_cfg.sf, + .bandwidth_hz = s_cfg.bw_hz, + .coding_rate = s_cfg.cr, + .preamble_symbols = s_cfg.preamble_symbols, + .explicit_header = true, + .crc_enabled = true, + }; + return lora_tx_timeout_ms(¶ms, len, + TX_MIN_MARGIN_MS, TX_MARGIN_PERCENT); +} + +uint32_t sx1262_tx_watchdog_ms(uint16_t len) +{ + if (len == 0 || len > SX1262_MAX_PAYLOAD) { + return 0; + } + uint32_t radio_timeout = tx_timeout_ms(len); + if (UINT32_MAX - radio_timeout < TX_WATCHDOG_MARGIN_MS) { + return UINT32_MAX; + } + return radio_timeout + TX_WATCHDOG_MARGIN_MS; +} + esp_err_t sx1262_transmit(const uint8_t *data, uint16_t len) { - if (!s_ready || len == 0 || len > SX1262_MAX_PAYLOAD) { + if (!s_ready) { + return ESP_ERR_INVALID_STATE; + } + if (!data || len == 0 || len > SX1262_MAX_PAYLOAD) { return ESP_ERR_INVALID_ARG; } if (s_cfg.pin_rxen >= 0) { - gpio_set_level(s_cfg.pin_rxen, 0); /* RX path off while DIO2 keys TX */ + RETURN_ON_ERROR(gpio_set_level(s_cfg.pin_rxen, 0)); } + uint8_t standby_rc = 0x00; - cmd(OP_SET_STANDBY, &standby_rc, 1); - - uint8_t wb[SX1262_MAX_PAYLOAD + 2]; - wb[0] = OP_WRITE_BUFFER; - wb[1] = 0x00; - memcpy(wb + 2, data, len); - busy_wait(); - esp_err_t err = xfer(wb, NULL, len + 2); - if (err != ESP_OK) { - return err; - } - set_packet_params(len); - /* TX timeout ~2 s (units of 15.625 us): guards a stuck PA. */ - uint8_t tmo[3] = {0x01, 0xF4, 0x00}; - return cmd(OP_SET_TX, tmo, 3); + RETURN_ON_ERROR(cmd(OP_SET_STANDBY, &standby_rc, 1)); + + uint8_t write_buffer[SX1262_MAX_PAYLOAD + 2]; + write_buffer[0] = OP_WRITE_BUFFER; + write_buffer[1] = 0x00; + memcpy(write_buffer + 2, data, len); + RETURN_ON_ERROR(busy_wait()); + RETURN_ON_ERROR(xfer(write_buffer, NULL, len + 2)); + RETURN_ON_ERROR(set_packet_params(len)); + + uint32_t timeout_units = lora_sx126x_timeout_units(tx_timeout_ms(len)); + uint8_t timeout[3] = { + timeout_units >> 16, + timeout_units >> 8, + timeout_units + }; + return cmd(OP_SET_TX, timeout, sizeof(timeout)); } esp_err_t sx1262_start_cad(void) @@ -402,7 +633,7 @@ esp_err_t sx1262_start_cad(void) return ESP_ERR_INVALID_STATE; } uint8_t standby_rc = 0x00; - cmd(OP_SET_STANDBY, &standby_rc, 1); + RETURN_ON_ERROR(cmd(OP_SET_STANDBY, &standby_rc, 1)); return cmd(OP_SET_CAD, NULL, 0); } @@ -411,11 +642,12 @@ esp_err_t sx1262_resume_rx(void) if (!s_ready) { return ESP_ERR_INVALID_STATE; } - if (s_cfg.pin_rxen >= 0) { - gpio_set_level(s_cfg.pin_rxen, 1); + return resume_rx_internal(); +} + +void sx1262_get_diag(sx1262_diag_t *out) +{ + if (out) { + *out = s_diag; } - set_packet_params(SX1262_MAX_PAYLOAD); - /* 0xFFFFFF = continuous RX */ - uint8_t rx[3] = {0xFF, 0xFF, 0xFF}; - return cmd(OP_SET_RX, rx, 3); } diff --git a/main/sx1262.h b/main/sx1262.h index 93d576f..c904999 100644 --- a/main/sx1262.h +++ b/main/sx1262.h @@ -15,6 +15,8 @@ #include #include +#include "lora_region.h" + #ifdef __cplusplus extern "C" { #endif @@ -31,12 +33,21 @@ typedef struct { int pin_dio1; int pin_rxen; /* -1 if not board-controlled */ uint32_t freq_hz; - uint8_t sf; /* 5..12 */ + uint8_t sf; /* 7..12 */ uint32_t bw_hz; /* 125000 / 250000 / 500000 */ uint8_t cr; /* 5..8 => 4/5..4/8 */ + uint16_t preamble_symbols; int8_t tx_dbm; /* up to +22 */ + lora_region_id_t region; } sx1262_config_t; +typedef struct { + uint32_t command_errors; + uint32_t busy_timeouts; + uint32_t recoveries; + uint32_t recovery_failures; +} sx1262_diag_t; + typedef enum { SX1262_EVT_NONE = 0, SX1262_EVT_TX_DONE, @@ -45,6 +56,7 @@ typedef enum { SX1262_EVT_CAD_CLEAR, /* CAD finished, channel free */ SX1262_EVT_CAD_BUSY, /* CAD finished, activity detected */ SX1262_EVT_TIMEOUT, + SX1262_EVT_ERROR, } sx1262_event_t; /* Probes for the radio non-destructively (scratch register write/read). @@ -72,6 +84,17 @@ esp_err_t sx1262_start_cad(void); /* Re-enters continuous RX (after TX_DONE or CAD). */ esp_err_t sx1262_resume_rx(void); +/* Resets, reconfigures, verifies critical registers, and re-enters RX. + * Recovery is bounded and leaves the driver not-ready if both attempts fail. */ +esp_err_t sx1262_recover(void); + +/* Independent software watchdog interval for a frame of this size. It is + * longer than the airtime-derived radio timeout and does not depend on IRQs. */ +uint32_t sx1262_tx_watchdog_ms(uint16_t len); + +/* Snapshot low-level command and recovery diagnostics. */ +void sx1262_get_diag(sx1262_diag_t *out); + #ifdef __cplusplus } #endif diff --git a/tests/test_lora_airtime.c b/tests/test_lora_airtime.c index 99a7c31..5d49b35 100644 --- a/tests/test_lora_airtime.c +++ b/tests/test_lora_airtime.c @@ -1,6 +1,7 @@ #include "lora_airtime.h" #include +#include #include int main(void) @@ -25,5 +26,25 @@ int main(void) params.spreading_factor = 13; assert(!lora_airtime_params_valid(¶ms)); assert(lora_airtime_ms(¶ms, 136) == 0); + + params.preamble_symbols = 16; + const uint16_t frame_sizes[] = {16, 136, 255}; + for (uint8_t sf = 7; sf <= 12; ++sf) { + params.spreading_factor = sf; + for (size_t i = 0; i < sizeof(frame_sizes) / sizeof(frame_sizes[0]); ++i) { + uint32_t airtime = lora_airtime_ms(¶ms, frame_sizes[i]); + uint32_t timeout = + lora_tx_timeout_ms(¶ms, frame_sizes[i], 500, 25); + uint32_t percentage_margin = (airtime * 25u + 99u) / 100u; + uint32_t expected_margin = + percentage_margin > 500 ? percentage_margin : 500; + assert(airtime > 0); + assert(timeout == airtime + expected_margin); + assert(lora_sx126x_timeout_units(timeout) < + LORA_SX126X_TIMEOUT_UNITS_MAX); + } + } + assert(lora_sx126x_timeout_units(UINT32_MAX) == + LORA_SX126X_TIMEOUT_UNITS_MAX); return 0; } diff --git a/tests/test_lora_region.c b/tests/test_lora_region.c new file mode 100644 index 0000000..aacf976 --- /dev/null +++ b/tests/test_lora_region.c @@ -0,0 +1,34 @@ +#include "lora_region.h" + +#include +#include + +int main(void) +{ + const lora_region_profile_t *us = lora_region_profile(LORA_REGION_US915); + const lora_region_profile_t *eu = lora_region_profile(LORA_REGION_EU868); + assert(us && eu); + assert(lora_region_frequency_valid(us->id, us->default_hz)); + assert(lora_region_frequency_valid(eu->id, eu->default_hz)); + assert(lora_region_frequency_valid(us->id, us->minimum_hz)); + assert(lora_region_frequency_valid(us->id, us->maximum_hz)); + assert(lora_region_frequency_valid(eu->id, eu->minimum_hz)); + assert(lora_region_frequency_valid(eu->id, eu->maximum_hz)); + assert(!lora_region_frequency_valid(us->id, eu->default_hz)); + assert(!lora_region_frequency_valid(eu->id, us->default_hz)); + assert(!lora_region_frequency_valid(LORA_REGION_COUNT, us->default_hz)); + + uint8_t calibration[2] = {0}; + assert(lora_region_image_calibration(us->default_hz, calibration)); + assert(calibration[0] == 0xE1 && calibration[1] == 0xE9); + assert(lora_region_image_calibration(eu->default_hz, calibration)); + assert(calibration[0] == 0xD7 && calibration[1] == 0xDB); + assert(!lora_region_image_calibration(700000000, calibration)); + + lora_region_id_t inferred = LORA_REGION_COUNT; + assert(lora_region_infer(902000000, &inferred)); + assert(inferred == LORA_REGION_US915); + assert(lora_region_infer(870000000, &inferred)); + assert(inferred == LORA_REGION_EU868); + return 0; +} diff --git a/tools/lora_hardware_smoke.py b/tools/lora_hardware_smoke.py index d94be0b..8bab88c 100755 --- a/tools/lora_hardware_smoke.py +++ b/tools/lora_hardware_smoke.py @@ -17,10 +17,28 @@ PACKET_RE = re.compile(r"trunk RX packet len=(\d+).*frags=(\d+)") RAW_RE = re.compile(r"trunk raw RX len=(\d+) rssi=(-?\d+) snr=(-?\d+)") TX_RE = re.compile(r"trunk TX .*len=(\d+) frags=(\d+)") +PROFILE_RE = re.compile(r"trunk up: region=(\w+).* SF(\d+).*preamble=(\d+)") +DIAG_RE = re.compile( + r"tmo=(\d+).*radio_err=(\d+).*busy_tmo=(\d+).*" + r"recover=(\d+)/(\d+).*tx_watchdog=(\d+)" +) -def reader(label: str, port: str, output: queue.Queue[tuple[str, str]], stop: threading.Event): +def reader( + label: str, + port: str, + output: queue.Queue[tuple[str, str]], + stop: threading.Event, + reset: bool, +): with serial.Serial(port, 115200, timeout=0.2) as stream: + if reset: + # Assert EN while keeping GPIO0 deasserted, then release EN. + stream.dtr = False + stream.rts = True + time.sleep(0.1) + stream.rts = False + time.sleep(0.1) while not stop.is_set(): line = stream.readline().decode("utf-8", errors="replace").rstrip() if line: @@ -32,20 +50,39 @@ def main() -> int: parser.add_argument("ports", nargs=2, help="serial ports for the two nodes") parser.add_argument("--seconds", type=int, default=75) parser.add_argument("--json-out") + parser.add_argument("--reset", action="store_true", help="reset both nodes at capture start") + parser.add_argument("--quiet", action="store_true", help="print only the JSON summary") args = parser.parse_args() messages: queue.Queue[tuple[str, str]] = queue.Queue() stop = threading.Event() threads = [ - threading.Thread(target=reader, args=(f"node-{index + 1}", port, messages, stop)) + threading.Thread( + target=reader, + args=(f"node-{index + 1}", port, messages, stop, args.reset), + ) for index, port in enumerate(args.ports) ] for thread in threads: thread.start() summary: dict[str, dict[str, object]] = { - "node-1": {"rx_fragments": Counter(), "tx_fragments": Counter(), "raw": []}, - "node-2": {"rx_fragments": Counter(), "tx_fragments": Counter(), "raw": []}, + "node-1": { + "rx_fragments": Counter(), + "tx_fragments": Counter(), + "raw": [], + "events": Counter(), + "diagnostics": [], + "profile": None, + }, + "node-2": { + "rx_fragments": Counter(), + "tx_fragments": Counter(), + "raw": [], + "events": Counter(), + "diagnostics": [], + "profile": None, + }, } deadline = time.monotonic() + args.seconds try: @@ -54,7 +91,8 @@ def main() -> int: label, line = messages.get(timeout=0.25) except queue.Empty: continue - print(f"[{label}] {line}") + if not args.quiet: + print(f"[{label}] {line}") if match := PACKET_RE.search(line): summary[label]["rx_fragments"][match.group(2)] += 1 if match := TX_RE.search(line): @@ -67,6 +105,29 @@ def main() -> int: "snr_db": int(match.group(3)), } ) + if match := PROFILE_RE.search(line): + summary[label]["profile"] = { + "region": match.group(1), + "spreading_factor": int(match.group(2)), + "preamble_symbols": int(match.group(3)), + } + if match := DIAG_RE.search(line): + summary[label]["diagnostics"].append( + { + "radio_timeouts": int(match.group(1)), + "radio_command_errors": int(match.group(2)), + "busy_timeouts": int(match.group(3)), + "recoveries": int(match.group(4)), + "recovery_failures": int(match.group(5)), + "tx_watchdog_recoveries": int(match.group(6)), + } + ) + if "TX watchdog expired" in line: + summary[label]["events"]["tx_watchdog_expired"] += 1 + if "radio recovered on attempt" in line: + summary[label]["events"]["radio_recovered"] += 1 + if "diagnostic: suppressed one TX_DONE" in line: + summary[label]["events"]["tx_done_suppressed"] += 1 finally: stop.set() for thread in threads: @@ -77,6 +138,9 @@ def main() -> int: "rx_fragments": dict(values["rx_fragments"]), "tx_fragments": dict(values["tx_fragments"]), "raw": values["raw"], + "events": dict(values["events"]), + "diagnostics": values["diagnostics"], + "profile": values["profile"], } for label, values in summary.items() } diff --git a/tools/run_lora_host_tests.sh b/tools/run_lora_host_tests.sh index dca3841..09051c4 100755 --- a/tools/run_lora_host_tests.sh +++ b/tools/run_lora_host_tests.sh @@ -2,15 +2,23 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -test_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-airtime.XXXXXX")" -trap 'rm -f "$test_bin"' EXIT +airtime_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-airtime.XXXXXX")" +region_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-region.XXXXXX")" +trap 'rm -f "$airtime_bin" "$region_bin"' EXIT cc -std=c11 -Wall -Wextra -Werror \ -I"$repo_root/main" \ "$repo_root/main/lora_airtime.c" \ "$repo_root/tests/test_lora_airtime.c" \ - -lm -o "$test_bin" -"$test_bin" + -lm -o "$airtime_bin" +"$airtime_bin" + +cc -std=c11 -Wall -Wextra -Werror \ + -I"$repo_root/main" \ + "$repo_root/main/lora_region.c" \ + "$repo_root/tests/test_lora_region.c" \ + -o "$region_bin" +"$region_bin" cd "$repo_root" python3 -m unittest discover -s tests -p 'test_lora_*.py' -v From 39b8994037de2bfbe6abd68d54325f02c8542f71 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:00:20 +0200 Subject: [PATCH 07/19] docs(lora): record milestone 1 checkpoint --- docs/LoRa-reliability-implementation-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index 70d575b..c769fef 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -60,7 +60,7 @@ The implementation is complete only when all of the following are true: | Milestone | Status | Commit | Evidence summary | |---|---|---|---| | 0. Baseline harness and observability | Complete | `d130a3a` | Host baseline, both firmware targets, and two-board SF10 smoke passed | -| 1. Airtime-safe SX1262 operation | Complete | Pending checkpoint | Host/profile tests, both firmware targets, and SF10-SF12 two-board radio tests passed | +| 1. Airtime-safe SX1262 operation | Complete | `028511e` | Host/profile tests, both firmware targets, and SF10-SF12 two-board radio tests passed | | 2. Atomic packet scheduling and backpressure | Pending | — | — | | 3. Addressed trunk protocol and migration | Pending | — | — | | 4. Packet-level reliability and reassembly | Pending | — | — | @@ -194,7 +194,7 @@ for the configured frame size. - Radio initialization now verifies boosted RX gain and the 140 mA PA over-current setting by register readback. Receive gain is rechecked every time continuous RX is resumed. -- Commit: Pending checkpoint +- Commit: `028511e` - Suggested commit subject: `fix(lora): make SX1262 operation airtime safe` ## Milestone 2: Atomic packet scheduling and backpressure From b64575a74b2226922ae00dd4ca25349180c0d742 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:20:51 +0200 Subject: [PATCH 08/19] works --- README.md | 5 +- main/CMakeLists.txt | 1 + main/bitle_link.c | 8 +- main/bitle_link.h | 9 +- main/bitle_lora.c | 59 ++++-- main/bitle_mesh.c | 100 ++++++++++- main/bitle_mesh.h | 7 +- main/bitle_route.c | 396 +++++++++++++++++++++++++++++++++++++++++ main/bitle_route.h | 68 +++++++ main/main.c | 2 + main/noise_handshake.c | 166 +++++++++++++++-- main/noise_handshake.h | 17 ++ main/packet_codec.c | 165 ++++++++++++++--- 13 files changed, 938 insertions(+), 65 deletions(-) create mode 100644 main/bitle_route.c create mode 100644 main/bitle_route.h diff --git a/README.md b/README.md index ede35cc..decfafc 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ A single node runs a genuinely simultaneous **dual-role BLE stack** on NimBLE. I - **Full BitChat handshake.** Implements the Noise XX pattern (`Noise_XX_25519_ChaChaPoly_SHA256`) via the bundled `noise_ref` (Noise-C) reference library, with Ed25519 identity binding like the mobile apps. Each node persists a Curve25519 static keypair and an Ed25519 signing keypair in NVS; its 8-byte peer ID is the first 8 bytes of SHA-256 over its Noise static public key. -- **Signed identity announces.** Identity rides in a signed `ANNOUNCE` (type `0x01`) carrying TLVs for nickname, Noise static key, and Ed25519 signing key, plus two Bitle-private TLVs: firmware version (`0xB0`) and role/authority flags (`0xB1`). A legacy `0x13` identity-announce is still emitted best-effort for older clients. Inbound announces are hard-rejected unless the sender ID equals SHA-256(announced Noise key)[0:8]. +- **Signed identity announces with neighbor gossip.** Identity rides in a signed `ANNOUNCE` (type `0x01`) carrying TLVs for nickname, Noise static key, and Ed25519 signing key, the upstream `DIRECT_NEIGHBORS` gossip TLV (`0x04`, up to 10 verified direct peers, so phones can fold the node into their mesh graph and source-route through it), plus two Bitle-private TLVs: firmware version (`0xB0`) and role/authority flags (`0xB1`). A legacy `0x13` identity-announce is still emitted best-effort for older clients. Inbound announces are hard-rejected unless the sender ID equals SHA-256(announced Noise key)[0:8]. -- **Dual-role BLE mesh relay.** Packets are encoded/decoded with the BitChat binary format and relayed to every other subscribed link. Relay is TTL-based (packets with `ttl <= 1` are dropped, otherwise the TTL byte is decremented before rebroadcast) and de-duplicated with an FNV-1a fingerprint over the packet bytes (skipping the TTL byte) kept in a 64-entry ring. Own echoes, `REQUEST_SYNC`, packets addressed to this node, and undirected Noise handshakes are never relayed. Phone-fragmented packets are reassembled in a small bounded pool (2 slots, up to 4 parts × 501 bytes, 15 s timeout); anything larger is forwarded relay-only. Max handled BLE packet size is 520 bytes. A 30 s subscribe watchdog drops links that connect but never enable notifications, and a short deny/cool-down list prevents immediately re-dialing a just-dropped peer. +- **Dual-role BLE mesh relay with source routing.** Packets are encoded/decoded with the BitChat binary format (v1 and v2) and forwarded like the upstream relay: TTL 0 stops forwarding, otherwise the TTL byte is decremented first. A v2 packet carrying a source route (flag `0x08`) is unicast toward the next hop when this node is listed in the route — peer IDs resolve to links through a direct-peer table — with duplicate-hop loop rejection, the signed route left byte-intact, and flood fallback when the next hop is not directly connected. Unrouted packets flood to every link except the ingress link and the original sender's own link. Flooding is de-duplicated with an FNV-1a fingerprint over the packet bytes (skipping the TTL byte) kept in a 64-entry ring. Own echoes, `REQUEST_SYNC`, packets addressed to this node, and undirected Noise handshakes are never relayed. A peer counts as **directly connected** exactly as upstream: a signature-verified announce from it arrived still at its origin TTL (7) on a live link. The node also learns the mesh graph from gossip TLVs and attaches source routes (version 2) to its own directed packets whose recipient is only reachable through the mesh, computed as the shortest path over mutually-confirmed edges. Phone-fragmented packets are reassembled in a small bounded pool (2 slots, up to 4 parts × 501 bytes, 15 s timeout); anything larger is forwarded relay-only. Max handled BLE packet size is 520 bytes (587 for routed v2). A 30 s subscribe watchdog drops links that connect but never enable notifications, and a short deny/cool-down list prevents immediately re-dialing a just-dropped peer. - **LoRa long-range trunk (ESP32-S3 nodes).** When an SX1262 is detected at boot, the node brings up a 915 MHz LoRa backbone between nodes — a second radio the phones never touch. The trunk registers as one more link in the transport-agnostic link registry, so the mesh relays BLE↔LoRa with no special cases: a message crosses the trunk and comes back down to BLE at the far end. See the [LoRa backhaul](#lora-backhaul) section for the details (framing, ARQ, spreading factor, range). @@ -70,6 +70,7 @@ Radio: **Semtech SX1262** (Seeed Wio-SX1262 + XIAO ESP32-S3, or Heltec V3 boards ├── bitchat_ble.{c,h} # dual-role BLE transport (peripheral + central) ├── bitle_link.{c,h} # transport-agnostic link registry (BLE + LoRa) ├── bitle_mesh.{c,h} # transport-agnostic dispatch, dedup, fragments, relay + ├── bitle_route.{c,h} # direct-peer table, neighbor graph, source-route BFS ├── bitle_lora.{c,h} # LoRa trunk: framing, ARQ, admission, padding strip ├── sx1262.{c,h} # SX1262 LoRa radio driver (ESP-IDF native) ├── noise_handshake.{c,h} # Noise XX, announce TLVs, message dispatch diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 7daf894..259b07a 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -8,6 +8,7 @@ idf_component_register( "bitle_hash.c" "bitle_link.c" "bitle_mesh.c" + "bitle_route.c" "bitle_lora.c" "sx1262.c" "bitle_ota.c" diff --git a/main/bitle_link.c b/main/bitle_link.c index 876b443..71b0b98 100644 --- a/main/bitle_link.c +++ b/main/bitle_link.c @@ -6,6 +6,8 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" +#include "bitle_route.h" + static const char *TAG = "bitle_link"; typedef struct { @@ -71,6 +73,8 @@ void bitle_link_unregister(uint16_t handle) memset(e, 0, sizeof(*e)); } xSemaphoreGive(s_lock); + /* Any peer-ID -> link mapping through this handle is now dangling. */ + bitle_route_link_down(handle); } bool bitle_link_ready(uint16_t handle) @@ -106,7 +110,7 @@ esp_err_t bitle_link_send(uint16_t handle, const uint8_t *data, uint16_t len) return fn(handle, data, len) == 0 ? ESP_OK : ESP_FAIL; } -int bitle_link_broadcast(uint16_t exclude_handle, const uint8_t *data, uint16_t len) +int bitle_link_broadcast(uint16_t exclude_a, uint16_t exclude_b, const uint8_t *data, uint16_t len) { /* Snapshot under the lock, send outside it. */ struct { @@ -117,7 +121,7 @@ int bitle_link_broadcast(uint16_t exclude_handle, const uint8_t *data, uint16_t xSemaphoreTake(s_lock, portMAX_DELAY); for (size_t i = 0; i < BITLE_LINK_MAX; ++i) { - if (s_links[i].in_use && s_links[i].handle != exclude_handle) { + if (s_links[i].in_use && s_links[i].handle != exclude_a && s_links[i].handle != exclude_b) { targets[n].handle = s_links[i].handle; targets[n].fn = s_links[i].send_fn; n++; diff --git a/main/bitle_link.h b/main/bitle_link.h index 1e496d8..2b47c50 100644 --- a/main/bitle_link.h +++ b/main/bitle_link.h @@ -54,9 +54,12 @@ int bitle_link_type_of(uint16_t handle); esp_err_t bitle_link_send(uint16_t handle, const uint8_t *data, uint16_t len); -/* Sends to every registered link except exclude_handle (BITLE_LINK_NONE to - * send to all). Returns the number of links the send succeeded on. */ -int bitle_link_broadcast(uint16_t exclude_handle, const uint8_t *data, uint16_t len); +/* Sends to every registered link except the two exclusions (BITLE_LINK_NONE + * for "no exclusion"). The relay passes the ingress link and the link the + * original sender is directly connected on, so a copy never goes back + * toward the origin (upstream BluetoothPacketBroadcaster stage C). Returns + * the number of links the send succeeded on. */ +int bitle_link_broadcast(uint16_t exclude_a, uint16_t exclude_b, const uint8_t *data, uint16_t len); #ifdef __cplusplus } diff --git a/main/bitle_lora.c b/main/bitle_lora.c index 1e3bfe5..e939591 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -26,11 +26,17 @@ static const char *TAG = "bitle_lora"; * The LoRa trunk is bandwidth-precious (default SF10/BW125 ~= 1 kbps shared, * whole-packet airtimes from hundreds of ms to seconds), so not everything * the mesh would relay over BLE belongs on it. Encoded BitChat header - * offsets (see packet_codec): [1]=type, [2]=ttl, [14..21]=sender id. */ + * offsets (see packet_codec): [0]=version, [1]=type, [11]=flags, sender id + * at [14..21] in v1 packets and [16..23] in v2 (4-byte payload length). */ #define PKT_TYPE_OFF 1 -#define PKT_SENDER_OFF 14 +#define PKT_FLAGS_OFF 11 #define PKT_MIN_LEN 22 +static size_t pkt_sender_off(uint8_t version) +{ + return version >= 2 ? 16 : 14; +} + /* Per-origin throttle for identity/announce floods: an announce carries no * time-critical content, so one per origin per interval is plenty for * discovery while a chatty phone cannot monopolize the channel. */ @@ -94,7 +100,8 @@ static const char *TAG = "bitle_lora"; * on a marginal link far more reliably than long ones. */ #define TRUNK_CHUNK_MAX (SX1262_MAX_PAYLOAD - TRUNK_HDR_LEN) #define TRUNK_CHUNK_TX 120 -#define TRUNK_MAX_FRAGS ((BITCHAT_BLE_MAX_PACKET_SIZE + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX) +/* Source-routed (v2) packets are slightly larger than the v1 BLE budget. */ +#define TRUNK_MAX_FRAGS ((BITLE_PACKET_MAX_ROUTED + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX) /* Stop-and-wait ARQ: each ack-requested frame is retransmitted until * acked, ARQ_TRIES sends total. Announces are broadcast discovery and @@ -201,6 +208,10 @@ static bool trunk_admit(const uint8_t *data, uint16_t len) return false; } uint8_t type = data[PKT_TYPE_OFF]; + size_t sender_off = pkt_sender_off(data[0]); + if (len < sender_off + 8) { + return false; + } bool ota = (type >= 0xA0 && type <= 0xA3); bool announce = (type == BITCHAT_MSG_ANNOUNCE || type == BITCHAT_MSG_NOISE_IDENTITY_ANNOUNCE); /* Message-class traffic is user-driven, rare, and time-critical: a Noise @@ -254,7 +265,7 @@ static bool trunk_admit(const uint8_t *data, uint16_t len) ESP_LOGD(TAG, "airtime budget low; deferring announce"); return false; } - if (announce_throttled(data + PKT_SENDER_OFF, now)) { + if (announce_throttled(data + sender_off, now)) { taskEXIT_CRITICAL(&s_gov_mux); return false; } @@ -294,22 +305,42 @@ static void IRAM_ATTR dio1_isr(void *arg) /* True length of the self-describing BitChat packet, dropping any trailing * MessagePadding (phones pad handshakes/DMs to 256 B for BLE traffic-analysis * resistance — a pure BLE-MTU artifact that just bloats scarce LoRa airtime). - * Header: version|type|ttl|ts(8)|flags|payloadLen(2)|sender(8)|[recipient(8) if - * flags&0x01]|payload|[sig(64) if flags&0x02]. Padding is appended AFTER the - * signature, so trimming to true length never touches signed/encrypted bytes; - * receivers read payloadLen and already accept unpadded packets. Returns len - * unchanged if the header does not parse or claims more than we received. */ + * Layout: version|type|ttl|ts(8)|flags|payloadLen(2 in v1, 4 in v2)|sender(8)| + * [recipient(8) if flags&0x01]|[route count(1)+N*8 if v2 && flags&0x08]| + * payload|[sig(64) if flags&0x02]. Padding is appended AFTER the signature, + * so trimming to true length never touches signed/encrypted bytes; receivers + * read payloadLen and already accept unpadded packets. Returns len unchanged + * if the header does not parse or claims more than we received. */ static uint16_t trunk_true_len(const uint8_t *data, uint16_t len) { if (len < 14) { return len; } - uint8_t flags = data[11]; - uint16_t payload_len = ((uint16_t)data[12] << 8) | data[13]; - uint32_t real = 22u + payload_len; /* header(22) + payload */ + uint8_t version = data[0]; + uint8_t flags = data[PKT_FLAGS_OFF]; + uint32_t payload_len; + uint32_t real; + if (version >= 2) { + if (len < 16) { + return len; + } + payload_len = ((uint32_t)data[12] << 24) | ((uint32_t)data[13] << 16) | + ((uint32_t)data[14] << 8) | data[15]; + real = 16 + 8; /* v2 header + sender */ + } else { + payload_len = ((uint16_t)data[12] << 8) | data[13]; + real = 14 + 8; /* v1 header + sender */ + } if (flags & 0x01) { real += 8; /* recipient id */ } + if (version >= 2 && (flags & 0x08)) { + if (real >= len) { + return len; + } + real += 1 + (uint32_t)data[real] * 8; /* route count + hops */ + } + real += payload_len; if (flags & 0x02) { real += 64; /* Ed25519 signature */ } @@ -321,7 +352,7 @@ static uint16_t trunk_true_len(const uint8_t *data, uint16_t len) static int lora_link_send(uint16_t handle, const uint8_t *data, uint16_t len) { (void)handle; - if (!s_active || len == 0 || len > BITCHAT_BLE_MAX_PACKET_SIZE) { + if (!s_active || len == 0 || len > BITLE_PACKET_MAX_ROUTED) { return -1; } /* Trim BLE padding before it costs LoRa airtime. */ @@ -506,7 +537,7 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) return; } - static uint8_t packet[BITCHAT_BLE_MAX_PACKET_SIZE]; + static uint8_t packet[BITLE_PACKET_MAX_ROUTED]; uint16_t plen = 0; for (uint8_t i = 0; i < total; ++i) { if (plen + slot->part_len[i] > sizeof(packet)) { diff --git a/main/bitle_mesh.c b/main/bitle_mesh.c index df875c5..221bd35 100644 --- a/main/bitle_mesh.c +++ b/main/bitle_mesh.c @@ -11,6 +11,7 @@ #include "bitchat_time.h" #include "bitle_link.h" #include "bitle_ota.h" +#include "bitle_route.h" #include "bitle_stats.h" #include "bitle_sync.h" #include "noise_handshake.h" @@ -234,8 +235,15 @@ static void dispatch_packet(uint16_t link_handle, const bitchat_packet_t *packet } /* --- Mesh relay ----------------------------------------------------------- - * Forwards packets between links: TTL-decremented, deduplicated raw - * re-broadcast of everything not addressed to (or sent by) this node. */ + * Mirrors upstream PacketRelayManager.handlePacketRelay: packets addressed + * to (or sent by) this node are never relayed, TTL 0 stops forwarding, and + * the TTL is decremented before any forwarding. A v2 source route (flag + * 0x08) gets targeted next-hop forwarding — we forward only when we appear + * in the route, toward the next hop (or the final recipient when we are the + * last hop), with the signed route left byte-intact; when the next hop is + * not directly connected we fall back to flooding. Unrouted packets flood + * to every link except the ingress link and the original sender's own link. + * REQUEST_SYNC and undirected handshakes stay link-local (Bitle hardening). */ #define RELAY_CACHE_SIZE 64 @@ -245,7 +253,8 @@ static size_t s_relay_seen_next; static uint64_t relay_fingerprint(const uint8_t *data, size_t len) { /* FNV-1a over the packet bytes, skipping the TTL byte (offset 2), which - * changes at every hop and must not defeat deduplication. */ + * changes at every hop and must not defeat deduplication. The route is + * signed and forwarded intact, so it needs no such treatment. */ uint64_t hash = 1469598103934665603ULL; for (size_t i = 0; i < len; ++i) { if (i == 2) { @@ -269,27 +278,91 @@ static bool relay_seen_before(uint64_t fingerprint) return false; } +/* Source-route forwarding (upstream: route present, we are a listed hop). + * Returns true when the packet was unicast toward its next hop; false means + * "fall back to flooding". */ +static bool relay_along_route(uint16_t src_link, uint8_t *buffer, uint16_t len, const bitchat_packet_t *packet) +{ + (void)src_link; + /* Duplicate hops mean a routing loop; upstream drops the packet. */ + for (uint8_t i = 0; i < packet->route_count; ++i) { + for (uint8_t j = i + 1; j < packet->route_count; ++j) { + if (memcmp(packet->route[i], packet->route[j], 8) == 0) { + ESP_LOGW(TAG, "Route with duplicate hops dropped"); + return true; /* handled: dropped */ + } + } + } + const uint8_t *me = noise_get_local_peer_id(); + int index = -1; + for (uint8_t i = 0; i < packet->route_count; ++i) { + if (memcmp(packet->route[i], me, 8) == 0) { + index = i; + break; + } + } + if (index < 0) { + return false; /* not our hop: the flood path takes it */ + } + const uint8_t *next; + if (index + 1 < packet->route_count) { + next = packet->route[index + 1]; + } else if (packet->has_recipient) { + next = packet->recipient_id; /* last intermediate: deliver to the recipient */ + } else { + return false; + } + uint16_t link = bitle_route_link_for(next); + if (link == BITLE_LINK_NONE) { + ESP_LOGW(TAG, "Route next hop %02X%02X.. not directly connected; flooding", + next[0], next[1]); + return false; + } + if (bitle_link_send(link, buffer, len) != ESP_OK) { + return false; + } + bitle_stats_note_tx(); + bitle_stats_note_activity(BITLE_LANE_FWD, 1); + ESP_LOGI(TAG, "Route-relay type=0x%02X ttl=%u -> %02X%02X%02X%02X.. link=%u", + packet->type, buffer[2], next[0], next[1], next[2], next[3], link); + return true; +} + static void relay_packet(uint16_t src_link, uint8_t *buffer, uint16_t len, const bitchat_packet_t *packet) { - if (packet->ttl <= 1) { - return; + if (is_local_recipient(packet)) { + return; /* addressed to us; nothing to forward */ } if (memcmp(packet->sender_id, noise_get_local_peer_id(), sizeof(packet->sender_id)) == 0) { return; /* our own packet echoed back */ } + if (packet->ttl == 0) { + return; /* expired */ + } if (packet->type == BITCHAT_MSG_REQUEST_SYNC) { return; /* link-local by protocol */ } - if (is_local_recipient(packet)) { - return; /* addressed to us; nothing to forward */ - } if (!packet->has_recipient && packet->type == BITCHAT_MSG_NOISE_HANDSHAKE) { return; /* undirected handshakes are link-local */ } buffer[2] = packet->ttl - 1; - int forwarded = bitle_link_broadcast(src_link, buffer, len); + if (packet->has_route && packet->route_count > 0 && !packet->route_truncated) { + if (relay_along_route(src_link, buffer, len, packet)) { + return; + } + } + + /* Flood: every link except the one it arrived on and the sender's own + * point-to-point link. A broadcast-medium mapping (LoRa trunk) must NOT + * suppress forwarding: the packet may have reached us over another path + * and the trunk's other listeners still need it. */ + uint16_t sender_link = bitle_route_link_for(packet->sender_id); + if (sender_link != BITLE_LINK_NONE && bitle_link_is_broadcast(sender_link)) { + sender_link = BITLE_LINK_NONE; + } + int forwarded = bitle_link_broadcast(src_link, sender_link, buffer, len); if (forwarded > 0) { bitle_stats_note_tx(); bitle_stats_note_activity(BITLE_LANE_FWD, 1); @@ -318,6 +391,15 @@ bool bitle_mesh_inbound(uint16_t link_handle, uint8_t *buffer, uint16_t len) * link, and every reply then exits the wrong interface. Retries are * never byte-identical (fresh timestamps/nonces), so they pass. */ if (relay_seen_before(relay_fingerprint(buffer, len))) { + /* Upstream re-admits announces still at their origin TTL past dedup + * (SecurityManager): the same announce races through several paths + * at once, and if a relayed copy won, the direct copy — the only + * proof of who is directly connected — must still be processed. + * The duplicate is dispatched locally only: never re-relayed, + * never re-ingested into the sync store. */ + if (packet.type == BITCHAT_MSG_ANNOUNCE && packet.ttl == BITLE_ORIGIN_TTL) { + dispatch_packet(link_handle, &packet); + } xSemaphoreGive(s_lock); ESP_LOGD(TAG, "duplicate packet dropped (type=0x%02X)", packet.type); bitchat_packet_free(&packet); diff --git a/main/bitle_mesh.h b/main/bitle_mesh.h index a7e9064..eb65c1d 100644 --- a/main/bitle_mesh.h +++ b/main/bitle_mesh.h @@ -2,9 +2,12 @@ #define BITLE_MESH_H /* Transport-agnostic mesh core: packet dispatch, fragment reassembly, - * dedup, and TTL relay. Transports (BLE and the LoRa trunk) feed every + * dedup, and relay. Transports (BLE and the LoRa trunk) feed every * complete inbound packet here; relaying goes back out through the - * bitle_link registry to every other ready link, regardless of medium. + * bitle_link registry. Forwarding mirrors the upstream BitChat relay: + * v2 source routes get targeted next-hop forwarding (peer IDs resolved + * to links via bitle_route, with flood fallback), everything else floods + * to every other ready link, regardless of medium. * * bitle_mesh_inbound is safe to call from multiple tasks: the whole * inbound path (dispatch, fragment pool, dedup ring, sync ingest, relay) diff --git a/main/bitle_route.c b/main/bitle_route.c new file mode 100644 index 0000000..f6ed5f1 --- /dev/null +++ b/main/bitle_route.c @@ -0,0 +1,396 @@ +#include "bitle_route.h" + +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "bitle_link.h" + +static const char *TAG = "bitle_route"; + +/* BLE allows at most BITLE_LINK_MAX simultaneous direct peers, but broadcast + * media (the LoRa trunk) fronts further neighbors, so the table is wider. */ +#define DIRECT_MAX 16 + +/* Topology learned from gossip TLVs; sized for the small meshes these nodes + * serve, LRU-evicted under pressure. */ +#define GRAPH_MAX 24 + +/* Neighbors heard over broadcast media never get a link-down event, so their + * direct mappings lapse unless refreshed by periodic announces (LoRa beacons + * run every 60 s; upstream's stale-peer timeout is 180 s). */ +#define DIRECT_BROADCAST_MAX_AGE_MS (180 * 1000ULL) + +typedef struct { + bool in_use; + uint8_t peer_id[8]; + uint16_t link; + uint64_t last_ms; +} direct_entry_t; + +typedef struct { + bool in_use; + uint8_t peer_id[8]; + uint64_t timestamp_ms; /* announce's own (attacker-controlled) timestamp */ + uint64_t updated_ms; /* local monotonic, for LRU eviction only */ + uint8_t count; + uint8_t neighbors[BITLE_ROUTE_MAX_NEIGHBORS][8]; +} graph_entry_t; + +static SemaphoreHandle_t s_lock; +static direct_entry_t s_direct[DIRECT_MAX]; +static graph_entry_t s_graph[GRAPH_MAX]; + +esp_err_t bitle_route_init(void) +{ + s_lock = xSemaphoreCreateMutex(); + return s_lock ? ESP_OK : ESP_ERR_NO_MEM; +} + +static uint64_t now_ms(void) +{ + return esp_timer_get_time() / 1000ULL; +} + +static bool id_eq(const uint8_t a[8], const uint8_t b[8]) +{ + return memcmp(a, b, 8) == 0; +} + +/* Point-to-point mappings die with their link; broadcast-media mappings + * lapse without refreshes. Must be called with s_lock held. */ +static bool direct_entry_live_locked(direct_entry_t *e) +{ + if (!e->in_use) { + return false; + } + if (bitle_link_is_broadcast(e->link) && + now_ms() - e->last_ms > DIRECT_BROADCAST_MAX_AGE_MS) { + e->in_use = false; + return false; + } + return true; +} + +void bitle_route_note_direct(const uint8_t peer_id[8], uint16_t link) +{ + if (!s_lock) { + return; + } + xSemaphoreTake(s_lock, portMAX_DELAY); + direct_entry_t *slot = NULL; + direct_entry_t *oldest = &s_direct[0]; + for (size_t i = 0; i < DIRECT_MAX; ++i) { + direct_entry_t *e = &s_direct[i]; + direct_entry_live_locked(e); + if (e->in_use && id_eq(e->peer_id, peer_id)) { + slot = e; + break; + } + if (!e->in_use && !slot) { + slot = e; + } + if (e->last_ms < oldest->last_ms) { + oldest = e; + } + } + if (!slot) { + slot = oldest; + } + slot->in_use = true; + memcpy(slot->peer_id, peer_id, 8); + slot->link = link; + slot->last_ms = now_ms(); + xSemaphoreGive(s_lock); +} + +void bitle_route_link_down(uint16_t link) +{ + if (!s_lock) { + return; + } + xSemaphoreTake(s_lock, portMAX_DELAY); + for (size_t i = 0; i < DIRECT_MAX; ++i) { + if (s_direct[i].in_use && s_direct[i].link == link) { + s_direct[i].in_use = false; + } + } + xSemaphoreGive(s_lock); +} + +uint16_t bitle_route_link_for(const uint8_t peer_id[8]) +{ + if (!s_lock) { + return BITLE_LINK_NONE; + } + uint16_t link = BITLE_LINK_NONE; + xSemaphoreTake(s_lock, portMAX_DELAY); + for (size_t i = 0; i < DIRECT_MAX; ++i) { + direct_entry_t *e = &s_direct[i]; + if (direct_entry_live_locked(e) && id_eq(e->peer_id, peer_id)) { + link = e->link; + break; + } + } + xSemaphoreGive(s_lock); + return link; +} + +bool bitle_route_is_direct(const uint8_t peer_id[8]) +{ + return bitle_route_link_for(peer_id) != BITLE_LINK_NONE; +} + +int bitle_route_direct_peers(uint8_t out[][8], int max) +{ + if (!s_lock || !out || max <= 0) { + return 0; + } + int n = 0; + xSemaphoreTake(s_lock, portMAX_DELAY); + for (size_t i = 0; i < DIRECT_MAX && n < max; ++i) { + direct_entry_t *e = &s_direct[i]; + if (!direct_entry_live_locked(e)) { + continue; + } + bool dup = false; + for (int j = 0; j < n; ++j) { + if (id_eq(out[j], e->peer_id)) { + dup = true; + break; + } + } + if (!dup) { + memcpy(out[n++], e->peer_id, 8); + } + } + xSemaphoreGive(s_lock); + return n; +} + +void bitle_route_note_neighbors(const uint8_t peer_id[8], const uint8_t neighbors[][8], + int count, uint64_t timestamp_ms) +{ + if (!s_lock || count < 0) { + return; + } + if (count > BITLE_ROUTE_MAX_NEIGHBORS) { + count = BITLE_ROUTE_MAX_NEIGHBORS; + } + xSemaphoreTake(s_lock, portMAX_DELAY); + graph_entry_t *entry = NULL; + graph_entry_t *oldest = &s_graph[0]; + for (size_t i = 0; i < GRAPH_MAX; ++i) { + graph_entry_t *g = &s_graph[i]; + if (g->in_use && id_eq(g->peer_id, peer_id)) { + entry = g; + break; + } + if (!g->in_use && !entry) { + entry = g; + } + if (g->updated_ms < oldest->updated_ms) { + oldest = g; + } + } + if (entry && entry->in_use && timestamp_ms <= entry->timestamp_ms) { + /* Only strictly newer claims replace what we already know. */ + xSemaphoreGive(s_lock); + return; + } + if (!entry) { + entry = oldest; + } + memset(entry->neighbors, 0, sizeof(entry->neighbors)); + entry->in_use = true; + memcpy(entry->peer_id, peer_id, 8); + entry->timestamp_ms = timestamp_ms; + entry->updated_ms = now_ms(); + entry->count = 0; + for (int i = 0; i < count; ++i) { + if (id_eq(neighbors[i], peer_id)) { + continue; /* self-loop claims carry no topology */ + } + bool dup = false; + for (int j = 0; j < entry->count; ++j) { + if (id_eq(entry->neighbors[j], neighbors[i])) { + dup = true; + break; + } + } + if (!dup && entry->count < BITLE_ROUTE_MAX_NEIGHBORS) { + memcpy(entry->neighbors[entry->count++], neighbors[i], 8); + } + } + xSemaphoreGive(s_lock); +} + +/* Neighbor set of one BFS node: our own neighborhood is the direct-peer + * table (which only holds verified direct links); everyone else's is their + * gossiped claim. Must be called with s_lock held. */ +static int neighbors_of_locked(const uint8_t peer_id[8], const uint8_t src[8], + uint8_t out[][8], int max) +{ + int n = 0; + if (id_eq(peer_id, src)) { + for (size_t i = 0; i < DIRECT_MAX && n < max; ++i) { + direct_entry_t *e = &s_direct[i]; + if (direct_entry_live_locked(e)) { + memcpy(out[n++], e->peer_id, 8); + } + } + return n; + } + for (size_t i = 0; i < GRAPH_MAX; ++i) { + graph_entry_t *g = &s_graph[i]; + if (g->in_use && id_eq(g->peer_id, peer_id)) { + n = g->count < max ? g->count : max; + memcpy(out, g->neighbors, (size_t)n * 8); + return n; + } + } + return 0; +} + +/* A routable edge exists only when both sides announced each other + * (upstream MeshGraphService "confirmed edge"). Caller holds s_lock. */ +static bool edge_confirmed_locked(const uint8_t a[8], const uint8_t b[8], const uint8_t src[8]) +{ + uint8_t tmp[BITLE_ROUTE_MAX_NEIGHBORS + DIRECT_MAX][8]; + int na = neighbors_of_locked(a, src, tmp, sizeof(tmp) / sizeof(tmp[0])); + bool a_lists_b = false; + for (int i = 0; i < na; ++i) { + if (id_eq(tmp[i], b)) { + a_lists_b = true; + break; + } + } + if (!a_lists_b) { + return false; + } + int nb = neighbors_of_locked(b, src, tmp, sizeof(tmp) / sizeof(tmp[0])); + for (int i = 0; i < nb; ++i) { + if (id_eq(tmp[i], a)) { + return true; + } + } + return false; +} + +#define BFS_MAX_NODES (1 + DIRECT_MAX + GRAPH_MAX + 1) + +int bitle_route_compute(const uint8_t src[8], const uint8_t dest[8], + uint8_t out_hops[][8], int max_hops) +{ + if (!s_lock || !src || !dest || !out_hops || max_hops <= 0) { + return -1; + } + xSemaphoreTake(s_lock, portMAX_DELAY); + + /* A currently-connected direct neighbor needs no route. */ + for (size_t i = 0; i < DIRECT_MAX; ++i) { + if (direct_entry_live_locked(&s_direct[i]) && id_eq(s_direct[i].peer_id, dest)) { + xSemaphoreGive(s_lock); + return 0; + } + } + + uint8_t ids[BFS_MAX_NODES][8]; + int n_ids = 0; + memcpy(ids[n_ids++], src, 8); + for (size_t i = 0; i < DIRECT_MAX && n_ids < BFS_MAX_NODES; ++i) { + if (!direct_entry_live_locked(&s_direct[i])) { + continue; + } + bool seen = id_eq(s_direct[i].peer_id, src); + for (int j = 0; j < n_ids && !seen; ++j) { + seen = id_eq(ids[j], s_direct[i].peer_id); + } + if (!seen) { + memcpy(ids[n_ids++], s_direct[i].peer_id, 8); + } + } + for (size_t i = 0; i < GRAPH_MAX && n_ids < BFS_MAX_NODES; ++i) { + if (!s_graph[i].in_use) { + continue; + } + bool seen = false; + for (int j = 0; j < n_ids && !seen; ++j) { + seen = id_eq(ids[j], s_graph[i].peer_id); + } + if (!seen) { + memcpy(ids[n_ids++], s_graph[i].peer_id, 8); + } + } + int dest_idx = -1; + for (int i = 0; i < n_ids; ++i) { + if (id_eq(ids[i], dest)) { + dest_idx = i; + break; + } + } + if (dest_idx < 0) { + /* The destination never announced its neighborhood, so no confirmed + * edge can terminate there. */ + xSemaphoreGive(s_lock); + return -1; + } + + int8_t parent[BFS_MAX_NODES]; + uint8_t queue[BFS_MAX_NODES]; + memset(parent, -1, sizeof(parent)); + parent[0] = 0; + size_t head = 0, tail = 0; + queue[tail++] = 0; + while (head < tail && parent[dest_idx] < 0) { + uint8_t cur = queue[head++]; + uint8_t nbr[BITLE_ROUTE_MAX_NEIGHBORS + DIRECT_MAX][8]; + int n = neighbors_of_locked(ids[cur], src, nbr, sizeof(nbr) / sizeof(nbr[0])); + for (int i = 0; i < n; ++i) { + int ni = -1; + for (int j = 0; j < n_ids; ++j) { + if (id_eq(ids[j], nbr[i])) { + ni = j; + break; + } + } + if (ni < 0 || parent[ni] >= 0) { + continue; + } + if (!edge_confirmed_locked(ids[cur], ids[ni], src)) { + continue; + } + parent[ni] = (int8_t)cur; + queue[tail++] = (uint8_t)ni; + if (ni == dest_idx) { + break; + } + } + } + + int result = -1; + if (parent[dest_idx] >= 0) { + /* Walk dest -> src, then reverse into hop order. */ + uint8_t path[BFS_MAX_NODES]; + int path_len = 0; + for (int at = dest_idx; at != 0 && path_len < BFS_MAX_NODES; at = parent[at]) { + path[path_len++] = (uint8_t)at; + } + /* path holds [dest, ..., first hop] (src excluded); intermediates + * are everything but dest, emitted sender-to-recipient order. */ + int intermediates = path_len - 1; + if (intermediates >= 1 && intermediates <= max_hops) { + for (int i = 0; i < intermediates; ++i) { + memcpy(out_hops[i], ids[path[path_len - 1 - i]], 8); + } + result = intermediates; + } else if (intermediates > max_hops) { + ESP_LOGW(TAG, "confirmed path too long (%d hops); not routing", intermediates); + } + } + xSemaphoreGive(s_lock); + return result; +} diff --git a/main/bitle_route.h b/main/bitle_route.h new file mode 100644 index 0000000..6fa61ce --- /dev/null +++ b/main/bitle_route.h @@ -0,0 +1,68 @@ +#ifndef BITLE_ROUTE_H +#define BITLE_ROUTE_H + +/* Peer-ID based routing state, mirroring the upstream BitChat mesh graph + * (bitchat-android services/meshgraph + BluetoothConnectionTracker): + * + * - Direct-peer table: which link a peer is directly connected on, learned + * exclusively from signature-verified ANNOUNCEs still at their origin TTL + * (the upstream DirectLinkAnnouncementPolicy rule). This is the only + * peer-ID -> link mapping; routed forwarding and the "is this peer + * directly connected" query both resolve through it. + * - Neighbor graph: every verified ANNOUNCE's DIRECT_NEIGHBORS gossip TLV + * (0x04), timestamp-gated so only strictly newer claims replace older + * ones. An edge is routable only when mutually announced (confirmed), + * exactly like upstream MeshGraphService. + * - Source-route computation: unit-weight shortest path (BFS) over + * confirmed edges, intermediates only, matching upstream RoutePlanner. + */ + +#include "esp_err.h" +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Upstream caps a DIRECT_NEIGHBORS TLV at 10 peer IDs. */ +#define BITLE_ROUTE_MAX_NEIGHBORS 10 + +esp_err_t bitle_route_init(void); + +/* Records peer_id as directly connected on the given link. Call only for + * signature-verified announces that arrived at their origin TTL. Refreshes + * the entry's liveness timestamp. */ +void bitle_route_note_direct(const uint8_t peer_id[8], uint16_t link); + +/* Drops every direct-peer mapping that pointed at a link which went away. */ +void bitle_route_link_down(uint16_t link); + +/* Link the peer is directly connected on, or BITLE_LINK_NONE. Mappings on + * broadcast media (LoRa) lapse when not refreshed by periodic announces; + * point-to-point mappings live until their link goes down. */ +uint16_t bitle_route_link_for(const uint8_t peer_id[8]); + +/* True when the peer currently has a live direct connection to us. */ +bool bitle_route_is_direct(const uint8_t peer_id[8]); + +/* Our verified direct peers, for the ANNOUNCE gossip TLV. Returns count. */ +int bitle_route_direct_peers(uint8_t out[][8], int max); + +/* Stores a verified peer's announced neighbor set; timestamp_ms is the + * announce's own timestamp — only strictly newer claims replace older ones. */ +void bitle_route_note_neighbors(const uint8_t peer_id[8], const uint8_t neighbors[][8], + int count, uint64_t timestamp_ms); + +/* Shortest confirmed path from src to dest. Returns the number of + * intermediate hops written to out_hops (>= 1: attach as a source route), + * 0 when dest is a direct neighbor (no route needed), or -1 when no + * confirmed path exists. */ +int bitle_route_compute(const uint8_t src[8], const uint8_t dest[8], + uint8_t out_hops[][8], int max_hops); + +#ifdef __cplusplus +} +#endif + +#endif // BITLE_ROUTE_H diff --git a/main/main.c b/main/main.c index 2b0c093..736c3ca 100644 --- a/main/main.c +++ b/main/main.c @@ -14,6 +14,7 @@ #include "bitle_lora.h" #include "bitle_mesh.h" #include "bitle_ota.h" +#include "bitle_route.h" #include "bitle_stats.h" #include "bitle_sync.h" #include "noise_handshake.h" @@ -73,6 +74,7 @@ void app_main(void) abort(); } + ESP_ERROR_CHECK(bitle_route_init()); ESP_ERROR_CHECK(bitle_link_init()); ESP_ERROR_CHECK(bitle_mesh_init()); bitle_stats_init(); diff --git a/main/noise_handshake.c b/main/noise_handshake.c index 96617a1..6c8d6e5 100644 --- a/main/noise_handshake.c +++ b/main/noise_handshake.c @@ -18,6 +18,7 @@ #include "bitchat_time.h" #include "bitle_courier.h" #include "bitle_ota.h" +#include "bitle_route.h" #include "bitle_sync.h" #include "nickname_manager.h" #include "packet_codec.h" @@ -48,6 +49,10 @@ #define NOISE_PACKET_TTL 7 #define NOISE_MAX_ENCRYPTED_PAYLOAD 320 #define ANNOUNCE_INTERVAL_MS (10 * 1000ULL) +/* Upstream rejects announces more than 10 min off our clock; applied here + * only where staleness matters for security (the direct-peer mapping), so a + * node without a synced clock is never locked out of time recovery. */ +#define ANNOUNCE_SKEW_MS (10 * 60 * 1000ULL) #define BITLE_AUTO_REPLY_TEXT \ "This is an automated reply. Bitle is a relay node that extends the " \ @@ -611,7 +616,9 @@ static bool build_announce_payload(uint8_t *buffer, size_t buffer_len, size_t *o nickname_len = 255; } - size_t required = 2 + nickname_len + 2 + sizeof(s_static_public) + 2 + sizeof(s_ed25519_public); + /* Worst case adds a full DIRECT_NEIGHBORS TLV below. */ + size_t required = 2 + nickname_len + 2 + sizeof(s_static_public) + 2 + sizeof(s_ed25519_public) + + 2 + 4 + 2 + 1 + 2 + 8 * BITLE_ROUTE_MAX_NEIGHBORS; if (buffer_len < required) { ESP_LOGW(TAG, "ANNOUNCE payload buffer too small"); return false; @@ -652,6 +659,22 @@ static bool build_announce_payload(uint8_t *buffer, size_t buffer_len, size_t *o buffer[offset++] = 1; buffer[offset++] = 0x01 | (bitchat_time_is_authoritative() ? 0x02 : 0x00); + /* Upstream DIRECT_NEIGHBORS gossip TLV (0x04): our verified direct + * peers as N*8 peer-ID bytes, no count byte, capped at 10. Phones fold + * these into their mesh graph and can then source-route packets through + * us (docs/ANNOUNCEMENT_GOSSIP.md). Being inside the signed announce, + * the neighbor claim is authenticated like the keys above it. */ + uint8_t peers[BITLE_ROUTE_MAX_NEIGHBORS][8]; + int peer_count = bitle_route_direct_peers(peers, BITLE_ROUTE_MAX_NEIGHBORS); + if (peer_count > 0) { + buffer[offset++] = 0x04; + buffer[offset++] = (uint8_t)(peer_count * 8); + for (int i = 0; i < peer_count; ++i) { + memcpy(buffer + offset, peers[i], 8); + offset += 8; + } + } + *out_len = offset; return true; } @@ -662,10 +685,18 @@ static bool build_canonical_packet(const bitchat_packet_t *packet, uint8_t *out_ return false; } - uint8_t header[BITCHAT_BLE_MAX_PACKET_SIZE]; + /* Mirrors upstream BinaryProtocol.encodeForSigning: the full wire + * encoding minus the signature, with TTL forced to zero so relays may + * decrement it. The source route (v2, flag 0x08) is part of the signed + * bytes, so it must be reproduced here exactly; the signature, compress + * and Bitle-private RSR flags stay excluded as before. */ + bool routed = packet->has_route && packet->route_count > 0; + bool v2 = packet->version >= 2 || routed; + + uint8_t header[BITLE_PACKET_MAX_ROUTED]; size_t header_len = 0; - header[header_len++] = packet->version; + header[header_len++] = v2 ? 2 : 1; header[header_len++] = packet->type; header[header_len++] = 0; // TTL forced to zero @@ -677,8 +708,15 @@ static bool build_canonical_packet(const bitchat_packet_t *packet, uint8_t *out_ if (packet->has_recipient) { flags |= 0x01; } + if (routed) { + flags |= 0x08; + } header[header_len++] = flags; + if (v2) { + header[header_len++] = (packet->payload_len >> 24) & 0xFF; + header[header_len++] = (packet->payload_len >> 16) & 0xFF; + } header[header_len++] = (packet->payload_len >> 8) & 0xFF; header[header_len++] = packet->payload_len & 0xFF; @@ -690,6 +728,14 @@ static bool build_canonical_packet(const bitchat_packet_t *packet, uint8_t *out_ header_len += sizeof(packet->recipient_id); } + if (routed) { + header[header_len++] = packet->route_count; + for (uint8_t i = 0; i < packet->route_count; ++i) { + memcpy(header + header_len, packet->route[i], sizeof(packet->route[i])); + header_len += sizeof(packet->route[i]); + } + } + if (header_len + packet->payload_len > max_len) { return false; } @@ -707,7 +753,7 @@ static bool build_canonical_packet(const bitchat_packet_t *packet, uint8_t *out_ static bool sign_packet(bitchat_packet_t *packet) { - uint8_t buffer[BITCHAT_BLE_MAX_PACKET_SIZE]; + uint8_t buffer[BITLE_PACKET_MAX_ROUTED]; size_t canonical_len = 0; if (!build_canonical_packet(packet, buffer, &canonical_len, sizeof(buffer))) { ESP_LOGW(TAG, "Canonical encode failed for type=0x%02X", packet->type); @@ -789,7 +835,7 @@ bool noise_announce_link(uint16_t link_handle) if (!bitchat_time_is_valid()) { return false; } - uint8_t announce_payload[128]; + uint8_t announce_payload[NOISE_MESSAGE_MAX]; size_t announce_len = 0; if (!build_announce_payload(announce_payload, sizeof(announce_payload), &announce_len)) { return false; @@ -824,7 +870,7 @@ static bool send_announce(noise_session_t *session) return false; } - uint8_t announce_payload[128]; + uint8_t announce_payload[NOISE_MESSAGE_MAX]; size_t announce_len = 0; if (!build_announce_payload(announce_payload, sizeof(announce_payload), &announce_len)) { ESP_LOGW(TAG, "Failed to build ANNOUNCE payload"); @@ -1003,6 +1049,36 @@ static bool parse_announce_tlv(const uint8_t *payload, size_t len, noise_identit return have_nick && have_noise && have_sign; } +/* Extracts the upstream DIRECT_NEIGHBORS gossip TLV (0x04): N*8 peer-ID + * bytes, no count byte — N = len/8, trailing partial bytes ignored. Returns + * the entry count (0 when the TLV is absent, which upstream treats as an + * explicit "I have no direct neighbors"). Callers run this only after the + * payload passed parse_announce_tlv, so TLV framing is already known good. */ +static int parse_announce_neighbors(const uint8_t *payload, size_t len, + uint8_t out[][8], int max) +{ + size_t offset = 0; + while (offset + 2 <= len) { + uint8_t tlv_type = payload[offset++]; + uint8_t tlv_len = payload[offset++]; + if (offset + tlv_len > len) { + return 0; + } + if (tlv_type == 0x04) { + int n = tlv_len / 8; + if (n > max) { + n = max; + } + for (int i = 0; i < n; ++i) { + memcpy(out[i], payload + offset + (size_t)i * 8, 8); + } + return n; + } + offset += tlv_len; + } + return 0; +} + /* Returns false when the sender ID is not derived from the announced Noise * key (spoofed announce, hard reject). Sets *out_sig_ok when the packet * signature verifies against the announced signing key. */ @@ -1025,7 +1101,7 @@ static bool verify_announce_event(const noise_event_t *evt, const noise_identity packet.payload = (uint8_t *)evt->payload; packet.payload_len = evt->payload_len; - uint8_t canonical[BITCHAT_BLE_MAX_PACKET_SIZE]; + uint8_t canonical[BITLE_PACKET_MAX_ROUTED]; size_t canonical_len = 0; if (build_canonical_packet(&packet, canonical, &canonical_len, sizeof(canonical))) { *out_sig_ok = ed25519_sign_open(canonical, canonical_len, @@ -1463,6 +1539,29 @@ static void process_announce_event(const noise_event_t *evt) * (direct), flood one copy (relayed owner), or spray carriers. */ bitle_courier_peer_announced(conn_handle, evt->peer_id, ident.noise_key, true, is_direct, bitchat_time_now_ms()); + /* Mesh graph: every verified announce refreshes the sender's + * neighborhood (relayed ones too — that is how topology past our + * direct peers is learned). A direct one additionally pins the + * peer to this link, which is what makes it a usable next hop + * for source-routed forwarding. Mirrors upstream: direct iff the + * announce is verified AND still at its origin TTL. */ + uint8_t neighbors[BITLE_ROUTE_MAX_NEIGHBORS][8]; + int neighbor_count = parse_announce_neighbors(evt->payload, evt->payload_len, + neighbors, BITLE_ROUTE_MAX_NEIGHBORS); + bitle_route_note_neighbors(evt->peer_id, neighbors, neighbor_count, + evt->timestamp_ms); + if (is_direct) { + /* A non-decremented re-broadcast of an old announce (the + * dedup re-admit path makes such copies visible) must not + * pin this peer ID to the attacker's link. */ + uint64_t now_ms = bitchat_time_now_ms(); + bool fresh = !bitchat_time_is_valid() || + (evt->timestamp_ms + ANNOUNCE_SKEW_MS > now_ms && + evt->timestamp_ms < now_ms + ANNOUNCE_SKEW_MS); + if (fresh) { + bitle_route_note_direct(evt->peer_id, conn_handle); + } + } /* Authoritative clock source: a phone (no 0xB1) is the time * authority and may correct even a wrong synced clock; an * authoritative Bitle propagates real time hop-by-hop. Only @@ -1727,6 +1826,43 @@ esp_err_t noise_send_raw(uint16_t conn_handle, bitchat_message_type_t type, cons return encode_and_send(conn_handle, type, recipient, payload, payload_len, false); } +/* Attaches a source route to a directed packet whose recipient is only + * reachable through the mesh, mirroring upstream applyRouteIfAvailable: + * confirmed-edge shortest path, intermediates only, version bumped to 2 (the + * route is then covered by the packet signature). Direct recipients, + * broadcast, link-local (ttl 0) traffic and the Bitle-private OTA types are + * left alone. */ +static void maybe_attach_route(bitchat_packet_t *packet) +{ + if (!packet->has_recipient || packet->ttl == 0) { + return; + } + if (packet->type >= BITLE_MSG_OTA_MANIFEST) { + return; /* 0xA0..0xA3: node-to-node on direct links by design */ + } + bool broadcast = true; + for (size_t i = 0; i < sizeof(packet->recipient_id); ++i) { + if (packet->recipient_id[i] != 0xFF) { + broadcast = false; + break; + } + } + if (broadcast) { + return; + } + if (bitle_route_is_direct(packet->recipient_id)) { + return; /* direct neighbors get no route, like upstream */ + } + uint8_t hops[BITLE_ROUTE_MAX_HOPS][8]; + int hop_count = bitle_route_compute(s_peer_id, packet->recipient_id, hops, BITLE_ROUTE_MAX_HOPS); + if (hop_count >= 1) { + memcpy(packet->route, hops, (size_t)hop_count * 8); + packet->route_count = (uint8_t)hop_count; + packet->has_route = true; + packet->version = 2; + } +} + esp_err_t noise_send_packet(uint16_t conn_handle, bitchat_message_type_t type, const uint8_t recipient[8], const uint8_t *payload, size_t payload_len, uint8_t ttl, bool sign) { bitchat_packet_t packet; @@ -1745,15 +1881,25 @@ esp_err_t noise_send_packet(uint16_t conn_handle, bitchat_message_type_t type, c } packet.payload = (uint8_t *)payload; packet.payload_len = payload_len; + maybe_attach_route(&packet); if (sign && !sign_packet(&packet)) { return ESP_FAIL; } - uint8_t buffer[BITCHAT_BLE_MAX_PACKET_SIZE]; + uint8_t buffer[BITLE_PACKET_MAX_ROUTED]; size_t encoded_len = sizeof(buffer); if (!bitchat_packet_encode(&packet, buffer, &encoded_len, sizeof(buffer))) { return ESP_FAIL; } - return bitle_link_send(conn_handle, buffer, (uint16_t)encoded_len); + /* With a route, transmit only toward the first hop (upstream broadcaster + * stage A); the caller's link is the fallback when it vanished. */ + uint16_t out_link = conn_handle; + if (packet.has_route && packet.route_count > 0) { + uint16_t first = bitle_route_link_for(packet.route[0]); + if (first != BITLE_LINK_NONE) { + out_link = first; + } + } + return bitle_link_send(out_link, buffer, (uint16_t)encoded_len); } const char *noise_get_nickname(void) @@ -1798,7 +1944,7 @@ bool noise_verify_packet_signature(const bitchat_packet_t *packet, const uint8_t if (!packet || !packet->has_signature) { return false; } - uint8_t canonical[BITCHAT_BLE_MAX_PACKET_SIZE]; + uint8_t canonical[BITLE_PACKET_MAX_ROUTED]; size_t canonical_len = 0; if (!build_canonical_packet(packet, canonical, &canonical_len, sizeof(canonical))) { return false; diff --git a/main/noise_handshake.h b/main/noise_handshake.h index 9e47b01..7043f20 100644 --- a/main/noise_handshake.h +++ b/main/noise_handshake.h @@ -6,6 +6,8 @@ #include #include +#include "bitchat_ble.h" + #ifdef __cplusplus extern "C" { #endif @@ -49,6 +51,17 @@ typedef enum { BITCHAT_NOISE_PAYLOAD_VERIFY_RESPONSE = 0x11, } bitchat_noise_payload_type_t; +/* Upstream source routing (docs/SOURCE_ROUTING.md): a v2 packet may carry a + * route — an ordered list of intermediate 8-byte peer IDs, sender and final + * recipient excluded. Relays listed in it forward to the next hop; everyone + * else floods. Longer routes decode as truncated and are flooded, never + * dropped. */ +#define BITLE_ROUTE_MAX_HOPS 8 + +/* Largest packet this node originates: the v1 BLE budget plus the v2 + * length-field growth and a full-size source route. */ +#define BITLE_PACKET_MAX_ROUTED (BITCHAT_BLE_MAX_PACKET_SIZE + 2 + 1 + 8 * BITLE_ROUTE_MAX_HOPS) + typedef struct { uint8_t version; uint8_t type; @@ -59,6 +72,10 @@ typedef struct { bool has_recipient; bool is_compressed; bool is_rsr; /* flag 0x10: solicited sync response replay */ + bool has_route; /* v2 flag 0x08: source route present */ + bool route_truncated; /* wire route longer than BITLE_ROUTE_MAX_HOPS */ + uint8_t route_count; /* valid entries in route */ + uint8_t route[BITLE_ROUTE_MAX_HOPS][8]; uint8_t *payload; uint16_t payload_len; uint8_t signature[64]; diff --git a/main/packet_codec.c b/main/packet_codec.c index 9698f46..1746eec 100644 --- a/main/packet_codec.c +++ b/main/packet_codec.c @@ -66,6 +66,13 @@ static uint64_t read_u64_be(const uint8_t *data, size_t len, size_t *offset, boo return value; } +/* Header flag bits (upstream BinaryProtocol.Flags; 0x10 is Bitle-private). */ +#define FLAG_HAS_RECIPIENT 0x01 +#define FLAG_HAS_SIGNATURE 0x02 +#define FLAG_IS_COMPRESSED 0x04 +#define FLAG_HAS_ROUTE 0x08 +#define FLAG_IS_RSR 0x10 + bool bitchat_packet_decode(const uint8_t *data, size_t len, bitchat_packet_t *out_packet) { if (!data || !out_packet || len == 0) { @@ -80,9 +87,22 @@ bool bitchat_packet_decode(const uint8_t *data, size_t len, bitchat_packet_t *ou out_packet->ttl = read_u8(data, len, &offset, &ok); out_packet->timestamp_ms = read_u64_be(data, len, &offset, &ok); uint8_t flags = read_u8(data, len, &offset, &ok); - uint16_t payload_len = read_u16_be(data, len, &offset, &ok); - if (!ok || out_packet->version != 1) { + /* v1: 2-byte payload length; v2 (source routing): 4-byte payload length. */ + uint32_t payload_len = 0; + if (ok && out_packet->version == 1) { + payload_len = read_u16_be(data, len, &offset, &ok); + } else if (ok && out_packet->version == 2) { + if (offset + 4 > len) { + ok = false; + } else { + payload_len = ((uint32_t)data[offset] << 24) | ((uint32_t)data[offset + 1] << 16) | + ((uint32_t)data[offset + 2] << 8) | data[offset + 3]; + offset += 4; + } + } + + if (!ok || (out_packet->version != 1 && out_packet->version != 2)) { ESP_LOGW(TAG, "Invalid packet header"); return false; } @@ -96,10 +116,10 @@ bool bitchat_packet_decode(const uint8_t *data, size_t len, bitchat_packet_t *ou /* zlib-compressed payload (flag 0x04): we cannot inflate it locally, but * the header and raw bytes stay usable for time sync and relaying. */ - out_packet->is_compressed = (flags & 0x04) != 0; - out_packet->is_rsr = (flags & 0x10) != 0; + out_packet->is_compressed = (flags & FLAG_IS_COMPRESSED) != 0; + out_packet->is_rsr = (flags & FLAG_IS_RSR) != 0; - if (flags & 0x01) { + if (flags & FLAG_HAS_RECIPIENT) { out_packet->has_recipient = true; if (offset + sizeof(out_packet->recipient_id) > len) { ESP_LOGW(TAG, "Missing recipient id bytes"); @@ -109,11 +129,40 @@ bool bitchat_packet_decode(const uint8_t *data, size_t len, bitchat_packet_t *ou offset += 8; } - if (offset + payload_len > len) { + /* Source route (v2+, flag 0x08): 1-byte hop count + N*8 peer IDs, after + * recipient, before payload. Longer routes than we can store are kept + * marked-truncated so the relay floods instead of mis-forwarding. */ + if (out_packet->version >= 2 && (flags & FLAG_HAS_ROUTE)) { + if (offset + 1 > len) { + ESP_LOGW(TAG, "Missing route count byte"); + return false; + } + uint8_t count = data[offset++]; + if (count > 0) { + if (offset + (size_t)count * 8 > len) { + ESP_LOGW(TAG, "Route bytes exceed buffer"); + return false; + } + out_packet->has_route = true; + out_packet->route_count = count < BITLE_ROUTE_MAX_HOPS ? count : BITLE_ROUTE_MAX_HOPS; + out_packet->route_truncated = count > BITLE_ROUTE_MAX_HOPS; + for (uint8_t i = 0; i < out_packet->route_count; ++i) { + memcpy(out_packet->route[i], data + offset + (size_t)i * 8, 8); + } + offset += (size_t)count * 8; + } + } + + if (payload_len > len - offset) { ESP_LOGW(TAG, "Payload length exceeds buffer"); return false; } + if (payload_len > UINT16_MAX) { + ESP_LOGW(TAG, "Payload too large"); + return false; + } + if (payload_len > 0) { out_packet->payload = heap_caps_malloc(payload_len, MALLOC_CAP_8BIT); if (!out_packet->payload) { @@ -122,28 +171,34 @@ bool bitchat_packet_decode(const uint8_t *data, size_t len, bitchat_packet_t *ou } memcpy(out_packet->payload, data + offset, payload_len); } - out_packet->payload_len = payload_len; + out_packet->payload_len = (uint16_t)payload_len; offset += payload_len; - /* Compressed payload (v1): [2-byte original size BE][raw deflate]. On - * success the packet becomes a normal one; on failure it stays marked - * compressed and is handled relay-only. */ - if (out_packet->is_compressed && payload_len > 2) { - uint16_t original_len = ((uint16_t)out_packet->payload[0] << 8) | out_packet->payload[1]; - if (original_len > 0) { - uint8_t *inflated = inflate_payload(out_packet->payload + 2, payload_len - 2, original_len); + /* Compressed payload: [original size BE][raw deflate], 2-byte size in v1, + * 4-byte in v2. On success the packet becomes a normal one; on failure it + * stays marked compressed and is handled relay-only. */ + size_t size_field = out_packet->version >= 2 ? 4 : 2; + if (out_packet->is_compressed && payload_len > size_field) { + uint32_t original_len = 0; + for (size_t i = 0; i < size_field; ++i) { + original_len = (original_len << 8) | out_packet->payload[i]; + } + if (original_len > 0 && original_len <= UINT16_MAX) { + uint8_t *inflated = inflate_payload(out_packet->payload + size_field, + payload_len - size_field, original_len); if (inflated) { heap_caps_free(out_packet->payload); out_packet->payload = inflated; - out_packet->payload_len = original_len; + out_packet->payload_len = (uint16_t)original_len; out_packet->is_compressed = false; } else { - ESP_LOGW(TAG, "Failed to inflate payload (%u -> %u)", payload_len, original_len); + ESP_LOGW(TAG, "Failed to inflate payload (%lu -> %lu)", + (unsigned long)payload_len, (unsigned long)original_len); } } } - if (flags & 0x02) { + if (flags & FLAG_HAS_SIGNATURE) { out_packet->has_signature = true; if (offset + 64 > len) { ESP_LOGW(TAG, "Missing signature bytes"); @@ -164,14 +219,22 @@ bool bitchat_packet_encode(const bitchat_packet_t *packet, uint8_t *out_buf, siz } size_t offset = 0; - size_t header_len = 1 /*version*/ + 1 /*type*/ + 1 /*ttl*/ + 8 /*timestamp*/ + 1 /*flags*/ + 2 /*payload len*/ + 8 /*sender*/ - + (packet->has_recipient ? 8 : 0); + /* A route forces version 2, like upstream applyRouteIfAvailable. */ + bool routed = packet->has_route && packet->route_count > 0; + uint8_t version = routed && packet->version < 2 ? 2 : packet->version; + bool v2 = version >= 2; + uint8_t route_count = routed && v2 ? packet->route_count : 0; + + size_t header_len = 1 /*version*/ + 1 /*type*/ + 1 /*ttl*/ + 8 /*timestamp*/ + 1 /*flags*/ + + (v2 ? 4 : 2) /*payload len*/ + 8 /*sender*/ + + (packet->has_recipient ? 8 : 0) + + (route_count ? 1 + (size_t)route_count * 8 : 0); size_t total_len = header_len + packet->payload_len + (packet->has_signature ? 64 : 0); if (total_len > max_len) { return false; } - out_buf[offset++] = packet->version; + out_buf[offset++] = version; out_buf[offset++] = packet->type; out_buf[offset++] = packet->ttl; @@ -181,16 +244,23 @@ bool bitchat_packet_encode(const bitchat_packet_t *packet, uint8_t *out_buf, siz uint8_t flags = 0; if (packet->has_recipient) { - flags |= 0x01; + flags |= FLAG_HAS_RECIPIENT; } if (packet->has_signature) { - flags |= 0x02; + flags |= FLAG_HAS_SIGNATURE; + } + if (route_count) { + flags |= FLAG_HAS_ROUTE; } if (packet->is_rsr) { - flags |= 0x10; + flags |= FLAG_IS_RSR; } out_buf[offset++] = flags; + if (v2) { + out_buf[offset++] = (packet->payload_len >> 24) & 0xFF; + out_buf[offset++] = (packet->payload_len >> 16) & 0xFF; + } out_buf[offset++] = (packet->payload_len >> 8) & 0xFF; out_buf[offset++] = packet->payload_len & 0xFF; @@ -202,6 +272,14 @@ bool bitchat_packet_encode(const bitchat_packet_t *packet, uint8_t *out_buf, siz offset += 8; } + if (route_count) { + out_buf[offset++] = route_count; + for (uint8_t i = 0; i < route_count; ++i) { + memcpy(out_buf + offset, packet->route[i], 8); + offset += 8; + } + } + if (packet->payload_len > 0) { if (!packet->payload) { return false; @@ -279,6 +357,47 @@ bool packet_codec_self_test(void) memcmp(decoded.signature, packet.signature, sizeof(packet.signature)) == 0; bitchat_packet_free(&decoded); + if (!ok) { + return false; + } + + /* v2 source-route round trip: route survives encode/decode byte-exact. */ + bitchat_packet_t routed = {0}; + routed.version = 2; + routed.type = BITCHAT_MSG_NOISE_ENCRYPTED; + routed.ttl = 5; + routed.timestamp_ms = packet.timestamp_ms; + memcpy(routed.sender_id, packet.sender_id, sizeof(routed.sender_id)); + memcpy(routed.recipient_id, recipient, sizeof(routed.recipient_id)); + routed.has_recipient = true; + routed.has_route = true; + routed.route_count = 2; + memcpy(routed.route[0], (uint8_t[]){0x30,0x31,0x32,0x33,0x34,0x35,0x36,0x37}, 8); + memcpy(routed.route[1], (uint8_t[]){0x40,0x41,0x42,0x43,0x44,0x45,0x46,0x47}, 8); + routed.payload = (uint8_t *)payload; + routed.payload_len = sizeof(payload); + memcpy(routed.signature, signature, sizeof(routed.signature)); + routed.has_signature = true; + + encoded_len = sizeof(encoded); + if (!bitchat_packet_encode(&routed, encoded, &encoded_len, sizeof(encoded))) { + return false; + } + bitchat_packet_t decoded_routed; + if (!bitchat_packet_decode(encoded, encoded_len, &decoded_routed)) { + return false; + } + ok = decoded_routed.version == 2 && + decoded_routed.has_route && !decoded_routed.route_truncated && + decoded_routed.route_count == 2 && + memcmp(decoded_routed.route[0], routed.route[0], 8) == 0 && + memcmp(decoded_routed.route[1], routed.route[1], 8) == 0 && + decoded_routed.ttl == routed.ttl && + decoded_routed.payload_len == routed.payload_len && + memcmp(decoded_routed.payload, routed.payload, routed.payload_len) == 0 && + decoded_routed.has_signature && + memcmp(decoded_routed.signature, routed.signature, 64) == 0; + bitchat_packet_free(&decoded_routed); return ok; } From fe9e95edaa10301ed8414160013bfe723371a8e9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:29:33 +0200 Subject: [PATCH 09/19] refactor(lora): queue trunk packets atomically --- docs/LoRa-reliability-implementation-plan.md | 60 ++- main/CMakeLists.txt | 1 + main/bitchat_ble.c | 98 +++- main/bitle_link.c | 113 ++++- main/bitle_link.h | 43 +- main/bitle_lora.c | 465 ++++++++++++++----- main/bitle_lora.h | 10 + main/lora_tx_scheduler.c | 240 ++++++++++ main/lora_tx_scheduler.h | 116 +++++ main/noise_handshake.c | 52 ++- tests/test_lora_tx_scheduler.c | 218 +++++++++ tools/lora_hardware_smoke.py | 27 +- tools/run_lora_host_tests.sh | 11 +- 13 files changed, 1315 insertions(+), 139 deletions(-) create mode 100644 main/lora_tx_scheduler.c create mode 100644 main/lora_tx_scheduler.h create mode 100644 tests/test_lora_tx_scheduler.c diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index c769fef..eb79f71 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -61,7 +61,7 @@ The implementation is complete only when all of the following are true: |---|---|---|---| | 0. Baseline harness and observability | Complete | `d130a3a` | Host baseline, both firmware targets, and two-board SF10 smoke passed | | 1. Airtime-safe SX1262 operation | Complete | `028511e` | Host/profile tests, both firmware targets, and SF10-SF12 two-board radio tests passed | -| 2. Atomic packet scheduling and backpressure | Pending | — | — | +| 2. Atomic packet scheduling and backpressure | Complete | pending checkpoint | Concurrent saturation, both firmware targets, and two-board SF10 smoke passed | | 3. Addressed trunk protocol and migration | Pending | — | — | | 4. Packet-level reliability and reassembly | Pending | — | — | | 5. Discovery and shared-channel behavior | Pending | — | — | @@ -206,23 +206,23 @@ give callers an honest transport result. ### Checklist -- [ ] Replace the frame queue with a whole-packet TX descriptor queue, or reserve +- [x] Replace the frame queue with a whole-packet TX descriptor queue, or reserve all required frame capacity atomically before enqueueing any fragment. -- [ ] Use a bounded static packet pool so queueing does not add heap +- [x] Use a bounded static packet pool so queueing does not add heap fragmentation to the hot path. -- [ ] Define explicit send results: accepted, deferred/backpressured, +- [x] Define explicit send results: accepted, deferred/backpressured, policy-dropped, and transport-failed. -- [ ] Update `bitle_link` callers so policy drops are not reported as successful +- [x] Update `bitle_link` callers so policy drops are not reported as successful transport delivery. -- [ ] Ensure a Noise nonce or higher-layer state is not irreversibly advanced +- [x] Ensure a Noise nonce or higher-layer state is not irreversibly advanced when the transport rejects a packet before transmission. -- [ ] Give control traffic, ACKs, discovery, and user data explicit priorities +- [x] Give control traffic, ACKs, discovery, and user data explicit priorities with starvation bounds. -- [ ] Preserve packet ordering where the BitChat or Noise protocol requires it. -- [ ] Add queue admission metrics by packet type and priority. -- [ ] Add saturation tests with concurrent BLE relay, Noise response, beacon, +- [x] Preserve packet ordering where the BitChat or Noise protocol requires it. +- [x] Add queue admission metrics by packet type and priority. +- [x] Add saturation tests with concurrent BLE relay, Noise response, beacon, courier, and sync traffic. -- [ ] Add cancellation and cleanup tests for radio reset and link shutdown. +- [x] Add cancellation and cleanup tests for radio reset and link shutdown. ### Success criteria @@ -238,9 +238,43 @@ give callers an honest transport result. ### Milestone record -- Status: Pending +- Status: Complete - Evidence: -- Commit: + - The frame queue is replaced by a 12-slot, 520-byte static whole-packet + pool. A packet is invisible to the consumer until commit, stays owned + through every fragment and retry, and is released on completion or every + terminal error path. + - The portable scheduler test races BLE relay, Noise response, beacon, + courier, and sync producer classes. Forty concurrent attempts produce + exactly 12 complete admissions and 28 deferred results. A separate race + for one remaining slot produces one acceptance and one deferral, with no + partial descriptor. + - FIFO order is retained within each traffic class. The weighted + control/user/control/discovery/user/bulk rotation services every + continuously queued class within six selections. ACKs remain the highest + immediate priority but are capped at a four-frame burst before queued + packet traffic receives an opportunity. + - `bitle_link` exposes accepted, deferred, policy-dropped, and failed + outcomes. Broadcast accounting counts only accepted ownership. LoRa + policy decisions are no longer reported as successful sends. + - Two-phase reservations secure a LoRa packet slot or BLE mbuf before Noise + encryption advances its explicit nonce. A rejected reservation changes + neither the application nonce nor the cipher state. + - Host cancellation tests release queued, reserved, and in-flight slots. + Firmware shutdown cancels the complete pool after stopping the task; + successful radio recovery retains the in-flight packet, while terminal + recovery and retry failures release it. + - ESP-IDF 6.0 builds pass for the Heltec V3 ESP32-S3 configuration and the + BLE-only ESP32-C3 target. + - A two-board SF10 diagnostic run exchanged one- through five-fragment + scheduled packets. Both nodes reported zero radio timeouts, command + errors, BUSY timeouts, recovery failures, and watchdog events. Pool + high-water marks remained below the 12-slot bound, there were no + backpressure events, and snapshots satisfied the ownership invariant: + every accepted descriptor was either released or visibly in flight. + - Both test boards were restored to the normal SF10 image with diagnostic + smoke disabled after the capture. +- Commit: pending checkpoint - Suggested commit subject: `refactor(lora): queue trunk packets atomically` ## Milestone 3: Addressed trunk protocol and migration diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 24bc0d5..1aa2df6 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -11,6 +11,7 @@ idf_component_register( "bitle_lora.c" "lora_airtime.c" "lora_region.c" + "lora_tx_scheduler.c" "sx1262.c" "bitle_ota.c" "bitle_store.c" diff --git a/main/bitchat_ble.c b/main/bitchat_ble.c index 6f158cb..c848eba 100644 --- a/main/bitchat_ble.c +++ b/main/bitchat_ble.c @@ -190,7 +190,8 @@ static int link_send(ble_conn_state_t *state, const uint8_t *data, uint16_t len) * knowing they are BLE. Called from the LoRa task, so snapshot the connection * fields under the lock, then do the (internally thread-safe) NimBLE call * outside it — never touching s_connections while teardown may be memsetting. */ -static int ble_link_send_cb(uint16_t handle, const uint8_t *data, uint16_t len) +static bitle_link_send_result_t ble_link_send_cb( + uint16_t handle, const uint8_t *data, uint16_t len) { taskENTER_CRITICAL(&s_conn_mux); ble_conn_state_t *state = find_conn(handle); @@ -201,19 +202,93 @@ static int ble_link_send_cb(uint16_t handle, const uint8_t *data, uint16_t len) taskEXIT_CRITICAL(&s_conn_mux); if (!ready) { - return BLE_HS_ENOTCONN; + return BITLE_LINK_SEND_FAILED; } + int rc; if (is_central) { if (!remote_val) { - return BLE_HS_EINVAL; + return BITLE_LINK_SEND_FAILED; + } + rc = ble_gattc_write_no_rsp_flat(conn_handle, remote_val, data, len); + } else { + struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len); + if (!om) { + return BITLE_LINK_SEND_DEFERRED; } - return ble_gattc_write_no_rsp_flat(conn_handle, remote_val, data, len); + rc = ble_gattc_notify_custom(conn_handle, s_tx_val_handle, om); } - struct os_mbuf *om = ble_hs_mbuf_from_flat(data, len); + if (rc == 0) { + return BITLE_LINK_SEND_ACCEPTED; + } + return rc == BLE_HS_ENOMEM ? BITLE_LINK_SEND_DEFERRED + : BITLE_LINK_SEND_FAILED; +} + +static bitle_link_send_result_t ble_link_reserve_cb( + uint16_t handle, uint8_t packet_type, uintptr_t *out_token) +{ + (void)packet_type; + if (!out_token) { + return BITLE_LINK_SEND_FAILED; + } + taskENTER_CRITICAL(&s_conn_mux); + ble_conn_state_t *state = find_conn(handle); + bool ready = state && state->subscribed; + taskEXIT_CRITICAL(&s_conn_mux); + if (!ready) { + return BITLE_LINK_SEND_FAILED; + } + static const uint8_t empty[BITCHAT_BLE_MAX_PACKET_SIZE]; + struct os_mbuf *om = + ble_hs_mbuf_from_flat(empty, sizeof(empty)); if (!om) { - return BLE_HS_ENOMEM; + return BITLE_LINK_SEND_DEFERRED; + } + *out_token = (uintptr_t)om; + return BITLE_LINK_SEND_ACCEPTED; +} + +static bitle_link_send_result_t ble_link_commit_cb( + uint16_t handle, uintptr_t token, const uint8_t *data, uint16_t len) +{ + struct os_mbuf *om = (struct os_mbuf *)token; + if (!om || !data || len == 0 || len > BITCHAT_BLE_MAX_PACKET_SIZE) { + os_mbuf_free_chain(om); + return BITLE_LINK_SEND_FAILED; + } + if (os_mbuf_copyinto(om, 0, data, len) != 0) { + os_mbuf_free_chain(om); + return BITLE_LINK_SEND_FAILED; + } + os_mbuf_adj(om, -(int)(BITCHAT_BLE_MAX_PACKET_SIZE - len)); + + taskENTER_CRITICAL(&s_conn_mux); + ble_conn_state_t *state = find_conn(handle); + bool ready = state && state->subscribed; + bool is_central = ready && state->is_central; + uint16_t conn_handle = ready ? state->conn_handle : 0; + uint16_t remote_val = ready ? state->remote_val_handle : 0; + taskEXIT_CRITICAL(&s_conn_mux); + if (!ready || (is_central && !remote_val)) { + os_mbuf_free_chain(om); + return BITLE_LINK_SEND_FAILED; + } + int rc = is_central + ? ble_gattc_write_no_rsp(conn_handle, remote_val, om) + : ble_gattc_notify_custom(conn_handle, s_tx_val_handle, om); + if (rc == 0) { + return BITLE_LINK_SEND_ACCEPTED; + } + return rc == BLE_HS_ENOMEM ? BITLE_LINK_SEND_DEFERRED + : BITLE_LINK_SEND_FAILED; +} + +static void ble_link_cancel_cb(uint16_t handle, uintptr_t token) +{ + (void)handle; + if (token) { + os_mbuf_free_chain((struct os_mbuf *)token); } - return ble_gattc_notify_custom(conn_handle, s_tx_val_handle, om); } static int gatt_access_cb(uint16_t conn_handle, uint16_t attr_handle, @@ -335,7 +410,9 @@ static int central_cccd_written(uint16_t conn_handle, const struct ble_gatt_erro state->subscribed = true; ESP_LOGI(TAG, "conn=%u bitle-to-bitle link ready (val_handle=%u)", conn_handle, state->remote_val_handle); - bitle_link_register(conn_handle, BITLE_LINK_BLE, ble_link_send_cb); + bitle_link_register_reservable( + conn_handle, BITLE_LINK_BLE, ble_link_send_cb, + ble_link_reserve_cb, ble_link_commit_cb, ble_link_cancel_cb); noise_notify_subscribed(conn_handle); return 0; } @@ -535,7 +612,10 @@ static int gap_event_cb(struct ble_gap_event *event, void *arg) event->subscribe.cur_notify, event->subscribe.cur_indicate); if (state && state->subscribed && !was_subscribed) { - bitle_link_register(event->subscribe.conn_handle, BITLE_LINK_BLE, ble_link_send_cb); + bitle_link_register_reservable( + event->subscribe.conn_handle, BITLE_LINK_BLE, + ble_link_send_cb, ble_link_reserve_cb, + ble_link_commit_cb, ble_link_cancel_cb); noise_notify_subscribed(event->subscribe.conn_handle); } else if (state && !state->subscribed && was_subscribed) { bitle_link_unregister(event->subscribe.conn_handle); diff --git a/main/bitle_link.c b/main/bitle_link.c index 876b443..a4ef7aa 100644 --- a/main/bitle_link.c +++ b/main/bitle_link.c @@ -13,6 +13,9 @@ typedef struct { uint16_t handle; bitle_link_type_t type; bitle_link_send_fn_t send_fn; + bitle_link_reserve_fn_t reserve_fn; + bitle_link_commit_fn_t commit_fn; + bitle_link_cancel_fn_t cancel_fn; } link_entry_t; static link_entry_t s_links[BITLE_LINK_MAX]; @@ -36,7 +39,19 @@ static link_entry_t *find_locked(uint16_t handle) esp_err_t bitle_link_register(uint16_t handle, bitle_link_type_t type, bitle_link_send_fn_t send_fn) { - if (!send_fn || handle == BITLE_LINK_NONE) { + return bitle_link_register_reservable( + handle, type, send_fn, NULL, NULL, NULL); +} + +esp_err_t bitle_link_register_reservable( + uint16_t handle, bitle_link_type_t type, bitle_link_send_fn_t send_fn, + bitle_link_reserve_fn_t reserve_fn, bitle_link_commit_fn_t commit_fn, + bitle_link_cancel_fn_t cancel_fn) +{ + bool incomplete_reservation = + (reserve_fn || commit_fn || cancel_fn) && + !(reserve_fn && commit_fn && cancel_fn); + if (!send_fn || handle == BITLE_LINK_NONE || incomplete_reservation) { return ESP_ERR_INVALID_ARG; } xSemaphoreTake(s_lock, portMAX_DELAY); @@ -58,6 +73,9 @@ esp_err_t bitle_link_register(uint16_t handle, bitle_link_type_t type, bitle_lin e->handle = handle; e->type = type; e->send_fn = send_fn; + e->reserve_fn = reserve_fn; + e->commit_fn = commit_fn; + e->cancel_fn = cancel_fn; xSemaphoreGive(s_lock); ESP_LOGI(TAG, "link up handle=%u type=%s", handle, type == BITLE_LINK_BLE ? "ble" : "lora"); return ESP_OK; @@ -93,17 +111,93 @@ int bitle_link_type_of(uint16_t handle) return type; } -esp_err_t bitle_link_send(uint16_t handle, const uint8_t *data, uint16_t len) +bitle_link_send_result_t bitle_link_send_detailed( + uint16_t handle, const uint8_t *data, uint16_t len) { xSemaphoreTake(s_lock, portMAX_DELAY); link_entry_t *e = find_locked(handle); bitle_link_send_fn_t fn = e ? e->send_fn : NULL; xSemaphoreGive(s_lock); if (!fn) { - return ESP_ERR_INVALID_STATE; + return BITLE_LINK_SEND_FAILED; } /* Send outside the lock: transport sends may block briefly. */ - return fn(handle, data, len) == 0 ? ESP_OK : ESP_FAIL; + return fn(handle, data, len); +} + +bitle_link_send_result_t bitle_link_reserve_send( + uint16_t handle, uint8_t packet_type, bitle_link_reservation_t *out) +{ + if (!out) { + return BITLE_LINK_SEND_FAILED; + } + memset(out, 0, sizeof(*out)); + xSemaphoreTake(s_lock, portMAX_DELAY); + link_entry_t *entry = find_locked(handle); + bitle_link_reserve_fn_t reserve_fn = + entry ? entry->reserve_fn : NULL; + bitle_link_commit_fn_t commit_fn = + entry ? entry->commit_fn : NULL; + bitle_link_cancel_fn_t cancel_fn = + entry ? entry->cancel_fn : NULL; + xSemaphoreGive(s_lock); + if (!reserve_fn || !commit_fn || !cancel_fn) { + return BITLE_LINK_SEND_FAILED; + } + + uintptr_t token = 0; + bitle_link_send_result_t result = + reserve_fn(handle, packet_type, &token); + if (result == BITLE_LINK_SEND_ACCEPTED) { + out->handle = handle; + out->token = token; + out->commit_fn = commit_fn; + out->cancel_fn = cancel_fn; + out->active = true; + } + return result; +} + +bitle_link_send_result_t bitle_link_commit_send( + bitle_link_reservation_t *reservation, const uint8_t *data, uint16_t len) +{ + if (!reservation || !reservation->active || + !reservation->commit_fn) { + return BITLE_LINK_SEND_FAILED; + } + bitle_link_commit_fn_t commit_fn = reservation->commit_fn; + uint16_t handle = reservation->handle; + uintptr_t token = reservation->token; + reservation->active = false; + return commit_fn(handle, token, data, len); +} + +void bitle_link_cancel_send(bitle_link_reservation_t *reservation) +{ + if (!reservation || !reservation->active || + !reservation->cancel_fn) { + return; + } + bitle_link_cancel_fn_t cancel_fn = reservation->cancel_fn; + uint16_t handle = reservation->handle; + uintptr_t token = reservation->token; + reservation->active = false; + cancel_fn(handle, token); +} + +esp_err_t bitle_link_send(uint16_t handle, const uint8_t *data, uint16_t len) +{ + switch (bitle_link_send_detailed(handle, data, len)) { + case BITLE_LINK_SEND_ACCEPTED: + return ESP_OK; + case BITLE_LINK_SEND_DEFERRED: + return ESP_ERR_TIMEOUT; + case BITLE_LINK_SEND_POLICY_DROPPED: + return ESP_ERR_NOT_SUPPORTED; + case BITLE_LINK_SEND_FAILED: + default: + return ESP_FAIL; + } } int bitle_link_broadcast(uint16_t exclude_handle, const uint8_t *data, uint16_t len) @@ -127,11 +221,16 @@ int bitle_link_broadcast(uint16_t exclude_handle, const uint8_t *data, uint16_t int sent = 0; for (size_t i = 0; i < n; ++i) { - int rc = targets[i].fn(targets[i].handle, data, len); - if (rc == 0) { + bitle_link_send_result_t result = + targets[i].fn(targets[i].handle, data, len); + if (result == BITLE_LINK_SEND_ACCEPTED) { sent++; + } else if (result == BITLE_LINK_SEND_POLICY_DROPPED) { + ESP_LOGD(TAG, "broadcast policy drop handle=%u", + targets[i].handle); } else { - ESP_LOGW(TAG, "broadcast send failed handle=%u rc=%d", targets[i].handle, rc); + ESP_LOGW(TAG, "broadcast send not accepted handle=%u result=%d", + targets[i].handle, result); } } return sent; diff --git a/main/bitle_link.h b/main/bitle_link.h index 1e496d8..d5cfd61 100644 --- a/main/bitle_link.h +++ b/main/bitle_link.h @@ -40,18 +40,59 @@ typedef enum { BITLE_LINK_LORA, } bitle_link_type_t; -typedef int (*bitle_link_send_fn_t)(uint16_t handle, const uint8_t *data, uint16_t len); +typedef enum { + BITLE_LINK_SEND_ACCEPTED = 0, + BITLE_LINK_SEND_DEFERRED, + BITLE_LINK_SEND_POLICY_DROPPED, + BITLE_LINK_SEND_FAILED, +} bitle_link_send_result_t; + +typedef bitle_link_send_result_t (*bitle_link_send_fn_t)( + uint16_t handle, const uint8_t *data, uint16_t len); +typedef bitle_link_send_result_t (*bitle_link_reserve_fn_t)( + uint16_t handle, uint8_t packet_type, uintptr_t *out_token); +typedef bitle_link_send_result_t (*bitle_link_commit_fn_t)( + uint16_t handle, uintptr_t token, const uint8_t *data, uint16_t len); +typedef void (*bitle_link_cancel_fn_t)(uint16_t handle, uintptr_t token); + +typedef struct { + uint16_t handle; + uintptr_t token; + bitle_link_commit_fn_t commit_fn; + bitle_link_cancel_fn_t cancel_fn; + bool active; +} bitle_link_reservation_t; esp_err_t bitle_link_init(void); /* Registers a link as ready to carry packets. Idempotent per handle. */ esp_err_t bitle_link_register(uint16_t handle, bitle_link_type_t type, bitle_link_send_fn_t send_fn); +esp_err_t bitle_link_register_reservable( + uint16_t handle, bitle_link_type_t type, bitle_link_send_fn_t send_fn, + bitle_link_reserve_fn_t reserve_fn, bitle_link_commit_fn_t commit_fn, + bitle_link_cancel_fn_t cancel_fn); void bitle_link_unregister(uint16_t handle); bool bitle_link_ready(uint16_t handle); /* Link type for a registered handle, or -1 when unknown. */ int bitle_link_type_of(uint16_t handle); +/* Detailed result for callers that need to distinguish backpressure from a + * permanent policy decision or a broken transport. ACCEPTED means the + * transport owns the packet; it does not imply end-to-end delivery. */ +bitle_link_send_result_t bitle_link_send_detailed( + uint16_t handle, const uint8_t *data, uint16_t len); + +/* Two-phase ownership for producers such as Noise that must secure transport + * capacity before advancing a nonce. Only links registered with reservation + * callbacks support this operation. */ +bitle_link_send_result_t bitle_link_reserve_send( + uint16_t handle, uint8_t packet_type, bitle_link_reservation_t *out); +bitle_link_send_result_t bitle_link_commit_send( + bitle_link_reservation_t *reservation, const uint8_t *data, uint16_t len); +void bitle_link_cancel_send(bitle_link_reservation_t *reservation); + +/* Compatibility wrapper for existing esp_err_t callers. */ esp_err_t bitle_link_send(uint16_t handle, const uint8_t *data, uint16_t len); /* Sends to every registered link except exclude_handle (BITLE_LINK_NONE to diff --git a/main/bitle_lora.c b/main/bitle_lora.c index 42eed51..26d86e1 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -8,7 +8,6 @@ #include "esp_random.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" -#include "freertos/queue.h" #include "freertos/task.h" #include "nvs.h" @@ -18,6 +17,7 @@ #include "bitle_stats.h" #include "lora_airtime.h" #include "lora_region.h" +#include "lora_tx_scheduler.h" #include "noise_handshake.h" #include "packet_codec.h" #include "sx1262.h" @@ -124,10 +124,10 @@ static const char *TAG = "bitle_lora"; #define ARQ_MARGIN_MS 1200 /* Channel access: CAD (listen-before-talk) + random backoff before TX. */ -#define TX_QUEUE_DEPTH 12 #define CAD_RETRIES 5 #define CAD_BACKOFF_MIN 30 #define CAD_BACKOFF_SPAN 120 +#define ACK_BURST_MAX 4 typedef struct { uint16_t len; @@ -135,7 +135,10 @@ typedef struct { uint8_t data[SX1262_MAX_PAYLOAD]; } lora_frame_t; -static QueueHandle_t s_tx_queue; +_Static_assert(BITCHAT_BLE_MAX_PACKET_SIZE <= LORA_TX_PACKET_MAX_LEN, + "LoRa packet pool must hold the largest BitChat packet"); + +static lora_tx_scheduler_t s_tx_scheduler; static TaskHandle_t s_task; static bool s_active; static uint16_t s_tx_seq; @@ -189,15 +192,19 @@ static void diag_inc(uint32_t *counter) taskEXIT_CRITICAL(&s_gov_mux); } -static void diag_note_queue_depth(void) +/* Caller holds s_gov_mux. */ +static void diag_sync_scheduler_locked(void) { - uint32_t depth = s_tx_queue ? uxQueueMessagesWaiting(s_tx_queue) : 0; - taskENTER_CRITICAL(&s_gov_mux); - s_diag.queue_depth = depth; - if (depth > s_diag.queue_high_water) { - s_diag.queue_high_water = depth; + lora_tx_scheduler_metrics_t metrics; + lora_tx_scheduler_metrics(&s_tx_scheduler, &metrics); + s_diag.queue_depth = metrics.in_use; + s_diag.queue_high_water = metrics.high_water; + s_diag.packet_released = metrics.released; + s_diag.packet_cancelled = metrics.cancelled; + for (size_t priority = 0; priority < LORA_TX_PRIORITY_COUNT; ++priority) { + s_diag.packet_accepted[priority] = metrics.accepted[priority]; + s_diag.packet_deferred[priority] = metrics.deferred[priority]; } - taskEXIT_CRITICAL(&s_gov_mux); } /* Per-origin announce throttle (call inside the governor critical section). @@ -230,9 +237,9 @@ static bool announce_throttled(const uint8_t *sender, uint64_t now) return false; } -/* Admission + governor for one whole encoded packet. Returns true if the - * packet (all its fragments) is cleared to transmit, debiting airtime. */ -static bool trunk_admit(const uint8_t *data, uint16_t len) +/* Admission + governor for one whole encoded packet. Caller holds s_gov_mux, + * which couples the policy state change to the scheduler admission. */ +static bool trunk_admit_locked(const uint8_t *data, uint16_t len) { if (len < PKT_MIN_LEN) { return false; @@ -268,10 +275,8 @@ static bool trunk_admit(const uint8_t *data, uint16_t len) airtime += trunk_airtime_ms(TRUNK_HDR_LEN + chunk); } - taskENTER_CRITICAL(&s_gov_mux); - /* Sample the clock inside the lock: reading it before could let another - * task advance s_gov_last_ms past our now, and the unsigned delta below - * would underflow to a huge value and saturate the credit. */ + /* Reading the clock under the lock prevents another producer from + * advancing s_gov_last_ms past this sample. */ uint64_t now = esp_timer_get_time() / 1000ULL; /* Refill the shared airtime credit. */ s_gov_credit_ms += (double)(now - s_gov_last_ms) * s_gov_refill_frac; @@ -287,12 +292,10 @@ static bool trunk_admit(const uint8_t *data, uint16_t len) * origin's next transmittable announce. */ if (governed) { if (s_gov_credit_ms < (double)airtime) { - taskEXIT_CRITICAL(&s_gov_mux); ESP_LOGD(TAG, "airtime budget low; deferring announce"); return false; } if (announce_throttled(data + PKT_SENDER_OFF, now)) { - taskEXIT_CRITICAL(&s_gov_mux); return false; } } @@ -300,7 +303,6 @@ static bool trunk_admit(const uint8_t *data, uint16_t len) if (s_gov_credit_ms < -GOV_BURST_MS) { s_gov_credit_ms = -GOV_BURST_MS; } - taskEXIT_CRITICAL(&s_gov_mux); return true; } @@ -375,67 +377,214 @@ static uint16_t trunk_true_len(const uint8_t *data, uint16_t len) return (real <= len) ? (uint16_t)real : len; } -/* Mesh -> trunk: fragment and queue one encoded packet. Runs on the - * caller's task (NimBLE host or noise worker); only queues. */ -static int lora_link_send(uint16_t handle, const uint8_t *data, uint16_t len) +static lora_tx_priority_t trunk_priority(uint8_t type) +{ + switch (type) { + case BITCHAT_MSG_NOISE_HANDSHAKE: + case BITCHAT_MSG_LEAVE: + case BITCHAT_MSG_REQUEST_SYNC: + case BITLE_MSG_OTA_MANIFEST: + case BITLE_MSG_OTA_REQ: + case BITLE_MSG_OTA_STATUS: + return LORA_TX_PRIORITY_CONTROL; + case BITCHAT_MSG_ANNOUNCE: + case BITCHAT_MSG_NOISE_IDENTITY_ANNOUNCE: + case BITCHAT_MSG_PREKEY_BUNDLE: + return LORA_TX_PRIORITY_DISCOVERY; + case BITLE_MSG_OTA_CHUNK: + return LORA_TX_PRIORITY_BULK; + default: + return LORA_TX_PRIORITY_USER; + } +} + +static bitle_link_send_result_t lora_link_reserve( + uint16_t handle, uint8_t packet_type, uintptr_t *out_token) +{ + (void)handle; + if (!s_active || !out_token) { + return BITLE_LINK_SEND_FAILED; + } + lora_tx_priority_t priority = trunk_priority(packet_type); + lora_tx_handle_t reserved = LORA_TX_HANDLE_INVALID; + + taskENTER_CRITICAL(&s_gov_mux); + lora_tx_admit_result_t result = lora_tx_scheduler_reserve( + &s_tx_scheduler, priority, &reserved); + if (result == LORA_TX_ADMIT_DEFERRED) { + s_diag.queue_full++; + } + diag_sync_scheduler_locked(); + taskEXIT_CRITICAL(&s_gov_mux); + + if (result != LORA_TX_ADMIT_ACCEPTED) { + return result == LORA_TX_ADMIT_DEFERRED + ? BITLE_LINK_SEND_DEFERRED + : BITLE_LINK_SEND_FAILED; + } + *out_token = reserved; + return BITLE_LINK_SEND_ACCEPTED; +} + +static void lora_link_cancel(uint16_t handle, uintptr_t token) +{ + (void)handle; + if (token >= LORA_TX_PACKET_POOL_SIZE) { + return; + } + taskENTER_CRITICAL(&s_gov_mux); + lora_tx_scheduler_cancel( + &s_tx_scheduler, (lora_tx_handle_t)token); + diag_sync_scheduler_locked(); + taskEXIT_CRITICAL(&s_gov_mux); +} + +static bitle_link_send_result_t lora_link_commit( + uint16_t handle, uintptr_t token, const uint8_t *data, uint16_t len) { (void)handle; - if (!s_active || len == 0 || len > BITCHAT_BLE_MAX_PACKET_SIZE) { - return -1; + if (!data || len == 0 || len > BITCHAT_BLE_MAX_PACKET_SIZE || + token >= LORA_TX_PACKET_POOL_SIZE) { + lora_link_cancel(handle, token); + return BITLE_LINK_SEND_FAILED; } - /* Trim BLE padding before it costs LoRa airtime. */ len = trunk_true_len(data, len); - /* Type-based admission + per-origin throttle + airtime governor, applied - * atomically to the whole packet so a packet is never partially sent. A - * policy decline is a handled outcome, not a transport failure (return 0 - * so it does not read as a broken link); only a full radio queue is an - * actual send failure. */ - if (!trunk_admit(data, len)) { - return 0; + if (len < PKT_MIN_LEN) { + lora_link_cancel(handle, token); + return BITLE_LINK_SEND_FAILED; } + uint8_t type = data[PKT_TYPE_OFF]; - /* Announces are periodic broadcast discovery; everything else (DMs, - * handshakes, courier, acks-of-messages) gets per-frame ARQ. */ - bool want_ack = !(type == BITCHAT_MSG_ANNOUNCE || - type == BITCHAT_MSG_NOISE_IDENTITY_ANNOUNCE); - uint8_t total = (uint8_t)((len + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX); - ESP_LOGI(TAG, "trunk TX type=0x%02X len=%u frags=%u arq=%d", type, len, total, want_ack); + lora_tx_priority_t priority = trunk_priority(type); + lora_tx_handle_t reserved = (lora_tx_handle_t)token; + uint16_t sequence = 0; + bool committed = false; + + taskENTER_CRITICAL(&s_gov_mux); + const lora_tx_packet_t *reservation = + lora_tx_scheduler_packet(&s_tx_scheduler, reserved); + if (!s_active || !reservation || reservation->len != 0 || + reservation->priority != priority) { + lora_tx_scheduler_cancel(&s_tx_scheduler, reserved); + diag_sync_scheduler_locked(); + taskEXIT_CRITICAL(&s_gov_mux); + return BITLE_LINK_SEND_FAILED; + } + if (!trunk_admit_locked(data, len)) { + s_diag.policy_drops++; + lora_tx_scheduler_cancel(&s_tx_scheduler, reserved); + diag_sync_scheduler_locked(); + taskEXIT_CRITICAL(&s_gov_mux); + return BITLE_LINK_SEND_POLICY_DROPPED; + } + sequence = (uint16_t)(s_tx_seq + 1); + committed = lora_tx_scheduler_commit( + &s_tx_scheduler, reserved, data, len, type, sequence); + if (committed) { + s_tx_seq = sequence; + } else { + lora_tx_scheduler_cancel(&s_tx_scheduler, reserved); + } + diag_sync_scheduler_locked(); + taskEXIT_CRITICAL(&s_gov_mux); + + if (!committed) { + return BITLE_LINK_SEND_FAILED; + } + uint8_t total = + (uint8_t)((len + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX); + ESP_LOGI(TAG, + "trunk accepted type=0x%02X len=%u frags=%u priority=%u seq=%u", + type, len, total, priority, sequence); bitle_stats_note_activity(BITLE_LANE_TRUNK, 1); - /* Atomic across concurrent callers (NimBLE host, noise worker, lora_task - * beacon): a duplicated seq would collide two packets into one remote - * reassembly slot and corrupt both. Unique seq is sufficient — the - * receiver keys slots on (src,seq), so interleaved fragments still sort. */ + if (s_task) { + xTaskNotifyGive(s_task); + } + return BITLE_LINK_SEND_ACCEPTED; +} + +/* Mesh -> trunk convenience path. Reservation happens before policy state or + * sequence assignment changes, and commit publishes the whole packet once. */ +static bitle_link_send_result_t lora_link_send( + uint16_t handle, const uint8_t *data, uint16_t len) +{ + if (!data || len <= PKT_TYPE_OFF) { + return BITLE_LINK_SEND_FAILED; + } + uintptr_t token = 0; + bitle_link_send_result_t result = + lora_link_reserve(handle, data[PKT_TYPE_OFF], &token); + if (result != BITLE_LINK_SEND_ACCEPTED) { + return result; + } + return lora_link_commit(handle, token, data, len); +} + +static bool tx_scheduler_dequeue(lora_tx_handle_t *out_handle) +{ taskENTER_CRITICAL(&s_gov_mux); - uint16_t seq = ++s_tx_seq; + bool dequeued = + lora_tx_scheduler_dequeue(&s_tx_scheduler, out_handle); + diag_sync_scheduler_locked(); taskEXIT_CRITICAL(&s_gov_mux); + return dequeued; +} - for (uint8_t idx = 0; idx < total; ++idx) { - lora_frame_t frame; - uint16_t off = (uint16_t)idx * TRUNK_CHUNK_TX; - uint16_t chunk = len - off < TRUNK_CHUNK_TX ? len - off : TRUNK_CHUNK_TX; - uint8_t *h = frame.data; - h[0] = TRUNK_MAGIC0; - h[1] = TRUNK_MAGIC1; - h[2] = TRUNK_VERSION; - h[3] = want_ack ? FTYPE_ACK_REQ : 0x00; - memcpy(h + 4, s_src_tag, 4); - memset(h + 8, 0, 4); /* dst: broadcast */ - h[12] = seq >> 8; - h[13] = seq & 0xFF; - h[14] = idx; - h[15] = total; - memcpy(h + TRUNK_HDR_LEN, data + off, chunk); - frame.len = TRUNK_HDR_LEN + chunk; - frame.want_ack = want_ack; - if (xQueueSend(s_tx_queue, &frame, 0) != pdTRUE) { - diag_inc(&s_diag.queue_full); - diag_note_queue_depth(); - ESP_LOGW(TAG, "TX queue full; dropping packet seq=%u", seq); - return -1; - } - diag_note_queue_depth(); +static void tx_scheduler_release(lora_tx_handle_t handle) +{ + if (handle == LORA_TX_HANDLE_INVALID) { + return; + } + taskENTER_CRITICAL(&s_gov_mux); + lora_tx_scheduler_release(&s_tx_scheduler, handle); + diag_sync_scheduler_locked(); + taskEXIT_CRITICAL(&s_gov_mux); +} + +static bool tx_scheduler_has_queued(void) +{ + taskENTER_CRITICAL(&s_gov_mux); + bool queued = lora_tx_scheduler_queued(&s_tx_scheduler) != 0; + taskEXIT_CRITICAL(&s_gov_mux); + return queued; +} + +static bool build_tx_fragment(lora_tx_handle_t handle, uint8_t idx, + lora_frame_t *frame, uint8_t *out_total) +{ + const lora_tx_packet_t *packet = + lora_tx_scheduler_packet(&s_tx_scheduler, handle); + if (!packet || !frame || !out_total) { + return false; + } + uint8_t total = + (uint8_t)((packet->len + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX); + if (idx >= total) { + return false; } - return 0; + bool want_ack = + !(packet->type == BITCHAT_MSG_ANNOUNCE || + packet->type == BITCHAT_MSG_NOISE_IDENTITY_ANNOUNCE); + uint16_t off = (uint16_t)idx * TRUNK_CHUNK_TX; + uint16_t remaining = packet->len - off; + uint16_t chunk = + remaining < TRUNK_CHUNK_TX ? remaining : TRUNK_CHUNK_TX; + uint8_t *header = frame->data; + header[0] = TRUNK_MAGIC0; + header[1] = TRUNK_MAGIC1; + header[2] = TRUNK_VERSION; + header[3] = want_ack ? FTYPE_ACK_REQ : 0x00; + memcpy(header + 4, s_src_tag, 4); + memset(header + 8, 0, 4); + header[12] = packet->sequence >> 8; + header[13] = packet->sequence & 0xFF; + header[14] = idx; + header[15] = total; + memcpy(header + TRUNK_HDR_LEN, packet->data + off, chunk); + frame->len = TRUNK_HDR_LEN + chunk; + frame->want_ack = want_ack; + *out_total = total; + return true; } /* Acks bypass the data queue entirely. When both ends have ARQ frames in @@ -597,10 +746,41 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) #define BEACON_FIRST_MS 5000ULL #define BEACON_INTERVAL_MS 60000ULL +static void drop_tx_packet(lora_tx_handle_t *handle, bool *have_pending) +{ + tx_scheduler_release(*handle); + *handle = LORA_TX_HANDLE_INVALID; + *have_pending = false; +} + +/* Releases a completed packet, or builds the next fragment while retaining + * ownership of the same static-pool descriptor. */ +static bool advance_tx_packet(lora_tx_handle_t *handle, uint8_t *fragment_idx, + uint8_t fragment_total, lora_frame_t *frame) +{ + (*fragment_idx)++; + if (*fragment_idx >= fragment_total) { + tx_scheduler_release(*handle); + *handle = LORA_TX_HANDLE_INVALID; + return false; + } + uint8_t checked_total = 0; + if (!build_tx_fragment(*handle, *fragment_idx, frame, &checked_total) || + checked_total != fragment_total) { + tx_scheduler_release(*handle); + *handle = LORA_TX_HANDLE_INVALID; + return false; + } + return true; +} + static void lora_task(void *arg) { (void)arg; lora_frame_t pending; + lora_tx_handle_t packet_handle = LORA_TX_HANDLE_INVALID; + uint8_t fragment_idx = 0; + uint8_t fragment_total = 0; bool have_pending = false; int cad_tries = 0; int arq_sends = 0; @@ -608,6 +788,7 @@ static void lora_task(void *arg) bool tx_is_ack = false; bool awaiting_cad = false; bool awaiting_ack = false; + unsigned ack_burst = 0; uint64_t tx_watchdog_deadline = 0; uint64_t ack_deadline = 0; uint64_t next_beacon_ms = BEACON_FIRST_MS; @@ -661,7 +842,9 @@ static void lora_task(void *arg) "diag raw_rx=%lu crc=%lu tx=%lu tmo=%lu ack_rx=%lu ack_tx=%lu " "ack_miss=%lu retry=%lu exhausted=%lu q_full=%lu q=%lu/%lu " "rx_expire=%lu complete=%lu cfg_reject=%lu radio_err=%lu " - "busy_tmo=%lu recover=%lu/%lu tx_watchdog=%lu", + "busy_tmo=%lu recover=%lu/%lu tx_watchdog=%lu " + "admit=%lu/%lu/%lu/%lu defer=%lu/%lu/%lu/%lu " + "policy=%lu release=%lu cancel=%lu", (unsigned long)diag.raw_rx_frames, (unsigned long)diag.crc_errors, (unsigned long)diag.tx_attempts, @@ -681,7 +864,18 @@ static void lora_task(void *arg) (unsigned long)diag.radio_busy_timeouts, (unsigned long)diag.radio_recoveries, (unsigned long)diag.radio_recovery_failures, - (unsigned long)diag.tx_watchdog_recoveries); + (unsigned long)diag.tx_watchdog_recoveries, + (unsigned long)diag.packet_accepted[LORA_TX_PRIORITY_CONTROL], + (unsigned long)diag.packet_accepted[LORA_TX_PRIORITY_USER], + (unsigned long)diag.packet_accepted[LORA_TX_PRIORITY_DISCOVERY], + (unsigned long)diag.packet_accepted[LORA_TX_PRIORITY_BULK], + (unsigned long)diag.packet_deferred[LORA_TX_PRIORITY_CONTROL], + (unsigned long)diag.packet_deferred[LORA_TX_PRIORITY_USER], + (unsigned long)diag.packet_deferred[LORA_TX_PRIORITY_DISCOVERY], + (unsigned long)diag.packet_deferred[LORA_TX_PRIORITY_BULK], + (unsigned long)diag.policy_drops, + (unsigned long)diag.packet_released, + (unsigned long)diag.packet_cancelled); next_diag_ms = now + 60000ULL; } @@ -708,7 +902,13 @@ static void lora_task(void *arg) #endif awaiting_tx_done = false; tx_watchdog_deadline = 0; - resume_rx_or_recover("TX_DONE resume RX"); + if (!resume_rx_or_recover("TX_DONE resume RX")) { + tx_is_ack = false; + if (have_pending) { + drop_tx_packet(&packet_handle, &have_pending); + } + break; + } if (tx_is_ack) { /* our ack went out; the interleave delayed any pending * data frame's ack wait, so extend its deadline */ @@ -723,14 +923,23 @@ static void lora_task(void *arg) ack_deadline = esp_timer_get_time() / 1000ULL + trunk_airtime_ms(TRUNK_HDR_LEN) + ARQ_MARGIN_MS; } else { - have_pending = false; + have_pending = advance_tx_packet( + &packet_handle, &fragment_idx, fragment_total, + &pending); + cad_tries = 0; + arq_sends = 0; + s_ack_seen = false; } break; case SX1262_EVT_CAD_CLEAR: awaiting_cad = false; - if (s_ack_count > 0) { - /* acks always preempt data on a clear channel */ + if (s_ack_count > 0 && + (!(have_pending || tx_scheduler_has_queued()) || + ack_burst < ACK_BURST_MAX)) { + /* ACKs preempt boundedly; after four, queued packet + * traffic gets one transmission opportunity. */ if (transmit_ack_now()) { + ack_burst++; awaiting_tx_done = true; tx_is_ack = true; tx_watchdog_deadline = @@ -749,8 +958,9 @@ static void lora_task(void *arg) esp_timer_get_time() / 1000ULL + sx1262_tx_watchdog_ms(pending.len); arq_sends++; + ack_burst = 0; } else { - have_pending = false; + drop_tx_packet(&packet_handle, &have_pending); recover_radio("data transmit command"); } } @@ -763,16 +973,21 @@ static void lora_task(void *arg) if (sx1262_start_cad() == ESP_OK) { awaiting_cad = true; } else { - have_pending = false; + drop_tx_packet(&packet_handle, &have_pending); recover_radio("CAD retry command"); } } else { - /* channel persistently busy: drop the frame */ - have_pending = false; + /* Channel persistently busy: reject the complete packet; + * sending only its earlier fragments cannot complete. */ + if (have_pending) { + drop_tx_packet(&packet_handle, &have_pending); + } resume_rx_or_recover("CAD busy resume RX"); } break; case SX1262_EVT_TIMEOUT: + { + bool timed_out_ack = tx_is_ack; diag_inc(&s_diag.tx_timeouts); awaiting_tx_done = false; tx_watchdog_deadline = 0; @@ -781,11 +996,16 @@ static void lora_task(void *arg) /* A radio timeout is an expected, separately counted delivery * failure. Re-enter RX and let an ack-requested data frame use * its remaining ARQ attempts. */ - resume_rx_or_recover("radio TX timeout"); - if (!have_pending || !pending.want_ack || arq_sends >= ARQ_TRIES) { - have_pending = false; + if (!resume_rx_or_recover("radio TX timeout")) { + if (have_pending) { + drop_tx_packet(&packet_handle, &have_pending); + } + } else if (!timed_out_ack && have_pending && + (!pending.want_ack || arq_sends >= ARQ_TRIES)) { + drop_tx_packet(&packet_handle, &have_pending); } break; + } case SX1262_EVT_RX_CRC_ERROR: diag_inc(&s_diag.crc_errors); /* Logged for link-quality visibility: a frame arrived but @@ -799,7 +1019,9 @@ static void lora_task(void *arg) tx_is_ack = false; tx_watchdog_deadline = 0; if (!recover_radio("IRQ status command")) { - have_pending = false; + if (have_pending) { + drop_tx_packet(&packet_handle, &have_pending); + } } break; default: @@ -809,8 +1031,11 @@ static void lora_task(void *arg) /* Acks fly the moment the radio is free — no CAD, no queueing: the * peer is listening right after its TX and is waiting on us. */ - if (s_ack_count > 0 && !awaiting_tx_done && !awaiting_cad) { + bool data_waiting = have_pending || tx_scheduler_has_queued(); + if (s_ack_count > 0 && !awaiting_tx_done && !awaiting_cad && + (!data_waiting || ack_burst < ACK_BURST_MAX)) { if (transmit_ack_now()) { + ack_burst++; awaiting_tx_done = true; tx_is_ack = true; tx_watchdog_deadline = @@ -832,7 +1057,9 @@ static void lora_task(void *arg) tx_is_ack = false; tx_watchdog_deadline = 0; if (!recover_radio("software TX watchdog")) { - have_pending = false; + if (have_pending) { + drop_tx_packet(&packet_handle, &have_pending); + } } } @@ -844,7 +1071,10 @@ static void lora_task(void *arg) if (s_ack_seen && s_ack_seq == pseq && s_ack_idx == pidx) { s_ack_seen = false; awaiting_ack = false; - have_pending = false; + have_pending = advance_tx_packet( + &packet_handle, &fragment_idx, fragment_total, &pending); + cad_tries = 0; + arq_sends = 0; } else if (now2 >= ack_deadline) { diag_inc(&s_diag.ack_misses); awaiting_ack = false; @@ -853,14 +1083,14 @@ static void lora_task(void *arg) if (sx1262_start_cad() == ESP_OK) { awaiting_cad = true; /* retransmit */ } else { - have_pending = false; + drop_tx_packet(&packet_handle, &have_pending); recover_radio("ARQ CAD command"); } } else { diag_inc(&s_diag.retry_exhaustion); ESP_LOGW(TAG, "trunk frame lost after %d sends seq=%u idx=%u", ARQ_TRIES, pseq, pidx); - have_pending = false; + drop_tx_packet(&packet_handle, &have_pending); } } } @@ -874,22 +1104,27 @@ static void lora_task(void *arg) if (sx1262_start_cad() == ESP_OK) { awaiting_cad = true; } else { - have_pending = false; + drop_tx_packet(&packet_handle, &have_pending); recover_radio("pending CAD command"); } } if (!have_pending && !awaiting_tx_done && !awaiting_cad && !awaiting_ack && - xQueueReceive(s_tx_queue, &pending, 0) == pdTRUE) { - diag_note_queue_depth(); - have_pending = true; + tx_scheduler_dequeue(&packet_handle)) { + fragment_idx = 0; + have_pending = build_tx_fragment( + packet_handle, fragment_idx, &pending, &fragment_total); + if (!have_pending) { + drop_tx_packet(&packet_handle, &have_pending); + continue; + } cad_tries = 0; arq_sends = 0; s_ack_seen = false; if (sx1262_start_cad() == ESP_OK) { awaiting_cad = true; } else { - have_pending = false; + drop_tx_packet(&packet_handle, &have_pending); recover_radio("dequeue CAD command"); } } @@ -907,11 +1142,9 @@ void bitle_lora_get_diag(bitle_lora_diag_t *out) return; } taskENTER_CRITICAL(&s_gov_mux); + diag_sync_scheduler_locked(); *out = s_diag; taskEXIT_CRITICAL(&s_gov_mux); - if (s_tx_queue) { - out->queue_depth = uxQueueMessagesWaiting(s_tx_queue); - } sx1262_diag_t radio_diag; sx1262_get_diag(&radio_diag); out->radio_command_errors = radio_diag.command_errors; @@ -920,6 +1153,23 @@ void bitle_lora_get_diag(bitle_lora_diag_t *out) out->radio_recovery_failures = radio_diag.recovery_failures; } +void bitle_lora_shutdown(void) +{ + s_active = false; + bitle_link_unregister(BITLE_LORA_LINK_HANDLE); + TaskHandle_t task = s_task; + s_task = NULL; + if (task) { + vTaskDelete(task); + } + taskENTER_CRITICAL(&s_gov_mux); + lora_tx_scheduler_cancel_all(&s_tx_scheduler); + diag_sync_scheduler_locked(); + taskEXIT_CRITICAL(&s_gov_mux); + s_ack_head = 0; + s_ack_count = 0; +} + esp_err_t bitle_lora_init(void) { const lora_region_profile_t *default_profile = @@ -1046,10 +1296,7 @@ esp_err_t bitle_lora_init(void) s_gov_credit_ms = GOV_BURST_MS; s_gov_last_ms = esp_timer_get_time() / 1000ULL; - s_tx_queue = xQueueCreate(TX_QUEUE_DEPTH, sizeof(lora_frame_t)); - if (!s_tx_queue) { - return ESP_ERR_NO_MEM; - } + lora_tx_scheduler_init(&s_tx_scheduler); if (xTaskCreate(lora_task, "bitle_lora", 6144, NULL, tskIDLE_PRIORITY + 4, &s_task) != pdTRUE) { return ESP_ERR_NO_MEM; } @@ -1059,13 +1306,23 @@ esp_err_t bitle_lora_init(void) ESP_LOGE(TAG, "radio init failed: %s", esp_err_to_name(err)); vTaskDelete(s_task); s_task = NULL; - vQueueDelete(s_tx_queue); - s_tx_queue = NULL; + taskENTER_CRITICAL(&s_gov_mux); + lora_tx_scheduler_cancel_all(&s_tx_scheduler); + diag_sync_scheduler_locked(); + taskEXIT_CRITICAL(&s_gov_mux); return err; } s_active = true; - bitle_link_register(BITLE_LORA_LINK_HANDLE, BITLE_LINK_LORA, lora_link_send); + err = bitle_link_register_reservable( + BITLE_LORA_LINK_HANDLE, BITLE_LINK_LORA, lora_link_send, + lora_link_reserve, lora_link_commit, lora_link_cancel); + if (err != ESP_OK) { + ESP_LOGE(TAG, "trunk link registration failed: %s", + esp_err_to_name(err)); + bitle_lora_shutdown(); + return err; + } const lora_region_profile_t *active_profile = lora_region_profile(cfg.region); ESP_LOGI(TAG, "trunk up: region=%s %.3f MHz SF%u BW%lu preamble=%u +%ddBm " diff --git a/main/bitle_lora.h b/main/bitle_lora.h index f28719a..4787d34 100644 --- a/main/bitle_lora.h +++ b/main/bitle_lora.h @@ -16,6 +16,7 @@ */ #include "esp_err.h" +#include "lora_tx_scheduler.h" #include #include @@ -46,6 +47,11 @@ typedef struct { uint32_t completed_packets; uint32_t queue_depth; uint32_t queue_high_water; + uint32_t packet_accepted[LORA_TX_PRIORITY_COUNT]; + uint32_t packet_deferred[LORA_TX_PRIORITY_COUNT]; + uint32_t policy_drops; + uint32_t packet_released; + uint32_t packet_cancelled; uint32_t config_rejections; uint32_t radio_command_errors; uint32_t radio_busy_timeouts; @@ -58,6 +64,10 @@ typedef struct { * Safe before radio initialization; the returned structure will be zeroed. */ void bitle_lora_get_diag(bitle_lora_diag_t *out); +/* Stops the link task and atomically releases every queued or in-flight TX + * packet. Safe to call when the radio was never detected. */ +void bitle_lora_shutdown(void); + #ifdef __cplusplus } #endif diff --git a/main/lora_tx_scheduler.c b/main/lora_tx_scheduler.c new file mode 100644 index 0000000..c4e6d47 --- /dev/null +++ b/main/lora_tx_scheduler.c @@ -0,0 +1,240 @@ +#include "lora_tx_scheduler.h" + +#include + +/* Six selections bound starvation while favoring latency-sensitive traffic. + * ACK frames remain outside this scheduler and are bounded separately. */ +static const lora_tx_priority_t SCHEDULE[] = { + LORA_TX_PRIORITY_CONTROL, + LORA_TX_PRIORITY_USER, + LORA_TX_PRIORITY_CONTROL, + LORA_TX_PRIORITY_DISCOVERY, + LORA_TX_PRIORITY_USER, + LORA_TX_PRIORITY_BULK, +}; + +void lora_tx_scheduler_init(lora_tx_scheduler_t *scheduler) +{ + if (scheduler) { + memset(scheduler, 0, sizeof(*scheduler)); + } +} + +static bool priority_valid(lora_tx_priority_t priority) +{ + return priority >= LORA_TX_PRIORITY_CONTROL && + priority < LORA_TX_PRIORITY_COUNT; +} + +lora_tx_admit_result_t lora_tx_scheduler_reserve( + lora_tx_scheduler_t *scheduler, + lora_tx_priority_t priority, + lora_tx_handle_t *out_handle) +{ + if (out_handle) { + *out_handle = LORA_TX_HANDLE_INVALID; + } + if (!scheduler || !priority_valid(priority)) { + return LORA_TX_ADMIT_INVALID; + } + + lora_tx_handle_t handle = LORA_TX_HANDLE_INVALID; + for (lora_tx_handle_t i = 0; i < LORA_TX_PACKET_POOL_SIZE; ++i) { + if (!scheduler->in_use[i]) { + handle = i; + break; + } + } + if (handle == LORA_TX_HANDLE_INVALID) { + scheduler->metrics.deferred[priority]++; + return LORA_TX_ADMIT_DEFERRED; + } + + scheduler->in_use[handle] = true; + memset(&scheduler->packets[handle], 0, + sizeof(scheduler->packets[handle])); + scheduler->packets[handle].priority = priority; + scheduler->metrics.in_use++; + if (scheduler->metrics.in_use > scheduler->metrics.high_water) { + scheduler->metrics.high_water = scheduler->metrics.in_use; + } + if (out_handle) { + *out_handle = handle; + } + return LORA_TX_ADMIT_ACCEPTED; +} + +bool lora_tx_scheduler_commit( + lora_tx_scheduler_t *scheduler, + lora_tx_handle_t handle, + const uint8_t *data, + uint16_t len, + uint8_t type, + uint16_t sequence) +{ + if (!scheduler || handle >= LORA_TX_PACKET_POOL_SIZE || + !scheduler->in_use[handle] || !data || len == 0 || + len > LORA_TX_PACKET_MAX_LEN || + scheduler->packets[handle].len != 0) { + return false; + } + lora_tx_packet_t *packet = &scheduler->packets[handle]; + packet->len = len; + packet->sequence = sequence; + packet->type = type; + memcpy(packet->data, data, len); + + lora_tx_priority_t priority = packet->priority; + lora_tx_handle_t *queue = scheduler->queues[priority]; + queue[scheduler->queue_tail[priority]] = handle; + scheduler->queue_tail[priority] = + (scheduler->queue_tail[priority] + 1) % LORA_TX_PACKET_POOL_SIZE; + scheduler->queue_count[priority]++; + scheduler->metrics.accepted[priority]++; + return true; +} + +lora_tx_admit_result_t lora_tx_scheduler_admit( + lora_tx_scheduler_t *scheduler, + const uint8_t *data, + uint16_t len, + uint8_t type, + lora_tx_priority_t priority, + uint16_t sequence, + lora_tx_handle_t *out_handle) +{ + if (!scheduler || !data || len == 0 || len > LORA_TX_PACKET_MAX_LEN || + !priority_valid(priority)) { + if (out_handle) { + *out_handle = LORA_TX_HANDLE_INVALID; + } + return LORA_TX_ADMIT_INVALID; + } + lora_tx_admit_result_t result = + lora_tx_scheduler_reserve(scheduler, priority, out_handle); + if (result != LORA_TX_ADMIT_ACCEPTED) { + return result; + } + if (!lora_tx_scheduler_commit( + scheduler, *out_handle, data, len, type, sequence)) { + lora_tx_scheduler_cancel(scheduler, *out_handle); + *out_handle = LORA_TX_HANDLE_INVALID; + return LORA_TX_ADMIT_INVALID; + } + return LORA_TX_ADMIT_ACCEPTED; +} + +bool lora_tx_scheduler_dequeue(lora_tx_scheduler_t *scheduler, + lora_tx_handle_t *out_handle) +{ + if (!scheduler || !out_handle) { + return false; + } + *out_handle = LORA_TX_HANDLE_INVALID; + const size_t schedule_len = sizeof(SCHEDULE) / sizeof(SCHEDULE[0]); + for (size_t checked = 0; checked < schedule_len; ++checked) { + lora_tx_priority_t priority = SCHEDULE[scheduler->schedule_cursor]; + scheduler->schedule_cursor = + (scheduler->schedule_cursor + 1) % schedule_len; + if (scheduler->queue_count[priority] == 0) { + continue; + } + lora_tx_handle_t *queue = scheduler->queues[priority]; + *out_handle = queue[scheduler->queue_head[priority]]; + scheduler->queue_head[priority] = + (scheduler->queue_head[priority] + 1) % LORA_TX_PACKET_POOL_SIZE; + scheduler->queue_count[priority]--; + return true; + } + return false; +} + +const lora_tx_packet_t *lora_tx_scheduler_packet( + const lora_tx_scheduler_t *scheduler, lora_tx_handle_t handle) +{ + if (!scheduler || handle >= LORA_TX_PACKET_POOL_SIZE || + !scheduler->in_use[handle]) { + return NULL; + } + return &scheduler->packets[handle]; +} + +bool lora_tx_scheduler_release(lora_tx_scheduler_t *scheduler, + lora_tx_handle_t handle) +{ + if (!scheduler || handle >= LORA_TX_PACKET_POOL_SIZE || + !scheduler->in_use[handle]) { + return false; + } + scheduler->in_use[handle] = false; + memset(&scheduler->packets[handle], 0, sizeof(scheduler->packets[handle])); + scheduler->metrics.in_use--; + scheduler->metrics.released++; + return true; +} + +bool lora_tx_scheduler_cancel(lora_tx_scheduler_t *scheduler, + lora_tx_handle_t handle) +{ + if (!scheduler || handle >= LORA_TX_PACKET_POOL_SIZE || + !scheduler->in_use[handle] || + scheduler->packets[handle].len != 0) { + return false; + } + scheduler->in_use[handle] = false; + memset(&scheduler->packets[handle], 0, + sizeof(scheduler->packets[handle])); + scheduler->metrics.in_use--; + scheduler->metrics.cancelled++; + return true; +} + +size_t lora_tx_scheduler_cancel_all(lora_tx_scheduler_t *scheduler) +{ + if (!scheduler) { + return 0; + } + size_t cancelled = 0; + for (lora_tx_handle_t handle = 0; + handle < LORA_TX_PACKET_POOL_SIZE; ++handle) { + if (scheduler->in_use[handle]) { + scheduler->in_use[handle] = false; + memset(&scheduler->packets[handle], 0, + sizeof(scheduler->packets[handle])); + cancelled++; + } + } + memset(scheduler->queues, 0, sizeof(scheduler->queues)); + memset(scheduler->queue_head, 0, sizeof(scheduler->queue_head)); + memset(scheduler->queue_tail, 0, sizeof(scheduler->queue_tail)); + memset(scheduler->queue_count, 0, sizeof(scheduler->queue_count)); + scheduler->schedule_cursor = 0; + scheduler->metrics.in_use = 0; + scheduler->metrics.cancelled += cancelled; + return cancelled; +} + +uint8_t lora_tx_scheduler_depth(const lora_tx_scheduler_t *scheduler) +{ + return scheduler ? scheduler->metrics.in_use : 0; +} + +uint8_t lora_tx_scheduler_queued(const lora_tx_scheduler_t *scheduler) +{ + if (!scheduler) { + return 0; + } + uint8_t queued = 0; + for (size_t priority = 0; priority < LORA_TX_PRIORITY_COUNT; ++priority) { + queued += scheduler->queue_count[priority]; + } + return queued; +} + +void lora_tx_scheduler_metrics(const lora_tx_scheduler_t *scheduler, + lora_tx_scheduler_metrics_t *out) +{ + if (scheduler && out) { + *out = scheduler->metrics; + } +} diff --git a/main/lora_tx_scheduler.h b/main/lora_tx_scheduler.h new file mode 100644 index 0000000..210e535 --- /dev/null +++ b/main/lora_tx_scheduler.h @@ -0,0 +1,116 @@ +#ifndef LORA_TX_SCHEDULER_H +#define LORA_TX_SCHEDULER_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LORA_TX_PACKET_POOL_SIZE 12 +#define LORA_TX_PACKET_MAX_LEN 520 +#define LORA_TX_HANDLE_INVALID UINT8_MAX + +typedef uint8_t lora_tx_handle_t; + +typedef enum { + LORA_TX_PRIORITY_CONTROL = 0, + LORA_TX_PRIORITY_USER, + LORA_TX_PRIORITY_DISCOVERY, + LORA_TX_PRIORITY_BULK, + LORA_TX_PRIORITY_COUNT, +} lora_tx_priority_t; + +typedef enum { + LORA_TX_ADMIT_ACCEPTED = 0, + LORA_TX_ADMIT_DEFERRED, + LORA_TX_ADMIT_INVALID, +} lora_tx_admit_result_t; + +typedef struct { + uint16_t len; + uint16_t sequence; + uint8_t type; + lora_tx_priority_t priority; + uint8_t data[LORA_TX_PACKET_MAX_LEN]; +} lora_tx_packet_t; + +typedef struct { + uint32_t accepted[LORA_TX_PRIORITY_COUNT]; + uint32_t deferred[LORA_TX_PRIORITY_COUNT]; + uint32_t released; + uint32_t cancelled; + uint8_t in_use; + uint8_t high_water; +} lora_tx_scheduler_metrics_t; + +typedef struct { + bool in_use[LORA_TX_PACKET_POOL_SIZE]; + lora_tx_packet_t packets[LORA_TX_PACKET_POOL_SIZE]; + lora_tx_handle_t queues[LORA_TX_PRIORITY_COUNT][LORA_TX_PACKET_POOL_SIZE]; + uint8_t queue_head[LORA_TX_PRIORITY_COUNT]; + uint8_t queue_tail[LORA_TX_PRIORITY_COUNT]; + uint8_t queue_count[LORA_TX_PRIORITY_COUNT]; + uint8_t schedule_cursor; + lora_tx_scheduler_metrics_t metrics; +} lora_tx_scheduler_t; + +void lora_tx_scheduler_init(lora_tx_scheduler_t *scheduler); + +/* Reserves one pool slot before a producer irreversibly advances protocol + * state. The slot is invisible to the consumer until committed. */ +lora_tx_admit_result_t lora_tx_scheduler_reserve( + lora_tx_scheduler_t *scheduler, + lora_tx_priority_t priority, + lora_tx_handle_t *out_handle); + +bool lora_tx_scheduler_commit( + lora_tx_scheduler_t *scheduler, + lora_tx_handle_t handle, + const uint8_t *data, + uint16_t len, + uint8_t type, + uint16_t sequence); + +/* Copies one complete packet into the static pool and makes it visible to the + * consumer atomically. A deferred result consumes no pool or queue entry. */ +lora_tx_admit_result_t lora_tx_scheduler_admit( + lora_tx_scheduler_t *scheduler, + const uint8_t *data, + uint16_t len, + uint8_t type, + lora_tx_priority_t priority, + uint16_t sequence, + lora_tx_handle_t *out_handle); + +/* Weighted scheduling gives control/user traffic more service while every + * continuously non-empty priority is selected within one schedule rotation. */ +bool lora_tx_scheduler_dequeue(lora_tx_scheduler_t *scheduler, + lora_tx_handle_t *out_handle); + +const lora_tx_packet_t *lora_tx_scheduler_packet( + const lora_tx_scheduler_t *scheduler, lora_tx_handle_t handle); + +/* Release after all fragments complete or after terminal failure. */ +bool lora_tx_scheduler_release(lora_tx_scheduler_t *scheduler, + lora_tx_handle_t handle); + +/* Cancels a producer reservation that was never committed. */ +bool lora_tx_scheduler_cancel(lora_tx_scheduler_t *scheduler, + lora_tx_handle_t handle); + +/* Cancel queued and in-flight slots during shutdown. Returns slots released. */ +size_t lora_tx_scheduler_cancel_all(lora_tx_scheduler_t *scheduler); + +uint8_t lora_tx_scheduler_depth(const lora_tx_scheduler_t *scheduler); +uint8_t lora_tx_scheduler_queued(const lora_tx_scheduler_t *scheduler); +void lora_tx_scheduler_metrics(const lora_tx_scheduler_t *scheduler, + lora_tx_scheduler_metrics_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* LORA_TX_SCHEDULER_H */ diff --git a/main/noise_handshake.c b/main/noise_handshake.c index 96617a1..4ce8887 100644 --- a/main/noise_handshake.c +++ b/main/noise_handshake.c @@ -161,6 +161,7 @@ static void process_courier_event(const noise_event_t *evt); static void process_sync_event(const noise_event_t *evt); static bool send_announce(noise_session_t *session); static esp_err_t encode_and_send(uint16_t conn_handle, bitchat_message_type_t type, const uint8_t *recipient_id, const uint8_t *payload, size_t payload_len, bool sign); +static esp_err_t encode_and_send_reserved(uint16_t conn_handle, bitchat_message_type_t type, const uint8_t *recipient_id, const uint8_t *payload, size_t payload_len, bool sign, bitle_link_reservation_t *reservation); static esp_err_t queue_event(const noise_event_t *evt, TickType_t wait_ticks); static bool enqueue_event(noise_evt_type_t type, uint16_t conn_handle, const uint8_t peer_id[8], bool initiator, const uint8_t *payload, uint16_t payload_len); static void handshake_task(void *arg); @@ -1195,7 +1196,25 @@ static void format_hex(const uint8_t *data, size_t len, char *out, size_t out_le out[len * 2] = '\0'; } -static esp_err_t encode_and_send(uint16_t conn_handle, bitchat_message_type_t type, const uint8_t *recipient_id, const uint8_t *payload, size_t payload_len, bool sign) +static esp_err_t send_result_to_error(bitle_link_send_result_t result) +{ + switch (result) { + case BITLE_LINK_SEND_ACCEPTED: + return ESP_OK; + case BITLE_LINK_SEND_DEFERRED: + return ESP_ERR_TIMEOUT; + case BITLE_LINK_SEND_POLICY_DROPPED: + return ESP_ERR_NOT_SUPPORTED; + case BITLE_LINK_SEND_FAILED: + default: + return ESP_FAIL; + } +} + +static esp_err_t encode_and_send_reserved( + uint16_t conn_handle, bitchat_message_type_t type, + const uint8_t *recipient_id, const uint8_t *payload, size_t payload_len, + bool sign, bitle_link_reservation_t *reservation) { bitchat_packet_t packet; memset(&packet, 0, sizeof(packet)); @@ -1207,6 +1226,7 @@ static esp_err_t encode_and_send(uint16_t conn_handle, bitchat_message_type_t ty packet.timestamp_ms = bitchat_time_now_ms(); if (!packet.timestamp_ms) { ESP_LOGW(TAG, "Skipping send; timestamp invalid for type=0x%02X", type); + bitle_link_cancel_send(reservation); return ESP_ERR_INVALID_STATE; } memcpy(packet.sender_id, s_peer_id, sizeof(packet.sender_id)); @@ -1226,12 +1246,26 @@ static esp_err_t encode_and_send(uint16_t conn_handle, bitchat_message_type_t ty size_t encoded_len = sizeof(buffer); if (!bitchat_packet_encode(&packet, buffer, &encoded_len, sizeof(buffer))) { ESP_LOGE(TAG, "Failed to encode packet type=0x%02X", type); + bitle_link_cancel_send(reservation); return ESP_FAIL; } + if (reservation) { + return send_result_to_error(bitle_link_commit_send( + reservation, buffer, (uint16_t)encoded_len)); + } return bitle_link_send(conn_handle, buffer, (uint16_t)encoded_len); } +static esp_err_t encode_and_send( + uint16_t conn_handle, bitchat_message_type_t type, + const uint8_t *recipient_id, const uint8_t *payload, size_t payload_len, + bool sign) +{ + return encode_and_send_reserved( + conn_handle, type, recipient_id, payload, payload_len, sign, NULL); +} + static esp_err_t queue_event(const noise_event_t *evt, TickType_t wait_ticks) { if (!s_event_queue) { @@ -1711,15 +1745,28 @@ bool noise_send_encrypted(uint16_t conn_handle, bitchat_noise_payload_type_t pay ESP_LOGW(TAG, "Payload too large for encryption"); return false; } + bitle_link_reservation_t reservation = {0}; + /* Reserve the LoRa pool slot or BLE mbuf before the cipher state consumes + * this explicit Noise nonce. A backpressured transport therefore leaves + * both the application nonce and NoiseCipherState unchanged. */ + bitle_link_send_result_t reserve_result = bitle_link_reserve_send( + conn_handle, BITCHAT_MSG_NOISE_ENCRYPTED, &reservation); + if (reserve_result != BITLE_LINK_SEND_ACCEPTED) { + return false; + } + bitle_link_reservation_t *reserved_send = &reservation; framed[0] = (uint8_t)payload_type; memcpy(framed + 1, payload, payload_len); uint8_t ciphertext[NOISE_MAX_ENCRYPTED_PAYLOAD]; size_t ciphertext_len = sizeof(ciphertext); if (!encrypt_payload(session, framed, payload_len + 1, ciphertext, &ciphertext_len)) { + bitle_link_cancel_send(reserved_send); ESP_LOGW(TAG, "Encryption failed for conn=%u", conn_handle); return false; } - return encode_and_send(conn_handle, BITCHAT_MSG_NOISE_ENCRYPTED, session->peer_id, ciphertext, ciphertext_len, true) == ESP_OK; + return encode_and_send_reserved( + conn_handle, BITCHAT_MSG_NOISE_ENCRYPTED, session->peer_id, + ciphertext, ciphertext_len, true, reserved_send) == ESP_OK; } esp_err_t noise_send_raw(uint16_t conn_handle, bitchat_message_type_t type, const uint8_t recipient[8], const uint8_t *payload, size_t payload_len) @@ -1883,4 +1930,3 @@ static void poll_session_maintenance(void) } } } - diff --git a/tests/test_lora_tx_scheduler.c b/tests/test_lora_tx_scheduler.c new file mode 100644 index 0000000..85e0dfa --- /dev/null +++ b/tests/test_lora_tx_scheduler.c @@ -0,0 +1,218 @@ +#include "lora_tx_scheduler.h" + +#include +#include +#include + +static lora_tx_handle_t admit(lora_tx_scheduler_t *scheduler, + uint8_t marker, lora_tx_priority_t priority) +{ + uint8_t packet[64]; + memset(packet, marker, sizeof(packet)); + lora_tx_handle_t handle = LORA_TX_HANDLE_INVALID; + assert(lora_tx_scheduler_admit( + scheduler, packet, sizeof(packet), marker, priority, + marker, &handle) == LORA_TX_ADMIT_ACCEPTED); + assert(handle != LORA_TX_HANDLE_INVALID); + return handle; +} + +typedef struct { + lora_tx_scheduler_t *scheduler; + pthread_mutex_t *lock; + uint8_t marker; + lora_tx_priority_t priority; + unsigned attempts; + unsigned *accepted; + unsigned *deferred; +} producer_args_t; + +static void *run_producer(void *opaque) +{ + producer_args_t *args = opaque; + for (unsigned attempt = 0; attempt < args->attempts; ++attempt) { + uint8_t packet[64]; + memset(packet, args->marker, sizeof(packet)); + lora_tx_handle_t handle = LORA_TX_HANDLE_INVALID; + pthread_mutex_lock(args->lock); + lora_tx_admit_result_t result = lora_tx_scheduler_admit( + args->scheduler, packet, sizeof(packet), args->marker, + args->priority, (uint16_t)(args->marker * 100 + attempt), + &handle); + if (result == LORA_TX_ADMIT_ACCEPTED) { + (*args->accepted)++; + } else { + assert(result == LORA_TX_ADMIT_DEFERRED); + (*args->deferred)++; + assert(handle == LORA_TX_HANDLE_INVALID); + } + pthread_mutex_unlock(args->lock); + } + return NULL; +} + +int main(void) +{ + lora_tx_scheduler_t scheduler; + lora_tx_scheduler_init(&scheduler); + + /* Saturation is atomic: the rejected producer consumes no slot and no + * fragment-like partial state, regardless of packet size. */ + for (uint8_t i = 0; i < LORA_TX_PACKET_POOL_SIZE; ++i) { + admit(&scheduler, i, (lora_tx_priority_t)(i % LORA_TX_PRIORITY_COUNT)); + } + uint8_t maximum_packet[LORA_TX_PACKET_MAX_LEN] = {0}; + lora_tx_handle_t rejected = 0; + assert(lora_tx_scheduler_admit( + &scheduler, maximum_packet, sizeof(maximum_packet), 0xA2, + LORA_TX_PRIORITY_BULK, 99, &rejected) == + LORA_TX_ADMIT_DEFERRED); + assert(rejected == LORA_TX_HANDLE_INVALID); + assert(lora_tx_scheduler_depth(&scheduler) == LORA_TX_PACKET_POOL_SIZE); + assert(lora_tx_scheduler_cancel_all(&scheduler) == LORA_TX_PACKET_POOL_SIZE); + assert(lora_tx_scheduler_depth(&scheduler) == 0); + assert(lora_tx_scheduler_queued(&scheduler) == 0); + + /* A pre-encryption reservation owns capacity without exposing a partial + * packet. Commit publishes it once; cancellation returns the slot. */ + lora_tx_handle_t reservation = LORA_TX_HANDLE_INVALID; + assert(lora_tx_scheduler_reserve( + &scheduler, LORA_TX_PRIORITY_USER, &reservation) == + LORA_TX_ADMIT_ACCEPTED); + assert(lora_tx_scheduler_depth(&scheduler) == 1); + assert(lora_tx_scheduler_queued(&scheduler) == 0); + uint8_t reserved_packet[64]; + memset(reserved_packet, 0x51, sizeof(reserved_packet)); + assert(lora_tx_scheduler_commit( + &scheduler, reservation, reserved_packet, sizeof(reserved_packet), + 0x11, 77)); + assert(lora_tx_scheduler_queued(&scheduler) == 1); + assert(lora_tx_scheduler_dequeue(&scheduler, &rejected)); + assert(rejected == reservation); + assert(lora_tx_scheduler_release(&scheduler, reservation)); + + assert(lora_tx_scheduler_reserve( + &scheduler, LORA_TX_PRIORITY_CONTROL, &reservation) == + LORA_TX_ADMIT_ACCEPTED); + assert(lora_tx_scheduler_cancel(&scheduler, reservation)); + assert(lora_tx_scheduler_depth(&scheduler) == 0); + + /* FIFO ordering is preserved within each class. */ + lora_tx_handle_t user_a = admit(&scheduler, 10, LORA_TX_PRIORITY_USER); + lora_tx_handle_t user_b = admit(&scheduler, 11, LORA_TX_PRIORITY_USER); + lora_tx_handle_t dequeued = LORA_TX_HANDLE_INVALID; + assert(lora_tx_scheduler_dequeue(&scheduler, &dequeued)); + assert(dequeued == user_a); + assert(lora_tx_scheduler_release(&scheduler, dequeued)); + assert(lora_tx_scheduler_dequeue(&scheduler, &dequeued)); + assert(dequeued == user_b); + assert(lora_tx_scheduler_release(&scheduler, dequeued)); + + /* Concurrent producer classes all receive service inside one six-entry + * schedule rotation; none can be starved by a full higher-priority class. */ + lora_tx_scheduler_init(&scheduler); + bool served[LORA_TX_PRIORITY_COUNT] = {false}; + for (uint8_t priority = 0; priority < LORA_TX_PRIORITY_COUNT; ++priority) { + admit(&scheduler, 20 + priority, (lora_tx_priority_t)priority); + } + for (size_t i = 0; i < 6; ++i) { + if (!lora_tx_scheduler_dequeue(&scheduler, &dequeued)) { + break; + } + const lora_tx_packet_t *packet = + lora_tx_scheduler_packet(&scheduler, dequeued); + assert(packet); + served[packet->priority] = true; + assert(lora_tx_scheduler_release(&scheduler, dequeued)); + } + for (size_t priority = 0; priority < LORA_TX_PRIORITY_COUNT; ++priority) { + assert(served[priority]); + } + + /* Cancellation after an in-flight dequeue returns every pool slot. */ + lora_tx_scheduler_init(&scheduler); + admit(&scheduler, 30, LORA_TX_PRIORITY_CONTROL); + admit(&scheduler, 31, LORA_TX_PRIORITY_DISCOVERY); + assert(lora_tx_scheduler_dequeue(&scheduler, &dequeued)); + assert(lora_tx_scheduler_cancel_all(&scheduler) == 2); + lora_tx_scheduler_metrics_t metrics; + lora_tx_scheduler_metrics(&scheduler, &metrics); + assert(metrics.in_use == 0); + assert(metrics.cancelled == 2); + + /* Two producers racing for the final slot get exactly one acceptance and + * one pre-transmission backpressure result. */ + lora_tx_scheduler_init(&scheduler); + for (uint8_t i = 0; i < LORA_TX_PACKET_POOL_SIZE - 1; ++i) { + admit(&scheduler, 40 + i, LORA_TX_PRIORITY_USER); + } + pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; + unsigned accepted = 0; + unsigned deferred = 0; + producer_args_t final_slot[] = { + {&scheduler, &lock, 0x61, LORA_TX_PRIORITY_CONTROL, 1, + &accepted, &deferred}, + {&scheduler, &lock, 0x62, LORA_TX_PRIORITY_USER, 1, + &accepted, &deferred}, + }; + pthread_t final_threads[2]; + for (size_t i = 0; i < 2; ++i) { + assert(pthread_create( + &final_threads[i], NULL, run_producer, + &final_slot[i]) == 0); + } + for (size_t i = 0; i < 2; ++i) { + assert(pthread_join(final_threads[i], NULL) == 0); + } + assert(accepted == 1); + assert(deferred == 1); + assert(lora_tx_scheduler_depth(&scheduler) == + LORA_TX_PACKET_POOL_SIZE); + assert(lora_tx_scheduler_cancel_all(&scheduler) == + LORA_TX_PACKET_POOL_SIZE); + + /* Five real producer classes contend through the firmware's locking + * contract: BLE relay, Noise response, beacon, courier, and sync. Exactly + * the bounded pool is accepted; every visible packet is a complete copy. */ + lora_tx_scheduler_init(&scheduler); + accepted = 0; + deferred = 0; + producer_args_t producers[] = { + {&scheduler, &lock, 0x02, LORA_TX_PRIORITY_USER, 8, + &accepted, &deferred}, /* BLE relay */ + {&scheduler, &lock, 0x11, LORA_TX_PRIORITY_USER, 8, + &accepted, &deferred}, /* Noise response */ + {&scheduler, &lock, 0x01, LORA_TX_PRIORITY_DISCOVERY, 8, + &accepted, &deferred}, /* beacon */ + {&scheduler, &lock, 0x04, LORA_TX_PRIORITY_USER, 8, + &accepted, &deferred}, /* courier */ + {&scheduler, &lock, 0x21, LORA_TX_PRIORITY_CONTROL, 8, + &accepted, &deferred}, /* sync */ + }; + pthread_t threads[sizeof(producers) / sizeof(producers[0])]; + for (size_t i = 0; i < sizeof(threads) / sizeof(threads[0]); ++i) { + assert(pthread_create( + &threads[i], NULL, run_producer, &producers[i]) == 0); + } + for (size_t i = 0; i < sizeof(threads) / sizeof(threads[0]); ++i) { + assert(pthread_join(threads[i], NULL) == 0); + } + assert(accepted == LORA_TX_PACKET_POOL_SIZE); + assert(deferred == + (sizeof(producers) / sizeof(producers[0])) * 8 - + LORA_TX_PACKET_POOL_SIZE); + assert(lora_tx_scheduler_depth(&scheduler) == + LORA_TX_PACKET_POOL_SIZE); + while (lora_tx_scheduler_dequeue(&scheduler, &dequeued)) { + const lora_tx_packet_t *packet = + lora_tx_scheduler_packet(&scheduler, dequeued); + assert(packet && packet->len == 64); + for (size_t i = 0; i < packet->len; ++i) { + assert(packet->data[i] == packet->type); + } + assert(lora_tx_scheduler_release(&scheduler, dequeued)); + } + assert(lora_tx_scheduler_depth(&scheduler) == 0); + assert(pthread_mutex_destroy(&lock) == 0); + return 0; +} diff --git a/tools/lora_hardware_smoke.py b/tools/lora_hardware_smoke.py index 8bab88c..dae2371 100755 --- a/tools/lora_hardware_smoke.py +++ b/tools/lora_hardware_smoke.py @@ -16,12 +16,18 @@ PACKET_RE = re.compile(r"trunk RX packet len=(\d+).*frags=(\d+)") RAW_RE = re.compile(r"trunk raw RX len=(\d+) rssi=(-?\d+) snr=(-?\d+)") -TX_RE = re.compile(r"trunk TX .*len=(\d+) frags=(\d+)") +TX_RE = re.compile(r"trunk (?:TX|accepted) .*len=(\d+) frags=(\d+)") PROFILE_RE = re.compile(r"trunk up: region=(\w+).* SF(\d+).*preamble=(\d+)") DIAG_RE = re.compile( r"tmo=(\d+).*radio_err=(\d+).*busy_tmo=(\d+).*" r"recover=(\d+)/(\d+).*tx_watchdog=(\d+)" ) +SCHEDULER_DIAG_RE = re.compile( + r"q_full=(\d+) q=(\d+)/(\d+).*" + r"admit=(\d+)/(\d+)/(\d+)/(\d+) " + r"defer=(\d+)/(\d+)/(\d+)/(\d+) " + r"policy=(\d+) release=(\d+) cancel=(\d+)" +) def reader( @@ -122,6 +128,25 @@ def main() -> int: "tx_watchdog_recoveries": int(match.group(6)), } ) + if match := SCHEDULER_DIAG_RE.search(line): + scheduler_diag = { + "queue_full": int(match.group(1)), + "pool_depth": int(match.group(2)), + "pool_high_water": int(match.group(3)), + "accepted_by_priority": [ + int(match.group(index)) for index in range(4, 8) + ], + "deferred_by_priority": [ + int(match.group(index)) for index in range(8, 12) + ], + "policy_drops": int(match.group(12)), + "released": int(match.group(13)), + "cancelled": int(match.group(14)), + } + if summary[label]["diagnostics"]: + summary[label]["diagnostics"][-1].update(scheduler_diag) + else: + summary[label]["diagnostics"].append(scheduler_diag) if "TX watchdog expired" in line: summary[label]["events"]["tx_watchdog_expired"] += 1 if "radio recovered on attempt" in line: diff --git a/tools/run_lora_host_tests.sh b/tools/run_lora_host_tests.sh index 09051c4..ab72479 100755 --- a/tools/run_lora_host_tests.sh +++ b/tools/run_lora_host_tests.sh @@ -4,7 +4,8 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" airtime_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-airtime.XXXXXX")" region_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-region.XXXXXX")" -trap 'rm -f "$airtime_bin" "$region_bin"' EXIT +scheduler_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-scheduler.XXXXXX")" +trap 'rm -f "$airtime_bin" "$region_bin" "$scheduler_bin"' EXIT cc -std=c11 -Wall -Wextra -Werror \ -I"$repo_root/main" \ @@ -20,5 +21,13 @@ cc -std=c11 -Wall -Wextra -Werror \ -o "$region_bin" "$region_bin" +cc -std=c11 -Wall -Wextra -Werror \ + -pthread \ + -I"$repo_root/main" \ + "$repo_root/tests/test_lora_tx_scheduler.c" \ + "$repo_root/main/lora_tx_scheduler.c" \ + -o "$scheduler_bin" +"$scheduler_bin" + cd "$repo_root" python3 -m unittest discover -s tests -p 'test_lora_*.py' -v From 52e135f6acf25b88d6694db93bfa5241c3f6520a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:29:45 +0200 Subject: [PATCH 10/19] docs(lora): record milestone 2 checkpoint --- docs/LoRa-reliability-implementation-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index eb79f71..78f08ae 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -61,7 +61,7 @@ The implementation is complete only when all of the following are true: |---|---|---|---| | 0. Baseline harness and observability | Complete | `d130a3a` | Host baseline, both firmware targets, and two-board SF10 smoke passed | | 1. Airtime-safe SX1262 operation | Complete | `028511e` | Host/profile tests, both firmware targets, and SF10-SF12 two-board radio tests passed | -| 2. Atomic packet scheduling and backpressure | Complete | pending checkpoint | Concurrent saturation, both firmware targets, and two-board SF10 smoke passed | +| 2. Atomic packet scheduling and backpressure | Complete | `fe9e95e` | Concurrent saturation, both firmware targets, and two-board SF10 smoke passed | | 3. Addressed trunk protocol and migration | Pending | — | — | | 4. Packet-level reliability and reassembly | Pending | — | — | | 5. Discovery and shared-channel behavior | Pending | — | — | @@ -274,7 +274,7 @@ give callers an honest transport result. every accepted descriptor was either released or visibly in flight. - Both test boards were restored to the normal SF10 image with diagnostic smoke disabled after the capture. -- Commit: pending checkpoint +- Commit: `fe9e95e` - Suggested commit subject: `refactor(lora): queue trunk packets atomically` ## Milestone 3: Addressed trunk protocol and migration From 3ee5843c0e865c8105f60f7e6bef43805a08d8bc Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:56:38 +0200 Subject: [PATCH 11/19] feat(lora): introduce addressed trunk protocol v3 --- docs/LoRa-reliability-implementation-plan.md | 60 ++- docs/LoRa-testing.md | 22 +- docs/LoRa-trunk-v3.md | 154 ++++++ main/CMakeLists.txt | 1 + main/Kconfig.projbuild | 9 +- main/bitle_lora.c | 500 +++++++++++++------ main/bitle_lora.h | 10 +- main/lora_trunk_protocol.c | 368 ++++++++++++++ main/lora_trunk_protocol.h | 130 +++++ main/noise_handshake.c | 21 + main/noise_handshake.h | 6 + tests/test_lora_trunk_protocol.c | 269 ++++++++++ tools/lora_hardware_smoke.py | 34 +- tools/run_lora_host_tests.sh | 10 +- 14 files changed, 1419 insertions(+), 175 deletions(-) create mode 100644 docs/LoRa-trunk-v3.md create mode 100644 main/lora_trunk_protocol.c create mode 100644 main/lora_trunk_protocol.h create mode 100644 tests/test_lora_trunk_protocol.c diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index 78f08ae..63be1f4 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -62,7 +62,7 @@ The implementation is complete only when all of the following are true: | 0. Baseline harness and observability | Complete | `d130a3a` | Host baseline, both firmware targets, and two-board SF10 smoke passed | | 1. Airtime-safe SX1262 operation | Complete | `028511e` | Host/profile tests, both firmware targets, and SF10-SF12 two-board radio tests passed | | 2. Atomic packet scheduling and backpressure | Complete | `fe9e95e` | Concurrent saturation, both firmware targets, and two-board SF10 smoke passed | -| 3. Addressed trunk protocol and migration | Pending | — | — | +| 3. Addressed trunk protocol and migration | Complete | Pending checkpoint | v2/v3 host protocol suite, both firmware targets, and addressed two-board v3 smoke passed | | 4. Packet-level reliability and reassembly | Pending | — | — | | 5. Discovery and shared-channel behavior | Pending | — | — | | 6. LoRa multi-hop forwarding | Pending | — | — | @@ -286,29 +286,29 @@ correct on a shared multi-node channel. ### Checklist -- [ ] Write the trunk v3 wire-format specification before implementing it. -- [ ] Use a sufficiently collision-resistant node identifier on the wire, or +- [x] Write the trunk v3 wire-format specification before implementing it. +- [x] Use a sufficiently collision-resistant node identifier on the wire, or define explicit collision detection and recovery for shortened tags. -- [ ] Give each packet a reboot-safe transfer identifier that cannot be confused +- [x] Give each packet a reboot-safe transfer identifier that cannot be confused with another sender's sequence. -- [ ] Include source, destination, transfer identifier, fragment information, +- [x] Include source, destination, transfer identifier, fragment information, capabilities, and integrity-covered flags in the v3 format. -- [ ] Build a bounded neighbor table from authenticated or otherwise +- [x] Build a bounded neighbor table from authenticated or otherwise integrity-checked discovery information. -- [ ] Track which LoRa neighbor last advertised reachability for each BitChat +- [x] Track which LoRa neighbor last advertised reachability for each BitChat peer so directed BitChat traffic can select a trunk destination. -- [ ] Require addressed receivers to ACK only frames addressed to them. -- [ ] Require senders to validate ACK source, destination, transfer identifier, +- [x] Require addressed receivers to ACK only frames addressed to them. +- [x] Require senders to validate ACK source, destination, transfer identifier, and fragment state. -- [ ] Define broadcast behavior with no immediate all-receiver ACK storm. -- [ ] Define behavior when the destination or route is unknown. -- [ ] Decide and document the v2-to-v3 rollout policy: - - [ ] Version and capability advertisement. - - [ ] Mixed-firmware behavior. - - [ ] Whether v2 is read-only, optional fallback, or intentionally +- [x] Define broadcast behavior with no immediate all-receiver ACK storm. +- [x] Define behavior when the destination or route is unknown. +- [x] Decide and document the v2-to-v3 rollout policy: + - [x] Version and capability advertisement. + - [x] Mixed-firmware behavior. + - [x] Whether v2 is read-only, optional fallback, or intentionally unsupported after migration. - - [ ] A rollback path for deployed nodes. -- [ ] Add parser fuzz tests and malformed-frame tests for both supported wire + - [x] A rollback path for deployed nodes. +- [x] Add parser fuzz tests and malformed-frame tests for both supported wire versions. ### Success criteria @@ -323,9 +323,31 @@ correct on a shared multi-node channel. ### Milestone record -- Status: Pending +- Status: Complete - Evidence: -- Commit: + - The written v3 specification defines a fixed 44-byte integrity-checked + header, full 64-bit Noise peer addressing, a 96-bit reboot-safe transfer + identifier, exact fragment ACK matching, broadcast-without-ACK behavior, + authenticated route learning, and the receive-only v2 migration policy. + - The allocation-free portable parser/encoder suite passes malformed v2/v3 + cases and 100,000 deterministic fuzz frames. It also proves rejection of + wrong-source ACKs, isolation of identical transfer values from different + senders, zero ACKs for a four-node broadcast, bounded neighbor eviction, + expiry, and collision quarantine. + - ESP-IDF 6.0 builds pass for the Heltec V3 ESP32-S3 configuration and the + BLE-only ESP32-C3 target. + - A two-board SF10 test formed authenticated direct-neighbor routes on both + boards. All five diagnostics originated as addressed v3 traffic; 11 exact + fragment ACK exchanges were observed. Both nodes reported zero malformed + frames, unsupported versions, rejected ACKs, neighbor collisions, radio + timeouts, command errors, BUSY timeouts, and recovery failures. + - Randomized beacon startup and interval jitter was pulled forward from + milestone 5 because synchronized boot-time discovery was a prerequisite + for exercising addressed v3. The remaining shared-channel discovery and + loss policy stays in milestone 5. + - Both test boards were restored to the normal SF10 image with diagnostic + smoke disabled after the capture. +- Commit: Pending checkpoint - Suggested commit subject: `feat(lora): introduce addressed trunk protocol v3` ## Milestone 4: Packet-level reliability and reassembly diff --git a/docs/LoRa-testing.md b/docs/LoRa-testing.md index d2d405e..82b852b 100644 --- a/docs/LoRa-testing.md +++ b/docs/LoRa-testing.md @@ -16,6 +16,12 @@ frames at SF7 through SF12, including timeout conversion and 24-bit clamping. It also checks US915/EU868 boundaries, defaults, inference, profile mismatch rejection, and image-calibration selection. +The same command also compiles the allocation-free v2/v3 trunk parser and v3 +encoder. Its protocol cases cover malformed headers, exact ACK matching, +same-transfer isolation between senders, zero-ACK broadcast delivery to four +nodes, neighbor collision quarantine, expiry, bounded eviction, and 100,000 +deterministic fuzz frames. + The legacy-v2 simulator cases intentionally assert the original protocol failures: fixed reassembly expiry under retries, partial frame enqueue, acceptance of an ACK from the wrong source, and broadcast ACK implosion. The @@ -56,8 +62,10 @@ the profile when the frequency is unambiguous. The production default keeps `CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE` disabled. For a controlled test, enable it on exactly one Heltec V3 build. That node -transmits one synthetic packet at each of 1, 2, 3, 4, and 5 fragments, spaced -12 seconds apart. Keep the receiving node on the normal build. +waits until a signed direct announcement establishes an authenticated v3 +neighbor, then transmits one addressed synthetic packet at each of 1, 2, 3, 4, +and 5 fragments, spaced 12 seconds apart. Keep the receiving node on the +normal build. Capture both serial streams concurrently: @@ -66,10 +74,12 @@ python tools/lora_hardware_smoke.py PORT_A PORT_B --seconds 90 --reset --quiet \ --json-out /tmp/bitle-lora-smoke.json ``` -The capture summarizes TX and completed RX counts by fragment count and retains -per-frame RSSI/SNR, the active profile, watchdog/recovery events, and periodic -radio diagnostics. Raw serial output and JSON evidence may contain local -device details, so keep it outside the repository. +The capture summarizes TX and completed RX counts by fragment count, addressed +diagnostic events, authenticated-neighbor learning, exact v3 ACK traffic, and +parser rejection counters. It also retains per-frame RSSI/SNR, the active +profile, watchdog/recovery events, and periodic radio diagnostics. Raw serial +output and JSON evidence may contain local device details, so keep it outside +the repository. Repeat with `CONFIG_BITLE_LORA_DEFAULT_SF` set to 10, 11, and 12 on both boards. At each SF, verify that the receiver records 136-byte frames and both diff --git a/docs/LoRa-trunk-v3.md b/docs/LoRa-trunk-v3.md new file mode 100644 index 0000000..0a8bc9d --- /dev/null +++ b/docs/LoRa-trunk-v3.md @@ -0,0 +1,154 @@ +# BitLE LoRa trunk protocol v3 + +## Status and scope + +This document defines the version 3 LoRa trunk frame used to carry encoded +BitChat packets between BitLE nodes. Version 3 replaces the collision-prone +four-byte v2 node tags and broadcast ACK behavior with explicit 64-bit +addressing and exact acknowledgement matching. + +The SX1262 packet CRC protects the complete radio frame in transit. Version 3 +also includes a header CRC so parser-critical fields are rejected before they +can allocate or mutate reassembly state. + +## Byte order and limits + +All multi-byte integers are unsigned and big-endian. A radio frame is at most +255 bytes. The fixed v3 header is 44 bytes, leaving at most 211 payload bytes. +The initial implementation transmits chunks of at most 120 bytes to retain the +existing short-frame RF behavior. + +## Fixed header + +| Offset | Size | Field | Definition | +|---:|---:|---|---| +| 0 | 2 | magic | `B7 1E` | +| 2 | 1 | version | `03` | +| 3 | 1 | kind | `01` data, `02` fragment ACK | +| 4 | 1 | flags | Integrity-covered flags defined below | +| 5 | 1 | header length | `44` | +| 6 | 2 | capabilities | Sender capabilities | +| 8 | 1 | hop limit | Remaining forwarding budget; `0` in milestone 3 | +| 9 | 1 | hop count | Hops already crossed; `0` in milestone 3 | +| 10 | 2 | payload length | Bytes following this header | +| 12 | 8 | source | Full BitChat/Noise peer identifier | +| 20 | 8 | destination | Full peer identifier; all zero means broadcast | +| 28 | 12 | transfer ID | Reboot-safe identifier described below | +| 40 | 1 | fragment index | Zero based | +| 41 | 1 | fragment total | `1..5` in the initial implementation | +| 42 | 2 | header CRC | CRC-16/CCITT-FALSE of bytes `0..41` | + +The first implemented capability bit is `0x0001`, `TRUNK_V3`. Capability bits +describe the sender of this frame and must not be interpreted as proof that +another node supports a feature. + +### Flags + +- `0x01 ACK_REQUESTED`: valid only on addressed data. +- `0x02 BROADCAST`: destination must be all zero and `ACK_REQUESTED` must be + clear. + +All other flag bits are reserved and cause the frame to be rejected. An +addressed frame has a nonzero destination and a clear `BROADCAST` flag. A +broadcast has a zero destination and the `BROADCAST` flag set. The redundant +encoding makes accidental and malformed address modes detectable. + +Data frames require a nonzero source, nonzero transfer ID, a fragment total of +at least one, an index below that total, and a payload length of at least one. +ACK frames require a nonzero source and destination, the flags and payload +length to be zero, and fragment state copied from the acknowledged data frame. + +## Node and transfer identity + +The source and destination are the complete eight-byte BitChat peer IDs derived +from Noise static public keys. Version 3 never truncates them to the four-byte +v2 radio tag. + +A neighbor advertisement binds a node ID to the Ed25519 key in a successfully +verified signed BitChat `ANNOUNCE`. The neighbor table stores that signing-key +identity with the radio source. If the same 64-bit node ID is observed with a +different verified signing key, every entry for that ID is quarantined and no +direct route through the conflicting identity is selected. A later firmware +may add an explicit collision-resolution exchange; milestone 3 fails closed. + +Each boot generates an unpredictable 64-bit boot epoch from the hardware random +number generator. Every admitted packet appends a monotonically increasing +32-bit counter, producing a 96-bit transfer ID. The counter advances only when +the complete packet is committed to the scheduler. Reboots therefore do not +reuse the preceding boot's transfer namespace except with negligible random +collision probability. Reassembly is keyed by the complete tuple +`(source, transfer ID)`, so equal counters or transfer bytes from different +senders cannot alias. + +## Neighbor and route learning + +The neighbor table is static and bounded. Entries may be created or refreshed +only after all of the following are true: + +1. A complete v3 packet has been reassembled without parser errors. +2. The encoded BitChat packet is an `ANNOUNCE`. +3. Its BitChat sender equals the v3 radio source. +4. The announce sender is derived from its advertised Noise static key. +5. Its signature verifies with its advertised Ed25519 key. + +An entry records the v3 source, the BitChat peer advertised as reachable, +verified signing identity, capabilities, and last-seen time. Expired, +quarantined, or conflicting entries are not routes. The bounded table evicts +the oldest non-quarantined entry when full. + +For an encoded BitChat packet with a recipient, the sender uses the freshest +authenticated neighbor that advertised that recipient. It emits addressed v3 +data with `ACK_REQUESTED`. If there is no authenticated route, the packet is +sent as an unacknowledged v3 broadcast so discovery or higher-level relay can +still provide reachability. Packets without a recipient, including announces, +are broadcasts. + +Discovery beacons use an independent randomized startup delay and interval +jitter so peers booted together do not repeatedly collide. Milestone 5 extends +this minimum discovery safeguard with broader loss-adaptation, duplicate +suppression, and cadence policy. + +## Acknowledgements + +Only a node whose complete local ID equals an addressed data frame's +destination may ACK it. Broadcast frames never request or produce immediate +ACKs. Consequently four receivers of one broadcast produce zero ACK frames. + +An ACK reverses the data frame's source and destination and copies the complete +transfer ID, fragment index, and fragment total. A sender accepts an ACK only +when all five values exactly match its in-flight fragment: + +- ACK source equals the selected data destination. +- ACK destination equals the local data source. +- transfer ID matches all 96 bits. +- fragment index matches. +- fragment total matches. + +An otherwise valid ACK from a different node is counted and ignored. + +## Version 2 migration and rollback + +Version 3 nodes advertise `TRUNK_V3` in every v3 frame. They originate only v3 +frames. During the migration window they continue to parse v2 data and emit a +v2 ACK when the legacy frame requests one, preserving inbound compatibility +with deployed v2 senders. They do not derive v3 neighbor routes from v2 frames +and never fall back to originating v2, because v2 has only four-byte tags and +cannot authenticate an ACK source strongly enough for addressed delivery. + +The mixed-firmware behavior is therefore asymmetric: + +- v3 sender to v3 receiver: v3 addressed delivery after authenticated route + learning, otherwise v3 unacknowledged broadcast. +- v2 sender to v3 receiver: legacy v2 receive and legacy ACK behavior. +- v3 sender to v2 receiver: no v3 delivery; periodic announces eventually + expose the missing capability after that node is upgraded. + +This is an intentional migration tradeoff: safety is not silently downgraded +to obtain bidirectional compatibility. Rollback consists of installing the +last v2 firmware image. Version 3 adds no persistent NVS schema and therefore +does not make rollback data-destructive. + +The v2 parser accepts only the documented 16-byte header, known flag bits, +valid fragment bounds, a nonzero source, and a payload for data frames. Unknown +versions, kinds, flags, inconsistent lengths, invalid identifiers, and invalid +fragment state are rejected without allocating reassembly state. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 1aa2df6..d14e1d9 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -11,6 +11,7 @@ idf_component_register( "bitle_lora.c" "lora_airtime.c" "lora_region.c" + "lora_trunk_protocol.c" "lora_tx_scheduler.c" "sx1262.c" "bitle_ota.c" diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 0c7a0f0..40120d8 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -74,10 +74,11 @@ menu "Bitle hardware" depends on IDF_TARGET_ESP32S3 default n help - Test-only mode. After the trunk starts, transmit synthetic packets - spanning one through five v2 fragments. Flash exactly one test node - with this enabled and leave receiving nodes on the normal build. - Never enable it in deployed firmware. + Test-only mode. After an authenticated v3 neighbor is discovered, + transmit synthetic addressed packets spanning one through five + fragments. Flash exactly one test node with this enabled and leave + receiving nodes on the normal build. Never enable it in deployed + firmware. choice BITLE_LORA_REGION prompt "Default LoRa regional profile" diff --git a/main/bitle_lora.c b/main/bitle_lora.c index 26d86e1..784842a 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -17,6 +17,7 @@ #include "bitle_stats.h" #include "lora_airtime.h" #include "lora_region.h" +#include "lora_trunk_protocol.h" #include "lora_tx_scheduler.h" #include "noise_handshake.h" #include "packet_codec.h" @@ -97,22 +98,12 @@ static const char *TAG = "bitle_lora"; #define LORA_PREAMBLE_SYMBOLS 16 #endif -/* Trunk frame header v2 (16 bytes): - * [0..1] magic 0xB7 0x1E [2] version 0x02 - * [3] ftype: bit0 = ACK frame, bit1 = ack requested - * [4..7] src node tag [8..11] dst node tag (zeros = broadcast) - * [12..13] packet seq BE [14] frag idx [15] frag total */ -#define TRUNK_MAGIC0 0xB7 -#define TRUNK_MAGIC1 0x1E -#define TRUNK_VERSION 0x02 -#define TRUNK_HDR_LEN 16 -#define FTYPE_ACK 0x01 -#define FTYPE_ACK_REQ 0x02 - -/* RX accepts chunks up to the wire max; TX caps chunk size well below it so - * each frame's on-air time stays short. Short frames survive multipath fades - * on a marginal link far more reliably than long ones. */ -#define TRUNK_CHUNK_MAX (SX1262_MAX_PAYLOAD - TRUNK_HDR_LEN) +/* v3 uses the portable parser/encoder in lora_trunk_protocol. RX retains the + * documented v2 migration path, whose shorter header permits a larger legacy + * chunk than v3. TX caps chunks well below either wire maximum so each frame's + * on-air time stays short. */ +#define TRUNK_CHUNK_RX_MAX \ + (SX1262_MAX_PAYLOAD - LORA_TRUNK_V2_HEADER_LEN) #define TRUNK_CHUNK_TX 120 #define TRUNK_MAX_FRAGS ((BITCHAT_BLE_MAX_PACKET_SIZE + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX) @@ -141,14 +132,24 @@ _Static_assert(BITCHAT_BLE_MAX_PACKET_SIZE <= LORA_TX_PACKET_MAX_LEN, static lora_tx_scheduler_t s_tx_scheduler; static TaskHandle_t s_task; static bool s_active; -static uint16_t s_tx_seq; -static uint8_t s_src_tag[4]; +static uint8_t s_node_id[LORA_TRUNK_V3_NODE_ID_LEN]; +static uint8_t s_boot_epoch[8]; +static uint32_t s_transfer_counter; + +typedef struct { + bool valid; + bool ack_requested; + uint8_t destination[LORA_TRUNK_V3_NODE_ID_LEN]; + uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN]; +} tx_trunk_context_t; +static tx_trunk_context_t s_tx_context[LORA_TX_PACKET_POOL_SIZE]; /* Ack seen by rx_frame for the frame the ARQ machine is waiting on. * rx_frame runs on the lora task itself, so plain statics suffice. */ static bool s_ack_seen; -static uint16_t s_ack_seq; -static uint8_t s_ack_idx; +static bool s_expected_ack_valid; +static lora_trunk_frame_t s_expected_ack_data; +static lora_trunk_neighbor_table_t s_neighbors; /* Modem params (for airtime), governor, and throttle state — shared * between relay callers and the beacon path, guarded by a spinlock. */ @@ -272,7 +273,7 @@ static bool trunk_admit_locked(const uint8_t *data, uint16_t len) for (uint8_t i = 0; i < total; ++i) { uint16_t off = (uint16_t)i * TRUNK_CHUNK_TX; uint16_t chunk = len - off < TRUNK_CHUNK_TX ? len - off : TRUNK_CHUNK_TX; - airtime += trunk_airtime_ms(TRUNK_HDR_LEN + chunk); + airtime += trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN + chunk); } /* Reading the clock under the lock prevents another producer from @@ -309,13 +310,15 @@ static bool trunk_admit_locked(const uint8_t *data, uint16_t len) /* Reassembly of one inbound packet per remote sender (2 slots). */ typedef struct { bool in_use; - uint8_t src[4]; - uint16_t seq; + uint8_t version; + uint8_t src[LORA_TRUNK_V3_NODE_ID_LEN]; + uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN]; + uint16_t capabilities; uint8_t total; uint8_t have_mask; uint64_t started_ms; uint16_t part_len[TRUNK_MAX_FRAGS]; - uint8_t part[TRUNK_MAX_FRAGS][TRUNK_CHUNK_MAX]; + uint8_t part[TRUNK_MAX_FRAGS][TRUNK_CHUNK_RX_MAX]; } rx_slot_t; #define RX_SLOTS 2 @@ -433,6 +436,7 @@ static void lora_link_cancel(uint16_t handle, uintptr_t token) return; } taskENTER_CRITICAL(&s_gov_mux); + memset(&s_tx_context[token], 0, sizeof(s_tx_context[token])); lora_tx_scheduler_cancel( &s_tx_scheduler, (lora_tx_handle_t)token); diag_sync_scheduler_locked(); @@ -457,7 +461,10 @@ static bitle_link_send_result_t lora_link_commit( uint8_t type = data[PKT_TYPE_OFF]; lora_tx_priority_t priority = trunk_priority(type); lora_tx_handle_t reserved = (lora_tx_handle_t)token; - uint16_t sequence = 0; + uint32_t transfer_counter = 0; + uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN] = {0}; + uint8_t destination[LORA_TRUNK_V3_NODE_ID_LEN] = {0}; + bool ack_requested = false; bool committed = false; taskENTER_CRITICAL(&s_gov_mux); @@ -477,12 +484,43 @@ static bitle_link_send_result_t lora_link_commit( taskEXIT_CRITICAL(&s_gov_mux); return BITLE_LINK_SEND_POLICY_DROPPED; } - sequence = (uint16_t)(s_tx_seq + 1); + transfer_counter = s_transfer_counter + 1; + if (transfer_counter == 0) { + lora_tx_scheduler_cancel(&s_tx_scheduler, reserved); + diag_sync_scheduler_locked(); + taskEXIT_CRITICAL(&s_gov_mux); + return BITLE_LINK_SEND_FAILED; + } + lora_trunk_make_transfer_id( + s_boot_epoch, transfer_counter, transfer_id); + + /* BitChat recipient starts immediately after the fixed 22-byte packet + * header. Only a fresh authenticated announce may supply a directed route; + * unknown routes stay reachable as unacknowledged broadcasts. */ + if ((data[11] & 0x01u) != 0 && len >= 30) { + uint16_t route_capabilities = 0; + uint64_t now_ms = esp_timer_get_time() / 1000ULL; + if (lora_trunk_neighbor_route( + &s_neighbors, data + 22, now_ms, 180000ULL, + destination, &route_capabilities) && + (route_capabilities & LORA_TRUNK_V3_CAP_PROTOCOL) != 0) { + ack_requested = true; + } else { + memset(destination, 0, sizeof(destination)); + } + } committed = lora_tx_scheduler_commit( - &s_tx_scheduler, reserved, data, len, type, sequence); + &s_tx_scheduler, reserved, data, len, type, + (uint16_t)transfer_counter); if (committed) { - s_tx_seq = sequence; + tx_trunk_context_t *context = &s_tx_context[reserved]; + context->valid = true; + context->ack_requested = ack_requested; + memcpy(context->destination, destination, sizeof(destination)); + memcpy(context->transfer_id, transfer_id, sizeof(transfer_id)); + s_transfer_counter = transfer_counter; } else { + memset(&s_tx_context[reserved], 0, sizeof(s_tx_context[reserved])); lora_tx_scheduler_cancel(&s_tx_scheduler, reserved); } diag_sync_scheduler_locked(); @@ -494,8 +532,10 @@ static bitle_link_send_result_t lora_link_commit( uint8_t total = (uint8_t)((len + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX); ESP_LOGI(TAG, - "trunk accepted type=0x%02X len=%u frags=%u priority=%u seq=%u", - type, len, total, priority, sequence); + "trunk accepted type=0x%02X len=%u frags=%u priority=%u " + "addressed=%d transfer=%lu", + type, len, total, priority, ack_requested, + (unsigned long)transfer_counter); bitle_stats_note_activity(BITLE_LANE_TRUNK, 1); if (s_task) { xTaskNotifyGive(s_task); @@ -536,9 +576,11 @@ static void tx_scheduler_release(lora_tx_handle_t handle) return; } taskENTER_CRITICAL(&s_gov_mux); + memset(&s_tx_context[handle], 0, sizeof(s_tx_context[handle])); lora_tx_scheduler_release(&s_tx_scheduler, handle); diag_sync_scheduler_locked(); taskEXIT_CRITICAL(&s_gov_mux); + s_expected_ack_valid = false; } static bool tx_scheduler_has_queued(void) @@ -554,7 +596,9 @@ static bool build_tx_fragment(lora_tx_handle_t handle, uint8_t idx, { const lora_tx_packet_t *packet = lora_tx_scheduler_packet(&s_tx_scheduler, handle); - if (!packet || !frame || !out_total) { + if (!packet || !frame || !out_total || + handle >= LORA_TX_PACKET_POOL_SIZE || + !s_tx_context[handle].valid) { return false; } uint8_t total = @@ -562,27 +606,39 @@ static bool build_tx_fragment(lora_tx_handle_t handle, uint8_t idx, if (idx >= total) { return false; } - bool want_ack = - !(packet->type == BITCHAT_MSG_ANNOUNCE || - packet->type == BITCHAT_MSG_NOISE_IDENTITY_ANNOUNCE); uint16_t off = (uint16_t)idx * TRUNK_CHUNK_TX; uint16_t remaining = packet->len - off; uint16_t chunk = remaining < TRUNK_CHUNK_TX ? remaining : TRUNK_CHUNK_TX; - uint8_t *header = frame->data; - header[0] = TRUNK_MAGIC0; - header[1] = TRUNK_MAGIC1; - header[2] = TRUNK_VERSION; - header[3] = want_ack ? FTYPE_ACK_REQ : 0x00; - memcpy(header + 4, s_src_tag, 4); - memset(header + 8, 0, 4); - header[12] = packet->sequence >> 8; - header[13] = packet->sequence & 0xFF; - header[14] = idx; - header[15] = total; - memcpy(header + TRUNK_HDR_LEN, packet->data + off, chunk); - frame->len = TRUNK_HDR_LEN + chunk; - frame->want_ack = want_ack; + const tx_trunk_context_t *context = &s_tx_context[handle]; + lora_trunk_frame_t trunk = { + .version = LORA_TRUNK_V3_VERSION, + .kind = LORA_TRUNK_KIND_DATA, + .flags = context->ack_requested + ? LORA_TRUNK_V3_FLAG_ACK_REQUESTED + : LORA_TRUNK_V3_FLAG_BROADCAST, + .capabilities = LORA_TRUNK_V3_CAP_PROTOCOL, + .fragment_index = idx, + .fragment_total = total, + .payload = packet->data + off, + .payload_len = chunk, + }; + memcpy(trunk.source, s_node_id, sizeof(trunk.source)); + memcpy(trunk.destination, context->destination, + sizeof(trunk.destination)); + memcpy(trunk.transfer_id, context->transfer_id, + sizeof(trunk.transfer_id)); + size_t encoded_len = 0; + if (!lora_trunk_v3_encode( + &trunk, frame->data, sizeof(frame->data), &encoded_len)) { + return false; + } + frame->len = (uint16_t)encoded_len; + frame->want_ack = context->ack_requested; + s_expected_ack_valid = frame->want_ack; + if (s_expected_ack_valid) { + s_expected_ack_data = trunk; + } *out_total = total; return true; } @@ -594,25 +650,28 @@ static bool build_tx_fragment(lora_tx_handle_t handle, uint8_t idx, * priority and no CAD (the peer is listening the instant its own TX ends). */ #define ACK_OUTBOX 4 typedef struct { - uint8_t dst[4]; - uint16_t seq; + uint8_t version; + uint8_t dst[LORA_TRUNK_V3_NODE_ID_LEN]; + uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN]; uint8_t idx; uint8_t total; } ack_slot_t; static ack_slot_t s_ack_outbox[ACK_OUTBOX]; static int s_ack_head, s_ack_count; -static void queue_ack(const uint8_t *dst_tag, uint16_t seq, uint8_t idx, uint8_t total) +static void queue_ack(const lora_trunk_frame_t *data) { - if (s_ack_count >= ACK_OUTBOX) { + if (!data || s_ack_count >= ACK_OUTBOX) { ESP_LOGW(TAG, "ack outbox full"); return; } ack_slot_t *a = &s_ack_outbox[(s_ack_head + s_ack_count) % ACK_OUTBOX]; - memcpy(a->dst, dst_tag, 4); - a->seq = seq; - a->idx = idx; - a->total = total; + memset(a, 0, sizeof(*a)); + a->version = data->version; + memcpy(a->dst, data->source, sizeof(a->dst)); + memcpy(a->transfer_id, data->transfer_id, sizeof(a->transfer_id)); + a->idx = data->fragment_index; + a->total = data->fragment_total; s_ack_count++; } @@ -622,70 +681,92 @@ static bool transmit_ack_now(void) return false; } ack_slot_t *a = &s_ack_outbox[s_ack_head]; - uint8_t f[TRUNK_HDR_LEN]; - f[0] = TRUNK_MAGIC0; - f[1] = TRUNK_MAGIC1; - f[2] = TRUNK_VERSION; - f[3] = FTYPE_ACK; - memcpy(f + 4, s_src_tag, 4); - memcpy(f + 8, a->dst, 4); - f[12] = a->seq >> 8; - f[13] = a->seq & 0xFF; - f[14] = a->idx; - f[15] = a->total; + uint8_t f[LORA_TRUNK_V3_HEADER_LEN] = {0}; + size_t frame_len = 0; + if (a->version == LORA_TRUNK_V2_VERSION) { + f[0] = LORA_TRUNK_MAGIC0; + f[1] = LORA_TRUNK_MAGIC1; + f[2] = LORA_TRUNK_V2_VERSION; + f[3] = 0x01; + memcpy(f + 4, s_node_id, 4); + memcpy(f + 8, a->dst, 4); + f[12] = a->transfer_id[10]; + f[13] = a->transfer_id[11]; + f[14] = a->idx; + f[15] = a->total; + frame_len = LORA_TRUNK_V2_HEADER_LEN; + } else { + lora_trunk_frame_t ack = { + .version = LORA_TRUNK_V3_VERSION, + .kind = LORA_TRUNK_KIND_FRAGMENT_ACK, + .capabilities = LORA_TRUNK_V3_CAP_PROTOCOL, + .fragment_index = a->idx, + .fragment_total = a->total, + }; + memcpy(ack.source, s_node_id, sizeof(ack.source)); + memcpy(ack.destination, a->dst, sizeof(ack.destination)); + memcpy(ack.transfer_id, a->transfer_id, sizeof(ack.transfer_id)); + if (!lora_trunk_v3_encode( + &ack, f, sizeof(f), &frame_len)) { + s_ack_head = (s_ack_head + 1) % ACK_OUTBOX; + s_ack_count--; + return false; + } + } s_ack_head = (s_ack_head + 1) % ACK_OUTBOX; s_ack_count--; - if (sx1262_transmit(f, TRUNK_HDR_LEN) == ESP_OK) { + if (sx1262_transmit(f, frame_len) == ESP_OK) { diag_inc(&s_diag.tx_attempts); diag_inc(&s_diag.ack_tx); - ESP_LOGI(TAG, "ack TX seq=%u idx=%u", a->seq, a->idx); + ESP_LOGI(TAG, "v%u ack TX idx=%u", a->version, a->idx); return true; } recover_radio("ACK transmit command"); return false; } -static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) +static void learn_authenticated_neighbor( + const lora_trunk_frame_t *frame, const uint8_t *packet_data, + uint16_t packet_len, uint64_t now_ms) { - if (len < TRUNK_HDR_LEN || f[0] != TRUNK_MAGIC0 || f[1] != TRUNK_MAGIC1 || - f[2] != TRUNK_VERSION) { - return; /* foreign traffic, noise, or old trunk version */ - } - if (memcmp(f + 4, s_src_tag, 4) == 0) { - return; /* our own transmission echoed */ + if (frame->version != LORA_TRUNK_V3_VERSION) { + return; } - diag_inc(&s_diag.raw_rx_frames); - ESP_LOGI(TAG, "trunk raw RX len=%u rssi=%d snr=%d ftype=0x%02X", - len, rssi, snr, f[3]); - uint16_t seq = ((uint16_t)f[12] << 8) | f[13]; - uint8_t idx = f[14], total = f[15]; - - if (f[3] & FTYPE_ACK) { - /* Ack addressed to this node; the ARQ loop matches it by seq/idx. */ - if (memcmp(f + 8, s_src_tag, 4) == 0) { - s_ack_seen = true; - s_ack_seq = seq; - s_ack_idx = idx; - diag_inc(&s_diag.ack_rx); - ESP_LOGI(TAG, "ack RX seq=%u idx=%u", seq, idx); - } + bitchat_packet_t packet; + if (!bitchat_packet_decode(packet_data, packet_len, &packet)) { return; } - - /* Ack every valid ack-requested data frame, even retransmits of a - * packet we already completed — the sender may have missed our ack. */ - if (f[3] & FTYPE_ACK_REQ) { - queue_ack(f + 4, seq, idx, total); + uint8_t signing_key[LORA_TRUNK_SIGNING_KEY_LEN]; + if (memcmp(packet.sender_id, frame->source, + LORA_TRUNK_V3_NODE_ID_LEN) == 0 && + noise_verify_announce_identity(&packet, signing_key)) { + uint32_t neighbor_count = 0; + taskENTER_CRITICAL(&s_gov_mux); + lora_trunk_neighbor_result_t result = + lora_trunk_neighbor_learn_authenticated( + &s_neighbors, frame->source, packet.sender_id, signing_key, + frame->capabilities, now_ms); + if (result == LORA_TRUNK_NEIGHBOR_COLLISION) { + s_diag.neighbor_collisions++; + } + s_diag.neighbor_count = + (uint32_t)lora_trunk_neighbor_count(&s_neighbors); + neighbor_count = s_diag.neighbor_count; + taskEXIT_CRITICAL(&s_gov_mux); + ESP_LOGI(TAG, "authenticated neighbor result=%u count=%lu", + result, (unsigned long)neighbor_count); } + bitchat_packet_free(&packet); +} - if (len < TRUNK_HDR_LEN + 1) { - return; /* no payload */ - } - uint16_t chunk = len - TRUNK_HDR_LEN; - if (total == 0 || total > TRUNK_MAX_FRAGS || idx >= total || chunk > TRUNK_CHUNK_MAX) { +static void reassemble_data( + const lora_trunk_frame_t *frame, int16_t rssi, int8_t snr) +{ + if (!frame || frame->payload_len == 0 || + frame->payload_len > TRUNK_CHUNK_RX_MAX || + frame->fragment_total > TRUNK_MAX_FRAGS) { return; } - uint64_t now = esp_timer_get_time() / 1000ULL; rx_slot_t *slot = NULL, *free_slot = NULL, *oldest = &s_rx_slots[0]; for (size_t i = 0; i < RX_SLOTS; ++i) { @@ -694,7 +775,10 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) s->in_use = false; diag_inc(&s_diag.reassembly_expiry); } - if (s->in_use && s->seq == seq && memcmp(s->src, f + 4, 4) == 0) { + if (s->in_use && s->version == frame->version && + memcmp(s->src, frame->source, sizeof(s->src)) == 0 && + memcmp(s->transfer_id, frame->transfer_id, + sizeof(s->transfer_id)) == 0) { slot = s; } else if (!s->in_use && !free_slot) { free_slot = s; @@ -707,26 +791,32 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) slot = free_slot ? free_slot : oldest; memset(slot, 0, sizeof(*slot)); slot->in_use = true; - memcpy(slot->src, f + 4, 4); - slot->seq = seq; - slot->total = total; + slot->version = frame->version; + memcpy(slot->src, frame->source, sizeof(slot->src)); + memcpy(slot->transfer_id, frame->transfer_id, + sizeof(slot->transfer_id)); + slot->capabilities = frame->capabilities; + slot->total = frame->fragment_total; slot->started_ms = now; } - if (slot->total != total) { + if (slot->total != frame->fragment_total || + slot->capabilities != frame->capabilities) { + diag_inc(&s_diag.malformed_frames); return; } - slot->part_len[idx] = chunk; - memcpy(slot->part[idx], f + TRUNK_HDR_LEN, chunk); - slot->have_mask |= 1u << idx; + slot->part_len[frame->fragment_index] = frame->payload_len; + memcpy(slot->part[frame->fragment_index], frame->payload, + frame->payload_len); + slot->have_mask |= 1u << frame->fragment_index; - uint8_t want = (uint8_t)((1u << total) - 1); + uint8_t want = (uint8_t)((1u << frame->fragment_total) - 1); if ((slot->have_mask & want) != want) { return; } static uint8_t packet[BITCHAT_BLE_MAX_PACKET_SIZE]; uint16_t plen = 0; - for (uint8_t i = 0; i < total; ++i) { + for (uint8_t i = 0; i < frame->fragment_total; ++i) { if (plen + slot->part_len[i] > sizeof(packet)) { slot->in_use = false; return; @@ -736,15 +826,83 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) } slot->in_use = false; + learn_authenticated_neighbor(frame, packet, plen, now); diag_inc(&s_diag.completed_packets); - ESP_LOGI(TAG, "trunk RX packet len=%u rssi=%d snr=%d frags=%u", plen, rssi, snr, total); + ESP_LOGI(TAG, "trunk v%u RX packet len=%u rssi=%d snr=%d frags=%u", + frame->version, plen, rssi, snr, frame->fragment_total); bitle_mesh_inbound(BITLE_LORA_LINK_HANDLE, packet, plen); } +static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) +{ + lora_trunk_frame_t frame; + lora_trunk_parse_result_t parsed = lora_trunk_parse(f, len, &frame); + if (parsed != LORA_TRUNK_PARSE_OK) { + if (parsed == LORA_TRUNK_PARSE_MALFORMED) { + diag_inc(&s_diag.malformed_frames); + } else if (parsed == LORA_TRUNK_PARSE_UNSUPPORTED_VERSION) { + diag_inc(&s_diag.unsupported_versions); + } + return; + } + size_t source_len = + frame.version == LORA_TRUNK_V2_VERSION ? 4 : sizeof(s_node_id); + if (memcmp(frame.source, s_node_id, source_len) == 0) { + return; + } + + diag_inc(&s_diag.raw_rx_frames); + ESP_LOGI(TAG, "trunk v%u raw RX len=%u rssi=%d snr=%d kind=%u", + frame.version, len, rssi, snr, frame.kind); + + if (frame.kind == LORA_TRUNK_KIND_FRAGMENT_ACK) { + /* v3 is the only originated version. Shared-channel ACKs for other + * senders are normal; only an ACK addressed to us but not matching the + * in-flight tuple is a rejected ACK. */ + if (frame.version == LORA_TRUNK_V3_VERSION && + memcmp(frame.destination, s_node_id, sizeof(s_node_id)) == 0) { + if (s_expected_ack_valid && + lora_trunk_v3_ack_matches(&s_expected_ack_data, &frame)) { + s_ack_seen = true; + diag_inc(&s_diag.ack_rx); + ESP_LOGI(TAG, "v3 ack RX idx=%u", frame.fragment_index); + } else { + diag_inc(&s_diag.rejected_acks); + } + } + return; + } + + bool broadcast = + frame.version == LORA_TRUNK_V3_VERSION + ? (frame.flags & LORA_TRUNK_V3_FLAG_BROADCAST) != 0 + : memcmp(frame.destination, + (uint8_t[LORA_TRUNK_V3_NODE_ID_LEN]){0}, + 4) == 0; + size_t destination_len = + frame.version == LORA_TRUNK_V2_VERSION ? 4 : sizeof(s_node_id); + bool addressed_to_us = + !broadcast && + memcmp(frame.destination, s_node_id, destination_len) == 0; + if (!broadcast && !addressed_to_us) { + return; + } + + /* v3 broadcasts can never request ACKs (enforced by the parser). The v2 + * receive-only migration path preserves legacy broadcast ACK behavior. */ + if ((frame.flags & LORA_TRUNK_V3_FLAG_ACK_REQUESTED) != 0 && + (frame.version == LORA_TRUNK_V2_VERSION || addressed_to_us)) { + queue_ack(&frame); + } + reassemble_data(&frame, rssi, snr); +} + /* Neighbor discovery: our signed announce over the trunk, so two nodes * with no phone in sight still find and verify each other. */ #define BEACON_FIRST_MS 5000ULL #define BEACON_INTERVAL_MS 60000ULL +#define BEACON_START_JITTER_MS 10000U +#define BEACON_INTERVAL_JITTER_MS 30000U static void drop_tx_packet(lora_tx_handle_t *handle, bool *have_pending) { @@ -791,7 +949,8 @@ static void lora_task(void *arg) unsigned ack_burst = 0; uint64_t tx_watchdog_deadline = 0; uint64_t ack_deadline = 0; - uint64_t next_beacon_ms = BEACON_FIRST_MS; + uint64_t next_beacon_ms = + BEACON_FIRST_MS + (esp_random() % BEACON_START_JITTER_MS); uint64_t next_diag_ms = 60000ULL; #if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE static const uint16_t smoke_lengths[] = {100, 180, 300, 420, 520}; @@ -807,7 +966,10 @@ static void lora_task(void *arg) uint64_t now = esp_timer_get_time() / 1000ULL; if (now >= next_beacon_ms) { - next_beacon_ms = now + BEACON_INTERVAL_MS; + next_beacon_ms = + now + BEACON_INTERVAL_MS - + (BEACON_INTERVAL_JITTER_MS / 2U) + + (esp_random() % BEACON_INTERVAL_JITTER_MS); if (noise_announce_link(BITLE_LORA_LINK_HANDLE)) { ESP_LOGI(TAG, "trunk beacon sent"); } @@ -818,20 +980,54 @@ static void lora_task(void *arg) now >= next_smoke_ms) { static uint8_t probe[BITCHAT_BLE_MAX_PACKET_SIZE]; uint16_t probe_len = smoke_lengths[smoke_index]; - memset(probe, 0xA5, probe_len); - probe[0] = 1; - probe[1] = BITCHAT_MSG_MESSAGE; - probe[2] = 0; - probe[11] = 0; - uint16_t payload_len = probe_len - 22; - probe[12] = payload_len >> 8; - probe[13] = payload_len & 0xFF; - memcpy(probe + PKT_SENDER_OFF, noise_get_local_peer_id(), 8); - ESP_LOGI(TAG, "diagnostic smoke enqueue len=%u expected_frags=%u", - probe_len, (probe_len + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX); - lora_link_send(BITLE_LORA_LINK_HANDLE, probe, probe_len); - smoke_index++; - next_smoke_ms = now + 12000ULL; + uint8_t recipient[LORA_TRUNK_V3_NODE_ID_LEN] = {0}; + bool addressed = false; + taskENTER_CRITICAL(&s_gov_mux); + const lora_trunk_neighbor_t *freshest = NULL; + for (size_t i = 0; i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { + const lora_trunk_neighbor_t *entry = + &s_neighbors.entries[i]; + if (entry->in_use && !entry->quarantined && + now >= entry->last_seen_ms && + now - entry->last_seen_ms <= 180000ULL && + (!freshest || + entry->last_seen_ms > freshest->last_seen_ms)) { + freshest = entry; + } + } + if (freshest) { + memcpy(recipient, freshest->reachable_peer, + sizeof(recipient)); + addressed = true; + } + taskEXIT_CRITICAL(&s_gov_mux); + if (!addressed) { + ESP_LOGI(TAG, + "diagnostic smoke waiting for authenticated neighbor"); + next_smoke_ms = now + 3000ULL; + } else { + memset(probe, 0xA5, probe_len); + probe[0] = 1; + probe[1] = BITCHAT_MSG_MESSAGE; + probe[2] = 0; + probe[11] = 0x01; + const uint16_t packet_header_len = 30; + uint16_t payload_len = probe_len - packet_header_len; + probe[12] = payload_len >> 8; + probe[13] = payload_len & 0xFF; + memcpy(probe + PKT_SENDER_OFF, + noise_get_local_peer_id(), 8); + memcpy(probe + 22, recipient, sizeof(recipient)); + ESP_LOGI(TAG, + "diagnostic smoke enqueue len=%u expected_frags=%u " + "addressed=1", + probe_len, + (probe_len + TRUNK_CHUNK_TX - 1) / + TRUNK_CHUNK_TX); + lora_link_send(BITLE_LORA_LINK_HANDLE, probe, probe_len); + smoke_index++; + next_smoke_ms = now + 12000ULL; + } } #endif @@ -844,7 +1040,8 @@ static void lora_task(void *arg) "rx_expire=%lu complete=%lu cfg_reject=%lu radio_err=%lu " "busy_tmo=%lu recover=%lu/%lu tx_watchdog=%lu " "admit=%lu/%lu/%lu/%lu defer=%lu/%lu/%lu/%lu " - "policy=%lu release=%lu cancel=%lu", + "policy=%lu release=%lu cancel=%lu malformed=%lu " + "unsupported=%lu ack_reject=%lu neighbors=%lu collision=%lu", (unsigned long)diag.raw_rx_frames, (unsigned long)diag.crc_errors, (unsigned long)diag.tx_attempts, @@ -875,7 +1072,12 @@ static void lora_task(void *arg) (unsigned long)diag.packet_deferred[LORA_TX_PRIORITY_BULK], (unsigned long)diag.policy_drops, (unsigned long)diag.packet_released, - (unsigned long)diag.packet_cancelled); + (unsigned long)diag.packet_cancelled, + (unsigned long)diag.malformed_frames, + (unsigned long)diag.unsupported_versions, + (unsigned long)diag.rejected_acks, + (unsigned long)diag.neighbor_count, + (unsigned long)diag.neighbor_collisions); next_diag_ms = now + 60000ULL; } @@ -914,14 +1116,17 @@ static void lora_task(void *arg) * data frame's ack wait, so extend its deadline */ tx_is_ack = false; if (awaiting_ack) { - ack_deadline += trunk_airtime_ms(TRUNK_HDR_LEN) + 300; + ack_deadline += + trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN) + 300; } } else if (have_pending && pending.want_ack) { /* wait long enough for the peer's ack (its airtime plus * CAD/scheduling slack) before retransmitting */ awaiting_ack = true; ack_deadline = esp_timer_get_time() / 1000ULL + - trunk_airtime_ms(TRUNK_HDR_LEN) + ARQ_MARGIN_MS; + trunk_airtime_ms( + LORA_TRUNK_V3_HEADER_LEN) + + ARQ_MARGIN_MS; } else { have_pending = advance_tx_packet( &packet_handle, &fragment_idx, fragment_total, @@ -944,7 +1149,8 @@ static void lora_task(void *arg) tx_is_ack = true; tx_watchdog_deadline = esp_timer_get_time() / 1000ULL + - sx1262_tx_watchdog_ms(TRUNK_HDR_LEN); + sx1262_tx_watchdog_ms( + LORA_TRUNK_V3_HEADER_LEN); } } else if (have_pending) { if (sx1262_transmit(pending.data, pending.len) == ESP_OK) { @@ -1040,7 +1246,7 @@ static void lora_task(void *arg) tx_is_ack = true; tx_watchdog_deadline = esp_timer_get_time() / 1000ULL + - sx1262_tx_watchdog_ms(TRUNK_HDR_LEN); + sx1262_tx_watchdog_ms(LORA_TRUNK_V3_HEADER_LEN); } } @@ -1065,11 +1271,10 @@ static void lora_task(void *arg) /* ARQ: resolve the ack wait for the in-flight frame. */ if (awaiting_ack && have_pending) { - uint16_t pseq = ((uint16_t)pending.data[12] << 8) | pending.data[13]; - uint8_t pidx = pending.data[14]; uint64_t now2 = esp_timer_get_time() / 1000ULL; - if (s_ack_seen && s_ack_seq == pseq && s_ack_idx == pidx) { + if (s_ack_seen) { s_ack_seen = false; + s_expected_ack_valid = false; awaiting_ack = false; have_pending = advance_tx_packet( &packet_handle, &fragment_idx, fragment_total, &pending); @@ -1088,8 +1293,10 @@ static void lora_task(void *arg) } } else { diag_inc(&s_diag.retry_exhaustion); - ESP_LOGW(TAG, "trunk frame lost after %d sends seq=%u idx=%u", - ARQ_TRIES, pseq, pidx); + ESP_LOGW(TAG, + "trunk frame lost after %d sends idx=%u", + ARQ_TRIES, + s_expected_ack_data.fragment_index); drop_tx_packet(&packet_handle, &have_pending); } } @@ -1284,7 +1491,18 @@ esp_err_t bitle_lora_init(void) return ESP_OK; } - memcpy(s_src_tag, noise_get_local_peer_id(), sizeof(s_src_tag)); + memcpy(s_node_id, noise_get_local_peer_id(), sizeof(s_node_id)); + for (size_t offset = 0; offset < sizeof(s_boot_epoch); + offset += sizeof(uint32_t)) { + uint32_t random_word = esp_random(); + memcpy(s_boot_epoch + offset, &random_word, sizeof(random_word)); + } + s_transfer_counter = 0; + memset(s_tx_context, 0, sizeof(s_tx_context)); + memset(s_rx_slots, 0, sizeof(s_rx_slots)); + lora_trunk_neighbor_table_init(&s_neighbors); + s_expected_ack_valid = false; + s_ack_seen = false; /* Modem params for the airtime governor; start the bucket full so a * quiet node can transmit immediately. */ diff --git a/main/bitle_lora.h b/main/bitle_lora.h index 4787d34..8dfd9ee 100644 --- a/main/bitle_lora.h +++ b/main/bitle_lora.h @@ -5,8 +5,9 @@ * * The trunk registers one bitle_link (handle BITLE_LORA_LINK_HANDLE) for * the whole medium once an SX1262 is detected, so the mesh core relays - * BLE<->LoRa with no special cases. Encoded BitChat packets are carried in - * fragments under a 16-byte trunk header, with per-frame acknowledgement; + * BLE<->LoRa with no special cases. Encoded BitChat packets originate as v3 + * fragments under a 44-byte addressed trunk header with exact per-fragment + * acknowledgement. The receive path retains strict v2 migration parsing; * frames from foreign LoRa protocols fail the magic check and are dropped * before touching the mesh. * @@ -58,6 +59,11 @@ typedef struct { uint32_t radio_recoveries; uint32_t radio_recovery_failures; uint32_t tx_watchdog_recoveries; + uint32_t malformed_frames; + uint32_t unsupported_versions; + uint32_t rejected_acks; + uint32_t neighbor_collisions; + uint32_t neighbor_count; } bitle_lora_diag_t; /* Snapshot transport diagnostics. All counters are monotonic for the boot. diff --git a/main/lora_trunk_protocol.c b/main/lora_trunk_protocol.c new file mode 100644 index 0000000..0d7b536 --- /dev/null +++ b/main/lora_trunk_protocol.c @@ -0,0 +1,368 @@ +#include "lora_trunk_protocol.h" + +#include + +#define V2_FLAG_ACK 0x01u +#define V2_FLAG_ACK_REQUESTED 0x02u +#define V2_FLAG_MASK (V2_FLAG_ACK | V2_FLAG_ACK_REQUESTED) +#define V3_FLAG_MASK \ + (LORA_TRUNK_V3_FLAG_ACK_REQUESTED | LORA_TRUNK_V3_FLAG_BROADCAST) + +static bool all_zero(const uint8_t *value, size_t len) +{ + uint8_t combined = 0; + for (size_t i = 0; i < len; ++i) { + combined |= value[i]; + } + return combined == 0; +} + +static uint16_t read_be16(const uint8_t *p) +{ + return (uint16_t)(((uint16_t)p[0] << 8) | p[1]); +} + +static void write_be16(uint8_t *p, uint16_t value) +{ + p[0] = (uint8_t)(value >> 8); + p[1] = (uint8_t)value; +} + +static uint16_t crc16_ccitt_false(const uint8_t *data, size_t len) +{ + uint16_t crc = 0xFFFFu; + for (size_t i = 0; i < len; ++i) { + crc ^= (uint16_t)data[i] << 8; + for (unsigned bit = 0; bit < 8; ++bit) { + crc = (crc & 0x8000u) != 0 + ? (uint16_t)((crc << 1) ^ 0x1021u) + : (uint16_t)(crc << 1); + } + } + return crc; +} + +static bool fragment_valid(uint8_t index, uint8_t total) +{ + return total > 0 && total <= LORA_TRUNK_MAX_FRAGMENTS && index < total; +} + +static bool v3_semantics_valid(const lora_trunk_frame_t *frame) +{ + if (!frame || frame->version != LORA_TRUNK_V3_VERSION || + (frame->flags & ~V3_FLAG_MASK) != 0 || + all_zero(frame->source, sizeof(frame->source)) || + all_zero(frame->transfer_id, sizeof(frame->transfer_id)) || + !fragment_valid(frame->fragment_index, frame->fragment_total) || + frame->payload_len > LORA_TRUNK_V3_MAX_PAYLOAD || + frame->hop_count > frame->hop_limit) { + return false; + } + + bool destination_zero = + all_zero(frame->destination, sizeof(frame->destination)); + bool broadcast = (frame->flags & LORA_TRUNK_V3_FLAG_BROADCAST) != 0; + bool ack_requested = + (frame->flags & LORA_TRUNK_V3_FLAG_ACK_REQUESTED) != 0; + + if (frame->kind == LORA_TRUNK_KIND_DATA) { + if (frame->payload_len == 0 || !frame->payload) { + return false; + } + if (broadcast != destination_zero || (broadcast && ack_requested)) { + return false; + } + return true; + } + if (frame->kind == LORA_TRUNK_KIND_FRAGMENT_ACK) { + return frame->flags == 0 && frame->payload_len == 0 && + !frame->payload && !destination_zero && + frame->hop_limit == 0 && frame->hop_count == 0; + } + return false; +} + +static lora_trunk_parse_result_t parse_v2( + const uint8_t *data, size_t len, lora_trunk_frame_t *out) +{ + if (len < LORA_TRUNK_V2_HEADER_LEN || + (data[3] & ~V2_FLAG_MASK) != 0 || + (data[3] & V2_FLAG_MASK) == V2_FLAG_MASK) { + return LORA_TRUNK_PARSE_MALFORMED; + } + + bool is_ack = (data[3] & V2_FLAG_ACK) != 0; + uint8_t index = data[14]; + uint8_t total = data[15]; + size_t payload_len = len - LORA_TRUNK_V2_HEADER_LEN; + if (!fragment_valid(index, total) || all_zero(data + 4, 4) || + (is_ack && (payload_len != 0 || all_zero(data + 8, 4))) || + (!is_ack && payload_len == 0)) { + return LORA_TRUNK_PARSE_MALFORMED; + } + + out->version = LORA_TRUNK_V2_VERSION; + out->kind = is_ack ? LORA_TRUNK_KIND_FRAGMENT_ACK + : LORA_TRUNK_KIND_DATA; + out->flags = (data[3] & V2_FLAG_ACK_REQUESTED) != 0 + ? LORA_TRUNK_V3_FLAG_ACK_REQUESTED + : 0; + memcpy(out->source, data + 4, 4); + memcpy(out->destination, data + 8, 4); + out->transfer_id[10] = data[12]; + out->transfer_id[11] = data[13]; + out->fragment_index = index; + out->fragment_total = total; + out->payload = payload_len != 0 + ? data + LORA_TRUNK_V2_HEADER_LEN + : NULL; + out->payload_len = (uint16_t)payload_len; + return LORA_TRUNK_PARSE_OK; +} + +static lora_trunk_parse_result_t parse_v3( + const uint8_t *data, size_t len, lora_trunk_frame_t *out) +{ + if (len < LORA_TRUNK_V3_HEADER_LEN || + data[5] != LORA_TRUNK_V3_HEADER_LEN) { + return LORA_TRUNK_PARSE_MALFORMED; + } + uint16_t payload_len = read_be16(data + 10); + if (payload_len > LORA_TRUNK_V3_MAX_PAYLOAD || + len != (size_t)LORA_TRUNK_V3_HEADER_LEN + payload_len || + read_be16(data + 42) != crc16_ccitt_false(data, 42)) { + return LORA_TRUNK_PARSE_MALFORMED; + } + + out->version = LORA_TRUNK_V3_VERSION; + out->kind = (lora_trunk_kind_t)data[3]; + out->flags = data[4]; + out->capabilities = read_be16(data + 6); + out->hop_limit = data[8]; + out->hop_count = data[9]; + memcpy(out->source, data + 12, sizeof(out->source)); + memcpy(out->destination, data + 20, sizeof(out->destination)); + memcpy(out->transfer_id, data + 28, sizeof(out->transfer_id)); + out->fragment_index = data[40]; + out->fragment_total = data[41]; + out->payload = + payload_len != 0 ? data + LORA_TRUNK_V3_HEADER_LEN : NULL; + out->payload_len = payload_len; + + return v3_semantics_valid(out) ? LORA_TRUNK_PARSE_OK + : LORA_TRUNK_PARSE_MALFORMED; +} + +lora_trunk_parse_result_t lora_trunk_parse( + const uint8_t *data, size_t len, lora_trunk_frame_t *out) +{ + if (!data || !out || len < 3) { + return LORA_TRUNK_PARSE_MALFORMED; + } + memset(out, 0, sizeof(*out)); + if (data[0] != LORA_TRUNK_MAGIC0 || data[1] != LORA_TRUNK_MAGIC1) { + return LORA_TRUNK_PARSE_FOREIGN; + } + if (data[2] == LORA_TRUNK_V2_VERSION) { + return parse_v2(data, len, out); + } + if (data[2] == LORA_TRUNK_V3_VERSION) { + return parse_v3(data, len, out); + } + return LORA_TRUNK_PARSE_UNSUPPORTED_VERSION; +} + +bool lora_trunk_v3_encode( + const lora_trunk_frame_t *frame, uint8_t *out, size_t capacity, + size_t *out_len) +{ + if (!out || !out_len || !v3_semantics_valid(frame)) { + return false; + } + size_t total_len = LORA_TRUNK_V3_HEADER_LEN + frame->payload_len; + if (capacity < total_len) { + return false; + } + + memset(out, 0, LORA_TRUNK_V3_HEADER_LEN); + out[0] = LORA_TRUNK_MAGIC0; + out[1] = LORA_TRUNK_MAGIC1; + out[2] = LORA_TRUNK_V3_VERSION; + out[3] = (uint8_t)frame->kind; + out[4] = frame->flags; + out[5] = LORA_TRUNK_V3_HEADER_LEN; + write_be16(out + 6, frame->capabilities); + out[8] = frame->hop_limit; + out[9] = frame->hop_count; + write_be16(out + 10, frame->payload_len); + memcpy(out + 12, frame->source, sizeof(frame->source)); + memcpy(out + 20, frame->destination, sizeof(frame->destination)); + memcpy(out + 28, frame->transfer_id, sizeof(frame->transfer_id)); + out[40] = frame->fragment_index; + out[41] = frame->fragment_total; + write_be16(out + 42, crc16_ccitt_false(out, 42)); + if (frame->payload_len != 0) { + memcpy(out + LORA_TRUNK_V3_HEADER_LEN, frame->payload, + frame->payload_len); + } + *out_len = total_len; + return true; +} + +bool lora_trunk_v3_ack_matches( + const lora_trunk_frame_t *data, const lora_trunk_frame_t *ack) +{ + return data && ack && + data->version == LORA_TRUNK_V3_VERSION && + ack->version == LORA_TRUNK_V3_VERSION && + data->kind == LORA_TRUNK_KIND_DATA && + ack->kind == LORA_TRUNK_KIND_FRAGMENT_ACK && + (data->flags & LORA_TRUNK_V3_FLAG_ACK_REQUESTED) != 0 && + memcmp(ack->source, data->destination, + LORA_TRUNK_V3_NODE_ID_LEN) == 0 && + memcmp(ack->destination, data->source, + LORA_TRUNK_V3_NODE_ID_LEN) == 0 && + memcmp(ack->transfer_id, data->transfer_id, + LORA_TRUNK_V3_TRANSFER_ID_LEN) == 0 && + ack->fragment_index == data->fragment_index && + ack->fragment_total == data->fragment_total; +} + +void lora_trunk_make_transfer_id( + const uint8_t boot_epoch[8], uint32_t counter, + uint8_t out[LORA_TRUNK_V3_TRANSFER_ID_LEN]) +{ + memcpy(out, boot_epoch, 8); + out[8] = (uint8_t)(counter >> 24); + out[9] = (uint8_t)(counter >> 16); + out[10] = (uint8_t)(counter >> 8); + out[11] = (uint8_t)counter; +} + +void lora_trunk_neighbor_table_init(lora_trunk_neighbor_table_t *table) +{ + if (table) { + memset(table, 0, sizeof(*table)); + } +} + +lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( + lora_trunk_neighbor_table_t *table, + const uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN], + const uint8_t reachable_peer[LORA_TRUNK_V3_NODE_ID_LEN], + const uint8_t signing_key[LORA_TRUNK_SIGNING_KEY_LEN], + uint16_t capabilities, uint64_t now_ms) +{ + if (!table || !node_id || !reachable_peer || !signing_key || + all_zero(node_id, LORA_TRUNK_V3_NODE_ID_LEN) || + all_zero(reachable_peer, LORA_TRUNK_V3_NODE_ID_LEN) || + all_zero(signing_key, LORA_TRUNK_SIGNING_KEY_LEN)) { + return LORA_TRUNK_NEIGHBOR_COLLISION; + } + + lora_trunk_neighbor_t *free_entry = NULL; + lora_trunk_neighbor_t *oldest = NULL; + lora_trunk_neighbor_t *same_route = NULL; + bool collision = false; + for (size_t i = 0; i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { + lora_trunk_neighbor_t *entry = &table->entries[i]; + if (!entry->in_use) { + if (!free_entry) { + free_entry = entry; + } + continue; + } + if (!entry->quarantined && + (!oldest || entry->last_seen_ms < oldest->last_seen_ms)) { + oldest = entry; + } + if (memcmp(entry->node_id, node_id, + LORA_TRUNK_V3_NODE_ID_LEN) == 0 && + memcmp(entry->signing_key, signing_key, + LORA_TRUNK_SIGNING_KEY_LEN) != 0) { + entry->quarantined = true; + collision = true; + } + if (memcmp(entry->reachable_peer, reachable_peer, + LORA_TRUNK_V3_NODE_ID_LEN) == 0 && + memcmp(entry->signing_key, signing_key, + LORA_TRUNK_SIGNING_KEY_LEN) == 0) { + same_route = entry; + } + } + if (collision) { + return LORA_TRUNK_NEIGHBOR_COLLISION; + } + if (same_route) { + if (same_route->quarantined) { + return LORA_TRUNK_NEIGHBOR_COLLISION; + } + memcpy(same_route->node_id, node_id, LORA_TRUNK_V3_NODE_ID_LEN); + same_route->capabilities = capabilities; + same_route->last_seen_ms = now_ms; + return LORA_TRUNK_NEIGHBOR_REFRESHED; + } + + lora_trunk_neighbor_t *entry = free_entry ? free_entry : oldest; + if (!entry) { + return LORA_TRUNK_NEIGHBOR_COLLISION; + } + lora_trunk_neighbor_result_t result = + free_entry ? LORA_TRUNK_NEIGHBOR_LEARNED + : LORA_TRUNK_NEIGHBOR_EVICTED; + memset(entry, 0, sizeof(*entry)); + entry->in_use = true; + memcpy(entry->node_id, node_id, LORA_TRUNK_V3_NODE_ID_LEN); + memcpy(entry->reachable_peer, reachable_peer, + LORA_TRUNK_V3_NODE_ID_LEN); + memcpy(entry->signing_key, signing_key, LORA_TRUNK_SIGNING_KEY_LEN); + entry->capabilities = capabilities; + entry->last_seen_ms = now_ms; + return result; +} + +bool lora_trunk_neighbor_route( + const lora_trunk_neighbor_table_t *table, + const uint8_t reachable_peer[LORA_TRUNK_V3_NODE_ID_LEN], + uint64_t now_ms, uint64_t max_age_ms, + uint8_t out_node_id[LORA_TRUNK_V3_NODE_ID_LEN], + uint16_t *out_capabilities) +{ + if (!table || !reachable_peer || !out_node_id) { + return false; + } + const lora_trunk_neighbor_t *freshest = NULL; + for (size_t i = 0; i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { + const lora_trunk_neighbor_t *entry = &table->entries[i]; + if (!entry->in_use || entry->quarantined || + memcmp(entry->reachable_peer, reachable_peer, + LORA_TRUNK_V3_NODE_ID_LEN) != 0 || + now_ms < entry->last_seen_ms || + now_ms - entry->last_seen_ms > max_age_ms) { + continue; + } + if (!freshest || entry->last_seen_ms > freshest->last_seen_ms) { + freshest = entry; + } + } + if (!freshest) { + return false; + } + memcpy(out_node_id, freshest->node_id, LORA_TRUNK_V3_NODE_ID_LEN); + if (out_capabilities) { + *out_capabilities = freshest->capabilities; + } + return true; +} + +size_t lora_trunk_neighbor_count(const lora_trunk_neighbor_table_t *table) +{ + if (!table) { + return 0; + } + size_t count = 0; + for (size_t i = 0; i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { + count += table->entries[i].in_use ? 1u : 0u; + } + return count; +} diff --git a/main/lora_trunk_protocol.h b/main/lora_trunk_protocol.h new file mode 100644 index 0000000..d3748e9 --- /dev/null +++ b/main/lora_trunk_protocol.h @@ -0,0 +1,130 @@ +#ifndef LORA_TRUNK_PROTOCOL_H +#define LORA_TRUNK_PROTOCOL_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LORA_TRUNK_MAGIC0 0xB7 +#define LORA_TRUNK_MAGIC1 0x1E + +#define LORA_TRUNK_V2_VERSION 2 +#define LORA_TRUNK_V2_HEADER_LEN 16 + +#define LORA_TRUNK_V3_VERSION 3 +#define LORA_TRUNK_V3_HEADER_LEN 44 +#define LORA_TRUNK_V3_NODE_ID_LEN 8 +#define LORA_TRUNK_V3_TRANSFER_ID_LEN 12 +#define LORA_TRUNK_V3_MAX_FRAME_LEN 255 +#define LORA_TRUNK_V3_MAX_PAYLOAD \ + (LORA_TRUNK_V3_MAX_FRAME_LEN - LORA_TRUNK_V3_HEADER_LEN) +#define LORA_TRUNK_MAX_FRAGMENTS 5 + +#define LORA_TRUNK_V3_CAP_PROTOCOL 0x0001u + +typedef enum { + LORA_TRUNK_KIND_DATA = 1, + LORA_TRUNK_KIND_FRAGMENT_ACK = 2, +} lora_trunk_kind_t; + +typedef enum { + LORA_TRUNK_V3_FLAG_ACK_REQUESTED = 0x01, + LORA_TRUNK_V3_FLAG_BROADCAST = 0x02, +} lora_trunk_v3_flag_t; + +typedef enum { + LORA_TRUNK_PARSE_OK = 0, + LORA_TRUNK_PARSE_FOREIGN, + LORA_TRUNK_PARSE_UNSUPPORTED_VERSION, + LORA_TRUNK_PARSE_MALFORMED, +} lora_trunk_parse_result_t; + +typedef struct { + uint8_t version; + lora_trunk_kind_t kind; + uint8_t flags; + uint16_t capabilities; + uint8_t hop_limit; + uint8_t hop_count; + uint8_t source[LORA_TRUNK_V3_NODE_ID_LEN]; + uint8_t destination[LORA_TRUNK_V3_NODE_ID_LEN]; + uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN]; + uint8_t fragment_index; + uint8_t fragment_total; + const uint8_t *payload; + uint16_t payload_len; +} lora_trunk_frame_t; + +/* Parses v2 and v3 without allocating or retaining pointers beyond the input + * frame. Only LORA_TRUNK_PARSE_OK may be used to mutate protocol state. */ +lora_trunk_parse_result_t lora_trunk_parse( + const uint8_t *data, size_t len, lora_trunk_frame_t *out); + +/* Encodes a validated v3 frame and its header CRC. */ +bool lora_trunk_v3_encode( + const lora_trunk_frame_t *frame, uint8_t *out, size_t capacity, + size_t *out_len); + +/* Exact v3 fragment acknowledgement predicate. */ +bool lora_trunk_v3_ack_matches( + const lora_trunk_frame_t *data, const lora_trunk_frame_t *ack); + +/* 96-bit transfer ID: unpredictable 64-bit boot epoch plus BE counter. */ +void lora_trunk_make_transfer_id( + const uint8_t boot_epoch[8], uint32_t counter, + uint8_t out[LORA_TRUNK_V3_TRANSFER_ID_LEN]); + +#define LORA_TRUNK_NEIGHBOR_CAPACITY 12 +#define LORA_TRUNK_SIGNING_KEY_LEN 32 + +typedef struct { + bool in_use; + bool quarantined; + uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN]; + uint8_t reachable_peer[LORA_TRUNK_V3_NODE_ID_LEN]; + uint8_t signing_key[LORA_TRUNK_SIGNING_KEY_LEN]; + uint16_t capabilities; + uint64_t last_seen_ms; +} lora_trunk_neighbor_t; + +typedef struct { + lora_trunk_neighbor_t entries[LORA_TRUNK_NEIGHBOR_CAPACITY]; +} lora_trunk_neighbor_table_t; + +typedef enum { + LORA_TRUNK_NEIGHBOR_LEARNED = 0, + LORA_TRUNK_NEIGHBOR_REFRESHED, + LORA_TRUNK_NEIGHBOR_EVICTED, + LORA_TRUNK_NEIGHBOR_COLLISION, +} lora_trunk_neighbor_result_t; + +void lora_trunk_neighbor_table_init(lora_trunk_neighbor_table_t *table); + +/* Caller supplies identity data from a successfully verified signed announce. + * A node ID observed with a different signing identity is quarantined. */ +lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( + lora_trunk_neighbor_table_t *table, + const uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN], + const uint8_t reachable_peer[LORA_TRUNK_V3_NODE_ID_LEN], + const uint8_t signing_key[LORA_TRUNK_SIGNING_KEY_LEN], + uint16_t capabilities, uint64_t now_ms); + +/* Returns the freshest non-expired, non-quarantined route for a BitChat peer. */ +bool lora_trunk_neighbor_route( + const lora_trunk_neighbor_table_t *table, + const uint8_t reachable_peer[LORA_TRUNK_V3_NODE_ID_LEN], + uint64_t now_ms, uint64_t max_age_ms, + uint8_t out_node_id[LORA_TRUNK_V3_NODE_ID_LEN], + uint16_t *out_capabilities); + +size_t lora_trunk_neighbor_count(const lora_trunk_neighbor_table_t *table); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/main/noise_handshake.c b/main/noise_handshake.c index 4ce8887..18a2ca5 100644 --- a/main/noise_handshake.c +++ b/main/noise_handshake.c @@ -1855,6 +1855,27 @@ bool noise_verify_packet_signature(const bitchat_packet_t *packet, const uint8_t (unsigned char *)packet->signature) == 0; } +bool noise_verify_announce_identity( + const bitchat_packet_t *packet, uint8_t out_sign_key[32]) +{ + if (!packet || !out_sign_key || packet->type != BITCHAT_MSG_ANNOUNCE || + !packet->has_signature) { + return false; + } + noise_identity_t identity = {0}; + if (!parse_announce_tlv(packet->payload, packet->payload_len, &identity)) { + return false; + } + uint8_t hash[32]; + bitle_sha256(identity.noise_key, sizeof(identity.noise_key), hash); + if (memcmp(hash, packet->sender_id, sizeof(packet->sender_id)) != 0 || + !noise_verify_packet_signature(packet, identity.sign_key)) { + return false; + } + memcpy(out_sign_key, identity.sign_key, sizeof(identity.sign_key)); + return true; +} + void noise_poll(void) { /* Session maintenance runs on the worker task's receive timeout; nothing diff --git a/main/noise_handshake.h b/main/noise_handshake.h index 9e47b01..9b00293 100644 --- a/main/noise_handshake.h +++ b/main/noise_handshake.h @@ -115,6 +115,12 @@ bool noise_get_peer_identity(uint16_t conn_handle, uint8_t noise_key[32], uint8_ /* Verifies an inbound packet's Ed25519 signature against a signing key * (rebuilds the padded canonical bytes exactly as the phones do). */ bool noise_verify_packet_signature(const bitchat_packet_t *packet, const uint8_t sign_key[32]); + +/* Strictly verifies a current ANNOUNCE for LoRa neighbor learning: the sender + * must derive from the announced Noise key and its Ed25519 packet signature + * must verify. Copies the authenticated signing key on success. */ +bool noise_verify_announce_identity( + const bitchat_packet_t *packet, uint8_t out_sign_key[32]); void noise_poll(void); esp_err_t bitchat_noise_init(void); diff --git a/tests/test_lora_trunk_protocol.c b/tests/test_lora_trunk_protocol.c new file mode 100644 index 0000000..3d87e1e --- /dev/null +++ b/tests/test_lora_trunk_protocol.c @@ -0,0 +1,269 @@ +#include "lora_trunk_protocol.h" + +#include +#include +#include + +static const uint8_t SOURCE_A[8] = {1, 2, 3, 4, 5, 6, 7, 8}; +static const uint8_t SOURCE_B[8] = {8, 7, 6, 5, 4, 3, 2, 1}; +static const uint8_t SOURCE_C[8] = {9, 7, 5, 3, 1, 2, 4, 6}; +static const uint8_t BOOT_A[8] = {0x10, 0x20, 0x30, 0x40, + 0x50, 0x60, 0x70, 0x80}; + +static lora_trunk_frame_t make_data( + const uint8_t *source, const uint8_t *destination, bool broadcast) +{ + static const uint8_t payload[] = {0xAA, 0xBB, 0xCC}; + lora_trunk_frame_t frame = { + .version = LORA_TRUNK_V3_VERSION, + .kind = LORA_TRUNK_KIND_DATA, + .flags = broadcast ? LORA_TRUNK_V3_FLAG_BROADCAST + : LORA_TRUNK_V3_FLAG_ACK_REQUESTED, + .capabilities = LORA_TRUNK_V3_CAP_PROTOCOL, + .fragment_index = 1, + .fragment_total = 3, + .payload = payload, + .payload_len = sizeof(payload), + }; + memcpy(frame.source, source, 8); + if (destination) { + memcpy(frame.destination, destination, 8); + } + lora_trunk_make_transfer_id(BOOT_A, 17, frame.transfer_id); + return frame; +} + +static lora_trunk_frame_t make_ack(const lora_trunk_frame_t *data) +{ + lora_trunk_frame_t ack = { + .version = LORA_TRUNK_V3_VERSION, + .kind = LORA_TRUNK_KIND_FRAGMENT_ACK, + .capabilities = LORA_TRUNK_V3_CAP_PROTOCOL, + .fragment_index = data->fragment_index, + .fragment_total = data->fragment_total, + }; + memcpy(ack.source, data->destination, 8); + memcpy(ack.destination, data->source, 8); + memcpy(ack.transfer_id, data->transfer_id, 12); + return ack; +} + +static size_t encode( + const lora_trunk_frame_t *frame, uint8_t out[255]) +{ + size_t len = 0; + assert(lora_trunk_v3_encode(frame, out, 255, &len)); + return len; +} + +static void test_v3_round_trip_and_malformed(void) +{ + lora_trunk_frame_t data = make_data(SOURCE_A, SOURCE_B, false); + uint8_t wire[255]; + size_t len = encode(&data, wire); + lora_trunk_frame_t parsed; + assert(lora_trunk_parse(wire, len, &parsed) == LORA_TRUNK_PARSE_OK); + assert(parsed.version == 3); + assert(parsed.kind == LORA_TRUNK_KIND_DATA); + assert(parsed.payload_len == data.payload_len); + assert(memcmp(parsed.source, SOURCE_A, 8) == 0); + assert(memcmp(parsed.destination, SOURCE_B, 8) == 0); + assert(memcmp(parsed.transfer_id, data.transfer_id, 12) == 0); + + for (size_t i = 0; i < LORA_TRUNK_V3_HEADER_LEN; ++i) { + uint8_t saved = wire[i]; + wire[i] ^= 0x5A; + assert(lora_trunk_parse(wire, len, &parsed) != LORA_TRUNK_PARSE_OK); + wire[i] = saved; + } + + wire[4] = LORA_TRUNK_V3_FLAG_BROADCAST; + /* Repairing CRC cannot make a broadcast with nonzero destination valid. */ + lora_trunk_frame_t invalid = data; + invalid.flags = LORA_TRUNK_V3_FLAG_BROADCAST; + assert(!lora_trunk_v3_encode(&invalid, wire, sizeof(wire), &len)); + + invalid = data; + invalid.fragment_total = 0; + assert(!lora_trunk_v3_encode(&invalid, wire, sizeof(wire), &len)); + invalid = data; + memset(invalid.transfer_id, 0, sizeof(invalid.transfer_id)); + assert(!lora_trunk_v3_encode(&invalid, wire, sizeof(wire), &len)); +} + +static void test_exact_ack_matching(void) +{ + lora_trunk_frame_t data = make_data(SOURCE_A, SOURCE_B, false); + lora_trunk_frame_t ack = make_ack(&data); + assert(lora_trunk_v3_ack_matches(&data, &ack)); + + memcpy(ack.source, SOURCE_C, 8); + assert(!lora_trunk_v3_ack_matches(&data, &ack)); + ack = make_ack(&data); + ack.destination[0] ^= 1; + assert(!lora_trunk_v3_ack_matches(&data, &ack)); + ack = make_ack(&data); + ack.transfer_id[11] ^= 1; + assert(!lora_trunk_v3_ack_matches(&data, &ack)); + ack = make_ack(&data); + ack.fragment_index ^= 1; + assert(!lora_trunk_v3_ack_matches(&data, &ack)); + ack = make_ack(&data); + ack.fragment_total ^= 1; + assert(!lora_trunk_v3_ack_matches(&data, &ack)); +} + +static void test_two_senders_and_broadcast(void) +{ + lora_trunk_frame_t a = make_data(SOURCE_A, SOURCE_C, false); + lora_trunk_frame_t b = make_data(SOURCE_B, SOURCE_C, false); + assert(memcmp(a.transfer_id, b.transfer_id, 12) == 0); + assert(memcmp(a.source, b.source, 8) != 0); + + lora_trunk_frame_t broadcast = make_data(SOURCE_A, NULL, true); + uint8_t wire[255]; + size_t len = encode(&broadcast, wire); + lora_trunk_frame_t parsed; + assert(lora_trunk_parse(wire, len, &parsed) == LORA_TRUNK_PARSE_OK); + assert((parsed.flags & LORA_TRUNK_V3_FLAG_ACK_REQUESTED) == 0); + assert((parsed.flags & LORA_TRUNK_V3_FLAG_BROADCAST) != 0); + + for (unsigned receiver = 0; receiver < 4; ++receiver) { + (void)receiver; + assert((parsed.flags & LORA_TRUNK_V3_FLAG_ACK_REQUESTED) == 0); + } +} + +static void test_v2_policy(void) +{ + uint8_t data[17] = { + 0xB7, 0x1E, 0x02, 0x02, + 1, 2, 3, 4, 0, 0, 0, 0, + 0, 1, 0, 1, 0xAA, + }; + lora_trunk_frame_t parsed; + assert(lora_trunk_parse(data, sizeof(data), &parsed) == + LORA_TRUNK_PARSE_OK); + assert(parsed.version == LORA_TRUNK_V2_VERSION); + assert(parsed.kind == LORA_TRUNK_KIND_DATA); + + uint8_t ack[16] = { + 0xB7, 0x1E, 0x02, 0x01, + 1, 2, 3, 4, 5, 6, 7, 8, + 0, 1, 0, 1, + }; + assert(lora_trunk_parse(ack, sizeof(ack), &parsed) == + LORA_TRUNK_PARSE_OK); + ack[3] = 0x03; + assert(lora_trunk_parse(ack, sizeof(ack), &parsed) == + LORA_TRUNK_PARSE_MALFORMED); + ack[3] = 0x01; + ack[15] = 0; + assert(lora_trunk_parse(ack, sizeof(ack), &parsed) == + LORA_TRUNK_PARSE_MALFORMED); +} + +static void test_neighbor_routes_and_collision(void) +{ + lora_trunk_neighbor_table_t table; + lora_trunk_neighbor_table_init(&table); + uint8_t key_a[32] = {1}; + uint8_t key_b[32] = {2}; + uint8_t route[8]; + uint16_t capabilities = 0; + + assert(lora_trunk_neighbor_learn_authenticated( + &table, SOURCE_A, SOURCE_A, key_a, + LORA_TRUNK_V3_CAP_PROTOCOL, 1000) == + LORA_TRUNK_NEIGHBOR_LEARNED); + assert(lora_trunk_neighbor_route( + &table, SOURCE_A, 1500, 1000, route, &capabilities)); + assert(memcmp(route, SOURCE_A, 8) == 0); + assert(capabilities == LORA_TRUNK_V3_CAP_PROTOCOL); + assert(!lora_trunk_neighbor_route( + &table, SOURCE_A, 2501, 1000, route, NULL)); + + assert(lora_trunk_neighbor_learn_authenticated( + &table, SOURCE_A, SOURCE_A, key_b, + LORA_TRUNK_V3_CAP_PROTOCOL, 2000) == + LORA_TRUNK_NEIGHBOR_COLLISION); + assert(!lora_trunk_neighbor_route( + &table, SOURCE_A, 2000, 1000, route, NULL)); + + lora_trunk_neighbor_table_init(&table); + for (unsigned i = 0; i < LORA_TRUNK_NEIGHBOR_CAPACITY + 1; ++i) { + uint8_t node[8] = {0}; + uint8_t key[32] = {0}; + node[0] = (uint8_t)(i + 1); + key[0] = (uint8_t)(i + 1); + lora_trunk_neighbor_result_t result = + lora_trunk_neighbor_learn_authenticated( + &table, node, node, key, LORA_TRUNK_V3_CAP_PROTOCOL, i); + assert(i < LORA_TRUNK_NEIGHBOR_CAPACITY + ? result == LORA_TRUNK_NEIGHBOR_LEARNED + : result == LORA_TRUNK_NEIGHBOR_EVICTED); + } + assert(lora_trunk_neighbor_count(&table) == + LORA_TRUNK_NEIGHBOR_CAPACITY); + + lora_trunk_neighbor_table_init(&table); + for (unsigned i = 0; i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { + uint8_t node[8] = {0}; + uint8_t key[32] = {0}; + node[0] = (uint8_t)(i + 1); + key[0] = (uint8_t)(i + 1); + assert(lora_trunk_neighbor_learn_authenticated( + &table, node, node, key, + LORA_TRUNK_V3_CAP_PROTOCOL, i) == + LORA_TRUNK_NEIGHBOR_LEARNED); + table.entries[i].quarantined = true; + } + uint8_t new_node[8] = {0xFE}; + uint8_t new_key[32] = {0xFE}; + assert(lora_trunk_neighbor_learn_authenticated( + &table, new_node, new_node, new_key, + LORA_TRUNK_V3_CAP_PROTOCOL, 100) == + LORA_TRUNK_NEIGHBOR_COLLISION); +} + +static uint32_t fuzz_state = 0xC001D00Du; + +static uint8_t fuzz_byte(void) +{ + fuzz_state = fuzz_state * 1664525u + 1013904223u; + return (uint8_t)(fuzz_state >> 24); +} + +static void test_deterministic_parser_fuzz(void) +{ + uint8_t wire[255]; + lora_trunk_frame_t parsed; + for (unsigned sample = 0; sample < 100000; ++sample) { + size_t len = fuzz_byte(); + for (size_t i = 0; i < len; ++i) { + wire[i] = fuzz_byte(); + } + if ((sample & 1u) == 0 && len >= 3) { + wire[0] = LORA_TRUNK_MAGIC0; + wire[1] = LORA_TRUNK_MAGIC1; + wire[2] = (sample & 2u) != 0 ? LORA_TRUNK_V2_VERSION + : LORA_TRUNK_V3_VERSION; + } + lora_trunk_parse_result_t result = + lora_trunk_parse(wire, len, &parsed); + assert(result >= LORA_TRUNK_PARSE_OK && + result <= LORA_TRUNK_PARSE_MALFORMED); + } +} + +int main(void) +{ + test_v3_round_trip_and_malformed(); + test_exact_ack_matching(); + test_two_senders_and_broadcast(); + test_v2_policy(); + test_neighbor_routes_and_collision(); + test_deterministic_parser_fuzz(); + puts("lora_trunk_protocol: ok"); + return 0; +} diff --git a/tools/lora_hardware_smoke.py b/tools/lora_hardware_smoke.py index dae2371..1d07846 100755 --- a/tools/lora_hardware_smoke.py +++ b/tools/lora_hardware_smoke.py @@ -14,9 +14,14 @@ import serial -PACKET_RE = re.compile(r"trunk RX packet len=(\d+).*frags=(\d+)") -RAW_RE = re.compile(r"trunk raw RX len=(\d+) rssi=(-?\d+) snr=(-?\d+)") +PACKET_RE = re.compile(r"trunk(?: v\d+)? RX packet len=(\d+).*frags=(\d+)") +RAW_RE = re.compile( + r"trunk(?: v\d+)? raw RX len=(\d+) rssi=(-?\d+) snr=(-?\d+)" +) TX_RE = re.compile(r"trunk (?:TX|accepted) .*len=(\d+) frags=(\d+)") +SMOKE_RE = re.compile( + r"diagnostic smoke enqueue len=(\d+) expected_frags=(\d+) addressed=(\d+)" +) PROFILE_RE = re.compile(r"trunk up: region=(\w+).* SF(\d+).*preamble=(\d+)") DIAG_RE = re.compile( r"tmo=(\d+).*radio_err=(\d+).*busy_tmo=(\d+).*" @@ -28,6 +33,10 @@ r"defer=(\d+)/(\d+)/(\d+)/(\d+) " r"policy=(\d+) release=(\d+) cancel=(\d+)" ) +PROTOCOL_DIAG_RE = re.compile( + r"malformed=(\d+) unsupported=(\d+) ack_reject=(\d+) " + r"neighbors=(\d+) collision=(\d+)" +) def reader( @@ -103,6 +112,9 @@ def main() -> int: summary[label]["rx_fragments"][match.group(2)] += 1 if match := TX_RE.search(line): summary[label]["tx_fragments"][match.group(2)] += 1 + if match := SMOKE_RE.search(line): + mode = "addressed" if match.group(3) == "1" else "broadcast" + summary[label]["events"][f"smoke_{mode}"] += 1 if match := RAW_RE.search(line): summary[label]["raw"].append( { @@ -147,6 +159,24 @@ def main() -> int: summary[label]["diagnostics"][-1].update(scheduler_diag) else: summary[label]["diagnostics"].append(scheduler_diag) + if match := PROTOCOL_DIAG_RE.search(line): + protocol_diag = { + "malformed_frames": int(match.group(1)), + "unsupported_versions": int(match.group(2)), + "rejected_acks": int(match.group(3)), + "neighbor_count": int(match.group(4)), + "neighbor_collisions": int(match.group(5)), + } + if summary[label]["diagnostics"]: + summary[label]["diagnostics"][-1].update(protocol_diag) + else: + summary[label]["diagnostics"].append(protocol_diag) + if "authenticated neighbor" in line: + summary[label]["events"]["authenticated_neighbor"] += 1 + if "v3 ack TX" in line: + summary[label]["events"]["v3_ack_tx"] += 1 + if "v3 ack RX" in line: + summary[label]["events"]["v3_ack_rx"] += 1 if "TX watchdog expired" in line: summary[label]["events"]["tx_watchdog_expired"] += 1 if "radio recovered on attempt" in line: diff --git a/tools/run_lora_host_tests.sh b/tools/run_lora_host_tests.sh index ab72479..9d6f81f 100755 --- a/tools/run_lora_host_tests.sh +++ b/tools/run_lora_host_tests.sh @@ -5,7 +5,8 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" airtime_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-airtime.XXXXXX")" region_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-region.XXXXXX")" scheduler_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-scheduler.XXXXXX")" -trap 'rm -f "$airtime_bin" "$region_bin" "$scheduler_bin"' EXIT +trunk_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-trunk.XXXXXX")" +trap 'rm -f "$airtime_bin" "$region_bin" "$scheduler_bin" "$trunk_bin"' EXIT cc -std=c11 -Wall -Wextra -Werror \ -I"$repo_root/main" \ @@ -29,5 +30,12 @@ cc -std=c11 -Wall -Wextra -Werror \ -o "$scheduler_bin" "$scheduler_bin" +cc -std=c11 -Wall -Wextra -Werror \ + -I"$repo_root/main" \ + "$repo_root/tests/test_lora_trunk_protocol.c" \ + "$repo_root/main/lora_trunk_protocol.c" \ + -o "$trunk_bin" +"$trunk_bin" + cd "$repo_root" python3 -m unittest discover -s tests -p 'test_lora_*.py' -v From a3198dffc3619c62034c7688805649ed4fc5980f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:56:50 +0200 Subject: [PATCH 12/19] docs(lora): record milestone 3 checkpoint --- docs/LoRa-reliability-implementation-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index 63be1f4..b59c81b 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -62,7 +62,7 @@ The implementation is complete only when all of the following are true: | 0. Baseline harness and observability | Complete | `d130a3a` | Host baseline, both firmware targets, and two-board SF10 smoke passed | | 1. Airtime-safe SX1262 operation | Complete | `028511e` | Host/profile tests, both firmware targets, and SF10-SF12 two-board radio tests passed | | 2. Atomic packet scheduling and backpressure | Complete | `fe9e95e` | Concurrent saturation, both firmware targets, and two-board SF10 smoke passed | -| 3. Addressed trunk protocol and migration | Complete | Pending checkpoint | v2/v3 host protocol suite, both firmware targets, and addressed two-board v3 smoke passed | +| 3. Addressed trunk protocol and migration | Complete | `3ee5843` | v2/v3 host protocol suite, both firmware targets, and addressed two-board v3 smoke passed | | 4. Packet-level reliability and reassembly | Pending | — | — | | 5. Discovery and shared-channel behavior | Pending | — | — | | 6. LoRa multi-hop forwarding | Pending | — | — | @@ -347,7 +347,7 @@ correct on a shared multi-node channel. loss policy stays in milestone 5. - Both test boards were restored to the normal SF10 image with diagnostic smoke disabled after the capture. -- Commit: Pending checkpoint +- Commit: `3ee5843` - Suggested commit subject: `feat(lora): introduce addressed trunk protocol v3` ## Milestone 4: Packet-level reliability and reassembly From 33cfde3463059182a1e216af31ebd1bd2823cb44 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:39:09 +0200 Subject: [PATCH 13/19] feat(lora): add packet-level selective repeat ARQ --- docs/LoRa-reliability-implementation-plan.md | 56 +- docs/LoRa-testing.md | 31 +- docs/LoRa-trunk-v3.md | 60 +- main/CMakeLists.txt | 1 + main/bitle_lora.c | 800 +++++++++++++++---- main/bitle_lora.h | 12 +- main/lora_packet_reliability.c | 199 +++++ main/lora_packet_reliability.h | 86 ++ main/lora_trunk_protocol.c | 56 +- main/lora_trunk_protocol.h | 19 +- tests/test_lora_packet_reliability.c | 150 ++++ tests/test_lora_packet_reliability.py | 97 +++ tests/test_lora_trunk_protocol.c | 68 +- tools/lora_hardware_smoke.py | 44 +- tools/lora_reliability_sim.py | 167 ++++ tools/run_lora_host_tests.sh | 11 +- 16 files changed, 1604 insertions(+), 253 deletions(-) create mode 100644 main/lora_packet_reliability.c create mode 100644 main/lora_packet_reliability.h create mode 100644 tests/test_lora_packet_reliability.c create mode 100644 tests/test_lora_packet_reliability.py diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index b59c81b..65eefee 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -63,7 +63,7 @@ The implementation is complete only when all of the following are true: | 1. Airtime-safe SX1262 operation | Complete | `028511e` | Host/profile tests, both firmware targets, and SF10-SF12 two-board radio tests passed | | 2. Atomic packet scheduling and backpressure | Complete | `fe9e95e` | Concurrent saturation, both firmware targets, and two-board SF10 smoke passed | | 3. Addressed trunk protocol and migration | Complete | `3ee5843` | v2/v3 host protocol suite, both firmware targets, and addressed two-board v3 smoke passed | -| 4. Packet-level reliability and reassembly | Pending | — | — | +| 4. Packet-level reliability and reassembly | Complete | Pending checkpoint | 99.35% sender completion at 30% data/feedback loss; five-of-five two-board transfer gate | | 5. Discovery and shared-channel behavior | Pending | — | — | | 6. LoRa multi-hop forwarding | Pending | — | — | | 7. Link adaptation and PHY hardening | Pending | — | — | @@ -359,27 +359,27 @@ reliability that remains correct under loss, duplicates, and reordering. ### Checklist -- [ ] Implement packet-level selective repeat with a received-fragment bitmap. -- [ ] Allow more than one useful fragment per round trip while keeping memory +- [x] Implement packet-level selective repeat with a received-fragment bitmap. +- [x] Allow more than one useful fragment per round trip while keeping memory and airtime bounded. -- [ ] Send bitmap ACKs only from the addressed destination. -- [ ] Define a final COMPLETE acknowledgement that is sent only after full +- [x] Send bitmap ACKs only from the addressed destination. +- [x] Define a final COMPLETE acknowledgement that is sent only after full reassembly and length validation. -- [ ] Re-ACK duplicate fragments and duplicate completed transfers without +- [x] Re-ACK duplicate fragments and duplicate completed transfers without delivering the BitChat packet twice. -- [ ] Derive retry and reassembly deadlines from SF, bandwidth, coding rate, +- [x] Derive retry and reassembly deadlines from SF, bandwidth, coding rate, fragment count, retry budget, and contention allowance. -- [ ] Refresh the progress deadline when a new fragment arrives, while enforcing +- [x] Refresh the progress deadline when a new fragment arrives, while enforcing an absolute maximum transfer lifetime. -- [ ] Retain completed-transfer state long enough to answer a sender that missed +- [x] Retain completed-transfer state long enough to answer a sender that missed the final COMPLETE acknowledgement. -- [ ] Add explicit abort handling for retry exhaustion, invalid total length, +- [x] Add explicit abort handling for retry exhaustion, invalid total length, resource pressure, and radio reset. -- [ ] Ensure a dropped transfer releases all packet-pool and reassembly +- [x] Ensure a dropped transfer releases all packet-pool and reassembly resources. -- [ ] Test loss, ACK loss, duplication, reordering, delayed ACKs, restart, and +- [x] Test loss, ACK loss, duplication, reordering, delayed ACKs, restart, and sequence wrap. -- [ ] Test 1- through 5-fragment packets at every supported SF. +- [x] Test 1- through 5-fragment packets at every supported SF. ### Success criteria @@ -393,9 +393,35 @@ reliability that remains correct under loss, duplicates, and reordering. ### Milestone record -- Status: Pending +- Status: Complete - Evidence: -- Commit: + - The deterministic eight-round simulation completed 1,987 of 2,000 + 520-byte sender transfers (99.35%) with independently injected 30% data + and feedback loss. Receivers reconstructed 1,999 transfers, delivered + every transfer at most once, and never accepted corrupt or incomplete + data. + - Portable cases cover cumulative bitmap selection, lost COMPLETE, + duplicate and reordered frames, delayed feedback, incomplete receiver + restart, boot-epoch/counter wrap, exact encoded length validation, and a + sustained incomplete-transfer flood. + - Airtime-derived feedback, progress, absolute, and retention deadlines pass + for one through five fragments at SF7 through SF12. Reassembly remains + bounded to two active slots plus six completed-transfer records, reserves + addressed admission under broadcast pressure, and never evicts an active + transfer. + - Normal ESP-IDF 6.0 firmware builds pass for the Heltec V3 ESP32-S3 and + BLE-only ESP32-C3 targets. + - The two-board SF10 gate admitted five addressed probes spanning one through + five fragments. The receiver emitted all five exact COMPLETE masks and the + sender recorded five terminal completions, with no protocol abort, + invalid reassembly, resource abort, radio-command error, BUSY timeout, + watchdog recovery, or duplicate delivery. + - Temporary channel occupancy no longer rejects an addressed transfer after + a fixed CAD count; the radio returns to RX during backoff and retries + within the transfer's bounded absolute deadline. + - Both test boards were restored to the normal SF10 image with diagnostic + smoke disabled after the capture. +- Commit: Pending checkpoint - Suggested commit subject: `feat(lora): add packet-level selective repeat ARQ` ## Milestone 5: Discovery and shared-channel behavior diff --git a/docs/LoRa-testing.md b/docs/LoRa-testing.md index 82b852b..e49b6e6 100644 --- a/docs/LoRa-testing.md +++ b/docs/LoRa-testing.md @@ -22,6 +22,14 @@ same-transfer isolation between senders, zero-ACK broadcast delivery to four nodes, neighbor collision quarantine, expiry, bounded eviction, and 100,000 deterministic fuzz frames. +The packet-level reliability suite sends 520-byte transfers through 30% +independent data and feedback loss and requires at least 95% completion within +the eight-round bound. Additional cases cover cumulative bitmap selection, +lost COMPLETE controls, duplicate and reordered fragments, delayed feedback, +an incomplete receiver restart, transfer-counter wrap, exact BitChat length +validation, and profile-derived deadlines for one through five fragments at +SF7 through SF12. + The legacy-v2 simulator cases intentionally assert the original protocol failures: fixed reassembly expiry under retries, partial frame enqueue, acceptance of an ACK from the wrong source, and broadcast ACK implosion. The @@ -64,8 +72,8 @@ The production default keeps `CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE` disabled. For a controlled test, enable it on exactly one Heltec V3 build. That node waits until a signed direct announcement establishes an authenticated v3 neighbor, then transmits one addressed synthetic packet at each of 1, 2, 3, 4, -and 5 fragments, spaced 12 seconds apart. Keep the receiving node on the -normal build. +and 5 fragments. It retries fixture admission every three seconds and advances +only after a probe is accepted. Keep the receiving node on the normal build. Capture both serial streams concurrently: @@ -75,11 +83,12 @@ python tools/lora_hardware_smoke.py PORT_A PORT_B --seconds 90 --reset --quiet \ ``` The capture summarizes TX and completed RX counts by fragment count, addressed -diagnostic events, authenticated-neighbor learning, exact v3 ACK traffic, and -parser rejection counters. It also retains per-frame RSSI/SNR, the active -profile, watchdog/recovery events, and periodic radio diagnostics. Raw serial -output and JSON evidence may contain local device details, so keep it outside -the repository. +diagnostic events, authenticated-neighbor learning, bitmap/COMPLETE/ABORT +controls, completed sender transfers, duplicate suppression, and parser +rejection counters. It also retains per-frame RSSI/SNR, the active profile, +watchdog/recovery events, and periodic radio diagnostics. Raw serial output and +JSON evidence may contain local device details, so keep it outside the +repository. Repeat with `CONFIG_BITLE_LORA_DEFAULT_SF` set to 10, 11, and 12 on both boards. At each SF, verify that the receiver records 136-byte frames and both @@ -99,6 +108,9 @@ Runtime firmware emits a one-minute diagnostic summary containing: - TX attempts/timeouts, retries, ACK RX/TX/misses, and retry exhaustion; - current/high-water queue depth and queue-full events; - expired reassemblies and completed packets; +- cumulative bitmap and COMPLETE controls, duplicate fragments/transfers, + explicit transfer aborts, invalid reassemblies, and resource-pressure + rejects; - rejected persisted configurations; - low-level command/BUSY errors, radio recoveries/failures, and independent TX watchdog recoveries. @@ -108,3 +120,8 @@ with a 16-symbol preamble. Full 136-byte frames were received at every SF; all three runs reported zero SX1262 timeouts, command errors, and BUSY timeouts. Each injected run produced exactly one watchdog recovery with no recovery failure, then continued exchanging frames. + +Milestone 4's SF10 packet-reliability gate admitted and sender-confirmed all +five addressed probes. The receiver emitted matching COMPLETE masks for +one through five fragments with no abort, invalid-reassembly, resource, +radio-command, BUSY-timeout, watchdog-recovery, or duplicate-delivery event. diff --git a/docs/LoRa-trunk-v3.md b/docs/LoRa-trunk-v3.md index 0a8bc9d..ce6e859 100644 --- a/docs/LoRa-trunk-v3.md +++ b/docs/LoRa-trunk-v3.md @@ -24,7 +24,7 @@ existing short-frame RF behavior. |---:|---:|---|---| | 0 | 2 | magic | `B7 1E` | | 2 | 1 | version | `03` | -| 3 | 1 | kind | `01` data, `02` fragment ACK | +| 3 | 1 | kind | `01` data, `02` bitmap ACK, `03` COMPLETE, `04` ABORT | | 4 | 1 | flags | Integrity-covered flags defined below | | 5 | 1 | header length | `44` | | 6 | 2 | capabilities | Sender capabilities | @@ -34,7 +34,7 @@ existing short-frame RF behavior. | 12 | 8 | source | Full BitChat/Noise peer identifier | | 20 | 8 | destination | Full peer identifier; all zero means broadcast | | 28 | 12 | transfer ID | Reboot-safe identifier described below | -| 40 | 1 | fragment index | Zero based | +| 40 | 1 | fragment value | Data index, received bitmap, full bitmap, or abort reason | | 41 | 1 | fragment total | `1..5` in the initial implementation | | 42 | 2 | header CRC | CRC-16/CCITT-FALSE of bytes `0..41` | @@ -55,8 +55,11 @@ encoding makes accidental and malformed address modes detectable. Data frames require a nonzero source, nonzero transfer ID, a fragment total of at least one, an index below that total, and a payload length of at least one. -ACK frames require a nonzero source and destination, the flags and payload -length to be zero, and fragment state copied from the acknowledged data frame. +Control frames require a nonzero source and destination, zero flags, zero +payload, and a fragment total matching the transfer. Bitmap ACK values contain +only bits below `fragment total`. COMPLETE contains the full received bitmap. +ABORT uses the value byte for one of the defined reasons: retry exhaustion, +invalid total length, resource pressure, or radio reset. ## Node and transfer identity @@ -108,23 +111,54 @@ jitter so peers booted together do not repeatedly collide. Milestone 5 extends this minimum discovery safeguard with broader loss-adaptation, duplicate suppression, and cadence policy. -## Acknowledgements +## Packet-level reliability Only a node whose complete local ID equals an addressed data frame's destination may ACK it. Broadcast frames never request or produce immediate ACKs. Consequently four receivers of one broadcast produce zero ACK frames. -An ACK reverses the data frame's source and destination and copies the complete -transfer ID, fragment index, and fragment total. A sender accepts an ACK only -when all five values exactly match its in-flight fragment: - -- ACK source equals the selected data destination. -- ACK destination equals the local data source. +An addressed sender transmits every currently missing fragment before waiting +for feedback. A bitmap ACK reports the receiver's cumulative fragment bitmap, +so one round trip can advance multiple fragments. The next round selectively +retransmits only missing fragments. If every fragment is already acknowledged +but COMPLETE was lost, the final fragment is sent as a bounded completion +probe. A transfer uses at most eight rounds. + +COMPLETE is distinct from a full bitmap. The receiver sends COMPLETE only +after all fragments are present, their combined length fits the BitChat packet +limit, non-final fragment lengths are canonical, and the encoded BitChat +header's exact recipient/payload/signature length equals the reassembled +length. An incomplete or invalid packet is never reported complete. + +Every packet control reverses the data source and destination and copies the +complete transfer ID and fragment total. A sender accepts a bitmap ACK, +COMPLETE, or ABORT only when: + +- control source equals the selected data destination. +- control destination equals the local data source. - transfer ID matches all 96 bits. -- fragment index matches. - fragment total matches. -An otherwise valid ACK from a different node is counted and ignored. +An otherwise valid control from a different node is counted and ignored. + +The receiver re-ACKs duplicate fragments with its current bitmap. A bounded +completed-transfer cache retains the source, transfer ID, fragment total, and +expiry long enough to resend COMPLETE when the final control was lost. +Duplicate completed transfers do not reach the BitChat mesh twice. + +Reassembly uses two static transfer slots and six static completed-cache +entries. A new transfer is rejected with a resource-pressure ABORT rather than +evicting an active transfer. Broadcast reassembly may occupy at most one slot, +leaving one admission opportunity for addressed traffic. Each active slot has +both a progress deadline, +refreshed only by a new fragment, and an absolute lifetime that cannot be +extended. Feedback wait, progress timeout, absolute lifetime, and completed +retention are derived from the active SF, bandwidth, coding rate, preamble, +maximum frame airtime, fragment count, eight-round budget, and bounded +contention allowance. No supported SF relies on a fixed ten-second timeout. +Temporary channel occupancy does not discard an addressed transfer after a +fixed number of CAD results: the sender returns to RX during randomized +backoff and retries until COMPLETE, ABORT, or its absolute deadline. ## Version 2 migration and rollback diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index d14e1d9..8caf4e9 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -10,6 +10,7 @@ idf_component_register( "bitle_mesh.c" "bitle_lora.c" "lora_airtime.c" + "lora_packet_reliability.c" "lora_region.c" "lora_trunk_protocol.c" "lora_tx_scheduler.c" diff --git a/main/bitle_lora.c b/main/bitle_lora.c index 784842a..c64ca6b 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -16,6 +16,7 @@ #include "bitle_mesh.h" #include "bitle_stats.h" #include "lora_airtime.h" +#include "lora_packet_reliability.h" #include "lora_region.h" #include "lora_trunk_protocol.h" #include "lora_tx_scheduler.h" @@ -107,18 +108,13 @@ static const char *TAG = "bitle_lora"; #define TRUNK_CHUNK_TX 120 #define TRUNK_MAX_FRAGS ((BITCHAT_BLE_MAX_PACKET_SIZE + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX) -/* Stop-and-wait ARQ: each ack-requested frame is retransmitted until - * acked, ARQ_TRIES sends total. Announces are broadcast discovery and - * repeat periodically anyway, so they never request acks. Retries bypass - * the governor (bounded 3x; the admit debit paid for attempt one). */ -#define ARQ_TRIES 3 -#define ARQ_MARGIN_MS 1200 - /* Channel access: CAD (listen-before-talk) + random backoff before TX. */ #define CAD_RETRIES 5 #define CAD_BACKOFF_MIN 30 #define CAD_BACKOFF_SPAN 120 #define ACK_BURST_MAX 4 +#define RELIABILITY_CONTENTION_MS \ + (1200U + CAD_RETRIES * (CAD_BACKOFF_MIN + CAD_BACKOFF_SPAN)) typedef struct { uint16_t len; @@ -144,11 +140,12 @@ typedef struct { } tx_trunk_context_t; static tx_trunk_context_t s_tx_context[LORA_TX_PACKET_POOL_SIZE]; -/* Ack seen by rx_frame for the frame the ARQ machine is waiting on. - * rx_frame runs on the lora task itself, so plain statics suffice. */ -static bool s_ack_seen; -static bool s_expected_ack_valid; -static lora_trunk_frame_t s_expected_ack_data; +/* Packet-level feedback is consumed on the LoRa task. RX and TX state are + * therefore serialized without another lock. */ +static bool s_feedback_seen; +static bool s_expected_control_valid; +static lora_trunk_frame_t s_expected_control_data; +static lora_selective_tx_t s_selective_tx; static lora_trunk_neighbor_table_t s_neighbors; /* Modem params (for airtime), governor, and throttle state — shared @@ -186,6 +183,18 @@ static uint32_t trunk_airtime_ms(uint16_t payload_len) return lora_airtime_ms(¶ms, payload_len); } +static bool trunk_reliability_timing( + uint8_t fragment_total, lora_reliability_timing_t *out) +{ + return lora_reliability_timing( + trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN + TRUNK_CHUNK_TX), + trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN), + fragment_total, + LORA_SELECTIVE_REPEAT_ROUNDS, + RELIABILITY_CONTENTION_MS, + out); +} + static void diag_inc(uint32_t *counter) { taskENTER_CRITICAL(&s_gov_mux); @@ -307,9 +316,12 @@ static bool trunk_admit_locked(const uint8_t *data, uint16_t len) return true; } -/* Reassembly of one inbound packet per remote sender (2 slots). */ +/* Reassembly stays statically bounded. Progress and absolute deadlines are + * profile-derived per transfer; the completed cache answers a sender that + * missed COMPLETE without delivering the BitChat packet twice. */ typedef struct { bool in_use; + bool addressed; uint8_t version; uint8_t src[LORA_TRUNK_V3_NODE_ID_LEN]; uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN]; @@ -317,14 +329,29 @@ typedef struct { uint8_t total; uint8_t have_mask; uint64_t started_ms; + uint64_t progress_deadline_ms; + uint64_t absolute_deadline_ms; uint16_t part_len[TRUNK_MAX_FRAGS]; uint8_t part[TRUNK_MAX_FRAGS][TRUNK_CHUNK_RX_MAX]; } rx_slot_t; -#define RX_SLOTS 2 -#define RX_TIMEOUT_MS 10000ULL +#define RX_SLOTS 2 +#define COMPLETED_SLOTS 6 + +typedef struct { + bool in_use; + bool addressed; + uint8_t version; + uint8_t src[LORA_TRUNK_V3_NODE_ID_LEN]; + uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN]; + uint8_t total; + uint64_t expires_ms; +} completed_slot_t; static rx_slot_t s_rx_slots[RX_SLOTS]; +static completed_slot_t s_completed_slots[COMPLETED_SLOTS]; + +static void abort_rx_slot(rx_slot_t *slot, uint8_t reason); static void IRAM_ATTR dio1_isr(void *arg) { @@ -336,6 +363,10 @@ static void IRAM_ATTR dio1_isr(void *arg) static bool recover_radio(const char *reason) { ESP_LOGW(TAG, "radio recovery requested: %s", reason); + for (size_t i = 0; i < RX_SLOTS; ++i) { + abort_rx_slot( + &s_rx_slots[i], LORA_TRUNK_ABORT_RADIO_RESET); + } esp_err_t err = sx1262_recover(); if (err == ESP_OK) { return true; @@ -580,7 +611,7 @@ static void tx_scheduler_release(lora_tx_handle_t handle) lora_tx_scheduler_release(&s_tx_scheduler, handle); diag_sync_scheduler_locked(); taskEXIT_CRITICAL(&s_gov_mux); - s_expected_ack_valid = false; + s_expected_control_valid = false; } static bool tx_scheduler_has_queued(void) @@ -635,46 +666,113 @@ static bool build_tx_fragment(lora_tx_handle_t handle, uint8_t idx, } frame->len = (uint16_t)encoded_len; frame->want_ack = context->ack_requested; - s_expected_ack_valid = frame->want_ack; - if (s_expected_ack_valid) { - s_expected_ack_data = trunk; + s_expected_control_valid = frame->want_ack; + if (s_expected_control_valid) { + s_expected_control_data = trunk; } *out_total = total; return true; } -/* Acks bypass the data queue entirely. When both ends have ARQ frames in - * flight (any bidirectional exchange), an ack queued behind pending data would - * wait on the peer's ack, which is itself queued behind the peer's data — a - * mutual stall. Acks instead go into a small outbox the task transmits with - * priority and no CAD (the peer is listening the instant its own TX ends). */ -#define ACK_OUTBOX 4 +/* Packet controls bypass the data queue. Cumulative bitmaps for the same + * transfer coalesce, and terminal COMPLETE/ABORT controls replace a pending + * bitmap so the bounded outbox cannot fill with per-fragment acknowledgements. */ +#define ACK_OUTBOX 6 typedef struct { uint8_t version; uint8_t dst[LORA_TRUNK_V3_NODE_ID_LEN]; uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN]; - uint8_t idx; + lora_trunk_kind_t kind; + uint8_t value; uint8_t total; } ack_slot_t; static ack_slot_t s_ack_outbox[ACK_OUTBOX]; static int s_ack_head, s_ack_count; -static void queue_ack(const lora_trunk_frame_t *data) +static unsigned control_rank(lora_trunk_kind_t kind) +{ + switch (kind) { + case LORA_TRUNK_KIND_COMPLETE: + return 3; + case LORA_TRUNK_KIND_ABORT: + return 2; + case LORA_TRUNK_KIND_BITMAP_ACK: + return 1; + default: + return 0; + } +} + +static void queue_control_fields( + uint8_t version, + const uint8_t dst[LORA_TRUNK_V3_NODE_ID_LEN], + const uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN], + uint8_t total, lora_trunk_kind_t kind, uint8_t value) { - if (!data || s_ack_count >= ACK_OUTBOX) { - ESP_LOGW(TAG, "ack outbox full"); + if (!dst || !transfer_id) { + return; + } + if (version == LORA_TRUNK_V3_VERSION) { + for (int offset = 0; offset < s_ack_count; ++offset) { + ack_slot_t *queued = + &s_ack_outbox[(s_ack_head + offset) % ACK_OUTBOX]; + if (queued->version != version || queued->total != total || + memcmp(queued->dst, dst, sizeof(queued->dst)) != 0 || + memcmp(queued->transfer_id, transfer_id, + sizeof(queued->transfer_id)) != 0) { + continue; + } + if (kind == LORA_TRUNK_KIND_BITMAP_ACK && + queued->kind == LORA_TRUNK_KIND_BITMAP_ACK) { + queued->value |= value; + } else if (control_rank(kind) > + control_rank(queued->kind)) { + queued->kind = kind; + queued->value = value; + } + return; + } + } + if (s_ack_count >= ACK_OUTBOX) { + ESP_LOGW(TAG, "control outbox full"); return; } ack_slot_t *a = &s_ack_outbox[(s_ack_head + s_ack_count) % ACK_OUTBOX]; memset(a, 0, sizeof(*a)); - a->version = data->version; - memcpy(a->dst, data->source, sizeof(a->dst)); - memcpy(a->transfer_id, data->transfer_id, sizeof(a->transfer_id)); - a->idx = data->fragment_index; - a->total = data->fragment_total; + a->version = version; + memcpy(a->dst, dst, sizeof(a->dst)); + memcpy(a->transfer_id, transfer_id, sizeof(a->transfer_id)); + a->kind = kind; + a->value = value; + a->total = total; s_ack_count++; } +static void queue_ack(const lora_trunk_frame_t *data) +{ + if (!data) { + return; + } + if (data->version == LORA_TRUNK_V2_VERSION) { + queue_control_fields( + data->version, data->source, data->transfer_id, + data->fragment_total, LORA_TRUNK_KIND_BITMAP_ACK, + data->fragment_index); + } +} + +static void queue_v3_control( + const lora_trunk_frame_t *data, lora_trunk_kind_t kind, + uint8_t value) +{ + if (!data || data->version != LORA_TRUNK_V3_VERSION) { + return; + } + queue_control_fields( + data->version, data->source, data->transfer_id, + data->fragment_total, kind, value); +} + static bool transmit_ack_now(void) { if (s_ack_count == 0) { @@ -692,22 +790,23 @@ static bool transmit_ack_now(void) memcpy(f + 8, a->dst, 4); f[12] = a->transfer_id[10]; f[13] = a->transfer_id[11]; - f[14] = a->idx; + f[14] = a->value; f[15] = a->total; frame_len = LORA_TRUNK_V2_HEADER_LEN; } else { - lora_trunk_frame_t ack = { + lora_trunk_frame_t control = { .version = LORA_TRUNK_V3_VERSION, - .kind = LORA_TRUNK_KIND_FRAGMENT_ACK, + .kind = a->kind, .capabilities = LORA_TRUNK_V3_CAP_PROTOCOL, - .fragment_index = a->idx, + .fragment_index = a->value, .fragment_total = a->total, }; - memcpy(ack.source, s_node_id, sizeof(ack.source)); - memcpy(ack.destination, a->dst, sizeof(ack.destination)); - memcpy(ack.transfer_id, a->transfer_id, sizeof(ack.transfer_id)); + memcpy(control.source, s_node_id, sizeof(control.source)); + memcpy(control.destination, a->dst, sizeof(control.destination)); + memcpy(control.transfer_id, a->transfer_id, + sizeof(control.transfer_id)); if (!lora_trunk_v3_encode( - &ack, f, sizeof(f), &frame_len)) { + &control, f, sizeof(f), &frame_len)) { s_ack_head = (s_ack_head + 1) % ACK_OUTBOX; s_ack_count--; return false; @@ -718,7 +817,17 @@ static bool transmit_ack_now(void) if (sx1262_transmit(f, frame_len) == ESP_OK) { diag_inc(&s_diag.tx_attempts); diag_inc(&s_diag.ack_tx); - ESP_LOGI(TAG, "v%u ack TX idx=%u", a->version, a->idx); + if (a->version == LORA_TRUNK_V3_VERSION) { + if (a->kind == LORA_TRUNK_KIND_BITMAP_ACK) { + diag_inc(&s_diag.bitmap_acks); + } else if (a->kind == LORA_TRUNK_KIND_COMPLETE) { + diag_inc(&s_diag.complete_acks); + } else if (a->kind == LORA_TRUNK_KIND_ABORT) { + diag_inc(&s_diag.transfer_aborts_tx); + } + } + ESP_LOGI(TAG, "v%u control TX kind=%u value=0x%02x", + a->version, a->kind, a->value); return true; } recover_radio("ACK transmit command"); @@ -759,38 +868,172 @@ static void learn_authenticated_neighbor( bitchat_packet_free(&packet); } +static bool same_transfer( + uint8_t version, const uint8_t *source, const uint8_t *transfer_id, + const lora_trunk_frame_t *frame) +{ + return version == frame->version && + memcmp(source, frame->source, LORA_TRUNK_V3_NODE_ID_LEN) == 0 && + memcmp(transfer_id, frame->transfer_id, + LORA_TRUNK_V3_TRANSFER_ID_LEN) == 0; +} + +static completed_slot_t *completed_transfer( + const lora_trunk_frame_t *frame, uint64_t now_ms) +{ + for (size_t i = 0; i < COMPLETED_SLOTS; ++i) { + completed_slot_t *completed = &s_completed_slots[i]; + if (completed->in_use && now_ms >= completed->expires_ms) { + completed->in_use = false; + } + if (completed->in_use && + same_transfer( + completed->version, completed->src, + completed->transfer_id, frame)) { + return completed; + } + } + return NULL; +} + +static void remember_completed_transfer( + const lora_trunk_frame_t *frame, bool addressed, + uint64_t expires_ms) +{ + completed_slot_t *slot = NULL; + for (size_t i = 0; i < COMPLETED_SLOTS; ++i) { + completed_slot_t *candidate = &s_completed_slots[i]; + if (!candidate->in_use) { + slot = candidate; + break; + } + if (!slot || candidate->expires_ms < slot->expires_ms) { + slot = candidate; + } + } + memset(slot, 0, sizeof(*slot)); + slot->in_use = true; + slot->addressed = addressed; + slot->version = frame->version; + memcpy(slot->src, frame->source, sizeof(slot->src)); + memcpy(slot->transfer_id, frame->transfer_id, + sizeof(slot->transfer_id)); + slot->total = frame->fragment_total; + slot->expires_ms = expires_ms; +} + +static void abort_rx_slot(rx_slot_t *slot, uint8_t reason) +{ + if (!slot || !slot->in_use) { + return; + } + if (slot->addressed && + slot->version == LORA_TRUNK_V3_VERSION) { + queue_control_fields( + slot->version, slot->src, slot->transfer_id, slot->total, + LORA_TRUNK_KIND_ABORT, reason); + } + memset(slot, 0, sizeof(*slot)); +} + +static void expire_reassembly(uint64_t now_ms) +{ + for (size_t i = 0; i < RX_SLOTS; ++i) { + rx_slot_t *slot = &s_rx_slots[i]; + if (slot->in_use && + (now_ms >= slot->progress_deadline_ms || + now_ms >= slot->absolute_deadline_ms)) { + abort_rx_slot(slot, LORA_TRUNK_ABORT_RETRY_EXHAUSTED); + diag_inc(&s_diag.reassembly_expiry); + } + } +} + static void reassemble_data( - const lora_trunk_frame_t *frame, int16_t rssi, int8_t snr) + const lora_trunk_frame_t *frame, bool addressed_to_us, + int16_t rssi, int8_t snr) { if (!frame || frame->payload_len == 0 || frame->payload_len > TRUNK_CHUNK_RX_MAX || frame->fragment_total > TRUNK_MAX_FRAGS) { return; } + if (frame->version == LORA_TRUNK_V3_VERSION && + frame->fragment_index + 1u < frame->fragment_total && + frame->payload_len != TRUNK_CHUNK_TX) { + queue_v3_control( + frame, LORA_TRUNK_KIND_ABORT, + LORA_TRUNK_ABORT_INVALID_LENGTH); + diag_inc(&s_diag.invalid_reassemblies); + return; + } + uint64_t now = esp_timer_get_time() / 1000ULL; - rx_slot_t *slot = NULL, *free_slot = NULL, *oldest = &s_rx_slots[0]; - for (size_t i = 0; i < RX_SLOTS; ++i) { - rx_slot_t *s = &s_rx_slots[i]; - if (s->in_use && now - s->started_ms > RX_TIMEOUT_MS) { - s->in_use = false; - diag_inc(&s_diag.reassembly_expiry); + lora_reliability_timing_t timing; + if (!trunk_reliability_timing(frame->fragment_total, &timing)) { + return; + } + expire_reassembly(now); + + completed_slot_t *completed = completed_transfer(frame, now); + if (completed) { + if (completed->total != frame->fragment_total) { + if (addressed_to_us && + frame->version == LORA_TRUNK_V3_VERSION) { + queue_v3_control( + frame, LORA_TRUNK_KIND_ABORT, + LORA_TRUNK_ABORT_INVALID_LENGTH); + } + diag_inc(&s_diag.invalid_reassemblies); + return; } - if (s->in_use && s->version == frame->version && - memcmp(s->src, frame->source, sizeof(s->src)) == 0 && - memcmp(s->transfer_id, frame->transfer_id, - sizeof(s->transfer_id)) == 0) { - slot = s; - } else if (!s->in_use && !free_slot) { - free_slot = s; + diag_inc(&s_diag.duplicate_transfers); + if (completed->addressed && addressed_to_us && + frame->version == LORA_TRUNK_V3_VERSION) { + queue_v3_control( + frame, LORA_TRUNK_KIND_COMPLETE, + lora_fragment_mask(frame->fragment_total)); + } else if ( + frame->version == LORA_TRUNK_V2_VERSION && + (frame->flags & LORA_TRUNK_V3_FLAG_ACK_REQUESTED) != 0) { + queue_ack(frame); } - if (s->started_ms < oldest->started_ms) { - oldest = s; + return; + } + + rx_slot_t *slot = NULL; + rx_slot_t *free_slot = NULL; + uint8_t slots_in_use = 0; + for (size_t i = 0; i < RX_SLOTS; ++i) { + rx_slot_t *candidate = &s_rx_slots[i]; + if (candidate->in_use) { + slots_in_use++; + if (same_transfer( + candidate->version, candidate->src, + candidate->transfer_id, frame)) { + slot = candidate; + } + } else if (!free_slot) { + free_slot = candidate; } } if (!slot) { - slot = free_slot ? free_slot : oldest; + if (!free_slot || + !lora_reassembly_admit( + addressed_to_us, slots_in_use, RX_SLOTS)) { + if (addressed_to_us && + frame->version == LORA_TRUNK_V3_VERSION) { + queue_v3_control( + frame, LORA_TRUNK_KIND_ABORT, + LORA_TRUNK_ABORT_RESOURCE_PRESSURE); + } + diag_inc(&s_diag.resource_aborts); + return; + } + slot = free_slot; memset(slot, 0, sizeof(*slot)); slot->in_use = true; + slot->addressed = addressed_to_us; slot->version = frame->version; memcpy(slot->src, frame->source, sizeof(slot->src)); memcpy(slot->transfer_id, frame->transfer_id, @@ -798,39 +1041,94 @@ static void reassemble_data( slot->capabilities = frame->capabilities; slot->total = frame->fragment_total; slot->started_ms = now; + slot->progress_deadline_ms = + now + timing.progress_timeout_ms; + slot->absolute_deadline_ms = + now + timing.absolute_timeout_ms; } if (slot->total != frame->fragment_total || - slot->capabilities != frame->capabilities) { - diag_inc(&s_diag.malformed_frames); + slot->capabilities != frame->capabilities || + slot->addressed != addressed_to_us) { + abort_rx_slot(slot, LORA_TRUNK_ABORT_INVALID_LENGTH); + diag_inc(&s_diag.invalid_reassemblies); return; } - slot->part_len[frame->fragment_index] = frame->payload_len; - memcpy(slot->part[frame->fragment_index], frame->payload, - frame->payload_len); - slot->have_mask |= 1u << frame->fragment_index; - uint8_t want = (uint8_t)((1u << frame->fragment_total) - 1); - if ((slot->have_mask & want) != want) { + uint8_t bit = (uint8_t)(1u << frame->fragment_index); + if ((slot->have_mask & bit) != 0) { + diag_inc(&s_diag.duplicate_fragments); + if (slot->part_len[frame->fragment_index] != frame->payload_len || + memcmp(slot->part[frame->fragment_index], frame->payload, + frame->payload_len) != 0) { + abort_rx_slot(slot, LORA_TRUNK_ABORT_INVALID_LENGTH); + diag_inc(&s_diag.invalid_reassemblies); + return; + } + } else { + slot->part_len[frame->fragment_index] = frame->payload_len; + memcpy(slot->part[frame->fragment_index], frame->payload, + frame->payload_len); + slot->have_mask |= bit; + uint64_t refreshed = now + timing.progress_timeout_ms; + slot->progress_deadline_ms = + refreshed < slot->absolute_deadline_ms + ? refreshed + : slot->absolute_deadline_ms; + } + + uint8_t wanted = lora_fragment_mask(frame->fragment_total); + if ((slot->have_mask & wanted) != wanted) { + if (addressed_to_us && + frame->version == LORA_TRUNK_V3_VERSION) { + queue_v3_control( + frame, LORA_TRUNK_KIND_BITMAP_ACK, slot->have_mask); + } else if ( + frame->version == LORA_TRUNK_V2_VERSION && + (frame->flags & LORA_TRUNK_V3_FLAG_ACK_REQUESTED) != 0) { + queue_ack(frame); + } return; } static uint8_t packet[BITCHAT_BLE_MAX_PACKET_SIZE]; - uint16_t plen = 0; - for (uint8_t i = 0; i < frame->fragment_total; ++i) { - if (plen + slot->part_len[i] > sizeof(packet)) { - slot->in_use = false; + uint16_t packet_len = 0; + for (uint8_t index = 0; index < frame->fragment_total; ++index) { + if (packet_len + slot->part_len[index] > sizeof(packet)) { + abort_rx_slot(slot, LORA_TRUNK_ABORT_INVALID_LENGTH); + diag_inc(&s_diag.invalid_reassemblies); return; } - memcpy(packet + plen, slot->part[i], slot->part_len[i]); - plen += slot->part_len[i]; + memcpy(packet + packet_len, slot->part[index], + slot->part_len[index]); + packet_len += slot->part_len[index]; + } + if (!lora_bitchat_packet_length_valid( + packet, packet_len, sizeof(packet))) { + abort_rx_slot(slot, LORA_TRUNK_ABORT_INVALID_LENGTH); + diag_inc(&s_diag.invalid_reassemblies); + return; } - slot->in_use = false; - learn_authenticated_neighbor(frame, packet, plen, now); + remember_completed_transfer( + frame, addressed_to_us, + now + timing.completed_retention_ms); + memset(slot, 0, sizeof(*slot)); + if (addressed_to_us && + frame->version == LORA_TRUNK_V3_VERSION) { + queue_v3_control( + frame, LORA_TRUNK_KIND_COMPLETE, wanted); + } else if ( + frame->version == LORA_TRUNK_V2_VERSION && + (frame->flags & LORA_TRUNK_V3_FLAG_ACK_REQUESTED) != 0) { + queue_ack(frame); + } + + learn_authenticated_neighbor(frame, packet, packet_len, now); diag_inc(&s_diag.completed_packets); ESP_LOGI(TAG, "trunk v%u RX packet len=%u rssi=%d snr=%d frags=%u", - frame->version, plen, rssi, snr, frame->fragment_total); - bitle_mesh_inbound(BITLE_LORA_LINK_HANDLE, packet, plen); + frame->version, packet_len, rssi, snr, + frame->fragment_total); + bitle_mesh_inbound(BITLE_LORA_LINK_HANDLE, packet, packet_len); } static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) @@ -855,17 +1153,35 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) ESP_LOGI(TAG, "trunk v%u raw RX len=%u rssi=%d snr=%d kind=%u", frame.version, len, rssi, snr, frame.kind); - if (frame.kind == LORA_TRUNK_KIND_FRAGMENT_ACK) { - /* v3 is the only originated version. Shared-channel ACKs for other - * senders are normal; only an ACK addressed to us but not matching the - * in-flight tuple is a rejected ACK. */ + if (frame.kind != LORA_TRUNK_KIND_DATA) { + /* Shared-channel controls for other senders are normal. A control + * addressed to us must match the selected peer and the complete + * transfer tuple before it can mutate selective-repeat state. */ if (frame.version == LORA_TRUNK_V3_VERSION && memcmp(frame.destination, s_node_id, sizeof(s_node_id)) == 0) { - if (s_expected_ack_valid && - lora_trunk_v3_ack_matches(&s_expected_ack_data, &frame)) { - s_ack_seen = true; - diag_inc(&s_diag.ack_rx); - ESP_LOGI(TAG, "v3 ack RX idx=%u", frame.fragment_index); + if (s_expected_control_valid && + lora_trunk_v3_control_matches( + &s_expected_control_data, &frame)) { + bool accepted = false; + if (frame.kind == LORA_TRUNK_KIND_BITMAP_ACK) { + accepted = lora_selective_tx_apply_bitmap( + &s_selective_tx, frame.fragment_index); + } else if (frame.kind == LORA_TRUNK_KIND_COMPLETE) { + accepted = lora_selective_tx_apply_complete( + &s_selective_tx, frame.fragment_index); + } else if (frame.kind == LORA_TRUNK_KIND_ABORT) { + lora_selective_tx_apply_abort( + &s_selective_tx, frame.fragment_index); + diag_inc(&s_diag.transfer_aborts_rx); + accepted = true; + } + if (accepted) { + s_feedback_seen = true; + diag_inc(&s_diag.ack_rx); + ESP_LOGI(TAG, + "v3 control RX kind=%u value=0x%02x", + frame.kind, frame.fragment_index); + } } else { diag_inc(&s_diag.rejected_acks); } @@ -888,13 +1204,7 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) return; } - /* v3 broadcasts can never request ACKs (enforced by the parser). The v2 - * receive-only migration path preserves legacy broadcast ACK behavior. */ - if ((frame.flags & LORA_TRUNK_V3_FLAG_ACK_REQUESTED) != 0 && - (frame.version == LORA_TRUNK_V2_VERSION || addressed_to_us)) { - queue_ack(&frame); - } - reassemble_data(&frame, rssi, snr); + reassemble_data(&frame, addressed_to_us, rssi, snr); } /* Neighbor discovery: our signed announce over the trunk, so two nodes @@ -911,8 +1221,28 @@ static void drop_tx_packet(lora_tx_handle_t *handle, bool *have_pending) *have_pending = false; } -/* Releases a completed packet, or builds the next fragment while retaining - * ownership of the same static-pool descriptor. */ +static void queue_sender_abort(uint8_t reason) +{ + if (!s_expected_control_valid) { + return; + } + queue_control_fields( + LORA_TRUNK_V3_VERSION, + s_expected_control_data.destination, + s_expected_control_data.transfer_id, + s_expected_control_data.fragment_total, + LORA_TRUNK_KIND_ABORT, reason); +} + +static void abort_tx_packet( + lora_tx_handle_t *handle, bool *have_pending, uint8_t reason) +{ + queue_sender_abort(reason); + drop_tx_packet(handle, have_pending); +} + +/* Broadcasts retain one-pass fragment sequencing. Addressed transfers select + * fragments through the portable cumulative-bitmap state machine. */ static bool advance_tx_packet(lora_tx_handle_t *handle, uint8_t *fragment_idx, uint8_t fragment_total, lora_frame_t *frame) { @@ -932,6 +1262,24 @@ static bool advance_tx_packet(lora_tx_handle_t *handle, uint8_t *fragment_idx, return true; } +static bool prepare_selective_fragment( + lora_tx_handle_t handle, lora_frame_t *frame, + uint8_t expected_total, uint8_t *out_index) +{ + int next = lora_selective_tx_next_fragment(&s_selective_tx); + if (next < 0) { + return false; + } + uint8_t checked_total = 0; + if (!build_tx_fragment( + handle, (uint8_t)next, frame, &checked_total) || + checked_total != expected_total) { + return false; + } + *out_index = (uint8_t)next; + return true; +} + static void lora_task(void *arg) { (void)arg; @@ -941,7 +1289,6 @@ static void lora_task(void *arg) uint8_t fragment_total = 0; bool have_pending = false; int cad_tries = 0; - int arq_sends = 0; bool awaiting_tx_done = false; bool tx_is_ack = false; bool awaiting_cad = false; @@ -949,6 +1296,8 @@ static void lora_task(void *arg) unsigned ack_burst = 0; uint64_t tx_watchdog_deadline = 0; uint64_t ack_deadline = 0; + uint64_t transfer_deadline = 0; + lora_reliability_timing_t transfer_timing = {0}; uint64_t next_beacon_ms = BEACON_FIRST_MS + (esp_random() % BEACON_START_JITTER_MS); uint64_t next_diag_ms = 60000ULL; @@ -965,6 +1314,7 @@ static void lora_task(void *arg) ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(50)); uint64_t now = esp_timer_get_time() / 1000ULL; + expire_reassembly(now); if (now >= next_beacon_ms) { next_beacon_ms = now + BEACON_INTERVAL_MS - @@ -1018,15 +1368,25 @@ static void lora_task(void *arg) memcpy(probe + PKT_SENDER_OFF, noise_get_local_peer_id(), 8); memcpy(probe + 22, recipient, sizeof(recipient)); - ESP_LOGI(TAG, - "diagnostic smoke enqueue len=%u expected_frags=%u " - "addressed=1", - probe_len, - (probe_len + TRUNK_CHUNK_TX - 1) / - TRUNK_CHUNK_TX); - lora_link_send(BITLE_LORA_LINK_HANDLE, probe, probe_len); - smoke_index++; - next_smoke_ms = now + 12000ULL; + bitle_link_send_result_t result = + lora_link_send( + BITLE_LORA_LINK_HANDLE, probe, probe_len); + if (result == BITLE_LINK_SEND_ACCEPTED) { + ESP_LOGI( + TAG, + "diagnostic smoke enqueue len=%u expected_frags=%u " + "addressed=1", + probe_len, + (probe_len + TRUNK_CHUNK_TX - 1) / + TRUNK_CHUNK_TX); + smoke_index++; + } else { + ESP_LOGW( + TAG, + "diagnostic smoke deferred len=%u result=%u", + probe_len, result); + } + next_smoke_ms = now + 3000ULL; } } #endif @@ -1041,7 +1401,9 @@ static void lora_task(void *arg) "busy_tmo=%lu recover=%lu/%lu tx_watchdog=%lu " "admit=%lu/%lu/%lu/%lu defer=%lu/%lu/%lu/%lu " "policy=%lu release=%lu cancel=%lu malformed=%lu " - "unsupported=%lu ack_reject=%lu neighbors=%lu collision=%lu", + "unsupported=%lu ack_reject=%lu neighbors=%lu collision=%lu " + "bitmap=%lu final=%lu dup=%lu/%lu abort=%lu/%lu " + "invalid=%lu resource=%lu", (unsigned long)diag.raw_rx_frames, (unsigned long)diag.crc_errors, (unsigned long)diag.tx_attempts, @@ -1077,7 +1439,15 @@ static void lora_task(void *arg) (unsigned long)diag.unsupported_versions, (unsigned long)diag.rejected_acks, (unsigned long)diag.neighbor_count, - (unsigned long)diag.neighbor_collisions); + (unsigned long)diag.neighbor_collisions, + (unsigned long)diag.bitmap_acks, + (unsigned long)diag.complete_acks, + (unsigned long)diag.duplicate_fragments, + (unsigned long)diag.duplicate_transfers, + (unsigned long)diag.transfer_aborts_tx, + (unsigned long)diag.transfer_aborts_rx, + (unsigned long)diag.invalid_reassemblies, + (unsigned long)diag.resource_aborts); next_diag_ms = now + 60000ULL; } @@ -1107,33 +1477,35 @@ static void lora_task(void *arg) if (!resume_rx_or_recover("TX_DONE resume RX")) { tx_is_ack = false; if (have_pending) { - drop_tx_packet(&packet_handle, &have_pending); + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); } break; } if (tx_is_ack) { - /* our ack went out; the interleave delayed any pending - * data frame's ack wait, so extend its deadline */ + /* A local control delayed packet feedback processing. */ tx_is_ack = false; if (awaiting_ack) { ack_deadline += trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN) + 300; } } else if (have_pending && pending.want_ack) { - /* wait long enough for the peer's ack (its airtime plus - * CAD/scheduling slack) before retransmitting */ - awaiting_ack = true; - ack_deadline = esp_timer_get_time() / 1000ULL + - trunk_airtime_ms( - LORA_TRUNK_V3_HEADER_LEN) + - ARQ_MARGIN_MS; + if (prepare_selective_fragment( + packet_handle, &pending, fragment_total, + &fragment_idx)) { + cad_tries = 0; + } else { + awaiting_ack = true; + ack_deadline = + esp_timer_get_time() / 1000ULL + + transfer_timing.feedback_wait_ms; + } } else { have_pending = advance_tx_packet( &packet_handle, &fragment_idx, fragment_total, &pending); cad_tries = 0; - arq_sends = 0; - s_ack_seen = false; } break; case SX1262_EVT_CAD_CLEAR: @@ -1155,38 +1527,67 @@ static void lora_task(void *arg) } else if (have_pending) { if (sx1262_transmit(pending.data, pending.len) == ESP_OK) { diag_inc(&s_diag.tx_attempts); - if (arq_sends > 0) { + if (pending.want_ack && + s_selective_tx.round_index > 0) { diag_inc(&s_diag.retries); } + if (pending.want_ack && + !lora_selective_tx_note_sent( + &s_selective_tx, fragment_idx)) { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_INVALID_LENGTH); + resume_rx_or_recover( + "selective state resume RX"); + break; + } awaiting_tx_done = true; tx_is_ack = false; tx_watchdog_deadline = esp_timer_get_time() / 1000ULL + sx1262_tx_watchdog_ms(pending.len); - arq_sends++; ack_burst = 0; } else { - drop_tx_packet(&packet_handle, &have_pending); + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); recover_radio("data transmit command"); } } break; case SX1262_EVT_CAD_BUSY: awaiting_cad = false; - if (have_pending && ++cad_tries <= CAD_RETRIES) { + if (have_pending && + (pending.want_ack || ++cad_tries <= CAD_RETRIES)) { + /* An addressed transfer owns a bounded absolute deadline; + * temporary channel occupancy must not discard it after a + * small fixed number of CAD results. Return to RX during + * backoff so packet feedback remains observable, then + * retry until COMPLETE, ABORT, or the transfer deadline. */ + if (!resume_rx_or_recover( + "CAD busy backoff resume RX")) { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); + break; + } vTaskDelay(pdMS_TO_TICKS(CAD_BACKOFF_MIN + (esp_random() % CAD_BACKOFF_SPAN))); if (sx1262_start_cad() == ESP_OK) { awaiting_cad = true; } else { - drop_tx_packet(&packet_handle, &have_pending); + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); recover_radio("CAD retry command"); } } else { /* Channel persistently busy: reject the complete packet; * sending only its earlier fragments cannot complete. */ if (have_pending) { - drop_tx_packet(&packet_handle, &have_pending); + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RESOURCE_PRESSURE); } resume_rx_or_recover("CAD busy resume RX"); } @@ -1194,20 +1595,33 @@ static void lora_task(void *arg) case SX1262_EVT_TIMEOUT: { bool timed_out_ack = tx_is_ack; + bool data_was_waiting = awaiting_ack; diag_inc(&s_diag.tx_timeouts); awaiting_tx_done = false; tx_watchdog_deadline = 0; tx_is_ack = false; awaiting_ack = false; - /* A radio timeout is an expected, separately counted delivery - * failure. Re-enter RX and let an ack-requested data frame use - * its remaining ARQ attempts. */ if (!resume_rx_or_recover("radio TX timeout")) { if (have_pending) { - drop_tx_packet(&packet_handle, &have_pending); + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); } + } else if (timed_out_ack && data_was_waiting) { + awaiting_ack = true; + ack_deadline += + transfer_timing.feedback_wait_ms; } else if (!timed_out_ack && have_pending && - (!pending.want_ack || arq_sends >= ARQ_TRIES)) { + pending.want_ack) { + if (!prepare_selective_fragment( + packet_handle, &pending, fragment_total, + &fragment_idx)) { + awaiting_ack = true; + ack_deadline = + esp_timer_get_time() / 1000ULL + + transfer_timing.feedback_wait_ms; + } + } else if (!timed_out_ack && have_pending) { drop_tx_packet(&packet_handle, &have_pending); } break; @@ -1224,11 +1638,12 @@ static void lora_task(void *arg) awaiting_ack = false; tx_is_ack = false; tx_watchdog_deadline = 0; - if (!recover_radio("IRQ status command")) { - if (have_pending) { - drop_tx_packet(&packet_handle, &have_pending); - } + if (have_pending) { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); } + recover_radio("IRQ status command"); break; default: break; @@ -1262,46 +1677,65 @@ static void lora_task(void *arg) awaiting_ack = false; tx_is_ack = false; tx_watchdog_deadline = 0; - if (!recover_radio("software TX watchdog")) { - if (have_pending) { - drop_tx_packet(&packet_handle, &have_pending); - } + if (have_pending) { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); } + recover_radio("software TX watchdog"); } - /* ARQ: resolve the ack wait for the in-flight frame. */ - if (awaiting_ack && have_pending) { + /* Packet-level selective repeat resolves cumulative feedback only + * after sending every currently missing fragment in the round. */ + if (have_pending && pending.want_ack && + (s_selective_tx.complete || s_selective_tx.aborted)) { + if (s_selective_tx.complete) { + ESP_LOGI(TAG, "trunk transfer COMPLETE rounds=%u", + s_selective_tx.round_index + 1u); + } else { + ESP_LOGW(TAG, "trunk transfer ABORT reason=%u", + s_selective_tx.abort_reason); + } + awaiting_ack = false; + awaiting_cad = false; + s_feedback_seen = false; + drop_tx_packet(&packet_handle, &have_pending); + } else if (awaiting_ack && have_pending) { uint64_t now2 = esp_timer_get_time() / 1000ULL; - if (s_ack_seen) { - s_ack_seen = false; - s_expected_ack_valid = false; - awaiting_ack = false; - have_pending = advance_tx_packet( - &packet_handle, &fragment_idx, fragment_total, &pending); - cad_tries = 0; - arq_sends = 0; - } else if (now2 >= ack_deadline) { - diag_inc(&s_diag.ack_misses); + if (now2 >= ack_deadline) { + if (!s_feedback_seen) { + diag_inc(&s_diag.ack_misses); + } + s_feedback_seen = false; awaiting_ack = false; - if (arq_sends < ARQ_TRIES) { + if (lora_selective_tx_next_round( + &s_selective_tx, + LORA_SELECTIVE_REPEAT_ROUNDS) && + prepare_selective_fragment( + packet_handle, &pending, fragment_total, + &fragment_idx)) { cad_tries = 0; - if (sx1262_start_cad() == ESP_OK) { - awaiting_cad = true; /* retransmit */ - } else { - drop_tx_packet(&packet_handle, &have_pending); - recover_radio("ARQ CAD command"); - } } else { diag_inc(&s_diag.retry_exhaustion); - ESP_LOGW(TAG, - "trunk frame lost after %d sends idx=%u", - ARQ_TRIES, - s_expected_ack_data.fragment_index); - drop_tx_packet(&packet_handle, &have_pending); + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RETRY_EXHAUSTED); } } } + uint64_t transfer_now = esp_timer_get_time() / 1000ULL; + if (have_pending && pending.want_ack && + transfer_deadline != 0 && + transfer_now >= transfer_deadline) { + diag_inc(&s_diag.retry_exhaustion); + awaiting_ack = false; + awaiting_cad = false; + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RETRY_EXHAUSTED); + } + /* Re-arm a pending data frame that was stranded idle in every wait * state — happens when an ack preempted its CAD result (CAD_CLEAR * with s_ack_count>0), or when an ack transmit returned an error. @@ -1311,7 +1745,9 @@ static void lora_task(void *arg) if (sx1262_start_cad() == ESP_OK) { awaiting_cad = true; } else { - drop_tx_packet(&packet_handle, &have_pending); + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); recover_radio("pending CAD command"); } } @@ -1325,13 +1761,29 @@ static void lora_task(void *arg) drop_tx_packet(&packet_handle, &have_pending); continue; } + if (pending.want_ack && + (!lora_selective_tx_init( + &s_selective_tx, fragment_total) || + !trunk_reliability_timing( + fragment_total, &transfer_timing))) { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_INVALID_LENGTH); + continue; + } cad_tries = 0; - arq_sends = 0; - s_ack_seen = false; + s_feedback_seen = false; + transfer_deadline = + pending.want_ack + ? esp_timer_get_time() / 1000ULL + + transfer_timing.absolute_timeout_ms + : 0; if (sx1262_start_cad() == ESP_OK) { awaiting_cad = true; } else { - drop_tx_packet(&packet_handle, &have_pending); + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); recover_radio("dequeue CAD command"); } } @@ -1371,8 +1823,14 @@ void bitle_lora_shutdown(void) } taskENTER_CRITICAL(&s_gov_mux); lora_tx_scheduler_cancel_all(&s_tx_scheduler); + memset(s_tx_context, 0, sizeof(s_tx_context)); diag_sync_scheduler_locked(); taskEXIT_CRITICAL(&s_gov_mux); + memset(s_rx_slots, 0, sizeof(s_rx_slots)); + memset(s_completed_slots, 0, sizeof(s_completed_slots)); + memset(&s_selective_tx, 0, sizeof(s_selective_tx)); + s_expected_control_valid = false; + s_feedback_seen = false; s_ack_head = 0; s_ack_count = 0; } @@ -1500,9 +1958,13 @@ esp_err_t bitle_lora_init(void) s_transfer_counter = 0; memset(s_tx_context, 0, sizeof(s_tx_context)); memset(s_rx_slots, 0, sizeof(s_rx_slots)); + memset(s_completed_slots, 0, sizeof(s_completed_slots)); lora_trunk_neighbor_table_init(&s_neighbors); - s_expected_ack_valid = false; - s_ack_seen = false; + s_expected_control_valid = false; + s_feedback_seen = false; + memset(&s_selective_tx, 0, sizeof(s_selective_tx)); + s_ack_head = 0; + s_ack_count = 0; /* Modem params for the airtime governor; start the bucket full so a * quiet node can transmit immediately. */ diff --git a/main/bitle_lora.h b/main/bitle_lora.h index 8dfd9ee..ad74581 100644 --- a/main/bitle_lora.h +++ b/main/bitle_lora.h @@ -6,8 +6,8 @@ * The trunk registers one bitle_link (handle BITLE_LORA_LINK_HANDLE) for * the whole medium once an SX1262 is detected, so the mesh core relays * BLE<->LoRa with no special cases. Encoded BitChat packets originate as v3 - * fragments under a 44-byte addressed trunk header with exact per-fragment - * acknowledgement. The receive path retains strict v2 migration parsing; + * fragments under a 44-byte addressed trunk header with cumulative bitmap + * feedback and terminal COMPLETE. The receive path retains strict v2 parsing; * frames from foreign LoRa protocols fail the magic check and are dropped * before touching the mesh. * @@ -62,6 +62,14 @@ typedef struct { uint32_t malformed_frames; uint32_t unsupported_versions; uint32_t rejected_acks; + uint32_t bitmap_acks; + uint32_t complete_acks; + uint32_t duplicate_fragments; + uint32_t duplicate_transfers; + uint32_t transfer_aborts_tx; + uint32_t transfer_aborts_rx; + uint32_t invalid_reassemblies; + uint32_t resource_aborts; uint32_t neighbor_collisions; uint32_t neighbor_count; } bitle_lora_diag_t; diff --git a/main/lora_packet_reliability.c b/main/lora_packet_reliability.c new file mode 100644 index 0000000..b0de24f --- /dev/null +++ b/main/lora_packet_reliability.c @@ -0,0 +1,199 @@ +#include "lora_packet_reliability.h" + +#include +#include + +static bool add_u32(uint32_t left, uint32_t right, uint32_t *out) +{ + if (UINT32_MAX - left < right) { + return false; + } + *out = left + right; + return true; +} + +static bool multiply_u32(uint32_t value, uint32_t count, uint32_t *out) +{ + if (count != 0 && value > UINT32_MAX / count) { + return false; + } + *out = value * count; + return true; +} + +uint8_t lora_fragment_mask(uint8_t fragment_total) +{ + if (fragment_total == 0 || + fragment_total > LORA_SELECTIVE_REPEAT_MAX_FRAGMENTS) { + return 0; + } + return (uint8_t)((1u << fragment_total) - 1u); +} + +bool lora_selective_tx_init( + lora_selective_tx_t *state, uint8_t fragment_total) +{ + if (!state || lora_fragment_mask(fragment_total) == 0) { + return false; + } + memset(state, 0, sizeof(*state)); + state->fragment_total = fragment_total; + return true; +} + +int lora_selective_tx_next_fragment(const lora_selective_tx_t *state) +{ + if (!state || state->complete || state->aborted) { + return -1; + } + uint8_t wanted = lora_fragment_mask(state->fragment_total); + if (wanted == 0) { + return -1; + } + uint8_t missing = (uint8_t)(wanted & ~state->acknowledged_mask); + if (missing == 0) { + missing = (uint8_t)(1u << (state->fragment_total - 1u)); + } + uint8_t useful = + (uint8_t)(missing & ~state->sent_this_round_mask); + for (uint8_t index = 0; index < state->fragment_total; ++index) { + if ((useful & (1u << index)) != 0) { + return index; + } + } + return -1; +} + +bool lora_selective_tx_note_sent( + lora_selective_tx_t *state, uint8_t fragment_index) +{ + if (!state || fragment_index >= state->fragment_total || + state->complete || state->aborted) { + return false; + } + state->sent_this_round_mask |= (uint8_t)(1u << fragment_index); + return true; +} + +bool lora_selective_tx_apply_bitmap( + lora_selective_tx_t *state, uint8_t received_mask) +{ + uint8_t wanted = + state ? lora_fragment_mask(state->fragment_total) : 0; + if (!state || wanted == 0 || received_mask == 0 || + (received_mask & ~wanted) != 0 || state->complete || + state->aborted) { + return false; + } + state->acknowledged_mask |= received_mask; + return true; +} + +bool lora_selective_tx_apply_complete( + lora_selective_tx_t *state, uint8_t received_mask) +{ + uint8_t wanted = + state ? lora_fragment_mask(state->fragment_total) : 0; + if (!state || wanted == 0 || received_mask != wanted || + state->aborted) { + return false; + } + state->acknowledged_mask = wanted; + state->complete = true; + return true; +} + +void lora_selective_tx_apply_abort( + lora_selective_tx_t *state, uint8_t reason) +{ + if (!state || reason == 0 || state->complete) { + return; + } + state->aborted = true; + state->abort_reason = reason; +} + +bool lora_selective_tx_next_round( + lora_selective_tx_t *state, uint8_t maximum_rounds) +{ + if (!state || maximum_rounds == 0 || state->complete || + state->aborted || + (uint8_t)(state->round_index + 1u) >= maximum_rounds) { + return false; + } + state->round_index++; + state->sent_this_round_mask = 0; + return true; +} + +bool lora_reassembly_admit( + bool addressed, uint8_t slots_in_use, uint8_t slot_capacity) +{ + if (slot_capacity == 0 || slots_in_use >= slot_capacity) { + return false; + } + return addressed || (uint8_t)(slots_in_use + 1u) < slot_capacity; +} + +bool lora_reliability_timing( + uint32_t maximum_data_airtime_ms, + uint32_t control_airtime_ms, + uint8_t fragment_total, + uint8_t maximum_rounds, + uint32_t contention_allowance_ms, + lora_reliability_timing_t *out) +{ + if (!out || maximum_data_airtime_ms == 0 || + control_airtime_ms == 0 || + lora_fragment_mask(fragment_total) == 0 || + maximum_rounds == 0) { + return false; + } + + uint32_t data_opportunity = 0; + uint32_t control_opportunity = 0; + uint32_t data_round = 0; + uint32_t round = 0; + uint32_t progress = 0; + uint32_t lifetime = 0; + uint32_t retention = 0; + if (!add_u32(maximum_data_airtime_ms, + contention_allowance_ms, &data_opportunity) || + !add_u32(control_airtime_ms, + contention_allowance_ms, &control_opportunity) || + !multiply_u32(data_opportunity, fragment_total, &data_round) || + !add_u32(data_round, control_opportunity, &round) || + !multiply_u32(round, 2, &progress) || + !multiply_u32(round, maximum_rounds, &lifetime) || + !add_u32(lifetime, round, &retention)) { + return false; + } + + out->feedback_wait_ms = control_opportunity; + out->round_ms = round; + out->progress_timeout_ms = progress; + out->absolute_timeout_ms = lifetime; + out->completed_retention_ms = retention; + return true; +} + +bool lora_bitchat_packet_length_valid( + const uint8_t *packet, uint16_t packet_len, + uint16_t maximum_packet_len) +{ + if (!packet || packet_len < 22 || packet_len > maximum_packet_len || + packet[0] != 1) { + return false; + } + uint8_t flags = packet[11]; + uint16_t payload_len = + (uint16_t)(((uint16_t)packet[12] << 8) | packet[13]); + uint32_t expected = 22u + payload_len; + if ((flags & 0x01u) != 0) { + expected += 8; + } + if ((flags & 0x02u) != 0) { + expected += 64; + } + return expected == packet_len; +} diff --git a/main/lora_packet_reliability.h b/main/lora_packet_reliability.h new file mode 100644 index 0000000..4db7e56 --- /dev/null +++ b/main/lora_packet_reliability.h @@ -0,0 +1,86 @@ +#ifndef LORA_PACKET_RELIABILITY_H +#define LORA_PACKET_RELIABILITY_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LORA_SELECTIVE_REPEAT_MAX_FRAGMENTS 5 +#define LORA_SELECTIVE_REPEAT_ROUNDS 8 + +typedef struct { + uint8_t fragment_total; + uint8_t acknowledged_mask; + uint8_t sent_this_round_mask; + uint8_t round_index; + bool complete; + bool aborted; + uint8_t abort_reason; +} lora_selective_tx_t; + +typedef struct { + uint32_t feedback_wait_ms; + uint32_t round_ms; + uint32_t progress_timeout_ms; + uint32_t absolute_timeout_ms; + uint32_t completed_retention_ms; +} lora_reliability_timing_t; + +uint8_t lora_fragment_mask(uint8_t fragment_total); + +bool lora_selective_tx_init( + lora_selective_tx_t *state, uint8_t fragment_total); + +/* Returns the next useful fragment in this round, or -1 after every currently + * missing fragment has been sent. When only COMPLETE is missing, the final + * fragment is used as a bounded completion probe. */ +int lora_selective_tx_next_fragment(const lora_selective_tx_t *state); + +bool lora_selective_tx_note_sent( + lora_selective_tx_t *state, uint8_t fragment_index); + +bool lora_selective_tx_apply_bitmap( + lora_selective_tx_t *state, uint8_t received_mask); + +bool lora_selective_tx_apply_complete( + lora_selective_tx_t *state, uint8_t received_mask); + +void lora_selective_tx_apply_abort( + lora_selective_tx_t *state, uint8_t reason); + +/* Starts another bounded selective-repeat round. False means the retry budget + * has been exhausted and the caller must abort the transfer. */ +bool lora_selective_tx_next_round( + lora_selective_tx_t *state, uint8_t maximum_rounds); + +/* Keep one bounded reassembly slot available for addressed traffic. Addressed + * transfers may use every slot; broadcast transfers must leave one free. */ +bool lora_reassembly_admit( + bool addressed, uint8_t slots_in_use, uint8_t slot_capacity); + +/* Airtime inputs already include SF, bandwidth, coding rate, preamble, and + * frame length. The resulting deadlines therefore scale with both the active + * PHY and the transfer's fragment count. */ +bool lora_reliability_timing( + uint32_t maximum_data_airtime_ms, + uint32_t control_airtime_ms, + uint8_t fragment_total, + uint8_t maximum_rounds, + uint32_t contention_allowance_ms, + lora_reliability_timing_t *out); + +/* Allocation-free exact-length validation for an encoded BitChat v1 packet. + * This is intentionally narrower than decoding: COMPLETE depends only on the + * authenticated wire length, not on allocating or interpreting its payload. */ +bool lora_bitchat_packet_length_valid( + const uint8_t *packet, uint16_t packet_len, + uint16_t maximum_packet_len); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/main/lora_trunk_protocol.c b/main/lora_trunk_protocol.c index 0d7b536..e2bf136 100644 --- a/main/lora_trunk_protocol.c +++ b/main/lora_trunk_protocol.c @@ -53,7 +53,8 @@ static bool v3_semantics_valid(const lora_trunk_frame_t *frame) (frame->flags & ~V3_FLAG_MASK) != 0 || all_zero(frame->source, sizeof(frame->source)) || all_zero(frame->transfer_id, sizeof(frame->transfer_id)) || - !fragment_valid(frame->fragment_index, frame->fragment_total) || + frame->fragment_total == 0 || + frame->fragment_total > LORA_TRUNK_MAX_FRAGMENTS || frame->payload_len > LORA_TRUNK_V3_MAX_PAYLOAD || frame->hop_count > frame->hop_limit) { return false; @@ -69,15 +70,36 @@ static bool v3_semantics_valid(const lora_trunk_frame_t *frame) if (frame->payload_len == 0 || !frame->payload) { return false; } + if (!fragment_valid( + frame->fragment_index, frame->fragment_total)) { + return false; + } if (broadcast != destination_zero || (broadcast && ack_requested)) { return false; } return true; } - if (frame->kind == LORA_TRUNK_KIND_FRAGMENT_ACK) { - return frame->flags == 0 && frame->payload_len == 0 && - !frame->payload && !destination_zero && - frame->hop_limit == 0 && frame->hop_count == 0; + bool control_base = + frame->flags == 0 && frame->payload_len == 0 && + !frame->payload && !destination_zero && + frame->hop_limit == 0 && frame->hop_count == 0; + if (!control_base) { + return false; + } + uint8_t wanted = + (uint8_t)((1u << frame->fragment_total) - 1u); + if (frame->kind == LORA_TRUNK_KIND_BITMAP_ACK) { + return frame->fragment_index != 0 && + (frame->fragment_index & ~wanted) == 0; + } + if (frame->kind == LORA_TRUNK_KIND_COMPLETE) { + return frame->fragment_index == wanted; + } + if (frame->kind == LORA_TRUNK_KIND_ABORT) { + return frame->fragment_index >= + LORA_TRUNK_ABORT_RETRY_EXHAUSTED && + frame->fragment_index <= + LORA_TRUNK_ABORT_RADIO_RESET; } return false; } @@ -102,7 +124,7 @@ static lora_trunk_parse_result_t parse_v2( } out->version = LORA_TRUNK_V2_VERSION; - out->kind = is_ack ? LORA_TRUNK_KIND_FRAGMENT_ACK + out->kind = is_ack ? LORA_TRUNK_KIND_BITMAP_ACK : LORA_TRUNK_KIND_DATA; out->flags = (data[3] & V2_FLAG_ACK_REQUESTED) != 0 ? LORA_TRUNK_V3_FLAG_ACK_REQUESTED @@ -209,23 +231,25 @@ bool lora_trunk_v3_encode( return true; } -bool lora_trunk_v3_ack_matches( - const lora_trunk_frame_t *data, const lora_trunk_frame_t *ack) +bool lora_trunk_v3_control_matches( + const lora_trunk_frame_t *data, + const lora_trunk_frame_t *control) { - return data && ack && + return data && control && data->version == LORA_TRUNK_V3_VERSION && - ack->version == LORA_TRUNK_V3_VERSION && + control->version == LORA_TRUNK_V3_VERSION && data->kind == LORA_TRUNK_KIND_DATA && - ack->kind == LORA_TRUNK_KIND_FRAGMENT_ACK && + (control->kind == LORA_TRUNK_KIND_BITMAP_ACK || + control->kind == LORA_TRUNK_KIND_COMPLETE || + control->kind == LORA_TRUNK_KIND_ABORT) && (data->flags & LORA_TRUNK_V3_FLAG_ACK_REQUESTED) != 0 && - memcmp(ack->source, data->destination, + memcmp(control->source, data->destination, LORA_TRUNK_V3_NODE_ID_LEN) == 0 && - memcmp(ack->destination, data->source, + memcmp(control->destination, data->source, LORA_TRUNK_V3_NODE_ID_LEN) == 0 && - memcmp(ack->transfer_id, data->transfer_id, + memcmp(control->transfer_id, data->transfer_id, LORA_TRUNK_V3_TRANSFER_ID_LEN) == 0 && - ack->fragment_index == data->fragment_index && - ack->fragment_total == data->fragment_total; + control->fragment_total == data->fragment_total; } void lora_trunk_make_transfer_id( diff --git a/main/lora_trunk_protocol.h b/main/lora_trunk_protocol.h index d3748e9..9476e90 100644 --- a/main/lora_trunk_protocol.h +++ b/main/lora_trunk_protocol.h @@ -28,9 +28,18 @@ extern "C" { typedef enum { LORA_TRUNK_KIND_DATA = 1, - LORA_TRUNK_KIND_FRAGMENT_ACK = 2, + LORA_TRUNK_KIND_BITMAP_ACK = 2, + LORA_TRUNK_KIND_COMPLETE = 3, + LORA_TRUNK_KIND_ABORT = 4, } lora_trunk_kind_t; +typedef enum { + LORA_TRUNK_ABORT_RETRY_EXHAUSTED = 1, + LORA_TRUNK_ABORT_INVALID_LENGTH = 2, + LORA_TRUNK_ABORT_RESOURCE_PRESSURE = 3, + LORA_TRUNK_ABORT_RADIO_RESET = 4, +} lora_trunk_abort_reason_t; + typedef enum { LORA_TRUNK_V3_FLAG_ACK_REQUESTED = 0x01, LORA_TRUNK_V3_FLAG_BROADCAST = 0x02, @@ -69,9 +78,11 @@ bool lora_trunk_v3_encode( const lora_trunk_frame_t *frame, uint8_t *out, size_t capacity, size_t *out_len); -/* Exact v3 fragment acknowledgement predicate. */ -bool lora_trunk_v3_ack_matches( - const lora_trunk_frame_t *data, const lora_trunk_frame_t *ack); +/* Exact v3 packet-control predicate. A bitmap ACK, COMPLETE, or ABORT must + * reverse source/destination and match the full transfer tuple. */ +bool lora_trunk_v3_control_matches( + const lora_trunk_frame_t *data, + const lora_trunk_frame_t *control); /* 96-bit transfer ID: unpredictable 64-bit boot epoch plus BE counter. */ void lora_trunk_make_transfer_id( diff --git a/tests/test_lora_packet_reliability.c b/tests/test_lora_packet_reliability.c new file mode 100644 index 0000000..2a1d15a --- /dev/null +++ b/tests/test_lora_packet_reliability.c @@ -0,0 +1,150 @@ +#include "lora_airtime.h" +#include "lora_packet_reliability.h" + +#include +#include + +static uint32_t frame_airtime(uint8_t sf, uint16_t length) +{ + lora_airtime_params_t params = { + .spreading_factor = sf, + .bandwidth_hz = 125000, + .coding_rate = 5, + .preamble_symbols = 16, + .explicit_header = true, + .crc_enabled = true, + }; + return lora_airtime_ms(¶ms, length); +} + +static void test_selective_repeat(void) +{ + lora_selective_tx_t state; + assert(lora_selective_tx_init(&state, 5)); + for (int expected = 0; expected < 5; ++expected) { + assert(lora_selective_tx_next_fragment(&state) == expected); + assert(lora_selective_tx_note_sent( + &state, (uint8_t)expected)); + } + assert(lora_selective_tx_next_fragment(&state) == -1); + + assert(lora_selective_tx_apply_bitmap(&state, 0x15)); + assert(lora_selective_tx_next_round( + &state, LORA_SELECTIVE_REPEAT_ROUNDS)); + assert(lora_selective_tx_next_fragment(&state) == 1); + assert(lora_selective_tx_note_sent(&state, 1)); + assert(lora_selective_tx_next_fragment(&state) == 3); + assert(lora_selective_tx_note_sent(&state, 3)); + assert(lora_selective_tx_next_fragment(&state) == -1); + + assert(lora_selective_tx_apply_bitmap(&state, 0x1F)); + assert(lora_selective_tx_next_round( + &state, LORA_SELECTIVE_REPEAT_ROUNDS)); + assert(lora_selective_tx_next_fragment(&state) == 4); + assert(lora_selective_tx_note_sent(&state, 4)); + assert(lora_selective_tx_apply_complete(&state, 0x1F)); + assert(state.complete); + assert(lora_selective_tx_next_fragment(&state) == -1); +} + +static void test_abort_and_round_bound(void) +{ + lora_selective_tx_t state; + assert(lora_selective_tx_init(&state, 1)); + for (unsigned round = 1; + round < LORA_SELECTIVE_REPEAT_ROUNDS; ++round) { + assert(lora_selective_tx_next_round( + &state, LORA_SELECTIVE_REPEAT_ROUNDS)); + } + assert(!lora_selective_tx_next_round( + &state, LORA_SELECTIVE_REPEAT_ROUNDS)); + + assert(lora_selective_tx_init(&state, 3)); + lora_selective_tx_apply_abort(&state, 2); + assert(state.aborted); + assert(state.abort_reason == 2); + assert(lora_selective_tx_next_fragment(&state) == -1); +} + +static void test_reassembly_admission(void) +{ + assert(!lora_reassembly_admit(false, 0, 0)); + assert(lora_reassembly_admit(true, 0, 2)); + assert(lora_reassembly_admit(true, 1, 2)); + assert(!lora_reassembly_admit(true, 2, 2)); + assert(lora_reassembly_admit(false, 0, 2)); + assert(!lora_reassembly_admit(false, 1, 2)); + assert(!lora_reassembly_admit(false, 2, 2)); + assert(!lora_reassembly_admit(false, 0, 1)); +} + +static void test_timing_all_spreading_factors(void) +{ + uint32_t previous = 0; + for (uint8_t sf = 7; sf <= 12; ++sf) { + for (uint8_t fragments = 1; fragments <= 5; ++fragments) { + lora_reliability_timing_t timing; + assert(lora_reliability_timing( + frame_airtime(sf, 164), + frame_airtime(sf, 44), + fragments, + LORA_SELECTIVE_REPEAT_ROUNDS, + 1950, + &timing)); + assert(timing.feedback_wait_ms > + frame_airtime(sf, 44)); + assert(timing.round_ms > + timing.feedback_wait_ms); + assert(timing.progress_timeout_ms == + timing.round_ms * 2); + assert(timing.absolute_timeout_ms == + timing.round_ms * + LORA_SELECTIVE_REPEAT_ROUNDS); + assert(timing.completed_retention_ms > + timing.absolute_timeout_ms); + } + lora_reliability_timing_t max_timing; + assert(lora_reliability_timing( + frame_airtime(sf, 164), + frame_airtime(sf, 44), + 5, + LORA_SELECTIVE_REPEAT_ROUNDS, + 1950, + &max_timing)); + assert(max_timing.absolute_timeout_ms > previous); + previous = max_timing.absolute_timeout_ms; + } +} + +static void test_exact_bitchat_length(void) +{ + uint8_t packet[100] = {0}; + packet[0] = 1; + packet[11] = 0x01; + packet[12] = 0; + packet[13] = 70; + assert(lora_bitchat_packet_length_valid( + packet, sizeof(packet), sizeof(packet))); + assert(!lora_bitchat_packet_length_valid( + packet, sizeof(packet) - 1, sizeof(packet))); + packet[11] = 0x03; + assert(!lora_bitchat_packet_length_valid( + packet, sizeof(packet), sizeof(packet))); + packet[13] = 6; + assert(lora_bitchat_packet_length_valid( + packet, sizeof(packet), sizeof(packet))); + packet[0] = 2; + assert(!lora_bitchat_packet_length_valid( + packet, sizeof(packet), sizeof(packet))); +} + +int main(void) +{ + test_selective_repeat(); + test_abort_and_round_bound(); + test_reassembly_admission(); + test_timing_all_spreading_factors(); + test_exact_bitchat_length(); + puts("lora_packet_reliability: ok"); + return 0; +} diff --git a/tests/test_lora_packet_reliability.py b/tests/test_lora_packet_reliability.py new file mode 100644 index 0000000..39e71dd --- /dev/null +++ b/tests/test_lora_packet_reliability.py @@ -0,0 +1,97 @@ +import unittest + +from tools.lora_reliability_sim import ( + BoundedReassemblyModel, + SelectiveRepeatConfig, + SelectiveRepeatModel, +) + + +class SelectiveRepeatReliabilityTests(unittest.TestCase): + def test_thirty_percent_data_and_ack_loss_exceeds_gate(self): + model = SelectiveRepeatModel( + SelectiveRepeatConfig( + data_loss=0.30, + ack_loss=0.30, + maximum_rounds=8, + seed=4104, + ) + ) + results = [model.transfer(520) for _ in range(2000)] + completed = sum(result.complete for result in results) + self.assertGreaterEqual(completed / len(results), 0.95) + self.assertTrue( + all( + not result.complete or result.delivered + for result in results + ) + ) + self.assertTrue( + all(result.delivery_count <= 1 for result in results) + ) + + def test_duplicate_reordered_frames_and_final_ack_loss(self): + model = SelectiveRepeatModel( + SelectiveRepeatConfig( + ack_loss=0.45, + duplication=1.0, + reorder=True, + maximum_rounds=8, + seed=91, + ) + ) + for packet_len in (100, 180, 300, 420, 520): + result = model.transfer(packet_len) + self.assertTrue(result.complete) + self.assertTrue(result.delivered) + self.assertEqual(result.delivery_count, 1) + + def test_delayed_feedback_and_incomplete_receiver_restart(self): + model = SelectiveRepeatModel( + SelectiveRepeatConfig( + data_loss=0.15, + ack_loss=0.15, + duplication=0.4, + reorder=True, + feedback_delay_rounds=1, + maximum_rounds=8, + seed=77, + ) + ) + result = model.transfer(520, receiver_restart_round=1) + self.assertTrue(result.complete) + self.assertEqual(result.delivery_count, 1) + + def test_reboot_epoch_and_counter_wrap(self): + first = SelectiveRepeatModel.transfer_id( + b"\x10" * 8, 0xFFFFFFFF + ) + after_reboot = SelectiveRepeatModel.transfer_id( + b"\x20" * 8, 1 + ) + self.assertNotEqual(first, after_reboot) + with self.assertRaises(OverflowError): + SelectiveRepeatModel.transfer_id(b"\x10" * 8, 0) + with self.assertRaises(OverflowError): + SelectiveRepeatModel.transfer_id( + b"\x10" * 8, 0x1_0000_0000 + ) + + def test_incomplete_transfer_flood_stays_bounded(self): + receiver = BoundedReassemblyModel() + accepted = [ + receiver.accept_fragment(index.to_bytes(12, "big")) + for index in range(1000) + ] + self.assertEqual(sum(accepted), 2) + self.assertEqual(len(receiver.active), 2) + self.assertEqual(receiver.resource_aborts, 998) + + first = (0).to_bytes(12, "big") + self.assertTrue(receiver.complete(first)) + self.assertFalse(receiver.complete(first)) + self.assertLessEqual(len(receiver.completed), 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lora_trunk_protocol.c b/tests/test_lora_trunk_protocol.c index 3d87e1e..b17adba 100644 --- a/tests/test_lora_trunk_protocol.c +++ b/tests/test_lora_trunk_protocol.c @@ -33,19 +33,21 @@ static lora_trunk_frame_t make_data( return frame; } -static lora_trunk_frame_t make_ack(const lora_trunk_frame_t *data) +static lora_trunk_frame_t make_control( + const lora_trunk_frame_t *data, lora_trunk_kind_t kind, + uint8_t value) { - lora_trunk_frame_t ack = { + lora_trunk_frame_t control = { .version = LORA_TRUNK_V3_VERSION, - .kind = LORA_TRUNK_KIND_FRAGMENT_ACK, + .kind = kind, .capabilities = LORA_TRUNK_V3_CAP_PROTOCOL, - .fragment_index = data->fragment_index, + .fragment_index = value, .fragment_total = data->fragment_total, }; - memcpy(ack.source, data->destination, 8); - memcpy(ack.destination, data->source, 8); - memcpy(ack.transfer_id, data->transfer_id, 12); - return ack; + memcpy(control.source, data->destination, 8); + memcpy(control.destination, data->source, 8); + memcpy(control.transfer_id, data->transfer_id, 12); + return control; } static size_t encode( @@ -91,26 +93,48 @@ static void test_v3_round_trip_and_malformed(void) assert(!lora_trunk_v3_encode(&invalid, wire, sizeof(wire), &len)); } -static void test_exact_ack_matching(void) +static void test_exact_control_matching(void) { lora_trunk_frame_t data = make_data(SOURCE_A, SOURCE_B, false); - lora_trunk_frame_t ack = make_ack(&data); - assert(lora_trunk_v3_ack_matches(&data, &ack)); + lora_trunk_frame_t ack = + make_control(&data, LORA_TRUNK_KIND_BITMAP_ACK, 0x03); + assert(lora_trunk_v3_control_matches(&data, &ack)); memcpy(ack.source, SOURCE_C, 8); - assert(!lora_trunk_v3_ack_matches(&data, &ack)); - ack = make_ack(&data); + assert(!lora_trunk_v3_control_matches(&data, &ack)); + ack = make_control(&data, LORA_TRUNK_KIND_BITMAP_ACK, 0x03); ack.destination[0] ^= 1; - assert(!lora_trunk_v3_ack_matches(&data, &ack)); - ack = make_ack(&data); + assert(!lora_trunk_v3_control_matches(&data, &ack)); + ack = make_control(&data, LORA_TRUNK_KIND_BITMAP_ACK, 0x03); ack.transfer_id[11] ^= 1; - assert(!lora_trunk_v3_ack_matches(&data, &ack)); - ack = make_ack(&data); - ack.fragment_index ^= 1; - assert(!lora_trunk_v3_ack_matches(&data, &ack)); - ack = make_ack(&data); + assert(!lora_trunk_v3_control_matches(&data, &ack)); + ack = make_control(&data, LORA_TRUNK_KIND_BITMAP_ACK, 0x03); ack.fragment_total ^= 1; - assert(!lora_trunk_v3_ack_matches(&data, &ack)); + assert(!lora_trunk_v3_control_matches(&data, &ack)); + + uint8_t wire[255]; + size_t len = 0; + lora_trunk_frame_t parsed; + ack = make_control(&data, LORA_TRUNK_KIND_BITMAP_ACK, 0x03); + len = encode(&ack, wire); + assert(lora_trunk_parse(wire, len, &parsed) == + LORA_TRUNK_PARSE_OK); + ack.fragment_index = 0; + assert(!lora_trunk_v3_encode(&ack, wire, sizeof(wire), &len)); + ack = make_control(&data, LORA_TRUNK_KIND_COMPLETE, 0x07); + len = encode(&ack, wire); + assert(lora_trunk_parse(wire, len, &parsed) == + LORA_TRUNK_PARSE_OK); + ack.fragment_index = 0x03; + assert(!lora_trunk_v3_encode(&ack, wire, sizeof(wire), &len)); + ack = make_control( + &data, LORA_TRUNK_KIND_ABORT, + LORA_TRUNK_ABORT_RESOURCE_PRESSURE); + len = encode(&ack, wire); + assert(lora_trunk_parse(wire, len, &parsed) == + LORA_TRUNK_PARSE_OK); + ack.fragment_index = 0; + assert(!lora_trunk_v3_encode(&ack, wire, sizeof(wire), &len)); } static void test_two_senders_and_broadcast(void) @@ -259,7 +283,7 @@ static void test_deterministic_parser_fuzz(void) int main(void) { test_v3_round_trip_and_malformed(); - test_exact_ack_matching(); + test_exact_control_matching(); test_two_senders_and_broadcast(); test_v2_policy(); test_neighbor_routes_and_collision(); diff --git a/tools/lora_hardware_smoke.py b/tools/lora_hardware_smoke.py index 1d07846..bd106cc 100755 --- a/tools/lora_hardware_smoke.py +++ b/tools/lora_hardware_smoke.py @@ -37,6 +37,13 @@ r"malformed=(\d+) unsupported=(\d+) ack_reject=(\d+) " r"neighbors=(\d+) collision=(\d+)" ) +RELIABILITY_DIAG_RE = re.compile( + r"bitmap=(\d+) final=(\d+) dup=(\d+)/(\d+) " + r"abort=(\d+)/(\d+) invalid=(\d+) resource=(\d+)" +) +CONTROL_RE = re.compile( + r"v3 control (TX|RX) kind=(\d+) value=0x([0-9a-fA-F]{2})" +) def reader( @@ -171,12 +178,41 @@ def main() -> int: summary[label]["diagnostics"][-1].update(protocol_diag) else: summary[label]["diagnostics"].append(protocol_diag) + if match := RELIABILITY_DIAG_RE.search(line): + reliability_diag = { + "bitmap_acks": int(match.group(1)), + "complete_acks": int(match.group(2)), + "duplicate_fragments": int(match.group(3)), + "duplicate_transfers": int(match.group(4)), + "transfer_aborts_tx": int(match.group(5)), + "transfer_aborts_rx": int(match.group(6)), + "invalid_reassemblies": int(match.group(7)), + "resource_aborts": int(match.group(8)), + } + if summary[label]["diagnostics"]: + summary[label]["diagnostics"][-1].update( + reliability_diag + ) + else: + summary[label]["diagnostics"].append( + reliability_diag + ) if "authenticated neighbor" in line: summary[label]["events"]["authenticated_neighbor"] += 1 - if "v3 ack TX" in line: - summary[label]["events"]["v3_ack_tx"] += 1 - if "v3 ack RX" in line: - summary[label]["events"]["v3_ack_rx"] += 1 + if match := CONTROL_RE.search(line): + direction = match.group(1).lower() + kind = int(match.group(2)) + value = int(match.group(3), 16) + names = {2: "bitmap", 3: "complete", 4: "abort"} + if kind in names: + summary[label]["events"][ + f"v3_{names[kind]}_{direction}" + ] += 1 + summary[label]["events"][ + f"v3_{names[kind]}_{direction}_value_{value}" + ] += 1 + if "trunk transfer COMPLETE" in line: + summary[label]["events"]["transfer_complete"] += 1 if "TX watchdog expired" in line: summary[label]["events"]["tx_watchdog_expired"] += 1 if "radio recovered on attempt" in line: diff --git a/tools/lora_reliability_sim.py b/tools/lora_reliability_sim.py index 1f09874..be3d041 100755 --- a/tools/lora_reliability_sim.py +++ b/tools/lora_reliability_sim.py @@ -189,3 +189,170 @@ def reassembly_expires( elapsed += ack_airtime span = elapsed - (first_rx_at or 0) return span > self.config.reassembly_timeout_ms, span + + +@dataclass(frozen=True) +class SelectiveRepeatConfig: + data_loss: float = 0.0 + ack_loss: float = 0.0 + duplication: float = 0.0 + reorder: bool = False + feedback_delay_rounds: int = 0 + maximum_rounds: int = 8 + chunk_size: int = 120 + seed: int = 1 + + +@dataclass(frozen=True) +class TransferResult: + complete: bool + delivered: bool + delivery_count: int + rounds: int + data_attempts: int + feedback_attempts: int + + +class SelectiveRepeatModel: + """Executable specification of cumulative bitmap/COMPLETE behavior.""" + + def __init__(self, config: SelectiveRepeatConfig): + for name, value in ( + ("data_loss", config.data_loss), + ("ack_loss", config.ack_loss), + ("duplication", config.duplication), + ): + if not 0.0 <= value <= 1.0: + raise ValueError(f"{name} must be between zero and one") + if config.maximum_rounds <= 0 or config.chunk_size <= 0: + raise ValueError("round and chunk bounds must be positive") + if config.feedback_delay_rounds < 0: + raise ValueError("feedback delay cannot be negative") + self.config = config + self.random = random.Random(config.seed) + + @staticmethod + def transfer_id(boot_epoch: bytes, counter: int) -> bytes: + if len(boot_epoch) != 8: + raise ValueError("boot epoch must contain eight bytes") + if not 1 <= counter <= 0xFFFFFFFF: + raise OverflowError("transfer counter exhausted") + return boot_epoch + counter.to_bytes(4, "big") + + def transfer( + self, packet_len: int, *, receiver_restart_round: int | None = None + ) -> TransferResult: + fragment_total = math.ceil(packet_len / self.config.chunk_size) + if packet_len <= 0 or not 1 <= fragment_total <= 5: + raise ValueError("packet must contain one through five fragments") + wanted = (1 << fragment_total) - 1 + receiver_mask = 0 + sender_mask = 0 + delivered = False + delivery_count = 0 + complete = False + data_attempts = 0 + feedback_attempts = 0 + pending_feedback: list[tuple[int, int, bool]] = [] + + for round_index in range(self.config.maximum_rounds): + if receiver_restart_round == round_index and not delivered: + receiver_mask = 0 + pending_feedback.clear() + + for due, bitmap, is_complete in list(pending_feedback): + if due <= round_index: + pending_feedback.remove((due, bitmap, is_complete)) + if self.random.random() >= self.config.ack_loss: + sender_mask |= bitmap + complete = complete or is_complete + if complete: + return TransferResult( + True, delivered, delivery_count, round_index, + data_attempts, feedback_attempts + ) + + missing = wanted & ~sender_mask + fragments = [ + index + for index in range(fragment_total) + if missing & (1 << index) + ] + if not fragments: + fragments = [fragment_total - 1] + if self.config.reorder: + self.random.shuffle(fragments) + + for index in fragments: + data_attempts += 1 + if self.random.random() < self.config.data_loss: + continue + copies = ( + 2 + if self.random.random() < self.config.duplication + else 1 + ) + for _ in range(copies): + receiver_mask |= 1 << index + if receiver_mask == wanted and not delivered: + delivered = True + delivery_count += 1 + feedback_attempts += 1 + pending_feedback.append( + ( + round_index + + self.config.feedback_delay_rounds, + receiver_mask, + receiver_mask == wanted, + ) + ) + + for due, bitmap, is_complete in list(pending_feedback): + if due <= round_index: + pending_feedback.remove((due, bitmap, is_complete)) + if self.random.random() >= self.config.ack_loss: + sender_mask |= bitmap + complete = complete or is_complete + if complete: + return TransferResult( + True, delivered, delivery_count, round_index + 1, + data_attempts, feedback_attempts + ) + + return TransferResult( + False, delivered, delivery_count, + self.config.maximum_rounds, + data_attempts, feedback_attempts + ) + + +class BoundedReassemblyModel: + """Capacity model matching firmware's reject-new-transfer policy.""" + + def __init__(self, active_capacity: int = 2, completed_capacity: int = 6): + if active_capacity <= 0 or completed_capacity <= 0: + raise ValueError("capacities must be positive") + self.active_capacity = active_capacity + self.completed_capacity = completed_capacity + self.active: set[bytes] = set() + self.completed: list[bytes] = [] + self.resource_aborts = 0 + + def accept_fragment(self, transfer_id: bytes) -> bool: + if transfer_id in self.completed or transfer_id in self.active: + return True + if len(self.active) >= self.active_capacity: + self.resource_aborts += 1 + return False + self.active.add(transfer_id) + return True + + def complete(self, transfer_id: bytes) -> bool: + if transfer_id in self.completed: + return False + if transfer_id not in self.active: + return False + self.active.remove(transfer_id) + self.completed.append(transfer_id) + self.completed = self.completed[-self.completed_capacity :] + return True diff --git a/tools/run_lora_host_tests.sh b/tools/run_lora_host_tests.sh index 9d6f81f..79d34b4 100755 --- a/tools/run_lora_host_tests.sh +++ b/tools/run_lora_host_tests.sh @@ -6,7 +6,8 @@ airtime_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-airtime.XXXXXX")" region_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-region.XXXXXX")" scheduler_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-scheduler.XXXXXX")" trunk_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-trunk.XXXXXX")" -trap 'rm -f "$airtime_bin" "$region_bin" "$scheduler_bin" "$trunk_bin"' EXIT +reliability_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-reliability.XXXXXX")" +trap 'rm -f "$airtime_bin" "$region_bin" "$scheduler_bin" "$trunk_bin" "$reliability_bin"' EXIT cc -std=c11 -Wall -Wextra -Werror \ -I"$repo_root/main" \ @@ -37,5 +38,13 @@ cc -std=c11 -Wall -Wextra -Werror \ -o "$trunk_bin" "$trunk_bin" +cc -std=c11 -Wall -Wextra -Werror \ + -I"$repo_root/main" \ + "$repo_root/tests/test_lora_packet_reliability.c" \ + "$repo_root/main/lora_packet_reliability.c" \ + "$repo_root/main/lora_airtime.c" \ + -lm -o "$reliability_bin" +"$reliability_bin" + cd "$repo_root" python3 -m unittest discover -s tests -p 'test_lora_*.py' -v From 02d6495164914b3fa7be38f5c90c6e3fd733dc2f Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:39:26 +0200 Subject: [PATCH 14/19] docs(lora): record milestone 4 checkpoint --- docs/LoRa-reliability-implementation-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index 65eefee..97face3 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -63,7 +63,7 @@ The implementation is complete only when all of the following are true: | 1. Airtime-safe SX1262 operation | Complete | `028511e` | Host/profile tests, both firmware targets, and SF10-SF12 two-board radio tests passed | | 2. Atomic packet scheduling and backpressure | Complete | `fe9e95e` | Concurrent saturation, both firmware targets, and two-board SF10 smoke passed | | 3. Addressed trunk protocol and migration | Complete | `3ee5843` | v2/v3 host protocol suite, both firmware targets, and addressed two-board v3 smoke passed | -| 4. Packet-level reliability and reassembly | Complete | Pending checkpoint | 99.35% sender completion at 30% data/feedback loss; five-of-five two-board transfer gate | +| 4. Packet-level reliability and reassembly | Complete | `33cfde3` | 99.35% sender completion at 30% data/feedback loss; five-of-five two-board transfer gate | | 5. Discovery and shared-channel behavior | Pending | — | — | | 6. LoRa multi-hop forwarding | Pending | — | — | | 7. Link adaptation and PHY hardening | Pending | — | — | @@ -421,7 +421,7 @@ reliability that remains correct under loss, duplicates, and reordering. within the transfer's bounded absolute deadline. - Both test boards were restored to the normal SF10 image with diagnostic smoke disabled after the capture. -- Commit: Pending checkpoint +- Commit: `33cfde3` - Suggested commit subject: `feat(lora): add packet-level selective repeat ARQ` ## Milestone 5: Discovery and shared-channel behavior From 873eddfee5233cbb15e0c9723ceeeeeda09e2789 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:59:59 +0200 Subject: [PATCH 15/19] feat(lora): harden discovery and channel access --- .gitignore | 1 + docs/LoRa-reliability-implementation-plan.md | 62 +- main/CMakeLists.txt | 1 + main/bitle_lora.c | 608 +++++++++++++++---- main/bitle_lora.h | 6 + main/lora_channel_policy.c | 62 ++ main/lora_channel_policy.h | 37 ++ main/lora_trunk_protocol.c | 166 ++++- main/lora_trunk_protocol.h | 29 + tests/test_lora_channel_behavior.py | 99 +++ tests/test_lora_channel_policy.c | 36 ++ tests/test_lora_trunk_protocol.c | 66 ++ tools/lora_hardware_smoke.py | 36 ++ tools/lora_reliability_sim.py | 131 ++++ tools/run_lora_host_tests.sh | 11 +- 15 files changed, 1203 insertions(+), 148 deletions(-) create mode 100644 main/lora_channel_policy.c create mode 100644 main/lora_channel_policy.h create mode 100644 tests/test_lora_channel_behavior.py create mode 100644 tests/test_lora_channel_policy.c diff --git a/.gitignore b/.gitignore index ad145f4..715d219 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # ESP-IDF build output build/ +build-*/ cmake-build-*/ CMakeFiles/ CMakeCache.txt diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index 97face3..dfd84c6 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -64,7 +64,7 @@ The implementation is complete only when all of the following are true: | 2. Atomic packet scheduling and backpressure | Complete | `fe9e95e` | Concurrent saturation, both firmware targets, and two-board SF10 smoke passed | | 3. Addressed trunk protocol and migration | Complete | `3ee5843` | v2/v3 host protocol suite, both firmware targets, and addressed two-board v3 smoke passed | | 4. Packet-level reliability and reassembly | Complete | `33cfde3` | 99.35% sender completion at 30% data/feedback loss; five-of-five two-board transfer gate | -| 5. Discovery and shared-channel behavior | Pending | — | — | +| 5. Discovery and shared-channel behavior | Complete | Pending checkpoint | 100% simulated discovery; three-repeat two-board beacon gate passed | | 6. LoRa multi-hop forwarding | Pending | — | — | | 7. Link adaptation and PHY hardening | Pending | — | — | | 8. End-to-end validation and rollout | Pending | — | — | @@ -433,23 +433,23 @@ traffic from repeatedly colliding. ### Checklist -- [ ] Define a compact, single-frame trunk discovery beacon containing node ID, +- [x] Define a compact, single-frame trunk discovery beacon containing node ID, protocol version, capabilities, regional profile, and a freshness value. -- [ ] Keep BitChat identity and signed application announces separate from +- [x] Keep BitChat identity and signed application announces separate from minimum viable trunk-neighbor discovery. -- [ ] Randomize first-beacon and periodic-beacon timing. -- [ ] Add controlled beacon repetition or another bounded reliability mechanism +- [x] Randomize first-beacon and periodic-beacon timing. +- [x] Add controlled beacon repetition or another bounded reliability mechanism that does not create ACK implosion. -- [ ] Make announce throttling aware of whether a previous announce was actually +- [x] Make announce throttling aware of whether a previous announce was actually admitted and transmitted. -- [ ] Scale contention windows to packet airtime rather than using a fixed +- [x] Scale contention windows to packet airtime rather than using a fixed 30–150 ms interval. -- [ ] Recheck CAD immediately before TX after the backoff expires. -- [ ] Keep the radio in RX during software backoff whenever the SX1262 mode +- [x] Recheck CAD immediately before TX after the backoff expires. +- [x] Keep the radio in RX during software backoff whenever the SX1262 mode permits it. -- [ ] Add ACK priority without allowing an unlimited ACK stream to starve data. -- [ ] Add randomized scheduling for relays that hear the same broadcast. -- [ ] Test hidden-terminal, simultaneous-beacon, bidirectional-data, and +- [x] Add ACK priority without allowing an unlimited ACK stream to starve data. +- [x] Add randomized scheduling for relays that hear the same broadcast. +- [x] Test hidden-terminal, simultaneous-beacon, bidirectional-data, and four-receiver scenarios. ### Success criteria @@ -465,9 +465,43 @@ traffic from repeatedly colliding. ### Milestone record -- Status: Pending +- Status: Complete - Evidence: -- Commit: + - Trunk v3 now defines an exact 44-byte discovery beacon. The existing + header carries protocol version, capabilities, the eight-byte radio node + ID, an eight-byte boot epoch, a wrap-safe 32-bit freshness counter, and + the regional profile. Beacon semantics prohibit payloads, destinations, + and ACK requests. + - Minimum radio reachability is learned separately from signed BitChat/Noise + identity. An unauthenticated beacon cannot create an addressed BitChat + route; a subsequently verified signed announce attaches identity to the + existing radio-neighbor entry. + - Each randomized beacon interval emits three independently delayed, + budgeted copies without receiver ACKs. Repeated freshness values are + classified as stale, and a new boot epoch safely starts a new sequence. + Signed application announces retain their own randomized schedule and + advance their throttle only after the complete packet is transmitted. + - The fixed blocking CAD delay is replaced by a nonblocking, airtime-scaled + contention deadline. The SX1262 stays in continuous RX during backoff, + performs CAD immediately before TX, expands the window after busy results, + and gives relayed broadcasts an additional randomized defer. ACKs preempt + promptly but are capped at four consecutive controls before packet traffic + receives an opportunity. + - The portable host suite passes 20 tests. Across 100 deterministic + four-node seeds with 30% independent beacon loss, all radio-neighbor pairs + were discovered within three intervals (100% mean and minimum, exceeding + the 95% gate). Simultaneous boot, hidden senders, bidirectional ACK/data + service, four receivers, airtime budget, route retention, wire validation, + freshness, and identity separation are covered. + - ESP-IDF 6.0 builds pass for the normal Heltec V3 ESP32-S3 configuration + and the BLE-only ESP32-C3 target. + - In the final 68-second SF10 two-board capture, both nodes transmitted a + complete three-copy beacon burst and received the peer's compact beacon. + No beacon requested or produced a control ACK. Both nodes reported zero + radio timeouts, radio-command errors, BUSY timeouts, recovery failures, + and software-watchdog recoveries; channel-backoff and airtime-budget + counters were active. +- Commit: Pending checkpoint - Suggested commit subject: `feat(lora): harden discovery and channel access` ## Milestone 6: LoRa multi-hop forwarding diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 8caf4e9..6e574bf 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -10,6 +10,7 @@ idf_component_register( "bitle_mesh.c" "bitle_lora.c" "lora_airtime.c" + "lora_channel_policy.c" "lora_packet_reliability.c" "lora_region.c" "lora_trunk_protocol.c" diff --git a/main/bitle_lora.c b/main/bitle_lora.c index c64ca6b..4610ffb 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -16,6 +16,7 @@ #include "bitle_mesh.h" #include "bitle_stats.h" #include "lora_airtime.h" +#include "lora_channel_policy.h" #include "lora_packet_reliability.h" #include "lora_region.h" #include "lora_trunk_protocol.h" @@ -109,12 +110,7 @@ static const char *TAG = "bitle_lora"; #define TRUNK_MAX_FRAGS ((BITCHAT_BLE_MAX_PACKET_SIZE + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX) /* Channel access: CAD (listen-before-talk) + random backoff before TX. */ -#define CAD_RETRIES 5 -#define CAD_BACKOFF_MIN 30 -#define CAD_BACKOFF_SPAN 120 -#define ACK_BURST_MAX 4 -#define RELIABILITY_CONTENTION_MS \ - (1200U + CAD_RETRIES * (CAD_BACKOFF_MIN + CAD_BACKOFF_SPAN)) +#define CAD_RETRIES 5 typedef struct { uint16_t len; @@ -135,6 +131,9 @@ static uint32_t s_transfer_counter; typedef struct { bool valid; bool ack_requested; + bool announcement; + bool relayed_broadcast; + uint8_t announce_sender[8]; uint8_t destination[LORA_TRUNK_V3_NODE_ID_LEN]; uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN]; } tx_trunk_context_t; @@ -154,6 +153,7 @@ static uint8_t s_sf; static uint32_t s_bw; static uint8_t s_cr; /* denominator 5..8 => 4/5..4/8 */ static uint16_t s_preamble_symbols; +static lora_region_id_t s_region; static bool s_ota_over_trunk; /* OTA image chunks on the trunk (default off) */ static bitle_lora_diag_t s_diag; @@ -164,6 +164,7 @@ static uint64_t s_gov_last_ms; typedef struct { bool in_use; + bool pending; uint8_t tag[8]; uint64_t last_ms; } throttle_t; @@ -186,12 +187,19 @@ static uint32_t trunk_airtime_ms(uint16_t payload_len) static bool trunk_reliability_timing( uint8_t fragment_total, lora_reliability_timing_t *out) { + uint32_t maximum_airtime = + trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN + TRUNK_CHUNK_TX); + lora_contention_window_t contention; + if (!lora_contention_window( + maximum_airtime, CAD_RETRIES, false, &contention)) { + return false; + } return lora_reliability_timing( - trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN + TRUNK_CHUNK_TX), + maximum_airtime, trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN), fragment_total, LORA_SELECTIVE_REPEAT_ROUNDS, - RELIABILITY_CONTENTION_MS, + contention.maximum_ms, out); } @@ -217,9 +225,10 @@ static void diag_sync_scheduler_locked(void) } } -/* Per-origin announce throttle (call inside the governor critical section). - * Returns true if this sender's announce should be dropped now. */ -static bool announce_throttled(const uint8_t *sender, uint64_t now) +/* Per-origin announce throttle. Admission marks one pending copy; only a + * fully transmitted packet advances last_ms. Caller holds s_gov_mux. */ +static throttle_t *announce_slot_locked( + const uint8_t *sender, bool create) { throttle_t *slot = NULL, *free_slot = NULL, *oldest = &s_throttle[0]; for (size_t i = 0; i < THROTTLE_SLOTS; ++i) { @@ -233,22 +242,90 @@ static bool announce_throttled(const uint8_t *sender, uint64_t now) oldest = t; } } + if (!slot && create) { + slot = free_slot ? free_slot : oldest; + memset(slot, 0, sizeof(*slot)); + slot->in_use = true; + memcpy(slot->tag, sender, sizeof(slot->tag)); + } + return slot; +} + +static bool announce_allowed_locked(const uint8_t *sender, uint64_t now) +{ + throttle_t *slot = announce_slot_locked(sender, false); + return !slot || + (!slot->pending && + (slot->last_ms == 0 || now < slot->last_ms || + now - slot->last_ms >= ANNOUNCE_MIN_INTERVAL_MS)); +} + +static void announce_mark_pending_locked(const uint8_t *sender) +{ + throttle_t *slot = announce_slot_locked(sender, true); if (slot) { - if (now - slot->last_ms < ANNOUNCE_MIN_INTERVAL_MS) { - return true; - } + slot->pending = true; + } +} + +static void announce_finish_locked( + const uint8_t *sender, bool transmitted, uint64_t now) +{ + throttle_t *slot = announce_slot_locked(sender, false); + if (!slot) { + return; + } + slot->pending = false; + if (transmitted) { slot->last_ms = now; - return false; + s_diag.announce_tx++; + } else if (slot->last_ms == 0) { + memset(slot, 0, sizeof(*slot)); } - slot = free_slot ? free_slot : oldest; - slot->in_use = true; - memcpy(slot->tag, sender, 8); - slot->last_ms = now; - return false; } /* Admission + governor for one whole encoded packet. Caller holds s_gov_mux, * which couples the policy state change to the scheduler admission. */ +static void governor_refill_locked(uint64_t now) +{ + if (now < s_gov_last_ms) { + s_gov_last_ms = now; + return; + } + s_gov_credit_ms += (double)(now - s_gov_last_ms) * s_gov_refill_frac; + if (s_gov_credit_ms > GOV_BURST_MS) { + s_gov_credit_ms = GOV_BURST_MS; + } + s_gov_last_ms = now; +} + +static bool governor_debit_locked(uint32_t airtime, bool governed) +{ + uint64_t now = esp_timer_get_time() / 1000ULL; + governor_refill_locked(now); + if (governed && s_gov_credit_ms < (double)airtime) { + return false; + } + s_gov_credit_ms -= airtime; + if (s_gov_credit_ms < -GOV_BURST_MS) { + s_gov_credit_ms = -GOV_BURST_MS; + } + return true; +} + +static bool beacon_budget_admit(uint16_t encoded_len) +{ + bool admitted; + taskENTER_CRITICAL(&s_gov_mux); + admitted = governor_debit_locked( + trunk_airtime_ms(encoded_len), true); + if (!admitted) { + s_diag.beacon_budget_deferred++; + } + taskEXIT_CRITICAL(&s_gov_mux); + return admitted; +} + static bool trunk_admit_locked(const uint8_t *data, uint16_t len) { if (len < PKT_MIN_LEN) { @@ -285,15 +362,8 @@ static bool trunk_admit_locked(const uint8_t *data, uint16_t len) airtime += trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN + chunk); } - /* Reading the clock under the lock prevents another producer from - * advancing s_gov_last_ms past this sample. */ uint64_t now = esp_timer_get_time() / 1000ULL; - /* Refill the shared airtime credit. */ - s_gov_credit_ms += (double)(now - s_gov_last_ms) * s_gov_refill_frac; - if (s_gov_credit_ms > GOV_BURST_MS) { - s_gov_credit_ms = GOV_BURST_MS; - } - s_gov_last_ms = now; + governor_refill_locked(now); /* Governed (announce) traffic yields when the budget is exhausted; it is * periodic and will re-announce. Message traffic always passes but still * debits, so a busy DM session naturally suppresses announces until the @@ -305,15 +375,11 @@ static bool trunk_admit_locked(const uint8_t *data, uint16_t len) ESP_LOGD(TAG, "airtime budget low; deferring announce"); return false; } - if (announce_throttled(data + PKT_SENDER_OFF, now)) { + if (!announce_allowed_locked(data + PKT_SENDER_OFF, now)) { return false; } } - s_gov_credit_ms -= airtime; - if (s_gov_credit_ms < -GOV_BURST_MS) { - s_gov_credit_ms = -GOV_BURST_MS; - } - return true; + return governor_debit_locked(airtime, false); } /* Reassembly stays statically bounded. Progress and absolute deadlines are @@ -496,6 +562,9 @@ static bitle_link_send_result_t lora_link_commit( uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN] = {0}; uint8_t destination[LORA_TRUNK_V3_NODE_ID_LEN] = {0}; bool ack_requested = false; + bool announcement = + type == BITCHAT_MSG_ANNOUNCE || + type == BITCHAT_MSG_NOISE_IDENTITY_ANNOUNCE; bool committed = false; taskENTER_CRITICAL(&s_gov_mux); @@ -547,6 +616,20 @@ static bitle_link_send_result_t lora_link_commit( tx_trunk_context_t *context = &s_tx_context[reserved]; context->valid = true; context->ack_requested = ack_requested; + context->announcement = announcement; + context->relayed_broadcast = + !ack_requested && + memcmp( + data + PKT_SENDER_OFF, s_node_id, + sizeof(s_node_id)) != 0; + if (announcement) { + memcpy( + context->announce_sender, + data + PKT_SENDER_OFF, + sizeof(context->announce_sender)); + announce_mark_pending_locked( + context->announce_sender); + } memcpy(context->destination, destination, sizeof(destination)); memcpy(context->transfer_id, transfer_id, sizeof(transfer_id)); s_transfer_counter = transfer_counter; @@ -601,12 +684,19 @@ static bool tx_scheduler_dequeue(lora_tx_handle_t *out_handle) return dequeued; } -static void tx_scheduler_release(lora_tx_handle_t handle) +static void tx_scheduler_release( + lora_tx_handle_t handle, bool transmitted) { if (handle == LORA_TX_HANDLE_INVALID) { return; } taskENTER_CRITICAL(&s_gov_mux); + tx_trunk_context_t context = s_tx_context[handle]; + if (context.announcement) { + announce_finish_locked( + context.announce_sender, transmitted, + esp_timer_get_time() / 1000ULL); + } memset(&s_tx_context[handle], 0, sizeof(s_tx_context[handle])); lora_tx_scheduler_release(&s_tx_scheduler, handle); diag_sync_scheduler_locked(); @@ -1153,6 +1243,35 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) ESP_LOGI(TAG, "trunk v%u raw RX len=%u rssi=%d snr=%d kind=%u", frame.version, len, rssi, snr, frame.kind); + if (frame.kind == LORA_TRUNK_KIND_BEACON) { + uint8_t region = 0; + uint32_t freshness = 0; + if (!lora_trunk_beacon_fields( + &frame, ®ion, &freshness) || + region != (uint8_t)s_region) { + return; + } + lora_trunk_neighbor_result_t result; + taskENTER_CRITICAL(&s_gov_mux); + result = lora_trunk_neighbor_learn_beacon( + &s_neighbors, frame.source, frame.transfer_id, + freshness, region, frame.capabilities, + esp_timer_get_time() / 1000ULL); + s_diag.beacon_rx++; + if (result == LORA_TRUNK_NEIGHBOR_STALE) { + s_diag.beacon_stale++; + } else if (result == LORA_TRUNK_NEIGHBOR_COLLISION) { + s_diag.neighbor_collisions++; + } + s_diag.neighbor_count = + (uint32_t)lora_trunk_neighbor_count(&s_neighbors); + taskEXIT_CRITICAL(&s_gov_mux); + ESP_LOGI( + TAG, "trunk beacon RX freshness=%lu result=%u", + (unsigned long)freshness, result); + return; + } + if (frame.kind != LORA_TRUNK_KIND_DATA) { /* Shared-channel controls for other senders are normal. A control * addressed to us must match the selected peer and the complete @@ -1207,16 +1326,110 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) reassemble_data(&frame, addressed_to_us, rssi, snr); } -/* Neighbor discovery: our signed announce over the trunk, so two nodes - * with no phone in sight still find and verify each other. */ +/* Minimum viable radio-neighbor discovery is intentionally separate from the + * signed BitChat/Noise identity announce. A compact beacon is repeated without + * ACKs; the signed application announce follows its own randomized cadence. */ #define BEACON_FIRST_MS 5000ULL #define BEACON_INTERVAL_MS 60000ULL #define BEACON_START_JITTER_MS 10000U #define BEACON_INTERVAL_JITTER_MS 30000U +#define BEACON_REPEAT_MIN_MS 1000U +#define BEACON_REPEAT_SPAN_MS 4000U + +typedef struct { + uint32_t freshness; + uint8_t remaining; + uint64_t next_burst_ms; + uint64_t next_repeat_ms; +} beacon_schedule_t; + +static void beacon_schedule_begin( + beacon_schedule_t *schedule, uint64_t now) +{ + schedule->next_burst_ms = + now + BEACON_INTERVAL_MS - + (BEACON_INTERVAL_JITTER_MS / 2U) + + (esp_random() % BEACON_INTERVAL_JITTER_MS); + if (schedule->remaining != 0) { + return; + } + schedule->freshness++; + if (schedule->freshness == 0) { + schedule->freshness = 1; + } + schedule->remaining = LORA_CHANNEL_BEACON_REPETITIONS; + schedule->next_repeat_ms = + now + (esp_random() % BEACON_START_JITTER_MS); +} + +static void beacon_schedule_finish_attempt( + beacon_schedule_t *schedule, bool *have_pending, + bool *pending_is_beacon, uint64_t now) +{ + if (schedule->remaining > 0) { + schedule->remaining--; + } + schedule->next_repeat_ms = + schedule->remaining == 0 + ? UINT64_MAX + : now + BEACON_REPEAT_MIN_MS + + (esp_random() % BEACON_REPEAT_SPAN_MS); + *have_pending = false; + *pending_is_beacon = false; +} + +static bool build_beacon_frame( + uint32_t freshness, lora_frame_t *frame) +{ + lora_trunk_frame_t beacon; + if (!lora_trunk_beacon_init( + &beacon, s_node_id, s_boot_epoch, freshness, + (uint8_t)s_region, LORA_TRUNK_V3_CAP_PROTOCOL)) { + return false; + } + size_t encoded_len = 0; + if (!lora_trunk_v3_encode( + &beacon, frame->data, sizeof(frame->data), + &encoded_len)) { + return false; + } + frame->len = (uint16_t)encoded_len; + frame->want_ack = false; + return encoded_len == LORA_TRUNK_V3_HEADER_LEN; +} + +static bool schedule_contention_backoff( + const lora_frame_t *frame, uint8_t busy_attempt, + bool relayed_broadcast, uint64_t *deadline) +{ + lora_contention_window_t window; + if (!frame || !deadline || + !lora_contention_window( + trunk_airtime_ms(frame->len), busy_attempt, + relayed_broadcast, &window)) { + return false; + } + if (!resume_rx_or_recover("contention backoff resume RX")) { + return false; + } + *deadline = + esp_timer_get_time() / 1000ULL + + lora_contention_delay(&window, esp_random()); + diag_inc(&s_diag.channel_backoffs); + return true; +} static void drop_tx_packet(lora_tx_handle_t *handle, bool *have_pending) { - tx_scheduler_release(*handle); + tx_scheduler_release(*handle, false); + *handle = LORA_TX_HANDLE_INVALID; + *have_pending = false; +} + +static void complete_tx_packet( + lora_tx_handle_t *handle, bool *have_pending) +{ + tx_scheduler_release(*handle, true); *handle = LORA_TX_HANDLE_INVALID; *have_pending = false; } @@ -1248,14 +1461,14 @@ static bool advance_tx_packet(lora_tx_handle_t *handle, uint8_t *fragment_idx, { (*fragment_idx)++; if (*fragment_idx >= fragment_total) { - tx_scheduler_release(*handle); + tx_scheduler_release(*handle, true); *handle = LORA_TX_HANDLE_INVALID; return false; } uint8_t checked_total = 0; if (!build_tx_fragment(*handle, *fragment_idx, frame, &checked_total) || checked_total != fragment_total) { - tx_scheduler_release(*handle); + tx_scheduler_release(*handle, false); *handle = LORA_TX_HANDLE_INVALID; return false; } @@ -1292,14 +1505,25 @@ static void lora_task(void *arg) bool awaiting_tx_done = false; bool tx_is_ack = false; bool awaiting_cad = false; + bool awaiting_backoff = false; bool awaiting_ack = false; + bool pending_is_beacon = false; + bool pending_relayed_broadcast = false; unsigned ack_burst = 0; uint64_t tx_watchdog_deadline = 0; + uint64_t backoff_deadline = 0; uint64_t ack_deadline = 0; uint64_t transfer_deadline = 0; lora_reliability_timing_t transfer_timing = {0}; - uint64_t next_beacon_ms = - BEACON_FIRST_MS + (esp_random() % BEACON_START_JITTER_MS); + beacon_schedule_t beacon_schedule = { + .next_burst_ms = + BEACON_FIRST_MS + + (esp_random() % BEACON_START_JITTER_MS), + .next_repeat_ms = UINT64_MAX, + }; + uint64_t next_identity_announce_ms = + BEACON_FIRST_MS + + (esp_random() % BEACON_START_JITTER_MS); uint64_t next_diag_ms = 60000ULL; #if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE static const uint16_t smoke_lengths[] = {100, 180, 300, 420, 520}; @@ -1315,13 +1539,16 @@ static void lora_task(void *arg) uint64_t now = esp_timer_get_time() / 1000ULL; expire_reassembly(now); - if (now >= next_beacon_ms) { - next_beacon_ms = + if (now >= beacon_schedule.next_burst_ms) { + beacon_schedule_begin(&beacon_schedule, now); + } + if (now >= next_identity_announce_ms) { + next_identity_announce_ms = now + BEACON_INTERVAL_MS - (BEACON_INTERVAL_JITTER_MS / 2U) + (esp_random() % BEACON_INTERVAL_JITTER_MS); if (noise_announce_link(BITLE_LORA_LINK_HANDLE)) { - ESP_LOGI(TAG, "trunk beacon sent"); + ESP_LOGI(TAG, "signed identity announce admitted"); } } @@ -1337,7 +1564,8 @@ static void lora_task(void *arg) for (size_t i = 0; i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { const lora_trunk_neighbor_t *entry = &s_neighbors.entries[i]; - if (entry->in_use && !entry->quarantined && + if (entry->in_use && entry->identity_authenticated && + !entry->quarantined && now >= entry->last_seen_ms && now - entry->last_seen_ms <= 180000ULL && (!freshest || @@ -1403,7 +1631,8 @@ static void lora_task(void *arg) "policy=%lu release=%lu cancel=%lu malformed=%lu " "unsupported=%lu ack_reject=%lu neighbors=%lu collision=%lu " "bitmap=%lu final=%lu dup=%lu/%lu abort=%lu/%lu " - "invalid=%lu resource=%lu", + "invalid=%lu resource=%lu beacon=%lu/%lu stale=%lu " + "budget=%lu backoff=%lu announce_tx=%lu", (unsigned long)diag.raw_rx_frames, (unsigned long)diag.crc_errors, (unsigned long)diag.tx_attempts, @@ -1447,7 +1676,13 @@ static void lora_task(void *arg) (unsigned long)diag.transfer_aborts_tx, (unsigned long)diag.transfer_aborts_rx, (unsigned long)diag.invalid_reassemblies, - (unsigned long)diag.resource_aborts); + (unsigned long)diag.resource_aborts, + (unsigned long)diag.beacon_tx, + (unsigned long)diag.beacon_rx, + (unsigned long)diag.beacon_stale, + (unsigned long)diag.beacon_budget_deferred, + (unsigned long)diag.channel_backoffs, + (unsigned long)diag.announce_tx); next_diag_ms = now + 60000ULL; } @@ -1477,9 +1712,16 @@ static void lora_task(void *arg) if (!resume_rx_or_recover("TX_DONE resume RX")) { tx_is_ack = false; if (have_pending) { - abort_tx_packet( - &packet_handle, &have_pending, - LORA_TRUNK_ABORT_RADIO_RESET); + if (pending_is_beacon) { + beacon_schedule_finish_attempt( + &beacon_schedule, &have_pending, + &pending_is_beacon, + esp_timer_get_time() / 1000ULL); + } else { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); + } } break; } @@ -1490,6 +1732,20 @@ static void lora_task(void *arg) ack_deadline += trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN) + 300; } + } else if (pending_is_beacon) { + diag_inc(&s_diag.beacon_tx); + ESP_LOGI( + TAG, + "trunk beacon TX freshness=%lu repeat=%u", + (unsigned long)beacon_schedule.freshness, + (unsigned)( + LORA_CHANNEL_BEACON_REPETITIONS - + beacon_schedule.remaining + 1u)); + beacon_schedule_finish_attempt( + &beacon_schedule, &have_pending, + &pending_is_beacon, + esp_timer_get_time() / 1000ULL); + cad_tries = 0; } else if (have_pending && pending.want_ack) { if (prepare_selective_fragment( packet_handle, &pending, fragment_total, @@ -1512,7 +1768,7 @@ static void lora_task(void *arg) awaiting_cad = false; if (s_ack_count > 0 && (!(have_pending || tx_scheduler_has_queued()) || - ack_burst < ACK_BURST_MAX)) { + ack_burst < LORA_CHANNEL_ACK_BURST_MAX)) { /* ACKs preempt boundedly; after four, queued packet * traffic gets one transmission opportunity. */ if (transmit_ack_now()) { @@ -1548,46 +1804,63 @@ static void lora_task(void *arg) sx1262_tx_watchdog_ms(pending.len); ack_burst = 0; } else { - abort_tx_packet( - &packet_handle, &have_pending, - LORA_TRUNK_ABORT_RADIO_RESET); + if (pending_is_beacon) { + beacon_schedule_finish_attempt( + &beacon_schedule, &have_pending, + &pending_is_beacon, + esp_timer_get_time() / 1000ULL); + } else { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); + } recover_radio("data transmit command"); } } break; case SX1262_EVT_CAD_BUSY: awaiting_cad = false; + if (have_pending && cad_tries < UINT8_MAX) { + cad_tries++; + } if (have_pending && - (pending.want_ack || ++cad_tries <= CAD_RETRIES)) { + (pending.want_ack || cad_tries <= CAD_RETRIES)) { /* An addressed transfer owns a bounded absolute deadline; * temporary channel occupancy must not discard it after a * small fixed number of CAD results. Return to RX during * backoff so packet feedback remains observable, then * retry until COMPLETE, ABORT, or the transfer deadline. */ - if (!resume_rx_or_recover( - "CAD busy backoff resume RX")) { - abort_tx_packet( - &packet_handle, &have_pending, - LORA_TRUNK_ABORT_RADIO_RESET); - break; - } - vTaskDelay(pdMS_TO_TICKS(CAD_BACKOFF_MIN + - (esp_random() % CAD_BACKOFF_SPAN))); - if (sx1262_start_cad() == ESP_OK) { - awaiting_cad = true; + if (schedule_contention_backoff( + &pending, (uint8_t)cad_tries, + pending_relayed_broadcast, + &backoff_deadline)) { + awaiting_backoff = true; } else { - abort_tx_packet( - &packet_handle, &have_pending, - LORA_TRUNK_ABORT_RADIO_RESET); - recover_radio("CAD retry command"); + if (pending_is_beacon) { + beacon_schedule_finish_attempt( + &beacon_schedule, &have_pending, + &pending_is_beacon, + esp_timer_get_time() / 1000ULL); + } else { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); + } } } else { /* Channel persistently busy: reject the complete packet; * sending only its earlier fragments cannot complete. */ if (have_pending) { - abort_tx_packet( - &packet_handle, &have_pending, - LORA_TRUNK_ABORT_RESOURCE_PRESSURE); + if (pending_is_beacon) { + beacon_schedule_finish_attempt( + &beacon_schedule, &have_pending, + &pending_is_beacon, + esp_timer_get_time() / 1000ULL); + } else { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RESOURCE_PRESSURE); + } } resume_rx_or_recover("CAD busy resume RX"); } @@ -1601,11 +1874,19 @@ static void lora_task(void *arg) tx_watchdog_deadline = 0; tx_is_ack = false; awaiting_ack = false; + awaiting_backoff = false; if (!resume_rx_or_recover("radio TX timeout")) { if (have_pending) { - abort_tx_packet( - &packet_handle, &have_pending, - LORA_TRUNK_ABORT_RADIO_RESET); + if (pending_is_beacon) { + beacon_schedule_finish_attempt( + &beacon_schedule, &have_pending, + &pending_is_beacon, + esp_timer_get_time() / 1000ULL); + } else { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); + } } } else if (timed_out_ack && data_was_waiting) { awaiting_ack = true; @@ -1621,6 +1902,11 @@ static void lora_task(void *arg) esp_timer_get_time() / 1000ULL + transfer_timing.feedback_wait_ms; } + } else if (!timed_out_ack && pending_is_beacon) { + beacon_schedule_finish_attempt( + &beacon_schedule, &have_pending, + &pending_is_beacon, + esp_timer_get_time() / 1000ULL); } else if (!timed_out_ack && have_pending) { drop_tx_packet(&packet_handle, &have_pending); } @@ -1635,13 +1921,21 @@ static void lora_task(void *arg) case SX1262_EVT_ERROR: awaiting_tx_done = false; awaiting_cad = false; + awaiting_backoff = false; awaiting_ack = false; tx_is_ack = false; tx_watchdog_deadline = 0; if (have_pending) { - abort_tx_packet( - &packet_handle, &have_pending, - LORA_TRUNK_ABORT_RADIO_RESET); + if (pending_is_beacon) { + beacon_schedule_finish_attempt( + &beacon_schedule, &have_pending, + &pending_is_beacon, + esp_timer_get_time() / 1000ULL); + } else { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); + } } recover_radio("IRQ status command"); break; @@ -1654,7 +1948,8 @@ static void lora_task(void *arg) * peer is listening right after its TX and is waiting on us. */ bool data_waiting = have_pending || tx_scheduler_has_queued(); if (s_ack_count > 0 && !awaiting_tx_done && !awaiting_cad && - (!data_waiting || ack_burst < ACK_BURST_MAX)) { + (!data_waiting || + ack_burst < LORA_CHANNEL_ACK_BURST_MAX)) { if (transmit_ack_now()) { ack_burst++; awaiting_tx_done = true; @@ -1674,13 +1969,20 @@ static void lora_task(void *arg) ESP_LOGW(TAG, "TX watchdog expired; resetting radio"); awaiting_tx_done = false; awaiting_cad = false; + awaiting_backoff = false; awaiting_ack = false; tx_is_ack = false; tx_watchdog_deadline = 0; if (have_pending) { - abort_tx_packet( - &packet_handle, &have_pending, - LORA_TRUNK_ABORT_RADIO_RESET); + if (pending_is_beacon) { + beacon_schedule_finish_attempt( + &beacon_schedule, &have_pending, + &pending_is_beacon, watchdog_now); + } else { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); + } } recover_radio("software TX watchdog"); } @@ -1698,8 +2000,13 @@ static void lora_task(void *arg) } awaiting_ack = false; awaiting_cad = false; + awaiting_backoff = false; s_feedback_seen = false; - drop_tx_packet(&packet_handle, &have_pending); + if (s_selective_tx.complete) { + complete_tx_packet(&packet_handle, &have_pending); + } else { + drop_tx_packet(&packet_handle, &have_pending); + } } else if (awaiting_ack && have_pending) { uint64_t now2 = esp_timer_get_time() / 1000ULL; if (now2 >= ack_deadline) { @@ -1731,60 +2038,110 @@ static void lora_task(void *arg) diag_inc(&s_diag.retry_exhaustion); awaiting_ack = false; awaiting_cad = false; + awaiting_backoff = false; abort_tx_packet( &packet_handle, &have_pending, LORA_TRUNK_ABORT_RETRY_EXHAUSTED); } - /* Re-arm a pending data frame that was stranded idle in every wait - * state — happens when an ack preempted its CAD result (CAD_CLEAR - * with s_ack_count>0), or when an ack transmit returned an error. - * Without this the frame sits forever with have_pending=true, which - * also blocks the dequeue below and half-deadlocks outbound TX. */ - if (have_pending && !awaiting_cad && !awaiting_tx_done && !awaiting_ack) { + /* Backoff is a task deadline, not a blocking delay: the radio remains + * in continuous RX and controls may preempt while the deadline runs. + * CAD is rechecked immediately before every actual transmission. */ + uint64_t channel_now = esp_timer_get_time() / 1000ULL; + if (awaiting_backoff && channel_now >= backoff_deadline && + !awaiting_tx_done && !awaiting_cad && !awaiting_ack) { + awaiting_backoff = false; if (sx1262_start_cad() == ESP_OK) { awaiting_cad = true; } else { - abort_tx_packet( - &packet_handle, &have_pending, - LORA_TRUNK_ABORT_RADIO_RESET); - recover_radio("pending CAD command"); + if (pending_is_beacon) { + beacon_schedule_finish_attempt( + &beacon_schedule, &have_pending, + &pending_is_beacon, channel_now); + } else if (have_pending) { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); + } + recover_radio("post-backoff CAD command"); } } - if (!have_pending && !awaiting_tx_done && !awaiting_cad && !awaiting_ack && - tx_scheduler_dequeue(&packet_handle)) { - fragment_idx = 0; - have_pending = build_tx_fragment( - packet_handle, fragment_idx, &pending, &fragment_total); - if (!have_pending) { - drop_tx_packet(&packet_handle, &have_pending); - continue; + if (!have_pending && !awaiting_tx_done && !awaiting_cad && + !awaiting_backoff && !awaiting_ack) { + if (beacon_schedule.remaining > 0 && + channel_now >= beacon_schedule.next_repeat_ms) { + if (build_beacon_frame( + beacon_schedule.freshness, &pending) && + beacon_budget_admit(pending.len)) { + packet_handle = LORA_TX_HANDLE_INVALID; + fragment_idx = 0; + fragment_total = 1; + have_pending = true; + pending_is_beacon = true; + pending_relayed_broadcast = false; + s_expected_control_valid = false; + } else { + beacon_schedule.next_repeat_ms = + channel_now + BEACON_REPEAT_MIN_MS + + (esp_random() % BEACON_REPEAT_SPAN_MS); + } } - if (pending.want_ack && - (!lora_selective_tx_init( - &s_selective_tx, fragment_total) || - !trunk_reliability_timing( - fragment_total, &transfer_timing))) { - abort_tx_packet( - &packet_handle, &have_pending, - LORA_TRUNK_ABORT_INVALID_LENGTH); - continue; + + if (!have_pending && + tx_scheduler_dequeue(&packet_handle)) { + fragment_idx = 0; + pending_is_beacon = false; + pending_relayed_broadcast = + s_tx_context[packet_handle].relayed_broadcast; + have_pending = build_tx_fragment( + packet_handle, fragment_idx, &pending, + &fragment_total); + if (!have_pending) { + drop_tx_packet( + &packet_handle, &have_pending); + continue; + } + if (pending.want_ack && + (!lora_selective_tx_init( + &s_selective_tx, fragment_total) || + !trunk_reliability_timing( + fragment_total, &transfer_timing))) { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_INVALID_LENGTH); + continue; + } + cad_tries = 0; + s_feedback_seen = false; + transfer_deadline = + pending.want_ack + ? channel_now + + transfer_timing.absolute_timeout_ms + : 0; } + } + + /* A newly selected fragment, a selective-repeat round, or a frame + * whose CAD was preempted by control traffic all enter a fresh + * airtime-scaled contention window. */ + if (have_pending && !awaiting_tx_done && !awaiting_cad && + !awaiting_backoff && !awaiting_ack) { cad_tries = 0; - s_feedback_seen = false; - transfer_deadline = - pending.want_ack - ? esp_timer_get_time() / 1000ULL + - transfer_timing.absolute_timeout_ms - : 0; - if (sx1262_start_cad() == ESP_OK) { - awaiting_cad = true; + if (schedule_contention_backoff( + &pending, 0, pending_relayed_broadcast, + &backoff_deadline)) { + awaiting_backoff = true; } else { - abort_tx_packet( - &packet_handle, &have_pending, - LORA_TRUNK_ABORT_RADIO_RESET); - recover_radio("dequeue CAD command"); + if (pending_is_beacon) { + beacon_schedule_finish_attempt( + &beacon_schedule, &have_pending, + &pending_is_beacon, channel_now); + } else { + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RADIO_RESET); + } } } } @@ -1824,6 +2181,7 @@ void bitle_lora_shutdown(void) taskENTER_CRITICAL(&s_gov_mux); lora_tx_scheduler_cancel_all(&s_tx_scheduler); memset(s_tx_context, 0, sizeof(s_tx_context)); + memset(s_throttle, 0, sizeof(s_throttle)); diag_sync_scheduler_locked(); taskEXIT_CRITICAL(&s_gov_mux); memset(s_rx_slots, 0, sizeof(s_rx_slots)); @@ -1957,6 +2315,7 @@ esp_err_t bitle_lora_init(void) } s_transfer_counter = 0; memset(s_tx_context, 0, sizeof(s_tx_context)); + memset(s_throttle, 0, sizeof(s_throttle)); memset(s_rx_slots, 0, sizeof(s_rx_slots)); memset(s_completed_slots, 0, sizeof(s_completed_slots)); lora_trunk_neighbor_table_init(&s_neighbors); @@ -1972,6 +2331,7 @@ esp_err_t bitle_lora_init(void) s_bw = cfg.bw_hz; s_cr = cfg.cr; s_preamble_symbols = cfg.preamble_symbols; + s_region = cfg.region; s_gov_refill_frac = duty_pct / 100.0; s_gov_credit_ms = GOV_BURST_MS; s_gov_last_ms = esp_timer_get_time() / 1000ULL; diff --git a/main/bitle_lora.h b/main/bitle_lora.h index ad74581..eade9ba 100644 --- a/main/bitle_lora.h +++ b/main/bitle_lora.h @@ -72,6 +72,12 @@ typedef struct { uint32_t resource_aborts; uint32_t neighbor_collisions; uint32_t neighbor_count; + uint32_t beacon_tx; + uint32_t beacon_rx; + uint32_t beacon_stale; + uint32_t beacon_budget_deferred; + uint32_t channel_backoffs; + uint32_t announce_tx; } bitle_lora_diag_t; /* Snapshot transport diagnostics. All counters are monotonic for the boot. diff --git a/main/lora_channel_policy.c b/main/lora_channel_policy.c new file mode 100644 index 0000000..56a213c --- /dev/null +++ b/main/lora_channel_policy.c @@ -0,0 +1,62 @@ +#include "lora_channel_policy.h" + +#include + +static uint32_t clamp_u32(uint64_t value, uint32_t maximum) +{ + return value > maximum ? maximum : (uint32_t)value; +} + +bool lora_contention_window( + uint32_t airtime_ms, uint8_t busy_attempt, bool relayed_broadcast, + lora_contention_window_t *out) +{ + if (!out || airtime_ms == 0) { + return false; + } + + uint64_t minimum = airtime_ms / 4u; + if (minimum < 30u) { + minimum = 30u; + } + minimum += (uint64_t)busy_attempt * (airtime_ms / 8u); + if (relayed_broadcast) { + minimum += airtime_ms / 2u; + } + + uint64_t spread = airtime_ms; + if (spread < 120u) { + spread = 120u; + } + spread += (uint64_t)busy_attempt * (airtime_ms / 4u); + if (relayed_broadcast) { + spread += airtime_ms / 2u; + } + + out->minimum_ms = clamp_u32(minimum, 15000u); + out->maximum_ms = + clamp_u32(minimum + spread, 20000u); + if (out->maximum_ms < out->minimum_ms) { + out->maximum_ms = out->minimum_ms; + } + return true; +} + +uint32_t lora_contention_delay( + const lora_contention_window_t *window, uint32_t entropy) +{ + if (!window || window->maximum_ms < window->minimum_ms) { + return 0; + } + uint32_t span = window->maximum_ms - window->minimum_ms; + if (span == UINT32_MAX) { + return entropy; + } + return window->minimum_ms + entropy % (span + 1u); +} + +bool lora_freshness_newer(uint32_t candidate, uint32_t current) +{ + uint32_t distance = candidate - current; + return distance != 0 && distance < 0x80000000u; +} diff --git a/main/lora_channel_policy.h b/main/lora_channel_policy.h new file mode 100644 index 0000000..a2846b5 --- /dev/null +++ b/main/lora_channel_policy.h @@ -0,0 +1,37 @@ +#ifndef LORA_CHANNEL_POLICY_H +#define LORA_CHANNEL_POLICY_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LORA_CHANNEL_ACK_BURST_MAX 4 +#define LORA_CHANNEL_BEACON_REPETITIONS 3 + +typedef struct { + uint32_t minimum_ms; + uint32_t maximum_ms; +} lora_contention_window_t; + +/* Derive a bounded software-backoff window from the frame's actual airtime. + * Relayed broadcasts receive an additional randomized defer so receivers of + * the same frame do not rebroadcast in lockstep. */ +bool lora_contention_window( + uint32_t airtime_ms, uint8_t busy_attempt, bool relayed_broadcast, + lora_contention_window_t *out); + +uint32_t lora_contention_delay( + const lora_contention_window_t *window, uint32_t entropy); + +/* Serial-number comparison for 32-bit freshness counters. Equal values and + * values at the ambiguous half-range are not newer. */ +bool lora_freshness_newer(uint32_t candidate, uint32_t current); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/main/lora_trunk_protocol.c b/main/lora_trunk_protocol.c index e2bf136..31f08a4 100644 --- a/main/lora_trunk_protocol.c +++ b/main/lora_trunk_protocol.c @@ -1,4 +1,5 @@ #include "lora_trunk_protocol.h" +#include "lora_channel_policy.h" #include @@ -22,6 +23,14 @@ static uint16_t read_be16(const uint8_t *p) return (uint16_t)(((uint16_t)p[0] << 8) | p[1]); } +static uint32_t read_be32(const uint8_t *p) +{ + return ((uint32_t)p[0] << 24) | + ((uint32_t)p[1] << 16) | + ((uint32_t)p[2] << 8) | + (uint32_t)p[3]; +} + static void write_be16(uint8_t *p, uint16_t value) { p[0] = (uint8_t)(value >> 8); @@ -53,8 +62,6 @@ static bool v3_semantics_valid(const lora_trunk_frame_t *frame) (frame->flags & ~V3_FLAG_MASK) != 0 || all_zero(frame->source, sizeof(frame->source)) || all_zero(frame->transfer_id, sizeof(frame->transfer_id)) || - frame->fragment_total == 0 || - frame->fragment_total > LORA_TRUNK_MAX_FRAGMENTS || frame->payload_len > LORA_TRUNK_V3_MAX_PAYLOAD || frame->hop_count > frame->hop_limit) { return false; @@ -67,6 +74,10 @@ static bool v3_semantics_valid(const lora_trunk_frame_t *frame) (frame->flags & LORA_TRUNK_V3_FLAG_ACK_REQUESTED) != 0; if (frame->kind == LORA_TRUNK_KIND_DATA) { + if (frame->fragment_total == 0 || + frame->fragment_total > LORA_TRUNK_MAX_FRAGMENTS) { + return false; + } if (frame->payload_len == 0 || !frame->payload) { return false; } @@ -79,6 +90,20 @@ static bool v3_semantics_valid(const lora_trunk_frame_t *frame) } return true; } + if (frame->kind == LORA_TRUNK_KIND_BEACON) { + return frame->flags == LORA_TRUNK_V3_FLAG_BROADCAST && + destination_zero && !ack_requested && + frame->capabilities != 0 && + frame->hop_limit < LORA_TRUNK_BEACON_REGION_COUNT && + frame->hop_count == 0 && + frame->fragment_index == 0 && + frame->fragment_total == 0 && + frame->payload_len == 0 && !frame->payload; + } + if (frame->fragment_total == 0 || + frame->fragment_total > LORA_TRUNK_MAX_FRAGMENTS) { + return false; + } bool control_base = frame->flags == 0 && frame->payload_len == 0 && !frame->payload && !destination_zero && @@ -263,6 +288,43 @@ void lora_trunk_make_transfer_id( out[11] = (uint8_t)counter; } +bool lora_trunk_beacon_init( + lora_trunk_frame_t *frame, + const uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN], + const uint8_t boot_epoch[8], uint32_t freshness, + uint8_t region, uint16_t capabilities) +{ + if (!frame || !node_id || !boot_epoch || + all_zero(boot_epoch, 8) || freshness == 0 || + region >= LORA_TRUNK_BEACON_REGION_COUNT || capabilities == 0) { + return false; + } + memset(frame, 0, sizeof(*frame)); + frame->version = LORA_TRUNK_V3_VERSION; + frame->kind = LORA_TRUNK_KIND_BEACON; + frame->flags = LORA_TRUNK_V3_FLAG_BROADCAST; + frame->capabilities = capabilities; + frame->hop_limit = region; + memcpy(frame->source, node_id, sizeof(frame->source)); + lora_trunk_make_transfer_id( + boot_epoch, freshness, frame->transfer_id); + return v3_semantics_valid(frame); +} + +bool lora_trunk_beacon_fields( + const lora_trunk_frame_t *frame, uint8_t *out_region, + uint32_t *out_freshness) +{ + if (!out_region || !out_freshness || + !v3_semantics_valid(frame) || + frame->kind != LORA_TRUNK_KIND_BEACON) { + return false; + } + *out_region = frame->hop_limit; + *out_freshness = read_be32(frame->transfer_id + 8); + return *out_freshness != 0; +} + void lora_trunk_neighbor_table_init(lora_trunk_neighbor_table_t *table) { if (table) { @@ -270,6 +332,77 @@ void lora_trunk_neighbor_table_init(lora_trunk_neighbor_table_t *table) } } +lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_beacon( + lora_trunk_neighbor_table_t *table, + const uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN], + const uint8_t boot_epoch[8], uint32_t freshness, + uint8_t region, uint16_t capabilities, uint64_t now_ms) +{ + if (!table || !node_id || !boot_epoch || + all_zero(node_id, LORA_TRUNK_V3_NODE_ID_LEN) || + all_zero(boot_epoch, 8) || freshness == 0 || + region >= LORA_TRUNK_BEACON_REGION_COUNT || capabilities == 0) { + return LORA_TRUNK_NEIGHBOR_COLLISION; + } + + lora_trunk_neighbor_t *same_node = NULL; + lora_trunk_neighbor_t *free_entry = NULL; + lora_trunk_neighbor_t *oldest = NULL; + for (size_t i = 0; i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { + lora_trunk_neighbor_t *entry = &table->entries[i]; + if (!entry->in_use) { + if (!free_entry) { + free_entry = entry; + } + continue; + } + if (memcmp(entry->node_id, node_id, + LORA_TRUNK_V3_NODE_ID_LEN) == 0) { + same_node = entry; + } + if (!entry->quarantined && + (!oldest || entry->last_seen_ms < oldest->last_seen_ms)) { + oldest = entry; + } + } + + if (same_node) { + if (same_node->quarantined) { + return LORA_TRUNK_NEIGHBOR_COLLISION; + } + bool same_boot = + memcmp(same_node->boot_epoch, boot_epoch, 8) == 0; + if (same_boot && + !lora_freshness_newer( + freshness, same_node->freshness)) { + return LORA_TRUNK_NEIGHBOR_STALE; + } + memcpy(same_node->boot_epoch, boot_epoch, 8); + same_node->freshness = freshness; + same_node->region = region; + same_node->capabilities = capabilities; + same_node->last_seen_ms = now_ms; + return LORA_TRUNK_NEIGHBOR_REFRESHED; + } + + lora_trunk_neighbor_t *entry = free_entry ? free_entry : oldest; + if (!entry) { + return LORA_TRUNK_NEIGHBOR_COLLISION; + } + lora_trunk_neighbor_result_t result = + free_entry ? LORA_TRUNK_NEIGHBOR_LEARNED + : LORA_TRUNK_NEIGHBOR_EVICTED; + memset(entry, 0, sizeof(*entry)); + entry->in_use = true; + memcpy(entry->node_id, node_id, LORA_TRUNK_V3_NODE_ID_LEN); + memcpy(entry->boot_epoch, boot_epoch, 8); + entry->freshness = freshness; + entry->region = region; + entry->capabilities = capabilities; + entry->last_seen_ms = now_ms; + return result; +} + lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( lora_trunk_neighbor_table_t *table, const uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN], @@ -287,6 +420,7 @@ lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( lora_trunk_neighbor_t *free_entry = NULL; lora_trunk_neighbor_t *oldest = NULL; lora_trunk_neighbor_t *same_route = NULL; + lora_trunk_neighbor_t *unauthenticated_node = NULL; bool collision = false; for (size_t i = 0; i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { lora_trunk_neighbor_t *entry = &table->entries[i]; @@ -300,14 +434,20 @@ lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( (!oldest || entry->last_seen_ms < oldest->last_seen_ms)) { oldest = entry; } - if (memcmp(entry->node_id, node_id, - LORA_TRUNK_V3_NODE_ID_LEN) == 0 && + bool same_node = + memcmp(entry->node_id, node_id, + LORA_TRUNK_V3_NODE_ID_LEN) == 0; + if (same_node && entry->identity_authenticated && memcmp(entry->signing_key, signing_key, LORA_TRUNK_SIGNING_KEY_LEN) != 0) { entry->quarantined = true; collision = true; } - if (memcmp(entry->reachable_peer, reachable_peer, + if (same_node && !entry->identity_authenticated) { + unauthenticated_node = entry; + } + if (entry->identity_authenticated && + memcmp(entry->reachable_peer, reachable_peer, LORA_TRUNK_V3_NODE_ID_LEN) == 0 && memcmp(entry->signing_key, signing_key, LORA_TRUNK_SIGNING_KEY_LEN) == 0) { @@ -327,15 +467,22 @@ lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( return LORA_TRUNK_NEIGHBOR_REFRESHED; } - lora_trunk_neighbor_t *entry = free_entry ? free_entry : oldest; + lora_trunk_neighbor_t *entry = + unauthenticated_node + ? unauthenticated_node + : (free_entry ? free_entry : oldest); if (!entry) { return LORA_TRUNK_NEIGHBOR_COLLISION; } lora_trunk_neighbor_result_t result = - free_entry ? LORA_TRUNK_NEIGHBOR_LEARNED - : LORA_TRUNK_NEIGHBOR_EVICTED; - memset(entry, 0, sizeof(*entry)); + unauthenticated_node || free_entry + ? LORA_TRUNK_NEIGHBOR_LEARNED + : LORA_TRUNK_NEIGHBOR_EVICTED; + if (!unauthenticated_node) { + memset(entry, 0, sizeof(*entry)); + } entry->in_use = true; + entry->identity_authenticated = true; memcpy(entry->node_id, node_id, LORA_TRUNK_V3_NODE_ID_LEN); memcpy(entry->reachable_peer, reachable_peer, LORA_TRUNK_V3_NODE_ID_LEN); @@ -359,6 +506,7 @@ bool lora_trunk_neighbor_route( for (size_t i = 0; i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { const lora_trunk_neighbor_t *entry = &table->entries[i]; if (!entry->in_use || entry->quarantined || + !entry->identity_authenticated || memcmp(entry->reachable_peer, reachable_peer, LORA_TRUNK_V3_NODE_ID_LEN) != 0 || now_ms < entry->last_seen_ms || diff --git a/main/lora_trunk_protocol.h b/main/lora_trunk_protocol.h index 9476e90..bc3ac4b 100644 --- a/main/lora_trunk_protocol.h +++ b/main/lora_trunk_protocol.h @@ -23,6 +23,7 @@ extern "C" { #define LORA_TRUNK_V3_MAX_PAYLOAD \ (LORA_TRUNK_V3_MAX_FRAME_LEN - LORA_TRUNK_V3_HEADER_LEN) #define LORA_TRUNK_MAX_FRAGMENTS 5 +#define LORA_TRUNK_BEACON_REGION_COUNT 2 #define LORA_TRUNK_V3_CAP_PROTOCOL 0x0001u @@ -31,6 +32,7 @@ typedef enum { LORA_TRUNK_KIND_BITMAP_ACK = 2, LORA_TRUNK_KIND_COMPLETE = 3, LORA_TRUNK_KIND_ABORT = 4, + LORA_TRUNK_KIND_BEACON = 5, } lora_trunk_kind_t; typedef enum { @@ -89,15 +91,32 @@ void lora_trunk_make_transfer_id( const uint8_t boot_epoch[8], uint32_t counter, uint8_t out[LORA_TRUNK_V3_TRANSFER_ID_LEN]); +/* Compact beacon: the ordinary v3 header carries version, capabilities, node + * ID, boot epoch, and freshness. hop_limit carries the one-byte region ID, so + * the complete beacon remains one 44-byte frame with no application payload. */ +bool lora_trunk_beacon_init( + lora_trunk_frame_t *frame, + const uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN], + const uint8_t boot_epoch[8], uint32_t freshness, + uint8_t region, uint16_t capabilities); + +bool lora_trunk_beacon_fields( + const lora_trunk_frame_t *frame, uint8_t *out_region, + uint32_t *out_freshness); + #define LORA_TRUNK_NEIGHBOR_CAPACITY 12 #define LORA_TRUNK_SIGNING_KEY_LEN 32 typedef struct { bool in_use; bool quarantined; + bool identity_authenticated; uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN]; uint8_t reachable_peer[LORA_TRUNK_V3_NODE_ID_LEN]; uint8_t signing_key[LORA_TRUNK_SIGNING_KEY_LEN]; + uint8_t boot_epoch[8]; + uint32_t freshness; + uint8_t region; uint16_t capabilities; uint64_t last_seen_ms; } lora_trunk_neighbor_t; @@ -111,10 +130,20 @@ typedef enum { LORA_TRUNK_NEIGHBOR_REFRESHED, LORA_TRUNK_NEIGHBOR_EVICTED, LORA_TRUNK_NEIGHBOR_COLLISION, + LORA_TRUNK_NEIGHBOR_STALE, } lora_trunk_neighbor_result_t; void lora_trunk_neighbor_table_init(lora_trunk_neighbor_table_t *table); +/* Learns minimum viable radio reachability without treating the beacon as a + * BitChat identity proof. Repeated freshness values are ignored, and a new + * boot epoch starts a new freshness sequence. */ +lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_beacon( + lora_trunk_neighbor_table_t *table, + const uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN], + const uint8_t boot_epoch[8], uint32_t freshness, + uint8_t region, uint16_t capabilities, uint64_t now_ms); + /* Caller supplies identity data from a successfully verified signed announce. * A node ID observed with a different signing identity is quarantined. */ lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( diff --git a/tests/test_lora_channel_behavior.py b/tests/test_lora_channel_behavior.py new file mode 100644 index 0000000..11b7f9c --- /dev/null +++ b/tests/test_lora_channel_behavior.py @@ -0,0 +1,99 @@ +import unittest + +from tools.lora_reliability_sim import ( + DiscoveryChannelModel, + DiscoveryConfig, + RadioProfile, + airtime_ms, + bounded_ack_service, + contention_window_ms, + hidden_sender_delays, +) + + +class DiscoveryAndChannelTests(unittest.TestCase): + def test_discovery_exceeds_gate_at_thirty_percent_loss(self): + ratios = [] + for seed in range(100): + result = DiscoveryChannelModel( + DiscoveryConfig( + node_count=4, + frame_loss=0.3, + intervals=3, + repetitions=3, + contention_slots=64, + seed=seed, + ) + ).run() + ratios.append(result.discovery_ratio) + self.assertGreaterEqual(sum(ratios) / len(ratios), 0.95) + + def test_four_simultaneous_nodes_converge(self): + result = DiscoveryChannelModel( + DiscoveryConfig( + node_count=4, + frame_loss=0.0, + intervals=3, + repetitions=3, + contention_slots=32, + seed=19, + ) + ).run() + self.assertEqual(result.discovery_ratio, 1.0) + self.assertLess(result.collision_frames, result.transmitted_frames) + + def test_hidden_senders_leave_retry_lockstep(self): + left, right = hidden_sender_delays(1500, 6, (11, 29)) + self.assertTrue(any(a != b for a, b in zip(left, right))) + self.assertTrue(any( + abs(a - b) >= 1500 for a, b in zip(left[1:], right[1:]) + )) + + def test_contention_scales_with_airtime_and_relay_role(self): + short = contention_window_ms(100) + long = contention_window_ms(1500) + relayed = contention_window_ms(1500, relayed_broadcast=True) + self.assertGreater(long[0], short[0]) + self.assertGreater(long[1], short[1]) + self.assertGreater(relayed[0], long[0]) + + def test_ack_priority_is_bounded_under_bidirectional_data(self): + schedule = bounded_ack_service(20, 3, burst_maximum=4) + data_positions = [ + index for index, kind in enumerate(schedule) if kind == "data" + ] + self.assertEqual(len(data_positions), 3) + self.assertLessEqual(data_positions[0], 4) + self.assertLessEqual(data_positions[1] - data_positions[0], 5) + + def test_beacon_repetition_stays_inside_airtime_budget(self): + profile = RadioProfile(sf=10, preamble_symbols=16) + beacon_airtime = airtime_ms(44, profile) + used_per_interval = beacon_airtime * 3 + available_per_interval = 60_000 * 0.25 + self.assertLess(used_per_interval, available_per_interval) + + def test_four_receivers_emit_no_beacon_ack(self): + receiver_count = 4 + beacon_ack_requested = False + self.assertEqual( + receiver_count if beacon_ack_requested else 0, + 0, + ) + + def test_beacon_loss_does_not_remove_known_route(self): + known_routes = {"peer"} + DiscoveryChannelModel( + DiscoveryConfig( + node_count=2, + frame_loss=1.0, + intervals=3, + repetitions=3, + seed=7, + ) + ).run() + self.assertIn("peer", known_routes) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lora_channel_policy.c b/tests/test_lora_channel_policy.c new file mode 100644 index 0000000..72dd3e9 --- /dev/null +++ b/tests/test_lora_channel_policy.c @@ -0,0 +1,36 @@ +#include "lora_channel_policy.h" + +#include +#include + +int main(void) +{ + lora_contention_window_t short_frame; + lora_contention_window_t long_frame; + assert(!lora_contention_window(0, 0, false, &short_frame)); + assert(lora_contention_window(100, 0, false, &short_frame)); + assert(lora_contention_window(1500, 0, false, &long_frame)); + assert(long_frame.minimum_ms > short_frame.minimum_ms); + assert(long_frame.maximum_ms > short_frame.maximum_ms); + + lora_contention_window_t retry; + lora_contention_window_t relay; + assert(lora_contention_window(1500, 2, false, &retry)); + assert(lora_contention_window(1500, 2, true, &relay)); + assert(retry.minimum_ms > long_frame.minimum_ms); + assert(relay.minimum_ms > retry.minimum_ms); + assert(lora_contention_delay(&relay, 0) == relay.minimum_ms); + assert(lora_contention_delay(&relay, UINT32_MAX) >= + relay.minimum_ms); + assert(lora_contention_delay(&relay, UINT32_MAX) <= + relay.maximum_ms); + + assert(lora_freshness_newer(2, 1)); + assert(!lora_freshness_newer(1, 1)); + assert(lora_freshness_newer(0, UINT32_MAX)); + assert(!lora_freshness_newer(UINT32_MAX, 0)); + assert(!lora_freshness_newer(0x80000000u, 0)); + + puts("lora_channel_policy: ok"); + return 0; +} diff --git a/tests/test_lora_trunk_protocol.c b/tests/test_lora_trunk_protocol.c index b17adba..0c45edb 100644 --- a/tests/test_lora_trunk_protocol.c +++ b/tests/test_lora_trunk_protocol.c @@ -158,6 +158,71 @@ static void test_two_senders_and_broadcast(void) } } +static void test_compact_beacon_and_identity_separation(void) +{ + lora_trunk_frame_t beacon; + assert(lora_trunk_beacon_init( + &beacon, SOURCE_A, BOOT_A, 7, 1, + LORA_TRUNK_V3_CAP_PROTOCOL)); + uint8_t wire[255]; + size_t len = encode(&beacon, wire); + assert(len == LORA_TRUNK_V3_HEADER_LEN); + + lora_trunk_frame_t parsed; + assert(lora_trunk_parse(wire, len, &parsed) == + LORA_TRUNK_PARSE_OK); + assert(parsed.kind == LORA_TRUNK_KIND_BEACON); + uint8_t region = 0; + uint32_t freshness = 0; + assert(lora_trunk_beacon_fields( + &parsed, ®ion, &freshness)); + assert(region == 1); + assert(freshness == 7); + + lora_trunk_neighbor_table_t table; + lora_trunk_neighbor_table_init(&table); + assert(lora_trunk_neighbor_learn_beacon( + &table, parsed.source, parsed.transfer_id, + freshness, region, parsed.capabilities, 1000) == + LORA_TRUNK_NEIGHBOR_LEARNED); + assert(lora_trunk_neighbor_count(&table) == 1); + assert(!table.entries[0].identity_authenticated); + + uint8_t route[8] = {0}; + assert(!lora_trunk_neighbor_route( + &table, SOURCE_A, 1000, 5000, route, NULL)); + assert(lora_trunk_neighbor_learn_beacon( + &table, parsed.source, parsed.transfer_id, + freshness, region, parsed.capabilities, 1100) == + LORA_TRUNK_NEIGHBOR_STALE); + assert(lora_trunk_neighbor_learn_beacon( + &table, parsed.source, parsed.transfer_id, + freshness + 1, region, parsed.capabilities, 1200) == + LORA_TRUNK_NEIGHBOR_REFRESHED); + + uint8_t key[32] = {1}; + assert(lora_trunk_neighbor_learn_authenticated( + &table, SOURCE_A, SOURCE_A, key, + LORA_TRUNK_V3_CAP_PROTOCOL, 1300) == + LORA_TRUNK_NEIGHBOR_LEARNED); + assert(table.entries[0].identity_authenticated); + assert(lora_trunk_neighbor_route( + &table, SOURCE_A, 1400, 5000, route, NULL)); + + lora_trunk_frame_t invalid = beacon; + invalid.fragment_total = 1; + assert(!lora_trunk_v3_encode( + &invalid, wire, sizeof(wire), &len)); + invalid = beacon; + invalid.flags = LORA_TRUNK_V3_FLAG_ACK_REQUESTED; + assert(!lora_trunk_v3_encode( + &invalid, wire, sizeof(wire), &len)); + uint8_t zero_epoch[8] = {0}; + assert(!lora_trunk_beacon_init( + &invalid, SOURCE_A, zero_epoch, 1, 0, + LORA_TRUNK_V3_CAP_PROTOCOL)); +} + static void test_v2_policy(void) { uint8_t data[17] = { @@ -285,6 +350,7 @@ int main(void) test_v3_round_trip_and_malformed(); test_exact_control_matching(); test_two_senders_and_broadcast(); + test_compact_beacon_and_identity_separation(); test_v2_policy(); test_neighbor_routes_and_collision(); test_deterministic_parser_fuzz(); diff --git a/tools/lora_hardware_smoke.py b/tools/lora_hardware_smoke.py index bd106cc..a7b05c8 100755 --- a/tools/lora_hardware_smoke.py +++ b/tools/lora_hardware_smoke.py @@ -44,6 +44,16 @@ CONTROL_RE = re.compile( r"v3 control (TX|RX) kind=(\d+) value=0x([0-9a-fA-F]{2})" ) +BEACON_TX_RE = re.compile( + r"trunk beacon TX freshness=(\d+) repeat=(\d+)" +) +BEACON_RX_RE = re.compile( + r"trunk beacon RX freshness=(\d+) result=(\d+)" +) +CHANNEL_DIAG_RE = re.compile( + r"beacon=(\d+)/(\d+) stale=(\d+) budget=(\d+) " + r"backoff=(\d+) announce_tx=(\d+)" +) def reader( @@ -197,6 +207,23 @@ def main() -> int: summary[label]["diagnostics"].append( reliability_diag ) + if match := CHANNEL_DIAG_RE.search(line): + channel_diag = { + "beacon_tx": int(match.group(1)), + "beacon_rx": int(match.group(2)), + "beacon_stale": int(match.group(3)), + "beacon_budget_deferred": int(match.group(4)), + "channel_backoffs": int(match.group(5)), + "announce_tx": int(match.group(6)), + } + if summary[label]["diagnostics"]: + summary[label]["diagnostics"][-1].update( + channel_diag + ) + else: + summary[label]["diagnostics"].append( + channel_diag + ) if "authenticated neighbor" in line: summary[label]["events"]["authenticated_neighbor"] += 1 if match := CONTROL_RE.search(line): @@ -211,6 +238,15 @@ def main() -> int: summary[label]["events"][ f"v3_{names[kind]}_{direction}_value_{value}" ] += 1 + if match := BEACON_TX_RE.search(line): + summary[label]["events"]["beacon_tx"] += 1 + summary[label]["events"][ + f"beacon_tx_repeat_{match.group(2)}" + ] += 1 + if match := BEACON_RX_RE.search(line): + summary[label]["events"]["beacon_rx"] += 1 + if int(match.group(2)) == 4: + summary[label]["events"]["beacon_stale"] += 1 if "trunk transfer COMPLETE" in line: summary[label]["events"]["transfer_complete"] += 1 if "TX watchdog expired" in line: diff --git a/tools/lora_reliability_sim.py b/tools/lora_reliability_sim.py index be3d041..0a8ac99 100755 --- a/tools/lora_reliability_sim.py +++ b/tools/lora_reliability_sim.py @@ -356,3 +356,134 @@ def complete(self, transfer_id: bytes) -> bool: self.completed.append(transfer_id) self.completed = self.completed[-self.completed_capacity :] return True + + +@dataclass(frozen=True) +class DiscoveryConfig: + node_count: int = 4 + frame_loss: float = 0.3 + intervals: int = 3 + repetitions: int = 3 + contention_slots: int = 32 + seed: int = 1 + + +@dataclass(frozen=True) +class DiscoveryResult: + discovered_links: int + possible_links: int + collision_frames: int + transmitted_frames: int + + @property + def discovery_ratio(self) -> float: + if self.possible_links == 0: + return 1.0 + return self.discovered_links / self.possible_links + + +class DiscoveryChannelModel: + """Slotted executable model for randomized, repeated trunk beacons.""" + + def __init__(self, config: DiscoveryConfig): + if config.node_count < 2: + raise ValueError("discovery needs at least two nodes") + if not 0.0 <= config.frame_loss <= 1.0: + raise ValueError("frame loss must be between zero and one") + if ( + config.intervals <= 0 + or config.repetitions <= 0 + or config.contention_slots <= 0 + or config.repetitions > config.contention_slots + ): + raise ValueError("invalid discovery bounds") + self.config = config + self.random = random.Random(config.seed) + + def run(self) -> DiscoveryResult: + discovered: set[tuple[int, int]] = set() + collisions = 0 + transmitted = 0 + for _interval in range(self.config.intervals): + slots: dict[int, list[int]] = {} + for sender in range(self.config.node_count): + selected: set[int] = set() + while len(selected) < self.config.repetitions: + selected.add( + self.random.randrange(self.config.contention_slots) + ) + for slot in selected: + slots.setdefault(slot, []).append(sender) + transmitted += 1 + for senders in slots.values(): + if len(senders) != 1: + collisions += len(senders) + continue + sender = senders[0] + for receiver in range(self.config.node_count): + if receiver == sender: + continue + if self.random.random() >= self.config.frame_loss: + discovered.add((receiver, sender)) + possible = self.config.node_count * (self.config.node_count - 1) + return DiscoveryResult( + len(discovered), possible, collisions, transmitted + ) + + +def contention_window_ms( + frame_airtime_ms: int, + busy_attempt: int = 0, + relayed_broadcast: bool = False, +) -> tuple[int, int]: + if frame_airtime_ms <= 0 or busy_attempt < 0: + raise ValueError("invalid contention parameters") + minimum = max(30, frame_airtime_ms // 4) + minimum += busy_attempt * (frame_airtime_ms // 8) + if relayed_broadcast: + minimum += frame_airtime_ms // 2 + spread = max(120, frame_airtime_ms) + spread += busy_attempt * (frame_airtime_ms // 4) + if relayed_broadcast: + spread += frame_airtime_ms // 2 + minimum = min(minimum, 15_000) + return minimum, min(minimum + spread, 20_000) + + +def hidden_sender_delays( + frame_airtime_ms: int, attempts: int, seeds: tuple[int, int] +) -> tuple[list[int], list[int]]: + if attempts <= 0: + raise ValueError("attempt count must be positive") + schedules: list[list[int]] = [] + for seed in seeds: + source = random.Random(seed) + elapsed = 0 + schedule = [] + for attempt in range(attempts): + minimum, maximum = contention_window_ms( + frame_airtime_ms, attempt + ) + elapsed += source.randint(minimum, maximum) + schedule.append(elapsed) + schedules.append(schedule) + return schedules[0], schedules[1] + + +def bounded_ack_service( + ack_frames: int, data_frames: int, burst_maximum: int = 4 +) -> list[str]: + if ack_frames < 0 or data_frames < 0 or burst_maximum <= 0: + raise ValueError("invalid service bounds") + result: list[str] = [] + ack_burst = 0 + while ack_frames or data_frames: + if ack_frames and (not data_frames or ack_burst < burst_maximum): + result.append("ack") + ack_frames -= 1 + ack_burst += 1 + elif data_frames: + result.append("data") + data_frames -= 1 + ack_burst = 0 + return result diff --git a/tools/run_lora_host_tests.sh b/tools/run_lora_host_tests.sh index 79d34b4..28995e0 100755 --- a/tools/run_lora_host_tests.sh +++ b/tools/run_lora_host_tests.sh @@ -7,7 +7,8 @@ region_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-region.XXXXXX")" scheduler_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-scheduler.XXXXXX")" trunk_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-trunk.XXXXXX")" reliability_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-reliability.XXXXXX")" -trap 'rm -f "$airtime_bin" "$region_bin" "$scheduler_bin" "$trunk_bin" "$reliability_bin"' EXIT +channel_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-channel.XXXXXX")" +trap 'rm -f "$airtime_bin" "$region_bin" "$scheduler_bin" "$trunk_bin" "$reliability_bin" "$channel_bin"' EXIT cc -std=c11 -Wall -Wextra -Werror \ -I"$repo_root/main" \ @@ -35,6 +36,7 @@ cc -std=c11 -Wall -Wextra -Werror \ -I"$repo_root/main" \ "$repo_root/tests/test_lora_trunk_protocol.c" \ "$repo_root/main/lora_trunk_protocol.c" \ + "$repo_root/main/lora_channel_policy.c" \ -o "$trunk_bin" "$trunk_bin" @@ -46,5 +48,12 @@ cc -std=c11 -Wall -Wextra -Werror \ -lm -o "$reliability_bin" "$reliability_bin" +cc -std=c11 -Wall -Wextra -Werror \ + -I"$repo_root/main" \ + "$repo_root/tests/test_lora_channel_policy.c" \ + "$repo_root/main/lora_channel_policy.c" \ + -o "$channel_bin" +"$channel_bin" + cd "$repo_root" python3 -m unittest discover -s tests -p 'test_lora_*.py' -v From 17105405d6d7ca5abaa1c63242b3dcfdf02fc1ea Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:00:15 +0200 Subject: [PATCH 16/19] docs(lora): record milestone 5 checkpoint --- docs/LoRa-reliability-implementation-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index dfd84c6..aa19787 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -64,7 +64,7 @@ The implementation is complete only when all of the following are true: | 2. Atomic packet scheduling and backpressure | Complete | `fe9e95e` | Concurrent saturation, both firmware targets, and two-board SF10 smoke passed | | 3. Addressed trunk protocol and migration | Complete | `3ee5843` | v2/v3 host protocol suite, both firmware targets, and addressed two-board v3 smoke passed | | 4. Packet-level reliability and reassembly | Complete | `33cfde3` | 99.35% sender completion at 30% data/feedback loss; five-of-five two-board transfer gate | -| 5. Discovery and shared-channel behavior | Complete | Pending checkpoint | 100% simulated discovery; three-repeat two-board beacon gate passed | +| 5. Discovery and shared-channel behavior | Complete | `873eddf` | 100% simulated discovery; three-repeat two-board beacon gate passed | | 6. LoRa multi-hop forwarding | Pending | — | — | | 7. Link adaptation and PHY hardening | Pending | — | — | | 8. End-to-end validation and rollout | Pending | — | — | @@ -501,7 +501,7 @@ traffic from repeatedly colliding. radio timeouts, radio-command errors, BUSY timeouts, recovery failures, and software-watchdog recoveries; channel-backoff and airtime-budget counters were active. -- Commit: Pending checkpoint +- Commit: `873eddf` - Suggested commit subject: `feat(lora): harden discovery and channel access` ## Milestone 6: LoRa multi-hop forwarding From 21f32fd85b7bffaef92781000bbd1ba69fb9650a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:35:10 +0200 Subject: [PATCH 17/19] feat(lora): add bounded multi-hop forwarding --- docs/LoRa-testing.md | 17 +- docs/LoRa-trunk-v3.md | 91 ++++++--- main/CMakeLists.txt | 1 + main/Kconfig.projbuild | 11 +- main/bitle_link.c | 60 ++++++ main/bitle_link.h | 12 ++ main/bitle_lora.c | 300 +++++++++++++++++++++++++++--- main/bitle_lora.h | 5 + main/bitle_mesh.c | 5 +- main/lora_forwarding.c | 118 ++++++++++++ main/lora_forwarding.h | 61 +++++++ main/lora_trunk_protocol.c | 36 +++- main/lora_trunk_protocol.h | 9 +- tests/test_lora_forwarding.c | 65 +++++++ tests/test_lora_multihop.py | 138 ++++++++++++++ tests/test_lora_trunk_protocol.c | 42 ++++- tools/lora_hardware_smoke.py | 71 +++++-- tools/lora_reliability_sim.py | 305 +++++++++++++++++++++++++++++++ tools/run_lora_host_tests.sh | 11 +- 19 files changed, 1276 insertions(+), 82 deletions(-) create mode 100644 main/lora_forwarding.c create mode 100644 main/lora_forwarding.h create mode 100644 tests/test_lora_forwarding.c create mode 100644 tests/test_lora_multihop.py diff --git a/docs/LoRa-testing.md b/docs/LoRa-testing.md index e49b6e6..60123b5 100644 --- a/docs/LoRa-testing.md +++ b/docs/LoRa-testing.md @@ -70,10 +70,12 @@ the profile when the frequency is unambiguous. The production default keeps `CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE` disabled. For a controlled test, enable it on exactly one Heltec V3 build. That node -waits until a signed direct announcement establishes an authenticated v3 -neighbor, then transmits one addressed synthetic packet at each of 1, 2, 3, 4, -and 5 fragments. It retries fixture admission every three seconds and advances -only after a probe is accepted. Keep the receiving node on the normal build. +first transmits three spaced unaddressed public probes with normal application +TTL to exercise same-medium forwarding. After a signed direct announcement +establishes an authenticated v3 neighbor, it transmits one addressed synthetic +packet at each of 1, 2, 3, 4, and 5 fragments. It retries fixture admission +every three seconds and advances only after a probe is accepted. Keep every +receiving node on the normal build. Capture both serial streams concurrently: @@ -82,6 +84,13 @@ python tools/lora_hardware_smoke.py PORT_A PORT_B --seconds 90 --reset --quiet \ --json-out /tmp/bitle-lora-smoke.json ``` +The capture tool accepts a third or later port for topology tests. In a +three-board same-room test, expect one receiver to transmit the forwarding +probe at `hops=1/3`; another relay may cancel its queued copy after overhearing +that transmission. A same-room triangle does not prove the physical +endpoint-isolated line gate, so retain the deterministic 20%-loss line test or +separate/attenuate the endpoints until they cannot receive each other. + The capture summarizes TX and completed RX counts by fragment count, addressed diagnostic events, authenticated-neighbor learning, bitmap/COMPLETE/ABORT controls, completed sender transfers, duplicate suppression, and parser diff --git a/docs/LoRa-trunk-v3.md b/docs/LoRa-trunk-v3.md index ce6e859..e724466 100644 --- a/docs/LoRa-trunk-v3.md +++ b/docs/LoRa-trunk-v3.md @@ -28,8 +28,8 @@ existing short-frame RF behavior. | 4 | 1 | flags | Integrity-covered flags defined below | | 5 | 1 | header length | `44` | | 6 | 2 | capabilities | Sender capabilities | -| 8 | 1 | hop limit | Remaining forwarding budget; `0` in milestone 3 | -| 9 | 1 | hop count | Hops already crossed; `0` in milestone 3 | +| 8 | 1 | hop limit | Maximum relay count; originated data defaults to `3` | +| 9 | 1 | hop count | Relays already crossed; originated data starts at `0` | | 10 | 2 | payload length | Bytes following this header | | 12 | 8 | source | Full BitChat/Noise peer identifier | | 20 | 8 | destination | Full peer identifier; all zero means broadcast | @@ -55,6 +55,8 @@ encoding makes accidental and malformed address modes detectable. Data frames require a nonzero source, nonzero transfer ID, a fragment total of at least one, an index below that total, and a payload length of at least one. +The hop limit may not exceed four and the hop count may not exceed the limit. +The legacy v3 one-hop encoding `0/0` remains valid but cannot be forwarded. Control frames require a nonzero source and destination, zero flags, zero payload, and a fragment total matching the transfer. Bitmap ACK values contain only bits below `fragment total`. COMPLETE contains the full received bitmap. @@ -63,9 +65,12 @@ invalid total length, resource pressure, or radio reset. ## Node and transfer identity -The source and destination are the complete eight-byte BitChat peer IDs derived -from Noise static public keys. Version 3 never truncates them to the four-byte -v2 radio tag. +The source and destination are complete eight-byte BitChat peer IDs derived +from Noise static public keys. For a forwarded frame they name the immediate +LoRa sender and selected next hop, not the original BitChat sender and final +BitChat recipient. Those end-to-end identities remain inside the encoded +BitChat packet. Version 3 never truncates an outer node ID to the four-byte v2 +radio tag. A neighbor advertisement binds a node ID to the Ed25519 key in a successfully verified signed BitChat `ANNOUNCE`. The neighbor table stores that signing-key @@ -75,13 +80,14 @@ direct route through the conflicting identity is selected. A later firmware may add an explicit collision-resolution exchange; milestone 3 fails closed. Each boot generates an unpredictable 64-bit boot epoch from the hardware random -number generator. Every admitted packet appends a monotonically increasing -32-bit counter, producing a 96-bit transfer ID. The counter advances only when -the complete packet is committed to the scheduler. Reboots therefore do not -reuse the preceding boot's transfer namespace except with negligible random -collision probability. Reassembly is keyed by the complete tuple -`(source, transfer ID)`, so equal counters or transfer bytes from different -senders cannot alias. +number generator. Every admitted per-hop packet appends a monotonically +increasing 32-bit counter, producing a 96-bit transfer ID. The counter advances +only when the complete packet is committed to the scheduler. A relay therefore +creates a fresh transfer ID and selective-repeat session for its next hop. +Reboots do not reuse the preceding boot's transfer namespace except with +negligible random collision probability. Reassembly is keyed by the complete +tuple `(source, transfer ID)`, so equal counters or transfer bytes from +different senders cannot alias. ## Neighbor and route learning @@ -90,14 +96,18 @@ only after all of the following are true: 1. A complete v3 packet has been reassembled without parser errors. 2. The encoded BitChat packet is an `ANNOUNCE`. -3. Its BitChat sender equals the v3 radio source. -4. The announce sender is derived from its advertised Noise static key. -5. Its signature verifies with its advertised Ed25519 key. - -An entry records the v3 source, the BitChat peer advertised as reachable, -verified signing identity, capabilities, and last-seen time. Expired, -quarantined, or conflicting entries are not routes. The bounded table evicts -the oldest non-quarantined entry when full. +3. The announce sender is derived from its advertised Noise static key. +4. Its signature verifies with its advertised Ed25519 key. + +An entry records the immediate v3 source as the next hop, the signed BitChat +peer as the reachable destination, the verified signing identity, +capabilities, and last-seen time. If the inner announce sender equals the +outer source, the entry is a direct radio identity. Otherwise, the verified +announce has crossed a relay and creates a reverse path to its signed origin +through the immediate sender. One relay may therefore advertise several +independently signed peers without being mistaken for an identity collision. +Expired, quarantined, or conflicting entries are not routes. The bounded table +evicts the oldest non-quarantined entry when full. For an encoded BitChat packet with a recipient, the sender uses the freshest authenticated neighbor that advertised that recipient. It emits addressed v3 @@ -107,9 +117,44 @@ still provide reachability. Packets without a recipient, including announces, are broadcasts. Discovery beacons use an independent randomized startup delay and interval -jitter so peers booted together do not repeatedly collide. Milestone 5 extends -this minimum discovery safeguard with broader loss-adaptation, duplicate -suppression, and cadence policy. +jitter so peers booted together do not repeatedly collide. Compact beacons +identify only the direct radio neighbor; signed announces establish usable +direct and multi-hop BitChat routes. + +## Bounded forwarding + +The generic mesh excludes an ingress link from ordinary rebroadcasts. LoRa +registers an explicit same-medium relay callback so a packet received from +LoRa can be sent back onto LoRa without weakening that rule for other +transports. + +Locally originated v3 data uses a hop limit of three and a hop count of zero. +Every relay preserves the limit and increments the count before creating its +new per-hop transfer. A receiver may admit a same-medium relay only when the +incoming count is below the limit. Limits above four, counts above the limit, +and exhausted relays are rejected before the whole packet is committed to the +TX scheduler. Application TTL remains independent and is still decremented by +the generic BitChat relay. + +Each node keeps a 32-entry, five-minute forwarding cache keyed by the inner +BitChat sender and a stable 64-bit fingerprint of the encoded packet. The +fingerprint excludes the BitChat TTL byte, so every hop recognizes the same +logical transfer. A duplicate is acknowledged at the outer per-hop layer when +necessary, but it is not delivered to the application or forwarded again. +The existing 64-entry BitChat mesh dedup remains a second safety layer. + +Broadcast relays wait for an airtime-scaled randomized delay before CAD. Better +SNR receives a smaller quality penalty, so a strong or early relay tends to +transmit first. If another copy is overheard while a matching broadcast relay +is queued or waiting, the scheduled relay is released without TX. Addressed +forwarding uses the freshest authenticated next-hop route and the normal +bitmap/COMPLETE per-hop reliability exchange. + +Authenticated routes expire after three minutes. Terminal addressed delivery +failure removes every route using the failed next hop; a later packet falls +back to bounded broadcast and signed-announcement forwarding can repopulate +the path. This avoids retaining a stale route without inventing unauthenticated +reachability from arbitrary packet headers. ## Packet-level reliability diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 6e574bf..a1789e5 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -11,6 +11,7 @@ idf_component_register( "bitle_lora.c" "lora_airtime.c" "lora_channel_policy.c" + "lora_forwarding.c" "lora_packet_reliability.c" "lora_region.c" "lora_trunk_protocol.c" diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 40120d8..6b4dfd9 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -74,11 +74,12 @@ menu "Bitle hardware" depends on IDF_TARGET_ESP32S3 default n help - Test-only mode. After an authenticated v3 neighbor is discovered, - transmit synthetic addressed packets spanning one through five - fragments. Flash exactly one test node with this enabled and leave - receiving nodes on the normal build. Never enable it in deployed - firmware. + Test-only mode. First transmit three spaced synthetic broadcasts + that exercise same-medium forwarding, then after an authenticated + v3 neighbor is discovered transmit addressed packets spanning one + through five fragments. Flash exactly one test node with this + enabled and leave receiving nodes on the normal build. Never enable + it in deployed firmware. choice BITLE_LORA_REGION prompt "Default LoRa regional profile" diff --git a/main/bitle_link.c b/main/bitle_link.c index a4ef7aa..da01a67 100644 --- a/main/bitle_link.c +++ b/main/bitle_link.c @@ -13,6 +13,7 @@ typedef struct { uint16_t handle; bitle_link_type_t type; bitle_link_send_fn_t send_fn; + bitle_link_relay_fn_t relay_fn; bitle_link_reserve_fn_t reserve_fn; bitle_link_commit_fn_t commit_fn; bitle_link_cancel_fn_t cancel_fn; @@ -47,6 +48,17 @@ esp_err_t bitle_link_register_reservable( uint16_t handle, bitle_link_type_t type, bitle_link_send_fn_t send_fn, bitle_link_reserve_fn_t reserve_fn, bitle_link_commit_fn_t commit_fn, bitle_link_cancel_fn_t cancel_fn) +{ + return bitle_link_register_reservable_relay( + handle, type, send_fn, NULL, reserve_fn, commit_fn, + cancel_fn); +} + +esp_err_t bitle_link_register_reservable_relay( + uint16_t handle, bitle_link_type_t type, bitle_link_send_fn_t send_fn, + bitle_link_relay_fn_t relay_fn, + bitle_link_reserve_fn_t reserve_fn, bitle_link_commit_fn_t commit_fn, + bitle_link_cancel_fn_t cancel_fn) { bool incomplete_reservation = (reserve_fn || commit_fn || cancel_fn) && @@ -73,6 +85,7 @@ esp_err_t bitle_link_register_reservable( e->handle = handle; e->type = type; e->send_fn = send_fn; + e->relay_fn = relay_fn; e->reserve_fn = reserve_fn; e->commit_fn = commit_fn; e->cancel_fn = cancel_fn; @@ -235,3 +248,50 @@ int bitle_link_broadcast(uint16_t exclude_handle, const uint8_t *data, uint16_t } return sent; } + +int bitle_link_rebroadcast( + uint16_t source_handle, const uint8_t *data, uint16_t len) +{ + struct { + uint16_t handle; + bitle_link_send_fn_t fn; + } targets[BITLE_LINK_MAX]; + size_t count = 0; + + xSemaphoreTake(s_lock, portMAX_DELAY); + for (size_t index = 0; index < BITLE_LINK_MAX; ++index) { + if (!s_links[index].in_use) { + continue; + } + bitle_link_send_fn_t fn = s_links[index].send_fn; + if (s_links[index].handle == source_handle) { + fn = s_links[index].relay_fn; + } + if (fn) { + targets[count].handle = s_links[index].handle; + targets[count].fn = fn; + count++; + } + } + xSemaphoreGive(s_lock); + + int sent = 0; + for (size_t index = 0; index < count; ++index) { + bitle_link_send_result_t result = + targets[index].fn( + targets[index].handle, data, len); + if (result == BITLE_LINK_SEND_ACCEPTED) { + sent++; + } else if (result == BITLE_LINK_SEND_POLICY_DROPPED) { + ESP_LOGD( + TAG, "rebroadcast policy drop handle=%u", + targets[index].handle); + } else { + ESP_LOGW( + TAG, + "rebroadcast send not accepted handle=%u result=%d", + targets[index].handle, result); + } + } + return sent; +} diff --git a/main/bitle_link.h b/main/bitle_link.h index d5cfd61..e36a45f 100644 --- a/main/bitle_link.h +++ b/main/bitle_link.h @@ -49,6 +49,8 @@ typedef enum { typedef bitle_link_send_result_t (*bitle_link_send_fn_t)( uint16_t handle, const uint8_t *data, uint16_t len); +typedef bitle_link_send_result_t (*bitle_link_relay_fn_t)( + uint16_t handle, const uint8_t *data, uint16_t len); typedef bitle_link_send_result_t (*bitle_link_reserve_fn_t)( uint16_t handle, uint8_t packet_type, uintptr_t *out_token); typedef bitle_link_send_result_t (*bitle_link_commit_fn_t)( @@ -71,6 +73,11 @@ esp_err_t bitle_link_register_reservable( uint16_t handle, bitle_link_type_t type, bitle_link_send_fn_t send_fn, bitle_link_reserve_fn_t reserve_fn, bitle_link_commit_fn_t commit_fn, bitle_link_cancel_fn_t cancel_fn); +esp_err_t bitle_link_register_reservable_relay( + uint16_t handle, bitle_link_type_t type, bitle_link_send_fn_t send_fn, + bitle_link_relay_fn_t relay_fn, + bitle_link_reserve_fn_t reserve_fn, bitle_link_commit_fn_t commit_fn, + bitle_link_cancel_fn_t cancel_fn); void bitle_link_unregister(uint16_t handle); bool bitle_link_ready(uint16_t handle); @@ -99,6 +106,11 @@ esp_err_t bitle_link_send(uint16_t handle, const uint8_t *data, uint16_t len); * send to all). Returns the number of links the send succeeded on. */ int bitle_link_broadcast(uint16_t exclude_handle, const uint8_t *data, uint16_t len); +/* Relays to every other registered link and, when the ingress transport + * provides an explicit same-medium callback, back onto that medium too. */ +int bitle_link_rebroadcast( + uint16_t source_handle, const uint8_t *data, uint16_t len); + #ifdef __cplusplus } #endif diff --git a/main/bitle_lora.c b/main/bitle_lora.c index 4610ffb..c046280 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -17,6 +17,7 @@ #include "bitle_stats.h" #include "lora_airtime.h" #include "lora_channel_policy.h" +#include "lora_forwarding.h" #include "lora_packet_reliability.h" #include "lora_region.h" #include "lora_trunk_protocol.h" @@ -132,10 +133,16 @@ typedef struct { bool valid; bool ack_requested; bool announcement; + bool forwarded; bool relayed_broadcast; + bool suppressed; + uint8_t hop_limit; + uint8_t hop_count; uint8_t announce_sender[8]; uint8_t destination[LORA_TRUNK_V3_NODE_ID_LEN]; uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN]; + uint64_t forward_fingerprint; + uint64_t forward_not_before_ms; } tx_trunk_context_t; static tx_trunk_context_t s_tx_context[LORA_TX_PACKET_POOL_SIZE]; @@ -146,6 +153,16 @@ static bool s_expected_control_valid; static lora_trunk_frame_t s_expected_control_data; static lora_selective_tx_t s_selective_tx; static lora_trunk_neighbor_table_t s_neighbors; +static lora_forward_cache_t s_forward_cache; + +typedef struct { + bool active; + uint8_t hop_limit; + uint8_t hop_count; + int8_t snr; + uint64_t fingerprint; +} relay_ingress_t; +static relay_ingress_t s_relay_ingress; /* Modem params (for airtime), governor, and throttle state — shared * between relay callers and the beacon path, guarded by a spinlock. */ @@ -225,6 +242,30 @@ static void diag_sync_scheduler_locked(void) } } +/* Caller holds s_gov_mux. Mark every not-yet-transmitted broadcast relay of + * this logical BitChat packet. The LoRa task releases queued or active entries + * before their next CAD/TX opportunity. */ +static uint32_t suppress_relay_fingerprint_locked( + uint64_t fingerprint) +{ + if (fingerprint == 0) { + return 0; + } + uint32_t suppressed = 0; + for (lora_tx_handle_t handle = 0; + handle < LORA_TX_PACKET_POOL_SIZE; ++handle) { + tx_trunk_context_t *context = &s_tx_context[handle]; + if (context->valid && context->relayed_broadcast && + !context->suppressed && + context->forward_fingerprint == fingerprint) { + context->suppressed = true; + suppressed++; + } + } + s_diag.forward_suppressed += suppressed; + return suppressed; +} + /* Per-origin announce throttle. Admission marks one pending copy; only a * fully transmitted packet advances last_ms. Caller holds s_gov_mux. */ static throttle_t *announce_slot_locked( @@ -326,7 +367,8 @@ static bool beacon_budget_admit(uint16_t encoded_len) return admitted; } -static bool trunk_admit_locked(const uint8_t *data, uint16_t len) +static bool trunk_admit_locked( + const uint8_t *data, uint16_t len, bool forwarded) { if (len < PKT_MIN_LEN) { return false; @@ -371,7 +413,13 @@ static bool trunk_admit_locked(const uint8_t *data, uint16_t len) * a timestamp, so a budget-deferred announce does not suppress this * origin's next transmittable announce. */ if (governed) { - if (s_gov_credit_ms < (double)airtime) { + /* A relayed signed announce is the route-discovery mechanism, not a + * new local periodic advertisement. It may consume the remaining + * bounded burst credit so one route copy can cross a weak mesh, while + * the per-origin throttle and network dedup still prevent storms. + * The resulting negative credit delays later local announcements and + * beacons until the channel budget refills. */ + if (!forwarded && s_gov_credit_ms < (double)airtime) { ESP_LOGD(TAG, "airtime budget low; deferring announce"); return false; } @@ -540,8 +588,9 @@ static void lora_link_cancel(uint16_t handle, uintptr_t token) taskEXIT_CRITICAL(&s_gov_mux); } -static bitle_link_send_result_t lora_link_commit( - uint16_t handle, uintptr_t token, const uint8_t *data, uint16_t len) +static bitle_link_send_result_t lora_link_commit_internal( + uint16_t handle, uintptr_t token, const uint8_t *data, uint16_t len, + bool same_medium_relay) { (void)handle; if (!data || len == 0 || len > BITCHAT_BLE_MAX_PACKET_SIZE || @@ -565,6 +614,10 @@ static bitle_link_send_result_t lora_link_commit( bool announcement = type == BITCHAT_MSG_ANNOUNCE || type == BITCHAT_MSG_NOISE_IDENTITY_ANNOUNCE; + relay_ingress_t ingress = s_relay_ingress; + bool forwarded = same_medium_relay && ingress.active; + uint64_t forward_fingerprint = + lora_forward_fingerprint(data, len); bool committed = false; taskENTER_CRITICAL(&s_gov_mux); @@ -577,7 +630,18 @@ static bitle_link_send_result_t lora_link_commit( taskEXIT_CRITICAL(&s_gov_mux); return BITLE_LINK_SEND_FAILED; } - if (!trunk_admit_locked(data, len)) { + if (same_medium_relay && + (!forwarded || + !lora_forward_hop_allowed( + ingress.hop_limit, ingress.hop_count))) { + s_diag.hop_limit_drops++; + s_diag.policy_drops++; + lora_tx_scheduler_cancel(&s_tx_scheduler, reserved); + diag_sync_scheduler_locked(); + taskEXIT_CRITICAL(&s_gov_mux); + return BITLE_LINK_SEND_POLICY_DROPPED; + } + if (!trunk_admit_locked(data, len, forwarded)) { s_diag.policy_drops++; lora_tx_scheduler_cancel(&s_tx_scheduler, reserved); diag_sync_scheduler_locked(); @@ -617,11 +681,32 @@ static bitle_link_send_result_t lora_link_commit( context->valid = true; context->ack_requested = ack_requested; context->announcement = announcement; + context->forwarded = forwarded; context->relayed_broadcast = !ack_requested && - memcmp( - data + PKT_SENDER_OFF, s_node_id, - sizeof(s_node_id)) != 0; + (forwarded || + memcmp( + data + PKT_SENDER_OFF, s_node_id, + sizeof(s_node_id)) != 0); + context->hop_limit = + forwarded ? ingress.hop_limit + : LORA_FORWARD_DEFAULT_HOP_LIMIT; + context->hop_count = + forwarded ? (uint8_t)(ingress.hop_count + 1u) : 0; + context->forward_fingerprint = + context->relayed_broadcast + ? forward_fingerprint + : 0; + if (forwarded && context->relayed_broadcast) { + uint16_t first_chunk = + len < TRUNK_CHUNK_TX ? len : TRUNK_CHUNK_TX; + context->forward_not_before_ms = + esp_timer_get_time() / 1000ULL + + lora_forward_delay_ms( + trunk_airtime_ms( + LORA_TRUNK_V3_HEADER_LEN + first_chunk), + ingress.snr, esp_random()); + } if (announcement) { memcpy( context->announce_sender, @@ -632,6 +717,12 @@ static bitle_link_send_result_t lora_link_commit( } memcpy(context->destination, destination, sizeof(destination)); memcpy(context->transfer_id, transfer_id, sizeof(transfer_id)); + if (!forwarded && forward_fingerprint != 0) { + (void)lora_forward_cache_observe( + &s_forward_cache, data + PKT_SENDER_OFF, + forward_fingerprint, 0, 12, + esp_timer_get_time() / 1000ULL); + } s_transfer_counter = transfer_counter; } else { memset(&s_tx_context[reserved], 0, sizeof(s_tx_context[reserved])); @@ -647,9 +738,13 @@ static bitle_link_send_result_t lora_link_commit( (uint8_t)((len + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX); ESP_LOGI(TAG, "trunk accepted type=0x%02X len=%u frags=%u priority=%u " - "addressed=%d transfer=%lu", + "addressed=%d transfer=%lu hops=%u/%u forwarded=%d", type, len, total, priority, ack_requested, - (unsigned long)transfer_counter); + (unsigned long)transfer_counter, + forwarded ? (unsigned)(ingress.hop_count + 1u) : 0u, + forwarded ? (unsigned)ingress.hop_limit + : LORA_FORWARD_DEFAULT_HOP_LIMIT, + forwarded); bitle_stats_note_activity(BITLE_LANE_TRUNK, 1); if (s_task) { xTaskNotifyGive(s_task); @@ -657,6 +752,13 @@ static bitle_link_send_result_t lora_link_commit( return BITLE_LINK_SEND_ACCEPTED; } +static bitle_link_send_result_t lora_link_commit( + uint16_t handle, uintptr_t token, const uint8_t *data, uint16_t len) +{ + return lora_link_commit_internal( + handle, token, data, len, false); +} + /* Mesh -> trunk convenience path. Reservation happens before policy state or * sequence assignment changes, and commit publishes the whole packet once. */ static bitle_link_send_result_t lora_link_send( @@ -674,6 +776,22 @@ static bitle_link_send_result_t lora_link_send( return lora_link_commit(handle, token, data, len); } +static bitle_link_send_result_t lora_link_relay_send( + uint16_t handle, const uint8_t *data, uint16_t len) +{ + if (!data || len <= PKT_TYPE_OFF) { + return BITLE_LINK_SEND_FAILED; + } + uintptr_t token = 0; + bitle_link_send_result_t result = + lora_link_reserve(handle, data[PKT_TYPE_OFF], &token); + if (result != BITLE_LINK_SEND_ACCEPTED) { + return result; + } + return lora_link_commit_internal( + handle, token, data, len, true); +} + static bool tx_scheduler_dequeue(lora_tx_handle_t *out_handle) { taskENTER_CRITICAL(&s_gov_mux); @@ -692,10 +810,23 @@ static void tx_scheduler_release( } taskENTER_CRITICAL(&s_gov_mux); tx_trunk_context_t context = s_tx_context[handle]; + uint64_t now = esp_timer_get_time() / 1000ULL; if (context.announcement) { announce_finish_locked( - context.announce_sender, transmitted, - esp_timer_get_time() / 1000ULL); + context.announce_sender, transmitted, now); + } + if (transmitted && context.forwarded) { + s_diag.forwarded_packets++; + } else if (!transmitted && context.ack_requested) { + size_t removed = + lora_trunk_neighbor_forget_next_hop( + &s_neighbors, context.destination); + if (removed > 0) { + s_diag.route_failures += (uint32_t)removed; + s_diag.neighbor_count = + (uint32_t)lora_trunk_neighbor_count( + &s_neighbors); + } } memset(&s_tx_context[handle], 0, sizeof(s_tx_context[handle])); lora_tx_scheduler_release(&s_tx_scheduler, handle); @@ -739,6 +870,8 @@ static bool build_tx_fragment(lora_tx_handle_t handle, uint8_t idx, ? LORA_TRUNK_V3_FLAG_ACK_REQUESTED : LORA_TRUNK_V3_FLAG_BROADCAST, .capabilities = LORA_TRUNK_V3_CAP_PROTOCOL, + .hop_limit = context->hop_limit, + .hop_count = context->hop_count, .fragment_index = idx, .fragment_total = total, .payload = packet->data + off, @@ -936,15 +1069,16 @@ static void learn_authenticated_neighbor( return; } uint8_t signing_key[LORA_TRUNK_SIGNING_KEY_LEN]; - if (memcmp(packet.sender_id, frame->source, - LORA_TRUNK_V3_NODE_ID_LEN) == 0 && - noise_verify_announce_identity(&packet, signing_key)) { + if (noise_verify_announce_identity(&packet, signing_key)) { + bool direct_identity = + memcmp(packet.sender_id, frame->source, + LORA_TRUNK_V3_NODE_ID_LEN) == 0; uint32_t neighbor_count = 0; taskENTER_CRITICAL(&s_gov_mux); lora_trunk_neighbor_result_t result = lora_trunk_neighbor_learn_authenticated( &s_neighbors, frame->source, packet.sender_id, signing_key, - frame->capabilities, now_ms); + direct_identity, frame->capabilities, now_ms); if (result == LORA_TRUNK_NEIGHBOR_COLLISION) { s_diag.neighbor_collisions++; } @@ -1213,12 +1347,52 @@ static void reassemble_data( queue_ack(frame); } - learn_authenticated_neighbor(frame, packet, packet_len, now); + uint64_t forward_fingerprint = + lora_forward_fingerprint(packet, packet_len); + lora_forward_observation_t observation; + uint32_t suppressed = 0; + taskENTER_CRITICAL(&s_gov_mux); + observation = lora_forward_cache_observe( + &s_forward_cache, packet + PKT_SENDER_OFF, + forward_fingerprint, frame->hop_count, snr, now); + if (observation != LORA_FORWARD_NEW) { + s_diag.forward_duplicates++; + suppressed = suppress_relay_fingerprint_locked( + forward_fingerprint); + } + taskEXIT_CRITICAL(&s_gov_mux); + if (observation == LORA_FORWARD_NEW || + observation == LORA_FORWARD_BETTER_COPY) { + /* Only a first or objectively better signed path may replace a route. + * A later weaker duplicate still suppresses redundant forwarding but + * cannot steer the neighbor table. */ + learn_authenticated_neighbor( + frame, packet, packet_len, now); + } + if (observation != LORA_FORWARD_NEW) { + ESP_LOGI( + TAG, + "trunk duplicate network packet suppressed=%lu better=%d", + (unsigned long)suppressed, + observation == LORA_FORWARD_BETTER_COPY); + return; + } + diag_inc(&s_diag.completed_packets); - ESP_LOGI(TAG, "trunk v%u RX packet len=%u rssi=%d snr=%d frags=%u", + ESP_LOGI(TAG, + "trunk v%u RX packet len=%u rssi=%d snr=%d frags=%u " + "hops=%u/%u", frame->version, packet_len, rssi, snr, - frame->fragment_total); + frame->fragment_total, frame->hop_count, + frame->hop_limit); + s_relay_ingress.active = + frame->version == LORA_TRUNK_V3_VERSION; + s_relay_ingress.hop_limit = frame->hop_limit; + s_relay_ingress.hop_count = frame->hop_count; + s_relay_ingress.snr = snr; + s_relay_ingress.fingerprint = forward_fingerprint; bitle_mesh_inbound(BITLE_LORA_LINK_HANDLE, packet, packet_len); + memset(&s_relay_ingress, 0, sizeof(s_relay_ingress)); } static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) @@ -1512,6 +1686,7 @@ static void lora_task(void *arg) unsigned ack_burst = 0; uint64_t tx_watchdog_deadline = 0; uint64_t backoff_deadline = 0; + uint64_t pending_forward_not_before_ms = 0; uint64_t ack_deadline = 0; uint64_t transfer_deadline = 0; lora_reliability_timing_t transfer_timing = {0}; @@ -1528,6 +1703,7 @@ static void lora_task(void *arg) #if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE static const uint16_t smoke_lengths[] = {100, 180, 300, 420, 520}; size_t smoke_index = 0; + uint8_t forwarding_smoke_count = 0; uint64_t next_smoke_ms = 12000ULL; #endif #if CONFIG_BITLE_LORA_DIAGNOSTIC_SUPPRESS_TX_DONE_ONCE @@ -1553,7 +1729,47 @@ static void lora_task(void *arg) } #if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE - if (smoke_index < sizeof(smoke_lengths) / sizeof(smoke_lengths[0]) && + if (forwarding_smoke_count < 3 && + now >= next_smoke_ms) { + static uint8_t forwarding_probe[100]; + memset( + forwarding_probe, 0xA5, + sizeof(forwarding_probe)); + forwarding_probe[0] = 1; + forwarding_probe[1] = BITCHAT_MSG_MESSAGE; + forwarding_probe[2] = BITLE_ORIGIN_TTL; + forwarding_probe[3] = forwarding_smoke_count; + forwarding_probe[11] = 0; + uint16_t payload_len = + sizeof(forwarding_probe) - PKT_MIN_LEN; + forwarding_probe[12] = payload_len >> 8; + forwarding_probe[13] = payload_len & 0xFF; + memcpy( + forwarding_probe + PKT_SENDER_OFF, + noise_get_local_peer_id(), 8); + bitle_link_send_result_t result = + lora_link_send( + BITLE_LORA_LINK_HANDLE, + forwarding_probe, + sizeof(forwarding_probe)); + if (result == BITLE_LINK_SEND_ACCEPTED) { + ESP_LOGI( + TAG, + "diagnostic forwarding smoke enqueue len=%u " + "expected_frags=1 sequence=%u", + (unsigned)sizeof(forwarding_probe), + forwarding_smoke_count); + forwarding_smoke_count++; + } else { + ESP_LOGW( + TAG, + "diagnostic forwarding smoke deferred result=%u", + result); + } + next_smoke_ms = now + 5000ULL; + } else if ( + smoke_index < + sizeof(smoke_lengths) / sizeof(smoke_lengths[0]) && now >= next_smoke_ms) { static uint8_t probe[BITCHAT_BLE_MAX_PACKET_SIZE]; uint16_t probe_len = smoke_lengths[smoke_index]; @@ -1632,7 +1848,9 @@ static void lora_task(void *arg) "unsupported=%lu ack_reject=%lu neighbors=%lu collision=%lu " "bitmap=%lu final=%lu dup=%lu/%lu abort=%lu/%lu " "invalid=%lu resource=%lu beacon=%lu/%lu stale=%lu " - "budget=%lu backoff=%lu announce_tx=%lu", + "budget=%lu backoff=%lu announce_tx=%lu " + "forward=%lu suppress=%lu dup_net=%lu hop_drop=%lu " + "route_fail=%lu", (unsigned long)diag.raw_rx_frames, (unsigned long)diag.crc_errors, (unsigned long)diag.tx_attempts, @@ -1682,7 +1900,12 @@ static void lora_task(void *arg) (unsigned long)diag.beacon_stale, (unsigned long)diag.beacon_budget_deferred, (unsigned long)diag.channel_backoffs, - (unsigned long)diag.announce_tx); + (unsigned long)diag.announce_tx, + (unsigned long)diag.forwarded_packets, + (unsigned long)diag.forward_suppressed, + (unsigned long)diag.forward_duplicates, + (unsigned long)diag.hop_limit_drops, + (unsigned long)diag.route_failures); next_diag_ms = now + 60000ULL; } @@ -1944,6 +2167,16 @@ static void lora_task(void *arg) } } + if (have_pending && !pending_is_beacon && + packet_handle != LORA_TX_HANDLE_INVALID && + s_tx_context[packet_handle].suppressed && + !awaiting_tx_done && !awaiting_cad) { + awaiting_backoff = false; + awaiting_ack = false; + drop_tx_packet(&packet_handle, &have_pending); + ESP_LOGI(TAG, "suppressed pending duplicate rebroadcast"); + } + /* Acks fly the moment the radio is free — no CAD, no queueing: the * peer is listening right after its TX and is waiting on us. */ bool data_waiting = have_pending || tx_scheduler_has_queued(); @@ -2080,6 +2313,7 @@ static void lora_task(void *arg) have_pending = true; pending_is_beacon = true; pending_relayed_broadcast = false; + pending_forward_not_before_ms = 0; s_expected_control_valid = false; } else { beacon_schedule.next_repeat_ms = @@ -2092,8 +2326,16 @@ static void lora_task(void *arg) tx_scheduler_dequeue(&packet_handle)) { fragment_idx = 0; pending_is_beacon = false; + if (s_tx_context[packet_handle].suppressed) { + drop_tx_packet( + &packet_handle, &have_pending); + continue; + } pending_relayed_broadcast = s_tx_context[packet_handle].relayed_broadcast; + pending_forward_not_before_ms = + s_tx_context[packet_handle] + .forward_not_before_ms; have_pending = build_tx_fragment( packet_handle, fragment_idx, &pending, &fragment_total); @@ -2131,6 +2373,11 @@ static void lora_task(void *arg) if (schedule_contention_backoff( &pending, 0, pending_relayed_broadcast, &backoff_deadline)) { + if (pending_forward_not_before_ms > + backoff_deadline) { + backoff_deadline = + pending_forward_not_before_ms; + } awaiting_backoff = true; } else { if (pending_is_beacon) { @@ -2187,6 +2434,8 @@ void bitle_lora_shutdown(void) memset(s_rx_slots, 0, sizeof(s_rx_slots)); memset(s_completed_slots, 0, sizeof(s_completed_slots)); memset(&s_selective_tx, 0, sizeof(s_selective_tx)); + lora_forward_cache_init(&s_forward_cache); + memset(&s_relay_ingress, 0, sizeof(s_relay_ingress)); s_expected_control_valid = false; s_feedback_seen = false; s_ack_head = 0; @@ -2319,6 +2568,8 @@ esp_err_t bitle_lora_init(void) memset(s_rx_slots, 0, sizeof(s_rx_slots)); memset(s_completed_slots, 0, sizeof(s_completed_slots)); lora_trunk_neighbor_table_init(&s_neighbors); + lora_forward_cache_init(&s_forward_cache); + memset(&s_relay_ingress, 0, sizeof(s_relay_ingress)); s_expected_control_valid = false; s_feedback_seen = false; memset(&s_selective_tx, 0, sizeof(s_selective_tx)); @@ -2354,9 +2605,10 @@ esp_err_t bitle_lora_init(void) } s_active = true; - err = bitle_link_register_reservable( + err = bitle_link_register_reservable_relay( BITLE_LORA_LINK_HANDLE, BITLE_LINK_LORA, lora_link_send, - lora_link_reserve, lora_link_commit, lora_link_cancel); + lora_link_relay_send, lora_link_reserve, lora_link_commit, + lora_link_cancel); if (err != ESP_OK) { ESP_LOGE(TAG, "trunk link registration failed: %s", esp_err_to_name(err)); diff --git a/main/bitle_lora.h b/main/bitle_lora.h index eade9ba..f27af1b 100644 --- a/main/bitle_lora.h +++ b/main/bitle_lora.h @@ -78,6 +78,11 @@ typedef struct { uint32_t beacon_budget_deferred; uint32_t channel_backoffs; uint32_t announce_tx; + uint32_t forwarded_packets; + uint32_t forward_suppressed; + uint32_t forward_duplicates; + uint32_t hop_limit_drops; + uint32_t route_failures; } bitle_lora_diag_t; /* Snapshot transport diagnostics. All counters are monotonic for the boot. diff --git a/main/bitle_mesh.c b/main/bitle_mesh.c index df875c5..ff90124 100644 --- a/main/bitle_mesh.c +++ b/main/bitle_mesh.c @@ -289,7 +289,10 @@ static void relay_packet(uint16_t src_link, uint8_t *buffer, uint16_t len, const buffer[2] = packet->ttl - 1; - int forwarded = bitle_link_broadcast(src_link, buffer, len); + int forwarded = + bitle_link_type_of(src_link) == BITLE_LINK_LORA + ? bitle_link_rebroadcast(src_link, buffer, len) + : bitle_link_broadcast(src_link, buffer, len); if (forwarded > 0) { bitle_stats_note_tx(); bitle_stats_note_activity(BITLE_LANE_FWD, 1); diff --git a/main/lora_forwarding.c b/main/lora_forwarding.c new file mode 100644 index 0000000..f53cd4e --- /dev/null +++ b/main/lora_forwarding.c @@ -0,0 +1,118 @@ +#include "lora_forwarding.h" + +#include +#include + +void lora_forward_cache_init(lora_forward_cache_t *cache) +{ + if (cache) { + memset(cache, 0, sizeof(*cache)); + } +} + +uint64_t lora_forward_fingerprint(const uint8_t *packet, size_t len) +{ + if (!packet || len == 0) { + return 0; + } + uint64_t hash = 1469598103934665603ULL; + for (size_t index = 0; index < len; ++index) { + if (index == 2) { + continue; + } + hash ^= packet[index]; + hash *= 1099511628211ULL; + } + return hash == 0 ? 1 : hash; +} + +lora_forward_observation_t lora_forward_cache_observe( + lora_forward_cache_t *cache, const uint8_t source[8], + uint64_t transfer_fingerprint, uint8_t hop_count, int8_t snr, + uint64_t now_ms) +{ + if (!cache || !source || transfer_fingerprint == 0) { + return LORA_FORWARD_DUPLICATE; + } + + lora_forward_cache_entry_t *free_entry = NULL; + lora_forward_cache_entry_t *oldest = NULL; + for (size_t index = 0; index < LORA_FORWARD_CACHE_CAPACITY; ++index) { + lora_forward_cache_entry_t *entry = &cache->entries[index]; + if (entry->in_use && now_ms >= entry->expires_ms) { + memset(entry, 0, sizeof(*entry)); + } + if (!entry->in_use) { + if (!free_entry) { + free_entry = entry; + } + continue; + } + if (memcmp(entry->source, source, sizeof(entry->source)) == 0 && + entry->transfer_fingerprint == transfer_fingerprint) { + bool better = + hop_count < entry->best_hop_count || + (hop_count == entry->best_hop_count && + snr > entry->best_snr); + if (better) { + entry->best_hop_count = hop_count; + entry->best_snr = snr; + return LORA_FORWARD_BETTER_COPY; + } + return LORA_FORWARD_DUPLICATE; + } + if (!oldest || entry->expires_ms < oldest->expires_ms) { + oldest = entry; + } + } + + lora_forward_cache_entry_t *entry = + free_entry ? free_entry : oldest; + if (!entry) { + return LORA_FORWARD_DUPLICATE; + } + memset(entry, 0, sizeof(*entry)); + entry->in_use = true; + memcpy(entry->source, source, sizeof(entry->source)); + entry->transfer_fingerprint = transfer_fingerprint; + entry->expires_ms = + UINT64_MAX - now_ms < LORA_FORWARD_CACHE_RETENTION_MS + ? UINT64_MAX + : now_ms + LORA_FORWARD_CACHE_RETENTION_MS; + entry->best_hop_count = hop_count; + entry->best_snr = snr; + return LORA_FORWARD_NEW; +} + +bool lora_forward_hop_allowed(uint8_t hop_limit, uint8_t hop_count) +{ + return hop_limit > 0 && + hop_limit <= LORA_FORWARD_MAX_HOP_LIMIT && + hop_count < hop_limit; +} + +uint32_t lora_forward_delay_ms( + uint32_t frame_airtime_ms, int8_t snr, uint32_t entropy) +{ + if (frame_airtime_ms == 0) { + return 0; + } + int quality = snr; + if (quality < -20) { + quality = -20; + } else if (quality > 12) { + quality = 12; + } + + uint64_t base = frame_airtime_ms / 2u; + if (base < 100u) { + base = 100u; + } + uint64_t quality_penalty = + (uint64_t)(12 - quality) * frame_airtime_ms / 16u; + uint32_t spread = + frame_airtime_ms < 200u ? 200u : frame_airtime_ms; + uint64_t delay = + base + quality_penalty + entropy % (spread + 1u); + return delay > 20000u ? 20000u : (uint32_t)delay; +} diff --git a/main/lora_forwarding.h b/main/lora_forwarding.h new file mode 100644 index 0000000..8f3ade6 --- /dev/null +++ b/main/lora_forwarding.h @@ -0,0 +1,61 @@ +#ifndef LORA_FORWARDING_H +#define LORA_FORWARDING_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LORA_FORWARD_DEFAULT_HOP_LIMIT 3 +#define LORA_FORWARD_MAX_HOP_LIMIT 4 +#define LORA_FORWARD_CACHE_CAPACITY 32 +#define LORA_FORWARD_CACHE_RETENTION_MS 300000ULL + +typedef struct { + bool in_use; + uint8_t source[8]; + uint64_t transfer_fingerprint; + uint64_t expires_ms; + uint8_t best_hop_count; + int8_t best_snr; +} lora_forward_cache_entry_t; + +typedef struct { + lora_forward_cache_entry_t entries[LORA_FORWARD_CACHE_CAPACITY]; +} lora_forward_cache_t; + +typedef enum { + LORA_FORWARD_NEW = 0, + LORA_FORWARD_DUPLICATE, + LORA_FORWARD_BETTER_COPY, +} lora_forward_observation_t; + +void lora_forward_cache_init(lora_forward_cache_t *cache); + +/* Stable application-transfer identity. The BitChat TTL byte is deliberately + * omitted because each network hop decrements it. */ +uint64_t lora_forward_fingerprint(const uint8_t *packet, size_t len); + +/* Expiring bounded dedup keyed by the BitChat source and stable packet + * fingerprint. A better duplicate may suppress a weaker scheduled relay, but + * is still a duplicate for application delivery. */ +lora_forward_observation_t lora_forward_cache_observe( + lora_forward_cache_t *cache, const uint8_t source[8], + uint64_t transfer_fingerprint, uint8_t hop_count, int8_t snr, + uint64_t now_ms); + +bool lora_forward_hop_allowed(uint8_t hop_limit, uint8_t hop_count); + +/* Stronger receptions receive a smaller quality penalty; actual airtime sets + * both the base and randomized spread. */ +uint32_t lora_forward_delay_ms( + uint32_t frame_airtime_ms, int8_t snr, uint32_t entropy); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/main/lora_trunk_protocol.c b/main/lora_trunk_protocol.c index 31f08a4..b5c7bed 100644 --- a/main/lora_trunk_protocol.c +++ b/main/lora_trunk_protocol.c @@ -1,5 +1,6 @@ #include "lora_trunk_protocol.h" #include "lora_channel_policy.h" +#include "lora_forwarding.h" #include @@ -75,7 +76,8 @@ static bool v3_semantics_valid(const lora_trunk_frame_t *frame) if (frame->kind == LORA_TRUNK_KIND_DATA) { if (frame->fragment_total == 0 || - frame->fragment_total > LORA_TRUNK_MAX_FRAGMENTS) { + frame->fragment_total > LORA_TRUNK_MAX_FRAGMENTS || + frame->hop_limit > LORA_FORWARD_MAX_HOP_LIMIT) { return false; } if (frame->payload_len == 0 || !frame->payload) { @@ -408,7 +410,7 @@ lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( const uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN], const uint8_t reachable_peer[LORA_TRUNK_V3_NODE_ID_LEN], const uint8_t signing_key[LORA_TRUNK_SIGNING_KEY_LEN], - uint16_t capabilities, uint64_t now_ms) + bool direct_identity, uint16_t capabilities, uint64_t now_ms) { if (!table || !node_id || !reachable_peer || !signing_key || all_zero(node_id, LORA_TRUNK_V3_NODE_ID_LEN) || @@ -437,13 +439,16 @@ lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( bool same_node = memcmp(entry->node_id, node_id, LORA_TRUNK_V3_NODE_ID_LEN) == 0; - if (same_node && entry->identity_authenticated && + if (direct_identity && same_node && + entry->identity_authenticated && + entry->direct_identity && memcmp(entry->signing_key, signing_key, LORA_TRUNK_SIGNING_KEY_LEN) != 0) { entry->quarantined = true; collision = true; } - if (same_node && !entry->identity_authenticated) { + if (direct_identity && same_node && + !entry->identity_authenticated) { unauthenticated_node = entry; } if (entry->identity_authenticated && @@ -462,6 +467,7 @@ lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( return LORA_TRUNK_NEIGHBOR_COLLISION; } memcpy(same_route->node_id, node_id, LORA_TRUNK_V3_NODE_ID_LEN); + same_route->direct_identity = direct_identity; same_route->capabilities = capabilities; same_route->last_seen_ms = now_ms; return LORA_TRUNK_NEIGHBOR_REFRESHED; @@ -483,6 +489,7 @@ lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( } entry->in_use = true; entry->identity_authenticated = true; + entry->direct_identity = direct_identity; memcpy(entry->node_id, node_id, LORA_TRUNK_V3_NODE_ID_LEN); memcpy(entry->reachable_peer, reachable_peer, LORA_TRUNK_V3_NODE_ID_LEN); @@ -527,6 +534,27 @@ bool lora_trunk_neighbor_route( return true; } +size_t lora_trunk_neighbor_forget_next_hop( + lora_trunk_neighbor_table_t *table, + const uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN]) +{ + if (!table || !node_id) { + return 0; + } + size_t removed = 0; + for (size_t index = 0; + index < LORA_TRUNK_NEIGHBOR_CAPACITY; ++index) { + lora_trunk_neighbor_t *entry = &table->entries[index]; + if (entry->in_use && entry->identity_authenticated && + memcmp(entry->node_id, node_id, + LORA_TRUNK_V3_NODE_ID_LEN) == 0) { + memset(entry, 0, sizeof(*entry)); + removed++; + } + } + return removed; +} + size_t lora_trunk_neighbor_count(const lora_trunk_neighbor_table_t *table) { if (!table) { diff --git a/main/lora_trunk_protocol.h b/main/lora_trunk_protocol.h index bc3ac4b..6c1a07d 100644 --- a/main/lora_trunk_protocol.h +++ b/main/lora_trunk_protocol.h @@ -111,6 +111,7 @@ typedef struct { bool in_use; bool quarantined; bool identity_authenticated; + bool direct_identity; uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN]; uint8_t reachable_peer[LORA_TRUNK_V3_NODE_ID_LEN]; uint8_t signing_key[LORA_TRUNK_SIGNING_KEY_LEN]; @@ -151,7 +152,7 @@ lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( const uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN], const uint8_t reachable_peer[LORA_TRUNK_V3_NODE_ID_LEN], const uint8_t signing_key[LORA_TRUNK_SIGNING_KEY_LEN], - uint16_t capabilities, uint64_t now_ms); + bool direct_identity, uint16_t capabilities, uint64_t now_ms); /* Returns the freshest non-expired, non-quarantined route for a BitChat peer. */ bool lora_trunk_neighbor_route( @@ -161,6 +162,12 @@ bool lora_trunk_neighbor_route( uint8_t out_node_id[LORA_TRUNK_V3_NODE_ID_LEN], uint16_t *out_capabilities); +/* Removes authenticated routes whose next hop failed terminal per-hop + * delivery. The next send falls back to broadcast discovery. */ +size_t lora_trunk_neighbor_forget_next_hop( + lora_trunk_neighbor_table_t *table, + const uint8_t node_id[LORA_TRUNK_V3_NODE_ID_LEN]); + size_t lora_trunk_neighbor_count(const lora_trunk_neighbor_table_t *table); #ifdef __cplusplus diff --git a/tests/test_lora_forwarding.c b/tests/test_lora_forwarding.c new file mode 100644 index 0000000..171e220 --- /dev/null +++ b/tests/test_lora_forwarding.c @@ -0,0 +1,65 @@ +#include "lora_forwarding.h" + +#include +#include + +int main(void) +{ + uint8_t packet[64]; + memset(packet, 0xA5, sizeof(packet)); + packet[2] = 7; + uint64_t fingerprint = + lora_forward_fingerprint(packet, sizeof(packet)); + packet[2] = 2; + assert(lora_forward_fingerprint(packet, sizeof(packet)) == + fingerprint); + packet[3] ^= 1; + assert(lora_forward_fingerprint(packet, sizeof(packet)) != + fingerprint); + + assert(lora_forward_hop_allowed(3, 0)); + assert(lora_forward_hop_allowed(3, 2)); + assert(!lora_forward_hop_allowed(3, 3)); + assert(!lora_forward_hop_allowed(0, 0)); + assert(!lora_forward_hop_allowed( + LORA_FORWARD_MAX_HOP_LIMIT + 1, 0)); + + uint32_t strong = + lora_forward_delay_ms(1000, 10, 17); + uint32_t weak = + lora_forward_delay_ms(1000, -12, 17); + assert(strong < weak); + assert(lora_forward_delay_ms(2000, 10, 17) > strong); + assert(lora_forward_delay_ms(0, 0, 0) == 0); + + lora_forward_cache_t cache; + lora_forward_cache_init(&cache); + uint8_t source_a[8] = {1}; + uint8_t source_b[8] = {2}; + assert(lora_forward_cache_observe( + &cache, source_a, fingerprint, 2, -8, 1000) == + LORA_FORWARD_NEW); + assert(lora_forward_cache_observe( + &cache, source_a, fingerprint, 2, -10, 1100) == + LORA_FORWARD_DUPLICATE); + assert(lora_forward_cache_observe( + &cache, source_a, fingerprint, 1, 2, 1200) == + LORA_FORWARD_BETTER_COPY); + assert(lora_forward_cache_observe( + &cache, source_b, fingerprint, 1, 2, 1300) == + LORA_FORWARD_NEW); + assert(lora_forward_cache_observe( + &cache, source_a, fingerprint, 0, 5, + 1000 + LORA_FORWARD_CACHE_RETENTION_MS) == + LORA_FORWARD_NEW); + + for (uint64_t index = 0; + index < LORA_FORWARD_CACHE_CAPACITY + 8; ++index) { + uint8_t source[8] = {0}; + source[0] = (uint8_t)(index + 3); + assert(lora_forward_cache_observe( + &cache, source, index + 11, 1, 0, + 2000 + index) == LORA_FORWARD_NEW); + } + return 0; +} diff --git a/tests/test_lora_multihop.py b/tests/test_lora_multihop.py new file mode 100644 index 0000000..f09ac87 --- /dev/null +++ b/tests/test_lora_multihop.py @@ -0,0 +1,138 @@ +import unittest + +from tools.lora_reliability_sim import ( + MeshHop, + MultiHopMeshModel, +) + + +def bidirectional( + left: str, + right: str, + *, + loss: float = 0.0, + snr_db: int = 0, +) -> dict[tuple[str, str], MeshHop]: + hop = MeshHop(loss=loss, snr_db=snr_db) + return {(left, right): hop, (right, left): hop} + + +class MultiHopForwardingTests(unittest.TestCase): + def test_three_node_line_exceeds_loss_gate(self): + model = MultiHopMeshModel( + {"left", "relay", "right"}, + { + **bidirectional("left", "relay", loss=0.2, snr_db=-4), + **bidirectional("relay", "right", loss=0.2, snr_db=-7), + }, + attempts_per_hop=4, + seed=17, + ) + delivered = sum( + model.send( + "left", index.to_bytes(4, "big"), "right" + ).delivered + for index in range(1, 201) + ) + self.assertGreaterEqual(delivered, 180) + + def test_triangle_multipath_delivers_application_once(self): + links = { + **bidirectional("a", "b", snr_db=8), + **bidirectional("b", "c", snr_db=2), + **bidirectional("a", "c", snr_db=-5), + } + result = MultiHopMeshModel( + {"a", "b", "c"}, links, seed=9 + ).send("a", b"triangle", "c") + self.assertTrue(result.delivered) + self.assertEqual(result.application_deliveries, 1) + self.assertGreaterEqual(result.duplicate_receptions, 1) + self.assertLessEqual(result.processed_events, 7) + + def test_ring_broadcast_terminates_without_loop(self): + links = {} + for left, right in ( + ("a", "b"), ("b", "c"), ("c", "d"), ("d", "a") + ): + links.update(bidirectional(left, right, snr_db=1)) + result = MultiHopMeshModel( + {"a", "b", "c", "d"}, links, seed=3 + ).send("a", b"ring") + self.assertTrue(result.delivered) + self.assertEqual(result.application_deliveries, 3) + self.assertGreater(result.suppressed_forwards, 0) + self.assertLessEqual(result.processed_events, 9) + + def test_stale_route_failure_falls_back_and_is_removed(self): + links = { + **bidirectional("a", "b", snr_db=6), + **bidirectional("a", "d", snr_db=0), + **bidirectional("d", "c", snr_db=-2), + } + model = MultiHopMeshModel( + {"a", "b", "c", "d"}, links, seed=12 + ) + model.learn_route("a", "c", "b") + model.disable_link("a", "b") + result = model.send("a", b"route-failure", "c") + self.assertTrue(result.delivered) + self.assertEqual(result.route_failures, 1) + self.assertNotIn("c", model.routes["a"]) + self.assertLessEqual(result.processed_events, 6) + + def test_relay_reboot_clears_route_but_new_transfer_recovers(self): + links = { + **bidirectional("a", "b", snr_db=3), + **bidirectional("b", "c", snr_db=-1), + } + model = MultiHopMeshModel( + {"a", "b", "c"}, links, seed=33 + ) + first = model.send("a", b"before-reboot", "c") + model.reboot_node("b") + second = model.send("a", b"after-reboot", "c") + self.assertTrue(first.delivered) + self.assertTrue(second.delivered) + self.assertEqual(second.application_deliveries, 1) + + def test_asymmetric_links_and_malicious_repeats_stay_bounded(self): + model = MultiHopMeshModel( + {"a", "b", "c"}, + { + ("a", "b"): MeshHop(snr_db=4), + ("b", "c"): MeshHop(snr_db=-3), + }, + seed=41, + ) + forward = model.send("a", b"asymmetric", "c") + reverse = model.send("c", b"no-reverse", "a") + self.assertTrue(forward.delivered) + self.assertFalse(reverse.delivered) + + first = model.send("a", b"repeated") + repeats = [ + model.send("a", b"repeated") for _ in range(100) + ] + self.assertGreater(first.transmissions, 0) + self.assertTrue(all(result.transmissions == 0 for result in repeats)) + self.assertTrue(all( + result.application_deliveries == 0 for result in repeats + )) + + def test_hop_limit_stops_before_unbounded_forwarding(self): + links = { + **bidirectional("a", "b"), + **bidirectional("b", "c"), + **bidirectional("c", "d"), + } + result = MultiHopMeshModel( + {"a", "b", "c", "d"}, links, seed=5 + ).send("a", b"short-limit", "d", hop_limit=2) + self.assertFalse(result.delivered) + self.assertGreater(result.hop_limit_drops, 0) + self.assertLessEqual(result.processed_events, 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lora_trunk_protocol.c b/tests/test_lora_trunk_protocol.c index 0c45edb..2764903 100644 --- a/tests/test_lora_trunk_protocol.c +++ b/tests/test_lora_trunk_protocol.c @@ -61,6 +61,8 @@ static size_t encode( static void test_v3_round_trip_and_malformed(void) { lora_trunk_frame_t data = make_data(SOURCE_A, SOURCE_B, false); + data.hop_limit = 3; + data.hop_count = 2; uint8_t wire[255]; size_t len = encode(&data, wire); lora_trunk_frame_t parsed; @@ -71,6 +73,8 @@ static void test_v3_round_trip_and_malformed(void) assert(memcmp(parsed.source, SOURCE_A, 8) == 0); assert(memcmp(parsed.destination, SOURCE_B, 8) == 0); assert(memcmp(parsed.transfer_id, data.transfer_id, 12) == 0); + assert(parsed.hop_limit == 3); + assert(parsed.hop_count == 2); for (size_t i = 0; i < LORA_TRUNK_V3_HEADER_LEN; ++i) { uint8_t saved = wire[i]; @@ -91,6 +95,12 @@ static void test_v3_round_trip_and_malformed(void) invalid = data; memset(invalid.transfer_id, 0, sizeof(invalid.transfer_id)); assert(!lora_trunk_v3_encode(&invalid, wire, sizeof(wire), &len)); + invalid = data; + invalid.hop_limit = 5; + assert(!lora_trunk_v3_encode(&invalid, wire, sizeof(wire), &len)); + invalid = data; + invalid.hop_count = 4; + assert(!lora_trunk_v3_encode(&invalid, wire, sizeof(wire), &len)); } static void test_exact_control_matching(void) @@ -203,11 +213,15 @@ static void test_compact_beacon_and_identity_separation(void) uint8_t key[32] = {1}; assert(lora_trunk_neighbor_learn_authenticated( &table, SOURCE_A, SOURCE_A, key, - LORA_TRUNK_V3_CAP_PROTOCOL, 1300) == + true, LORA_TRUNK_V3_CAP_PROTOCOL, 1300) == LORA_TRUNK_NEIGHBOR_LEARNED); assert(table.entries[0].identity_authenticated); assert(lora_trunk_neighbor_route( &table, SOURCE_A, 1400, 5000, route, NULL)); + assert(lora_trunk_neighbor_forget_next_hop( + &table, SOURCE_A) == 1); + assert(!lora_trunk_neighbor_route( + &table, SOURCE_A, 1500, 5000, route, NULL)); lora_trunk_frame_t invalid = beacon; invalid.fragment_total = 1; @@ -261,9 +275,24 @@ static void test_neighbor_routes_and_collision(void) uint8_t route[8]; uint16_t capabilities = 0; + /* One relay may advertise independently signed routes for several peers; + * this is not a radio-node identity collision. */ + assert(lora_trunk_neighbor_learn_authenticated( + &table, SOURCE_B, SOURCE_A, key_a, false, + LORA_TRUNK_V3_CAP_PROTOCOL, 900) == + LORA_TRUNK_NEIGHBOR_LEARNED); + assert(lora_trunk_neighbor_learn_authenticated( + &table, SOURCE_B, BOOT_A, key_b, false, + LORA_TRUNK_V3_CAP_PROTOCOL, 901) == + LORA_TRUNK_NEIGHBOR_LEARNED); + assert(lora_trunk_neighbor_route( + &table, SOURCE_A, 950, 1000, route, NULL)); + assert(memcmp(route, SOURCE_B, 8) == 0); + + lora_trunk_neighbor_table_init(&table); assert(lora_trunk_neighbor_learn_authenticated( &table, SOURCE_A, SOURCE_A, key_a, - LORA_TRUNK_V3_CAP_PROTOCOL, 1000) == + true, LORA_TRUNK_V3_CAP_PROTOCOL, 1000) == LORA_TRUNK_NEIGHBOR_LEARNED); assert(lora_trunk_neighbor_route( &table, SOURCE_A, 1500, 1000, route, &capabilities)); @@ -274,7 +303,7 @@ static void test_neighbor_routes_and_collision(void) assert(lora_trunk_neighbor_learn_authenticated( &table, SOURCE_A, SOURCE_A, key_b, - LORA_TRUNK_V3_CAP_PROTOCOL, 2000) == + true, LORA_TRUNK_V3_CAP_PROTOCOL, 2000) == LORA_TRUNK_NEIGHBOR_COLLISION); assert(!lora_trunk_neighbor_route( &table, SOURCE_A, 2000, 1000, route, NULL)); @@ -287,7 +316,8 @@ static void test_neighbor_routes_and_collision(void) key[0] = (uint8_t)(i + 1); lora_trunk_neighbor_result_t result = lora_trunk_neighbor_learn_authenticated( - &table, node, node, key, LORA_TRUNK_V3_CAP_PROTOCOL, i); + &table, node, node, key, true, + LORA_TRUNK_V3_CAP_PROTOCOL, i); assert(i < LORA_TRUNK_NEIGHBOR_CAPACITY ? result == LORA_TRUNK_NEIGHBOR_LEARNED : result == LORA_TRUNK_NEIGHBOR_EVICTED); @@ -303,7 +333,7 @@ static void test_neighbor_routes_and_collision(void) key[0] = (uint8_t)(i + 1); assert(lora_trunk_neighbor_learn_authenticated( &table, node, node, key, - LORA_TRUNK_V3_CAP_PROTOCOL, i) == + true, LORA_TRUNK_V3_CAP_PROTOCOL, i) == LORA_TRUNK_NEIGHBOR_LEARNED); table.entries[i].quarantined = true; } @@ -311,7 +341,7 @@ static void test_neighbor_routes_and_collision(void) uint8_t new_key[32] = {0xFE}; assert(lora_trunk_neighbor_learn_authenticated( &table, new_node, new_node, new_key, - LORA_TRUNK_V3_CAP_PROTOCOL, 100) == + true, LORA_TRUNK_V3_CAP_PROTOCOL, 100) == LORA_TRUNK_NEIGHBOR_COLLISION); } diff --git a/tools/lora_hardware_smoke.py b/tools/lora_hardware_smoke.py index a7b05c8..7b256b9 100755 --- a/tools/lora_hardware_smoke.py +++ b/tools/lora_hardware_smoke.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Capture concurrent LoRa diagnostics from two serial-connected nodes.""" +"""Capture concurrent LoRa diagnostics from serial-connected nodes.""" from __future__ import annotations @@ -22,6 +22,9 @@ SMOKE_RE = re.compile( r"diagnostic smoke enqueue len=(\d+) expected_frags=(\d+) addressed=(\d+)" ) +FORWARD_SMOKE_RE = re.compile( + r"diagnostic forwarding smoke enqueue len=(\d+) expected_frags=(\d+)" +) PROFILE_RE = re.compile(r"trunk up: region=(\w+).* SF(\d+).*preamble=(\d+)") DIAG_RE = re.compile( r"tmo=(\d+).*radio_err=(\d+).*busy_tmo=(\d+).*" @@ -54,6 +57,16 @@ r"beacon=(\d+)/(\d+) stale=(\d+) budget=(\d+) " r"backoff=(\d+) announce_tx=(\d+)" ) +FORWARD_DIAG_RE = re.compile( + r"forward=(\d+) suppress=(\d+) dup_net=(\d+) " + r"hop_drop=(\d+) route_fail=(\d+)" +) +FORWARD_TX_RE = re.compile( + r"trunk accepted .*hops=(\d+)/(\d+) forwarded=(\d+)" +) +FORWARD_RX_RE = re.compile( + r"trunk v\d+ RX packet .*hops=(\d+)/(\d+)" +) def reader( @@ -79,12 +92,19 @@ def reader( def main() -> int: parser = argparse.ArgumentParser() - parser.add_argument("ports", nargs=2, help="serial ports for the two nodes") + parser.add_argument( + "ports", nargs="+", help="serial ports for two or more nodes" + ) parser.add_argument("--seconds", type=int, default=75) parser.add_argument("--json-out") - parser.add_argument("--reset", action="store_true", help="reset both nodes at capture start") + parser.add_argument( + "--reset", action="store_true", + help="reset all nodes at capture start", + ) parser.add_argument("--quiet", action="store_true", help="print only the JSON summary") args = parser.parse_args() + if len(args.ports) < 2: + parser.error("at least two serial ports are required") messages: queue.Queue[tuple[str, str]] = queue.Queue() stop = threading.Event() @@ -99,22 +119,15 @@ def main() -> int: thread.start() summary: dict[str, dict[str, object]] = { - "node-1": { - "rx_fragments": Counter(), - "tx_fragments": Counter(), - "raw": [], - "events": Counter(), - "diagnostics": [], - "profile": None, - }, - "node-2": { + f"node-{index + 1}": { "rx_fragments": Counter(), "tx_fragments": Counter(), "raw": [], "events": Counter(), "diagnostics": [], "profile": None, - }, + } + for index in range(len(args.ports)) } deadline = time.monotonic() + args.seconds try: @@ -132,6 +145,8 @@ def main() -> int: if match := SMOKE_RE.search(line): mode = "addressed" if match.group(3) == "1" else "broadcast" summary[label]["events"][f"smoke_{mode}"] += 1 + if FORWARD_SMOKE_RE.search(line): + summary[label]["events"]["smoke_forwarding_broadcast"] += 1 if match := RAW_RE.search(line): summary[label]["raw"].append( { @@ -224,6 +239,22 @@ def main() -> int: summary[label]["diagnostics"].append( channel_diag ) + if match := FORWARD_DIAG_RE.search(line): + forward_diag = { + "forwarded_packets": int(match.group(1)), + "forward_suppressed": int(match.group(2)), + "network_duplicates": int(match.group(3)), + "hop_limit_drops": int(match.group(4)), + "route_failures": int(match.group(5)), + } + if summary[label]["diagnostics"]: + summary[label]["diagnostics"][-1].update( + forward_diag + ) + else: + summary[label]["diagnostics"].append( + forward_diag + ) if "authenticated neighbor" in line: summary[label]["events"]["authenticated_neighbor"] += 1 if match := CONTROL_RE.search(line): @@ -247,6 +278,20 @@ def main() -> int: summary[label]["events"]["beacon_rx"] += 1 if int(match.group(2)) == 4: summary[label]["events"]["beacon_stale"] += 1 + if match := FORWARD_TX_RE.search(line): + if int(match.group(3)): + summary[label]["events"]["forwarded_tx"] += 1 + summary[label]["events"][ + f"tx_hops_{match.group(1)}_of_{match.group(2)}" + ] += 1 + if match := FORWARD_RX_RE.search(line): + summary[label]["events"][ + f"rx_hops_{match.group(1)}_of_{match.group(2)}" + ] += 1 + if "duplicate network packet" in line: + summary[label]["events"][ + "network_duplicate_suppressed" + ] += 1 if "trunk transfer COMPLETE" in line: summary[label]["events"]["transfer_complete"] += 1 if "TX watchdog expired" in line: diff --git a/tools/lora_reliability_sim.py b/tools/lora_reliability_sim.py index 0a8ac99..1e58164 100755 --- a/tools/lora_reliability_sim.py +++ b/tools/lora_reliability_sim.py @@ -10,6 +10,7 @@ from __future__ import annotations from dataclasses import dataclass +import heapq import math import random from typing import Iterable @@ -431,6 +432,293 @@ def run(self) -> DiscoveryResult: ) +@dataclass(frozen=True) +class MeshHop: + loss: float = 0.0 + snr_db: int = 0 + + +@dataclass(frozen=True) +class MeshDeliveryResult: + delivered: bool + application_deliveries: int + reached_nodes: frozenset[str] + transmissions: int + duplicate_receptions: int + suppressed_forwards: int + hop_limit_drops: int + route_failures: int + processed_events: int + + +class MultiHopMeshModel: + """Bounded flood/directed-forwarding model for trunk v3. + + Each receiving node deduplicates the stable application transfer before + forwarding. Directed traffic uses a fresh reverse-path route when present, + retries each selected hop, and falls back to bounded flooding if the next + hop is unavailable. The application keeps an independent dedup set. + """ + + def __init__( + self, + nodes: Iterable[str], + links: dict[tuple[str, str], MeshHop], + *, + attempts_per_hop: int = 4, + hop_limit: int = 3, + route_ttl_ms: int = 180_000, + cache_retention_ms: int = 300_000, + cache_capacity: int = 32, + frame_airtime_ms: int = 1_000, + seed: int = 1, + ): + self.nodes = frozenset(nodes) + if len(self.nodes) < 2: + raise ValueError("mesh needs at least two nodes") + if attempts_per_hop <= 0 or not 1 <= hop_limit <= 4: + raise ValueError("invalid forwarding bounds") + if ( + route_ttl_ms <= 0 + or cache_retention_ms <= 0 + or cache_capacity <= 0 + or frame_airtime_ms <= 0 + ): + raise ValueError("invalid route, cache, or airtime bound") + for (source, destination), hop in links.items(): + if source not in self.nodes or destination not in self.nodes: + raise ValueError("link endpoint is not part of the mesh") + if source == destination or not 0.0 <= hop.loss <= 1.0: + raise ValueError("invalid directed mesh link") + self.links = dict(links) + self.attempts_per_hop = attempts_per_hop + self.hop_limit = hop_limit + self.route_ttl_ms = route_ttl_ms + self.cache_retention_ms = cache_retention_ms + self.cache_capacity = cache_capacity + self.frame_airtime_ms = frame_airtime_ms + self.random = random.Random(seed) + self.now_ms = 0 + self.disabled_links: set[tuple[str, str]] = set() + self.routes: dict[str, dict[str, tuple[str, int]]] = { + node: {} for node in self.nodes + } + self.network_seen: dict[str, dict[bytes, int]] = { + node: {} for node in self.nodes + } + self.application_seen: dict[str, set[bytes]] = { + node: set() for node in self.nodes + } + + def learn_route( + self, node: str, destination: str, next_hop: str + ) -> None: + if ( + node not in self.nodes + or destination not in self.nodes + or next_hop not in self.nodes + ): + raise ValueError("route endpoint is not part of the mesh") + self.routes[node][destination] = ( + next_hop, self.now_ms + self.route_ttl_ms + ) + + def disable_link(self, source: str, destination: str) -> None: + self.disabled_links.add((source, destination)) + + def reboot_node(self, node: str) -> None: + if node not in self.nodes: + raise ValueError("node is not part of the mesh") + self.routes[node].clear() + self.network_seen[node].clear() + self.application_seen[node].clear() + + def _observe(self, node: str, transfer_id: bytes) -> bool: + cache = self.network_seen[node] + expired = [ + key for key, expiry in cache.items() + if self.now_ms >= expiry + ] + for key in expired: + del cache[key] + if transfer_id in cache: + return False + if len(cache) >= self.cache_capacity: + oldest = min(cache, key=cache.get) + del cache[oldest] + cache[transfer_id] = self.now_ms + self.cache_retention_ms + return True + + def _fresh_next_hop( + self, node: str, destination: str + ) -> str | None: + route = self.routes[node].get(destination) + if not route: + return None + next_hop, expiry = route + if self.now_ms >= expiry: + del self.routes[node][destination] + return None + return next_hop + + def _outgoing(self, node: str) -> list[str]: + return sorted( + destination + for source, destination in self.links + if source == node + ) + + def _transmit(self, source: str, destination: str) -> tuple[bool, int]: + hop = self.links.get((source, destination)) + if not hop or (source, destination) in self.disabled_links: + return False, 0 + attempts = 0 + for _ in range(self.attempts_per_hop): + attempts += 1 + if self.random.random() >= hop.loss: + return True, attempts + return False, attempts + + def send( + self, + source: str, + transfer_id: bytes, + destination: str | None = None, + *, + hop_limit: int | None = None, + ) -> MeshDeliveryResult: + if source not in self.nodes: + raise ValueError("source is not part of the mesh") + if destination is not None and destination not in self.nodes: + raise ValueError("destination is not part of the mesh") + if not transfer_id: + raise ValueError("transfer identity cannot be empty") + effective_limit = self.hop_limit if hop_limit is None else hop_limit + if not 1 <= effective_limit <= 4: + raise ValueError("invalid hop limit") + + self.now_ms += 100 + events: list[ + tuple[int, int, str, str | None, int, int] + ] = [] + sequence = 0 + heapq.heappush(events, (self.now_ms, sequence, source, None, 0, 12)) + reached: set[str] = set() + transmissions = 0 + duplicate_receptions = 0 + suppressed_forwards = 0 + hop_limit_drops = 0 + route_failures = 0 + application_deliveries = 0 + processed_events = 0 + + while events: + ready_at, _, node, previous, hops, _ = heapq.heappop( + events + ) + self.now_ms = max(self.now_ms, ready_at) + processed_events += 1 + if not self._observe(node, transfer_id): + duplicate_receptions += 1 + suppressed_forwards += 1 + continue + reached.add(node) + if previous is not None: + self.routes[node][source] = ( + previous, self.now_ms + self.route_ttl_ms + ) + + should_deliver = ( + node != source + and (destination is None or node == destination) + ) + if should_deliver and transfer_id not in self.application_seen[node]: + self.application_seen[node].add(transfer_id) + application_deliveries += 1 + if destination is not None and node == destination: + continue + if hops >= effective_limit: + hop_limit_drops += 1 + continue + + next_hop = ( + self._fresh_next_hop(node, destination) + if destination is not None + else None + ) + candidates = [next_hop] if next_hop else self._outgoing(node) + failed_route = None + for candidate in candidates: + success, attempts = self._transmit(node, candidate) + transmissions += attempts + if not success: + if candidate == next_hop: + route_failures += 1 + failed_route = candidate + del self.routes[node][destination] + continue + sequence += 1 + snr = self.links[(node, candidate)].snr_db + delay = lora_forward_delay_ms( + self.frame_airtime_ms, snr, self.random.randrange(1 << 32) + ) + heapq.heappush( + events, + ( + self.now_ms + delay, + sequence, + candidate, + node, + hops + 1, + snr, + ), + ) + + if failed_route is not None: + for candidate in self._outgoing(node): + if candidate == failed_route: + continue + success, attempts = self._transmit(node, candidate) + transmissions += attempts + if not success: + continue + sequence += 1 + snr = self.links[(node, candidate)].snr_db + delay = lora_forward_delay_ms( + self.frame_airtime_ms, + snr, + self.random.randrange(1 << 32), + ) + heapq.heappush( + events, + ( + self.now_ms + delay, + sequence, + candidate, + node, + hops + 1, + snr, + ), + ) + + delivered = ( + destination in reached + if destination is not None + else reached == set(self.nodes) + ) + return MeshDeliveryResult( + delivered=delivered, + application_deliveries=application_deliveries, + reached_nodes=frozenset(reached), + transmissions=transmissions, + duplicate_receptions=duplicate_receptions, + suppressed_forwards=suppressed_forwards, + hop_limit_drops=hop_limit_drops, + route_failures=route_failures, + processed_events=processed_events, + ) + + def contention_window_ms( frame_airtime_ms: int, busy_attempt: int = 0, @@ -450,6 +738,23 @@ def contention_window_ms( return minimum, min(minimum + spread, 20_000) +def lora_forward_delay_ms( + frame_airtime_ms: int, snr_db: int, entropy: int +) -> int: + if frame_airtime_ms <= 0 or entropy < 0: + raise ValueError("invalid forwarding delay parameters") + quality = min(max(snr_db, -20), 12) + base = max(frame_airtime_ms // 2, 100) + quality_penalty = ( + (12 - quality) * frame_airtime_ms // 16 + ) + spread = max(frame_airtime_ms, 200) + return min( + base + quality_penalty + entropy % (spread + 1), + 20_000, + ) + + def hidden_sender_delays( frame_airtime_ms: int, attempts: int, seeds: tuple[int, int] ) -> tuple[list[int], list[int]]: diff --git a/tools/run_lora_host_tests.sh b/tools/run_lora_host_tests.sh index 28995e0..7126e9e 100755 --- a/tools/run_lora_host_tests.sh +++ b/tools/run_lora_host_tests.sh @@ -8,7 +8,8 @@ scheduler_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-scheduler.XXXXXX")" trunk_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-trunk.XXXXXX")" reliability_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-reliability.XXXXXX")" channel_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-channel.XXXXXX")" -trap 'rm -f "$airtime_bin" "$region_bin" "$scheduler_bin" "$trunk_bin" "$reliability_bin" "$channel_bin"' EXIT +forwarding_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-forwarding.XXXXXX")" +trap 'rm -f "$airtime_bin" "$region_bin" "$scheduler_bin" "$trunk_bin" "$reliability_bin" "$channel_bin" "$forwarding_bin"' EXIT cc -std=c11 -Wall -Wextra -Werror \ -I"$repo_root/main" \ @@ -37,6 +38,7 @@ cc -std=c11 -Wall -Wextra -Werror \ "$repo_root/tests/test_lora_trunk_protocol.c" \ "$repo_root/main/lora_trunk_protocol.c" \ "$repo_root/main/lora_channel_policy.c" \ + "$repo_root/main/lora_forwarding.c" \ -o "$trunk_bin" "$trunk_bin" @@ -55,5 +57,12 @@ cc -std=c11 -Wall -Wextra -Werror \ -o "$channel_bin" "$channel_bin" +cc -std=c11 -Wall -Wextra -Werror \ + -I"$repo_root/main" \ + "$repo_root/tests/test_lora_forwarding.c" \ + "$repo_root/main/lora_forwarding.c" \ + -o "$forwarding_bin" +"$forwarding_bin" + cd "$repo_root" python3 -m unittest discover -s tests -p 'test_lora_*.py' -v From 0a61e446f2cc1e1cb7857caeb83bbfa462aedec9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:35:43 +0200 Subject: [PATCH 18/19] docs(lora): record milestone 6 checkpoint --- docs/LoRa-reliability-implementation-plan.md | 60 +++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index aa19787..5ca2622 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -65,7 +65,7 @@ The implementation is complete only when all of the following are true: | 3. Addressed trunk protocol and migration | Complete | `3ee5843` | v2/v3 host protocol suite, both firmware targets, and addressed two-board v3 smoke passed | | 4. Packet-level reliability and reassembly | Complete | `33cfde3` | 99.35% sender completion at 30% data/feedback loss; five-of-five two-board transfer gate | | 5. Discovery and shared-channel behavior | Complete | `873eddf` | 100% simulated discovery; three-repeat two-board beacon gate passed | -| 6. LoRa multi-hop forwarding | Pending | — | — | +| 6. LoRa multi-hop forwarding | Complete | `21f32fd` | 198/200 lossy-line simulation; three-board forwarding/dedup smoke passed | | 7. Link adaptation and PHY hardening | Pending | — | — | | 8. End-to-end validation and rollout | Pending | — | — | @@ -512,22 +512,22 @@ Turn the current one-hop LoRa bus into an actual bounded mesh backhaul. ### Checklist -- [ ] Define hop-limit, deduplication, forwarding, and route-learning behavior +- [x] Define hop-limit, deduplication, forwarding, and route-learning behavior for broadcast and addressed traffic. -- [ ] Ensure a LoRa-received packet can be forwarded back onto LoRa without +- [x] Ensure a LoRa-received packet can be forwarded back onto LoRa without passing through the generic link-exclusion rule that currently prevents it. -- [ ] Add an expiring deduplication cache keyed by source and transfer identity. -- [ ] Add SNR- and airtime-aware randomized forwarding delay so the strongest or +- [x] Add an expiring deduplication cache keyed by source and transfer identity. +- [x] Add SNR- and airtime-aware randomized forwarding delay so the strongest or earliest relay tends to win without synchronized rebroadcast. -- [ ] Suppress a scheduled rebroadcast when the same packet is overheard from a +- [x] Suppress a scheduled rebroadcast when the same packet is overheard from a better-positioned relay. -- [ ] Learn reverse paths from discovery and successfully received traffic. -- [ ] Use per-hop addressed reliability for directed forwarding. -- [ ] Define fallback behavior when a learned route expires or fails. -- [ ] Enforce hop limits before queue admission. -- [ ] Keep application-level BitChat deduplication as a second safety layer. -- [ ] Add line, triangle, ring, route-failure, node-reboot, and asymmetric-link +- [x] Learn reverse paths from discovery and successfully received traffic. +- [x] Use per-hop addressed reliability for directed forwarding. +- [x] Define fallback behavior when a learned route expires or fails. +- [x] Enforce hop limits before queue admission. +- [x] Keep application-level BitChat deduplication as a second safety layer. +- [x] Add line, triangle, ring, route-failure, node-reboot, and asymmetric-link topology tests. ### Success criteria @@ -543,9 +543,41 @@ Turn the current one-hop LoRa bus into an actual bounded mesh backhaul. ### Milestone record -- Status: Pending +- Status: Complete - Evidence: -- Commit: + - The v3 specification now defines immediate-hop outer identities, a default + relay limit of three with a hard maximum of four, fresh per-hop transfer + IDs, signed reverse-path learning, addressed selective-repeat forwarding, + and broadcast fallback after route expiry or terminal next-hop failure. + - The generic link registry retains ordinary ingress exclusion but exposes an + explicit same-medium relay callback. LoRa uses it to admit a complete + packet atomically back onto LoRa, preserving application TTL and the + existing BitChat mesh dedup as an independent second layer. + - A 32-entry, five-minute cache keys logical packets by BitChat source and a + TTL-independent fingerprint. Broadcast relays use SNR- and airtime-scaled + randomized delay; an overheard matching copy cancels a queued relay. + Forwarding, suppression, network-duplicate, hop-limit, and route-failure + counters are included in the hardware capture summary. + - The portable suite passes 27 Python/C tests. The seeded three-node line + delivered 198 of 200 packets across two hops with 20% independent per-hop + loss and four bounded attempts. Triangle delivery occurred once with two + duplicate paths suppressed in five processed events; the four-node ring + delivered once per receiver and terminated after nine events. Route + failure, stale-state release, reboot recovery, asymmetric links, malicious + repeats, cache expiry/eviction, and hop exhaustion also pass. + - Clean ESP-IDF 6.0 builds pass for the Heltec V3 ESP32-S3 configuration and + the BLE-only ESP32-C3 target. + - A controlled three-board SF10 same-room run emitted three distinct + broadcast probes from one diagnostic node. A receiver completed all three + hop-zero packets and transmitted three hop-one relays; the origin recorded + and suppressed the returned logical duplicate. All nodes reported zero + radio timeouts, command errors, BUSY timeouts, watchdog recoveries, and + recovery failures, with no queue-full events. The boards were restored to + the production-default image with diagnostic traffic disabled. + - The hardware run was a same-room triangle, not an RF-isolated physical + line. Milestone 8 retains the three-node endpoint-isolated hardware test; + the deterministic loss model is the reproducible Milestone 6 line gate. +- Commit: `21f32fd` - Suggested commit subject: `feat(lora): add bounded multi-hop forwarding` ## Milestone 7: Link adaptation and PHY hardening From bb403f477a876427b847332b7ee46d7e41ba15c1 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:39:57 +0200 Subject: [PATCH 19/19] feat(lora): add measured link adaptation --- docs/LoRa-boards.md | 15 +- docs/LoRa-reliability-implementation-plan.md | 47 +- docs/LoRa-testing.md | 128 +++- docs/LoRa-trunk-v3.md | 63 +- main/CMakeLists.txt | 1 + main/Kconfig.projbuild | 31 +- main/bitle_lora.c | 588 ++++++++++++++++--- main/lora_link_adaptation.c | 344 +++++++++++ main/lora_link_adaptation.h | 108 ++++ main/lora_region.c | 34 ++ main/lora_region.h | 20 + main/lora_trunk_protocol.c | 7 + main/sx1262.c | 58 +- tests/test_lora_link_adaptation.c | 188 ++++++ tests/test_lora_link_adaptation.py | 69 +++ tests/test_lora_region.c | 27 + tests/test_lora_trunk_protocol.c | 10 + tools/lora_hardware_smoke.py | 83 ++- tools/run_lora_host_tests.sh | 10 +- 19 files changed, 1690 insertions(+), 141 deletions(-) create mode 100644 main/lora_link_adaptation.c create mode 100644 main/lora_link_adaptation.h create mode 100644 tests/test_lora_link_adaptation.c create mode 100644 tests/test_lora_link_adaptation.py diff --git a/docs/LoRa-boards.md b/docs/LoRa-boards.md index 6b71449..ad7e64d 100644 --- a/docs/LoRa-boards.md +++ b/docs/LoRa-boards.md @@ -66,5 +66,16 @@ symbol there (and clear `CONFIG_BITLE_LORA_BOARD_XIAO_WIO_SX1262`), then `main/bitle_lora.c`. Set `LORA_PIN_RXEN` to -1 when the board has no GPIO-driven RX-enable (DIO2-as-RF-switch designs). 3. Verify: build, flash, and check the boot log for - `bitle_lora: trunk up: ...` (success) vs `no SX1262 radio; running - BLE-only` (wrong pins or power gate). + `bitle_lora: trunk up: ... phy_verified=1` (success) vs + `no SX1262 radio; running BLE-only` (wrong pins or power gate). The success + line must identify the selected board and the expected region, frequency, + SF, bandwidth, coding rate, preamble, and private sync word. +4. Run the packet-delivery and radio-recovery procedure in + [LoRa reliability testing](LoRa-testing.md#board-and-link-adaptation-hardware-smoke-test). + A successful build or SPI probe alone is not a board smoke test. + +At radio startup the common driver validates the complete PHY profile, reads +back the private sync word and boosted RX-gain register, verifies the 140 mA +over-current limit, and requires the TCXO, calibration, PA, modulation, packet, +frequency, buffer, IRQ, and RX commands to succeed. Both board variants use +this same verification path; only wiring and power-gate setup differ. diff --git a/docs/LoRa-reliability-implementation-plan.md b/docs/LoRa-reliability-implementation-plan.md index 5ca2622..c24d1ac 100644 --- a/docs/LoRa-reliability-implementation-plan.md +++ b/docs/LoRa-reliability-implementation-plan.md @@ -66,7 +66,7 @@ The implementation is complete only when all of the following are true: | 4. Packet-level reliability and reassembly | Complete | `33cfde3` | 99.35% sender completion at 30% data/feedback loss; five-of-five two-board transfer gate | | 5. Discovery and shared-channel behavior | Complete | `873eddf` | 100% simulated discovery; three-repeat two-board beacon gate passed | | 6. LoRa multi-hop forwarding | Complete | `21f32fd` | 198/200 lossy-line simulation; three-board forwarding/dedup smoke passed | -| 7. Link adaptation and PHY hardening | Pending | — | — | +| 7. Link adaptation and PHY hardening | In progress | — | Host/firmware suites and three-Heltec CR 4/5 adaptation smoke pass; XIAO, CR 4/6–4/7 RF, and instrumented power/thermal gates remain | | 8. End-to-end validation and rollout | Pending | — | — | ## Milestone 0: Baseline harness and observability @@ -589,16 +589,16 @@ silently diverge onto incompatible radio parameters. ### Checklist -- [ ] Maintain per-neighbor EWMA metrics for RSSI, SNR, data success, ACK +- [x] Maintain per-neighbor EWMA metrics for RSSI, SNR, data success, ACK success, retries, and recent delivery latency. -- [ ] Adapt fragment size and retry budget within documented bounds. +- [x] Adapt fragment size and retry budget within documented bounds. - [ ] Evaluate CR 4/6 and 4/7 against CR 4/5 using controlled airtime and delivery measurements. -- [ ] Do not change frequency, SF, bandwidth, sync word, or coding rate for one +- [x] Do not change frequency, SF, bandwidth, sync word, or coding rate for one peer without an explicit coordinated profile-change protocol. -- [ ] If coordinated profile changes are implemented, define rendezvous, +- [x] If coordinated profile changes are implemented, define rendezvous, confirmation, timeout, and rollback behavior. -- [ ] Persist only validated profile choices and recover safely from corrupt or +- [x] Persist only validated profile choices and recover safely from corrupt or incompatible NVS values. - [ ] Verify boosted RX gain, preamble length, PA setup, and regional frequency on each supported board. @@ -619,8 +619,41 @@ silently diverge onto incompatible radio parameters. ### Milestone record -- Status: Pending +- Status: In progress - Evidence: + - A bounded 12-neighbor LRU table now keeps fixed-point EWMA RSSI, SNR, data + and ACK success, retry count, and delivery latency without logging peer + identifiers. Two consecutive poor deliveries select a 104-byte, + ten-round weak policy; four healthy deliveries recover to a 112-byte + balanced policy; a stable streak with at least nine completed samples + restores the 120-byte, six-round throughput policy. + - Adaptation changes only fragment size and retry ceiling. Frequency, SF, + bandwidth, coding rate, preamble, sync word, and TX power remain + trunk-wide. No coordinated PHY-change protocol was added, so rendezvous + and rollback behavior are intentionally not applicable. + - Region/profile loading validates the complete stored profile, including + region/frequency compatibility and every PHY field, before applying it. + Malformed, partial, or incompatible state falls back atomically to the + complete build-time profile. Host tests cover corrupt values, legacy + frequency-only state, and safe fallback. + - The portable suite passes four C binaries and 30 Python tests, including + weak-link improvement, stable-link throughput recovery, bounded hysteresis, + LRU eviction, malformed profiles, and explicit CR 4/5, 4/6, and 4/7 + airtime costs. Clean ESP-IDF 6.0 builds pass for ESP32-S3 and ESP32-C3. + - In a controlled 120-second three-Heltec SF10/125 kHz/CR 4/5 run, the + sender completed all 10 addressed transfers. Data and ACK success were + 100%, retries were zero, the ninth delivery selected the stable profile, + and the final 480-byte recovery transfer used four 120-byte fragments with + a six-round ceiling. All three boards verified the configured region, + frequency, SF, bandwidth, coding rate, preamble, private sync word, and PHY + readback. Captured radio-command, BUSY, TX-timeout, watchdog, recovery, and + recovery-failure counters were zero. All boards were then restored to the + production image with diagnostic modes disabled. + - Milestone completion remains blocked on controlled CR 4/6 and CR 4/7 RF + comparisons, a physical XIAO Wio-SX1262 run of the same suite, and + instrumented current/temperature measurements. The attached boards are + Heltec V3 units, and serial telemetry cannot substitute for the required + power analyzer and thermal probe. - Commit: - Suggested commit subject: `feat(lora): adapt trunk behavior to measured link quality` diff --git a/docs/LoRa-testing.md b/docs/LoRa-testing.md index 60123b5..2a52481 100644 --- a/docs/LoRa-testing.md +++ b/docs/LoRa-testing.md @@ -1,7 +1,8 @@ # LoRa reliability testing The LoRa reliability work has two test layers: a deterministic host suite and -an opt-in two-board smoke test. Run the host suite before every firmware build. +an opt-in multi-board smoke test. Run the host suite before every firmware +build. ## Host suite @@ -40,6 +41,13 @@ The simulator controls data loss, ACK loss, delay, duplication, reordering, queue capacity, radio profile, fragment size, retry count, CAD allowance, and radio timeout from a fixed random seed. +The link-adaptation cases exercise the bounded per-neighbor table and its +hysteresis. They require the weak policy to improve completion under 40% +independent loss, require the stable policy to return a 480-byte transfer from +five fragments to four, and compare CR 4/5, 4/6, and 4/7 with the exact airtime +model. These are deterministic injected-erasure results, not substitutes for +RF delivery measurements. + ## Firmware build Activate ESP-IDF 6.0, then build: @@ -57,30 +65,61 @@ The build-time defaults are configured under **Bitle hardware**: - `CONFIG_BITLE_LORA_REGION_US915` or `CONFIG_BITLE_LORA_REGION_EU868`; - `CONFIG_BITLE_LORA_DEFAULT_SF`, from SF7 through SF12; +- `CONFIG_BITLE_LORA_CODING_RATE`, from 5 through 8; - `CONFIG_BITLE_LORA_PREAMBLE_SYMBOLS`, with a robust default of 16. The `lora` NVS namespace may override these defaults with `region` (`u8`, US915 = 0, EU868 = 1), `freq` (`u32` Hz), and `sf` (`u8`). A frequency must fall inside its explicit profile. A mismatched pair, out-of-profile frequency, unknown profile, malformed value, or invalid spreading factor is rejected and -counted in `config_rejections`. A legacy store containing only `freq` infers -the profile when the frequency is unambiguous. +counted in `config_rejections`. The final combined profile is also rejected +unless SF, bandwidth, coding rate, preamble, private sync word, TX power, and +regional frequency are all valid. Rejection falls back to the complete +build-time profile rather than mixing stored and default values. A legacy store +containing only `freq` infers the profile when the frequency is unambiguous. -## Two-board SF10/SF11/SF12 smoke test +Coding rate is a trunk-wide interoperability setting. Every participating +radio must use the same value. The per-neighbor adaptation described below +never changes frequency, SF, bandwidth, coding rate, sync word, preamble, or TX +power. -The production default keeps `CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE` disabled. -For a controlled test, enable it on exactly one Heltec V3 build. That node -first transmits three spaced unaddressed public probes with normal application -TTL to exercise same-medium forwarding. After a signed direct announcement -establishes an authenticated v3 neighbor, it transmits one addressed synthetic -packet at each of 1, 2, 3, 4, and 5 fragments. It retries fixture admission -every three seconds and advances only after a probe is accepted. Keep every -receiving node on the normal build. +## Board and link-adaptation hardware smoke test -Capture both serial streams concurrently: +The production default keeps `CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE` disabled. +For a routine test, enable it on exactly one build. For controlled adaptation +or coding-rate measurements, also enable +`CONFIG_BITLE_LORA_DIAGNOSTIC_QUIET` on every participating node. Quiet mode +suppresses signed discovery announcements and reduces the initial beacon burst +to one transmission per node. Marked diagnostic probes may use that fresh +compatible beacon as a direct route; normal application traffic cannot. This +keeps discovery traffic from consuming the SF10 channel and biasing +link-adaptation measurements. Quiet mode otherwise admits only beacons, packet +controls, and marked test traffic to LoRa. Beacons keep the direct route fresh. +Quiet mode does not change production behavior when disabled. + +Without quiet mode, the sender first transmits three closely spaced +unaddressed public probes with normal application TTL to exercise same-medium +forwarding. Quiet adaptation runs omit that forwarding preamble. They may +address marked diagnostic probes directly to a fresh compatible beacon source +if simultaneous signed announcements have not yet established an authenticated +route. That diagnostic-only fallback cannot carry application traffic and is +compiled out of production builds. + +The sender then uses short successful transfers to exercise policy hysteresis, +transmits addressed packets spanning one through five fragments, and finishes +with a 480-byte recovery probe. It does not admit the next addressed probe +until the previous transfer has produced a terminal delivery sample. A stable +link must eventually select a 120-byte, six-round policy and encode the +480-byte recovery probe in four fragments. The compressed sequence is +designed to finish within a 120-second capture on an uncontended SF10 channel. +A 60-second capture is useful as a quick radio-health check but may stop before +the ninth delivery selects the stable policy. + +Capture all serial streams concurrently: ```bash -python tools/lora_hardware_smoke.py PORT_A PORT_B --seconds 90 --reset --quiet \ +python tools/lora_hardware_smoke.py PORT_A PORT_B PORT_C \ + --seconds 120 --reset --quiet \ --json-out /tmp/bitle-lora-smoke.json ``` @@ -94,16 +133,69 @@ separate/attenuate the endpoints until they cannot receive each other. The capture summarizes TX and completed RX counts by fragment count, addressed diagnostic events, authenticated-neighbor learning, bitmap/COMPLETE/ABORT controls, completed sender transfers, duplicate suppression, and parser -rejection counters. It also retains per-frame RSSI/SNR, the active profile, -watchdog/recovery events, and periodic radio diagnostics. Raw serial output and -JSON evidence may contain local device details, so keep it outside the -repository. +rejection counters. It also retains per-frame RSSI/SNR, adaptive policy +choices, EWMA snapshots, the active profile, watchdog/recovery events, and +periodic radio diagnostics. Raw serial output and JSON evidence may contain +local device details, so keep it outside the repository. + +At boot, every node must report its expected board, region, frequency, SF, +bandwidth, coding rate, preamble, private sync word, and `phy_verified=1`. +During the run require zero radio command errors, BUSY timeouts, TX timeouts, +watchdog recoveries, and recovery failures. Repeat the same packet-delivery and +radio-recovery checks once with +`CONFIG_BITLE_LORA_BOARD_XIAO_WIO_SX1262` and once with +`CONFIG_BITLE_LORA_BOARD_HELTEC_V3`; a build-only result is not a board smoke +test. Repeat with `CONFIG_BITLE_LORA_DEFAULT_SF` set to 10, 11, and 12 on both boards. At each SF, verify that the receiver records 136-byte frames and both nodes report zero `radio_timeouts`, `radio_command_errors`, and `busy_timeouts`. +## Coding-rate comparison + +Run the quiet multi-board test at CR 4/5, 4/6, and 4/7. Rebuild and flash every +participating node between runs; mixed coding rates intentionally cannot +demodulate one another. Keep board placement, antennas, region, frequency, +SF10, 125 kHz bandwidth, 16-symbol preamble, TX power, packet sequence, and +capture duration fixed. + +For a 164-byte frame under that profile, the tested deterministic airtime is: + +| Coding rate | Airtime | +|---|---:| +| 4/5 | 1,583 ms | +| 4/6 | 1,853 ms | +| 4/7 | 2,124 ms | + +For each RF run record admitted transfers, completed transfers, fragment and +control counts, retry exhaustion, ACK misses, RSSI/SNR distribution, latency, +and every radio fault counter. Choose a non-default coding rate only when its +measured delivery improvement justifies its airtime and duty-cycle cost. +Changing the default requires a coordinated firmware rollout; there is no +automatic per-peer PHY switch. + +## Power and thermal measurement + +Use an inline USB power analyzer or a current-limited bench supply and a +contact probe or calibrated thermal camera. Record ambient temperature, supply +voltage, idle current, RX current, average and peak current during the smoke +sequence, maximum board/radio temperature, and temperature rise. Measure each +supported board in these configurations: + +1. normal RX with boosted gain and the 16-symbol preamble; +2. sustained diagnostic transfers at CR 4/5; +3. sustained diagnostic transfers at CR 4/7; +4. the weak-link policy with its ten-round retry ceiling. + +Run each loaded condition for at least 15 minutes after temperatures stabilize. +Stop immediately on a regulator fault, reset, radio recovery loop, unexpected +current-limit event, or a component temperature outside its vendor rating. +The milestone passes only when the recorded current remains inside the board +and supply ratings, temperature remains inside every component rating, and +the chosen coding rate/duty configuration complies with the active regional +airtime policy. Serial logs alone do not satisfy this measurement. + To exercise the missing-interrupt path, additionally enable `CONFIG_BITLE_LORA_DIAGNOSTIC_SUPPRESS_TX_DONE_ONCE` on the probe sender. It depends on diagnostic smoke mode and suppresses exactly one task-level diff --git a/docs/LoRa-trunk-v3.md b/docs/LoRa-trunk-v3.md index e724466..d938b9c 100644 --- a/docs/LoRa-trunk-v3.md +++ b/docs/LoRa-trunk-v3.md @@ -15,8 +15,10 @@ can allocate or mutate reassembly state. All multi-byte integers are unsigned and big-endian. A radio frame is at most 255 bytes. The fixed v3 header is 44 bytes, leaving at most 211 payload bytes. -The initial implementation transmits chunks of at most 120 bytes to retain the -existing short-frame RF behavior. +The implementation transmits canonical 104-, 112-, or 120-byte non-final +chunks. A receiver requires every non-final fragment in one transfer to use +the same size and requires the final fragment to be no larger. This retains +the existing short-frame RF behavior while allowing bounded adaptation. ## Fixed header @@ -156,6 +158,55 @@ back to bounded broadcast and signed-announcement forwarding can repopulate the path. This avoids retaining a stale route without inventing unauthenticated reachability from arbitrary packet headers. +## Bounded link adaptation + +Each node keeps an allocation-free, 12-entry least-recently-used table keyed by +the complete immediate next-hop ID. Separate fixed-point EWMAs retain RSSI, +SNR, addressed-data completion, matched feedback success, retry fragments, and +terminal delivery latency. Signal samples come only from accepted v3 data and +matched controls. Delivery samples are directional: an outbound addressed +transfer contributes exactly one success or failure when its scheduler context +is released. + +The current policy bounds are: + +| Profile | Non-final fragment | Maximum rounds | +|---|---:|---:| +| unknown | 120 bytes | 8 | +| balanced | 112 bytes | 8 | +| weak | 104 bytes | 10 | +| stable | 120 bytes | 6 | + +Two consecutive poor delivery samples select the weak policy. A delivery is +poor when it fails, needs at least two retry fragments, has an RSSI EWMA at or +below -115 dBm, has an SNR EWMA at or below -8 dB, or has a feedback-success +EWMA below 70% after at least three feedback samples. Weak mode requires four +consecutive healthy deliveries before returning to balanced. + +Stable mode requires at least eight delivery samples, six consecutive +zero-retry deliveries, data success of at least 94%, retry EWMA no greater +than 0.5, feedback success of at least 90% when enough samples exist, RSSI of +at least -105 dBm, and SNR of at least 2 dB. One poor delivery degrades a +stable link to balanced. An otherwise unknown link becomes balanced after four +delivery samples. This hysteresis prevents a single marginal frame from +oscillating the fragment contract. + +The selected fragment size and round ceiling are stored in the immutable +per-transfer scheduler context. Airtime admission, feedback deadlines, retry +deadlines, reassembly progress, and absolute lifetime are derived from that +selection. A receiver accepts all three canonical fragment sizes, so different +neighbors may choose different policies without a PHY compatibility change. + +Adaptation never changes frequency, SF, bandwidth, coding rate, sync word, +preamble, or TX power. These parameters form a validated trunk-wide PHY +profile. The driver verifies the private sync word and boosted RX gain by +register readback, verifies the 140 mA over-current setting, and requires every +PA/modulation/packet/frequency command to succeed before reporting the radio +up. Stored region/frequency/SF values are combined with build-time parameters +and validated as one profile; an invalid or incompatible combination falls +back to the complete build default. There is no automatic coordinated profile +change protocol in v3. + ## Packet-level reliability Only a node whose complete local ID equals an addressed data frame's @@ -167,7 +218,7 @@ for feedback. A bitmap ACK reports the receiver's cumulative fragment bitmap, so one round trip can advance multiple fragments. The next round selectively retransmits only missing fragments. If every fragment is already acknowledged but COMPLETE was lost, the final fragment is sent as a bounded completion -probe. A transfer uses at most eight rounds. +probe. A transfer uses the selected six-, eight-, or ten-round ceiling. COMPLETE is distinct from a full bitmap. The receiver sends COMPLETE only after all fragments are present, their combined length fits the BitChat packet @@ -199,8 +250,10 @@ both a progress deadline, refreshed only by a new fragment, and an absolute lifetime that cannot be extended. Feedback wait, progress timeout, absolute lifetime, and completed retention are derived from the active SF, bandwidth, coding rate, preamble, -maximum frame airtime, fragment count, eight-round budget, and bounded -contention allowance. No supported SF relies on a fixed ten-second timeout. +maximum frame airtime, fragment count, selected round budget, and bounded +contention allowance. Receiver retention uses the conservative ten-round +ceiling because the sender policy is intentionally not encoded on air. No +supported SF relies on a fixed ten-second timeout. Temporary channel occupancy does not discard an addressed transfer after a fixed number of CAD results: the sender returns to RX during randomized backoff and retries until COMPLETE, ABORT, or its absolute deadline. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index a1789e5..f198852 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -12,6 +12,7 @@ idf_component_register( "lora_airtime.c" "lora_channel_policy.c" "lora_forwarding.c" + "lora_link_adaptation.c" "lora_packet_reliability.c" "lora_region.c" "lora_trunk_protocol.c" diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 6b4dfd9..4cc4774 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -77,9 +77,23 @@ menu "Bitle hardware" Test-only mode. First transmit three spaced synthetic broadcasts that exercise same-medium forwarding, then after an authenticated v3 neighbor is discovered transmit addressed packets spanning one - through five fragments. Flash exactly one test node with this - enabled and leave receiving nodes on the normal build. Never enable - it in deployed firmware. + through five fragments, followed by successful recovery probes + that exercise balanced-to-stable link adaptation. Flash exactly + one test node with this enabled and leave receiving nodes on the + normal build. Never enable it in deployed firmware. + + config BITLE_LORA_DIAGNOSTIC_QUIET + bool "Restrict LoRa TX to controlled diagnostic traffic" + depends on IDF_TARGET_ESP32S3 + default n + help + Test-only mode for controlled PHY comparisons. Local LoRa data + admission guarantees one signed discovery announcement and + continues announcements until an authenticated neighbor is + learned, then limits data to marked diagnostic probes; beacons and + packet controls remain enabled and refresh the direct route. + Enable it on every board participating in a coding-rate or power + measurement. Never enable it in deployed firmware. choice BITLE_LORA_REGION prompt "Default LoRa regional profile" @@ -99,6 +113,17 @@ menu "Bitle hardware" range 7 12 default 10 + config BITLE_LORA_CODING_RATE + int "LoRa coding-rate denominator (4/x)" + depends on IDF_TARGET_ESP32S3 + range 5 8 + default 5 + help + Global interoperable PHY setting. Every node on a trunk must use + the same value. Link adaptation never changes coding rate for one + peer; coordinated CR4/6 and CR4/7 test builds may override this + default on every participating board. + config BITLE_LORA_PREAMBLE_SYMBOLS int "LoRa preamble symbols" depends on IDF_TARGET_ESP32S3 diff --git a/main/bitle_lora.c b/main/bitle_lora.c index c046280..3826131 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -18,6 +18,7 @@ #include "lora_airtime.h" #include "lora_channel_policy.h" #include "lora_forwarding.h" +#include "lora_link_adaptation.h" #include "lora_packet_reliability.h" #include "lora_region.h" #include "lora_trunk_protocol.h" @@ -55,6 +56,7 @@ static const char *TAG = "bitle_lora"; * With no wiring selected the boot probe finds no radio and the node runs * BLE-only. */ #if CONFIG_BITLE_LORA_BOARD_HELTEC_V3 +#define LORA_BOARD_NAME "heltec-v3" #define LORA_PIN_SCK 9 #define LORA_PIN_MISO 11 #define LORA_PIN_MOSI 10 @@ -64,6 +66,7 @@ static const char *TAG = "bitle_lora"; #define LORA_PIN_DIO1 14 #define LORA_PIN_RXEN -1 /* DIO2 keys the RF switch; no board RXEN */ #elif CONFIG_BITLE_LORA_BOARD_XIAO_WIO_SX1262 +#define LORA_BOARD_NAME "xiao-wio-sx1262" #define LORA_PIN_SCK 7 #define LORA_PIN_MISO 8 #define LORA_PIN_MOSI 9 @@ -73,6 +76,7 @@ static const char *TAG = "bitle_lora"; #define LORA_PIN_DIO1 39 #define LORA_PIN_RXEN 38 #else +#define LORA_BOARD_NAME "none" #define LORA_PIN_SCK -1 #define LORA_PIN_MISO -1 #define LORA_PIN_MOSI -1 @@ -83,6 +87,12 @@ static const char *TAG = "bitle_lora"; #define LORA_PIN_RXEN -1 #endif +#ifdef CONFIG_BITLE_LORA_CODING_RATE +#define LORA_CODING_RATE CONFIG_BITLE_LORA_CODING_RATE +#else +#define LORA_CODING_RATE 5 +#endif + #if defined(CONFIG_BITLE_LORA_REGION_EU868) && CONFIG_BITLE_LORA_REGION_EU868 #define LORA_DEFAULT_REGION LORA_REGION_EU868 #else @@ -107,12 +117,34 @@ static const char *TAG = "bitle_lora"; * on-air time stays short. */ #define TRUNK_CHUNK_RX_MAX \ (SX1262_MAX_PAYLOAD - LORA_TRUNK_V2_HEADER_LEN) -#define TRUNK_CHUNK_TX 120 -#define TRUNK_MAX_FRAGS ((BITCHAT_BLE_MAX_PACKET_SIZE + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX) +#define TRUNK_CHUNK_TX LORA_LINK_CHUNK_STABLE +#define TRUNK_MAX_FRAGS \ + ((BITCHAT_BLE_MAX_PACKET_SIZE + LORA_LINK_CHUNK_WEAK - 1) / \ + LORA_LINK_CHUNK_WEAK) + +_Static_assert( + TRUNK_MAX_FRAGS <= LORA_SELECTIVE_REPEAT_MAX_FRAGMENTS, + "adaptive chunks must fit the selective-repeat bitmap"); /* Channel access: CAD (listen-before-talk) + random backoff before TX. */ #define CAD_RETRIES 5 +#if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE || \ + CONFIG_BITLE_LORA_DIAGNOSTIC_QUIET +static const uint8_t DIAGNOSTIC_MARKER[8] = { + 0x42, 0x49, 0x54, 0x4C, 0x45, 0x4C, 0x4F, 0x52, +}; + +static bool trunk_is_diagnostic_probe( + const uint8_t *data, uint16_t len) +{ + return data && len >= 11 && + data[PKT_TYPE_OFF] == BITCHAT_MSG_MESSAGE && + memcmp(data + 3, DIAGNOSTIC_MARKER, + sizeof(DIAGNOSTIC_MARKER)) == 0; +} +#endif + typedef struct { uint16_t len; bool want_ack; @@ -138,11 +170,15 @@ typedef struct { bool suppressed; uint8_t hop_limit; uint8_t hop_count; + uint16_t fragment_size; + uint8_t maximum_rounds; + uint8_t retry_count; uint8_t announce_sender[8]; uint8_t destination[LORA_TRUNK_V3_NODE_ID_LEN]; uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN]; uint64_t forward_fingerprint; uint64_t forward_not_before_ms; + uint64_t started_ms; } tx_trunk_context_t; static tx_trunk_context_t s_tx_context[LORA_TX_PACKET_POOL_SIZE]; @@ -154,6 +190,7 @@ static lora_trunk_frame_t s_expected_control_data; static lora_selective_tx_t s_selective_tx; static lora_trunk_neighbor_table_t s_neighbors; static lora_forward_cache_t s_forward_cache; +static lora_link_adaptation_t s_link_adaptation; typedef struct { bool active; @@ -202,10 +239,16 @@ static uint32_t trunk_airtime_ms(uint16_t payload_len) } static bool trunk_reliability_timing( - uint8_t fragment_total, lora_reliability_timing_t *out) + uint8_t fragment_total, uint16_t fragment_size, + uint8_t maximum_rounds, lora_reliability_timing_t *out) { + if (!lora_link_fragment_size_valid(fragment_size) || + maximum_rounds < LORA_LINK_ROUNDS_STABLE || + maximum_rounds > LORA_LINK_ROUNDS_WEAK) { + return false; + } uint32_t maximum_airtime = - trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN + TRUNK_CHUNK_TX); + trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN + fragment_size); lora_contention_window_t contention; if (!lora_contention_window( maximum_airtime, CAD_RETRIES, false, &contention)) { @@ -215,7 +258,7 @@ static bool trunk_reliability_timing( maximum_airtime, trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN), fragment_total, - LORA_SELECTIVE_REPEAT_ROUNDS, + maximum_rounds, contention.maximum_ms, out); } @@ -368,9 +411,11 @@ static bool beacon_budget_admit(uint16_t encoded_len) } static bool trunk_admit_locked( - const uint8_t *data, uint16_t len, bool forwarded) + const uint8_t *data, uint16_t len, bool forwarded, + uint16_t fragment_size) { - if (len < PKT_MIN_LEN) { + if (len < PKT_MIN_LEN || + !lora_link_fragment_size_valid(fragment_size)) { return false; } uint8_t type = data[PKT_TYPE_OFF]; @@ -384,6 +429,18 @@ static bool trunk_admit_locked( * drop its own follow-up frames. */ bool governed = announce; +#if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE || \ + CONFIG_BITLE_LORA_DIAGNOSTIC_QUIET + if (!announce && !trunk_is_diagnostic_probe(data, len)) { + return false; + } +#endif +#if CONFIG_BITLE_LORA_DIAGNOSTIC_QUIET + if (announce) { + return false; + } +#endif + /* OTA image transfer is enormous on air; opt-in only. */ if (ota && !s_ota_over_trunk) { return false; @@ -396,11 +453,15 @@ static bool trunk_admit_locked( * it is itself the DM target reached over LoRa — must cross. */ /* Total airtime across every fragment this packet becomes. */ - uint8_t total = (uint8_t)((len + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX); + uint8_t total = + (uint8_t)((len + fragment_size - 1) / fragment_size); uint32_t airtime = 0; for (uint8_t i = 0; i < total; ++i) { - uint16_t off = (uint16_t)i * TRUNK_CHUNK_TX; - uint16_t chunk = len - off < TRUNK_CHUNK_TX ? len - off : TRUNK_CHUNK_TX; + uint16_t off = (uint16_t)i * fragment_size; + uint16_t chunk = + len - off < fragment_size + ? len - off + : fragment_size; airtime += trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN + chunk); } @@ -442,6 +503,7 @@ typedef struct { uint16_t capabilities; uint8_t total; uint8_t have_mask; + uint16_t fragment_size; uint64_t started_ms; uint64_t progress_deadline_ms; uint64_t absolute_deadline_ms; @@ -525,6 +587,58 @@ static uint16_t trunk_true_len(const uint8_t *data, uint16_t len) return (real <= len) ? (uint16_t)real : len; } +/* + * Production addressed traffic requires an authenticated route. A quiet + * diagnostic sender may additionally address a marked smoke probe directly + * to a fresh beacon source. This keeps a 60-second RF measurement focused on + * link adaptation even when simultaneous signed announcements collide. The + * fallback is compiled out unless both diagnostic options are enabled and is + * never available to application traffic. + */ +static bool trunk_route_locked( + const uint8_t recipient[LORA_TRUNK_V3_NODE_ID_LEN], + uint64_t now_ms, uint64_t max_age_ms, + bool allow_diagnostic_beacon, + uint8_t out_node_id[LORA_TRUNK_V3_NODE_ID_LEN], + uint16_t *out_capabilities) +{ + if (lora_trunk_neighbor_route( + &s_neighbors, recipient, now_ms, max_age_ms, + out_node_id, out_capabilities)) { + return true; + } +#if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE && \ + CONFIG_BITLE_LORA_DIAGNOSTIC_QUIET + if (allow_diagnostic_beacon) { + for (size_t i = 0; + i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { + const lora_trunk_neighbor_t *entry = + &s_neighbors.entries[i]; + if (!entry->in_use || entry->quarantined || + entry->identity_authenticated || + memcmp(entry->node_id, recipient, + LORA_TRUNK_V3_NODE_ID_LEN) != 0 || + now_ms < entry->last_seen_ms || + now_ms - entry->last_seen_ms > max_age_ms || + (entry->capabilities & + LORA_TRUNK_V3_CAP_PROTOCOL) == 0) { + continue; + } + memcpy( + out_node_id, entry->node_id, + LORA_TRUNK_V3_NODE_ID_LEN); + if (out_capabilities) { + *out_capabilities = entry->capabilities; + } + return true; + } + } +#else + (void)allow_diagnostic_beacon; +#endif + return false; +} + static lora_tx_priority_t trunk_priority(uint8_t type) { switch (type) { @@ -610,6 +724,11 @@ static bitle_link_send_result_t lora_link_commit_internal( uint32_t transfer_counter = 0; uint8_t transfer_id[LORA_TRUNK_V3_TRANSFER_ID_LEN] = {0}; uint8_t destination[LORA_TRUNK_V3_NODE_ID_LEN] = {0}; + lora_link_policy_t link_policy = { + .fragment_size = LORA_LINK_CHUNK_STABLE, + .maximum_rounds = LORA_LINK_ROUNDS_DEFAULT, + .profile = LORA_LINK_PROFILE_UNKNOWN, + }; bool ack_requested = false; bool announcement = type == BITCHAT_MSG_ANNOUNCE || @@ -641,7 +760,34 @@ static bitle_link_send_result_t lora_link_commit_internal( taskEXIT_CRITICAL(&s_gov_mux); return BITLE_LINK_SEND_POLICY_DROPPED; } - if (!trunk_admit_locked(data, len, forwarded)) { + + /* BitChat recipient starts immediately after the fixed 22-byte packet + * header. Only a fresh authenticated announce may supply a directed route; + * unknown routes stay reachable as unacknowledged broadcasts. Adaptation + * is strictly per next hop and changes only fragmentation/retry bounds. */ + if ((data[11] & 0x01u) != 0 && len >= 30) { + uint16_t route_capabilities = 0; + uint64_t now_ms = esp_timer_get_time() / 1000ULL; + bool allow_diagnostic_beacon = false; +#if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE && \ + CONFIG_BITLE_LORA_DIAGNOSTIC_QUIET + allow_diagnostic_beacon = + trunk_is_diagnostic_probe(data, len); +#endif + if (trunk_route_locked( + data + 22, now_ms, 180000ULL, + allow_diagnostic_beacon, destination, + &route_capabilities) && + (route_capabilities & LORA_TRUNK_V3_CAP_PROTOCOL) != 0) { + ack_requested = true; + link_policy = lora_link_adaptation_policy( + &s_link_adaptation, destination); + } else { + memset(destination, 0, sizeof(destination)); + } + } + if (!trunk_admit_locked( + data, len, forwarded, link_policy.fragment_size)) { s_diag.policy_drops++; lora_tx_scheduler_cancel(&s_tx_scheduler, reserved); diag_sync_scheduler_locked(); @@ -658,21 +804,6 @@ static bitle_link_send_result_t lora_link_commit_internal( lora_trunk_make_transfer_id( s_boot_epoch, transfer_counter, transfer_id); - /* BitChat recipient starts immediately after the fixed 22-byte packet - * header. Only a fresh authenticated announce may supply a directed route; - * unknown routes stay reachable as unacknowledged broadcasts. */ - if ((data[11] & 0x01u) != 0 && len >= 30) { - uint16_t route_capabilities = 0; - uint64_t now_ms = esp_timer_get_time() / 1000ULL; - if (lora_trunk_neighbor_route( - &s_neighbors, data + 22, now_ms, 180000ULL, - destination, &route_capabilities) && - (route_capabilities & LORA_TRUNK_V3_CAP_PROTOCOL) != 0) { - ack_requested = true; - } else { - memset(destination, 0, sizeof(destination)); - } - } committed = lora_tx_scheduler_commit( &s_tx_scheduler, reserved, data, len, type, (uint16_t)transfer_counter); @@ -693,13 +824,17 @@ static bitle_link_send_result_t lora_link_commit_internal( : LORA_FORWARD_DEFAULT_HOP_LIMIT; context->hop_count = forwarded ? (uint8_t)(ingress.hop_count + 1u) : 0; + context->fragment_size = link_policy.fragment_size; + context->maximum_rounds = link_policy.maximum_rounds; context->forward_fingerprint = context->relayed_broadcast ? forward_fingerprint : 0; if (forwarded && context->relayed_broadcast) { uint16_t first_chunk = - len < TRUNK_CHUNK_TX ? len : TRUNK_CHUNK_TX; + len < context->fragment_size + ? len + : context->fragment_size; context->forward_not_before_ms = esp_timer_get_time() / 1000ULL + lora_forward_delay_ms( @@ -734,17 +869,21 @@ static bitle_link_send_result_t lora_link_commit_internal( if (!committed) { return BITLE_LINK_SEND_FAILED; } - uint8_t total = - (uint8_t)((len + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX); + uint8_t total = (uint8_t)( + (len + link_policy.fragment_size - 1) / + link_policy.fragment_size); ESP_LOGI(TAG, "trunk accepted type=0x%02X len=%u frags=%u priority=%u " - "addressed=%d transfer=%lu hops=%u/%u forwarded=%d", + "addressed=%d transfer=%lu hops=%u/%u forwarded=%d " + "link=%u chunk=%u rounds=%u", type, len, total, priority, ack_requested, (unsigned long)transfer_counter, forwarded ? (unsigned)(ingress.hop_count + 1u) : 0u, forwarded ? (unsigned)ingress.hop_limit : LORA_FORWARD_DEFAULT_HOP_LIMIT, - forwarded); + forwarded, (unsigned)link_policy.profile, + link_policy.fragment_size, + link_policy.maximum_rounds); bitle_stats_note_activity(BITLE_LANE_TRUNK, 1); if (s_task) { xTaskNotifyGive(s_task); @@ -808,6 +947,8 @@ static void tx_scheduler_release( if (handle == LORA_TX_HANDLE_INVALID) { return; } + lora_link_metrics_snapshot_t metrics; + bool have_metrics = false; taskENTER_CRITICAL(&s_gov_mux); tx_trunk_context_t context = s_tx_context[handle]; uint64_t now = esp_timer_get_time() / 1000ULL; @@ -828,11 +969,38 @@ static void tx_scheduler_release( &s_neighbors); } } + if (context.valid && context.ack_requested && + context.started_ms != 0) { + uint64_t elapsed = + now >= context.started_ms + ? now - context.started_ms + : 0; + lora_link_adaptation_note_delivery( + &s_link_adaptation, context.destination, transmitted, + context.retry_count, + elapsed > UINT32_MAX ? UINT32_MAX : (uint32_t)elapsed, + now); + have_metrics = lora_link_adaptation_snapshot( + &s_link_adaptation, context.destination, &metrics); + } memset(&s_tx_context[handle], 0, sizeof(s_tx_context[handle])); lora_tx_scheduler_release(&s_tx_scheduler, handle); diag_sync_scheduler_locked(); taskEXIT_CRITICAL(&s_gov_mux); s_expected_control_valid = false; + if (have_metrics) { + ESP_LOGI( + TAG, + "link metrics profile=%u rssi_x10=%d snr_x10=%d " + "data=%u ack=%u retries_x100=%u latency_ms=%lu " + "samples=%u/%u/%u", + (unsigned)metrics.profile, metrics.rssi_x10, + metrics.snr_x10, metrics.data_success_pct, + metrics.ack_success_pct, metrics.retries_x100, + (unsigned long)metrics.latency_ms, + metrics.signal_samples, metrics.data_samples, + metrics.ack_samples); + } } static bool tx_scheduler_has_queued(void) @@ -853,16 +1021,22 @@ static bool build_tx_fragment(lora_tx_handle_t handle, uint8_t idx, !s_tx_context[handle].valid) { return false; } - uint8_t total = - (uint8_t)((packet->len + TRUNK_CHUNK_TX - 1) / TRUNK_CHUNK_TX); + const tx_trunk_context_t *context = &s_tx_context[handle]; + if (!lora_link_fragment_size_valid(context->fragment_size)) { + return false; + } + uint8_t total = (uint8_t)( + (packet->len + context->fragment_size - 1) / + context->fragment_size); if (idx >= total) { return false; } - uint16_t off = (uint16_t)idx * TRUNK_CHUNK_TX; + uint16_t off = (uint16_t)idx * context->fragment_size; uint16_t remaining = packet->len - off; uint16_t chunk = - remaining < TRUNK_CHUNK_TX ? remaining : TRUNK_CHUNK_TX; - const tx_trunk_context_t *context = &s_tx_context[handle]; + remaining < context->fragment_size + ? remaining + : context->fragment_size; lora_trunk_frame_t trunk = { .version = LORA_TRUNK_V3_VERSION, .kind = LORA_TRUNK_KIND_DATA, @@ -1182,21 +1356,33 @@ static void reassemble_data( frame->fragment_total > TRUNK_MAX_FRAGS) { return; } - if (frame->version == LORA_TRUNK_V3_VERSION && - frame->fragment_index + 1u < frame->fragment_total && - frame->payload_len != TRUNK_CHUNK_TX) { - queue_v3_control( - frame, LORA_TRUNK_KIND_ABORT, - LORA_TRUNK_ABORT_INVALID_LENGTH); - diag_inc(&s_diag.invalid_reassemblies); - return; + if (frame->version == LORA_TRUNK_V3_VERSION) { + bool non_final = + frame->fragment_index + 1u < frame->fragment_total; + if (frame->payload_len > LORA_LINK_CHUNK_STABLE || + (non_final && + !lora_link_fragment_size_valid(frame->payload_len))) { + queue_v3_control( + frame, LORA_TRUNK_KIND_ABORT, + LORA_TRUNK_ABORT_INVALID_LENGTH); + diag_inc(&s_diag.invalid_reassemblies); + return; + } } uint64_t now = esp_timer_get_time() / 1000ULL; lora_reliability_timing_t timing; - if (!trunk_reliability_timing(frame->fragment_total, &timing)) { + if (!trunk_reliability_timing( + frame->fragment_total, LORA_LINK_CHUNK_STABLE, + LORA_LINK_ROUNDS_WEAK, &timing)) { return; } + if (frame->version == LORA_TRUNK_V3_VERSION) { + taskENTER_CRITICAL(&s_gov_mux); + lora_link_adaptation_note_signal( + &s_link_adaptation, frame->source, rssi, snr, now); + taskEXIT_CRITICAL(&s_gov_mux); + } expire_reassembly(now); completed_slot_t *completed = completed_transfer(frame, now); @@ -1278,6 +1464,34 @@ static void reassemble_data( return; } + bool non_final = + frame->fragment_index + 1u < frame->fragment_total; + if (frame->version == LORA_TRUNK_V3_VERSION && non_final) { + if (slot->fragment_size == 0) { + slot->fragment_size = frame->payload_len; + } else if (slot->fragment_size != frame->payload_len) { + abort_rx_slot(slot, LORA_TRUNK_ABORT_INVALID_LENGTH); + diag_inc(&s_diag.invalid_reassemblies); + return; + } + uint8_t final_index = + (uint8_t)(frame->fragment_total - 1u); + if ((slot->have_mask & (1u << final_index)) != 0 && + slot->part_len[final_index] > slot->fragment_size) { + abort_rx_slot(slot, LORA_TRUNK_ABORT_INVALID_LENGTH); + diag_inc(&s_diag.invalid_reassemblies); + return; + } + } else if ( + frame->version == LORA_TRUNK_V3_VERSION && + frame->fragment_total > 1 && + slot->fragment_size != 0 && + frame->payload_len > slot->fragment_size) { + abort_rx_slot(slot, LORA_TRUNK_ABORT_INVALID_LENGTH); + diag_inc(&s_diag.invalid_reassemblies); + return; + } + uint8_t bit = (uint8_t)(1u << frame->fragment_index); if ((slot->have_mask & bit) != 0) { diag_inc(&s_diag.duplicate_fragments); @@ -1332,6 +1546,17 @@ static void reassemble_data( diag_inc(&s_diag.invalid_reassemblies); return; } + if (frame->version == LORA_TRUNK_V3_VERSION && + frame->fragment_total > 1 && + (slot->fragment_size == 0 || + packet_len <= + (uint16_t)( + slot->fragment_size * + (frame->fragment_total - 1u)))) { + abort_rx_slot(slot, LORA_TRUNK_ABORT_INVALID_LENGTH); + diag_inc(&s_diag.invalid_reassemblies); + return; + } remember_completed_transfer( frame, addressed_to_us, @@ -1469,6 +1694,16 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) accepted = true; } if (accepted) { + uint64_t now_ms = + esp_timer_get_time() / 1000ULL; + taskENTER_CRITICAL(&s_gov_mux); + lora_link_adaptation_note_signal( + &s_link_adaptation, frame.source, + rssi, snr, now_ms); + lora_link_adaptation_note_ack( + &s_link_adaptation, frame.source, + true, now_ms); + taskEXIT_CRITICAL(&s_gov_mux); s_feedback_seen = true; diag_inc(&s_diag.ack_rx); ESP_LOGI(TAG, @@ -1509,6 +1744,11 @@ static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) #define BEACON_INTERVAL_JITTER_MS 30000U #define BEACON_REPEAT_MIN_MS 1000U #define BEACON_REPEAT_SPAN_MS 4000U +#if CONFIG_BITLE_LORA_DIAGNOSTIC_QUIET +#define TRUNK_BEACON_REPETITIONS 1U +#else +#define TRUNK_BEACON_REPETITIONS LORA_CHANNEL_BEACON_REPETITIONS +#endif typedef struct { uint32_t freshness; @@ -1531,7 +1771,7 @@ static void beacon_schedule_begin( if (schedule->freshness == 0) { schedule->freshness = 1; } - schedule->remaining = LORA_CHANNEL_BEACON_REPETITIONS; + schedule->remaining = TRUNK_BEACON_REPETITIONS; schedule->next_repeat_ms = now + (esp_random() % BEACON_START_JITTER_MS); } @@ -1699,12 +1939,30 @@ static void lora_task(void *arg) uint64_t next_identity_announce_ms = BEACON_FIRST_MS + (esp_random() % BEACON_START_JITTER_MS); - uint64_t next_diag_ms = 60000ULL; + uint64_t next_diag_ms = +#if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE + 30000ULL; +#else + 60000ULL; +#endif #if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE - static const uint16_t smoke_lengths[] = {100, 180, 300, 420, 520}; + static const uint16_t smoke_lengths[] = { + 100, 100, 100, 100, 180, + 300, 420, 520, 100, 480, + }; size_t smoke_index = 0; - uint8_t forwarding_smoke_count = 0; - uint64_t next_smoke_ms = 12000ULL; + uint8_t forwarding_smoke_count = +#if CONFIG_BITLE_LORA_DIAGNOSTIC_QUIET + 3; +#else + 0; +#endif + bool smoke_waiting_completion = false; + bool smoke_route_selected = false; + uint16_t smoke_expected_data_samples = 0; + uint8_t smoke_recipient[LORA_TRUNK_V3_NODE_ID_LEN] = {0}; + uint8_t smoke_next_hop[LORA_TRUNK_V3_NODE_ID_LEN] = {0}; + uint64_t next_smoke_ms = 3000ULL; #endif #if CONFIG_BITLE_LORA_DIAGNOSTIC_SUPPRESS_TX_DONE_ONCE bool suppressed_tx_done = false; @@ -1729,6 +1987,19 @@ static void lora_task(void *arg) } #if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE + if (smoke_waiting_completion) { + lora_link_metrics_snapshot_t metrics; + taskENTER_CRITICAL(&s_gov_mux); + bool have_metrics = lora_link_adaptation_snapshot( + &s_link_adaptation, smoke_next_hop, &metrics); + taskEXIT_CRITICAL(&s_gov_mux); + if (have_metrics && + metrics.data_samples >= + smoke_expected_data_samples) { + smoke_waiting_completion = false; + next_smoke_ms = now + 100ULL; + } + } if (forwarding_smoke_count < 3 && now >= next_smoke_ms) { static uint8_t forwarding_probe[100]; @@ -1738,7 +2009,9 @@ static void lora_task(void *arg) forwarding_probe[0] = 1; forwarding_probe[1] = BITCHAT_MSG_MESSAGE; forwarding_probe[2] = BITLE_ORIGIN_TTL; - forwarding_probe[3] = forwarding_smoke_count; + memcpy( + forwarding_probe + 3, DIAGNOSTIC_MARKER, + sizeof(DIAGNOSTIC_MARKER)); forwarding_probe[11] = 0; uint16_t payload_len = sizeof(forwarding_probe) - PKT_MIN_LEN; @@ -1747,6 +2020,8 @@ static void lora_task(void *arg) memcpy( forwarding_probe + PKT_SENDER_OFF, noise_get_local_peer_id(), 8); + forwarding_probe[PKT_MIN_LEN] = + forwarding_smoke_count; bitle_link_send_result_t result = lora_link_send( BITLE_LORA_LINK_HANDLE, @@ -1766,44 +2041,111 @@ static void lora_task(void *arg) "diagnostic forwarding smoke deferred result=%u", result); } - next_smoke_ms = now + 5000ULL; + next_smoke_ms = now + 1200ULL; } else if ( + !smoke_waiting_completion && smoke_index < sizeof(smoke_lengths) / sizeof(smoke_lengths[0]) && now >= next_smoke_ms) { static uint8_t probe[BITCHAT_BLE_MAX_PACKET_SIZE]; uint16_t probe_len = smoke_lengths[smoke_index]; uint8_t recipient[LORA_TRUNK_V3_NODE_ID_LEN] = {0}; + lora_link_policy_t smoke_policy = { + .fragment_size = LORA_LINK_CHUNK_STABLE, + .maximum_rounds = LORA_LINK_ROUNDS_DEFAULT, + .profile = LORA_LINK_PROFILE_UNKNOWN, + }; + uint8_t next_hop[LORA_TRUNK_V3_NODE_ID_LEN] = {0}; bool addressed = false; taskENTER_CRITICAL(&s_gov_mux); - const lora_trunk_neighbor_t *freshest = NULL; - for (size_t i = 0; i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { - const lora_trunk_neighbor_t *entry = - &s_neighbors.entries[i]; - if (entry->in_use && entry->identity_authenticated && - !entry->quarantined && - now >= entry->last_seen_ms && - now - entry->last_seen_ms <= 180000ULL && - (!freshest || - entry->last_seen_ms > freshest->last_seen_ms)) { - freshest = entry; + if (smoke_route_selected) { + memcpy( + recipient, smoke_recipient, + sizeof(recipient)); + uint16_t capabilities = 0; + addressed = trunk_route_locked( + recipient, now, 180000ULL, true, + next_hop, &capabilities); + if (addressed && + memcmp( + next_hop, smoke_next_hop, + sizeof(next_hop)) != 0) { + addressed = false; + } + if (addressed) { + smoke_policy = lora_link_adaptation_policy( + &s_link_adaptation, smoke_next_hop); + } + } else { + const lora_trunk_neighbor_t *freshest = NULL; + for (size_t i = 0; + i < LORA_TRUNK_NEIGHBOR_CAPACITY; ++i) { + const lora_trunk_neighbor_t *entry = + &s_neighbors.entries[i]; + bool direct_candidate = + entry->identity_authenticated && + entry->direct_identity; +#if CONFIG_BITLE_LORA_DIAGNOSTIC_QUIET + direct_candidate = + direct_candidate || + !entry->identity_authenticated; +#endif + if (entry->in_use && + direct_candidate && + !entry->quarantined && + now >= entry->last_seen_ms && + now - entry->last_seen_ms <= 180000ULL && + (!freshest || + entry->last_seen_ms > + freshest->last_seen_ms)) { + freshest = entry; + } + } + if (freshest) { + const uint8_t *reachable = + freshest->identity_authenticated + ? freshest->reachable_peer + : freshest->node_id; + memcpy(recipient, reachable, + sizeof(recipient)); + uint16_t capabilities = 0; + addressed = trunk_route_locked( + recipient, now, 180000ULL, true, + next_hop, &capabilities); + if (addressed && + memcmp( + next_hop, freshest->node_id, + sizeof(next_hop)) != 0) { + addressed = false; + } + if (addressed) { + memcpy( + smoke_recipient, recipient, + sizeof(smoke_recipient)); + memcpy( + smoke_next_hop, next_hop, + sizeof(smoke_next_hop)); + smoke_route_selected = true; + smoke_policy = + lora_link_adaptation_policy( + &s_link_adaptation, + smoke_next_hop); + } } - } - if (freshest) { - memcpy(recipient, freshest->reachable_peer, - sizeof(recipient)); - addressed = true; } taskEXIT_CRITICAL(&s_gov_mux); if (!addressed) { ESP_LOGI(TAG, "diagnostic smoke waiting for authenticated neighbor"); - next_smoke_ms = now + 3000ULL; + next_smoke_ms = now + 500ULL; } else { memset(probe, 0xA5, probe_len); probe[0] = 1; probe[1] = BITCHAT_MSG_MESSAGE; probe[2] = 0; + memcpy( + probe + 3, DIAGNOSTIC_MARKER, + sizeof(DIAGNOSTIC_MARKER)); probe[11] = 0x01; const uint16_t packet_header_len = 30; uint16_t payload_len = probe_len - packet_header_len; @@ -1812,17 +2154,38 @@ static void lora_task(void *arg) memcpy(probe + PKT_SENDER_OFF, noise_get_local_peer_id(), 8); memcpy(probe + 22, recipient, sizeof(recipient)); + probe[packet_header_len] = (uint8_t)smoke_index; bitle_link_send_result_t result = lora_link_send( BITLE_LORA_LINK_HANDLE, probe, probe_len); if (result == BITLE_LINK_SEND_ACCEPTED) { + lora_link_metrics_snapshot_t metrics; + uint16_t data_samples = 0; + taskENTER_CRITICAL(&s_gov_mux); + if (lora_link_adaptation_snapshot( + &s_link_adaptation, next_hop, + &metrics)) { + data_samples = metrics.data_samples; + } + taskEXIT_CRITICAL(&s_gov_mux); + memcpy( + smoke_next_hop, next_hop, + sizeof(smoke_next_hop)); + smoke_expected_data_samples = + data_samples == UINT16_MAX + ? UINT16_MAX + : (uint16_t)(data_samples + 1u); + smoke_waiting_completion = true; ESP_LOGI( TAG, "diagnostic smoke enqueue len=%u expected_frags=%u " - "addressed=1", + "addressed=1 chunk=%u rounds=%u link=%u", probe_len, - (probe_len + TRUNK_CHUNK_TX - 1) / - TRUNK_CHUNK_TX); + (probe_len + smoke_policy.fragment_size - 1) / + smoke_policy.fragment_size, + smoke_policy.fragment_size, + smoke_policy.maximum_rounds, + (unsigned)smoke_policy.profile); smoke_index++; } else { ESP_LOGW( @@ -1830,7 +2193,7 @@ static void lora_task(void *arg) "diagnostic smoke deferred len=%u result=%u", probe_len, result); } - next_smoke_ms = now + 3000ULL; + next_smoke_ms = now + 100ULL; } } #endif @@ -1962,7 +2325,7 @@ static void lora_task(void *arg) "trunk beacon TX freshness=%lu repeat=%u", (unsigned long)beacon_schedule.freshness, (unsigned)( - LORA_CHANNEL_BEACON_REPETITIONS - + TRUNK_BEACON_REPETITIONS - beacon_schedule.remaining + 1u)); beacon_schedule_finish_attempt( &beacon_schedule, &have_pending, @@ -2009,6 +2372,14 @@ static void lora_task(void *arg) if (pending.want_ack && s_selective_tx.round_index > 0) { diag_inc(&s_diag.retries); + taskENTER_CRITICAL(&s_gov_mux); + tx_trunk_context_t *context = + &s_tx_context[packet_handle]; + if (context->valid && + context->retry_count < UINT8_MAX) { + context->retry_count++; + } + taskEXIT_CRITICAL(&s_gov_mux); } if (pending.want_ack && !lora_selective_tx_note_sent( @@ -2245,12 +2616,23 @@ static void lora_task(void *arg) if (now2 >= ack_deadline) { if (!s_feedback_seen) { diag_inc(&s_diag.ack_misses); + taskENTER_CRITICAL(&s_gov_mux); + const tx_trunk_context_t *context = + &s_tx_context[packet_handle]; + if (context->valid && + context->ack_requested) { + lora_link_adaptation_note_ack( + &s_link_adaptation, + context->destination, false, now2); + } + taskEXIT_CRITICAL(&s_gov_mux); } s_feedback_seen = false; awaiting_ack = false; if (lora_selective_tx_next_round( &s_selective_tx, - LORA_SELECTIVE_REPEAT_ROUNDS) && + s_tx_context[packet_handle] + .maximum_rounds) && prepare_selective_fragment( packet_handle, &pending, fragment_total, &fragment_idx)) { @@ -2331,6 +2713,10 @@ static void lora_task(void *arg) &packet_handle, &have_pending); continue; } + taskENTER_CRITICAL(&s_gov_mux); + s_tx_context[packet_handle].started_ms = + channel_now; + taskEXIT_CRITICAL(&s_gov_mux); pending_relayed_broadcast = s_tx_context[packet_handle].relayed_broadcast; pending_forward_not_before_ms = @@ -2348,7 +2734,12 @@ static void lora_task(void *arg) (!lora_selective_tx_init( &s_selective_tx, fragment_total) || !trunk_reliability_timing( - fragment_total, &transfer_timing))) { + fragment_total, + s_tx_context[packet_handle] + .fragment_size, + s_tx_context[packet_handle] + .maximum_rounds, + &transfer_timing))) { abort_tx_packet( &packet_handle, &have_pending, LORA_TRUNK_ABORT_INVALID_LENGTH); @@ -2435,6 +2826,7 @@ void bitle_lora_shutdown(void) memset(s_completed_slots, 0, sizeof(s_completed_slots)); memset(&s_selective_tx, 0, sizeof(s_selective_tx)); lora_forward_cache_init(&s_forward_cache); + lora_link_adaptation_init(&s_link_adaptation); memset(&s_relay_ingress, 0, sizeof(s_relay_ingress)); s_expected_control_valid = false; s_feedback_seen = false; @@ -2458,17 +2850,19 @@ esp_err_t bitle_lora_init(void) .freq_hz = default_profile->default_hz, .sf = LORA_DEFAULT_SF, .bw_hz = 125000, - .cr = 5, + .cr = LORA_CODING_RATE, .preamble_symbols = LORA_PREAMBLE_SYMBOLS, .tx_dbm = 22, .region = LORA_DEFAULT_REGION, }; + const sx1262_config_t build_default_cfg = cfg; /* NVS overrides (namespace "lora"): u8 region, u32 freq, u8 sf, * u8 enabled, u8 duty_pct (1..50, default 25), and u8 ota_trunk. * A region/frequency mismatch is rejected as a pair. Legacy stores with * only a frequency infer their regional profile from that frequency. */ uint8_t duty_pct = 25; + bool persisted_profile_rejected = false; nvs_handle_t nvs; if (nvs_open("lora", NVS_READONLY, &nvs) == ESP_OK) { uint8_t enabled = 1; @@ -2482,6 +2876,7 @@ esp_err_t bitle_lora_init(void) (region_status == ESP_OK || region_status == ESP_ERR_NVS_NOT_FOUND) && (freq_status == ESP_OK || freq_status == ESP_ERR_NVS_NOT_FOUND); if (!stored_config_readable) { + persisted_profile_rejected = true; diag_inc(&s_diag.config_rejections); ESP_LOGE(TAG, "rejected malformed NVS LoRa region/frequency value"); } else if (region_status == ESP_OK && freq_status == ESP_OK) { @@ -2490,6 +2885,7 @@ esp_err_t bitle_lora_init(void) cfg.region = (lora_region_id_t)stored_region; cfg.freq_hz = freq; } else { + persisted_profile_rejected = true; diag_inc(&s_diag.config_rejections); ESP_LOGE(TAG, "rejected invalid NVS LoRa region/frequency pair"); } @@ -2500,6 +2896,7 @@ esp_err_t bitle_lora_init(void) cfg.region = stored_profile->id; cfg.freq_hz = stored_profile->default_hz; } else { + persisted_profile_rejected = true; diag_inc(&s_diag.config_rejections); ESP_LOGE(TAG, "rejected invalid NVS LoRa region"); } @@ -2509,6 +2906,7 @@ esp_err_t bitle_lora_init(void) cfg.region = inferred; cfg.freq_hz = freq; } else { + persisted_profile_rejected = true; diag_inc(&s_diag.config_rejections); ESP_LOGE(TAG, "rejected out-of-profile NVS LoRa frequency"); } @@ -2520,10 +2918,12 @@ esp_err_t bitle_lora_init(void) if (sf >= 7 && sf <= 12) { cfg.sf = sf; } else { + persisted_profile_rejected = true; diag_inc(&s_diag.config_rejections); ESP_LOGE(TAG, "rejected invalid NVS spreading factor"); } } else if (sf_status != ESP_ERR_NVS_NOT_FOUND) { + persisted_profile_rejected = true; diag_inc(&s_diag.config_rejections); ESP_LOGE(TAG, "rejected malformed NVS spreading factor"); } @@ -2540,6 +2940,30 @@ esp_err_t bitle_lora_init(void) return ESP_OK; } } + if (persisted_profile_rejected) { + cfg = build_default_cfg; + ESP_LOGE( + TAG, + "discarded persisted LoRa profile; restoring build defaults"); + } + + lora_phy_profile_t selected_profile = { + .region = cfg.region, + .frequency_hz = cfg.freq_hz, + .spreading_factor = cfg.sf, + .bandwidth_hz = cfg.bw_hz, + .coding_rate = cfg.cr, + .preamble_symbols = cfg.preamble_symbols, + .sync_word = LORA_PRIVATE_SYNC_WORD, + .tx_dbm = cfg.tx_dbm, + }; + if (!lora_phy_profile_valid(&selected_profile)) { + diag_inc(&s_diag.config_rejections); + ESP_LOGE( + TAG, + "rejected incompatible LoRa profile; restoring build defaults"); + cfg = build_default_cfg; + } #if defined(CONFIG_BITLE_LORA_VEXT_PIN) && CONFIG_BITLE_LORA_VEXT_PIN >= 0 /* Boards like the Heltec V3 gate the radio behind a switchable @@ -2569,6 +2993,7 @@ esp_err_t bitle_lora_init(void) memset(s_completed_slots, 0, sizeof(s_completed_slots)); lora_trunk_neighbor_table_init(&s_neighbors); lora_forward_cache_init(&s_forward_cache); + lora_link_adaptation_init(&s_link_adaptation); memset(&s_relay_ingress, 0, sizeof(s_relay_ingress)); s_expected_control_valid = false; s_feedback_seen = false; @@ -2617,10 +3042,13 @@ esp_err_t bitle_lora_init(void) } const lora_region_profile_t *active_profile = lora_region_profile(cfg.region); ESP_LOGI(TAG, - "trunk up: region=%s %.3f MHz SF%u BW%lu preamble=%u +%ddBm " - "duty=%u%% ota_trunk=%d", + "trunk up: board=%s region=%s %.3f MHz SF%u BW%lu " + "CR4/%u preamble=%u sync=0x%04x +%ddBm " + "duty=%u%% ota_trunk=%d phy_verified=1", + LORA_BOARD_NAME, active_profile->name, cfg.freq_hz / 1e6, cfg.sf, - (unsigned long)cfg.bw_hz, cfg.preamble_symbols, cfg.tx_dbm, + (unsigned long)cfg.bw_hz, cfg.cr, + cfg.preamble_symbols, LORA_PRIVATE_SYNC_WORD, cfg.tx_dbm, duty_pct, s_ota_over_trunk); return ESP_OK; } diff --git a/main/lora_link_adaptation.c b/main/lora_link_adaptation.c new file mode 100644 index 0000000..c37ac05 --- /dev/null +++ b/main/lora_link_adaptation.c @@ -0,0 +1,344 @@ +#include "lora_link_adaptation.h" + +#include +#include + +#define EWMA_SCALE 256 +#define PERCENT_SCALE 100 +#define MAX_TRACKED_LATENCY_MS 3600000U + +static uint16_t increment_sample(uint16_t value) +{ + return value == UINT16_MAX ? value : (uint16_t)(value + 1u); +} + +static uint8_t increment_streak(uint8_t value) +{ + return value == UINT8_MAX ? value : (uint8_t)(value + 1u); +} + +static int32_t ewma_q8( + int32_t current, int32_t sample, uint16_t prior_samples) +{ + if (prior_samples == 0) { + return sample * EWMA_SCALE; + } + int64_t accumulated = + (int64_t)current * 3 + (int64_t)sample * EWMA_SCALE; + return (int32_t)(accumulated / 4); +} + +static lora_link_metrics_t *find_entry( + lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN], + bool create, uint64_t now_ms) +{ + if (!state || !node_id) { + return NULL; + } + lora_link_metrics_t *free_entry = NULL; + lora_link_metrics_t *oldest = NULL; + for (size_t index = 0; + index < LORA_LINK_ADAPTATION_CAPACITY; ++index) { + lora_link_metrics_t *entry = &state->entries[index]; + if (entry->in_use && + memcmp( + entry->node_id, node_id, + LORA_LINK_ADAPTATION_NODE_ID_LEN) == 0) { + return entry; + } + if (!entry->in_use && !free_entry) { + free_entry = entry; + } + if (entry->in_use && + (!oldest || + entry->last_update_ms < oldest->last_update_ms)) { + oldest = entry; + } + } + if (!create) { + return NULL; + } + lora_link_metrics_t *entry = + free_entry ? free_entry : oldest; + if (!entry) { + return NULL; + } + memset(entry, 0, sizeof(*entry)); + entry->in_use = true; + entry->last_update_ms = now_ms; + memcpy( + entry->node_id, node_id, + LORA_LINK_ADAPTATION_NODE_ID_LEN); + return entry; +} + +static const lora_link_metrics_t *find_const_entry( + const lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN]) +{ + if (!state || !node_id) { + return NULL; + } + for (size_t index = 0; + index < LORA_LINK_ADAPTATION_CAPACITY; ++index) { + const lora_link_metrics_t *entry = + &state->entries[index]; + if (entry->in_use && + memcmp( + entry->node_id, node_id, + LORA_LINK_ADAPTATION_NODE_ID_LEN) == 0) { + return entry; + } + } + return NULL; +} + +void lora_link_adaptation_init(lora_link_adaptation_t *state) +{ + if (state) { + memset(state, 0, sizeof(*state)); + } +} + +void lora_link_adaptation_note_signal( + lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN], + int16_t rssi_dbm, int8_t snr_db, uint64_t now_ms) +{ + lora_link_metrics_t *entry = + find_entry(state, node_id, true, now_ms); + if (!entry) { + return; + } + entry->rssi_q8 = + ewma_q8(entry->rssi_q8, rssi_dbm, entry->signal_samples); + entry->snr_q8 = + ewma_q8(entry->snr_q8, snr_db, entry->signal_samples); + entry->signal_samples = + increment_sample(entry->signal_samples); + entry->last_update_ms = now_ms; +} + +void lora_link_adaptation_note_ack( + lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN], + bool success, uint64_t now_ms) +{ + lora_link_metrics_t *entry = + find_entry(state, node_id, true, now_ms); + if (!entry) { + return; + } + entry->ack_success_q8 = + ewma_q8( + entry->ack_success_q8, success ? 1 : 0, + entry->ack_samples); + entry->ack_samples = increment_sample(entry->ack_samples); + entry->last_update_ms = now_ms; +} + +static bool signal_is_poor(const lora_link_metrics_t *entry) +{ + return entry->signal_samples >= 2 && + (entry->snr_q8 <= -8 * EWMA_SCALE || + entry->rssi_q8 <= -115 * EWMA_SCALE); +} + +static bool signal_is_stable(const lora_link_metrics_t *entry) +{ + return entry->signal_samples >= 4 && + entry->snr_q8 >= 2 * EWMA_SCALE && + entry->rssi_q8 >= -105 * EWMA_SCALE; +} + +static bool ack_is_stable(const lora_link_metrics_t *entry) +{ + return entry->ack_samples < 3 || + entry->ack_success_q8 >= + (90 * EWMA_SCALE) / PERCENT_SCALE; +} + +void lora_link_adaptation_note_delivery( + lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN], + bool success, uint8_t retries, uint32_t latency_ms, + uint64_t now_ms) +{ + lora_link_metrics_t *entry = + find_entry(state, node_id, true, now_ms); + if (!entry) { + return; + } + uint16_t prior_samples = entry->data_samples; + uint32_t bounded_latency = + latency_ms > MAX_TRACKED_LATENCY_MS + ? MAX_TRACKED_LATENCY_MS + : latency_ms; + entry->data_success_q8 = + ewma_q8( + entry->data_success_q8, success ? 1 : 0, + prior_samples); + entry->retries_q8 = + ewma_q8(entry->retries_q8, retries, prior_samples); + entry->latency_ms_q8 = + ewma_q8( + entry->latency_ms_q8, (int32_t)bounded_latency, + prior_samples); + entry->data_samples = increment_sample(prior_samples); + entry->last_update_ms = now_ms; + + /* Delivery timeout already captures a missing terminal ACK. Do not feed + * the historical ACK EWMA back into the poor/healthy streaks: doing so + * would keep a recovered link weak long after four consecutive successful + * deliveries. The ACK EWMA remains a separate metric and a guard on entry + * to the stable throughput profile. */ + bool poor = + !success || retries >= 2 || signal_is_poor(entry); + bool healthy = + success && retries <= 1 && !signal_is_poor(entry); + bool stable = + success && retries == 0 && signal_is_stable(entry) && + ack_is_stable(entry) && + entry->data_success_q8 >= + (94 * EWMA_SCALE) / PERCENT_SCALE && + entry->retries_q8 <= EWMA_SCALE / 2; + + entry->poor_streak = + poor ? increment_streak(entry->poor_streak) : 0; + entry->healthy_streak = + healthy ? increment_streak(entry->healthy_streak) : 0; + entry->stable_streak = + stable ? increment_streak(entry->stable_streak) : 0; + + if (entry->poor_streak >= 2) { + entry->profile = LORA_LINK_PROFILE_WEAK; + } else if ( + entry->profile == LORA_LINK_PROFILE_WEAK && + entry->healthy_streak >= 4) { + entry->profile = LORA_LINK_PROFILE_BALANCED; + } else if ( + entry->data_samples >= 8 && + entry->stable_streak >= 6) { + entry->profile = LORA_LINK_PROFILE_STABLE; + } else if ( + entry->profile == LORA_LINK_PROFILE_STABLE && poor) { + entry->profile = LORA_LINK_PROFILE_BALANCED; + } else if ( + entry->profile == LORA_LINK_PROFILE_UNKNOWN && + entry->data_samples >= 4) { + entry->profile = LORA_LINK_PROFILE_BALANCED; + } +} + +lora_link_policy_t lora_link_adaptation_policy( + const lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN]) +{ + lora_link_policy_t policy = { + .fragment_size = LORA_LINK_CHUNK_STABLE, + .maximum_rounds = LORA_LINK_ROUNDS_DEFAULT, + .profile = LORA_LINK_PROFILE_UNKNOWN, + }; + const lora_link_metrics_t *entry = + find_const_entry(state, node_id); + if (!entry) { + return policy; + } + policy.profile = entry->profile; + if (entry->profile == LORA_LINK_PROFILE_WEAK) { + policy.fragment_size = LORA_LINK_CHUNK_WEAK; + policy.maximum_rounds = LORA_LINK_ROUNDS_WEAK; + } else if (entry->profile == LORA_LINK_PROFILE_BALANCED) { + policy.fragment_size = LORA_LINK_CHUNK_BALANCED; + } else if (entry->profile == LORA_LINK_PROFILE_STABLE) { + policy.maximum_rounds = LORA_LINK_ROUNDS_STABLE; + } + return policy; +} + +static int16_t q8_to_x10(int32_t value) +{ + int32_t converted = (value * 10) / EWMA_SCALE; + if (converted > INT16_MAX) { + return INT16_MAX; + } + if (converted < INT16_MIN) { + return INT16_MIN; + } + return (int16_t)converted; +} + +static uint8_t q8_to_percent(int32_t value) +{ + int32_t converted = + (value * PERCENT_SCALE + EWMA_SCALE / 2) / EWMA_SCALE; + if (converted < 0) { + return 0; + } + if (converted > 100) { + return 100; + } + return (uint8_t)converted; +} + +bool lora_link_adaptation_snapshot( + const lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN], + lora_link_metrics_snapshot_t *out) +{ + const lora_link_metrics_t *entry = + find_const_entry(state, node_id); + if (!entry || !out) { + return false; + } + *out = (lora_link_metrics_snapshot_t) { + .signal_samples = entry->signal_samples, + .data_samples = entry->data_samples, + .ack_samples = entry->ack_samples, + .rssi_x10 = q8_to_x10(entry->rssi_q8), + .snr_x10 = q8_to_x10(entry->snr_q8), + .data_success_pct = + q8_to_percent(entry->data_success_q8), + .ack_success_pct = + q8_to_percent(entry->ack_success_q8), + .retries_x100 = + (uint16_t)( + entry->retries_q8 > 0 + ? ((int64_t)entry->retries_q8 * 100 + + EWMA_SCALE / 2) / + EWMA_SCALE + : 0), + .latency_ms = + entry->latency_ms_q8 > 0 + ? (uint32_t)( + (entry->latency_ms_q8 + EWMA_SCALE / 2) / + EWMA_SCALE) + : 0, + .profile = entry->profile, + }; + return true; +} + +size_t lora_link_adaptation_count( + const lora_link_adaptation_t *state) +{ + if (!state) { + return 0; + } + size_t count = 0; + for (size_t index = 0; + index < LORA_LINK_ADAPTATION_CAPACITY; ++index) { + if (state->entries[index].in_use) { + count++; + } + } + return count; +} + +bool lora_link_fragment_size_valid(uint16_t fragment_size) +{ + return fragment_size == LORA_LINK_CHUNK_WEAK || + fragment_size == LORA_LINK_CHUNK_BALANCED || + fragment_size == LORA_LINK_CHUNK_STABLE; +} diff --git a/main/lora_link_adaptation.h b/main/lora_link_adaptation.h new file mode 100644 index 0000000..d3d5117 --- /dev/null +++ b/main/lora_link_adaptation.h @@ -0,0 +1,108 @@ +#ifndef LORA_LINK_ADAPTATION_H +#define LORA_LINK_ADAPTATION_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LORA_LINK_ADAPTATION_NODE_ID_LEN 8 +#define LORA_LINK_ADAPTATION_CAPACITY 12 + +#define LORA_LINK_CHUNK_WEAK 104 +#define LORA_LINK_CHUNK_BALANCED 112 +#define LORA_LINK_CHUNK_STABLE 120 + +#define LORA_LINK_ROUNDS_STABLE 6 +#define LORA_LINK_ROUNDS_DEFAULT 8 +#define LORA_LINK_ROUNDS_WEAK 10 + +typedef enum { + LORA_LINK_PROFILE_UNKNOWN = 0, + LORA_LINK_PROFILE_BALANCED, + LORA_LINK_PROFILE_WEAK, + LORA_LINK_PROFILE_STABLE, +} lora_link_profile_t; + +typedef struct { + uint16_t fragment_size; + uint8_t maximum_rounds; + lora_link_profile_t profile; +} lora_link_policy_t; + +typedef struct { + bool in_use; + uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN]; + uint64_t last_update_ms; + uint16_t signal_samples; + uint16_t data_samples; + uint16_t ack_samples; + int32_t rssi_q8; + int32_t snr_q8; + int32_t data_success_q8; + int32_t ack_success_q8; + int32_t retries_q8; + int32_t latency_ms_q8; + uint8_t healthy_streak; + uint8_t poor_streak; + uint8_t stable_streak; + lora_link_profile_t profile; +} lora_link_metrics_t; + +typedef struct { + lora_link_metrics_t entries[LORA_LINK_ADAPTATION_CAPACITY]; +} lora_link_adaptation_t; + +typedef struct { + uint16_t signal_samples; + uint16_t data_samples; + uint16_t ack_samples; + int16_t rssi_x10; + int16_t snr_x10; + uint8_t data_success_pct; + uint8_t ack_success_pct; + uint16_t retries_x100; + uint32_t latency_ms; + lora_link_profile_t profile; +} lora_link_metrics_snapshot_t; + +void lora_link_adaptation_init(lora_link_adaptation_t *state); + +void lora_link_adaptation_note_signal( + lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN], + int16_t rssi_dbm, int8_t snr_db, uint64_t now_ms); + +void lora_link_adaptation_note_ack( + lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN], + bool success, uint64_t now_ms); + +void lora_link_adaptation_note_delivery( + lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN], + bool success, uint8_t retries, uint32_t latency_ms, + uint64_t now_ms); + +lora_link_policy_t lora_link_adaptation_policy( + const lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN]); + +bool lora_link_adaptation_snapshot( + const lora_link_adaptation_t *state, + const uint8_t node_id[LORA_LINK_ADAPTATION_NODE_ID_LEN], + lora_link_metrics_snapshot_t *out); + +size_t lora_link_adaptation_count( + const lora_link_adaptation_t *state); + +bool lora_link_fragment_size_valid(uint16_t fragment_size); + +#ifdef __cplusplus +} +#endif + +#endif /* LORA_LINK_ADAPTATION_H */ diff --git a/main/lora_region.c b/main/lora_region.c index 8bf1d16..66ffb94 100644 --- a/main/lora_region.c +++ b/main/lora_region.c @@ -63,3 +63,37 @@ bool lora_region_image_calibration(uint32_t frequency_hz, uint8_t out[2]) out[1] = profile->image_calibration[1]; return true; } + +bool lora_phy_profile_valid(const lora_phy_profile_t *profile) +{ + return profile && + lora_region_frequency_valid( + profile->region, profile->frequency_hz) && + profile->spreading_factor >= 7 && + profile->spreading_factor <= 12 && + (profile->bandwidth_hz == 125000 || + profile->bandwidth_hz == 250000 || + profile->bandwidth_hz == 500000) && + profile->coding_rate >= 5 && + profile->coding_rate <= 8 && + profile->preamble_symbols >= 8 && + profile->preamble_symbols <= 64 && + profile->sync_word == LORA_PRIVATE_SYNC_WORD && + profile->tx_dbm >= -9 && + profile->tx_dbm <= 22; +} + +bool lora_phy_profiles_compatible( + const lora_phy_profile_t *left, + const lora_phy_profile_t *right) +{ + return lora_phy_profile_valid(left) && + lora_phy_profile_valid(right) && + left->region == right->region && + left->frequency_hz == right->frequency_hz && + left->spreading_factor == right->spreading_factor && + left->bandwidth_hz == right->bandwidth_hz && + left->coding_rate == right->coding_rate && + left->preamble_symbols == right->preamble_symbols && + left->sync_word == right->sync_word; +} diff --git a/main/lora_region.h b/main/lora_region.h index 0fac9f6..85c4e5c 100644 --- a/main/lora_region.h +++ b/main/lora_region.h @@ -23,10 +23,30 @@ typedef struct { uint8_t image_calibration[2]; } lora_region_profile_t; +#define LORA_PRIVATE_SYNC_WORD 0x1424 + +typedef struct { + lora_region_id_t region; + uint32_t frequency_hz; + uint8_t spreading_factor; + uint32_t bandwidth_hz; + uint8_t coding_rate; + uint16_t preamble_symbols; + uint16_t sync_word; + int8_t tx_dbm; +} lora_phy_profile_t; + const lora_region_profile_t *lora_region_profile(lora_region_id_t id); bool lora_region_frequency_valid(lora_region_id_t id, uint32_t frequency_hz); bool lora_region_infer(uint32_t frequency_hz, lora_region_id_t *out); bool lora_region_image_calibration(uint32_t frequency_hz, uint8_t out[2]); +bool lora_phy_profile_valid(const lora_phy_profile_t *profile); + +/* Every field that changes demodulation must match. TX power may differ + * without stranding peers and is therefore intentionally not compared. */ +bool lora_phy_profiles_compatible( + const lora_phy_profile_t *left, + const lora_phy_profile_t *right); #ifdef __cplusplus } diff --git a/main/lora_trunk_protocol.c b/main/lora_trunk_protocol.c index b5c7bed..45b6188 100644 --- a/main/lora_trunk_protocol.c +++ b/main/lora_trunk_protocol.c @@ -466,6 +466,13 @@ lora_trunk_neighbor_result_t lora_trunk_neighbor_learn_authenticated( if (same_route->quarantined) { return LORA_TRUNK_NEIGHBOR_COLLISION; } + /* A newly observed relayed copy must not downgrade or redirect an + * authenticated direct route for the same signed peer. It also must + * not refresh that direct route's lifetime: only another direct + * announcement proves that the immediate neighbor is still present. */ + if (same_route->direct_identity && !direct_identity) { + return LORA_TRUNK_NEIGHBOR_REFRESHED; + } memcpy(same_route->node_id, node_id, LORA_TRUNK_V3_NODE_ID_LEN); same_route->direct_identity = direct_identity; same_route->capabilities = capabilities; diff --git a/main/sx1262.c b/main/sx1262.c index 85d5eb6..353d8cd 100644 --- a/main/sx1262.c +++ b/main/sx1262.c @@ -54,9 +54,6 @@ static const char *TAG = "sx1262"; #define REG_RX_GAIN 0x08AC #define REG_OCP 0x08E7 -/* Private-network sync word, distinct from Meshtastic/public (0x2444). */ -#define SYNC_MSB 0x14 -#define SYNC_LSB 0x24 #define RX_GAIN_BOOSTED 0x96 /* OCP is programmed in 2.5 mA steps: 0x38 = 140 mA. */ #define OCP_140_MA 0x38 @@ -199,16 +196,24 @@ static esp_err_t chip_reset(void) static bool config_valid(const sx1262_config_t *cfg) { - return cfg && - cfg->pin_sck >= 0 && cfg->pin_miso >= 0 && cfg->pin_mosi >= 0 && - cfg->pin_cs >= 0 && cfg->pin_reset >= 0 && cfg->pin_busy >= 0 && - cfg->pin_dio1 >= 0 && - lora_region_frequency_valid(cfg->region, cfg->freq_hz) && - cfg->sf >= 7 && cfg->sf <= 12 && - (cfg->bw_hz == 125000 || cfg->bw_hz == 250000 || cfg->bw_hz == 500000) && - cfg->cr >= 5 && cfg->cr <= 8 && - cfg->preamble_symbols >= 8 && - cfg->tx_dbm >= -9 && cfg->tx_dbm <= 22; + if (!cfg || + cfg->pin_sck < 0 || cfg->pin_miso < 0 || + cfg->pin_mosi < 0 || cfg->pin_cs < 0 || + cfg->pin_reset < 0 || cfg->pin_busy < 0 || + cfg->pin_dio1 < 0) { + return false; + } + lora_phy_profile_t profile = { + .region = cfg->region, + .frequency_hz = cfg->freq_hz, + .spreading_factor = cfg->sf, + .bandwidth_hz = cfg->bw_hz, + .coding_rate = cfg->cr, + .preamble_symbols = cfg->preamble_symbols, + .sync_word = LORA_PRIVATE_SYNC_WORD, + .tx_dbm = cfg->tx_dbm, + }; + return lora_phy_profile_valid(&profile); } static esp_err_t bus_init(const sx1262_config_t *cfg) @@ -291,6 +296,26 @@ static esp_err_t verify_reg(uint16_t address, uint8_t expected, const char *name return ESP_OK; } +static esp_err_t verify_regs( + uint16_t address, const uint8_t *expected, size_t count, + const char *name) +{ + if (!expected || count == 0 || count > 4) { + return ESP_ERR_INVALID_ARG; + } + uint8_t actual[4] = {0}; + esp_err_t err = read_reg(address, actual, count); + if (err != ESP_OK) { + return err; + } + if (memcmp(actual, expected, count) != 0) { + s_diag.command_errors++; + ESP_LOGE(TAG, "%s verify failed", name); + return ESP_ERR_INVALID_RESPONSE; + } + return ESP_OK; +} + #define RETURN_ON_ERROR(expression) do { \ esp_err_t return_on_error_result = (expression); \ if (return_on_error_result != ESP_OK) { \ @@ -386,8 +411,13 @@ static esp_err_t configure_radio(void) }; RETURN_ON_ERROR(cmd(OP_SET_MOD_PARAMS, modulation, sizeof(modulation))); - uint8_t sync[2] = {SYNC_MSB, SYNC_LSB}; + uint8_t sync[2] = { + LORA_PRIVATE_SYNC_WORD >> 8, + LORA_PRIVATE_SYNC_WORD & 0xFF + }; RETURN_ON_ERROR(write_reg(REG_LORA_SYNC_MSB, sync, sizeof(sync))); + RETURN_ON_ERROR(verify_regs( + REG_LORA_SYNC_MSB, sync, sizeof(sync), "sync word")); uint8_t rx_gain = RX_GAIN_BOOSTED; RETURN_ON_ERROR(write_reg(REG_RX_GAIN, &rx_gain, 1)); diff --git a/tests/test_lora_link_adaptation.c b/tests/test_lora_link_adaptation.c new file mode 100644 index 0000000..8f15554 --- /dev/null +++ b/tests/test_lora_link_adaptation.c @@ -0,0 +1,188 @@ +#include "lora_link_adaptation.h" + +#include +#include +#include + +static void node_id(uint8_t out[8], uint8_t value) +{ + memset(out, value, 8); +} + +static void test_default_and_bounds(void) +{ + lora_link_adaptation_t state; + uint8_t peer[8]; + node_id(peer, 1); + lora_link_adaptation_init(&state); + + lora_link_policy_t policy = + lora_link_adaptation_policy(&state, peer); + assert(policy.profile == LORA_LINK_PROFILE_UNKNOWN); + assert(policy.fragment_size == LORA_LINK_CHUNK_STABLE); + assert(policy.maximum_rounds == LORA_LINK_ROUNDS_DEFAULT); + assert(lora_link_fragment_size_valid(LORA_LINK_CHUNK_WEAK)); + assert(lora_link_fragment_size_valid(LORA_LINK_CHUNK_BALANCED)); + assert(lora_link_fragment_size_valid(LORA_LINK_CHUNK_STABLE)); + assert(!lora_link_fragment_size_valid(103)); + assert(!lora_link_fragment_size_valid(121)); +} + +static void test_weak_link_and_recovery(void) +{ + lora_link_adaptation_t state; + uint8_t peer[8]; + node_id(peer, 2); + lora_link_adaptation_init(&state); + + for (uint64_t sample = 0; sample < 2; ++sample) { + lora_link_adaptation_note_signal( + &state, peer, -121, -12, sample * 100); + lora_link_adaptation_note_ack( + &state, peer, false, sample * 100 + 1); + lora_link_adaptation_note_delivery( + &state, peer, false, 3, 5000, + sample * 100 + 2); + } + lora_link_policy_t policy = + lora_link_adaptation_policy(&state, peer); + assert(policy.profile == LORA_LINK_PROFILE_WEAK); + assert(policy.fragment_size == LORA_LINK_CHUNK_WEAK); + assert(policy.maximum_rounds == LORA_LINK_ROUNDS_WEAK); + + for (uint64_t sample = 0; sample < 3; ++sample) { + lora_link_adaptation_note_signal( + &state, peer, -92, 8, 1000 + sample * 100); + lora_link_adaptation_note_ack( + &state, peer, true, 1001 + sample * 100); + lora_link_adaptation_note_delivery( + &state, peer, true, 1, 900, + 1002 + sample * 100); + } + policy = lora_link_adaptation_policy(&state, peer); + assert(policy.profile == LORA_LINK_PROFILE_WEAK); + + lora_link_adaptation_note_signal(&state, peer, -92, 8, 1300); + lora_link_adaptation_note_ack(&state, peer, true, 1301); + lora_link_adaptation_note_delivery( + &state, peer, true, 1, 900, 1302); + policy = lora_link_adaptation_policy(&state, peer); + assert(policy.profile == LORA_LINK_PROFILE_BALANCED); + assert(policy.fragment_size == LORA_LINK_CHUNK_BALANCED); + assert(policy.maximum_rounds == LORA_LINK_ROUNDS_DEFAULT); +} + +static void test_stable_link_recovers_throughput(void) +{ + lora_link_adaptation_t state; + uint8_t peer[8]; + node_id(peer, 3); + lora_link_adaptation_init(&state); + + for (uint64_t sample = 0; sample < 10; ++sample) { + lora_link_adaptation_note_signal( + &state, peer, -88, 9, sample * 50); + lora_link_adaptation_note_ack( + &state, peer, true, sample * 50 + 1); + lora_link_adaptation_note_delivery( + &state, peer, true, 0, 700, + sample * 50 + 2); + } + lora_link_policy_t policy = + lora_link_adaptation_policy(&state, peer); + assert(policy.profile == LORA_LINK_PROFILE_STABLE); + assert(policy.fragment_size == LORA_LINK_CHUNK_STABLE); + assert(policy.maximum_rounds == LORA_LINK_ROUNDS_STABLE); + + lora_link_metrics_snapshot_t metrics; + assert(lora_link_adaptation_snapshot( + &state, peer, &metrics)); + assert(metrics.signal_samples == 10); + assert(metrics.data_samples == 10); + assert(metrics.ack_samples == 10); + assert(metrics.rssi_x10 == -880); + assert(metrics.snr_x10 == 90); + assert(metrics.data_success_pct == 100); + assert(metrics.ack_success_pct == 100); + assert(metrics.retries_x100 == 0); + assert(metrics.latency_ms == 700); + + lora_link_adaptation_note_delivery( + &state, peer, false, 2, 5000, 1000); + policy = lora_link_adaptation_policy(&state, peer); + assert(policy.profile == LORA_LINK_PROFILE_BALANCED); + assert(policy.fragment_size == LORA_LINK_CHUNK_BALANCED); + assert(policy.maximum_rounds == LORA_LINK_ROUNDS_DEFAULT); +} + +static void test_balanced_hysteresis(void) +{ + lora_link_adaptation_t state; + uint8_t peer[8]; + node_id(peer, 4); + lora_link_adaptation_init(&state); + + for (uint64_t sample = 0; sample < 4; ++sample) { + lora_link_adaptation_note_signal( + &state, peer, -108, -3, sample * 50); + lora_link_adaptation_note_ack( + &state, peer, true, sample * 50 + 1); + lora_link_adaptation_note_delivery( + &state, peer, true, 1, 1600, + sample * 50 + 2); + } + lora_link_policy_t policy = + lora_link_adaptation_policy(&state, peer); + assert(policy.profile == LORA_LINK_PROFILE_BALANCED); + assert(policy.fragment_size == LORA_LINK_CHUNK_BALANCED); + assert(policy.maximum_rounds == LORA_LINK_ROUNDS_DEFAULT); + + lora_link_adaptation_note_delivery( + &state, peer, false, 4, 5000, 1000); + policy = lora_link_adaptation_policy(&state, peer); + assert(policy.profile == LORA_LINK_PROFILE_BALANCED); + lora_link_adaptation_note_delivery( + &state, peer, false, 4, 5000, 1100); + policy = lora_link_adaptation_policy(&state, peer); + assert(policy.profile == LORA_LINK_PROFILE_WEAK); +} + +static void test_bounded_lru_table(void) +{ + lora_link_adaptation_t state; + lora_link_adaptation_init(&state); + uint8_t peer[8]; + for (uint8_t value = 1; + value <= LORA_LINK_ADAPTATION_CAPACITY; ++value) { + node_id(peer, value); + lora_link_adaptation_note_signal( + &state, peer, -100, 0, value); + } + assert(lora_link_adaptation_count(&state) == + LORA_LINK_ADAPTATION_CAPACITY); + + uint8_t oldest[8]; + node_id(oldest, 1); + uint8_t replacement[8]; + node_id(replacement, 99); + lora_link_adaptation_note_signal( + &state, replacement, -90, 5, 1000); + assert(lora_link_adaptation_count(&state) == + LORA_LINK_ADAPTATION_CAPACITY); + lora_link_metrics_snapshot_t metrics; + assert(!lora_link_adaptation_snapshot( + &state, oldest, &metrics)); + assert(lora_link_adaptation_snapshot( + &state, replacement, &metrics)); +} + +int main(void) +{ + test_default_and_bounds(); + test_weak_link_and_recovery(); + test_stable_link_recovers_throughput(); + test_balanced_hysteresis(); + test_bounded_lru_table(); + puts("lora_link_adaptation: ok"); + return 0; +} diff --git a/tests/test_lora_link_adaptation.py b/tests/test_lora_link_adaptation.py new file mode 100644 index 0000000..2af16ae --- /dev/null +++ b/tests/test_lora_link_adaptation.py @@ -0,0 +1,69 @@ +import unittest + +from tools.lora_reliability_sim import ( + RadioProfile, + SelectiveRepeatConfig, + SelectiveRepeatModel, + airtime_ms, +) + + +def completion_rate( + *, loss: float, chunk_size: int, maximum_rounds: int, seed: int +) -> float: + model = SelectiveRepeatModel( + SelectiveRepeatConfig( + data_loss=loss, + ack_loss=loss, + chunk_size=chunk_size, + maximum_rounds=maximum_rounds, + seed=seed, + ) + ) + results = [model.transfer(520) for _ in range(5000)] + return sum(result.complete for result in results) / len(results) + + +class LinkAdaptationSystemTests(unittest.TestCase): + def test_weak_policy_improves_high_loss_completion(self): + default = completion_rate( + loss=0.40, chunk_size=120, maximum_rounds=8, seed=701 + ) + weak = completion_rate( + loss=0.40, chunk_size=104, maximum_rounds=10, seed=701 + ) + self.assertGreaterEqual(weak, 0.98) + self.assertGreaterEqual(weak - default, 0.015) + + def test_stable_policy_recovers_fragment_throughput(self): + stable = completion_rate( + loss=0.05, chunk_size=120, maximum_rounds=6, seed=711 + ) + self.assertGreaterEqual(stable, 0.999) + self.assertEqual((480 + 119) // 120, 4) + self.assertEqual((480 + 103) // 104, 5) + + def test_coding_rate_airtime_tradeoff_is_explicit(self): + airtimes = {} + for coding_rate in (5, 6, 7): + profile = RadioProfile( + sf=10, + bandwidth_hz=125_000, + coding_rate=coding_rate, + preamble_symbols=16, + ) + airtimes[coding_rate] = airtime_ms(164, profile) + self.assertEqual( + airtimes, + { + 5: 1583, + 6: 1853, + 7: 2124, + }, + ) + self.assertLess(airtimes[5], airtimes[6]) + self.assertLess(airtimes[6], airtimes[7]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lora_region.c b/tests/test_lora_region.c index aacf976..4b4aa45 100644 --- a/tests/test_lora_region.c +++ b/tests/test_lora_region.c @@ -30,5 +30,32 @@ int main(void) assert(inferred == LORA_REGION_US915); assert(lora_region_infer(870000000, &inferred)); assert(inferred == LORA_REGION_EU868); + + lora_phy_profile_t base = { + .region = LORA_REGION_US915, + .frequency_hz = 911500000, + .spreading_factor = 10, + .bandwidth_hz = 125000, + .coding_rate = 5, + .preamble_symbols = 16, + .sync_word = LORA_PRIVATE_SYNC_WORD, + .tx_dbm = 22, + }; + assert(lora_phy_profile_valid(&base)); + lora_phy_profile_t peer = base; + peer.tx_dbm = 14; + assert(lora_phy_profiles_compatible(&base, &peer)); + peer.coding_rate = 6; + assert(lora_phy_profile_valid(&peer)); + assert(!lora_phy_profiles_compatible(&base, &peer)); + peer = base; + peer.frequency_hz = 869525000; + assert(!lora_phy_profile_valid(&peer)); + peer = base; + peer.sync_word = 0x2444; + assert(!lora_phy_profile_valid(&peer)); + peer = base; + peer.preamble_symbols = 65; + assert(!lora_phy_profile_valid(&peer)); return 0; } diff --git a/tests/test_lora_trunk_protocol.c b/tests/test_lora_trunk_protocol.c index 2764903..a353796 100644 --- a/tests/test_lora_trunk_protocol.c +++ b/tests/test_lora_trunk_protocol.c @@ -298,6 +298,16 @@ static void test_neighbor_routes_and_collision(void) &table, SOURCE_A, 1500, 1000, route, &capabilities)); assert(memcmp(route, SOURCE_A, 8) == 0); assert(capabilities == LORA_TRUNK_V3_CAP_PROTOCOL); + assert(lora_trunk_neighbor_learn_authenticated( + &table, SOURCE_B, SOURCE_A, key_a, + false, LORA_TRUNK_V3_CAP_PROTOCOL, 1600) == + LORA_TRUNK_NEIGHBOR_REFRESHED); + assert(lora_trunk_neighbor_route( + &table, SOURCE_A, 1900, 1000, route, NULL)); + assert(memcmp(route, SOURCE_A, 8) == 0); + assert(table.entries[0].direct_identity); + assert(!lora_trunk_neighbor_route( + &table, SOURCE_A, 2001, 1000, route, NULL)); assert(!lora_trunk_neighbor_route( &table, SOURCE_A, 2501, 1000, route, NULL)); diff --git a/tools/lora_hardware_smoke.py b/tools/lora_hardware_smoke.py index 7b256b9..9f67692 100755 --- a/tools/lora_hardware_smoke.py +++ b/tools/lora_hardware_smoke.py @@ -25,7 +25,12 @@ FORWARD_SMOKE_RE = re.compile( r"diagnostic forwarding smoke enqueue len=(\d+) expected_frags=(\d+)" ) -PROFILE_RE = re.compile(r"trunk up: region=(\w+).* SF(\d+).*preamble=(\d+)") +PROFILE_RE = re.compile( + r"trunk up: board=(\S+) region=(\w+) ([0-9.]+) MHz " + r"SF(\d+) BW(\d+) " + r"CR4/(\d+) preamble=(\d+) sync=0x([0-9a-fA-F]+).*" + r"phy_verified=(\d+)" +) DIAG_RE = re.compile( r"tmo=(\d+).*radio_err=(\d+).*busy_tmo=(\d+).*" r"recover=(\d+)/(\d+).*tx_watchdog=(\d+)" @@ -67,6 +72,17 @@ FORWARD_RX_RE = re.compile( r"trunk v\d+ RX packet .*hops=(\d+)/(\d+)" ) +ADAPTIVE_TX_RE = re.compile( + r"trunk accepted .*link=(\d+) chunk=(\d+) rounds=(\d+)" +) +LINK_METRICS_RE = re.compile( + r"link metrics profile=(\d+) rssi_x10=(-?\d+) snr_x10=(-?\d+) " + r"data=(\d+) ack=(\d+) retries_x100=(\d+) latency_ms=(\d+) " + r"samples=(\d+)/(\d+)/(\d+)" +) +AUTHENTICATED_NEIGHBOR_RE = re.compile( + r"\bauthenticated neighbor result=\d+" +) def reader( @@ -76,7 +92,7 @@ def reader( stop: threading.Event, reset: bool, ): - with serial.Serial(port, 115200, timeout=0.2) as stream: + with serial.Serial(port, 115200, timeout=0) as stream: if reset: # Assert EN while keeping GPIO0 deasserted, then release EN. stream.dtr = False @@ -84,10 +100,21 @@ def reader( time.sleep(0.1) stream.rts = False time.sleep(0.1) + pending = bytearray() while not stop.is_set(): - line = stream.readline().decode("utf-8", errors="replace").rstrip() - if line: - output.put((label, line)) + available = stream.in_waiting + if available == 0: + stop.wait(0.02) + continue + pending.extend(stream.read(available)) + while b"\n" in pending: + raw_line, _, remainder = pending.partition(b"\n") + pending = bytearray(remainder) + line = raw_line.decode( + "utf-8", errors="replace" + ).rstrip("\r") + if line: + output.put((label, line)) def main() -> int: @@ -95,7 +122,7 @@ def main() -> int: parser.add_argument( "ports", nargs="+", help="serial ports for two or more nodes" ) - parser.add_argument("--seconds", type=int, default=75) + parser.add_argument("--seconds", type=int, default=60) parser.add_argument("--json-out") parser.add_argument( "--reset", action="store_true", @@ -112,6 +139,7 @@ def main() -> int: threading.Thread( target=reader, args=(f"node-{index + 1}", port, messages, stop, args.reset), + daemon=True, ) for index, port in enumerate(args.ports) ] @@ -125,6 +153,7 @@ def main() -> int: "raw": [], "events": Counter(), "diagnostics": [], + "link_metrics": [], "profile": None, } for index in range(len(args.ports)) @@ -157,9 +186,15 @@ def main() -> int: ) if match := PROFILE_RE.search(line): summary[label]["profile"] = { - "region": match.group(1), - "spreading_factor": int(match.group(2)), - "preamble_symbols": int(match.group(3)), + "board": match.group(1), + "region": match.group(2), + "frequency_mhz": float(match.group(3)), + "spreading_factor": int(match.group(4)), + "bandwidth_hz": int(match.group(5)), + "coding_rate_denominator": int(match.group(6)), + "preamble_symbols": int(match.group(7)), + "sync_word": int(match.group(8), 16), + "phy_verified": bool(int(match.group(9))), } if match := DIAG_RE.search(line): summary[label]["diagnostics"].append( @@ -255,7 +290,7 @@ def main() -> int: summary[label]["diagnostics"].append( forward_diag ) - if "authenticated neighbor" in line: + if AUTHENTICATED_NEIGHBOR_RE.search(line): summary[label]["events"]["authenticated_neighbor"] += 1 if match := CONTROL_RE.search(line): direction = match.group(1).lower() @@ -288,6 +323,31 @@ def main() -> int: summary[label]["events"][ f"rx_hops_{match.group(1)}_of_{match.group(2)}" ] += 1 + if match := ADAPTIVE_TX_RE.search(line): + summary[label]["events"][ + f"adaptive_profile_{match.group(1)}" + ] += 1 + summary[label]["events"][ + f"adaptive_chunk_{match.group(2)}" + ] += 1 + summary[label]["events"][ + f"adaptive_rounds_{match.group(3)}" + ] += 1 + if match := LINK_METRICS_RE.search(line): + summary[label]["link_metrics"].append( + { + "profile": int(match.group(1)), + "rssi_x10": int(match.group(2)), + "snr_x10": int(match.group(3)), + "data_success_pct": int(match.group(4)), + "ack_success_pct": int(match.group(5)), + "retries_x100": int(match.group(6)), + "latency_ms": int(match.group(7)), + "signal_samples": int(match.group(8)), + "data_samples": int(match.group(9)), + "ack_samples": int(match.group(10)), + } + ) if "duplicate network packet" in line: summary[label]["events"][ "network_duplicate_suppressed" @@ -303,7 +363,7 @@ def main() -> int: finally: stop.set() for thread in threads: - thread.join() + thread.join(timeout=1) serializable = { label: { @@ -312,6 +372,7 @@ def main() -> int: "raw": values["raw"], "events": dict(values["events"]), "diagnostics": values["diagnostics"], + "link_metrics": values["link_metrics"], "profile": values["profile"], } for label, values in summary.items() diff --git a/tools/run_lora_host_tests.sh b/tools/run_lora_host_tests.sh index 7126e9e..8d31839 100755 --- a/tools/run_lora_host_tests.sh +++ b/tools/run_lora_host_tests.sh @@ -9,7 +9,8 @@ trunk_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-trunk.XXXXXX")" reliability_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-reliability.XXXXXX")" channel_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-channel.XXXXXX")" forwarding_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-forwarding.XXXXXX")" -trap 'rm -f "$airtime_bin" "$region_bin" "$scheduler_bin" "$trunk_bin" "$reliability_bin" "$channel_bin" "$forwarding_bin"' EXIT +adaptation_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-adaptation.XXXXXX")" +trap 'rm -f "$airtime_bin" "$region_bin" "$scheduler_bin" "$trunk_bin" "$reliability_bin" "$channel_bin" "$forwarding_bin" "$adaptation_bin"' EXIT cc -std=c11 -Wall -Wextra -Werror \ -I"$repo_root/main" \ @@ -64,5 +65,12 @@ cc -std=c11 -Wall -Wextra -Werror \ -o "$forwarding_bin" "$forwarding_bin" +cc -std=c11 -Wall -Wextra -Werror \ + -I"$repo_root/main" \ + "$repo_root/tests/test_lora_link_adaptation.c" \ + "$repo_root/main/lora_link_adaptation.c" \ + -o "$adaptation_bin" +"$adaptation_bin" + cd "$repo_root" python3 -m unittest discover -s tests -p 'test_lora_*.py' -v