From 57cc977e58bac9874c9f76c3f93d484e06563be4 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 20 Jul 2026 10:50:55 -0700 Subject: [PATCH] Harden retry, power, and companion behavior --- docs/cli_commands.md | 6 +- docs/halo_keymind_settings.md | 6 +- examples/companion_radio/MyMesh.cpp | 13 + examples/companion_radio/MyMesh.h | 1 + examples/companion_radio/ui-new/UITask.cpp | 95 +++++- examples/companion_radio/ui-new/UITask.h | 9 + examples/companion_radio/ui-orig/UITask.cpp | 18 +- examples/companion_radio/ui-orig/UITask.h | 1 + examples/simple_repeater/MyMesh.cpp | 34 +- examples/simple_repeater/MyMesh.h | 2 + src/Dispatcher.cpp | 2 +- src/Mesh.cpp | 345 ++++++++++++++++---- src/Mesh.h | 48 +++ src/helpers/BaseSerialInterface.h | 3 + src/helpers/esp32/SerialBLEInterface.cpp | 62 +++- src/helpers/esp32/SerialBLEInterface.h | 11 + src/helpers/radiolib/RadioLibWrappers.cpp | 41 ++- src/helpers/radiolib/RadioLibWrappers.h | 4 +- test/test_trace_retry/test_trace_retry.cpp | 329 +++++++++++++++++++ variants/rak4631/RAK4631Board.cpp | 18 +- variants/rak4631/platformio.ini | 3 + variants/rak_wismesh_tag/platformio.ini | 1 + variants/t1000-e/platformio.ini | 1 + 23 files changed, 938 insertions(+), 115 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 667b13e5..da27cb09 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1775,7 +1775,11 @@ ignore, bridge, and bucket controls are repeater-only. **Parameters:** - `count`: Base retry attempts after the original send, from `0` to `15`. `0` disables flood retry. -**Note:** Actual attempts are capped at `15`. Path count 0 flood retries use `count * 2`; path count 1 retries use `count * 1.5`, rounded up; path count 2 and higher use the configured base count. Setting `count` to `0` immediately removes queued and future flood retries; a packet already transmitting is allowed to finish. +**Note:** The role first calculates its retry count: path count 0 uses `count * 2`, path count 1 uses `count * 1.5` rounded up, and path count 2 and higher uses the configured base count, with a hard cap of `15`. A shared payload policy then applies to every build: `REQ` never retries; `GRP_TXT` keeps the role-calculated count; remote-login-critical `RESPONSE`, `TXT_MSG`, `ANON_REQ`, and `PATH` packets keep up to `15` at the originating node (path count 0) and cap at `2` after entering the path; all other flood payload types cap at `1`. These caps never raise a lower role-calculated count. Setting `count` to `0` immediately removes queued and future flood retries; a packet already transmitting is allowed to finish. + +Forwarded neighbor adverts have an additional loop guard independent of the advert retry setting. After this node completes an advert transmission and hears a downstream copy with a longer path, it does not forward that same advert again while the advert's signed timestamp is less than six hours old. Self-originated adverts, adverts without a heard echo, and adverts six hours old or older are unaffected. + +An enabled self-originated advert retry waits at least one additional minute beyond the normal airtime-aware retry delay. Once a newer self advert has successfully entered the outbound queue, its retry sequence replaces queued or future retries for older self adverts; an older advert already transmitting is allowed to finish. Companion firmware permits this single slow retry for its own adverts while continuing to block retry attempts for neighbor adverts it forwards. **Defaults:** - `infra`: `1` diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index 16f1ce85..686b40c4 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -276,7 +276,7 @@ the bucket rules below instead. | Setting | What it does | How to use | Example | | --- | --- | --- | --- | -| `flood.retry.count` | Base flood retry attempts after initial TX. Path count 0 doubles it, path count 1 uses 1.5x rounded up, path count 2+ uses the base, and actual attempts cap at `15`; `0` disables flood retry. | `get flood.retry.count`, `set flood.retry.count <0-15>` | `set flood.retry.count 7` | +| `flood.retry.count` | Base flood retry attempts after initial TX. Role path scaling happens first, then all builds apply payload caps: `REQ` is `0`; `GRP_TXT` keeps up to `15`; remote-login `RESPONSE`, `TXT_MSG`, `ANON_REQ`, and `PATH` keep up to `15` at path count 0 and cap at `2` in transit; every other flood type caps at `1`. A lower calculated value is preserved, and `0` disables flood retry. | `get flood.retry.count`, `set flood.retry.count <0-15>` | `set flood.retry.count 7` | | `flood.retry.path` | Maximum path hash count eligible for flood retry, or `off` to disable the gate. | `get flood.retry.path`, `set flood.retry.path <0-63/off>` | `set flood.retry.path 1` | | `flood.retry.group.path` | Additional path gate for group data (`type=6`) flood retries. The stricter of this and `flood.retry.path` applies; `off` disables only this additional gate. Setting the general path gate to `0` forces this setting to `off`; a named preset restores the default of `1`. | `get flood.retry.group.path`, `set flood.retry.group.path <0-63/off>` | `set flood.retry.group.path 1` | | `flood.retry.advert` | Allows or blocks retry for node advert packets (`type=4`). Default is `off`. | `get flood.retry.advert`, `set flood.retry.advert on/off` | `set flood.retry.advert off` | @@ -286,6 +286,10 @@ the bucket rules below instead. | `flood.retry.bucket.` | Shows one bridge bucket. Buckets are numbered `1`-`6`. | `get flood.retry.bucket.` | `get flood.retry.bucket.1` | | `flood.retry.bucket` | Sets bridge bucket prefixes. | `set flood.retry.bucket <1-6> ` | `set flood.retry.bucket 1 71CE82,C7618C` | +Forwarded neighbor adverts also use an automatic echo guard in every build. If this node hears a downstream, longer-path echo after transmitting an advert whose signed timestamp is less than six hours old, it will not forward that exact advert again during the six-hour age window. This guard works even when `flood.retry.advert` is off and does not affect self-originated adverts. + +Self-originated advert retries are deliberately slow: the first retry waits at least one extra minute beyond the normal airtime-aware delay. Queueing a newer self advert retires queued and future retry attempts for older self adverts without disturbing other flood retry sequences. Companion firmware allows the one slow retry for its own adverts but not for neighbor adverts it relays. + The shared retry preset sets these flood defaults: | Preset | Retry count | Path gate | Group-data path gate | diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f4030d58..62b1b571 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -154,6 +154,9 @@ static const uint32_t COMMAND_RADIO_APPLY_TIMEOUT_MS = 5000UL; #ifndef DEFAULT_AUTOADD_CONFIG #define DEFAULT_AUTOADD_CONFIG 0 #endif +#ifndef DEFAULT_BUZZER_QUIET +#define DEFAULT_BUZZER_QUIET 0 +#endif #ifndef EMERGENCY_CLIENT_REPEAT_HOLD_MS #define EMERGENCY_CLIENT_REPEAT_HOLD_MS 120000UL @@ -728,6 +731,15 @@ bool MyMesh::allowPacketForward(const mesh::Packet* packet) { return _prefs.client_repeat != 0; } +bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const { + if (packet == NULL) return false; + // A companion may retry its own advert once, using the core's deliberately + // slow origin-advert delay. Do not add retries while relaying a neighbour's + // advert; the ordinary forwarding and recent-echo guard still apply. + return packet->getPayloadType() != PAYLOAD_TYPE_ADVERT + || isSelfOriginAdvert(packet); +} + bool MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) { if (scope.isNull()) { return sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1); @@ -1166,6 +1178,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.multi_acks = DEFAULT_MULTI_ACKS; _prefs.manual_add_contacts = DEFAULT_MANUAL_ADD_CONTACTS; _prefs.tx_power_dbm = LORA_TX_POWER; + _prefs.buzzer_quiet = DEFAULT_BUZZER_QUIET ? 1 : 0; _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // No automatic GPS updates by default _prefs.autoadd_config = DEFAULT_AUTOADD_CONFIG; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index d4f8d00b..58636b50 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -149,6 +149,7 @@ protected: uint8_t getExtraAckTransmitCount() const override; bool filterRecvFloodPacket(mesh::Packet* packet) override; bool allowPacketForward(const mesh::Packet* packet) override; + bool allowFloodRetry(const mesh::Packet* packet) const override; bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis); bool sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ca14b7fe..f2bb58ca 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -9,6 +9,12 @@ #ifndef AUTO_OFF_MILLIS #define AUTO_OFF_MILLIS 15000 // 15 seconds #endif +#ifndef USB_MESSAGE_PREVIEW_MILLIS + #define USB_MESSAGE_PREVIEW_MILLIS 15000UL +#endif +#ifndef BLE_PAIRING_DISPLAY_MILLIS + #define BLE_PAIRING_DISPLAY_MILLIS 120000UL +#endif #define BOOT_SCREEN_MILLIS 3000 // 3 seconds #ifdef PIN_STATUS_LED @@ -181,6 +187,8 @@ public: : _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0), _shutdown_init(false), sensors_lpp(200) { } + void showFirstPage() { _page = HomePage::FIRST; } + void poll() override { if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released _task->shutdown(); @@ -629,7 +637,10 @@ switch(t){ void UITask::msgRead(int msgcount) { _msgcount = msgcount; if (msgcount == 0) { - gotoHomeScreen(); + _deferred_msg_preview = false; + const bool holding_usb_preview = curr == msg_preview && _msg_preview_until != 0 + && static_cast(millis() - _msg_preview_until) < 0; + if (!holding_usb_preview) gotoHomeScreen(); } } @@ -637,7 +648,20 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i _msgcount = msgcount; ((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text); - setCurrScreen(msg_preview); + if (isPairingScreenActive()) { + // Keep the PIN visible, but retain the preview so it can be shown after + // pairing completes or the pairing display window expires. + _deferred_msg_preview = true; + } else { + setCurrScreen(msg_preview); + } + + // A connected app drains the offline queue almost immediately, which calls + // msgRead(0). While attached to a computer, retain the actual message screen + // for 15 seconds even though the app has already consumed the message. + _msg_preview_until = _board->isUsbHostConnected() + ? millis() + USB_MESSAGE_PREVIEW_MILLIS + : 0; if (_display != NULL) { if (!_display->isOn() && shouldWakeDisplayForMessage()) { @@ -676,6 +700,47 @@ void UITask::setCurrScreen(UIScreen* c) { _next_refresh = 100; } +bool UITask::isPairingScreenActive() const { + return _pairing_screen_until != 0 + && !hasConnection() + && static_cast(millis() - _pairing_screen_until) < 0; +} + +void UITask::showPairingPin() { + const unsigned long now = millis(); + _pairing_screen_until = now + BLE_PAIRING_DISPLAY_MILLIS; + if (curr == msg_preview && _msgcount > 0) { + _deferred_msg_preview = true; + } + static_cast(home)->showFirstPage(); + setCurrScreen(home); + + if (_display != NULL) { + if (!_display->isOn()) _display->turnOn(); + _auto_off = now + AUTO_OFF_MILLIS; + _next_refresh = 0; + } +} + +void UITask::finishPairingScreen(bool timed_out) { + _pairing_screen_until = 0; + + if (_deferred_msg_preview && _msgcount > 0) { + _deferred_msg_preview = false; + setCurrScreen(msg_preview); + _auto_off = millis() + AUTO_OFF_MILLIS; + } else { + _deferred_msg_preview = false; + gotoHomeScreen(); + if (timed_out && _display != NULL) { + _display->turnOff(); + } else { + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 0; + } + } +} + /* hardware-agnostic pre-shutdown activity should be done here */ @@ -711,6 +776,17 @@ bool UITask::isButtonPressed() const { } void UITask::loop() { + if (_serial->takePairingRequest()) { + showPairingPin(); + } + + if (_pairing_screen_until != 0) { + const bool timed_out = static_cast(millis() - _pairing_screen_until) >= 0; + if (hasConnection() || timed_out) { + finishPairingScreen(timed_out); + } + } + char c = 0; #if UI_HAS_JOYSTICK int ev = user_btn.check(); @@ -784,6 +860,13 @@ void UITask::loop() { } #endif + if (isPairingScreenActive()) { + // Pairing has visual priority over navigation and asynchronous screens. + static_cast(home)->showFirstPage(); + if (curr != home) setCurrScreen(home); + c = 0; + } + if (c != 0 && curr) { curr->handleInput(c); _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer @@ -798,6 +881,12 @@ void UITask::loop() { if (curr) curr->poll(); + if (curr == msg_preview && _msgcount == 0 && _msg_preview_until != 0 + && static_cast(millis() - _msg_preview_until) >= 0) { + _msg_preview_until = 0; + gotoHomeScreen(); + } + if (_display != NULL && _display->isOn()) { if (millis() >= _next_refresh && curr) { _display->startFrame(); @@ -827,7 +916,7 @@ void UITask::loop() { _auto_off = millis() + AUTO_OFF_MILLIS; } #endif - if (isDisplayAutoOffDue(_auto_off, AUTO_OFF_MILLIS)) { + if (!isPairingScreenActive() && isDisplayAutoOffDue(_auto_off, AUTO_OFF_MILLIS)) { _display->turnOff(); } #endif diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index a77ad6e7..9fafe3fd 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -32,6 +32,9 @@ class UITask : public AbstractUITask { GenericVibration vibration; #endif unsigned long _next_refresh, _auto_off; + unsigned long _msg_preview_until; + unsigned long _pairing_screen_until; + bool _deferred_msg_preview; NodePrefs* _node_prefs; char _alert[80]; unsigned long _alert_expiry; @@ -62,11 +65,17 @@ class UITask : public AbstractUITask { char handleTripleClick(char c); void setCurrScreen(UIScreen* c); + bool isPairingScreenActive() const; + void showPairingPin(); + void finishPairingScreen(bool timed_out); public: UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { next_batt_chck = _next_refresh = 0; + _msg_preview_until = 0; + _pairing_screen_until = 0; + _deferred_msg_preview = false; ui_started_at = 0; curr = NULL; } diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index 78349729..bd274286 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -90,15 +90,29 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no ui_started_at = millis(); } +bool UITask::shouldPlayMessageTone() const { +#ifdef BLE_PIN_CODE + return !hasConnection(); +#else + // A USB or Ethernet companion connection is not a Bluetooth connection and + // should not silence the standalone new-message alert. + return true; +#endif +} + void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) switch(t){ case UIEventType::contactMessage: // gemini's pick - buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + if (shouldPlayMessageTone()) { + buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + } break; case UIEventType::channelMessage: - buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#"); + if (shouldPlayMessageTone()) { + buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#"); + } break; case UIEventType::ack: buzzer.play("ack:d=32,o=8,b=120:c"); diff --git a/examples/companion_radio/ui-orig/UITask.h b/examples/companion_radio/ui-orig/UITask.h index 60cd0d04..e1874760 100644 --- a/examples/companion_radio/ui-orig/UITask.h +++ b/examples/companion_radio/ui-orig/UITask.h @@ -50,6 +50,7 @@ class UITask : public AbstractUITask { void handleButtonTriplePress(); void handleButtonQuadruplePress(); void handleButtonLongPress(); + bool shouldPlayMessageTone() const; public: diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 4d3aa53e..946ea683 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1578,6 +1578,25 @@ uint8_t MyMesh::floodRetryBridgeHeardMask(const mesh::Packet* packet, uint8_t so return mask; } +bool MyMesh::floodRetryBridgeEligible(const mesh::Packet* packet) const { + FloodRetryBridgeState* state = floodRetryBridgeStateFor(packet, false); + if (state != NULL) { + return (state->heard_mask & state->target_mask) != state->target_mask; + } + + uint8_t source_mask = floodRetrySourceMask(packet); + if (source_mask == 0) { + return false; + } + uint8_t target_mask = floodRetryBridgeTargetMask(source_mask); + if (target_mask == 0) { + return false; + } + uint8_t progress_marker = packet->getPathHashCount(); + uint8_t heard_mask = floodRetryBridgeHeardMask(packet, source_mask, progress_marker) & target_mask; + return (heard_mask & target_mask) != target_mask; +} + MyMesh::FloodRetryBridgeState* MyMesh::floodRetryBridgeStateFor(const mesh::Packet* packet, bool create) const { if (packet == NULL) { return NULL; @@ -1635,15 +1654,14 @@ bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const { if (!_prefs.flood_retry_bridge_enabled) { return true; } - FloodRetryBridgeState* state = floodRetryBridgeStateFor(packet, true); - if (state == NULL) { - return false; + return floodRetryBridgeEligible(packet); +} + +bool MyMesh::prepareFloodRetry(const mesh::Packet* packet) const { + if (!_prefs.flood_retry_bridge_enabled) { + return true; } - if ((state->heard_mask & state->target_mask) == state->target_mask) { - state->active = false; - return false; - } - return true; + return floodRetryBridgeStateFor(packet, true) != NULL; } void MyMesh::clearFloodRetryBridgeStateByKey(const uint8_t* retry_key) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 66c195f4..2150bc52 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -376,6 +376,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint8_t floodRetryBridgeTargetMask(uint8_t source_mask) const; uint8_t floodRetryBridgeHeardMask(const mesh::Packet* packet, uint8_t source_mask, uint8_t progress_marker) const; + bool floodRetryBridgeEligible(const mesh::Packet* packet) const; FloodRetryBridgeState* floodRetryBridgeStateFor(const mesh::Packet* packet, bool create) const; void clearFloodRetryBridgeStateByKey(const uint8_t* retry_key); void refreshFloodRetryReachability(const mesh::Packet* packet); @@ -510,6 +511,7 @@ protected: void onDirectRetryFailed(const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) override; void onDirectRetrySucceeded(const uint8_t* next_hop_hash, uint8_t next_hop_hash_len, int8_t snr_x4) override; bool allowFloodRetry(const mesh::Packet* packet) const override; + bool prepareFloodRetry(const mesh::Packet* packet) const override; void onFloodRetryEvent(const char* event, const mesh::Packet* packet, uint32_t delay_millis, uint8_t retry_attempt) override; void onFloodRetrySlotReleased(const uint8_t* retry_key) override; bool hasFloodRetryTargetPrefix(const mesh::Packet* packet) const override; diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 757fdf57..9e12913e 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -13,7 +13,7 @@ namespace mesh { #define MIN_TX_BUDGET_AIRTIME_DIV 2 // require at least 1/N of estimated airtime as budget before TX #ifndef NOISE_FLOOR_CALIB_INTERVAL - #define NOISE_FLOOR_CALIB_INTERVAL 30000 // request at most every 30 seconds + #define NOISE_FLOOR_CALIB_INTERVAL 2000 // refresh every 2 seconds #endif void Dispatcher::begin() { diff --git a/src/Mesh.cpp b/src/Mesh.cpp index d89dff5a..89eb0737 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -29,6 +29,9 @@ static const uint8_t DIRECT_RETRY_MAX_ATTEMPTS_HARD_MAX = 21; static const uint8_t FLOOD_RETRY_MAX_ATTEMPTS_DEFAULT = 15; static const uint8_t FLOOD_RETRY_MAX_ATTEMPTS_HARD_MAX = 15; static const uint8_t FLOOD_RETRY_MAX_PATH_DEFAULT = 1; +static const uint32_t ORIGIN_ADVERT_RETRY_EXTRA_DELAY_MS = 60UL * 1000UL; +static const uint32_t RECENT_ADVERT_MAX_AGE_SECONDS = 6UL * 60UL * 60UL; +static const uint32_t FORWARDED_ADVERT_ECHO_WATCH_MS = 5UL * 60UL * 1000UL; static bool hasValidEncryptedPayloadLength(uint16_t payload_len, uint16_t clear_prefix_len) { const uint16_t overhead = clear_prefix_len + CIPHER_MAC_SIZE; @@ -150,6 +153,7 @@ void Mesh::begin() { _active_flood_retry_count = 0; _waiting_direct_retry_count = 0; _waiting_flood_retry_count = 0; + _next_recent_advert_echo = 0; _next_direct_retry_timeout = 0; _next_flood_retry_timeout = 0; for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { @@ -183,10 +187,20 @@ void Mesh::begin() { memset(_flood_retries[i].retry_key, 0, sizeof(_flood_retries[i].retry_key)); _flood_retries[i].priority = 0; _flood_retries[i].progress_marker = 0; + _flood_retries[i].self_advert = false; _flood_retries[i].waiting_final_echo = false; _flood_retries[i].queued = false; _flood_retries[i].active = false; } + for (int i = 0; i < MAX_RECENT_ADVERT_ECHOS; i++) { + memset(_recent_advert_echoes[i].packet_hash, 0, + sizeof(_recent_advert_echoes[i].packet_hash)); + _recent_advert_echoes[i].advert_timestamp = 0; + _recent_advert_echoes[i].watch_started_at = 0; + _recent_advert_echoes[i].progress_marker = 0; + _recent_advert_echoes[i].confirmed = false; + _recent_advert_echoes[i].valid = false; + } Dispatcher::begin(); #if defined(ENABLE_OTA) uint32_t my_tid = 0; @@ -327,12 +341,21 @@ void Mesh::loop() { bool Mesh::allowPacketTransmit(const Packet* packet) const { #if defined(ENABLE_OTA) - if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_OTA) { - return isTempRadioActive(); + if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_OTA + && !isTempRadioActive()) { + return false; } -#else - (void)packet; #endif + if (packet != NULL && _active_flood_retry_count != 0) { + for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { + if (!_flood_retries[i].active || !_flood_retries[i].queued + || _flood_retries[i].packet != packet) { + continue; + } + uint8_t max_attempts = getEligibleFloodRetryMaxAttempts(packet); + return max_attempts > _flood_retries[i].retry_attempts_sent; + } + } return true; } @@ -392,6 +415,11 @@ bool Mesh::allowFloodRetry(const Packet* packet) const { (void)packet; return true; } +bool Mesh::isSelfOriginAdvert(const Packet* packet) const { + return packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_ADVERT + && packet->getPathHashCount() == 0 && packet->payload_len >= PUB_KEY_SIZE + && self_id.matches(packet->payload); +} bool Mesh::hasFloodRetryTargetPrefix(const Packet* packet) const { (void)packet; return false; @@ -412,10 +440,54 @@ uint8_t Mesh::applyGroupDataFloodRetryPathGate(const Packet* packet, } return general_gate; } +uint8_t Mesh::applyFloodRetryAttemptPolicy(const Packet* packet, + uint8_t role_max_attempts) { + uint8_t attempts = role_max_attempts > FLOOD_RETRY_MAX_ATTEMPTS_HARD_MAX + ? FLOOD_RETRY_MAX_ATTEMPTS_HARD_MAX + : role_max_attempts; + if (attempts == 0 || packet == NULL) { + return attempts; + } + + switch (packet->getPayloadType()) { + case PAYLOAD_TYPE_REQ: + return 0; + case PAYLOAD_TYPE_GRP_TXT: + return attempts; + case PAYLOAD_TYPE_RESPONSE: + case PAYLOAD_TYPE_TXT_MSG: + case PAYLOAD_TYPE_ANON_REQ: + case PAYLOAD_TYPE_PATH: + return packet->getPathHashCount() == 0 || attempts <= 2 ? attempts : 2; + default: + return attempts > 1 ? 1 : attempts; + } +} uint8_t Mesh::getFloodRetryMaxAttempts(const Packet* packet) const { (void)packet; return FLOOD_RETRY_MAX_ATTEMPTS_DEFAULT; } +uint8_t Mesh::getEffectiveFloodRetryMaxAttempts(const Packet* packet) const { + return applyFloodRetryAttemptPolicy(packet, getFloodRetryMaxAttempts(packet)); +} +uint8_t Mesh::getEligibleFloodRetryMaxAttempts(const Packet* packet) const { + if (packet == NULL || !packet->isRouteFlood()) { + return 0; + } + + uint8_t max_attempts = getEffectiveFloodRetryMaxAttempts(packet); + if (max_attempts == 0 || !allowFloodRetry(packet) + || hasFloodRetryTargetPrefix(packet)) { + return 0; + } + + uint8_t max_path_len = getFloodRetryMaxPathLength(packet); + if (max_path_len != FLOOD_RETRY_PATH_GATE_DISABLED + && packet->getPathHashCount() > max_path_len) { + return 0; + } + return max_attempts; +} uint32_t Mesh::getFloodRetryAttemptDelay(const Packet* packet, uint8_t attempt_idx) { (void)attempt_idx; if (packet == NULL) { @@ -426,13 +498,18 @@ uint32_t Mesh::getFloodRetryAttemptDelay(const Packet* packet, uint8_t attempt_i uint32_t packet_airtime = _radio->getEstAirtimeFor(packet->getRawLength()); uint32_t jitter_percent = _rng->nextInt(0, 201); uint32_t jitter = (packet_airtime * jitter_percent) / 100UL; - return max_packet_airtime + (20UL * packet_airtime) + jitter; + uint32_t delay = max_packet_airtime + (20UL * packet_airtime) + jitter; + if (isSelfOriginAdvert(packet)) { + delay += ORIGIN_ADVERT_RETRY_EXTRA_DELAY_MS; + } + return delay; } uint8_t Mesh::getExtraAckTransmitCount() const { return 0; } void Mesh::onSendComplete(Packet* packet) { + watchForwardedAdvertEcho(packet); armDirectRetryOnSendComplete(packet); armFloodRetryOnSendComplete(packet); } @@ -459,6 +536,7 @@ int Mesh::searchChannelsByHash(const uint8_t* hash, GroupChannel channels[], int } DispatcherAction Mesh::onRecvPacket(Packet* pkt) { + observeForwardedAdvertEcho(pkt); if (pkt->isRouteDirect()) { cancelDirectRetryOnEcho(pkt); } else if (pkt->isRouteFlood()) { @@ -845,6 +923,10 @@ void Mesh::removePathPrefix(Packet* pkt, uint8_t prefix_count) { } DispatcherAction Mesh::routeRecvPacket(Packet* packet) { + if (shouldSuppressEchoedAdvertForward(packet)) { + return ACTION_RELEASE; + } + uint8_t n = packet->getPathHashCount(); if (packet->isRouteFlood() && !packet->isMarkedDoNotRetransmit() && (n + 1)*packet->getPathHashSize() <= MAX_PATH_SIZE && allowPacketForward(packet)) { @@ -1529,12 +1611,70 @@ void Mesh::clearFloodRetrySlot(int idx) { memset(_flood_retries[idx].retry_key, 0, sizeof(_flood_retries[idx].retry_key)); _flood_retries[idx].priority = 0; _flood_retries[idx].progress_marker = 0; + _flood_retries[idx].self_advert = false; _flood_retries[idx].waiting_final_echo = false; _flood_retries[idx].queued = false; _flood_retries[idx].active = false; if (rebuild_timeout) rebuildNextFloodRetryTimeout(); } +void Mesh::retireFloodRetrySlot(int idx) { + if (idx < 0 || idx >= MAX_FLOOD_RETRY_SLOTS || !_flood_retries[idx].active) { + return; + } + + Packet* retry = _flood_retries[idx].queued ? _flood_retries[idx].packet : NULL; + if (retry != NULL && retry != getOutboundInFlight()) { + for (int j = 0; j < _mgr->getOutboundTotal(); j++) { + if (_mgr->getOutboundByIdx(j) != retry) continue; + Packet* pending = _mgr->removeOutboundByIdx(j); + if (pending != NULL) { + _flood_retries[idx].packet = NULL; + releasePacket(pending); + } + break; + } + } + clearFloodRetrySlot(idx); +} + +void Mesh::replaceQueuedSelfAdvertRetries(const Packet* packet) { + if (packet == NULL || !packet->isRouteFlood() || !isSelfOriginAdvert(packet)) { + return; + } + + int replacement_slot = -1; + bool found_prior = false; + for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { + if (!_flood_retries[i].active + || !_flood_retries[i].self_advert) { + continue; + } + if (_flood_retries[i].trigger_packet == packet) { + replacement_slot = i; + } else { + found_prior = true; + } + } + + if (!found_prior) return; + + for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { + if (i == replacement_slot || !_flood_retries[i].active + || !_flood_retries[i].self_advert) { + continue; + } + retireFloodRetrySlot(i); + } + + // A full retry table or an identical retry key can prevent the new advert + // from reserving a slot before it enters the outbound queue. Older advert + // retries are gone now, so let the successfully queued advert take over. + if (replacement_slot < 0) { + maybeScheduleFloodRetry(packet, 3); + } +} + void Mesh::rebuildNextFloodRetryTimeout() { bool found = false; uint32_t shortest_delay = 0; @@ -1566,20 +1706,7 @@ void Mesh::cancelAllFloodRetries() { for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { if (!_flood_retries[i].active) continue; - - Packet* retry = _flood_retries[i].queued ? _flood_retries[i].packet : NULL; - if (retry != NULL && retry != getOutboundInFlight()) { - for (int j = 0; j < _mgr->getOutboundTotal(); j++) { - if (_mgr->getOutboundByIdx(j) != retry) continue; - Packet* pending = _mgr->removeOutboundByIdx(j); - if (pending != NULL) { - _flood_retries[i].packet = NULL; - releasePacket(pending); - } - break; - } - } - clearFloodRetrySlot(i); + retireFloodRetrySlot(i); } } @@ -1607,20 +1734,7 @@ bool Mesh::cancelActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) { continue; } - Packet* retry = _flood_retries[i].queued ? _flood_retries[i].packet : NULL; - if (retry != NULL && retry != getOutboundInFlight()) { - for (int j = 0; j < _mgr->getOutboundTotal(); j++) { - if (_mgr->getOutboundByIdx(j) == retry) { - Packet* pending = _mgr->removeOutboundByIdx(j); - if (pending != NULL) { - _flood_retries[i].packet = NULL; - releasePacket(pending); - } - break; - } - } - } - clearFloodRetrySlot(i); + retireFloodRetrySlot(i); cancelled = true; } @@ -1677,6 +1791,121 @@ bool Mesh::isFloodRetryEchoTarget(const Packet* packet, uint8_t progress_marker) return packet->isRouteFlood() && packet->getPathHashCount() > progress_marker; } +bool Mesh::getRecentAdvertTimestamp(const Packet* packet, uint32_t& timestamp) const { + if (packet == NULL || packet->getPayloadType() != PAYLOAD_TYPE_ADVERT + || packet->payload_len < PUB_KEY_SIZE + sizeof(timestamp) + SIGNATURE_SIZE) { + return false; + } + memcpy(×tamp, &packet->payload[PUB_KEY_SIZE], sizeof(timestamp)); + return isRecentAdvertTimestamp(timestamp); +} + +bool Mesh::isRecentAdvertTimestamp(uint32_t timestamp) const { + uint32_t now = _rtc->getCurrentTime(); + return now >= timestamp && now - timestamp < RECENT_ADVERT_MAX_AGE_SECONDS; +} + +void Mesh::watchForwardedAdvertEcho(const Packet* packet) { + if (packet == NULL || !packet->isRouteFlood() || packet->getPathHashCount() == 0) { + return; + } + + uint32_t advert_timestamp; + if (!getRecentAdvertTimestamp(packet, advert_timestamp)) { + return; + } + + uint8_t packet_hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(packet_hash); + uint32_t now_millis = _ms->getMillis(); + int slot_idx = -1; + for (int i = 0; i < MAX_RECENT_ADVERT_ECHOS; i++) { + RecentAdvertEchoEntry& entry = _recent_advert_echoes[i]; + if (entry.valid && memcmp(entry.packet_hash, packet_hash, MAX_HASH_SIZE) == 0) { + if (entry.confirmed) { + return; + } + slot_idx = i; + break; + } + bool expired = entry.valid + && ((entry.confirmed && !isRecentAdvertTimestamp(entry.advert_timestamp)) + || (!entry.confirmed + && (uint32_t)(now_millis - entry.watch_started_at) + > FORWARDED_ADVERT_ECHO_WATCH_MS)); + if (slot_idx < 0 && (!entry.valid || expired)) { + slot_idx = i; + } + } + if (slot_idx < 0) { + slot_idx = _next_recent_advert_echo; + } + _next_recent_advert_echo = (slot_idx + 1) % MAX_RECENT_ADVERT_ECHOS; + + RecentAdvertEchoEntry& entry = _recent_advert_echoes[slot_idx]; + memcpy(entry.packet_hash, packet_hash, sizeof(entry.packet_hash)); + entry.advert_timestamp = advert_timestamp; + entry.watch_started_at = now_millis; + entry.progress_marker = packet->getPathHashCount(); + entry.confirmed = false; + entry.valid = true; +} + +void Mesh::observeForwardedAdvertEcho(const Packet* packet) { + if (packet == NULL || !packet->isRouteFlood()) { + return; + } + + uint32_t advert_timestamp; + if (!getRecentAdvertTimestamp(packet, advert_timestamp)) { + return; + } + + uint8_t packet_hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(packet_hash); + uint32_t now_millis = _ms->getMillis(); + for (int i = 0; i < MAX_RECENT_ADVERT_ECHOS; i++) { + RecentAdvertEchoEntry& entry = _recent_advert_echoes[i]; + if (!entry.valid || entry.confirmed + || memcmp(entry.packet_hash, packet_hash, MAX_HASH_SIZE) != 0) { + continue; + } + if ((uint32_t)(now_millis - entry.watch_started_at) > FORWARDED_ADVERT_ECHO_WATCH_MS) { + entry.valid = false; + continue; + } + // The exact advert payload is the identity. It may return through a + // different branch; a longer path still proves a downstream copy exists. + if (entry.advert_timestamp == advert_timestamp + && packet->getPathHashCount() > entry.progress_marker) { + entry.confirmed = true; + return; + } + } +} + +bool Mesh::shouldSuppressEchoedAdvertForward(const Packet* packet) const { + if (packet == NULL || !packet->isRouteFlood()) { + return false; + } + + uint32_t advert_timestamp; + if (!getRecentAdvertTimestamp(packet, advert_timestamp)) { + return false; + } + + uint8_t packet_hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(packet_hash); + for (int i = 0; i < MAX_RECENT_ADVERT_ECHOS; i++) { + const RecentAdvertEchoEntry& entry = _recent_advert_echoes[i]; + if (entry.valid && entry.confirmed && entry.advert_timestamp == advert_timestamp + && memcmp(entry.packet_hash, packet_hash, MAX_HASH_SIZE) == 0) { + return true; + } + } + return false; +} + bool Mesh::cancelFloodRetryOnEcho(const Packet* packet) { if (_active_flood_retry_count == 0) return false; @@ -1700,18 +1929,7 @@ bool Mesh::cancelFloodRetryOnEcho(const Packet* packet) { : _flood_retries[i].retry_attempts_sent + 1; onFloodRetryEvent("good", packet, echo_millis, retry_attempt); - if (_flood_retries[i].queued) { - for (int j = 0; j < _mgr->getOutboundTotal(); j++) { - if (_mgr->getOutboundByIdx(j) == _flood_retries[i].packet) { - Packet* pending = _mgr->removeOutboundByIdx(j); - if (pending) { - releasePacket(pending); - } - break; - } - } - } - clearFloodRetrySlot(i); + retireFloodRetrySlot(i); cleared = true; } @@ -1737,11 +1955,10 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) { onFloodRetryEvent("resent", packet, elapsed_millis, _flood_retries[i].retry_attempts_sent + 1); _flood_retries[i].retry_attempts_sent++; - uint8_t max_attempts = getFloodRetryMaxAttempts(packet); - if (max_attempts < 1) { - max_attempts = 1; - } else if (max_attempts > FLOOD_RETRY_MAX_ATTEMPTS_HARD_MAX) { - max_attempts = FLOOD_RETRY_MAX_ATTEMPTS_HARD_MAX; + uint8_t max_attempts = getEligibleFloodRetryMaxAttempts(packet); + if (max_attempts == 0) { + clearFloodRetrySlot(i); + continue; } if (_flood_retries[i].retry_attempts_sent >= max_attempts) { // Dispatcher releases the transmitted packet after this hook. Keep only @@ -1789,6 +2006,11 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) { continue; } + if (getEligibleFloodRetryMaxAttempts(packet) == 0) { + clearFloodRetrySlot(i); + continue; + } + Packet* retry = obtainNewPacket(); if (retry == NULL) { onFloodRetryEvent("dropped_no_packet", packet, _flood_retries[i].retry_delay, 1); @@ -1847,15 +2069,9 @@ void Mesh::maybeScheduleFloodRetry(const Packet* packet, uint8_t priority) { return; } - // Check the inexpensive global kill switch before prefix/path eligibility. - // This makes flood.retry.count=0 a genuinely cheap disabled state. - uint8_t max_attempts = getFloodRetryMaxAttempts(packet); - if (max_attempts == 0 || hasFloodRetryTargetPrefix(packet)) { - return; - } - - uint8_t max_path_len = getFloodRetryMaxPathLength(packet); - if (max_path_len != FLOOD_RETRY_PATH_GATE_DISABLED && packet->getPathHashCount() > max_path_len) { + // Keep all count/type/path gates in one check, which is also reused when a + // delayed retry reaches the radio so a newly disabled retry stays disabled. + if (getEligibleFloodRetryMaxAttempts(packet) == 0) { return; } @@ -1881,7 +2097,7 @@ void Mesh::maybeScheduleFloodRetry(const Packet* packet, uint8_t priority) { return; } - if (!allowFloodRetry(packet)) { + if (!prepareFloodRetry(packet)) { return; } @@ -1895,6 +2111,7 @@ void Mesh::maybeScheduleFloodRetry(const Packet* packet, uint8_t priority) { _flood_retries[slot_idx].retry_attempts_sent = 0; _flood_retries[slot_idx].priority = priority; _flood_retries[slot_idx].progress_marker = packet->getPathHashCount(); + _flood_retries[slot_idx].self_advert = isSelfOriginAdvert(packet); _flood_retries[slot_idx].waiting_final_echo = false; _flood_retries[slot_idx].queued = false; _flood_retries[slot_idx].active = true; @@ -2196,7 +2413,9 @@ bool Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_si pri = 1; } maybeScheduleFloodRetry(packet, pri); - return sendPacket(packet, pri, delay_millis); + bool queued = sendPacket(packet, pri, delay_millis); + if (queued) replaceQueuedSelfAdvertRetries(packet); + return queued; } bool Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis, uint8_t path_hash_size) { @@ -2228,7 +2447,9 @@ bool Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_m pri = 1; } maybeScheduleFloodRetry(packet, pri); - return sendPacket(packet, pri, delay_millis); + bool queued = sendPacket(packet, pri, delay_millis); + if (queued) replaceQueuedSelfAdvertRetries(packet); + return queued; } bool Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uint32_t delay_millis) { diff --git a/src/Mesh.h b/src/Mesh.h index 42324fb1..b6f949d3 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -25,6 +25,13 @@ namespace mesh { #define MAX_FLOOD_RETRY_SLOTS 6 #endif +#ifndef MAX_RECENT_ADVERT_ECHOS + #define MAX_RECENT_ADVERT_ECHOS 8 +#endif +#if MAX_RECENT_ADVERT_ECHOS < 1 + #error "MAX_RECENT_ADVERT_ECHOS must be at least 1" +#endif + #ifndef FLOOD_RETRY_PATH_GATE_DISABLED #define FLOOD_RETRY_PATH_GATE_DISABLED 0xFF #endif @@ -83,20 +90,32 @@ class Mesh : public Dispatcher { uint8_t retry_key[MAX_HASH_SIZE]; uint8_t priority; uint8_t progress_marker; + bool self_advert; bool waiting_final_echo; bool queued; bool active; }; + struct RecentAdvertEchoEntry { + uint8_t packet_hash[MAX_HASH_SIZE]; + uint32_t advert_timestamp; + uint32_t watch_started_at; + uint8_t progress_marker; + bool confirmed; + bool valid; + }; + RTCClock* _rtc; RNG* _rng; MeshTables* _tables; DirectRetryEntry _direct_retries[MAX_DIRECT_RETRY_SLOTS]; FloodRetryEntry _flood_retries[MAX_FLOOD_RETRY_SLOTS]; + RecentAdvertEchoEntry _recent_advert_echoes[MAX_RECENT_ADVERT_ECHOS]; uint8_t _active_direct_retry_count; uint8_t _active_flood_retry_count; uint8_t _waiting_direct_retry_count; uint8_t _waiting_flood_retry_count; + uint8_t _next_recent_advert_echo; unsigned long _next_direct_retry_timeout; unsigned long _next_flood_retry_timeout; @@ -117,7 +136,16 @@ class Mesh : public Dispatcher { bool canDecodeDirectPayloadForSelf(const Packet* packet); void maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool final_hop_retry = false); void clearFloodRetrySlot(int idx); + void retireFloodRetrySlot(int idx); + void replaceQueuedSelfAdvertRetries(const Packet* packet); bool cancelFloodRetryOnEcho(const Packet* packet); + bool getRecentAdvertTimestamp(const Packet* packet, uint32_t& timestamp) const; + bool isRecentAdvertTimestamp(uint32_t timestamp) const; + void watchForwardedAdvertEcho(const Packet* packet); + void observeForwardedAdvertEcho(const Packet* packet); + bool shouldSuppressEchoedAdvertForward(const Packet* packet) const; + uint8_t getEffectiveFloodRetryMaxAttempts(const Packet* packet) const; + uint8_t getEligibleFloodRetryMaxAttempts(const Packet* packet) const; void armFloodRetryOnSendComplete(const Packet* packet); void clearPendingFloodRetryOnSendFail(const Packet* packet); void maybeScheduleFloodRetry(const Packet* packet, uint8_t priority); @@ -231,6 +259,20 @@ protected: */ virtual bool allowFloodRetry(const Packet* packet) const; + /** + * \brief Reserve any role-specific state after a free flood retry slot is found. + * \returns false if role-specific state could not be reserved. + */ + virtual bool prepareFloodRetry(const Packet* packet) const { + (void)packet; + return true; + } + + /** + * \returns true only for a zero-hop advert carrying this node's public key. + */ + bool isSelfOriginAdvert(const Packet* packet) const; + /** * \brief Return true when this FLOOD packet already carries an application-defined target prefix. */ @@ -248,6 +290,12 @@ protected: uint8_t general_gate, uint8_t group_data_gate); + /** + * \returns the shared payload/path cap applied after a role chooses its flood retry count. + */ + static uint8_t applyFloodRetryAttemptPolicy(const Packet* packet, + uint8_t role_max_attempts); + /** * \returns maximum number of FLOOD retry transmissions after the initial TX. */ diff --git a/src/helpers/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index 8e058591..5ad8f259 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -21,6 +21,9 @@ public: // Backends whose busy methods are throttling/high-water signals should // override this with their actual queue state. virtual bool hasPendingIO() const { return isReadBusy() || isWriteBusy(); } + // Returns true once for each pending Bluetooth pairing prompt. Non-BLE + // transports keep the default implementation so UI code can poll safely. + virtual bool takePairingRequest() { return false; } virtual size_t writeFrame(const uint8_t src[], size_t len) = 0; virtual size_t checkRecvFrame(uint8_t dest[]) = 0; }; diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index 50e1501e..90858c7e 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -26,7 +26,9 @@ void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code // Create the BLE Device BLEDevice::init(dev_name); BLEDevice::setSecurityCallbacks(this); - BLEDevice::setMTU(MAX_FRAME_SIZE); + // ATT notifications consume three bytes of the negotiated MTU. Reserve + // that overhead so a MAX_FRAME_SIZE protocol frame fits without truncation. + BLEDevice::setMTU(MAX_FRAME_SIZE + 3); BLESecurity sec; sec.setStaticPIN(pin_code); @@ -44,7 +46,15 @@ void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code // Create a BLE Characteristic pTxCharacteristic = pService->createCharacteristic(CHARACTERISTIC_UUID_TX, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); pTxCharacteristic->setAccessPermissions(ESP_GATT_PERM_READ_ENC_MITM); - pTxCharacteristic->addDescriptor(new BLE2902()); + pTxCharacteristic->setCallbacks(this); + pTxDescriptor = new BLE2902(); + // Make notification setup start/finish pairing before the client begins its + // short device-info request timeout. The RX and TX characteristics already + // require the same MITM-encrypted link, so this does not add a new pairing + // requirement; it only moves it earlier in the connection handshake. + pTxDescriptor->setAccessPermissions( + (esp_gatt_perm_t)(ESP_GATT_PERM_READ_ENC_MITM | ESP_GATT_PERM_WRITE_ENC_MITM)); + pTxCharacteristic->addDescriptor(pTxDescriptor); BLECharacteristic * pRxCharacteristic = pService->createCharacteristic(CHARACTERISTIC_UUID_RX, BLECharacteristic::PROPERTY_WRITE); pRxCharacteristic->setAccessPermissions(ESP_GATT_PERM_WRITE_ENC_MITM); @@ -57,15 +67,18 @@ void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code uint32_t SerialBLEInterface::onPassKeyRequest() { BLE_DEBUG_PRINTLN("onPassKeyRequest()"); + _pairingRequestPending.store(true, std::memory_order_release); return _pin_code; } void SerialBLEInterface::onPassKeyNotify(uint32_t pass_key) { BLE_DEBUG_PRINTLN("onPassKeyNotify(%u)", pass_key); + _pairingRequestPending.store(true, std::memory_order_release); } bool SerialBLEInterface::onConfirmPIN(uint32_t pass_key) { BLE_DEBUG_PRINTLN("onConfirmPIN(%u)", pass_key); + _pairingRequestPending.store(true, std::memory_order_release); return true; } @@ -95,6 +108,9 @@ void SerialBLEInterface::onConnect(BLEServer* pServer) { void SerialBLEInterface::onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t *param) { BLE_DEBUG_PRINTLN("onConnect(), conn_id=%d, mtu=%d", param->connect.conn_id, pServer->getPeerMTU(param->connect.conn_id)); last_conn_id = param->connect.conn_id; + deviceConnected = false; // becomes usable only after authentication completes + notifySucceeded = false; + if (pTxDescriptor != NULL) pTxDescriptor->setNotifications(false); } void SerialBLEInterface::onMtuChanged(BLEServer* pServer, esp_ble_gatts_cb_param_t* param) { @@ -103,6 +119,9 @@ void SerialBLEInterface::onMtuChanged(BLEServer* pServer, esp_ble_gatts_cb_param void SerialBLEInterface::onDisconnect(BLEServer* pServer) { BLE_DEBUG_PRINTLN("onDisconnect()"); + deviceConnected = false; + notifySucceeded = false; + if (pTxDescriptor != NULL) pTxDescriptor->setNotifications(false); if (_isEnabled) { adv_restart_time = millis() + ADVERT_RESTART_DELAY; @@ -127,6 +146,15 @@ void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, esp_ble_gat } } +void SerialBLEInterface::onStatus(BLECharacteristic* pCharacteristic, Status status, uint32_t code) { + (void)pCharacteristic; + notifySucceeded = status == SUCCESS_NOTIFY; + if (!notifySucceeded) { + BLE_DEBUG_PRINTLN("notify failed, status=%d, code=%u; retaining frame", + (int)status, (unsigned)code); + } +} + // ---------- public methods void SerialBLEInterface::enable() { @@ -194,19 +222,31 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { if (send_queue_len > 0 // first, check send queue && millis() >= _last_write + BLE_WRITE_MIN_INTERVAL // space the writes apart ) { - _last_write = millis(); - pTxCharacteristic->setValue(send_queue[0].buf, send_queue[0].len); - pTxCharacteristic->notify(); + const uint16_t peer_mtu = pServer->getPeerMTU(last_conn_id); + const bool notifications_ready = pTxDescriptor != NULL && pTxDescriptor->getNotifications(); + const bool frame_fits = peer_mtu > 3 && send_queue[0].len <= peer_mtu - 3; - BLE_DEBUG_PRINTLN("writeBytes: sz=%d, hdr=%d", (uint32_t)send_queue[0].len, (uint32_t) send_queue[0].buf[0]); + // A fresh pairing can deliver the app's first command before its CCCD + // subscription or MTU exchange completes. Keep the response queued until + // both are ready instead of silently dropping/truncating device info. + if (notifications_ready && frame_fits) { + _last_write = millis(); + notifySucceeded = false; + pTxCharacteristic->setValue(send_queue[0].buf, send_queue[0].len); + pTxCharacteristic->notify(); - send_queue_len--; - for (int i = 0; i < send_queue_len; i++) { // delete top item from queue - send_queue[i] = send_queue[i + 1]; + if (notifySucceeded) { + BLE_DEBUG_PRINTLN("writeBytes: sz=%d, hdr=%d", (uint32_t)send_queue[0].len, (uint32_t) send_queue[0].buf[0]); + + send_queue_len--; + for (int i = 0; i < send_queue_len; i++) { // delete top item from queue + send_queue[i] = send_queue[i + 1]; + } + } } } - if (recv_queue_len > 0) { // check recv queue + if (deviceConnected && recv_queue_len > 0) { // check recv queue after authentication size_t len = recv_queue[0].len; // take from top of queue memcpy(dest, recv_queue[0].buf, len); @@ -219,8 +259,6 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { return len; } - if (pServer->getConnectedCount() == 0) deviceConnected = false; - if (deviceConnected != oldDeviceConnected) { if (!deviceConnected) { // disconnecting clearBuffers(); diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index a4fa433c..24e6c110 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -5,18 +5,22 @@ #include #include #include +#include class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLEServerCallbacks, BLECharacteristicCallbacks { BLEServer *pServer; BLEService *pService; BLECharacteristic * pTxCharacteristic; + BLE2902 *pTxDescriptor; bool deviceConnected; bool oldDeviceConnected; + bool notifySucceeded; bool _isEnabled; uint16_t last_conn_id; uint32_t _pin_code; unsigned long _last_write; unsigned long adv_restart_time; + std::atomic _pairingRequestPending{false}; struct Frame { uint8_t len; @@ -47,13 +51,17 @@ protected: // BLECharacteristicCallbacks methods void onWrite(BLECharacteristic* pCharacteristic, esp_ble_gatts_cb_param_t* param) override; + void onStatus(BLECharacteristic* pCharacteristic, Status status, uint32_t code) override; public: SerialBLEInterface() { pServer = NULL; pService = NULL; + pTxCharacteristic = NULL; + pTxDescriptor = NULL; deviceConnected = false; oldDeviceConnected = false; + notifySucceeded = false; adv_restart_time = 0; _isEnabled = false; _last_write = 0; @@ -79,6 +87,9 @@ public: bool isReadBusy() const override; bool isWriteBusy() const override; bool hasPendingIO() const override { return recv_queue_len > 0 || send_queue_len > 0; } + bool takePairingRequest() override { + return _pairingRequestPending.exchange(false, std::memory_order_acq_rel); + } size_t writeFrame(const uint8_t src[], size_t len) override; size_t checkRecvFrame(uint8_t dest[]) override; }; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 85aeccbd..fe6b49d8 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -8,16 +8,15 @@ #define STATE_TX_DONE 4 #define STATE_INT_READY 16 -#define NUM_NOISE_FLOOR_SAMPLES 16 +#define NUM_NOISE_FLOOR_SAMPLES 64 #define SAMPLING_THRESHOLD 14 // On-demand noise-floor calibration windows (RX duty-cycle powersaving only). // Requests are coalesced so retries cannot repeatedly force continuous RX. -#define NF_CALIB_INTERVAL_MS 300000UL // no more than once every five minutes +#define NF_CALIB_INTERVAL_MS 2000UL // match the original 2-second refresh cadence #define NF_CALIB_TIMEOUT_MS 5000UL // give up on the batch (busy channel) #define NF_CONTINUOUS_TIMEOUT_MS 1000UL // bound awake time without RX powersaving #define NF_CALIB_SETTLE_MS 20UL // frontend/AGC settle after RX entry -#define NF_SAMPLE_INTERVAL_MS 20UL // avoid back-to-back SPI RSSI reads static volatile uint8_t state = STATE_IDLE; @@ -57,7 +56,6 @@ void RadioLibWrapper::begin() { // Arm the bounded sampling window only after the radio actually reaches RX. // Companion initialization can take longer than the window itself. _nf_calib_deadline = 0; - _nf_next_sample_at = 0; } uint32_t RadioLibWrapper::getRngSeed() { @@ -170,11 +168,10 @@ void RadioLibWrapper::idle() { void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; - // With interference detection disabled, Dispatcher polling is a free flag - // update: passive retries request a refresh themselves only when the cached - // floor is stale. An enabled threshold keeps the floor periodically fresh; - // an invalid startup floor is retried even when interference detection is off. - if (threshold != 0 || !_noise_floor_valid) requestNoiseFloorRefresh(); + // Calibration is independent of interference detection. Callers such as the + // Dispatcher and KISS modem use a zero threshold but still expect a fresh + // floor measurement on every scheduled request. + requestNoiseFloorRefresh(); } void RadioLibWrapper::requestNoiseFloorRefresh() { @@ -183,7 +180,6 @@ void RadioLibWrapper::requestNoiseFloorRefresh() { _num_floor_samples = 0; _floor_sample_sum = 0; _nf_calib_deadline = 0; // starts when continuous RX is actually available - _nf_next_sample_at = 0; } void RadioLibWrapper::doResetAGC() { @@ -211,7 +207,6 @@ void RadioLibWrapper::resetAGC() { _num_floor_samples = 0; _floor_sample_sum = 0; _nf_calib_deadline = 0; // starts after reset recovery reaches RX - _nf_next_sample_at = 0; } void RadioLibWrapper::rxPsWatchdogCheck() { @@ -311,7 +306,6 @@ void RadioLibWrapper::noiseFloorCalibCheck(unsigned long now) { _nf_calib_active = true; _nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS; _nf_sample_from = now + NF_CALIB_SETTLE_MS; - _nf_next_sample_at = _nf_sample_from; _num_floor_samples = 0; // start a fresh batch for this window _floor_sample_sum = 0; state = STATE_IDLE; // recvRaw() re-arms; startReceiveMode() sees the @@ -324,7 +318,6 @@ void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) { _nf_refresh_requested = false; _nf_last_calib = now; _nf_calib_deadline = 0; - _nf_next_sample_at = 0; // force a receive re-arm back into duty-cycle mode, but don't clobber a // completed-but-unread packet or an in-flight TX (recvRaw()/onSendFinished() // will re-arm right after those anyway; same guard style as setRxPowerSaving) @@ -349,16 +342,25 @@ void RadioLibWrapper::loop() { } if (_nf_refresh_requested && _num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES && _floor_sample_sum != 0) { - _noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; - if (_noise_floor < -120) { - _noise_floor = -120; // clamp to lower bound of -120dBi + int16_t sampled_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; + if (sampled_floor < -120) { + sampled_floor = -120; // clamp to lower bound of -120dBi + } + if (_noise_floor_valid) { + // Favor the fresh high-rate batch while retaining a small amount of + // history: 25% previous floor + 75% newly sampled floor. Round the + // negative dBm result to the nearest integer instead of toward zero. + int32_t weighted_floor = (int32_t)_noise_floor + 3L * sampled_floor; + _noise_floor = weighted_floor < 0 ? (weighted_floor - 2) / 4 + : (weighted_floor + 2) / 4; + } else { + _noise_floor = sampled_floor; } _floor_sample_sum = 0; _noise_floor_valid = true; _nf_refresh_requested = false; _nf_last_calib = now; _nf_calib_deadline = 0; - _nf_next_sample_at = 0; MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); @@ -376,14 +378,12 @@ void RadioLibWrapper::loop() { _nf_refresh_requested = false; _nf_last_calib = now; _nf_calib_deadline = 0; - _nf_next_sample_at = 0; _num_floor_samples = 0; _floor_sample_sum = 0; } if (_nf_refresh_requested && state == STATE_RX - && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES - && (_nf_next_sample_at == 0 || (long)(now - _nf_next_sample_at) >= 0)) { + && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { // Noise floor is only sampled outside RX duty-cycle mode: continuously in // plain RX (powersaving off), or inside an on-demand calibration window // (powersaving on), skipping the first moments after RX entry there while @@ -392,7 +392,6 @@ void RadioLibWrapper::loop() { && !(_nf_calib_active && (long)(now - _nf_sample_from) < 0) && !isReceivingPacket()) { int rssi = getCurrentRSSI(); - _nf_next_sample_at = now + NF_SAMPLE_INTERVAL_MS; if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD _num_floor_samples++; _floor_sample_sum += rssi; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 376d8b42..d270ced4 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -61,7 +61,6 @@ protected: unsigned long _nf_last_calib; // millis of last completed/attempted window unsigned long _nf_calib_deadline; // abort window if the batch can't complete unsigned long _nf_sample_from; // no samples before this (RX entry settle) - unsigned long _nf_next_sample_at; // pace SPI RSSI reads during a sample batch void idle() override; void startRecv() override; @@ -99,8 +98,7 @@ public: _wd_stuck_thresh(0), _wd_observe_until(0), _wd_observe_ms(0), _cur_freq(0), _cur_bw(0), _cur_sf(0), _cur_cr(0), _cur_dbm(0), _cur_rx_boosted_gain(false), _params_valid(false), _dbm_valid(false), _rx_boosted_gain_valid(false), - _nf_calib_active(false), _nf_last_calib(0), _nf_calib_deadline(0), _nf_sample_from(0), - _nf_next_sample_at(0) + _nf_calib_active(false), _nf_last_calib(0), _nf_calib_deadline(0), _nf_sample_from(0) { n_recv = n_sent = n_recv_errors = n_wd_soft = n_wd_hard = 0; last_recv_millis = 0; diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index a7455b80..261ea9be 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -53,6 +53,9 @@ public: class TraceTestMesh : public mesh::Mesh { public: + bool forwardFloods = false; + bool floodRetriesAllowed = true; + TraceTestMesh(mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::PacketManager& mgr, mesh::MeshTables& tables) : mesh::Mesh(radio, ms, rng, rtc, mgr, tables) { } @@ -65,6 +68,34 @@ public: uint8_t group_data_gate) const { return applyGroupDataFloodRetryPathGate(packet, general_gate, group_data_gate); } + + uint8_t floodAttemptLimit(const mesh::Packet* packet, uint8_t role_max_attempts) const { + return applyFloodRetryAttemptPolicy(packet, role_max_attempts); + } + + uint32_t floodAttemptDelay(const mesh::Packet* packet, uint8_t attempt_idx = 0) { + return getFloodRetryAttemptDelay(packet, attempt_idx); + } + + void completePacketSend(mesh::Packet* packet) { + onSendComplete(packet); + } + + void receivePacket(mesh::Packet* packet) { + onRecvPacket(packet); + } + + mesh::DispatcherAction routePacket(mesh::Packet* packet) { + return routeRecvPacket(packet); + } + + bool allowPacketForward(const mesh::Packet*) override { + return forwardFloods; + } + + bool allowFloodRetry(const mesh::Packet*) const override { + return floodRetriesAllowed; + } }; TEST(RTCClock, UniqueSequenceCanFollowAnIntentionalBackwardCorrection) { @@ -124,6 +155,16 @@ static mesh::Packet* makeTrace(TraceTestMesh& node, uint32_t tag, uint32_t auth, return packet; } +static void initSelfAdvert(TraceTestMesh& node, mesh::Packet* packet, uint8_t marker) { + ASSERT_NE(packet, nullptr); + if (packet == nullptr) return; + packet->header = PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT; + packet->payload_len = PUB_KEY_SIZE + sizeof(uint32_t) + SIGNATURE_SIZE; + memset(packet->payload, 0, packet->payload_len); + memcpy(packet->payload, node.self_id.pub_key, PUB_KEY_SIZE); + packet->payload[packet->payload_len - 1] = marker; +} + TEST(TraceRetry, TraceAndAnonymousRequestsUseThreeAirtimes) { TraceTestClock clock; TraceTestRTC rtc; @@ -170,6 +211,294 @@ TEST(FloodRetry, GroupDataUsesTheStricterPathGate) { EXPECT_EQ(2, node.floodPathGate(&group_text, 2, 1)); } +TEST(FloodRetry, PayloadAndPathPolicyCapsEveryFloodType) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + + for (uint8_t type = 0; type <= PH_TYPE_MASK; type++) { + SCOPED_TRACE(static_cast(type)); + mesh::Packet packet; + packet.header = ROUTE_TYPE_FLOOD | (type << PH_TYPE_SHIFT); + packet.setPathHashSizeAndCount(1, 0); + + uint8_t origin_limit; + if (type == PAYLOAD_TYPE_REQ) { + origin_limit = 0; + } else if (type == PAYLOAD_TYPE_GRP_TXT || type == PAYLOAD_TYPE_RESPONSE + || type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_ANON_REQ + || type == PAYLOAD_TYPE_PATH) { + origin_limit = 15; + } else { + origin_limit = 1; + } + EXPECT_EQ(origin_limit, node.floodAttemptLimit(&packet, 15)); + EXPECT_EQ(0, node.floodAttemptLimit(&packet, 0)); + + packet.setPathHashSizeAndCount(1, 1); + uint8_t transit_limit; + if (type == PAYLOAD_TYPE_REQ) { + transit_limit = 0; + } else if (type == PAYLOAD_TYPE_GRP_TXT) { + transit_limit = 15; + } else if (type == PAYLOAD_TYPE_RESPONSE || type == PAYLOAD_TYPE_TXT_MSG + || type == PAYLOAD_TYPE_ANON_REQ || type == PAYLOAD_TYPE_PATH) { + transit_limit = 2; + } else { + transit_limit = 1; + } + EXPECT_EQ(transit_limit, node.floodAttemptLimit(&packet, 15)); + } +} + +TEST(FloodRetry, PayloadPolicyOnlyCapsAndNeverRaisesRoleCount) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + + mesh::Packet login_response; + login_response.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_RESPONSE << PH_TYPE_SHIFT); + login_response.setPathHashSizeAndCount(1, 0); + EXPECT_EQ(7, node.floodAttemptLimit(&login_response, 7)); + EXPECT_EQ(15, node.floodAttemptLimit(&login_response, 255)); + + login_response.setPathHashSizeAndCount(1, 3); + EXPECT_EQ(1, node.floodAttemptLimit(&login_response, 1)); + EXPECT_EQ(2, node.floodAttemptLimit(&login_response, 7)); + + mesh::Packet group_text; + group_text.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); + group_text.setPathHashSizeAndCount(1, 3); + EXPECT_EQ(7, node.floodAttemptLimit(&group_text, 7)); + EXPECT_EQ(15, node.floodAttemptLimit(&group_text, 255)); +} + +TEST(FloodRetry, OriginAdvertRetryHasAnExtraOneMinuteDelay) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + + mesh::Packet origin_advert; + initSelfAdvert(node, &origin_advert, 0x10); + origin_advert.header |= ROUTE_TYPE_FLOOD; + origin_advert.setPathHashSizeAndCount(1, 0); + mesh::Packet forwarded_advert = origin_advert; + forwarded_advert.setPathHashSizeAndCount(1, 1); + mesh::Packet foreign_origin_advert = origin_advert; + foreign_origin_advert.payload[0] ^= 0xFF; + mesh::Packet origin_group_text; + origin_group_text.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); + origin_group_text.setPathHashSizeAndCount(1, 0); + + uint32_t ordinary_delay = node.floodAttemptDelay(&origin_group_text); + EXPECT_EQ(ordinary_delay, node.floodAttemptDelay(&forwarded_advert)); + EXPECT_EQ(ordinary_delay, node.floodAttemptDelay(&foreign_origin_advert)); + EXPECT_EQ(ordinary_delay + 60000UL, node.floodAttemptDelay(&origin_advert)); +} + +TEST(FloodRetry, NewSelfAdvertReplacesTheOlderQueuedRetry) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + node.begin(); + + mesh::Packet* old_advert = manager.allocNew(); + ASSERT_NE(old_advert, nullptr); + initSelfAdvert(node, old_advert, 0x11); + ASSERT_TRUE(node.sendFlood(old_advert)); + ASSERT_EQ(1, manager.getOutboundTotal()); + + clock.now = 1; + node.loop(); + ASSERT_TRUE(radio.sending); + radio.complete = true; + clock.now = 2; + node.loop(); + ASSERT_EQ(1, manager.getOutboundTotal()); + mesh::Packet* old_retry = manager.getOutboundByIdx(0); + ASSERT_NE(old_retry, nullptr); + EXPECT_NE(old_advert, old_retry); + + mesh::Packet* group_data = manager.allocNew(); + ASSERT_NE(group_data, nullptr); + group_data->header = PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT; + group_data->payload_len = 1; + group_data->payload[0] = 0x33; + ASSERT_TRUE(node.sendFlood(group_data)); + clock.now = 3; + node.loop(); + ASSERT_TRUE(radio.sending); + radio.complete = true; + clock.now = 4; + node.loop(); + ASSERT_EQ(2, manager.getOutboundTotal()); + mesh::Packet* group_retry = NULL; + for (int i = 0; i < manager.getOutboundTotal(); i++) { + mesh::Packet* queued = manager.getOutboundByIdx(i); + if (queued != old_retry) group_retry = queued; + } + ASSERT_NE(group_retry, nullptr); + + mesh::Packet* new_advert = manager.allocNew(); + ASSERT_NE(new_advert, nullptr); + initSelfAdvert(node, new_advert, 0x22); + ASSERT_TRUE(node.sendFlood(new_advert)); + + ASSERT_EQ(2, manager.getOutboundTotal()); + bool found_new_advert = false; + bool found_group_retry = false; + for (int i = 0; i < manager.getOutboundTotal(); i++) { + mesh::Packet* queued = manager.getOutboundByIdx(i); + found_new_advert |= queued == new_advert; + found_group_retry |= queued == group_retry; + EXPECT_NE(old_retry, queued); + } + EXPECT_TRUE(found_new_advert); + EXPECT_TRUE(found_group_retry); +} + +TEST(FloodRetry, DisabledRetryIsRecheckedAfterInitialTxAndBeforeDelayedTx) { + { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + node.begin(); + + mesh::Packet* packet = manager.allocNew(); + ASSERT_NE(packet, nullptr); + packet->header = PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT; + packet->payload_len = 1; + packet->payload[0] = 0x44; + ASSERT_TRUE(node.sendFlood(packet)); + node.floodRetriesAllowed = false; + + clock.now = 1; + node.loop(); + ASSERT_TRUE(radio.sending); + radio.complete = true; + clock.now = 2; + node.loop(); + EXPECT_EQ(0, manager.getOutboundTotal()); + } + + { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + node.begin(); + + mesh::Packet* packet = manager.allocNew(); + ASSERT_NE(packet, nullptr); + packet->header = PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT; + packet->payload_len = 1; + packet->payload[0] = 0x55; + ASSERT_TRUE(node.sendFlood(packet)); + clock.now = 1; + node.loop(); + ASSERT_TRUE(radio.sending); + radio.complete = true; + clock.now = 2; + node.loop(); + ASSERT_EQ(1, manager.getOutboundTotal()); + + node.floodRetriesAllowed = false; + clock.now = 1000; + node.loop(); + EXPECT_FALSE(radio.sending); + EXPECT_EQ(0, manager.getOutboundTotal()); + } +} + +TEST(FloodRetry, RecentForwardedAdvertWithHeardEchoIsNotForwardedAgain) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + node.begin(); + node.forwardFloods = true; + rtc.now = 100000; + clock.now = 1000; + + mesh::Packet forwarded; + forwarded.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT); + forwarded.setPathHashSizeAndCount(1, 1); + forwarded.path[0] = 0x42; + forwarded.payload_len = PUB_KEY_SIZE + sizeof(uint32_t) + SIGNATURE_SIZE; + memset(forwarded.payload, 0x5A, forwarded.payload_len); + uint32_t emitted_timestamp = rtc.now - 60; + memcpy(&forwarded.payload[PUB_KEY_SIZE], &emitted_timestamp, sizeof(emitted_timestamp)); + node.completePacketSend(&forwarded); + + mesh::Packet echo = forwarded; + echo.setPathHashSizeAndCount(1, 2); + node.receivePacket(&echo); + + mesh::Packet repeated = forwarded; + EXPECT_EQ(ACTION_RELEASE, node.routePacket(&repeated)); + + rtc.now = emitted_timestamp + (6UL * 60UL * 60UL); + repeated = forwarded; + EXPECT_NE(ACTION_RELEASE, node.routePacket(&repeated)); +} + +TEST(FloodRetry, ForwardedAdvertEchoMayReturnThroughAnotherBranch) { + TraceTestClock clock; + TraceTestRTC rtc; + TraceTestRNG rng; + TraceTestRadio radio; + TraceTestTables tables; + StaticPoolPacketManager manager(12); + TraceTestMesh node(radio, clock, rng, rtc, manager, tables); + node.begin(); + node.forwardFloods = true; + rtc.now = 100000; + + mesh::Packet forwarded; + forwarded.header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_ADVERT << PH_TYPE_SHIFT); + forwarded.setPathHashSizeAndCount(1, 1); + forwarded.path[0] = 0x24; + forwarded.payload_len = PUB_KEY_SIZE + sizeof(uint32_t) + SIGNATURE_SIZE; + memset(forwarded.payload, 0xA5, forwarded.payload_len); + uint32_t emitted_timestamp = rtc.now - 60; + memcpy(&forwarded.payload[PUB_KEY_SIZE], &emitted_timestamp, sizeof(emitted_timestamp)); + node.completePacketSend(&forwarded); + + mesh::Packet other_branch = forwarded; + other_branch.setPathHashSizeAndCount(1, 2); + other_branch.path[0] ^= 0xFF; + node.receivePacket(&other_branch); + + mesh::Packet repeated = forwarded; + EXPECT_EQ(ACTION_RELEASE, node.routePacket(&repeated)); +} + TEST(TraceRetry, NewTraceReplacesQueuedRetryButAdvancedOldTraceStillQueues) { TraceTestClock clock; TraceTestRTC rtc; diff --git a/variants/rak4631/RAK4631Board.cpp b/variants/rak4631/RAK4631Board.cpp index 1b5698d0..2826e835 100644 --- a/variants/rak4631/RAK4631Board.cpp +++ b/variants/rak4631/RAK4631Board.cpp @@ -1,6 +1,8 @@ #include #include #include "nrf_gpio.h" +#include "nrf_sdm.h" +#include "nrf_soc.h" #include "RAK4631Board.h" @@ -39,7 +41,21 @@ void RAK4631Board::initiateShutdown(uint8_t reason) { #endif // NRF52_POWER_MANAGEMENT void RAK4631Board::begin() { +#ifdef RAK4631_COMPANION_FORCE_LDO + // Some RAK4631 battery/board combinations have proved unstable with the + // nRF52 DC/DC enabled. Explicitly select the LDO for companion firmware; + // merely skipping NRF52BoardDCDC::begin() would inherit any prior state. + NRF52Board::begin(); + uint8_t sd_enabled = 0; + sd_softdevice_is_enabled(&sd_enabled); + if (sd_enabled) { + sd_power_dcdc_mode_set(NRF_POWER_DCDC_DISABLE); + } else { + NRF_POWER->DCDCEN = 0; + } +#else NRF52BoardDCDC::begin(); +#endif pinMode(PIN_VBAT_READ, INPUT); #ifdef PIN_USER_BTN pinMode(PIN_USER_BTN, INPUT_PULLUP); @@ -64,4 +80,4 @@ void RAK4631Board::begin() { #endif digitalWrite(SX126X_POWER_EN, HIGH); delay(10); // give sx1262 some time to power up -} \ No newline at end of file +} diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 154adca5..af683724 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -217,6 +217,7 @@ board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld board_upload.maximum_size = 712704 build_flags = ${rak4631.build_flags} + -D RAK4631_COMPANION_FORCE_LDO=1 -I examples/companion_radio/ui-new -D PIN_USER_BTN=9 -D PIN_USER_BTN_ANA=31 @@ -241,6 +242,7 @@ build_unflags = -D EXTRAFS=1 build_flags = ${rak4631.build_flags} + -D RAK4631_COMPANION_FORCE_LDO=1 -I examples/companion_radio/ui-new -D PIN_USER_BTN=9 -D PIN_USER_BTN_ANA=31 @@ -267,6 +269,7 @@ board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld board_upload.maximum_size = 712704 build_flags = ${rak4631.build_flags} + -D RAK4631_COMPANION_FORCE_LDO=1 -I examples/companion_radio/ui-new -D PIN_USER_BTN=9 -D PIN_USER_BTN_ANA=31 diff --git a/variants/rak_wismesh_tag/platformio.ini b/variants/rak_wismesh_tag/platformio.ini index 0dd7d528..1efcfb51 100644 --- a/variants/rak_wismesh_tag/platformio.ini +++ b/variants/rak_wismesh_tag/platformio.ini @@ -9,6 +9,7 @@ build_flags = ${rak4631_hw.build_flags} -I src/helpers/ui -D RAK_WISMESH_TAG -D RAK_BOARD + -D DEFAULT_BUZZER_QUIET=1 -D P_LORA_TX_LED=LED_GREEN -D P_LORA_DIO_1=SX126X_DIO1 -D P_LORA_NSS=PIN_SPI_NSS diff --git a/variants/t1000-e/platformio.ini b/variants/t1000-e/platformio.ini index 37d37a45..0a35e54b 100644 --- a/variants/t1000-e/platformio.ini +++ b/variants/t1000-e/platformio.ini @@ -9,6 +9,7 @@ build_flags = ${rak4631_hw.build_flags} -I variants/t1000-e -I src/helpers/ui -D T1000_E + -D DEFAULT_BUZZER_QUIET=1 -D PIN_USER_BTN=6 -D USER_BTN_PRESSED=HIGH -D PIN_STATUS_LED=24