diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index 6410ee7..cc2b3b7 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -395,12 +395,12 @@ isReceiving() ``` For SX126x, `hwIsReceiving()` → `sx126x_is_receiving()` reads in this order: -1. **`data->rx_packet_active`** latch (no SPI). Set by the work handler on `HEADER_VALID`; cleared on every terminal event and RX (re)start. Covers the full payload phase. +1. **`data->rx_packet_active`** latch (no SPI). Set by the work handler on `HEADER_VALID`; cleared on every terminal event and RX (re)start. Covers the full payload phase. Bounded by a payload deadline: `header_seen_at_ms` is stamped when the latch is promoted, and once `sx126x_max_payload_ms()` (255-byte airtime at the current SF/BW, CR 4/8, LDRO on, +25% +100 ms) has elapsed the latch is released and the sticky PREAMBLE/SYNC/HEADER bits cleared. Continuous RX has no symbol timer, so without this a `HEADER_VALID` whose packet never completes would hold the TX gate closed until reboot; the DC parked-RX watchdog does not cover it (DC-only, and it treats the latch as a legitimate in-flight packet). 2. **Mutex-busy conservative** — if the SPI mutex is contended and `state == RX`, return true (the work handler is likely mid-`RxDone`). 3. **`HEADER_VALID` raw bit** — covers the microseconds between DIO1 firing and the work handler running. 4. **`PREAMBLE_DETECTED` raw bit with SF-aware grace** — `PREAMBLE_DETECTED` is masked off DIO1 (fires on noise), but visible in the IRQ register. On first observation, `is_receiving` records `data->preamble_seen_at_ms`; subsequent calls return true until either `HEADER_VALID` promotes the latch (timestamp reset) or `(preamble_len + 8) × 2^SF / BW` ms elapses — at which point the bit is explicitly cleared and TX is allowed. Grace scales with SF: ~82 ms at SF8, ~786 ms at SF12. -The poll path is otherwise non-destructive — IRQ bits are cleared only by the work-handler bulk clear (on any DIO1 event), explicit `clear_irq_status(IRQ_ALL)` at every RX (re)start, and the grace-expiry one-bit clear for foreign preambles. +The poll path is otherwise non-destructive — IRQ bits are cleared only by the work-handler bulk clear (on any DIO1 event), explicit `clear_irq_status(IRQ_ALL)` at every RX (re)start, the grace-expiry one-bit clear for foreign preambles, and the payload-deadline clear in step 1. ### 5.2.2 CAD-Timeout Recovery diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index 75f2977..3ffc021 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -65,6 +65,16 @@ All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the > **Password length:** admin and guest passwords are capped at **15 characters** (16-byte storage incl. NUL; same limit as Arduino MeshCore). The login-send path silently truncates anything longer, so a password >15 chars will never authenticate. Applies to `set guest.password` as well. +> **Guest access is off unless a guest password is set.** An empty `guest.password` (the default) disables guest login rather than matching a blank submitted password. To run an open room-server, use `set allow.read.only on` — that grants read-only (`PERM_ACL_GUEST`), not post rights. + +--- + +## Room Server + +| Command | Description | +|---------|-------------| +| `room.post ` | Post a message to the shared room as the server itself (system post). Pushed to clients like any other post. | + --- ## Region Filtering diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 467fb90..8b645fc 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -1486,23 +1486,23 @@ int CompanionMesh::appendSelfTelemetry(uint8_t *reply, uint8_t permissions) } } - // Environment sensors if authorized and available + /* Sensors are read once here. External temp/humidity/pressure require + * TELEM_PERM_ENVIRONMENT, but the MCU die temperature is reported under + * base permission — matching Arduino MeshCore (15e259c5) and ZephCore's + * own repeater/room-server telemetry, neither of which gates it. */ + struct env_data env; + bool env_ok = (env_sensors_read(&env) == 0); + bool temp_reported = false; + if (permissions & TELEM_PERM_ENVIRONMENT) { - struct env_data env; - if (env_sensors_read(&env) == 0) { + if (env_ok) { if (env.has_temperature) { + temp_reported = true; reply[i++] = CH_SELF; reply[i++] = LPP_TEMPERATURE; int16_t temp = (int16_t)(env.temperature_c * 10); reply[i++] = (temp >> 8) & 0xFF; reply[i++] = temp & 0xFF; - } else if (env.has_mcu_temperature) { - // MCU die temp as fallback when no external sensor - reply[i++] = CH_SELF; - reply[i++] = LPP_TEMPERATURE; - int16_t temp = (int16_t)(env.mcu_temperature_c * 10); - reply[i++] = (temp >> 8) & 0xFF; - reply[i++] = temp & 0xFF; } if (env.has_humidity) { reply[i++] = CH_SELF; @@ -1550,6 +1550,16 @@ int CompanionMesh::appendSelfTelemetry(uint8_t *reply, uint8_t permissions) } } + /* MCU die temperature — reported under base permission, but only when no + * external sensor already supplied a CH_SELF temperature (never emit two). */ + if (!temp_reported && env_ok && env.has_mcu_temperature) { + reply[i++] = CH_SELF; + reply[i++] = LPP_TEMPERATURE; + int16_t temp = (int16_t)(env.mcu_temperature_c * 10); + reply[i++] = (temp >> 8) & 0xFF; + reply[i++] = temp & 0xFF; + } + // Trigger GPS wake for fresh fix on next request if (gps_is_available() && gps_is_enabled()) { gps_request_fresh_fix(); diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index ab959c9..cca7cc6 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -161,6 +161,12 @@ uint8_t RepeaterMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t guest_pw, sizeof(received)); + /* An empty stored guest password disables guest access (as + * CONFIG_ZEPHCORE_GUEST_PASSWORD documents) rather than matching an + * empty submitted password and letting anyone in. Both compares above + * still run unconditionally, so timing is unchanged. */ + if (_prefs.guest_password[0] == 0) guest_match = false; + if (admin_match) { perms = PERM_ACL_ADMIN; } else if (guest_match) { diff --git a/zephcore/app/RoomServerMesh.cpp b/zephcore/app/RoomServerMesh.cpp index 33e3ee0..7a8a7cf 100644 --- a/zephcore/app/RoomServerMesh.cpp +++ b/zephcore/app/RoomServerMesh.cpp @@ -234,7 +234,18 @@ mesh::Packet* RoomServerMesh::createSelfAdvert() { /* ---- Room server: shared-post buffer + push-to-client sync ---- */ void RoomServerMesh::addPost(ClientInfo* client, const char* postData) { - posts[next_post_idx].author = client->id; + storePost(client->id, postData); +} + +/* Post authored by the server itself (admin "room.post" command). */ +void RoomServerMesh::addSystemPost(const char* postData) { + if (!postData || postData[0] == 0) return; + + storePost(self_id, postData); +} + +void RoomServerMesh::storePost(const mesh::Identity& author, const char* postData) { + posts[next_post_idx].author = author; strncpy(posts[next_post_idx].text, postData, MAX_POST_TEXT_LEN); posts[next_post_idx].text[MAX_POST_TEXT_LEN] = '\0'; posts[next_post_idx].post_timestamp = getRTCClock()->getCurrentTimeUnique(); @@ -466,6 +477,13 @@ void RoomServerMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, bool admin_match = mesh::Utils::constantTimeEqual(received, admin_pw, sizeof(received)); bool guest_match = mesh::Utils::constantTimeEqual(received, guest_pw, sizeof(received)); + /* An empty stored guest password disables guest access (as + * CONFIG_ZEPHCORE_GUEST_PASSWORD documents) rather than matching an + * empty submitted password and granting read+write to anyone. Both + * compares above still run unconditionally, so timing is unchanged. + * allow_read_only below remains the intended way to run an open room. */ + if (_prefs.guest_password[0] == 0) guest_match = false; + uint8_t perms; if (admin_match) { perms = PERM_ACL_ADMIN; @@ -1033,6 +1051,15 @@ void RoomServerMesh::handleCommand(uint32_t sender_timestamp, char* command, cha reply[0] = 0; } else if (memcmp(command, "region", 6) == 0) { handleRegionCommand(command, reply); + } else if (memcmp(command, "room.post", 9) == 0) { + char* msg = command + 9; + while (*msg == ' ') msg++; + if (*msg == 0) { + strcpy(reply, "Err - empty message"); + } else { + addSystemPost(msg); + strcpy(reply, "OK"); + } } else { _cli.handleCommand(sender_timestamp, command, reply); } diff --git a/zephcore/app/RoomServerMesh.h b/zephcore/app/RoomServerMesh.h index adf256e..131b4ae 100644 --- a/zephcore/app/RoomServerMesh.h +++ b/zephcore/app/RoomServerMesh.h @@ -105,6 +105,7 @@ class RoomServerMesh : public mesh::Mesh, public CommonCLICallbacks { /* Room server: shared-post buffer + push-to-client sync */ void addPost(ClientInfo* client, const char* postData); + void storePost(const mesh::Identity& author, const char* postData); void pushPostToClient(ClientInfo* client, PostInfo& post); uint8_t getUnsyncedCount(ClientInfo* client); bool processAck(const uint8_t* data); @@ -174,6 +175,9 @@ public: void begin(RepeaterDataStore* store); + /* Post authored by the server itself (admin "room.post " command). */ + void addSystemPost(const char* postData); + /* CommonCLICallbacks */ const char* getFirmwareVer() override { return FIRMWARE_VERSION; } const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } diff --git a/zephcore/boards/nrf52840/thinknode_m3/board.conf b/zephcore/boards/nrf52840/thinknode_m3/board.conf index dfde205..248f0bc 100644 --- a/zephcore/boards/nrf52840/thinknode_m3/board.conf +++ b/zephcore/boards/nrf52840/thinknode_m3/board.conf @@ -4,7 +4,7 @@ # Hardware: # - nRF52840 SoC with BLE # - LR1110 LoRa radio (TCXO 3.3V, DIO5/DIO6 RF switch) -# - GPS module on UART0 (9600 baud, 3-pin control) +# - GPS module on UART0 (9600 baud; power + enable control, reset left floating) # - Battery ADC on AIN3 (P0.05), 2:1 voltage divider # - LEDs: GREEN=P1.03, RED=P1.01, POWER=P0.29 # - Button on P0.12 (active-low, pull-up) diff --git a/zephcore/boards/nrf52840/thinknode_m3/board.overlay b/zephcore/boards/nrf52840/thinknode_m3/board.overlay index 87b1e99..0ab498c 100644 --- a/zephcore/boards/nrf52840/thinknode_m3/board.overlay +++ b/zephcore/boards/nrf52840/thinknode_m3/board.overlay @@ -5,7 +5,7 @@ * GPS control pins for the GPS module (generic NMEA at 9600 baud): * - GPS_POWER (P0.14) - Main power enable (always HIGH, gpio-hog) * - GPS_EN (P0.21) - Standby control (HIGH = awake, LOW = standby) - * - GPS_RESET (P0.25) - Reset (GPS_RESET_ACTIVE = LOW, REINIT pin) + * - GPS_RESET (P0.25) - REINIT pin, must stay FLOATING on this unit (see below) * * Power enable pins (always HIGH): * - PIN_PWR_EN (P0.16) - Main power domain @@ -24,20 +24,18 @@ }; }; - /* GPS hardware reset - P0.25 (GPS_RESET, active-LOW) - * Asserted (pin LOW) on standby entry to halt the module cleanly. - * Released (pin HIGH) after GPS_EN on wake with a brief reset pulse. */ - gps_rst: gps-reset { - compatible = "gpio-leds"; - gps_reset_pin: gps_reset { - gpios = <&gpio0 25 GPIO_ACTIVE_LOW>; - label = "GPS Reset"; - }; - }; + /* GPS hardware reset (P0.25, "REINIT") is deliberately NOT declared. + * + * On this unit the pin must be left floating: Arduino MeshCore drove it + * and GPS did not work, and setting GPS_RESET = -1 (never drive it) is + * what fixed NMEA output on real hardware — upstream 3af5ffe1, resolving + * meshcore-dev/MeshCore#1864 and #2879. Without a `gps-reset` alias, + * every reset path in ZephyrGPSManager compiles out + * (#if DT_NODE_EXISTS(DT_ALIAS(gps_reset))), so the pin stays untouched. + * GPS_EN (P0.21) alone handles wake/standby. */ aliases { gps-enable = &gps_enable_pin; - gps-reset = &gps_reset_pin; }; }; diff --git a/zephcore/boards/nrf52840/thinknode_m3/thinknode_m3.dts b/zephcore/boards/nrf52840/thinknode_m3/thinknode_m3.dts index cad1a56..6b8bfec 100644 --- a/zephcore/boards/nrf52840/thinknode_m3/thinknode_m3.dts +++ b/zephcore/boards/nrf52840/thinknode_m3/thinknode_m3.dts @@ -8,7 +8,7 @@ * - nRF52840 SoC with BLE * - LR1110 LoRa radio on SPI1 (TCXO 3.3V, DIO5/DIO6 RF switch) * - GPS module on UART0 (9600 baud, generic NMEA) - * Control: GPS_POWER=P0.14 (always on), GPS_EN=P0.21 (standby), GPS_RESET=P0.25 (active-LOW) + * Control: GPS_POWER=P0.14 (always on), GPS_EN=P0.21 (standby); GPS_RESET=P0.25 unused/floating * - Battery ADC on AIN3 (P0.05), 2:1 voltage divider * - LEDs: GREEN=P1.03, RED=P1.01 (active-low), POWER=P0.29 (always on via overlay) * - Button on P0.12 (active-low, pull-up), single button with multi-tap @@ -178,8 +178,9 @@ * Using gnss-nmea-generic (passive NMEA listener) — Arduino reference code * uses MicroNMEA (generic NMEA parser only, no proprietary commands), so * chip-specific drivers are neither needed nor safe. - * GPS_POWER=P0.14 (always on hog), GPS_EN=P0.21 (wake/standby), - * GPS_RESET=P0.25 (active-LOW, pulsed on wake, asserted during standby). + * GPS_POWER=P0.14 (always on hog), GPS_EN=P0.21 (wake/standby). + * GPS_RESET=P0.25 (REINIT) is intentionally left floating — driving it stops + * this module from producing NMEA (see board.overlay, upstream 3af5ffe1). * * zephyr,deferred-init: GPS needs GPIO power-up (GPS_EN HIGH) before NMEA * output. ZephyrGPSManager handles power-up then calls device_init(). */ diff --git a/zephcore/helpers/AdvertDataHelpers.cpp b/zephcore/helpers/AdvertDataHelpers.cpp index 8c819f3..05ba37d 100644 --- a/zephcore/helpers/AdvertDataHelpers.cpp +++ b/zephcore/helpers/AdvertDataHelpers.cpp @@ -4,6 +4,7 @@ */ #include "AdvertDataHelpers.h" +#include "UTF8Helpers.h" #include uint8_t AdvertDataBuilder::encodeTo(uint8_t app_data[]) @@ -25,10 +26,16 @@ uint8_t AdvertDataBuilder::encodeTo(uint8_t app_data[]) memcpy(&app_data[i], &_extra2, 2); i += 2; } if (_name && *_name != 0) { - app_data[0] |= ADV_NAME_MASK; - const char *sp = _name; - while (*sp && i < MAX_ADVERT_DATA_SIZE) { - app_data[i++] = *sp++; + /* Truncate on a UTF-8 code-point boundary: a byte-wise copy would + * emit a partial multi-byte sequence when a code point straddles + * MAX_ADVERT_DATA_SIZE, producing a mangled name on the wire. + * ADV_NAME_MASK is only set when at least one whole code point + * fits in the remaining space. */ + size_t name_len = mesh::validUtf8PrefixLength(_name, MAX_ADVERT_DATA_SIZE - i); + if (name_len > 0) { + app_data[0] |= ADV_NAME_MASK; + memcpy(&app_data[i], _name, name_len); + i += name_len; } } return i; diff --git a/zephcore/helpers/UTF8Helpers.h b/zephcore/helpers/UTF8Helpers.h new file mode 100644 index 0000000..bfb272c --- /dev/null +++ b/zephcore/helpers/UTF8Helpers.h @@ -0,0 +1,76 @@ +/* + * SPDX-License-Identifier: MIT + * ZephCore UTF8Helpers - UTF-8 aware truncation + * + * Ported from Arduino MeshCore 79dc1de6 ("fix: preserve UTF-8 advert names"). + */ + +#pragma once + +#include +#include + +namespace mesh { + +inline bool isUtf8Continuation(uint8_t byte) +{ + return (byte & 0xC0) == 0x80; +} + +/** + * Length of the longest prefix of `text` that is (a) valid UTF-8 and (b) no + * longer than `max_bytes`. Never splits a multi-byte code point, and rejects + * overlong encodings, surrogates and out-of-range 4-byte sequences. + * + * @returns byte count to copy; 0 if not even one code point fits + */ +inline size_t validUtf8PrefixLength(const char *text, size_t max_bytes) +{ + if (text == nullptr) return 0; + + size_t offset = 0; + while (text[offset] != '\0') { + const uint8_t first = (uint8_t)text[offset]; + size_t sequence_length = 0; + + if (first <= 0x7F) { + sequence_length = 1; + } else if (first >= 0xC2 && first <= 0xDF) { + sequence_length = 2; + } else if (first >= 0xE0 && first <= 0xEF) { + sequence_length = 3; + } else if (first >= 0xF0 && first <= 0xF4) { + sequence_length = 4; + } else { + break; /* continuation byte or overlong 2-byte lead */ + } + + if (offset + sequence_length > max_bytes) break; + + bool complete = true; + for (size_t i = 1; i < sequence_length; i++) { + if (text[offset + i] == '\0' || + !isUtf8Continuation((uint8_t)text[offset + i])) { + complete = false; + break; + } + } + if (!complete) break; + + /* Reject overlong 3-byte forms and UTF-16 surrogates. */ + if (sequence_length == 3) { + const uint8_t second = (uint8_t)text[offset + 1]; + if ((first == 0xE0 && second < 0xA0) || + (first == 0xED && second > 0x9F)) break; + } else if (sequence_length == 4) { + const uint8_t second = (uint8_t)text[offset + 1]; + if ((first == 0xF0 && second < 0x90) || + (first == 0xF4 && second > 0x8F)) break; + } + + offset += sequence_length; + } + return offset; +} + +} // namespace mesh diff --git a/zephcore/patches/zephyr/0013-lora-sx126x-rx-latch-deadline.patch b/zephcore/patches/zephyr/0013-lora-sx126x-rx-latch-deadline.patch new file mode 100644 index 0000000..1deccf4 --- /dev/null +++ b/zephcore/patches/zephyr/0013-lora-sx126x-rx-latch-deadline.patch @@ -0,0 +1,175 @@ +Bound the lifetime of the RX-busy latch (payload-phase deadline). + +sx126x_is_receiving() reports "busy" for the whole payload phase from the +rx_packet_active latch, which patch 0003 sets when HEADER_VALID fires and clears +only on a terminal IRQ (RX_DONE / CRC_ERR / RX_TX_TIMEOUT) or an RX (re)start. + +In continuous RX (SX126X_RX_TIMEOUT_CONTINUOUS) there is no symbol timer, so +none of those events is guaranteed to arrive. A HEADER_VALID whose packet never +completes therefore pins the TX gate true forever: the node keeps receiving but +never transmits again until reboot, with nothing in the logs pointing at it. The +duty-cycle parked-RX watchdog does not cover this case -- it is DC-only and +deliberately treats rx_packet_active as a legitimate in-flight packet. + +Add header_seen_at_ms, stamped when the latch is promoted, and release the latch +(clearing the sticky PREAMBLE_DETECTED / SYNC_WORD_VALID / HEADER_VALID / +HEADER_ERR bits) once sx126x_max_payload_ms() has elapsed. That bound is the +airtime of a 255-byte explicit-header packet at the current SF/BW with CR 4/8 +and LDRO pinned on, plus 25% and 100 ms -- deliberately generous, since firing +early would let TX start on top of a packet that is still arriving. + +Arduino MeshCore added the equivalent deadline in 79ef74ea (SX1262) and +0bd871cd (LR11X0); their preamble-phase timeout is already covered here by the +SF-aware grace in patch 0003. + +Kept separate from 0003 because that patch cannot be regenerated from the tree: +0011 and 0012 also modify this driver and would be folded into it. + +diff --git a/drivers/lora/native/sx126x/sx126x.c b/drivers/lora/native/sx126x/sx126x.c +index 822d0f5..00faca6 100644 +--- a/drivers/lora/native/sx126x/sx126x.c ++++ b/drivers/lora/native/sx126x/sx126x.c +@@ -82,14 +82,15 @@ static uint32_t bandwidth_to_hz(enum lora_signal_bandwidth bw) + } + + /* Reset all software state that indicates "we are currently receiving": +- * the rx_packet_active latch and the preamble-grace timestamp. Paired +- * write so the two fields never drift out of sync. Called from every +- * RX (re)start site, every terminal-event handler (RX_DONE / CRC_ERR / +- * RX_TX_TIMEOUT), and on TX-state entry. */ ++ * the rx_packet_active latch (and its deadline timestamp) and the ++ * preamble-grace timestamp. Paired write so the fields never drift out of ++ * sync. Called from every RX (re)start site, every terminal-event handler ++ * (RX_DONE / CRC_ERR / RX_TX_TIMEOUT), and on TX-state entry. */ + static inline void sx126x_reset_rx_busy_signals(struct sx126x_data *data) + { + data->rx_packet_active = false; + atomic_set(&data->preamble_seen_at_ms, 0); ++ atomic_set(&data->header_seen_at_ms, 0); + } + + /* Grace period for the PREAMBLE_DETECTED -> HEADER_VALID gap, SF/BW-aware. +@@ -113,6 +114,53 @@ static uint32_t sx126x_preamble_grace_ms(struct sx126x_data *data) + return (uint32_t)((us + 999U) / 1000U); + } + ++/* Upper bound on the payload phase: airtime of a maximum-length (255 byte) ++ * explicit-header packet at the current SF/BW, worst-case coding rate 4/8, ++ * plus margin. Used by sx126x_is_receiving() to bound the lifetime of the ++ * rx_packet_active latch. ++ * ++ * Why the latch needs a deadline at all: it is cleared only by a terminal IRQ ++ * (RX_DONE / CRC_ERR / RX_TX_TIMEOUT) or an RX (re)start. In continuous RX ++ * (SX126X_RX_TIMEOUT_CONTINUOUS) there is no symbol timer, so none of those is ++ * guaranteed to arrive -- a HEADER_VALID that never completes would pin the TX ++ * gate true forever and silently mute the node until reboot. (The parked-RX ++ * watchdog cannot cover this: it is duty-cycle only and deliberately treats ++ * rx_packet_active as a legitimate in-flight packet.) Mirrors the payload ++ * deadline Arduino MeshCore added in 79ef74ea / 0bd871cd. ++ * ++ * Deliberately generous: this is a stuck-state safety net, and clearing the ++ * latch early would let TX start on top of a packet that is still arriving. ++ * The low-data-rate-optimisation term is pinned at 1 (rather than read from ++ * the modem config) because DE=1 yields the larger symbol count of the two, ++ * i.e. the safer bound. */ ++static uint32_t sx126x_max_payload_ms(struct sx126x_data *data) ++{ ++ uint8_t sf = (uint8_t)data->config.datarate; ++ uint32_t bw_hz = bandwidth_to_hz(data->config.bandwidth); ++ ++ if (bw_hz == 0 || sf < 5 || sf > 12) { ++ return 30000; /* safe default if config is uninitialised */ ++ } ++ ++ /* Semtech payload-symbol count with PL=255, CRC on, explicit header, ++ * CR = 4/8 (coded_bits = 8), DE = 1: ++ * n = 8 + ceil((8*PL - 4*SF + 28 + 16) / (4*(SF - DE))) * 8 ++ * SF5/SF6 use SF-1 >= 4 so the divisor is always non-zero. */ ++ uint32_t numer = 8U * 255U + 28U + 16U; ++ uint32_t denom = 4U * (uint32_t)(sf - 1U); ++ ++ if (numer > 4U * (uint32_t)sf) { ++ numer -= 4U * (uint32_t)sf; ++ } ++ uint32_t n_sym = 8U + ((numer + denom - 1U) / denom) * 8U; ++ ++ /* n_sym * 2^sf * 1000000 / bw_hz -> us, then +25% and +100 ms margin. */ ++ uint64_t us = ((uint64_t)n_sym << sf) * 1000000ULL / bw_hz; ++ uint32_t ms = (uint32_t)((us + 999U) / 1000U); ++ ++ return ms + (ms / 4U) + 100U; ++} ++ + static bool should_enable_ldro(enum lora_datarate sf, enum lora_signal_bandwidth bw, + const struct sx126x_hal_config *config) + { +@@ -1225,6 +1273,14 @@ static void sx126x_irq_work_handler(struct k_work *work) + * is stale and we don't want a future is_receiving() to treat + * it as live when the latch eventually clears. */ + atomic_set(&data->preamble_seen_at_ms, 0); ++ /* Start the payload deadline (see sx126x_max_payload_ms). Use 1 ++ * as the "set" sentinel if k_uptime is 0 right after boot. */ ++ { ++ uint32_t now = k_uptime_get_32(); ++ ++ atomic_set(&data->header_seen_at_ms, ++ (atomic_val_t)(now == 0 ? 1U : now)); ++ } + } + + /* Re-enable the DIO1 interrupt for the next event (unless sleeping) */ +@@ -1886,9 +1942,33 @@ bool sx126x_is_receiving(const struct device *dev) + /* Primary source of truth: software latch set by the work handler on + * HEADER_VALID, cleared by terminal events (RX_DONE / CRC_ERR / + * RX_TX_TIMEOUT) and RX (re)start sites. Covers the entire payload +- * phase without an SPI access. */ ++ * phase without an SPI access. ++ * ++ * Bounded by a payload deadline: in continuous RX no terminal IRQ is ++ * guaranteed, so a HEADER_VALID that never completes would otherwise pin ++ * this gate true forever and mute TX until reboot. */ + if (data->rx_packet_active) { +- return true; ++ uint32_t seen = (uint32_t)atomic_get(&data->header_seen_at_ms); ++ uint32_t now = k_uptime_get_32(); ++ ++ if (seen == 0 || (now - seen) < sx126x_max_payload_ms(data)) { ++ return true; ++ } ++ ++ if (k_mutex_lock(&data->lock, K_NO_WAIT) != 0) { ++ /* Chip is busy elsewhere; re-check on the next poll. */ ++ return true; ++ } ++ LOG_WRN("RX latch stuck %u ms after HEADER_VALID, releasing TX gate", ++ now - seen); ++ /* Drop the sticky reception bits so the next poll starts clean. */ ++ sx126x_clear_irq_status(dev, SX126X_IRQ_PREAMBLE_DETECTED | ++ SX126X_IRQ_SYNC_WORD_VALID | ++ SX126X_IRQ_HEADER_VALID | ++ SX126X_IRQ_HEADER_ERR); ++ sx126x_reset_rx_busy_signals(data); ++ k_mutex_unlock(&data->lock); ++ return false; + } + + if (k_mutex_lock(&data->lock, K_NO_WAIT) != 0) { +diff --git a/drivers/lora/native/sx126x/sx126x.h b/drivers/lora/native/sx126x/sx126x.h +index 02bbaac..762f8eb 100644 +--- a/drivers/lora/native/sx126x/sx126x.h ++++ b/drivers/lora/native/sx126x/sx126x.h +@@ -98,6 +98,14 @@ struct sx126x_data { + * terminal-event handler, and on TX-state entry. */ + atomic_t preamble_seen_at_ms; + ++ /* Timestamp (k_uptime_get_32() units, ms) at which rx_packet_active was ++ * promoted by HEADER_VALID. Zero means "no payload phase being timed". ++ * Bounds the latch's lifetime: continuous RX has no symbol timer, so a ++ * HEADER_VALID whose packet never completes produces no terminal IRQ and ++ * would keep the TX gate closed forever. sx126x_is_receiving() releases ++ * the latch once sx126x_max_payload_ms() has elapsed. */ ++ atomic_t header_seen_at_ms; ++ + uint32_t dc_rx_time; /* stored duty cycle rx period (15.625us steps) */ + uint32_t dc_sleep_time; /* stored duty cycle sleep period (15.625us steps) */ +