From da55e91ce5e0fa188ff72eb9b973150a7319c509 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:04:02 +0200 Subject: [PATCH] feat(lora): harden trunk reliability --- .gitignore | 1 + docs/LoRa-testing.md | 215 ++ docs/LoRa-trunk-v3.md | 286 +++ main/CMakeLists.txt | 8 + main/Kconfig.projbuild | 72 + main/bitchat_ble.c | 98 +- main/bitle_link.c | 185 +- main/bitle_link.h | 58 +- main/bitle_lora.c | 3016 ++++++++++++++++++++--- main/bitle_lora.h | 67 +- main/bitle_mesh.c | 5 +- main/lora_airtime.c | 73 + main/lora_airtime.h | 43 + main/lora_channel_policy.c | 62 + main/lora_channel_policy.h | 37 + main/lora_forwarding.c | 118 + main/lora_forwarding.h | 61 + main/lora_link_adaptation.c | 344 +++ main/lora_link_adaptation.h | 108 + main/lora_packet_reliability.c | 199 ++ main/lora_packet_reliability.h | 86 + main/lora_region.c | 99 + main/lora_region.h | 55 + main/lora_trunk_protocol.c | 575 +++++ main/lora_trunk_protocol.h | 177 ++ main/lora_tx_scheduler.c | 240 ++ main/lora_tx_scheduler.h | 116 + main/noise_handshake.c | 73 +- main/noise_handshake.h | 6 + main/sx1262.c | 598 +++-- main/sx1262.h | 25 +- tests/test_lora_airtime.c | 50 + tests/test_lora_channel_behavior.py | 99 + tests/test_lora_channel_policy.c | 36 + tests/test_lora_forwarding.c | 65 + tests/test_lora_link_adaptation.c | 188 ++ tests/test_lora_link_adaptation.py | 69 + tests/test_lora_multihop.py | 138 ++ tests/test_lora_packet_reliability.c | 150 ++ tests/test_lora_packet_reliability.py | 97 + tests/test_lora_region.c | 61 + tests/test_lora_reliability_baseline.py | 73 + tests/test_lora_trunk_protocol.c | 399 +++ tests/test_lora_tx_scheduler.c | 218 ++ tools/lora_hardware_smoke.py | 389 +++ tools/lora_reliability_sim.py | 794 ++++++ tools/run_lora_host_tests.sh | 76 + 47 files changed, 9436 insertions(+), 572 deletions(-) create mode 100644 docs/LoRa-testing.md create mode 100644 docs/LoRa-trunk-v3.md create mode 100644 main/Kconfig.projbuild create mode 100644 main/lora_airtime.c create mode 100644 main/lora_airtime.h create mode 100644 main/lora_channel_policy.c create mode 100644 main/lora_channel_policy.h create mode 100644 main/lora_forwarding.c create mode 100644 main/lora_forwarding.h create mode 100644 main/lora_link_adaptation.c create mode 100644 main/lora_link_adaptation.h create mode 100644 main/lora_packet_reliability.c create mode 100644 main/lora_packet_reliability.h create mode 100644 main/lora_region.c create mode 100644 main/lora_region.h create mode 100644 main/lora_trunk_protocol.c create mode 100644 main/lora_trunk_protocol.h create mode 100644 main/lora_tx_scheduler.c create mode 100644 main/lora_tx_scheduler.h create mode 100644 tests/test_lora_airtime.c create mode 100644 tests/test_lora_channel_behavior.py create mode 100644 tests/test_lora_channel_policy.c create mode 100644 tests/test_lora_forwarding.c create mode 100644 tests/test_lora_link_adaptation.c create mode 100644 tests/test_lora_link_adaptation.py create mode 100644 tests/test_lora_multihop.py create mode 100644 tests/test_lora_packet_reliability.c create mode 100644 tests/test_lora_packet_reliability.py create mode 100644 tests/test_lora_region.c create mode 100644 tests/test_lora_reliability_baseline.py create mode 100644 tests/test_lora_trunk_protocol.c create mode 100644 tests/test_lora_tx_scheduler.c 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/.gitignore b/.gitignore index e79ffd2..b347568 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-testing.md b/docs/LoRa-testing.md new file mode 100644 index 0000000..83b37df --- /dev/null +++ b/docs/LoRa-testing.md @@ -0,0 +1,215 @@ +# LoRa reliability testing + +The LoRa reliability work has two test layers: a deterministic host suite and +an opt-in multi-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 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 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 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 +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 +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: + +```bash +source ~/.espressif/python_env/idf6.0_py3.14_env/bin/activate +source ../esp-idf/export.sh +idf.py build +``` + +## 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_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`. 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. + +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. + +## XIAO and link-adaptation hardware smoke test + +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 PORT_C \ + --seconds 120 --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 +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. Run the packet-delivery and +radio-recovery checks on the XIAO ESP32-S3 with Wio-SX1262 hardware; a +build-only result is not a board smoke test. + +Repeat with `CONFIG_BITLE_LORA_DEFAULT_SF` set to 10, 11, and 12 on all +participating nodes. At each SF, verify that the receiver records 136-byte +frames and every node reports 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 the +XIAO ESP32-S3 with Wio-SX1262 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 +`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; +- 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. diff --git a/docs/LoRa-trunk-v3.md b/docs/LoRa-trunk-v3.md new file mode 100644 index 0000000..d938b9c --- /dev/null +++ b/docs/LoRa-trunk-v3.md @@ -0,0 +1,286 @@ +# 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 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 + +| Offset | Size | Field | Definition | +|---:|---:|---|---| +| 0 | 2 | magic | `B7 1E` | +| 2 | 1 | version | `03` | +| 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 | +| 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 | +| 28 | 12 | transfer ID | Reboot-safe identifier described below | +| 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` | + +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. +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. +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 + +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 +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 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 + +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. 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 +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. 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. + +## 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 +destination may ACK it. Broadcast frames never request or produce immediate +ACKs. Consequently four receivers of one broadcast produce zero ACK frames. + +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 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 +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 total matches. + +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, 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. + +## 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 3097755..60c7d7b 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -9,6 +9,14 @@ idf_component_register( "bitle_link.c" "bitle_mesh.c" "bitle_lora.c" + "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" + "lora_tx_scheduler.c" "sx1262.c" "bitle_ota.c" "bitle_store.c" diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild new file mode 100644 index 0000000..0f5445b --- /dev/null +++ b/main/Kconfig.projbuild @@ -0,0 +1,72 @@ +menu "LoRa trunk" + + config BITLE_LORA_DIAGNOSTIC_SMOKE + bool "Transmit LoRa diagnostic smoke packets" + depends on IDF_TARGET_ESP32S3 + default n + help + 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, 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. Suppresses normal + LoRa announcements, permits one compatibility beacon per node, + and limits data to marked diagnostic probes. Packet controls + remain enabled. Enable it on every node in a coding-rate or power + measurement. 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_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 + 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/bitchat_ble.c b/main/bitchat_ble.c index c3119e8..df595db 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 4860691..da01a67 100644 --- a/main/bitle_link.c +++ b/main/bitle_link.c @@ -13,6 +13,10 @@ 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; } link_entry_t; static link_entry_t s_links[BITLE_LINK_MAX]; @@ -36,7 +40,30 @@ 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) +{ + 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) && + !(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 +85,10 @@ 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->relay_fn = relay_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; @@ -81,17 +112,105 @@ bool bitle_link_ready(uint16_t handle) return ready; } -esp_err_t bitle_link_send(uint16_t handle, const uint8_t *data, uint16_t len) +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; +} + +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) @@ -115,11 +234,63 @@ 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 not accepted handle=%u result=%d", + targets[i].handle, result); + } + } + 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, "broadcast send failed handle=%u rc=%d", targets[i].handle, rc); + 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 1689853..e36a45f 100644 --- a/main/bitle_link.h +++ b/main/bitle_link.h @@ -40,21 +40,77 @@ 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_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)( + 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); +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); +/* 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 * 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 3a0c1a9..54349d8 100644 --- a/main/bitle_lora.c +++ b/main/bitle_lora.c @@ -7,13 +7,20 @@ #include "esp_random.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" -#include "freertos/queue.h" #include "freertos/task.h" #include "nvs.h" #include "bitchat_ble.h" #include "bitle_link.h" #include "bitle_mesh.h" +#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" +#include "lora_tx_scheduler.h" #include "noise_handshake.h" #include "packet_codec.h" #include "sx1262.h" @@ -44,6 +51,7 @@ static const char *TAG = "bitle_lora"; /* 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 +#define LORA_BOARD_NAME "xiao-wio-sx1262" #define LORA_PIN_SCK 7 #define LORA_PIN_MISO 8 #define LORA_PIN_MOSI 9 @@ -53,6 +61,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 @@ -63,37 +72,63 @@ static const char *TAG = "bitle_lora"; #define LORA_PIN_RXEN -1 #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) -#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 +#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 +#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 + +/* 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 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 TX_QUEUE_DEPTH 12 -#define CAD_RETRIES 5 -#define CAD_BACKOFF_MIN 30 -#define CAD_BACKOFF_SPAN 120 +#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; @@ -101,24 +136,65 @@ 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; -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; + bool announcement; + bool forwarded; + bool relayed_broadcast; + 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]; + +/* 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; +static lora_forward_cache_t s_forward_cache; +static lora_link_adaptation_t s_link_adaptation; -/* 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; +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. */ 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; static portMUX_TYPE s_gov_mux = portMUX_INITIALIZER_UNLOCKED; static double s_gov_credit_ms; @@ -127,31 +203,101 @@ static uint64_t s_gov_last_ms; typedef struct { bool in_use; + bool pending; uint8_t tag[8]; uint64_t last_ms; } 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) +{ + lora_airtime_params_t params = { + .spreading_factor = s_sf, + .bandwidth_hz = s_bw, + .coding_rate = s_cr, + .preamble_symbols = s_preamble_symbols, + .explicit_header = true, + .crc_enabled = true, + }; + return lora_airtime_ms(¶ms, payload_len); +} + +static bool trunk_reliability_timing( + uint8_t fragment_total, uint16_t fragment_size, + uint8_t maximum_rounds, lora_reliability_timing_t *out) { - 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; + 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 + fragment_size); + lora_contention_window_t contention; + if (!lora_contention_window( + maximum_airtime, CAD_RETRIES, false, &contention)) { + return false; + } + return lora_reliability_timing( + maximum_airtime, + trunk_airtime_ms(LORA_TRUNK_V3_HEADER_LEN), + fragment_total, + maximum_rounds, + contention.maximum_ms, + out); +} + +static void diag_inc(uint32_t *counter) +{ + taskENTER_CRITICAL(&s_gov_mux); + (*counter)++; + taskEXIT_CRITICAL(&s_gov_mux); +} + +/* Caller holds s_gov_mux. */ +static void diag_sync_scheduler_locked(void) +{ + 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]; + } +} + +/* 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; } - double t = tpre + (8.0 + n) * tsym; - return (uint32_t)(t * 1000.0 + 0.5); + 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 (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) { @@ -165,25 +311,96 @@ 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; + s_diag.announce_tx++; + } else if (slot->last_ms == 0) { + memset(slot, 0, sizeof(*slot)); + } +} + +/* 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; } - slot = free_slot ? free_slot : oldest; - slot->in_use = true; - memcpy(slot->tag, sender, 8); - slot->last_ms = 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; } -/* 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) +static bool beacon_budget_admit(uint16_t encoded_len) { - if (len < PKT_MIN_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, bool forwarded, + uint16_t fragment_size) +{ + if (len < PKT_MIN_LEN || + !lora_link_fragment_size_valid(fragment_size)) { return false; } uint8_t type = data[PKT_TYPE_OFF]; @@ -197,6 +414,18 @@ static bool trunk_admit(const uint8_t *data, uint16_t len) * 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; @@ -209,25 +438,20 @@ static bool trunk_admit(const uint8_t *data, uint16_t len) * 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; - airtime += lora_airtime_ms(TRUNK_HDR_LEN + chunk); + 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); } - 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. */ 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 @@ -235,307 +459,1802 @@ static bool trunk_admit(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) { - taskEXIT_CRITICAL(&s_gov_mux); + /* 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; } - if (announce_throttled(data + PKT_SENDER_OFF, now)) { - taskEXIT_CRITICAL(&s_gov_mux); + 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; - } - taskEXIT_CRITICAL(&s_gov_mux); - return true; + return governor_debit_locked(airtime, false); } -/* 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; - uint8_t src[4]; - uint16_t seq; + bool addressed; + 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; + uint16_t fragment_size; 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_MAX]; + 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) +{ + BaseType_t woken = pdFALSE; + vTaskNotifyGiveFromISR((TaskHandle_t)arg, &woken); + portYIELD_FROM_ISR(woken); +} + +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; + } + 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). + * 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. */ +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 */ + if (flags & 0x01) { + real += 8; /* recipient id */ + } + if (flags & 0x02) { + real += 64; /* Ed25519 signature */ + } + 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) { + 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); + 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(); + taskEXIT_CRITICAL(&s_gov_mux); +} + +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 || + token >= LORA_TX_PACKET_POOL_SIZE) { + lora_link_cancel(handle, token); + return BITLE_LINK_SEND_FAILED; + } + len = trunk_true_len(data, len); + if (len < PKT_MIN_LEN) { + lora_link_cancel(handle, token); + return BITLE_LINK_SEND_FAILED; + } + + 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; + 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 || + 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); + 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 (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; + } + + /* 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(); + taskEXIT_CRITICAL(&s_gov_mux); + return BITLE_LINK_SEND_POLICY_DROPPED; + } + 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); + + committed = lora_tx_scheduler_commit( + &s_tx_scheduler, reserved, data, len, type, + (uint16_t)transfer_counter); + if (committed) { + tx_trunk_context_t *context = &s_tx_context[reserved]; + context->valid = true; + context->ack_requested = ack_requested; + context->announcement = announcement; + context->forwarded = forwarded; + context->relayed_broadcast = + !ack_requested && + (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->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 < context->fragment_size + ? len + : context->fragment_size; + 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, + 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)); + 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])); + 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 + 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 " + "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, (unsigned)link_policy.profile, + link_policy.fragment_size, + link_policy.maximum_rounds); + if (s_task) { + xTaskNotifyGive(s_task); + } + 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( + 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 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); + bool dequeued = + lora_tx_scheduler_dequeue(&s_tx_scheduler, out_handle); + diag_sync_scheduler_locked(); + taskEXIT_CRITICAL(&s_gov_mux); + return dequeued; +} + +static void tx_scheduler_release( + lora_tx_handle_t handle, bool transmitted) +{ + 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; + if (context.announcement) { + announce_finish_locked( + 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); + } + } + 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) +{ + 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 || + handle >= LORA_TX_PACKET_POOL_SIZE || + !s_tx_context[handle].valid) { + return false; + } + 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 * context->fragment_size; + uint16_t remaining = packet->len - off; + uint16_t chunk = + remaining < context->fragment_size + ? remaining + : context->fragment_size; + 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, + .hop_limit = context->hop_limit, + .hop_count = context->hop_count, + .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_control_valid = frame->want_ack; + if (s_expected_control_valid) { + s_expected_control_data = trunk; + } + *out_total = total; + return true; +} + +/* 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]; + 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 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 (!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 = 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) { + return false; + } + ack_slot_t *a = &s_ack_outbox[s_ack_head]; + 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->value; + f[15] = a->total; + frame_len = LORA_TRUNK_V2_HEADER_LEN; + } else { + lora_trunk_frame_t control = { + .version = LORA_TRUNK_V3_VERSION, + .kind = a->kind, + .capabilities = LORA_TRUNK_V3_CAP_PROTOCOL, + .fragment_index = a->value, + .fragment_total = a->total, + }; + 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( + &control, 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, frame_len) == ESP_OK) { + diag_inc(&s_diag.tx_attempts); + diag_inc(&s_diag.ack_tx); + 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"); + return false; +} + +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 (frame->version != LORA_TRUNK_V3_VERSION) { + return; + } + bitchat_packet_t packet; + if (!bitchat_packet_decode(packet_data, packet_len, &packet)) { + return; + } + uint8_t signing_key[LORA_TRUNK_SIGNING_KEY_LEN]; + 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, + direct_identity, 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); +} + +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, 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) { + 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, 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); + 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; + } + 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); + } + 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) { + 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, + sizeof(slot->transfer_id)); + 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 || + slot->addressed != addressed_to_us) { + abort_rx_slot(slot, LORA_TRUNK_ABORT_INVALID_LENGTH); + diag_inc(&s_diag.invalid_reassemblies); + 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); + 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 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 + 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; + } + 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, + 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); + } + + 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; + } -static void IRAM_ATTR dio1_isr(void *arg) -{ - BaseType_t woken = pdFALSE; - vTaskNotifyGiveFromISR((TaskHandle_t)arg, &woken); - portYIELD_FROM_ISR(woken); + diag_inc(&s_diag.completed_packets); + 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->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)); } -/* 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. */ -static uint16_t trunk_true_len(const uint8_t *data, uint16_t len) +static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) { - if (len < 14) { - return len; + 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; } - 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 */ - if (flags & 0x01) { - real += 8; /* recipient id */ + 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; } - if (flags & 0x02) { - real += 64; /* Ed25519 signature */ + + 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_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; } - 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) -{ - (void)handle; - if (!s_active || len == 0 || len > BITCHAT_BLE_MAX_PACKET_SIZE) { - return -1; + 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_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) { + 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, + "v3 control RX kind=%u value=0x%02x", + frame.kind, frame.fragment_index); + } + } else { + diag_inc(&s_diag.rejected_acks); + } + } + return; } - /* 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; + + 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; } - 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); - /* 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. */ - taskENTER_CRITICAL(&s_gov_mux); - uint16_t seq = ++s_tx_seq; - taskEXIT_CRITICAL(&s_gov_mux); - 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) { - ESP_LOGW(TAG, "TX queue full; dropping packet seq=%u", seq); - return -1; - } - } - return 0; -} - -/* 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 + reassemble_data(&frame, addressed_to_us, rssi, snr); +} + +/* 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 +#if CONFIG_BITLE_LORA_DIAGNOSTIC_QUIET +#define TRUNK_BEACON_REPETITIONS 1U +#else +#define TRUNK_BEACON_REPETITIONS LORA_CHANNEL_BEACON_REPETITIONS +#endif + typedef struct { - uint8_t dst[4]; - uint16_t seq; - 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; + uint32_t freshness; + uint8_t remaining; + uint64_t next_burst_ms; + uint64_t next_repeat_ms; +} beacon_schedule_t; -static void queue_ack(const uint8_t *dst_tag, uint16_t seq, uint8_t idx, uint8_t total) +static void beacon_schedule_begin( + beacon_schedule_t *schedule, uint64_t now) { - if (s_ack_count >= ACK_OUTBOX) { - ESP_LOGW(TAG, "ack outbox full"); + schedule->next_burst_ms = + now + BEACON_INTERVAL_MS - + (BEACON_INTERVAL_JITTER_MS / 2U) + + (esp_random() % BEACON_INTERVAL_JITTER_MS); + if (schedule->remaining != 0) { 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; - s_ack_count++; + schedule->freshness++; + if (schedule->freshness == 0) { + schedule->freshness = 1; + } + schedule->remaining = TRUNK_BEACON_REPETITIONS; + schedule->next_repeat_ms = + now + (esp_random() % BEACON_START_JITTER_MS); } -static bool transmit_ack_now(void) +static void beacon_schedule_finish_attempt( + beacon_schedule_t *schedule, bool *have_pending, + bool *pending_is_beacon, uint64_t now) { - if (s_ack_count == 0) { + 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; } - 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; - s_ack_head = (s_ack_head + 1) % ACK_OUTBOX; - s_ack_count--; - if (sx1262_transmit(f, TRUNK_HDR_LEN) == ESP_OK) { - ESP_LOGI(TAG, "ack TX seq=%u idx=%u", a->seq, a->idx); - return true; + size_t encoded_len = 0; + if (!lora_trunk_v3_encode( + &beacon, frame->data, sizeof(frame->data), + &encoded_len)) { + return false; } - sx1262_resume_rx(); - return false; + frame->len = (uint16_t)encoded_len; + frame->want_ack = false; + return encoded_len == LORA_TRUNK_V3_HEADER_LEN; } -static void rx_frame(const uint8_t *f, uint16_t len, int16_t rssi, int8_t snr) +static bool schedule_contention_backoff( + const lora_frame_t *frame, uint8_t busy_attempt, + bool relayed_broadcast, uint64_t *deadline) { - 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 */ + lora_contention_window_t window; + if (!frame || !deadline || + !lora_contention_window( + trunk_airtime_ms(frame->len), busy_attempt, + relayed_broadcast, &window)) { + return false; } - if (memcmp(f + 4, s_src_tag, 4) == 0) { - return; /* our own transmission echoed */ + if (!resume_rx_or_recover("contention backoff resume RX")) { + return false; } - uint16_t seq = ((uint16_t)f[12] << 8) | f[13]; - uint8_t idx = f[14], total = f[15]; + *deadline = + esp_timer_get_time() / 1000ULL + + lora_contention_delay(&window, esp_random()); + diag_inc(&s_diag.channel_backoffs); + return true; +} - 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; - ESP_LOGI(TAG, "ack RX seq=%u idx=%u", seq, idx); - } - return; - } +static void drop_tx_packet(lora_tx_handle_t *handle, bool *have_pending) +{ + tx_scheduler_release(*handle, false); + *handle = LORA_TX_HANDLE_INVALID; + *have_pending = false; +} - /* 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); - } +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; +} - 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 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); +} - 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; - } - if (s->in_use && s->seq == seq && memcmp(s->src, f + 4, 4) == 0) { - slot = s; - } else if (!s->in_use && !free_slot) { - free_slot = s; - } - if (s->started_ms < oldest->started_ms) { - oldest = s; - } - } - if (!slot) { - 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->started_ms = now; +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) +{ + (*fragment_idx)++; + if (*fragment_idx >= fragment_total) { + tx_scheduler_release(*handle, true); + *handle = LORA_TX_HANDLE_INVALID; + return false; } - if (slot->total != total) { - return; + uint8_t checked_total = 0; + if (!build_tx_fragment(*handle, *fragment_idx, frame, &checked_total) || + checked_total != fragment_total) { + tx_scheduler_release(*handle, false); + *handle = LORA_TX_HANDLE_INVALID; + return false; } - slot->part_len[idx] = chunk; - memcpy(slot->part[idx], f + TRUNK_HDR_LEN, chunk); - slot->have_mask |= 1u << idx; + return true; +} - uint8_t want = (uint8_t)((1u << total) - 1); - if ((slot->have_mask & want) != want) { - return; +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; } - - static uint8_t packet[BITCHAT_BLE_MAX_PACKET_SIZE]; - uint16_t plen = 0; - for (uint8_t i = 0; i < total; ++i) { - if (plen + slot->part_len[i] > sizeof(packet)) { - slot->in_use = false; - return; - } - memcpy(packet + plen, slot->part[i], slot->part_len[i]); - plen += slot->part_len[i]; + uint8_t checked_total = 0; + if (!build_tx_fragment( + handle, (uint8_t)next, frame, &checked_total) || + checked_total != expected_total) { + return false; } - slot->in_use = false; - - 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); + *out_index = (uint8_t)next; + return true; } -/* 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 - 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; 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 pending_forward_not_before_ms = 0; uint64_t ack_deadline = 0; - uint64_t next_beacon_ms = BEACON_FIRST_MS; + uint64_t transfer_deadline = 0; + lora_reliability_timing_t transfer_timing = {0}; + 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 = +#if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE + 30000ULL; +#else + 60000ULL; +#endif +#if CONFIG_BITLE_LORA_DIAGNOSTIC_SMOKE + 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 = +#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; +#endif while (true) { ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(50)); uint64_t now = esp_timer_get_time() / 1000ULL; - if (now >= next_beacon_ms) { - next_beacon_ms = now + BEACON_INTERVAL_MS; + expire_reassembly(now); + 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"); + } + } + +#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]; + memset( + forwarding_probe, 0xA5, + sizeof(forwarding_probe)); + forwarding_probe[0] = 1; + forwarding_probe[1] = BITCHAT_MSG_MESSAGE; + forwarding_probe[2] = BITLE_ORIGIN_TTL; + memcpy( + forwarding_probe + 3, DIAGNOSTIC_MARKER, + sizeof(DIAGNOSTIC_MARKER)); + 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); + forwarding_probe[PKT_MIN_LEN] = + forwarding_smoke_count; + 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 + 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); + 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); + } + } + } + taskEXIT_CRITICAL(&s_gov_mux); + if (!addressed) { + ESP_LOGI(TAG, + "diagnostic smoke waiting for authenticated neighbor"); + 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; + 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)); + 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 chunk=%u rounds=%u link=%u", + probe_len, + (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( + TAG, + "diagnostic smoke deferred len=%u result=%u", + probe_len, result); + } + next_smoke_ms = now + 100ULL; } } +#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 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 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 beacon=%lu/%lu stale=%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, + (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, + (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, + (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, + (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, + (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, + (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, + (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; + } sx1262_event_t evt; while ((evt = sx1262_poll_event()) != SX1262_EVT_NONE) { @@ -551,131 +2270,500 @@ 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; + if (!resume_rx_or_recover("TX_DONE resume RX")) { + tx_is_ack = false; + if (have_pending) { + 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; + } 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 += lora_airtime_ms(TRUNK_HDR_LEN) + 300; + 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)( + TRUNK_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) { - /* 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; + 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 = false; + have_pending = advance_tx_packet( + &packet_handle, &fragment_idx, fragment_total, + &pending); + cad_tries = 0; } 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 < LORA_CHANNEL_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 = + esp_timer_get_time() / 1000ULL + + sx1262_tx_watchdog_ms( + LORA_TRUNK_V3_HEADER_LEN); } } else if (have_pending) { if (sx1262_transmit(pending.data, pending.len) == ESP_OK) { + diag_inc(&s_diag.tx_attempts); + 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( + &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; - arq_sends++; + tx_is_ack = false; + tx_watchdog_deadline = + esp_timer_get_time() / 1000ULL + + sx1262_tx_watchdog_ms(pending.len); + ack_burst = 0; } else { - have_pending = false; - sx1262_resume_rx(); + 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 <= CAD_RETRIES) { - vTaskDelay(pdMS_TO_TICKS(CAD_BACKOFF_MIN + - (esp_random() % CAD_BACKOFF_SPAN))); - if (sx1262_start_cad() == ESP_OK) { - awaiting_cad = true; + if (have_pending && cad_tries < UINT8_MAX) { + cad_tries++; + } + 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 (schedule_contention_backoff( + &pending, (uint8_t)cad_tries, + pending_relayed_broadcast, + &backoff_deadline)) { + awaiting_backoff = true; + } else { + 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: drop the frame */ - have_pending = false; - sx1262_resume_rx(); + /* Channel persistently busy: reject the complete packet; + * sending only its earlier fragments cannot complete. */ + if (have_pending) { + 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"); } break; 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; - have_pending = false; + tx_watchdog_deadline = 0; + tx_is_ack = false; awaiting_ack = false; - sx1262_resume_rx(); + awaiting_backoff = false; + if (!resume_rx_or_recover("radio TX timeout")) { + if (have_pending) { + 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; + ack_deadline += + transfer_timing.feedback_wait_ms; + } else if (!timed_out_ack && have_pending && + 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 && 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); + } 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"); break; + 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) { + 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; default: break; } } + 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. */ - 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 < LORA_CHANNEL_ACK_BURST_MAX)) { if (transmit_ack_now()) { + ack_burst++; awaiting_tx_done = true; tx_is_ack = true; + tx_watchdog_deadline = + esp_timer_get_time() / 1000ULL + + sx1262_tx_watchdog_ms(LORA_TRUNK_V3_HEADER_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_backoff = false; + awaiting_ack = false; + tx_is_ack = false; + tx_watchdog_deadline = 0; + if (have_pending) { + 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"); } - /* 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]; + /* 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; + awaiting_backoff = false; + s_feedback_seen = false; + 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 (s_ack_seen && s_ack_seq == pseq && s_ack_idx == pidx) { - s_ack_seen = false; - awaiting_ack = false; - have_pending = false; - } else if (now2 >= ack_deadline) { + 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 (arq_sends < ARQ_TRIES) { + if (lora_selective_tx_next_round( + &s_selective_tx, + s_tx_context[packet_handle] + .maximum_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 { - have_pending = false; - } } else { - ESP_LOGW(TAG, "trunk frame lost after %d sends seq=%u idx=%u", - ARQ_TRIES, pseq, pidx); - have_pending = false; + diag_inc(&s_diag.retry_exhaustion); + 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) { + 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; + awaiting_backoff = false; + abort_tx_packet( + &packet_handle, &have_pending, + LORA_TRUNK_ABORT_RETRY_EXHAUSTED); + } + + /* 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 { - have_pending = false; + 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 && - xQueueReceive(s_tx_queue, &pending, 0) == pdTRUE) { - have_pending = true; + 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; + pending_forward_not_before_ms = 0; + s_expected_control_valid = false; + } else { + beacon_schedule.next_repeat_ms = + channel_now + BEACON_REPEAT_MIN_MS + + (esp_random() % BEACON_REPEAT_SPAN_MS); + } + } + + if (!have_pending && + 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; + } + 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 = + s_tx_context[packet_handle] + .forward_not_before_ms; + 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, + 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); + 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; - arq_sends = 0; - s_ack_seen = false; - if (sx1262_start_cad() == ESP_OK) { - awaiting_cad = true; + 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 { - have_pending = false; + 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); + } } } } @@ -686,8 +2774,54 @@ 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); + diag_sync_scheduler_locked(); + *out = s_diag; + taskEXIT_CRITICAL(&s_gov_mux); + 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; +} + +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); + 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)); + 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; + s_ack_head = 0; + s_ack_count = 0; +} + 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, @@ -697,27 +2831,85 @@ 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, + .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"): 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; + bool persisted_profile_rejected = false; 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) { + 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) { + 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 { + persisted_profile_rejected = true; + 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 { + persisted_profile_rejected = true; + 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 { + persisted_profile_rejected = true; + 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 { + 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"); } uint8_t d = 0; if (nvs_get_u8(nvs, "duty_pct", &d) == ESP_OK && d >= 1 && d <= 50) { @@ -732,27 +2924,69 @@ 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 (!sx1262_detect(&cfg)) { ESP_LOGI(TAG, "no SX1262 radio; running BLE-only"); 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_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); + 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; + 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. */ s_sf = cfg.sf; 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; - 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; } @@ -760,13 +2994,35 @@ 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; + 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); - 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, + err = bitle_link_register_reservable_relay( + BITLE_LORA_LINK_HANDLE, BITLE_LINK_LORA, lora_link_send, + 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)); + bitle_lora_shutdown(); + return err; + } + const lora_region_profile_t *active_profile = lora_region_profile(cfg.region); + ESP_LOGI(TAG, + "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.cr, + cfg.preamble_symbols, LORA_PRIVATE_SYNC_WORD, 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 eb7380e..f27af1b 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 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. * @@ -16,7 +17,9 @@ */ #include "esp_err.h" +#include "lora_tx_scheduler.h" #include +#include #ifdef __cplusplus extern "C" { @@ -30,6 +33,66 @@ 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; + 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; + 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 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; + 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; + 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. + * 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/bitle_mesh.c b/main/bitle_mesh.c index 893f151..86a34e9 100644 --- a/main/bitle_mesh.c +++ b/main/bitle_mesh.c @@ -288,7 +288,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) { ESP_LOGI(TAG, "Relayed type=0x%02X ttl=%u to %d link(s)", packet->type, buffer[2], forwarded); } diff --git a/main/lora_airtime.c b/main/lora_airtime.c new file mode 100644 index 0000000..f292c55 --- /dev/null +++ b/main/lora_airtime.c @@ -0,0 +1,73 @@ +#include "lora_airtime.h" + +#include +#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); +} + +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 new file mode 100644 index 0000000..0ae8434 --- /dev/null +++ b/main/lora_airtime.h @@ -0,0 +1,43 @@ +#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); + +/* 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 + +#endif /* LORA_AIRTIME_H */ 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_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_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_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_region.c b/main/lora_region.c new file mode 100644 index 0000000..66ffb94 --- /dev/null +++ b/main/lora_region.c @@ -0,0 +1,99 @@ +#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; +} + +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 new file mode 100644 index 0000000..85c4e5c --- /dev/null +++ b/main/lora_region.h @@ -0,0 +1,55 @@ +#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; + +#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 +} +#endif + +#endif /* LORA_REGION_H */ diff --git a/main/lora_trunk_protocol.c b/main/lora_trunk_protocol.c new file mode 100644 index 0000000..45b6188 --- /dev/null +++ b/main/lora_trunk_protocol.c @@ -0,0 +1,575 @@ +#include "lora_trunk_protocol.h" +#include "lora_channel_policy.h" +#include "lora_forwarding.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 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); + 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)) || + 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->fragment_total == 0 || + frame->fragment_total > LORA_TRUNK_MAX_FRAGMENTS || + frame->hop_limit > LORA_FORWARD_MAX_HOP_LIMIT) { + return false; + } + 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_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 && + 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; +} + +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_BITMAP_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_control_matches( + const lora_trunk_frame_t *data, + const lora_trunk_frame_t *control) +{ + return data && control && + data->version == LORA_TRUNK_V3_VERSION && + control->version == LORA_TRUNK_V3_VERSION && + data->kind == LORA_TRUNK_KIND_DATA && + (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(control->source, data->destination, + LORA_TRUNK_V3_NODE_ID_LEN) == 0 && + memcmp(control->destination, data->source, + LORA_TRUNK_V3_NODE_ID_LEN) == 0 && + memcmp(control->transfer_id, data->transfer_id, + LORA_TRUNK_V3_TRANSFER_ID_LEN) == 0 && + control->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; +} + +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) { + memset(table, 0, sizeof(*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], + const uint8_t reachable_peer[LORA_TRUNK_V3_NODE_ID_LEN], + const uint8_t signing_key[LORA_TRUNK_SIGNING_KEY_LEN], + 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) || + 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; + 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]; + 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; + } + bool same_node = + memcmp(entry->node_id, node_id, + LORA_TRUNK_V3_NODE_ID_LEN) == 0; + 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 (direct_identity && 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) { + same_route = entry; + } + } + if (collision) { + return LORA_TRUNK_NEIGHBOR_COLLISION; + } + if (same_route) { + 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; + same_route->last_seen_ms = now_ms; + return LORA_TRUNK_NEIGHBOR_REFRESHED; + } + + 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 = + 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; + 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); + 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 || + !entry->identity_authenticated || + 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_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) { + 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..6c1a07d --- /dev/null +++ b/main/lora_trunk_protocol.h @@ -0,0 +1,177 @@ +#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_BEACON_REGION_COUNT 2 + +#define LORA_TRUNK_V3_CAP_PROTOCOL 0x0001u + +typedef enum { + LORA_TRUNK_KIND_DATA = 1, + 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 { + 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, +} 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 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( + 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; + 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]; + uint8_t boot_epoch[8]; + uint32_t freshness; + uint8_t region; + 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_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( + 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], + 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( + 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); + +/* 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 +} +#endif + +#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 072f72b..97b28d5 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) @@ -1793,6 +1840,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 @@ -1868,4 +1936,3 @@ static void poll_session_maintenance(void) } } } - diff --git a/main/noise_handshake.h b/main/noise_handshake.h index 38fa9f6..7a06c26 100644 --- a/main/noise_handshake.h +++ b/main/noise_handshake.h @@ -109,6 +109,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/main/sx1262.c b/main/sx1262.c index bac2230..353d8cd 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,217 @@ 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 -/* Private-network sync word, distinct from Meshtastic/public (0x2444). */ -#define SYNC_MSB 0x14 -#define SYNC_LSB 0x24 +#define REG_RX_GAIN 0x08AC +#define REG_OCP 0x08E7 + +#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); } - busy_wait(); - return xfer(buf, NULL, n + 1); + esp_err_t err = busy_wait(); + if (err != ESP_OK) { + return err; + } + 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) +{ + 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) { + 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 +262,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 +280,242 @@ 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; +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; } - if (got != probe) { - return false; + uint8_t actual[4] = {0}; + esp_err_t err = read_reg(address, actual, count); + if (err != ESP_OK) { + return err; } - return true; + 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; } -esp_err_t sx1262_init(const sx1262_config_t *cfg, void (*dio1_notify)(void *arg), void *arg) +#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) { - s_cfg = *cfg; - chip_reset(); + 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)); + } + /* 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)); +} + +static esp_err_t configure_radio(void) +{ + 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); + 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 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); + 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 sync[2] = {SYNC_MSB, SYNC_LSB}; - write_reg(REG_LORA_SYNC_MSB, sync, 2); + 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 +523,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 +555,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 +592,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 +663,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 +672,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 new file mode 100644 index 0000000..5d49b35 --- /dev/null +++ b/tests/test_lora_airtime.c @@ -0,0 +1,50 @@ +#include "lora_airtime.h" + +#include +#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); + + 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_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_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_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_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_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_region.c b/tests/test_lora_region.c new file mode 100644 index 0000000..4b4aa45 --- /dev/null +++ b/tests/test_lora_region.c @@ -0,0 +1,61 @@ +#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); + + 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_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/tests/test_lora_trunk_protocol.c b/tests/test_lora_trunk_protocol.c new file mode 100644 index 0000000..a353796 --- /dev/null +++ b/tests/test_lora_trunk_protocol.c @@ -0,0 +1,399 @@ +#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_control( + const lora_trunk_frame_t *data, lora_trunk_kind_t kind, + uint8_t value) +{ + lora_trunk_frame_t control = { + .version = LORA_TRUNK_V3_VERSION, + .kind = kind, + .capabilities = LORA_TRUNK_V3_CAP_PROTOCOL, + .fragment_index = value, + .fragment_total = data->fragment_total, + }; + 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( + 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); + data.hop_limit = 3; + data.hop_count = 2; + 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); + 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]; + 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)); + 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) +{ + lora_trunk_frame_t data = make_data(SOURCE_A, SOURCE_B, false); + 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_control_matches(&data, &ack)); + ack = make_control(&data, LORA_TRUNK_KIND_BITMAP_ACK, 0x03); + ack.destination[0] ^= 1; + 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_control_matches(&data, &ack)); + ack = make_control(&data, LORA_TRUNK_KIND_BITMAP_ACK, 0x03); + ack.fragment_total ^= 1; + 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) +{ + 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_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, + 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; + 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] = { + 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; + + /* 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, + true, 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_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)); + + assert(lora_trunk_neighbor_learn_authenticated( + &table, SOURCE_A, SOURCE_A, key_b, + true, 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, true, + 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, + true, 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, + true, 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_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(); + puts("lora_trunk_protocol: ok"); + return 0; +} 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 new file mode 100755 index 0000000..9f67692 --- /dev/null +++ b/tools/lora_hardware_smoke.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""Capture concurrent LoRa diagnostics from 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(?: 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+)" +) +FORWARD_SMOKE_RE = re.compile( + r"diagnostic forwarding smoke enqueue len=(\d+) expected_frags=(\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+)" +) +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+)" +) +PROTOCOL_DIAG_RE = re.compile( + 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})" +) +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+)" +) +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+)" +) +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( + label: str, + port: str, + output: queue.Queue[tuple[str, str]], + stop: threading.Event, + reset: bool, +): + with serial.Serial(port, 115200, timeout=0) 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) + pending = bytearray() + while not stop.is_set(): + 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: + parser = argparse.ArgumentParser() + parser.add_argument( + "ports", nargs="+", help="serial ports for two or more nodes" + ) + parser.add_argument("--seconds", type=int, default=60) + parser.add_argument("--json-out") + 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() + threads = [ + threading.Thread( + target=reader, + args=(f"node-{index + 1}", port, messages, stop, args.reset), + daemon=True, + ) + for index, port in enumerate(args.ports) + ] + for thread in threads: + thread.start() + + summary: dict[str, dict[str, object]] = { + f"node-{index + 1}": { + "rx_fragments": Counter(), + "tx_fragments": Counter(), + "raw": [], + "events": Counter(), + "diagnostics": [], + "link_metrics": [], + "profile": None, + } + for index in range(len(args.ports)) + } + deadline = time.monotonic() + args.seconds + try: + while time.monotonic() < deadline: + try: + label, line = messages.get(timeout=0.25) + except queue.Empty: + continue + 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): + 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 FORWARD_SMOKE_RE.search(line): + summary[label]["events"]["smoke_forwarding_broadcast"] += 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)), + } + ) + if match := PROFILE_RE.search(line): + summary[label]["profile"] = { + "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( + { + "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 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 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 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 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 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_RE.search(line): + summary[label]["events"]["authenticated_neighbor"] += 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 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 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 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" + ] += 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: + 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: + thread.join(timeout=1) + + serializable = { + label: { + "rx_fragments": dict(values["rx_fragments"]), + "tx_fragments": dict(values["tx_fragments"]), + "raw": values["raw"], + "events": dict(values["events"]), + "diagnostics": values["diagnostics"], + "link_metrics": values["link_metrics"], + "profile": values["profile"], + } + 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..1e58164 --- /dev/null +++ b/tools/lora_reliability_sim.py @@ -0,0 +1,794 @@ +#!/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 heapq +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 + + +@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 + + +@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 + ) + + +@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, + 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 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]]: + 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 new file mode 100755 index 0000000..8d31839 --- /dev/null +++ b/tools/run_lora_host_tests.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +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")" +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")" +forwarding_bin="$(mktemp "${TMPDIR:-/tmp}/bitle-lora-forwarding.XXXXXX")" +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" \ + "$repo_root/main/lora_airtime.c" \ + "$repo_root/tests/test_lora_airtime.c" \ + -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" + +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" + +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" \ + "$repo_root/main/lora_forwarding.c" \ + -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" + +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" + +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" + +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