diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index 34ada2a..d32837a 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -216,7 +216,7 @@ loop(): - Flood packets: compute RX delay based on score → defer or process immediately - Direct packets: process immediately 4. checkSend(): Check outbound queue - - CAD: if channel busy, retry with random backoff (120-480ms) + - CAD: if channel busy, retry every 100-200ms (jittered) up to 4s total - Duty cycle: if exceeded, defer 5 seconds (admin packets exempt) - Final LBT check right before TX (closes timing gap) - Serialize and transmit @@ -313,10 +313,9 @@ Compile-time selection via `CONFIG_ZEPHCORE_RADIO_LR1110` in `RadioIncludes.h`. ### 5.3 Noise Floor EMA Algorithm in `triggerNoiseFloorCalibrate()`: -- Random 0-500ms jitter to break phase-lock with interference -- 4 RSSI samples per tick, take minimum +- 8 RSSI samples per tick, take median (insertion-sort midpoint) - Threshold filter: reject samples ≥ floor + 14dB (after 8-tick warmup) -- Periodic bypass: every 8th tick accepts unconditionally +- Periodic bypass: every 16th tick accepts unconditionally - EMA: `floor += round_nearest((sample - floor) / 8)`, clamped to [-120, -50] dBm ### 5.4 LR1110 Driver Errata Workarounds @@ -405,7 +404,7 @@ Power: `powersaving on/off` - **Internal**: LittleFS on flash (`/lfs`), 256-byte cache for reduced flash I/O - **External**: Optional LittleFS on QSPI (`/ext`) with auto-migration - **BLE bonds**: File-based settings on LittleFS (`/lfs/settings/`) — all platforms (no NVS) -- **Prefs**: 93-byte binary format, Arduino-compatible, field-by-field I/O +- **Prefs**: 292-byte binary format, Arduino-compatible, field-by-field I/O (see §13) - **Contacts**: 152-byte records, stored on external flash if available - **Channels**: 68-byte records (4 pad + 32 name + 32 secret) - **Blobs**: Fixed-size records with LRU eviction by timestamp @@ -688,11 +687,10 @@ Dispatcher::checkRecv() → Mesh::onRecvPacket() main event loop (every 5s) → Dispatcher::maintenanceLoop() → radio->triggerNoiseFloorCalibrate(threshold) → guards: in RX? TX active? duty cycle? mid-receive? - → random 0-500ms jitter - → read 4 RSSI samples, take min + → read 8 RSSI samples, take median → first sample: seed directly → warmup (<8 ticks): accept unconditionally - → periodic bypass (every 8th): accept unconditionally + → periodic bypass (every 16th): accept unconditionally → otherwise: reject if sample ≥ floor + 14dB → EMA: floor += round((sample - floor) / 8) → clamp [-120, -50] dBm diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 127b708..5ad4684 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -878,13 +878,12 @@ RepeaterMesh::RepeaterMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Mil } void RepeaterMesh::begin(RepeaterDataStore* store) { - mesh::Mesh::begin(); _store = store; - /* Load persisted data. - * NOTE: Identity is loaded in main_repeater.cpp before begin() is called, - * so we skip loading it here (self_id should already be set). */ - _store->loadPrefs(_prefs); + /* Prefs and identity are loaded by the caller (main_repeater.cpp) before + * begin() — the radio reads freq/bw/sf/cr through _prefs during + * Mesh::begin() → Dispatcher::begin() → Radio::begin(). */ + mesh::Mesh::begin(); _contention.setBackoffMultiplier(_prefs.backoff_multiplier); #ifdef CONFIG_ZEPHCORE_APC _power_ctrl.setSF(_prefs.sf); diff --git a/zephcore/src/Dispatcher.cpp b/zephcore/src/Dispatcher.cpp index 533353c..9cfebfc 100644 --- a/zephcore/src/Dispatcher.cpp +++ b/zephcore/src/Dispatcher.cpp @@ -1,440 +1,444 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * ZephCore Dispatcher implementation - */ - -#include -#include -#include -#include - -#include -LOG_MODULE_REGISTER(zephcore_dispatcher, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); - -#if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING) -#define PAYLOAD_TYPE_REQ 0x00 -#define PAYLOAD_TYPE_RESPONSE 0x01 -#define PAYLOAD_TYPE_TXT_MSG 0x02 -#define PAYLOAD_TYPE_PATH 0x08 -#endif - -namespace mesh { - -#define MAX_RX_DELAY_MILLIS 32000 /* upper bound for score-based RX delay */ - -Dispatcher::Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr) - : _radio(&radio), _ms(&ms), _mgr(&mgr) -{ - outbound = nullptr; - total_air_time = rx_air_time = 0; - next_tx_time = 0; - cad_busy_start = 0; - next_agc_reset_time = 0; - _err_flags = 0; - _duty_cycle.init(0); - radio_nonrx_start = 0; - prev_isrecv_mode = true; - n_sent_flood = n_sent_direct = 0; - n_recv_flood = n_recv_direct = 0; - _tx_queued_cb = nullptr; - _tx_queued_user_data = nullptr; -} - -void Dispatcher::begin() -{ - n_sent_flood = n_sent_direct = 0; - n_recv_flood = n_recv_direct = 0; - _err_flags = 0; - radio_nonrx_start = (uint32_t)_ms->getMillis(); - _radio->begin(); - prev_isrecv_mode = _radio->isInRecvMode(); - _duty_cycle.init(getDutyCyclePercent()); -} - -uint8_t Dispatcher::getDutyCyclePercent() const -{ - return 10; /* EU 868 default: 10% duty cycle */ -} - -bool Dispatcher::isAdminPacket(const Packet *pkt) -{ - uint8_t t = pkt->getPayloadType(); - return t == PAYLOAD_TYPE_REQ || t == PAYLOAD_TYPE_RESPONSE || - t == PAYLOAD_TYPE_ANON_REQ || t == PAYLOAD_TYPE_CONTROL; -} - -int Dispatcher::calcRxDelay(float score, uint32_t air_time) const -{ - /* LUT: 10^(0.85 - i*0.1) - 1, i=0..10; replaces powf() (~1.9KB saved) */ - static const float lut[11] = { - 6.0793f, 4.6236f, 3.4674f, 2.5489f, 1.8184f, 1.2389f, - 0.7783f, 0.4125f, 0.1220f, -0.1089f, -0.2921f - }; - if (score <= 0.0f) return (int)(lut[0] * (float)air_time); - if (score >= 1.0f) return (int)(lut[10] * (float)air_time); - float idx = score * 10.0f; - int i = (int)idx; - float frac = idx - (float)i; - float val = lut[i] + frac * (lut[i + 1] - lut[i]); - return (int)(val * (float)air_time); -} - -uint32_t Dispatcher::getCADFailRetryDelay() const -{ - return 200; /* ms between CAD retries; ~2 LoRa symbol periods at SF8/62.5k */ -} - -uint32_t Dispatcher::getCADFailMaxDuration() const -{ - return 4000; /* ms; ~20 retry attempts before giving up */ -} - -void Dispatcher::loop() -{ - if (outbound) { - if (_radio->isSendComplete()) { - uint32_t t = (uint32_t)_ms->getMillis() - outbound_start; - total_air_time += t; - _duty_cycle.recordTx(t, (uint32_t)_ms->getMillis()); - _radio->onSendFinished(); - logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); - if (outbound->isRouteFlood()) { - n_sent_flood++; - } else { - n_sent_direct++; - } - releasePacket(outbound); - outbound = nullptr; - } else if (millisHasNowPassed(outbound_expiry)) { - _radio->onSendFinished(); - logTxFail(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); - releasePacket(outbound); - outbound = nullptr; - } else { - return; - } - next_agc_reset_time = futureMillis(getAGCResetInterval()); - } - - { - Packet *pkt = _mgr->getNextInbound((uint32_t)_ms->getMillis()); - if (pkt) { - processRecvPacket(pkt); - } - } - checkRecv(); - checkSend(); -} - -void Dispatcher::maintenanceLoop() -{ - _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); - - /* RX mode watchdog: TX counts as "active" to avoid false triggers - * when the 5s housekeeping timer misses brief RX windows. */ - bool is_active = _radio->isInRecvMode() || !_radio->isSendComplete(); - if (is_active != prev_isrecv_mode) { - prev_isrecv_mode = is_active; - if (!is_active) { - radio_nonrx_start = (uint32_t)_ms->getMillis(); - } - } - if (!is_active && (uint32_t)_ms->getMillis() - radio_nonrx_start > 8000) { /* 8s stall threshold */ - _err_flags |= ERR_EVENT_STARTRX_TIMEOUT; - } - - /* Periodic AGC recalibration */ - if (getAGCResetInterval() > 0 && millisHasNowPassed(next_agc_reset_time)) { - _radio->resetAGC(); - next_agc_reset_time = futureMillis(getAGCResetInterval()); - } -} - -bool Dispatcher::tryParsePacket(Packet *pkt, const uint8_t *raw, int len) -{ - int i = 0; - - pkt->header = raw[i++]; - if (pkt->getPayloadVer() > PAYLOAD_VER_1) { - LOG_WRN("tryParsePacket: unsupported packet version"); - return false; - } - - if (pkt->hasTransportCodes()) { - memcpy(&pkt->transport_codes[0], &raw[i], 2); i += 2; - memcpy(&pkt->transport_codes[1], &raw[i], 2); i += 2; - } else { - pkt->transport_codes[0] = pkt->transport_codes[1] = 0; - } - - pkt->path_len = raw[i++]; - uint8_t path_mode = pkt->path_len >> 6; - if (path_mode == 3) { /* reserved path mode */ - LOG_WRN("tryParsePacket: unsupported path mode: 3"); - return false; - } - - uint8_t path_byte_len = (pkt->path_len & 63) * pkt->getPathHashSize(); - if (path_byte_len > MAX_PATH_SIZE || i + path_byte_len > len) { - LOG_WRN("tryParsePacket: partial or corrupt packet, len=%d", len); - return false; - } - - memcpy(pkt->path, &raw[i], path_byte_len); i += path_byte_len; - - pkt->payload_len = len - i; - if (pkt->payload_len > (int)sizeof(pkt->payload)) { - LOG_WRN("tryParsePacket: payload too big, payload_len=%d", (uint32_t)pkt->payload_len); - return false; - } - - memcpy(pkt->payload, &raw[i], pkt->payload_len); - return true; -} - -void Dispatcher::checkRecv() -{ - /* k_event is a bitfield — multiple ISR arrivals coalesce into one - * wake, so drain the entire ring each time. */ - for (;;) { - uint8_t raw[MAX_TRANS_UNIT + 1]; - int len = _radio->recvRaw(raw, MAX_TRANS_UNIT); - if (len <= 0) { - break; - } - - logRxRaw(_radio->getLastSNR(), _radio->getLastRSSI(), raw, len); - - Packet *pkt = _mgr->allocNew(); - if (pkt == nullptr) { - LOG_ERR("checkRecv: packet alloc failed"); - break; - } - - float score = 0.0f; - uint32_t air_time = 0; - - if (tryParsePacket(pkt, raw, len)) { - pkt->_snr = (int8_t)(_radio->getLastSNR() * 4.0f); /* x4 fixed-point SNR */ - score = _radio->packetScore(_radio->getLastSNR(), len); - air_time = _radio->getEstAirtimeFor(len); - rx_air_time += air_time; - } else { - _mgr->free(pkt); - continue; - } - -#if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING) - /* Arduino-compatible packet logging - use printk to bypass log level filtering */ - { - static uint8_t packet_hash[MAX_HASH_SIZE]; - static char hash_hex[MAX_HASH_SIZE * 2 + 1]; - pkt->calculatePacketHash(packet_hash); - Utils::toHex(hash_hex, packet_hash, MAX_HASH_SIZE); - - uint8_t ptype = pkt->getPayloadType(); - if (ptype == PAYLOAD_TYPE_PATH || ptype == PAYLOAD_TYPE_REQ || - ptype == PAYLOAD_TYPE_RESPONSE || ptype == PAYLOAD_TYPE_TXT_MSG) { - printk("%s: RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d time=%u hash=%s [%02X -> %02X]\n", - getLogDateTime(), pkt->getRawLength(), ptype, - pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, - (int)pkt->getSNR(), (int)_radio->getLastRSSI(), - (int)(score * 1000), air_time, hash_hex, - (uint32_t)pkt->payload[1], (uint32_t)pkt->payload[0]); - } else { - printk("%s: RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d time=%u hash=%s\n", - getLogDateTime(), pkt->getRawLength(), ptype, - pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, - (int)pkt->getSNR(), (int)_radio->getLastRSSI(), - (int)(score * 1000), air_time, hash_hex); - } - } -#endif - logRx(pkt, pkt->getRawLength(), score); - if (pkt->isRouteFlood()) { - n_recv_flood++; - processRecvPacket(pkt); - } else { - n_recv_direct++; - processRecvPacket(pkt); - } - } -} - -void Dispatcher::processRecvPacket(Packet *pkt) -{ - DispatcherAction action = onRecvPacket(pkt); - if (action == ACTION_RELEASE) { - _mgr->free(pkt); - } else if (action == ACTION_MANUAL_HOLD) { - /* subclass holds packet */ - } else { - uint8_t priority = (uint8_t)((action >> 24) - 1); - uint32_t delay = action & 0xFFFFFF; - _mgr->queueOutbound(pkt, priority, futureMillis((int)delay)); - if (_tx_queued_cb && delay > 0) { - _tx_queued_cb(delay, _tx_queued_user_data); - } - } -} - -void Dispatcher::checkSend() -{ - uint32_t now = (uint32_t)_ms->getMillis(); - int count = _mgr->getOutboundCount(now); - if (count == 0) { - cad_busy_start = 0; - return; - } - - if (_radio->isReceiving()) { - /* Channel busy — enforce retry timer so we don't hammer the check */ - if (!millisHasNowPassed(next_tx_time)) { - if (_tx_queued_cb) { - uint32_t remaining = next_tx_time - now; - _tx_queued_cb(remaining + 1, _tx_queued_user_data); - } - return; - } - if (cad_busy_start == 0) { - cad_busy_start = now; - } - if (now - cad_busy_start > getCADFailMaxDuration()) { - _err_flags |= ERR_EVENT_CAD_TIMEOUT; - LOG_ERR("checkSend: CAD timeout exceeded"); - } else { - uint32_t retry = getCADFailRetryDelay(); - next_tx_time = futureMillis((int)retry); - if (_tx_queued_cb) { - _tx_queued_cb(retry + 1, _tx_queued_user_data); - } - return; - } - } - cad_busy_start = 0; - - outbound = _mgr->getNextOutbound(now); - if (outbound) { - /* Duty cycle enforcement — exempt admin packets */ - if (!isAdminPacket(outbound) && _duty_cycle.isExceeded(now)) { - LOG_WRN("checkSend: duty cycle exceeded (%u/%u ms), re-queuing type=%d", - _duty_cycle.window_airtime_ms, _duty_cycle.budgetMs(), - outbound->getPayloadType()); - _mgr->queueOutbound(outbound, 0, futureMillis(5000)); - outbound = nullptr; - if (_tx_queued_cb) { - _tx_queued_cb(5000, _tx_queued_user_data); - } - return; - } - uint8_t raw[MAX_TRANS_UNIT]; - int len = 0; - raw[len++] = outbound->header; - if (outbound->hasTransportCodes()) { - memcpy(&raw[len], &outbound->transport_codes[0], 2); len += 2; - memcpy(&raw[len], &outbound->transport_codes[1], 2); len += 2; - } - raw[len++] = outbound->path_len; - len += Packet::writePath(&raw[len], outbound->path, outbound->path_len); - - if (len + outbound->payload_len > MAX_TRANS_UNIT) { - LOG_ERR("checkSend: packet too large len=%d+%d > %d", len, outbound->payload_len, MAX_TRANS_UNIT); - _mgr->free(outbound); - outbound = nullptr; - } else { - memcpy(&raw[len], outbound->payload, outbound->payload_len); - len += outbound->payload_len; - - uint32_t max_airtime = _radio->getEstAirtimeFor(len) * 3 / 2; - outbound_start = now; - -#if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING) - /* Arduino-compatible packet logging - use printk to bypass log level filtering */ - { - uint8_t ptype = outbound->getPayloadType(); - if (ptype == PAYLOAD_TYPE_PATH || ptype == PAYLOAD_TYPE_REQ || - ptype == PAYLOAD_TYPE_RESPONSE || ptype == PAYLOAD_TYPE_TXT_MSG) { - printk("%s: TX, len=%d (type=%d, route=%s, payload_len=%d) [%02X -> %02X]\n", - getLogDateTime(), len, ptype, - outbound->isRouteDirect() ? "D" : "F", outbound->payload_len, - (uint32_t)outbound->payload[1], (uint32_t)outbound->payload[0]); - } else { - printk("%s: TX, len=%d (type=%d, route=%s, payload_len=%d)\n", - getLogDateTime(), len, ptype, - outbound->isRouteDirect() ? "D" : "F", outbound->payload_len); - } - } -#endif - - /* Final LBT check — close the gap between initial - * isReceiving() and actual TX start (serialisation + - * logging can take 1-5 ms). */ - if (_radio->isReceiving()) { - uint32_t retry = getCADFailRetryDelay(); - _mgr->queueOutbound(outbound, 0, futureMillis((int)retry)); - outbound = nullptr; - if (_tx_queued_cb) { - _tx_queued_cb(retry, _tx_queued_user_data); - } - return; - } - - bool success = _radio->startSendRaw(raw, len); - if (!success) { - uint32_t retry = getCADFailRetryDelay(); - LOG_ERR("checkSend: startSendRaw failed! re-queuing delay=%u", retry); - logTxFail(outbound, outbound->getRawLength()); - _mgr->queueOutbound(outbound, 0, futureMillis((int)retry)); - outbound = nullptr; - if (_tx_queued_cb) { - _tx_queued_cb(retry, _tx_queued_user_data); - } - } else { - outbound_expiry = futureMillis((int)max_airtime); - } - } - } -} - -Packet *Dispatcher::obtainNewPacket() -{ - Packet *pkt = _mgr->allocNew(); - if (pkt == nullptr) { - _err_flags |= ERR_EVENT_FULL; - } else { - pkt->payload_len = pkt->path_len = 0; - pkt->_snr = 0; - } - return pkt; -} - -void Dispatcher::releasePacket(Packet *packet) -{ - _mgr->free(packet); -} - -void Dispatcher::sendPacket(Packet *packet, uint8_t priority, uint32_t delay_millis) -{ - if (!Packet::isValidPathLen(packet->path_len) || packet->payload_len > MAX_PACKET_PAYLOAD) { - LOG_ERR("sendPacket: rejected - path_len=%d or payload_len=%d invalid", - packet->path_len, packet->payload_len); - _mgr->free(packet); - } else { - _mgr->queueOutbound(packet, priority, futureMillis((int)delay_millis)); - if (_tx_queued_cb && delay_millis > 0) { - _tx_queued_cb(delay_millis, _tx_queued_user_data); - } - } -} - -bool Dispatcher::millisHasNowPassed(uint32_t timestamp) const -{ - return (int32_t)((uint32_t)_ms->getMillis() - timestamp) > 0; -} - -uint32_t Dispatcher::futureMillis(int millis_from_now) const -{ - return (uint32_t)_ms->getMillis() + millis_from_now; -} - -} /* namespace mesh */ +/* + * SPDX-License-Identifier: Apache-2.0 + * ZephCore Dispatcher implementation + */ + +#include +#include +#include +#include + +#include +#include +LOG_MODULE_REGISTER(zephcore_dispatcher, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); + +#if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING) +#define PAYLOAD_TYPE_REQ 0x00 +#define PAYLOAD_TYPE_RESPONSE 0x01 +#define PAYLOAD_TYPE_TXT_MSG 0x02 +#define PAYLOAD_TYPE_PATH 0x08 +#endif + +namespace mesh { + +#define MAX_RX_DELAY_MILLIS 32000 /* upper bound for score-based RX delay */ + +Dispatcher::Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr) + : _radio(&radio), _ms(&ms), _mgr(&mgr) +{ + outbound = nullptr; + total_air_time = rx_air_time = 0; + next_tx_time = 0; + cad_busy_start = 0; + next_agc_reset_time = 0; + _err_flags = 0; + _duty_cycle.init(0); + radio_nonrx_start = 0; + prev_isrecv_mode = true; + n_sent_flood = n_sent_direct = 0; + n_recv_flood = n_recv_direct = 0; + _tx_queued_cb = nullptr; + _tx_queued_user_data = nullptr; +} + +void Dispatcher::begin() +{ + n_sent_flood = n_sent_direct = 0; + n_recv_flood = n_recv_direct = 0; + _err_flags = 0; + radio_nonrx_start = (uint32_t)_ms->getMillis(); + _radio->begin(); + prev_isrecv_mode = _radio->isInRecvMode(); + _duty_cycle.init(getDutyCyclePercent()); +} + +uint8_t Dispatcher::getDutyCyclePercent() const +{ + return 10; /* EU 868 default: 10% duty cycle */ +} + +bool Dispatcher::isAdminPacket(const Packet *pkt) +{ + uint8_t t = pkt->getPayloadType(); + return t == PAYLOAD_TYPE_REQ || t == PAYLOAD_TYPE_RESPONSE || + t == PAYLOAD_TYPE_ANON_REQ || t == PAYLOAD_TYPE_CONTROL; +} + +int Dispatcher::calcRxDelay(float score, uint32_t air_time) const +{ + /* LUT: 10^(0.85 - i*0.1) - 1, i=0..10; replaces powf() (~1.9KB saved) */ + static const float lut[11] = { + 6.0793f, 4.6236f, 3.4674f, 2.5489f, 1.8184f, 1.2389f, + 0.7783f, 0.4125f, 0.1220f, -0.1089f, -0.2921f + }; + if (score <= 0.0f) return (int)(lut[0] * (float)air_time); + if (score >= 1.0f) return (int)(lut[10] * (float)air_time); + float idx = score * 10.0f; + int i = (int)idx; + float frac = idx - (float)i; + float val = lut[i] + frac * (lut[i + 1] - lut[i]); + return (int)(val * (float)air_time); +} + +uint32_t Dispatcher::getCADFailRetryDelay() const +{ + /* 100-200ms jittered retry: tighter than one SF8 flood airtime so we + * sample multiple RX duty-cycle windows, and randomized so two nodes + * contending on the same channel don't retry in lockstep. */ + return 100 + (sys_rand32_get() % 101); +} + +uint32_t Dispatcher::getCADFailMaxDuration() const +{ + return 4000; /* ms; ~20 retry attempts before giving up */ +} + +void Dispatcher::loop() +{ + if (outbound) { + if (_radio->isSendComplete()) { + uint32_t t = (uint32_t)_ms->getMillis() - outbound_start; + total_air_time += t; + _duty_cycle.recordTx(t, (uint32_t)_ms->getMillis()); + _radio->onSendFinished(); + logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); + if (outbound->isRouteFlood()) { + n_sent_flood++; + } else { + n_sent_direct++; + } + releasePacket(outbound); + outbound = nullptr; + } else if (millisHasNowPassed(outbound_expiry)) { + _radio->onSendFinished(); + logTxFail(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); + releasePacket(outbound); + outbound = nullptr; + } else { + return; + } + next_agc_reset_time = futureMillis(getAGCResetInterval()); + } + + { + Packet *pkt = _mgr->getNextInbound((uint32_t)_ms->getMillis()); + if (pkt) { + processRecvPacket(pkt); + } + } + checkRecv(); + checkSend(); +} + +void Dispatcher::maintenanceLoop() +{ + _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); + + /* RX mode watchdog: TX counts as "active" to avoid false triggers + * when the 5s housekeeping timer misses brief RX windows. */ + bool is_active = _radio->isInRecvMode() || !_radio->isSendComplete(); + if (is_active != prev_isrecv_mode) { + prev_isrecv_mode = is_active; + if (!is_active) { + radio_nonrx_start = (uint32_t)_ms->getMillis(); + } + } + if (!is_active && (uint32_t)_ms->getMillis() - radio_nonrx_start > 8000) { /* 8s stall threshold */ + _err_flags |= ERR_EVENT_STARTRX_TIMEOUT; + } + + /* Periodic AGC recalibration */ + if (getAGCResetInterval() > 0 && millisHasNowPassed(next_agc_reset_time)) { + _radio->resetAGC(); + next_agc_reset_time = futureMillis(getAGCResetInterval()); + } +} + +bool Dispatcher::tryParsePacket(Packet *pkt, const uint8_t *raw, int len) +{ + int i = 0; + + pkt->header = raw[i++]; + if (pkt->getPayloadVer() > PAYLOAD_VER_1) { + LOG_WRN("tryParsePacket: unsupported packet version"); + return false; + } + + if (pkt->hasTransportCodes()) { + memcpy(&pkt->transport_codes[0], &raw[i], 2); i += 2; + memcpy(&pkt->transport_codes[1], &raw[i], 2); i += 2; + } else { + pkt->transport_codes[0] = pkt->transport_codes[1] = 0; + } + + pkt->path_len = raw[i++]; + uint8_t path_mode = pkt->path_len >> 6; + if (path_mode == 3) { /* reserved path mode */ + LOG_WRN("tryParsePacket: unsupported path mode: 3"); + return false; + } + + uint8_t path_byte_len = (pkt->path_len & 63) * pkt->getPathHashSize(); + if (path_byte_len > MAX_PATH_SIZE || i + path_byte_len > len) { + LOG_WRN("tryParsePacket: partial or corrupt packet, len=%d", len); + return false; + } + + memcpy(pkt->path, &raw[i], path_byte_len); i += path_byte_len; + + pkt->payload_len = len - i; + if (pkt->payload_len > (int)sizeof(pkt->payload)) { + LOG_WRN("tryParsePacket: payload too big, payload_len=%d", (uint32_t)pkt->payload_len); + return false; + } + + memcpy(pkt->payload, &raw[i], pkt->payload_len); + return true; +} + +void Dispatcher::checkRecv() +{ + /* k_event is a bitfield — multiple ISR arrivals coalesce into one + * wake, so drain the entire ring each time. */ + for (;;) { + uint8_t raw[MAX_TRANS_UNIT + 1]; + int len = _radio->recvRaw(raw, MAX_TRANS_UNIT); + if (len <= 0) { + break; + } + + logRxRaw(_radio->getLastSNR(), _radio->getLastRSSI(), raw, len); + + Packet *pkt = _mgr->allocNew(); + if (pkt == nullptr) { + LOG_ERR("checkRecv: packet alloc failed"); + break; + } + + float score = 0.0f; + uint32_t air_time = 0; + + if (tryParsePacket(pkt, raw, len)) { + pkt->_snr = (int8_t)(_radio->getLastSNR() * 4.0f); /* x4 fixed-point SNR */ + score = _radio->packetScore(_radio->getLastSNR(), len); + air_time = _radio->getEstAirtimeFor(len); + rx_air_time += air_time; + } else { + _mgr->free(pkt); + continue; + } + +#if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING) + /* Arduino-compatible packet logging - use printk to bypass log level filtering */ + { + static uint8_t packet_hash[MAX_HASH_SIZE]; + static char hash_hex[MAX_HASH_SIZE * 2 + 1]; + pkt->calculatePacketHash(packet_hash); + Utils::toHex(hash_hex, packet_hash, MAX_HASH_SIZE); + + uint8_t ptype = pkt->getPayloadType(); + if (ptype == PAYLOAD_TYPE_PATH || ptype == PAYLOAD_TYPE_REQ || + ptype == PAYLOAD_TYPE_RESPONSE || ptype == PAYLOAD_TYPE_TXT_MSG) { + printk("%s: RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d time=%u hash=%s [%02X -> %02X]\n", + getLogDateTime(), pkt->getRawLength(), ptype, + pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, + (int)pkt->getSNR(), (int)_radio->getLastRSSI(), + (int)(score * 1000), air_time, hash_hex, + (uint32_t)pkt->payload[1], (uint32_t)pkt->payload[0]); + } else { + printk("%s: RX, len=%d (type=%d, route=%s, payload_len=%d) SNR=%d RSSI=%d score=%d time=%u hash=%s\n", + getLogDateTime(), pkt->getRawLength(), ptype, + pkt->isRouteDirect() ? "D" : "F", pkt->payload_len, + (int)pkt->getSNR(), (int)_radio->getLastRSSI(), + (int)(score * 1000), air_time, hash_hex); + } + } +#endif + logRx(pkt, pkt->getRawLength(), score); + if (pkt->isRouteFlood()) { + n_recv_flood++; + processRecvPacket(pkt); + } else { + n_recv_direct++; + processRecvPacket(pkt); + } + } +} + +void Dispatcher::processRecvPacket(Packet *pkt) +{ + DispatcherAction action = onRecvPacket(pkt); + if (action == ACTION_RELEASE) { + _mgr->free(pkt); + } else if (action == ACTION_MANUAL_HOLD) { + /* subclass holds packet */ + } else { + uint8_t priority = (uint8_t)((action >> 24) - 1); + uint32_t delay = action & 0xFFFFFF; + _mgr->queueOutbound(pkt, priority, futureMillis((int)delay)); + if (_tx_queued_cb && delay > 0) { + _tx_queued_cb(delay, _tx_queued_user_data); + } + } +} + +void Dispatcher::checkSend() +{ + uint32_t now = (uint32_t)_ms->getMillis(); + int count = _mgr->getOutboundCount(now); + if (count == 0) { + cad_busy_start = 0; + return; + } + + if (_radio->isReceiving()) { + /* Channel busy — enforce retry timer so we don't hammer the check */ + if (!millisHasNowPassed(next_tx_time)) { + if (_tx_queued_cb) { + uint32_t remaining = next_tx_time - now; + _tx_queued_cb(remaining + 1, _tx_queued_user_data); + } + return; + } + if (cad_busy_start == 0) { + cad_busy_start = now; + } + if (now - cad_busy_start > getCADFailMaxDuration()) { + _err_flags |= ERR_EVENT_CAD_TIMEOUT; + LOG_ERR("checkSend: CAD timeout exceeded"); + } else { + uint32_t retry = getCADFailRetryDelay(); + next_tx_time = futureMillis((int)retry); + if (_tx_queued_cb) { + _tx_queued_cb(retry + 1, _tx_queued_user_data); + } + return; + } + } + cad_busy_start = 0; + + outbound = _mgr->getNextOutbound(now); + if (outbound) { + /* Duty cycle enforcement — exempt admin packets */ + if (!isAdminPacket(outbound) && _duty_cycle.isExceeded(now)) { + LOG_WRN("checkSend: duty cycle exceeded (%u/%u ms), re-queuing type=%d", + _duty_cycle.window_airtime_ms, _duty_cycle.budgetMs(), + outbound->getPayloadType()); + _mgr->queueOutbound(outbound, 0, futureMillis(5000)); + outbound = nullptr; + if (_tx_queued_cb) { + _tx_queued_cb(5000, _tx_queued_user_data); + } + return; + } + uint8_t raw[MAX_TRANS_UNIT]; + int len = 0; + raw[len++] = outbound->header; + if (outbound->hasTransportCodes()) { + memcpy(&raw[len], &outbound->transport_codes[0], 2); len += 2; + memcpy(&raw[len], &outbound->transport_codes[1], 2); len += 2; + } + raw[len++] = outbound->path_len; + len += Packet::writePath(&raw[len], outbound->path, outbound->path_len); + + if (len + outbound->payload_len > MAX_TRANS_UNIT) { + LOG_ERR("checkSend: packet too large len=%d+%d > %d", len, outbound->payload_len, MAX_TRANS_UNIT); + _mgr->free(outbound); + outbound = nullptr; + } else { + memcpy(&raw[len], outbound->payload, outbound->payload_len); + len += outbound->payload_len; + + uint32_t max_airtime = _radio->getEstAirtimeFor(len) * 3 / 2; + outbound_start = now; + +#if IS_ENABLED(CONFIG_ZEPHCORE_PACKET_LOGGING) + /* Arduino-compatible packet logging - use printk to bypass log level filtering */ + { + uint8_t ptype = outbound->getPayloadType(); + if (ptype == PAYLOAD_TYPE_PATH || ptype == PAYLOAD_TYPE_REQ || + ptype == PAYLOAD_TYPE_RESPONSE || ptype == PAYLOAD_TYPE_TXT_MSG) { + printk("%s: TX, len=%d (type=%d, route=%s, payload_len=%d) [%02X -> %02X]\n", + getLogDateTime(), len, ptype, + outbound->isRouteDirect() ? "D" : "F", outbound->payload_len, + (uint32_t)outbound->payload[1], (uint32_t)outbound->payload[0]); + } else { + printk("%s: TX, len=%d (type=%d, route=%s, payload_len=%d)\n", + getLogDateTime(), len, ptype, + outbound->isRouteDirect() ? "D" : "F", outbound->payload_len); + } + } +#endif + + /* Final LBT check — close the gap between initial + * isReceiving() and actual TX start (serialisation + + * logging can take 1-5 ms). */ + if (_radio->isReceiving()) { + uint32_t retry = getCADFailRetryDelay(); + _mgr->queueOutbound(outbound, 0, futureMillis((int)retry)); + outbound = nullptr; + if (_tx_queued_cb) { + _tx_queued_cb(retry, _tx_queued_user_data); + } + return; + } + + bool success = _radio->startSendRaw(raw, len); + if (!success) { + uint32_t retry = getCADFailRetryDelay(); + LOG_ERR("checkSend: startSendRaw failed! re-queuing delay=%u", retry); + logTxFail(outbound, outbound->getRawLength()); + _mgr->queueOutbound(outbound, 0, futureMillis((int)retry)); + outbound = nullptr; + if (_tx_queued_cb) { + _tx_queued_cb(retry, _tx_queued_user_data); + } + } else { + outbound_expiry = futureMillis((int)max_airtime); + } + } + } +} + +Packet *Dispatcher::obtainNewPacket() +{ + Packet *pkt = _mgr->allocNew(); + if (pkt == nullptr) { + _err_flags |= ERR_EVENT_FULL; + } else { + pkt->payload_len = pkt->path_len = 0; + pkt->_snr = 0; + } + return pkt; +} + +void Dispatcher::releasePacket(Packet *packet) +{ + _mgr->free(packet); +} + +void Dispatcher::sendPacket(Packet *packet, uint8_t priority, uint32_t delay_millis) +{ + if (!Packet::isValidPathLen(packet->path_len) || packet->payload_len > MAX_PACKET_PAYLOAD) { + LOG_ERR("sendPacket: rejected - path_len=%d or payload_len=%d invalid", + packet->path_len, packet->payload_len); + _mgr->free(packet); + } else { + _mgr->queueOutbound(packet, priority, futureMillis((int)delay_millis)); + if (_tx_queued_cb && delay_millis > 0) { + _tx_queued_cb(delay_millis, _tx_queued_user_data); + } + } +} + +bool Dispatcher::millisHasNowPassed(uint32_t timestamp) const +{ + return (int32_t)((uint32_t)_ms->getMillis() - timestamp) > 0; +} + +uint32_t Dispatcher::futureMillis(int millis_from_now) const +{ + return (uint32_t)_ms->getMillis() + millis_from_now; +} + +} /* namespace mesh */ diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index 6aea6a7..a9a874f 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -276,22 +276,21 @@ static void gps_fix_callback(double lat, double lon, int64_t utc_time) #ifdef ZEPHCORE_LORA static mesh::ZephyrBoard zephyr_board; -/* Radio prefs — initialized with defaults in main(), updated after mesh.begin() - * loads persisted prefs. Passed to radio adapter at static construction time. */ -static NodePrefs radio_prefs; +/* Radio is constructed with no prefs pointer; main() binds it to + * repeater_mesh._prefs via setPrefs() before repeater_mesh.begin(). */ #if IS_ENABLED(CONFIG_ZEPHCORE_RADIO_LR1110) /* LR1110 via Zephyr LoRa driver */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); -static mesh::LR1110Radio lora_radio(lora_dev, zephyr_board, &radio_prefs); +static mesh::LR1110Radio lora_radio(lora_dev, zephyr_board); #elif IS_ENABLED(CONFIG_ZEPHCORE_RADIO_SX127X) /* SX127x via Zephyr loramac-node driver */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); -static mesh::SX127xRadio lora_radio(lora_dev, zephyr_board, &radio_prefs); +static mesh::SX127xRadio lora_radio(lora_dev, zephyr_board); #else /* SX126x via Zephyr LoRa driver */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); -static mesh::SX126xRadio lora_radio(lora_dev, zephyr_board, &radio_prefs); +static mesh::SX126xRadio lora_radio(lora_dev, zephyr_board); #endif static mesh::ZephyrMillisecondClock ms_clock; @@ -375,10 +374,6 @@ static void repeater_event_loop(void) int main(void) { - /* Initialize radio prefs with safe defaults before anything else */ - initNodePrefs(&radio_prefs); - strcpy(radio_prefs.node_name, "Repeater"); - #ifdef ZEPHCORE_LORA /* Clear any stale bootloader magic from previous sessions. * Prevents nRF52 boards from re-entering bootloader after reboot. */ @@ -470,39 +465,18 @@ int main(void) self_identity.pub_key[4], self_identity.pub_key[5], self_identity.pub_key[6], self_identity.pub_key[7]); - /* Pre-load persisted prefs into radio_prefs BEFORE repeater_mesh.begin(). - * - * Rationale: lora_radio was constructed at static-init time with a pointer - * to radio_prefs (see line ~286). When repeater_mesh.begin() runs, it - * calls Mesh::begin() -> Dispatcher::begin() -> _radio->begin(), which - * reads freq/bw/sf/cr through that pointer to configure the hardware. - * - * Without this pre-load, the radio boots on the compile-time defaults - * from initNodePrefs() (freq=869.618, EU ISM band) regardless of what the - * user configured. RepeaterMesh::begin() then loads the persisted prefs - * into its own _prefs member, so CLI/UI readback shows the correct saved - * values — but the hardware is already configured on the stale defaults - * and never gets reconfigured. The result: device appears operational on - * the configured frequency but is physically tuned to 869.618 MHz, so - * transmissions are not heard and no packets can be received. - * - * This went unnoticed in the EU because 869.618 happens to match the - * default; US/CA users on 910.525 (and any other non-default freq) hit it. - * - * Mirrors the temp_prefs pattern in main_companion.cpp. The subsequent - * setPrefs() rebind (after begin()) points the radio at the live prefs in - * RepeaterMesh so CLI `set radio` changes take effect on reconfigure(). */ - data_store.loadPrefs(radio_prefs); - - /* Start mesh with data store - this loads prefs, ACL, regions */ - repeater_mesh.begin(&data_store); - - /* Rebind radio prefs pointer to the live prefs inside repeater_mesh. - * radio_prefs above was a one-time copy for static init; from here on, - * the radio must read from the same struct RepeaterMesh mutates so that - * CLI-driven reconfigure() calls see current values. */ + /* Load persisted prefs and bind the radio to _prefs BEFORE begin() — the + * radio reads freq/bw/sf/cr through this pointer during Mesh::begin() → + * Dispatcher::begin() → Radio::begin(). Without this, the radio would + * configure on NodePrefs defaults (869.618 MHz) regardless of saved + * settings: CLI readback looked correct but the hardware stayed on EU. + * Mirrors the temp_prefs pattern in main_companion.cpp. */ + data_store.loadPrefs(*repeater_mesh.getNodePrefs()); lora_radio.setPrefs(repeater_mesh.getNodePrefs()); + /* Start mesh with data store - loads ACL, regions */ + repeater_mesh.begin(&data_store); + /* Generate default node name from hardware device ID if not set */ NodePrefs* prefs = repeater_mesh.getNodePrefs(); if (strlen(prefs->node_name) == 0 || strcmp(prefs->node_name, "Repeater") == 0) {