diff --git a/docs/cli_commands.md b/docs/cli_commands.md index ef1e5dcd..1facb639 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -481,6 +481,7 @@ send text.flood checking ridge link - Region hierarchy edits are not persistent until `region save` is run. After `region def west pnw wa w-wa sea`, run `region save` before enabling the alert if the hierarchy must survive a reboot. - A region must have a usable transport key. Public named regions derive one automatically; a private region without an available key is rejected. - The first alert is suppressed until the repeater has been up for at least 30 minutes. After that, the repeater checks every 30 minutes and sends low-battery warnings to the `#repeaters` channel in the selected region. +- Once an alert is successfully queued, another battery alert is suppressed for at least 12 hours. Battery recovery or toggling alerts off and back on does not bypass that cooldown during the same boot. - With `region def west pnw wa w-wa sea`, `set battery.alert on` selects `sea`; `set battery.alert on w-wa` overrides that default. - `get battery.alert.region` returns the selected scope, for example `> sea`. - The battery check never requests a wake earlier than its 30-minute deadline. If the normal loop is already awake when that deadline has elapsed, the check is effectively free of an additional wake. Time in light/event sleep counts toward the startup delay, and a queued alert keeps the repeater awake until the packet is handled. @@ -498,7 +499,7 @@ send text.flood checking ridge link - `battery.alert.low`: `20` - `battery.alert.critical`: `10` -**Note:** The low threshold must be greater than the critical threshold. +**Note:** The low threshold must be greater than the critical threshold. Alerts at or below the critical threshold use `CRITICAL BATTERY` in the message; both severities use the same 12-hour resend cooldown. --- @@ -1042,7 +1043,7 @@ del flood.channel.block.2 - **`name`** — Create `name` as a child of the current cursor (equivalent to `region put name` with the cursor as parent). Cursor moves to `name`. - **`name|jump`** *(or `name,jump`)* — Create `name` as a child of the current cursor, then move the cursor to `jump` (must already exist on the node, or have been created earlier in this command). `jump` is **not** the parent of `name`; use this form to pop back up and start another branch. -**Behavior:** Each created region defaults to flood-allowed (same as `region put`). The reply is the resulting region tree (same format as bare `region`); review it before running `region save` to persist. On error, the reply is `Err - ...` and any regions placed before the failure remain on the node, just like a partial chain of `region put`. +**Behavior:** Each created region defaults to flood-allowed (same as `region put`). The reply is the resulting region tree (same format as bare `region`); review it before running `region save` to persist. The command is transactional: invalid names, unknown or ambiguous jumps, table overflow, and hierarchy cycles return `Err - ...` without changing the existing tree. **Existing regions:** `region def` does not clear the existing tree — if a name already exists, its parent is updated to the current cursor; otherwise a new region is created. To start from scratch, `region remove` the unwanted regions first. @@ -1060,11 +1061,11 @@ region def a b c d|b e f region save ``` -**Example — error and partial state:** +**Example — transactional error:** ``` region def a b c|nope d ``` -The reply is `Err - unknown jump: nope`. `a`, `b`, and `c` were placed before the failure; `d` was not. Run `region` to inspect, then re-run with a corrected jump or repair with `region remove` / `region put`. +The reply is `Err - unknown or ambiguous jump: nope`. The existing tree is unchanged; re-run with a corrected jump. **Example — flat list** (each region a child of `*`). Use `|*` after each token to pop the cursor back to the root before the next token: ``` @@ -1624,6 +1625,7 @@ set direct.retry.cr 20.0,12.0,6.0,2.0 - If an unknown repeater fails, it is seeded into the table at `+2.75 dB`. - `set recent.repeater [snr_db]` seeds a missing prefix or adds another weighted sample for an existing prefix. - Successful `set recent.repeater` replies include the stored prefix and SNR, for example `OK - set A1B2C3 at 3.0 SNR`. +- Entries strictly older than 24 hours are removed during a sweep every three hours, so an entry can remain for at most approximately 27 hours. **Examples:** ``` diff --git a/docs/halo_keymind_settings.md b/docs/halo_keymind_settings.md index ec23b2c0..8009e48a 100644 --- a/docs/halo_keymind_settings.md +++ b/docs/halo_keymind_settings.md @@ -75,8 +75,8 @@ set flood.retry.ignore none | --- | --- | --- | --- | | `battery.alert` | Sends opt-in, region-scoped low-battery warnings to `#repeaters` after 30 minutes of uptime. | `get battery.alert`, `get battery.alert.region`, `set battery.alert on [region]`, `set battery.alert off` | `set battery.alert on sea` | | `battery.alert.low` | Warning threshold percentage. Must be greater than `battery.alert.critical`. | `get battery.alert.low`, `set battery.alert.low <1-100>` | `set battery.alert.low 20` | -| `battery.alert.critical` | Critical threshold percentage. Critical warnings repeat more often. | `get battery.alert.critical`, `set battery.alert.critical <0-99>` | `set battery.alert.critical 10` | -| `recent.repeater` | Shows, seeds, or clears the recent repeater prefix/SNR table used by direct retry and bridge freshness checks. | `get recent.repeater`, `get recent.repeater `, `set recent.repeater `, `clear recent.repeater` | `set recent.repeater A1B2C3 -8.5` | +| `battery.alert.critical` | Critical threshold percentage. Critical and warning alerts use the same 12-hour resend cooldown. | `get battery.alert.critical`, `set battery.alert.critical <0-99>` | `set battery.alert.critical 10` | +| `recent.repeater` | Shows, seeds, or clears the recent repeater prefix/SNR table used by direct retry and bridge freshness checks. Entries older than 24 hours are removed by a three-hour sweep. | `get recent.repeater`, `get recent.repeater `, `set recent.repeater `, `clear recent.repeater` | `set recent.repeater A1B2C3 -8.5` | | `flood.channel.data` | Turns forwarding of flood `GRP_DATA` channel packets on or off. With the default `on`, `GRP_DATA` repeats normally even when `flood.channel.block.hops` is set. | `get flood.channel.data`, `set flood.channel.data on/off` | `set flood.channel.data off` | | `flood.channel.data.hops` | Separate hop gate used only when `flood.channel.data` is `off`; `all` blocks `GRP_DATA` at any hop count, `1`-`7` repeats at that hop count or lower and blocks longer paths. | `get flood.channel.data.hops`, `set flood.channel.data.hops ` | `set flood.channel.data.hops 7` | | `flood.channel.block` | Blocks selected flood `GRP_TXT`/`GRP_DATA` channels when the key validates the packet. New repeater block lists start with editable/deletable `#wardriving h=4`. Add `h=` for a per-channel hop override. | `get flood.channel.block`, `set flood.channel.block[.n] [name] [h=...]`, `del flood.channel.block[.n]` | `set flood.channel.block #wardriving h=4` | @@ -105,8 +105,7 @@ checks every 30 minutes and sends a flood text warning to `#repeaters` when voltage is above `1 V` and the estimated battery percent is below `battery.alert.low`. -Warnings repeat every `24` hours, or every `12` hours when the estimate is -below `battery.alert.critical`. +Warnings and critical alerts both use a `12`-hour resend cooldown. Defaults: diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 84eca221..fa815618 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -117,6 +117,7 @@ #define DIRECT_SEND_PERHOP_FACTOR 6.0f #define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 #define LAZY_CONTACTS_WRITE_DELAY 5000 +#define EXPECTED_ACK_RETRY_RECHECK_MILLIS 1000 #ifndef DEFAULT_MULTI_ACKS #define DEFAULT_MULTI_ACKS 0 @@ -338,20 +339,27 @@ static bool calcFixedRxPowerSaving(uint8_t sf, float bw, uint32_t* rx_us, uint32 return true; } -static void applyFixedRxPowerSaving(uint8_t sf, float bw) { +static bool applyFixedRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { uint32_t rx_us, sleep_us; if (!calcFixedRxPowerSaving(sf, bw, &rx_us, &sleep_us)) { MESH_DEBUG_PRINTLN("RX Power Saving fixed profile invalid"); - return; + return false; } - bool ok = radio_driver.setRxPowerSaving(true, rx_us, sleep_us); + uint32_t timings[2] = {rx_us, sleep_us}; + const bool supports_rxps = radio_driver.supportsRxPowerSaving(); + // Keep ordinary radio configuration working on companion targets whose + // radio does not implement RX duty cycling. This matches the former + // setParams()+setRxPowerSaving() behavior while retaining one atomic + // transition on radios that do support it. + bool ok = radio_driver.setParams(freq, bw, sf, cr, supports_rxps ? timings : NULL); MESH_DEBUG_PRINTLN("RX Power Saving fixed level %d p%d: %s (%lu/%lu us)", RXPS_FIXED_LEVEL, RXPS_FIXED_PREAMBLE, - ok ? "Enabled" : "Unsupported", + ok && supports_rxps ? "Enabled" : (ok ? "Unsupported" : "Apply failed"), (unsigned long)rx_us, (unsigned long)sleep_us); + return ok; } #endif int MyMesh::calcRxDelay(float score, uint32_t air_time) const { @@ -521,10 +529,66 @@ void MyMesh::onContactPathUpdated(const ContactInfo &contact) { dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); } +void MyMesh::clearExpectedAck(AckTableEntry& entry, bool cancel_retries) { + if (cancel_retries && entry.ack != 0) { + cancelActiveRetries(entry.retry_key); + } + memset(&entry, 0, sizeof(entry)); +} + +void MyMesh::expireExpectedAcks() { + unsigned long now = _ms->getMillis(); + unsigned long nearest_delay = 0; + has_next_ack_expiry = false; + + for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { + AckTableEntry& entry = expected_ack_table[i]; + if (entry.ack == 0) { + continue; + } + + if (entry.expires_at == now || millisHasNowPassed(entry.expires_at)) { + if (!hasActiveRetries(entry.retry_key)) { + clearExpectedAck(entry, false); + continue; + } + // Keep the semantic match alive while its lower-level retry sequence is + // active, so a newer app submission can replace that sequence cleanly. + entry.expires_at = futureMillis(EXPECTED_ACK_RETRY_RECHECK_MILLIS); + } + + unsigned long delay = entry.expires_at - now; + if (!has_next_ack_expiry || delay < nearest_delay) { + nearest_delay = delay; + next_ack_expiry = entry.expires_at; + has_next_ack_expiry = true; + } + } + + if (!has_next_ack_expiry) { + next_ack_expiry = 0; + } +} + +MyMesh::AckTableEntry* MyMesh::findPendingTextMessage(const uint8_t text_fingerprint[MAX_HASH_SIZE]) { + expireExpectedAcks(); + for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { + AckTableEntry& entry = expected_ack_table[i]; + if (entry.ack != 0 + && memcmp(entry.text_fingerprint, text_fingerprint, MAX_HASH_SIZE) == 0) { + return &entry; + } + } + return NULL; +} + ContactInfo* MyMesh::processAck(const uint8_t *data) { + expireExpectedAcks(); + // see if matches any in a table for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { - if (memcmp(data, &expected_ack_table[i].ack, 4) == 0) { // got an ACK from recipient + if (expected_ack_table[i].ack != 0 + && memcmp(data, &expected_ack_table[i].ack, 4) == 0) { // got an ACK from recipient out_frame[0] = PUSH_CODE_SEND_CONFIRMED; memcpy(&out_frame[1], data, 4); uint32_t trip_time = _ms->getMillis() - expected_ack_table[i].msg_sent; @@ -532,8 +596,10 @@ ContactInfo* MyMesh::processAck(const uint8_t *data) { _serial->writeFrame(out_frame, 9); // NOTE: the same ACK can be received multiple times! - expected_ack_table[i].ack = 0; // clear expected hash, now that we have received ACK - return expected_ack_table[i].contact; + ContactInfo* contact = expected_ack_table[i].contact; + clearExpectedAck(expected_ack_table[i]); + expireExpectedAcks(); + return contact; } } return checkConnectionsAck(data); @@ -1006,17 +1072,23 @@ uint32_t MyMesh::calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t (path_hash_count + 1)); } -void MyMesh::onSendTimeout() {} +void MyMesh::onSendTimeout() { + expireExpectedAcks(); +} MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui) : BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables), _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) { _iter_started = false; _cli_rescue = false; + saved_radio_apply_pending = false; offline_queue_len = 0; app_target_ver = 0; clearPendingReqs(); + memset(expected_ack_table, 0, sizeof(expected_ack_table)); next_ack_idx = 0; + next_ack_expiry = 0; + has_next_ack_expiry = false; sign_data = NULL; dirty_contacts_expiry = 0; memset(advert_paths, 0, sizeof(advert_paths)); @@ -1128,19 +1200,26 @@ void MyMesh::begin(bool has_display) { addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel _store->loadChannels(this); - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - radio_driver.setTxPower(_prefs.tx_power_dbm); - radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + saved_radio_apply_pending = !applySavedRadioParams(); + if (!saved_radio_apply_pending) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + } board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); -#if RXPS_FIXED_ENABLED - applyFixedRxPowerSaving(_prefs.sf, _prefs.bw); -#endif MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); // NOTE: no FEM LNA wiring here — companion has its own NodePrefs without // radio_fem_rxgain, matching upstream (which also doesn't wire companion). } +bool MyMesh::applySavedRadioParams() { +#if RXPS_FIXED_ENABLED + return applyFixedRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); +#else + return radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); +#endif +} + const char *MyMesh::getNodeName() { return _prefs.node_name; } @@ -1261,6 +1340,17 @@ void MyMesh::handleCmdFrame(size_t len) { int tlen = len - i; uint32_t est_timeout; text[tlen] = 0; // ensure null + + uint8_t text_fingerprint[MAX_HASH_SIZE] = { 0 }; + uint8_t packet_retry_key[MAX_HASH_SIZE] = { 0 }; + AckTableEntry* replacement_entry = NULL; + if (txt_type == TXT_TYPE_PLAIN) { + mesh::Utils::sha256(text_fingerprint, sizeof(text_fingerprint), + recipient->id.pub_key, PUB_KEY_SIZE, + (const uint8_t*)text, strlen(text)); + replacement_entry = findPendingTextMessage(text_fingerprint); + } + int result; uint32_t expected_ack; if (txt_type == TXT_TYPE_CLI_DATA) { @@ -1268,17 +1358,39 @@ void MyMesh::handleCmdFrame(size_t len) { result = sendCommandData(*recipient, msg_timestamp, attempt, text, est_timeout); expected_ack = 0; // no Ack expected } else { - result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout); + result = sendMessage(*recipient, msg_timestamp, attempt, text, expected_ack, est_timeout, + packet_retry_key, + replacement_entry != NULL ? replacement_entry->retry_key : NULL); } - // TODO: add expected ACK to table if (result == MSG_SEND_FAILED) { writeErrFrame(ERR_CODE_TABLE_FULL); } else { + if (replacement_entry != NULL) { + // The newest successfully-composed submission wins. Keep the older + // sequence intact if packet allocation failed. sendMessage() already + // stopped only its matching retries before registering the new send. + clearExpectedAck(*replacement_entry, false); + } if (expected_ack) { - expected_ack_table[next_ack_idx].msg_sent = _ms->getMillis(); // add to circular table - expected_ack_table[next_ack_idx].ack = expected_ack; - expected_ack_table[next_ack_idx].contact = recipient; - next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; + AckTableEntry& entry = replacement_entry != NULL + ? *replacement_entry + : expected_ack_table[next_ack_idx]; + // Reusing a circular-table slot is only ACK bookkeeping. The evicted + // message still owns its lower-level retry sequence; only a semantic + // same-text replacement (cleared above) or a received ACK may cancel it. + clearExpectedAck(entry, false); + entry.msg_sent = _ms->getMillis(); // add to circular table + entry.expires_at = futureMillis(est_timeout); + entry.ack = expected_ack; + entry.contact = recipient; + memcpy(entry.text_fingerprint, text_fingerprint, sizeof(entry.text_fingerprint)); + memcpy(entry.retry_key, packet_retry_key, sizeof(entry.retry_key)); + if (replacement_entry == NULL) { + next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; + } + } + if (replacement_entry != NULL || expected_ack != 0) { + expireExpectedAcks(); } out_frame[0] = RESP_CODE_SENT; @@ -1579,17 +1691,24 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 && bw <= 500000) { + float new_freq = (float)freq / 1000.0; + float new_bw = (float)bw / 1000.0; +#if RXPS_FIXED_ENABLED + bool applied = applyFixedRadioParams(new_freq, new_bw, sf, cr); +#else + bool applied = radio_driver.setParams(new_freq, new_bw, sf, cr); +#endif + if (!applied) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + _prefs.sf = sf; _prefs.cr = cr; - _prefs.freq = (float)freq / 1000.0; - _prefs.bw = (float)bw / 1000.0; + _prefs.freq = new_freq; + _prefs.bw = new_bw; _prefs.client_repeat = repeat; savePrefs(); - - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); -#if RXPS_FIXED_ENABLED - applyFixedRxPowerSaving(_prefs.sf, _prefs.bw); -#endif MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, (uint32_t)cr); @@ -2454,6 +2573,20 @@ void MyMesh::checkSerialInterface() { void MyMesh::loop() { BaseChatMesh::loop(); + if (saved_radio_apply_pending && !hasOutbound()) { + // A power-saving wake can enter begin() with a complete packet already + // waiting. Preserve that packet, then apply the persisted radio settings + // once the receive/response path is idle. + radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + if (applySavedRadioParams()) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + saved_radio_apply_pending = false; + } + } + if (has_next_ack_expiry + && (next_ack_expiry == _ms->getMillis() || millisHasNowPassed(next_ack_expiry))) { + expireExpectedAcks(); + } if (emergency_client_repeat_packet != NULL && millisHasNowPassed(emergency_client_repeat_send_at)) { mesh::Packet* pkt = emergency_client_repeat_packet; emergency_client_repeat_packet = NULL; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 5f16ac4a..4b59595f 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -202,6 +202,7 @@ private: bool isValidClientRepeatFreq(uint32_t f) const; bool hasLocationTelemetryRecipient(); void updateGpsTelemetryPolicy(); + bool applySavedRadioParams(); // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } @@ -222,6 +223,7 @@ private: uint32_t _active_ble_pin; bool _iter_started; bool _cli_rescue; + bool saved_radio_apply_pending; bool send_unscoped; // force un-scoped flood (instead of using send_scope) char cli_command[80]; uint8_t app_target_ver; @@ -246,12 +248,21 @@ private: struct AckTableEntry { unsigned long msg_sent; + unsigned long expires_at; uint32_t ack; ContactInfo* contact; + uint8_t text_fingerprint[MAX_HASH_SIZE]; + uint8_t retry_key[MAX_HASH_SIZE]; }; #define EXPECTED_ACK_TABLE_SIZE 8 AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table int next_ack_idx; + unsigned long next_ack_expiry; + bool has_next_ack_expiry; + + void clearExpectedAck(AckTableEntry& entry, bool cancel_retries = true); + void expireExpectedAcks(); + AckTableEntry* findPendingTextMessage(const uint8_t text_fingerprint[MAX_HASH_SIZE]); #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index c02dd456..c1458d2e 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -103,8 +103,7 @@ #define LOW_BATTERY_MIN_VALID_MV 1000 #define LOW_BATTERY_STARTUP_DELAY (30ULL * 60ULL * 1000ULL) #define LOW_BATTERY_CHECK_INTERVAL (30UL * 60UL * 1000UL) -#define LOW_BATTERY_WARN_INTERVAL (24UL * 60UL * 60UL * 1000UL) -#define LOW_BATTERY_CRITICAL_INTERVAL (12UL * 60UL * 60UL * 1000UL) +#define LOW_BATTERY_ALERT_INTERVAL (12UL * 60UL * 60UL * 1000UL) static const char* skipLocalSpaces(const char* text) { while (text != NULL && *text == ' ') text++; @@ -2003,6 +2002,21 @@ void MyMesh::clearRecentRepeaters() { } } +void MyMesh::expireRecentRepeatersIfDue() { + if (!next_recent_repeater_sweep || !millisHasNowPassed(next_recent_repeater_sweep)) { + return; + } + + SimpleMeshTables* tables = static_cast(getTables()); + if (tables != NULL) { + int expired = tables->expireRecentRepeaters(_ms->getMillis(), RECENT_REPEATER_MAX_AGE_MILLIS); + if (expired > 0) { + MESH_DEBUG_PRINTLN("Recent repeaters: expired %d entries", expired); + } + } + next_recent_repeater_sweep = futureMillis(RECENT_REPEATER_SWEEP_INTERVAL_MILLIS); +} + mesh::DispatcherAction MyMesh::onRecvPacket(mesh::Packet* pkt) { if (pkt->getRouteType() == ROUTE_TYPE_TRANSPORT_FLOOD) { recv_pkt_region = region_map.findMatch(pkt, REGION_DENY_FLOOD); @@ -2299,12 +2313,15 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc uptime_millis = 0; next_local_advert = next_flood_advert = 0; next_battery_alert_check = 0; + next_recent_repeater_sweep = 0; last_battery_alert_sent = 0; battery_alert_sent = false; dirty_contacts_expiry = 0; active_bw = 0.0f; active_sf = 0; active_cr = 0; + saved_radio_apply_pending = false; + temp_radio_handoff_pending = false; memset(scheduled_radio_settings, 0, sizeof(scheduled_radio_settings)); _logging = false; region_load_active = false; @@ -2491,10 +2508,11 @@ void MyMesh::begin(FILESYSTEM *fs) { _alerter.setBridge(mqtt_bridge); #endif - applySavedRadioParams(); - radio_driver.setTxPower(_prefs.tx_power_dbm); - - radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + saved_radio_apply_pending = !applySavedRadioParams(); + if (!saved_radio_apply_pending) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + } MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); // LoRa FEM LNA (FEM boards only) @@ -2502,6 +2520,7 @@ void MyMesh::begin(FILESYSTEM *fs) { updateAdvertTimer(); updateFloodAdvertTimer(); + next_recent_repeater_sweep = futureMillis(RECENT_REPEATER_SWEEP_INTERVAL_MILLIS); #if ENV_INCLUDE_GPS == 1 applyGpsPrefs(); @@ -2649,22 +2668,37 @@ bool MyMesh::sendRepeatersFloodText(const char* text, const TransportKey* scope) void MyMesh::checkBatteryAlert() { if (!_prefs.battery_alert_enabled) { - battery_alert_sent = false; - return; - } - - // Ignore startup voltage sag and give solar/charger hardware time to settle. - // uptime_millis is 64-bit and includes time spent in the platform's light or - // event sleep, so this guard remains reliable across millis() wraparound. - if (uptime_millis < LOW_BATTERY_STARTUP_DELAY) { return; } if (next_battery_alert_check && !millisHasNowPassed(next_battery_alert_check)) { return; } + + // Ignore startup voltage sag and give solar/charger hardware time to settle. + // uptime_millis is 64-bit and includes time spent in the platform's light or + // event sleep, so this guard remains reliable across millis() wraparound. + // Arm the remaining startup delay once so subsequent loops use the cheaper + // 32-bit deadline check above and powersaving can include it as a wake limit. + if (uptime_millis < LOW_BATTERY_STARTUP_DELAY) { + next_battery_alert_check = futureMillis( + (unsigned long)(LOW_BATTERY_STARTUP_DELAY - uptime_millis)); + return; + } + next_battery_alert_check = futureMillis(LOW_BATTERY_CHECK_INTERVAL); + // A successful queue operation starts one fixed cooldown. Do this before + // region resolution and ADC sampling so repeat checks during the cooldown + // stay as cheap as possible. Battery recovery and alert toggles must not + // bypass the cooldown within this boot. + if (battery_alert_sent) { + if (uptime_millis - last_battery_alert_sent < LOW_BATTERY_ALERT_INTERVAL) { + return; + } + battery_alert_sent = false; + } + // Check the cheap configuration path first. This avoids powering the ADC or // battery-divider circuitry when the selected region has been removed or no // longer has a usable transport key. @@ -2676,34 +2710,44 @@ void MyMesh::checkBatteryAlert() { uint16_t batt_mv = board.getBattMilliVolts(); uint8_t batt_pct = batteryPercentFromMilliVolts(batt_mv); if (batt_mv <= LOW_BATTERY_MIN_VALID_MV || batt_pct >= _prefs.battery_alert_low_percent) { - battery_alert_sent = false; - return; - } - - unsigned long interval = batt_pct <= _prefs.battery_alert_critical_percent - ? LOW_BATTERY_CRITICAL_INTERVAL - : LOW_BATTERY_WARN_INTERVAL; - if (battery_alert_sent && !millisHasNowPassed(last_battery_alert_sent + interval)) { return; } char text[96]; - snprintf(text, sizeof(text), "LOW BATTERY %u%% (%u mV)", (uint32_t)batt_pct, (uint32_t)batt_mv); + const char* severity = batt_pct <= _prefs.battery_alert_critical_percent + ? "CRITICAL BATTERY" + : "LOW BATTERY"; + snprintf(text, sizeof(text), "%s %u%% (%u mV)", severity, (uint32_t)batt_pct, (uint32_t)batt_mv); if (sendRepeatersFloodText(text, &alert_scope)) { battery_alert_sent = true; - last_battery_alert_sent = millis(); + last_battery_alert_sent = uptime_millis; } } -void MyMesh::applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { - radio_driver.setParams(freq, bw, sf, cr); +bool MyMesh::applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr) { + uint32_t rx_us = _prefs.rx_ps_rx_us; + uint32_t sleep_us = _prefs.rx_ps_sleep_us; + if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0) { + uint32_t preamble = _prefs.rx_ps_preamble ? _prefs.rx_ps_preamble : (sf <= 8 ? 32UL : 16UL); + if (!CommonCLI::calculateRxPowerSavingLevel( + _prefs.rx_ps_level, sf, bw, preamble, &rx_us, &sleep_us)) return false; + } + uint32_t timings[2] = {rx_us, sleep_us}; + const uint32_t* applied_timings = _prefs.rx_powersaving_enabled + && radio_driver.supportsRxPowerSaving() ? timings : NULL; + if (!radio_driver.setParams(freq, bw, sf, cr, + applied_timings)) { + MESH_DEBUG_PRINTLN("Radio schedule: radio busy or parameter apply failed"); + return false; + } active_bw = bw; active_sf = sf; active_cr = cr; + return true; } -void MyMesh::applySavedRadioParams() { - applyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); +bool MyMesh::applySavedRadioParams() { + return applyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); } bool MyMesh::hasStartedScheduledTempRadio() const { @@ -2819,8 +2863,13 @@ void MyMesh::clearScheduledRadioSetting(int idx, bool restore_if_started) { && scheduled_radio_settings[idx].started; scheduled_radio_settings[idx].active = false; scheduled_radio_settings[idx].started = false; + if (scheduled_radio_settings[idx].temporary && temp_radio_handoff_pending + && countScheduledRadioSettings(true) == 0) { + temp_radio_handoff_pending = false; + saved_radio_apply_pending = true; + } if (restore_radio && !hasStartedScheduledTempRadio()) { - applySavedRadioParams(); + saved_radio_apply_pending = true; } } @@ -3012,7 +3061,11 @@ void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, ch } } if (restore_radio && !hasStartedScheduledTempRadio()) { - applySavedRadioParams(); + saved_radio_apply_pending = true; + } + if (temporary && temp_radio_handoff_pending) { + temp_radio_handoff_pending = false; + saved_radio_apply_pending = true; } snprintf(reply, 160, "OK - deleted %d", deleted); return; @@ -3034,9 +3087,12 @@ void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, ch } void MyMesh::processScheduledRadioSettings() { + // Never touch modulation registers while a packet is still on air. Due work + // remains queued and is retried on the first loop after TX completion. + if (hasOutbound()) return; + uint32_t now = getRTCClock()->getCurrentTime(); bool saved_params_changed = false; - bool temp_ended = false; while (true) { int due_idx = -1; @@ -3059,41 +3115,55 @@ void MyMesh::processScheduledRadioSettings() { _prefs.bw = setting.bw; _prefs.sf = setting.sf; _prefs.cr = setting.cr; - savePrefs(); setting.active = false; setting.started = false; saved_params_changed = true; } - if (saved_params_changed && !hasStartedScheduledTempRadio()) { - applySavedRadioParams(); + if (saved_params_changed) { + // Keep level-derived RX duty-cycle windows synchronized with the newly + // persisted SF/BW. Manual RX/sleep timings intentionally remain fixed. + CommonCLI::recalculateRxPowerSavingFromLevel(&_prefs); + savePrefs(); + saved_radio_apply_pending = true; } for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { ScheduledRadioSetting& setting = scheduled_radio_settings[i]; - if (setting.active && setting.temporary && setting.started && now >= setting.end_time - && !hasOutbound()) { + if (setting.active && setting.temporary && setting.started && now >= setting.end_time) { setting.active = false; setting.started = false; - temp_ended = true; + saved_radio_apply_pending = true; } } - if (temp_ended && !hasStartedScheduledTempRadio()) { - applySavedRadioParams(); - } - for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { ScheduledRadioSetting& setting = scheduled_radio_settings[i]; if (setting.active && setting.temporary && !setting.started && now >= setting.start_time) { if (now >= setting.end_time) { setting.active = false; - } else { - applyRadioParams(setting.freq, setting.bw, setting.sf, setting.cr); + if (temp_radio_handoff_pending) { + temp_radio_handoff_pending = false; + saved_radio_apply_pending = true; + } + } else if (applyRadioParams(setting.freq, setting.bw, setting.sf, setting.cr)) { setting.started = true; + temp_radio_handoff_pending = false; } } } + + if (saved_radio_apply_pending && !temp_radio_handoff_pending + && !hasStartedScheduledTempRadio()) { + // If begin() deferred the saved params to preserve a wake packet, its gain + // update was deferred for the same reason. Retry both at the first safe + // handoff; unsupported boosted-gain modes remain harmless here. + radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); + if (applySavedRadioParams()) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + saved_radio_apply_pending = false; + } + } } bool MyMesh::isMillisTimerDue(unsigned long timestamp) const { @@ -3101,6 +3171,8 @@ bool MyMesh::isMillisTimerDue(unsigned long timestamp) const { } bool MyMesh::hasScheduledRadioWorkDue() const { + if (saved_radio_apply_pending) return true; + uint32_t now = getRTCClock()->getCurrentTime(); for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; @@ -3172,6 +3244,7 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { sleep_secs = limitSleepToMillisTimer(next_flood_advert, sleep_secs); sleep_secs = limitSleepToMillisTimer(next_local_advert, sleep_secs); sleep_secs = limitSleepToMillisTimer(dirty_contacts_expiry, sleep_secs); + sleep_secs = limitSleepToMillisTimer(next_recent_repeater_sweep, sleep_secs); if (_prefs.battery_alert_enabled) { sleep_secs = limitSleepToMillisTimer(next_battery_alert_check, sleep_secs); } @@ -3180,15 +3253,27 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { } void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { + bool cancelled_started_temp = false; for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { if (scheduled_radio_settings[i].active && scheduled_radio_settings[i].temporary) { + cancelled_started_temp = cancelled_started_temp || scheduled_radio_settings[i].started; scheduled_radio_settings[i].active = false; scheduled_radio_settings[i].started = false; } } + if (cancelled_started_temp) { + // Keep the currently-active channel long enough for the CLI reply and use + // the new temporary entry as an explicit handoff. If that entry expires or + // is deleted before applying, the scheduler restores saved parameters. + temp_radio_handoff_pending = true; + } int slot = findFreeScheduledRadioSlot(); if (slot < 0) { + if (temp_radio_handoff_pending) { + temp_radio_handoff_pending = false; + saved_radio_apply_pending = true; + } return; } @@ -3876,10 +3961,19 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation - region_map = temp_map; // copy over the temp instance as new current map region_load_active = false; - - sprintf(reply, "OK - loaded %d regions", region_map.getCount()); + // resetFrom() preserves the selected IDs. Reject a replacement that + // omitted either selected region instead of leaving a dangling ID. + bool missing_default = region_map.getDefaultRegion() != NULL + && temp_map.getDefaultRegion() == NULL; + bool missing_home = region_map.getHomeRegion() != NULL + && temp_map.getHomeRegion() == NULL; + if (!missing_default && !missing_home) { + region_map = temp_map; + sprintf(reply, "OK - loaded %d regions", region_map.getCount()); + } else { + strcpy(reply, "Err - invalid region map; previous map retained"); + } } else { char *np = command; while (*np == ' ') np++; // skip indent @@ -4060,13 +4154,11 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char * sizeof(_prefs.battery_alert_region)); _prefs.battery_alert_enabled = 1; next_battery_alert_check = 0; - battery_alert_sent = false; savePrefs(); sprintf(reply, "OK - battery alerts scoped to %s", _prefs.battery_alert_region); } else if (strcmp(value, "off") == 0) { _prefs.battery_alert_enabled = 0; next_battery_alert_check = 0; - battery_alert_sent = false; savePrefs(); strcpy(reply, "OK"); } else { @@ -4115,6 +4207,7 @@ void MyMesh::loop() { // MQTT processing runs in a separate FreeRTOS task on Core 0, so we don't call bridge.loop() here mesh::Mesh::loop(); checkBatteryAlert(); + expireRecentRepeatersIfDue(); #if defined(WITH_BRIDGE) && !defined(WITH_MQTT_BRIDGE) // MQTT runs its own task; serial and ESP-NOW bridges remain cooperative. @@ -4201,6 +4294,7 @@ bool MyMesh::hasPendingWork() const { if (_mgr->getOutboundTotal() > 0) return true; if (isMillisTimerDue(next_flood_advert) || isMillisTimerDue(next_local_advert)) return true; if (isMillisTimerDue(dirty_contacts_expiry)) return true; + if (isMillisTimerDue(next_recent_repeater_sweep)) return true; if (_prefs.battery_alert_enabled && isMillisTimerDue(next_battery_alert_check)) return true; return hasScheduledRadioWorkDue(); } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 9173a480..55dd3ea5 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -12,6 +12,18 @@ #ifndef MESH_ENABLE_RECENT_REPEATERS #define MESH_ENABLE_RECENT_REPEATERS 1 #endif +#ifndef MAX_RECENT_REPEATERS + // Only repeater firmware supplies this RAM-heavy history storage. + #if !MESH_ENABLE_RECENT_REPEATERS + #define MAX_RECENT_REPEATERS 0 + #elif defined(ESP32) || defined(ESP32_PLATFORM) + #define MAX_RECENT_REPEATERS 2048 + #elif defined(NRF52_PLATFORM) + #define MAX_RECENT_REPEATERS 512 + #else + #define MAX_RECENT_REPEATERS 64 + #endif +#endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include @@ -101,6 +113,9 @@ struct NeighbourInfo { #define MAX_SCHEDULED_RADIO_SETTINGS (MAX_SCHEDULED_RADIO_SETTINGS_PER_TYPE * 2) +#define RECENT_REPEATER_MAX_AGE_MILLIS (24UL * 60UL * 60UL * 1000UL) +#define RECENT_REPEATER_SWEEP_INTERVAL_MILLIS (3UL * 60UL * 60UL * 1000UL) + class MyMesh : public mesh::Mesh, public CommonCLICallbacks { struct ScheduledRadioSetting { bool active; @@ -119,7 +134,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint64_t uptime_millis; unsigned long next_local_advert, next_flood_advert; unsigned long next_battery_alert_check; - unsigned long last_battery_alert_sent; + unsigned long next_recent_repeater_sweep; + uint64_t last_battery_alert_sent; bool battery_alert_sent; bool _logging; NodePrefs _prefs; @@ -171,6 +187,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { float active_bw; // live BW, including temporary radio overrides uint8_t active_sf; // live SF, including temporary radio overrides uint8_t active_cr; // live CR, including temporary radio overrides + bool saved_radio_apply_pending; + bool temp_radio_handoff_pending; ScheduledRadioSetting scheduled_radio_settings[MAX_SCHEDULED_RADIO_SETTINGS]; int matching_peer_indexes[MAX_CLIENTS]; #if defined(WITH_MQTT_BRIDGE) @@ -240,12 +258,13 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { bool getBatteryAlertScopeForRegion(const RegionEntry& region, TransportKey& scope); bool resolveBatteryAlertScope(TransportKey& scope); void checkBatteryAlert(); + void expireRecentRepeatersIfDue(); void printRecentRepeatersSerial(); File openAppend(const char* fname); bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); - void applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr); - void applySavedRadioParams(); + bool applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr); + bool applySavedRadioParams(); void processScheduledRadioSettings(); bool isMillisTimerDue(unsigned long timestamp) const; void loadFloodChannelBlocks(); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index c9587804..2eb82b5f 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -9,7 +9,12 @@ #endif StdRNG fast_rng; +#if MAX_RECENT_REPEATERS > 0 +SimpleMeshTables::RecentRepeaterInfo recent_repeater_storage[MAX_RECENT_REPEATERS]; +SimpleMeshTables tables(recent_repeater_storage, MAX_RECENT_REPEATERS); +#else SimpleMeshTables tables; +#endif MyMesh the_mesh(board, radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock, tables); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index a4365730..eabf4abe 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -652,6 +652,9 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _logging = false; region_load_active = false; set_radio_at = revert_radio_at = 0; + active_cr = LORA_CR; + temp_radio_applied = false; + saved_radio_apply_pending = false; recv_pkt_region = NULL; // defaults @@ -741,8 +744,10 @@ void MyMesh::begin(FILESYSTEM *fs) { } } - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - radio_driver.setTxPower(_prefs.tx_power_dbm); + saved_radio_apply_pending = !applySavedRadioParams(); + if (!saved_radio_apply_pending) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + } board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); // LoRa FEM LNA (FEM boards only) setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us); @@ -793,6 +798,18 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif } +bool MyMesh::applySavedRadioParams() { + uint32_t timings[2] = {_prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us}; + const uint32_t* applied_timings = _prefs.rx_powersaving_enabled + && radio_driver.supportsRxPowerSaving() ? timings : NULL; + if (!radio_driver.setParams( + _prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, applied_timings)) { + return false; + } + active_cr = _prefs.cr; + return true; +} + void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis, uint8_t path_hash_size) { if (scope.isNull()) { sendFlood(pkt, delay_millis, path_hash_size); @@ -969,10 +986,19 @@ void MyMesh::formatPacketStatsReply(char *reply) { void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation - region_map = temp_map; // copy over the temp instance as new current map region_load_active = false; - - sprintf(reply, "OK - loaded %d regions", region_map.getCount()); + // resetFrom() preserves the selected IDs. Reject a replacement that + // omitted either selected region instead of leaving a dangling ID. + bool missing_default = region_map.getDefaultRegion() != NULL + && temp_map.getDefaultRegion() == NULL; + bool missing_home = region_map.getHomeRegion() != NULL + && temp_map.getHomeRegion() == NULL; + if (!missing_default && !missing_home) { + region_map = temp_map; + sprintf(reply, "OK - loaded %d regions", region_map.getCount()); + } else { + strcpy(reply, "Err - invalid region map; previous map retained"); + } } else { char *np = command; while (*np == ' ') np++; // skip indent @@ -1119,16 +1145,48 @@ void MyMesh::loop() { updateAdvertTimer(); // schedule next local advert } - if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params - set_radio_at = 0; // clear timer - radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); - MESH_DEBUG_PRINTLN("Temp radio params"); + const bool revert_radio_due = revert_radio_at && millisHasNowPassed(revert_radio_at); + if (revert_radio_due && !temp_radio_applied) { + // The temporary window ended before it could be applied. Drop both timers + // so a previously busy radio cannot switch to the expired channel later. + set_radio_at = revert_radio_at = 0; + MESH_DEBUG_PRINTLN("Temp radio params expired before apply"); + } else if (revert_radio_due && !hasOutbound()) { + if (applySavedRadioParams()) { + if (saved_radio_apply_pending) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + } + set_radio_at = revert_radio_at = 0; + temp_radio_applied = false; + saved_radio_apply_pending = false; + MESH_DEBUG_PRINTLN("Radio params restored"); + } + } else if (set_radio_at && millisHasNowPassed(set_radio_at) && !hasOutbound()) { + uint32_t rx_us = _prefs.rx_ps_rx_us; + uint32_t sleep_us = _prefs.rx_ps_sleep_us; + bool timing_ok = true; + if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0) { + uint32_t preamble = _prefs.rx_ps_preamble ? _prefs.rx_ps_preamble + : (pending_sf <= 8 ? 32UL : 16UL); + timing_ok = CommonCLI::calculateRxPowerSavingLevel( + _prefs.rx_ps_level, pending_sf, pending_bw, preamble, &rx_us, &sleep_us); + } + uint32_t timings[2] = {rx_us, sleep_us}; + const uint32_t* applied_timings = _prefs.rx_powersaving_enabled + && radio_driver.supportsRxPowerSaving() ? timings : NULL; + if (timing_ok && radio_driver.setParams( + pending_freq, pending_bw, pending_sf, pending_cr, applied_timings)) { + set_radio_at = 0; + active_cr = pending_cr; + temp_radio_applied = true; + MESH_DEBUG_PRINTLN("Temp radio params"); + } } - if (revert_radio_at && millisHasNowPassed(revert_radio_at) && !hasOutbound()) { // revert radio params to orig - revert_radio_at = 0; // clear timer - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - MESH_DEBUG_PRINTLN("Radio params restored"); + if (saved_radio_apply_pending && !temp_radio_applied && !hasOutbound() + && applySavedRadioParams()) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + saved_radio_apply_pending = false; } // is pending dirty contacts write needed? diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 9673e842..0ca5c2dc 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -122,6 +122,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { float pending_bw; uint8_t pending_sf; uint8_t pending_cr; + uint8_t active_cr; + bool temp_radio_applied; + bool saved_radio_apply_pending; int matching_peer_indexes[MAX_CLIENTS]; #ifdef WITH_MQTT_BRIDGE MQTTBridge* bridge; @@ -131,6 +134,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { #endif void addPost(ClientInfo* client, const char* postData); + bool applySavedRadioParams(); void pushPostToClient(ClientInfo* client, PostInfo& post); uint8_t getUnsyncedCount(ClientInfo* client); bool processAck(const uint8_t *data); @@ -141,7 +145,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { protected: #if defined(ENABLE_OTA) bool isTempRadioActive() const override { - return set_radio_at == 0 && revert_radio_at != 0 && !millisHasNowPassed(revert_radio_at); + return temp_radio_applied && revert_radio_at != 0 && !millisHasNowPassed(revert_radio_at); } #endif float getAirtimeBudgetFactor() const override { @@ -171,7 +175,7 @@ protected: return _prefs.multi_acks; } uint8_t getDefaultTxCodingRate() const override { - return set_radio_at == 0 && revert_radio_at != 0 ? pending_cr : _prefs.cr; + return active_cr; } mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 6cabc3b0..4020362b 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -750,6 +750,9 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise last_read_time = 0; num_alert_tasks = 0; set_radio_at = revert_radio_at = 0; + active_cr = LORA_CR; + temp_radio_applied = false; + saved_radio_apply_pending = false; // defaults memset(&_prefs, 0, sizeof(_prefs)); @@ -816,8 +819,10 @@ void SensorMesh::begin(FILESYSTEM* fs) { } } - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - radio_driver.setTxPower(_prefs.tx_power_dbm); + saved_radio_apply_pending = !applySavedRadioParams(); + if (!saved_radio_apply_pending) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + } board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); // LoRa FEM LNA (FEM boards only) setRxPowerSaving(_prefs.rx_powersaving_enabled, _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us); @@ -831,6 +836,18 @@ void SensorMesh::begin(FILESYSTEM* fs) { #endif } +bool SensorMesh::applySavedRadioParams() { + uint32_t timings[2] = {_prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us}; + const uint32_t* applied_timings = _prefs.rx_powersaving_enabled + && radio_driver.supportsRxPowerSaving() ? timings : NULL; + if (!radio_driver.setParams( + _prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr, applied_timings)) { + return false; + } + active_cr = _prefs.cr; + return true; +} + bool SensorMesh::formatFileSystem() { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) return InternalFS.format(); @@ -969,16 +986,47 @@ void SensorMesh::loop() { updateAdvertTimer(); // schedule next local advert } - if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params - set_radio_at = 0; // clear timer - radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); - MESH_DEBUG_PRINTLN("Temp radio params"); + const bool revert_radio_due = revert_radio_at && millisHasNowPassed(revert_radio_at); + if (revert_radio_due && !temp_radio_applied) { + // Never apply a temporary channel after its window has already ended. + set_radio_at = revert_radio_at = 0; + MESH_DEBUG_PRINTLN("Temp radio params expired before apply"); + } else if (revert_radio_due && !hasOutbound()) { + if (applySavedRadioParams()) { + if (saved_radio_apply_pending) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + } + set_radio_at = revert_radio_at = 0; + temp_radio_applied = false; + saved_radio_apply_pending = false; + MESH_DEBUG_PRINTLN("Radio params restored"); + } + } else if (set_radio_at && millisHasNowPassed(set_radio_at) && !hasOutbound()) { + uint32_t rx_us = _prefs.rx_ps_rx_us; + uint32_t sleep_us = _prefs.rx_ps_sleep_us; + bool timing_ok = true; + if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0) { + uint32_t preamble = _prefs.rx_ps_preamble ? _prefs.rx_ps_preamble + : (pending_sf <= 8 ? 32UL : 16UL); + timing_ok = CommonCLI::calculateRxPowerSavingLevel( + _prefs.rx_ps_level, pending_sf, pending_bw, preamble, &rx_us, &sleep_us); + } + uint32_t timings[2] = {rx_us, sleep_us}; + const uint32_t* applied_timings = _prefs.rx_powersaving_enabled + && radio_driver.supportsRxPowerSaving() ? timings : NULL; + if (timing_ok && radio_driver.setParams( + pending_freq, pending_bw, pending_sf, pending_cr, applied_timings)) { + set_radio_at = 0; + active_cr = pending_cr; + temp_radio_applied = true; + MESH_DEBUG_PRINTLN("Temp radio params"); + } } - if (revert_radio_at && millisHasNowPassed(revert_radio_at) && !hasOutbound()) { // revert radio params to orig - revert_radio_at = 0; // clear timer - radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); - MESH_DEBUG_PRINTLN("Radio params restored"); + if (saved_radio_apply_pending && !temp_radio_applied && !hasOutbound() + && applySavedRadioParams()) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + saved_radio_apply_pending = false; } uint32_t curr = getRTCClock()->getCurrentTime(); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 3d0f6415..6916ee37 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -86,7 +86,7 @@ public: protected: #if defined(ENABLE_OTA) bool isTempRadioActive() const override { - return set_radio_at == 0 && revert_radio_at != 0 && !millisHasNowPassed(revert_radio_at); + return temp_radio_applied && revert_radio_at != 0 && !millisHasNowPassed(revert_radio_at); } #endif // current telemetry data queries @@ -130,7 +130,7 @@ protected: bool getCADEnabled() const override; int getAGCResetInterval() const override; uint8_t getDefaultTxCodingRate() const override { - return set_radio_at == 0 && revert_radio_at != 0 ? pending_cr : _prefs.cr; + return active_cr; } void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; int searchPeersByHash(const uint8_t* hash) override; @@ -162,6 +162,11 @@ private: float pending_bw; uint8_t pending_sf; uint8_t pending_cr; + uint8_t active_cr; + bool temp_radio_applied; + bool saved_radio_apply_pending; + + bool applySavedRadioParams(); uint8_t handleLoginReq(const mesh::Identity& sender, const uint8_t* secret, uint32_t sender_timestamp, const uint8_t* data, bool is_flood); uint8_t handleRequest(uint8_t perms, uint32_t sender_timestamp, uint8_t req_type, uint8_t* payload, size_t payload_len); diff --git a/platformio.ini b/platformio.ini index 798179ba..d22d0a86 100644 --- a/platformio.ini +++ b/platformio.ini @@ -199,6 +199,7 @@ build_src_filter = -<*> +<../src/Utils.cpp> +<../src/Packet.cpp> + +<../src/Dispatcher.cpp> +<../src/helpers/StaticPoolPacketManager.cpp> +<../src/helpers/ota/MerkleTree.cpp> +<../src/helpers/ota/MotaContainer.cpp> diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 2cac0238..be02fca5 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -198,6 +198,15 @@ void Dispatcher::loop() { } checkRecv(); checkSend(); + releaseDroppedOutbound(); +} + +void Dispatcher::releaseDroppedOutbound() { + Packet* dropped; + while ((dropped = _mgr->getNextDroppedOutbound()) != NULL) { + onSendFail(dropped); + releasePacket(dropped); + } } bool Dispatcher::tryParsePacket(Packet* pkt, const uint8_t* raw, int len) { diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 45b283c3..fd9fc017 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -130,6 +130,10 @@ public: (void)now; return NULL; } + // Managers that shed queued outbound packets return them here instead of + // freeing them silently. Dispatcher will run the normal send-failure + // lifecycle hook before returning each packet to the pool. + virtual Packet* getNextDroppedOutbound() { return NULL; } virtual int getOutboundCount(uint32_t now) const = 0; virtual int getOutboundTotal() const = 0; virtual int getFreeCount() const = 0; @@ -178,6 +182,7 @@ class Dispatcher { unsigned long duty_cycle_window_ms; void processRecvPacket(Packet* pkt); + void releaseDroppedOutbound(); void restoreOutboundTxOverrides(); void updateTxBudget(); diff --git a/src/Mesh.cpp b/src/Mesh.cpp index bc48e183..c24ce545 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -148,6 +148,7 @@ void Mesh::begin() { _direct_retries[i].retry_at = 0; _direct_retries[i].retry_delay = 0; _direct_retries[i].retry_attempts_sent = 0; + memset(_direct_retries[i].retry_key, 0, sizeof(_direct_retries[i].retry_key)); memset(_direct_retries[i].next_hop_hash, 0, sizeof(_direct_retries[i].next_hop_hash)); _direct_retries[i].next_hop_hash_len = 0; _direct_retries[i].payload_type = 0; @@ -215,27 +216,30 @@ void Mesh::loop() { continue; } - if (!_direct_retries[i].queued || !millisHasNowPassed(_direct_retries[i].retry_at)) { - continue; - } - - if (!isDirectRetryQueued(_direct_retries[i].packet)) { - if (_direct_retries[i].packet == getOutboundInFlight()) { - continue; // currently transmitting; keep slot until onSendComplete/onSendFail emits event - } + Packet* tracked_packet = _direct_retries[i].queued + ? _direct_retries[i].packet + : _direct_retries[i].trigger_packet; + if (tracked_packet == NULL + || (!isDirectRetryQueued(tracked_packet) && tracked_packet != getOutboundInFlight())) { uint32_t elapsed_millis = _direct_retries[i].retry_started_at == 0 ? 0 : (uint32_t)(_ms->getMillis() - _direct_retries[i].retry_started_at); - onDirectRetryEvent("dropped_queue_removed", NULL, elapsed_millis, - _direct_retries[i].retry_attempts_sent + 1, + uint8_t attempt = _direct_retries[i].queued + ? _direct_retries[i].retry_attempts_sent + 1 + : 1; + onDirectRetryEvent("dropped_queue_removed", NULL, elapsed_millis, attempt, _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len, _direct_retries[i].payload_type); - onDirectRetryEvent("failure", NULL, elapsed_millis, - _direct_retries[i].retry_attempts_sent + 1, + onDirectRetryEvent("failure", NULL, elapsed_millis, attempt, _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len, _direct_retries[i].payload_type); - onDirectRetryFailed(_direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); + // A local queue eviction says nothing about the next hop's RF quality. clearDirectRetrySlot(i); + continue; + } + + if (!_direct_retries[i].queued || !millisHasNowPassed(_direct_retries[i].retry_at)) { + continue; } } @@ -258,22 +262,25 @@ void Mesh::loop() { continue; } - if (!_flood_retries[i].queued || !millisHasNowPassed(_flood_retries[i].retry_at)) { - continue; - } - - if (!isFloodRetryQueued(_flood_retries[i].packet)) { - if (_flood_retries[i].packet == getOutboundInFlight()) { - continue; - } + Packet* tracked_packet = _flood_retries[i].queued + ? _flood_retries[i].packet + : _flood_retries[i].trigger_packet; + if (tracked_packet == NULL + || (!isFloodRetryQueued(tracked_packet) && tracked_packet != getOutboundInFlight())) { uint32_t elapsed_millis = _flood_retries[i].retry_started_at == 0 ? 0 : (uint32_t)(_ms->getMillis() - _flood_retries[i].retry_started_at); - onFloodRetryEvent("dropped_queue_removed", NULL, elapsed_millis, - _flood_retries[i].retry_attempts_sent + 1); - onFloodRetryEvent("failure", NULL, elapsed_millis, - _flood_retries[i].retry_attempts_sent + 1); + uint8_t attempt = _flood_retries[i].queued + ? _flood_retries[i].retry_attempts_sent + 1 + : 1; + onFloodRetryEvent("dropped_queue_removed", NULL, elapsed_millis, attempt); + onFloodRetryEvent("failure", NULL, elapsed_millis, attempt); clearFloodRetrySlot(i); + continue; + } + + if (!_flood_retries[i].queued || !millisHasNowPassed(_flood_retries[i].retry_at)) { + continue; } } #if defined(ENABLE_OTA) @@ -758,14 +765,13 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t type = pkt->payload[0] & 0x0F; if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { // a multipart ACK - Packet tmp; - tmp.header = pkt->header; - tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); - tmp.payload_len = pkt->payload_len - 1; - memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); - - if (!_tables->wasSeen(&tmp)) { - _tables->markSeen(&tmp); + if (!_tables->wasSeen(pkt)) { + _tables->markSeen(pkt); + Packet tmp; + tmp.header = pkt->header; + tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); + tmp.payload_len = pkt->payload_len - 1; + memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); uint32_t ack_crc; memcpy(&ack_crc, tmp.payload, 4); @@ -856,14 +862,13 @@ DispatcherAction Mesh::forwardMultipartDirect(Packet* pkt) { uint8_t type = pkt->payload[0] & 0x0F; if (type == PAYLOAD_TYPE_ACK && pkt->payload_len >= 5) { // a multipart ACK - Packet tmp; - tmp.header = pkt->header; - tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); - tmp.payload_len = pkt->payload_len - 1; - memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); - - if (!_tables->wasSeen(&tmp)) { // don't retransmit! - _tables->markSeen(&tmp); + if (!_tables->wasSeen(pkt)) { // don't retransmit this multipart transmission! + _tables->markSeen(pkt); + Packet tmp; + tmp.header = pkt->header; + tmp.path_len = Packet::copyPath(tmp.path, pkt->path, pkt->path_len); + tmp.payload_len = pkt->payload_len - 1; + memcpy(tmp.payload, &pkt->payload[1], tmp.payload_len); removePathPrefix(&tmp, 1); routeDirectRecvAcks(&tmp, ((uint32_t)remaining + 1) * 300); // expect multipart ACKs 300ms apart (x2) } @@ -906,6 +911,7 @@ void Mesh::clearDirectRetrySlot(int idx) { _direct_retries[idx].retry_at = 0; _direct_retries[idx].retry_delay = 0; _direct_retries[idx].retry_attempts_sent = 0; + memset(_direct_retries[idx].retry_key, 0, sizeof(_direct_retries[idx].retry_key)); memset(_direct_retries[idx].next_hop_hash, 0, sizeof(_direct_retries[idx].next_hop_hash)); _direct_retries[idx].next_hop_hash_len = 0; _direct_retries[idx].payload_type = 0; @@ -1293,6 +1299,15 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool return; } + uint8_t retry_key[MAX_HASH_SIZE]; + calculateDirectRetryKey(packet, retry_key); + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (_direct_retries[i].active + && memcmp(retry_key, _direct_retries[i].retry_key, MAX_HASH_SIZE) == 0) { + return; // the normal direct send still happens, but only one retry sequence owns this logical packet + } + } + int slot_idx = -1; for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { if (!_direct_retries[i].active) { @@ -1308,7 +1323,7 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool // Only store retry metadata here; allocate the retry packet after the initial TX really completes. uint32_t retry_delay = getDirectRetryAttemptDelay(packet, 0); - calculateDirectRetryKey(packet, _direct_retries[slot_idx].retry_key); + memcpy(_direct_retries[slot_idx].retry_key, retry_key, sizeof(retry_key)); _direct_retries[slot_idx].packet = NULL; _direct_retries[slot_idx].trigger_packet = const_cast(packet); _direct_retries[slot_idx].retry_started_at = 0; @@ -1350,6 +1365,83 @@ void Mesh::clearFloodRetrySlot(int idx) { _flood_retries[idx].active = false; } +bool Mesh::cancelActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) { + if (retry_key == NULL) { + return false; + } + + uint8_t key[MAX_HASH_SIZE]; + memcpy(key, retry_key, sizeof(key)); // tolerate callers passing storage owned by a retry slot + bool cancelled = false; + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active + || memcmp(key, _direct_retries[i].retry_key, MAX_HASH_SIZE) != 0) { + continue; + } + + Packet* retry = _direct_retries[i].queued ? _direct_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) { + _direct_retries[i].packet = NULL; + releasePacket(pending); + } + break; + } + } + } + clearDirectRetrySlot(i); + cancelled = true; + } + + for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { + if (!_flood_retries[i].active + || memcmp(key, _flood_retries[i].retry_key, MAX_HASH_SIZE) != 0) { + 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); + cancelled = true; + } + + return cancelled; +} + +bool Mesh::hasActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) const { + if (retry_key == NULL) { + return false; + } + + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (_direct_retries[i].active + && memcmp(retry_key, _direct_retries[i].retry_key, MAX_HASH_SIZE) == 0) { + return true; + } + } + for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { + if (_flood_retries[i].active + && memcmp(retry_key, _flood_retries[i].retry_key, MAX_HASH_SIZE) == 0) { + return true; + } + } + return false; +} + bool Mesh::isFloodRetryQueued(const Packet* packet) const { for (int i = 0; i < _mgr->getOutboundTotal(); i++) { if (_mgr->getOutboundByIdx(i) == packet) { diff --git a/src/Mesh.h b/src/Mesh.h index 36908412..c178284c 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -186,6 +186,18 @@ protected: */ virtual uint32_t getDirectRetryAttemptDelay(const Packet* packet, uint8_t attempt_idx); + /** + * \brief Cancel queued or future direct/flood retries for a packet payload hash. + * The original transmission and any retry already in progress are allowed to finish. + * \returns true if at least one active retry sequence was cancelled. + */ + bool cancelActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]); + + /** + * \returns true while a direct or flood retry sequence owns this packet payload hash. + */ + bool hasActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) const; + /** * \brief Decide whether a FLOOD packet should retry when no downstream echo is overheard. */ diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 972a97e9..0aaf6a64 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -439,9 +439,21 @@ mesh::Packet* BaseChatMesh::composeMsgPacket(const ContactInfo& recipient, uint3 return createDatagram(PAYLOAD_TYPE_TXT_MSG, recipient.id, recipient.getSharedSecret(self_id), temp, len); } -int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout) { +int BaseChatMesh::sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, + uint32_t& expected_ack, uint32_t& est_timeout, uint8_t* packet_hash, + const uint8_t* replace_retry_key) { mesh::Packet* pkt = composeMsgPacket(recipient, timestamp, attempt, text, expected_ack); if (pkt == NULL) return MSG_SEND_FAILED; + if (packet_hash != NULL) { + pkt->calculatePacketHash(packet_hash); + } + if (replace_retry_key != NULL) { + // Composition succeeded, so the new submission now owns this semantic DM. + // Cancel before sendDirect()/sendFloodScoped() registers its retry: when + // timestamp and attempt are unchanged, both encrypted packets have the + // same retry key and registering first would suppress the new sequence. + cancelActiveRetries(replace_retry_key); + } uint32_t t = _radio->getEstAirtimeFor(pkt->getRawLength()); diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index d9878547..deacd744 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -155,7 +155,9 @@ protected: public: mesh::Packet* createSelfAdvert(const char* name); mesh::Packet* createSelfAdvert(const char* name, double lat, double lon); - int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& expected_ack, uint32_t& est_timeout); + int sendMessage(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, + uint32_t& expected_ack, uint32_t& est_timeout, uint8_t* packet_hash = NULL, + const uint8_t* replace_retry_key = NULL); int sendCommandData(const ContactInfo& recipient, uint32_t timestamp, uint8_t attempt, const char* text, uint32_t& est_timeout); bool sendGroupMessage(uint32_t timestamp, mesh::GroupChannel& channel, const char* sender_name, const char* text, int text_len); bool sendGroupData(mesh::GroupChannel& channel, uint8_t* path, uint8_t path_len, uint16_t data_type, const uint8_t* data, int data_len); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index a1a85e8f..01d3e1be 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -339,8 +339,8 @@ static uint32_t ceilPositiveFloat(float value) { return value > (float)rounded ? rounded + 1 : rounded; } -static bool calcRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble, - uint32_t* rx_us, uint32_t* sleep_us) { +bool CommonCLI::calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble, + uint32_t* rx_us, uint32_t* sleep_us) { if (level < 1 || level > 10 || sf < 5 || sf > 12 || bw <= 0.0f || (preamble != 16 && preamble != 32)) { return false; } @@ -372,12 +372,12 @@ static void ensureRxPowerSavingDefaults(NodePrefs* prefs) { // Recomputes rx_ps_rx_us/rx_ps_sleep_us from the stored level and the current // radio SF/BW. No-op (returns false) for manual timings (rx_ps_level == 0). // Lets level-based RX powersaving auto-retune when SF/BW change. -static bool recalcRxPowerSavingFromLevel(NodePrefs* prefs) { +bool CommonCLI::recalculateRxPowerSavingFromLevel(NodePrefs* prefs) { if (prefs->rx_ps_level < 1 || prefs->rx_ps_level > 10) return false; // manual: nothing to recompute uint32_t preamble = prefs->rx_ps_preamble ? prefs->rx_ps_preamble : rxPowerSavingPreambleForSF(prefs->sf); uint32_t rx_us, sleep_us; - if (!calcRxPowerSavingLevel(prefs->rx_ps_level, prefs->sf, prefs->bw, preamble, &rx_us, &sleep_us)) { + if (!calculateRxPowerSavingLevel(prefs->rx_ps_level, prefs->sf, prefs->bw, preamble, &rx_us, &sleep_us)) { return false; } if (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us)) { @@ -1247,7 +1247,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->rx_ps_preamble = 0; // 0 = auto (derive from SF) } ensureRxPowerSavingDefaults(_prefs); - recalcRxPowerSavingFromLevel(_prefs); // retune level-based timings to the loaded SF/BW + recalculateRxPowerSavingFromLevel(_prefs); // retune level-based timings to the loaded SF/BW file.close(); } @@ -2165,9 +2165,9 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, _prefs->disable_fwd ? "OK - repeat is now OFF" : "OK - repeat is now ON"); } else if (memcmp(config, "radio.rxgain ", 13) == 0) { bool enabled = memcmp(&config[13], "on", 2) == 0; - _prefs->rx_boosted_gain = enabled; - savePrefs(); if (_callbacks->setRxBoostedGain(enabled)) { + _prefs->rx_boosted_gain = enabled; + savePrefs(); strcpy(reply, "OK"); } else { strcpy(reply, "Error: unsupported"); @@ -2251,7 +2251,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } } - if (level_requested && !calcRxPowerSavingLevel(level, _prefs->sf, _prefs->bw, preamble, &rx_us, &sleep_us)) { + if (level_requested && !calculateRxPowerSavingLevel(level, _prefs->sf, _prefs->bw, preamble, &rx_us, &sleep_us)) { strcpy(reply, "ERROR: level range is 1-10; preamble is 16 or 32"); return; } @@ -2308,7 +2308,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->bw = bw; // Retune level-based RX powersaving to the new SF/BW. Persist only; the // radio itself is "reboot to apply", and begin() re-arms the timings then. - bool rxps_retuned = recalcRxPowerSavingFromLevel(_prefs); + bool rxps_retuned = recalculateRxPowerSavingFromLevel(_prefs); _callbacks->savePrefs(); strcpy(reply, rxps_retuned ? "OK - reboot to apply (rxps retuned)" : "OK - reboot to apply"); } else { @@ -3186,7 +3186,7 @@ static bool processRegionDefSegment(RegionMap* map, char* tok, RegionEntry** cur if (jump) { RegionEntry* j = map->findByNamePrefix(jump); - if (j == NULL) { snprintf(reply, 160, "Err - unknown jump: %s", jump); return false; } + if (j == NULL) { snprintf(reply, 160, "Err - unknown or ambiguous jump: %s", jump); return false; } *cursor = j; } else { *cursor = r; @@ -3204,10 +3204,14 @@ void CommonCLI::handleRegionCmd(char* command, char* reply) { rtrimSpaces(payload); if (*payload == '\0') { snprintf(reply, 160, "Err - empty def"); return; } - RegionEntry* cursor = &_region_map->getWildcard(); + // Build the complete definition on a staged copy. A bad name, jump, table + // overflow, or cycle leaves the live hierarchy completely unchanged. + RegionMap staged(*_region_map); + RegionEntry* cursor = &staged.getWildcard(); for (char* tok; (tok = takeToken(&payload)) != nullptr; ) { - if (!processRegionDefSegment(_region_map, tok, &cursor, reply)) return; + if (!processRegionDefSegment(&staged, tok, &cursor, reply)) return; } + *_region_map = staged; _region_map->exportTo(reply, 160); return; } @@ -3302,7 +3306,10 @@ void CommonCLI::handleRegionCmd(char* command, char* reply) { } else if (n >= 3 && strcmp(parts[1], "remove") == 0) { auto region = _region_map->findByName(parts[2]); if (region) { + RegionEntry* current_default = _region_map->getDefaultRegion(); + bool removed_default = current_default != NULL && current_default->id == region->id; if (_region_map->removeRegion(*region)) { + if (removed_default) _callbacks->onDefaultRegionChanged(NULL); strcpy(reply, "OK"); } else { strcpy(reply, "Err - not empty"); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 5c5bef40..357dc6ad 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -616,6 +616,10 @@ class CommonCLI { bool handleObserverCommand(uint32_t sender_timestamp, char* command, char* reply); public: + static bool calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble, + uint32_t* rx_us, uint32_t* sleep_us); + static bool recalculateRxPowerSavingFromLevel(NodePrefs* prefs); + CommonCLI(mesh::MainBoard& board, mesh::RTCClock& rtc, SensorManager& sensors, RegionMap& region_map, ClientACL& acl, NodePrefs* prefs, CommonCLICallbacks* callbacks) : _board(&board), _rtc(&rtc), _sensors(&sensors), _region_map(®ion_map), _acl(&acl), _prefs(prefs), _callbacks(callbacks) { } diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index 25636c8f..8b4f4e2c 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -1,4 +1,5 @@ #include "RegionMap.h" +#include #include #include @@ -55,7 +56,7 @@ bool RegionMap::is_name_char(uint8_t c) { } static const char* skip_hash(const char* name) { - return *name == '#' ? name + 1 : name; + return RegionNameUtils::canonical(name); } static File openWrite(FILESYSTEM* _fs, const char* filename) { @@ -107,7 +108,8 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) { if (!success) break; const char* terminator = (const char*)memchr(r.name, 0, sizeof(r.name)); - if (r.id == 0 || r.id == 0xFFFF || terminator == NULL || r.name[0] == 0) { + if (r.id == 0 || r.id == 0xFFFF || terminator == NULL || r.name[0] == 0 + || RegionNameUtils::canonical(r.name)[0] == 0) { success = false; break; } @@ -126,7 +128,10 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) { // parent references are allowed. A bounded walk also rejects cycles. for (uint16_t i = 0; success && i < loaded_count; i++) { for (uint16_t j = i + 1; j < loaded_count; j++) { - if (loaded[i].id == loaded[j].id) success = false; + if (loaded[i].id == loaded[j].id + || RegionNameUtils::equivalent(loaded[i].name, loaded[j].name)) { + success = false; + } } uint16_t parent = loaded[i].parent; for (uint16_t depth = 0; success && parent != 0; depth++) { @@ -209,24 +214,41 @@ bool RegionMap::save(FILESYSTEM* _fs, const char* path) { } RegionEntry* RegionMap::putRegion(const char* name, uint16_t parent_id, uint16_t id) { - const char* sp = name; // check for illegal name chars + if (name == NULL || RegionNameUtils::canonical(name)[0] == 0) return NULL; + + size_t name_len = 0; + const char* sp = name; // check length and illegal name chars in one pass while (*sp) { - if (!is_name_char(*sp)) return NULL; // error + if (!is_name_char(*sp) || ++name_len >= sizeof(regions[0].name)) return NULL; sp++; } auto region = findByName(name); if (region) { - if (region->id == parent_id) return NULL; // ERROR: invalid parent! + if ((id != 0 && id != region->id) || wouldCreateCycle(region->id, parent_id)) { + return NULL; + } region->parent = parent_id; // re-parent / move this region in the hierarchy } else { if (num_regions >= MAX_REGION_ENTRIES) return NULL; // full! + if (findById(parent_id) == NULL) return NULL; if (id == 0xFFFF || (id == 0 && (next_id == 0 || next_id == 0xFFFF))) return NULL; + if (id != 0 && findById(id) != NULL) return NULL; + // 0xFFFF is the reserved/exhausted sentinel. Only consume 0xFFFE when this + // entry fills the table; otherwise saving would produce a map that load() + // correctly rejects as unable to allocate its remaining slots. + if ((id == 0 ? next_id : id) == 0xFFFE + && num_regions + 1 < MAX_REGION_ENTRIES) return NULL; region = ®ions[num_regions++]; // alloc new RegionEntry region->flags = REGION_DENY_FLOOD; // DENY by default - region->id = id == 0 ? next_id++ : id; + if (id == 0) { + region->id = next_id++; + } else { + region->id = id; + if (id >= next_id) next_id = id + 1; + } StrHelper::strncpy(region->name, name, sizeof(region->name)); region->parent = parent_id; } @@ -268,6 +290,7 @@ RegionEntry* RegionMap::findMatch(mesh::Packet* packet, uint8_t mask) { } RegionEntry* RegionMap::findByName(const char* name) { + if (name == NULL) return NULL; if (strcmp(name, "*") == 0) return &wildcard; if (*name == '#') { name++; } // ignore the '#' when matching by name @@ -279,18 +302,27 @@ RegionEntry* RegionMap::findByName(const char* name) { } RegionEntry* RegionMap::findByNamePrefix(const char* prefix) { + if (prefix == NULL) return NULL; if (strcmp(prefix, "*") == 0) return &wildcard; if (*prefix == '#') { prefix++; } // ignore the '#' when matching by name + if (*prefix == 0) return NULL; + size_t prefix_len = strlen(prefix); RegionEntry* partial = NULL; + bool ambiguous = false; for (int i = 0; i < num_regions; i++) { auto region = ®ions[i]; - if (strcmp(prefix, skip_hash(region->name)) == 0) return region; // is a complete match, preference this one - if (memcmp(prefix, skip_hash(region->name), strlen(prefix)) == 0) { - partial = region; + const char* candidate = skip_hash(region->name); + if (strcmp(prefix, candidate) == 0) return region; // exact matches always win + if (strlen(candidate) >= prefix_len && memcmp(prefix, candidate, prefix_len) == 0) { + if (partial != NULL) { + ambiguous = true; + } else { + partial = region; + } } } - return partial; + return ambiguous ? NULL : partial; } RegionEntry* RegionMap::findById(uint16_t id) { @@ -339,14 +371,50 @@ bool RegionMap::removeRegion(const RegionEntry& region) { regions[i] = regions[i + 1]; i++; } + memset(®ions[num_regions], 0, sizeof(regions[num_regions])); + if (default_id == region.id) default_id = 0; + if (home_id == region.id) home_id = 0; return true; // success } bool RegionMap::clear() { num_regions = 0; + next_id = 1; + default_id = home_id = 0; + memset(regions, 0, sizeof(regions)); + wildcard.id = wildcard.parent = 0; + wildcard.flags = 0; + strcpy(wildcard.name, "*"); return true; // success } +bool RegionMap::wouldCreateCycle(uint16_t region_id, uint16_t parent_id) const { + uint16_t current = parent_id; + for (uint16_t depth = 0; current != 0; depth++) { + if (current == region_id || depth >= num_regions) return true; + + const RegionEntry* parent = NULL; + for (uint16_t i = 0; i < num_regions; i++) { + if (regions[i].id == current) { + parent = ®ions[i]; + break; + } + } + if (parent == NULL) return true; + current = parent->parent; + } + return false; +} + +void RegionMap::resetFrom(const RegionMap& src) { + next_id = src.next_id; + home_id = src.home_id; + default_id = src.default_id; + num_regions = 0; + memset(regions, 0, sizeof(regions)); + wildcard = src.wildcard; +} + void RegionMap::printChildRegions(int indent, const RegionEntry* parent, Stream& out) const { for (int i = 0; i < indent; i++) { out.print(' '); diff --git a/src/helpers/RegionMap.h b/src/helpers/RegionMap.h index 5eb14429..d920091e 100644 --- a/src/helpers/RegionMap.h +++ b/src/helpers/RegionMap.h @@ -28,6 +28,7 @@ class RegionMap { RegionEntry wildcard; void printChildRegions(int indent, const RegionEntry* parent, Stream& out) const; + bool wouldCreateCycle(uint16_t region_id, uint16_t parent_id) const; public: RegionMap(TransportKeyStore& store); @@ -49,7 +50,7 @@ public: void setDefaultRegion(const RegionEntry* def); bool removeRegion(const RegionEntry& region); bool clear(); - void resetFrom(const RegionMap& src) { num_regions = 0; next_id = src.next_id; } + void resetFrom(const RegionMap& src); int getCount() const { return num_regions; } const RegionEntry* getByIdx(int i) const { return ®ions[i]; } const RegionEntry* getRoot() const { return &wildcard; } diff --git a/src/helpers/RegionNameUtils.h b/src/helpers/RegionNameUtils.h new file mode 100644 index 00000000..2624b795 --- /dev/null +++ b/src/helpers/RegionNameUtils.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace RegionNameUtils { + +inline const char* canonical(const char* name) { + return name != NULL && name[0] == '#' ? name + 1 : name; +} + +inline bool equivalent(const char* first, const char* second) { + return first != NULL && second != NULL + && strcmp(canonical(first), canonical(second)) == 0; +} + +} // namespace RegionNameUtils diff --git a/src/helpers/RxReservePacketManager.h b/src/helpers/RxReservePacketManager.h index eae3d597..88c5a767 100644 --- a/src/helpers/RxReservePacketManager.h +++ b/src/helpers/RxReservePacketManager.h @@ -27,6 +27,7 @@ class RxReservePacketManager : public StaticPoolPacketManager { int _rx_reserve, _emergency_floor; int _cap; + PacketQueue _dropped; // scheduled_for per queued packet, keyed by packet pointer. The pool is a fixed set // of _cap Packet objects, so _cap slots cover every possible key with no eviction. struct AgeEntry { mesh::Packet* pkt; uint32_t scheduled_for; }; @@ -58,8 +59,13 @@ class RxReservePacketManager : public StaticPoolPacketManager { if (pkt && lookupAge(pkt, &scheduled_for) && (int32_t)(now - scheduled_for) > (int32_t)STALE_OUTBOUND_MS) { MESH_DEBUG_PRINTLN("RxReservePacketManager: dropping stale queued outbound"); - removeOutboundByIdx(i); - free(pkt); + mesh::Packet* dropped = removeOutboundByIdx(i); + if (dropped != NULL) { + // Keep ownership until Dispatcher can notify retry/application state. + // The dropped queue cannot overflow: every entry is one of the same + // fixed _cap pool packets and is no longer in another manager queue. + _dropped.add(dropped, 0, 0); + } } } } @@ -67,7 +73,7 @@ class RxReservePacketManager : public StaticPoolPacketManager { public: RxReservePacketManager(int pool_size, int rx_reserve) : StaticPoolPacketManager(pool_size), _rx_reserve(rx_reserve), - _emergency_floor(rx_reserve / 2), _cap(pool_size) { + _emergency_floor(rx_reserve / 2), _cap(pool_size), _dropped(pool_size) { _ages = new AgeEntry[pool_size]; for (int i = 0; i < pool_size; i++) { _ages[i].pkt = NULL; _ages[i].scheduled_for = 0; } } @@ -97,6 +103,10 @@ public: expireStaleOutbound(now); return StaticPoolPacketManager::peekNextOutbound(now); } + + mesh::Packet* getNextDroppedOutbound() override { + return _dropped.removeByIdx(0); + } }; // The packet manager for an app build: observer builds reserve a quarter of the pool diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index a6dca49d..ba4fc3e1 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -13,24 +13,10 @@ #ifndef MAX_PACKET_ACKS #define MAX_PACKET_ACKS 64 #endif +#if MAX_PACKET_ACKS < 1 + #error "MAX_PACKET_ACKS must be at least 1" +#endif #define ACK_VALID_BYTES ((MAX_PACKET_ACKS + 7) / 8) -#ifndef MESH_ENABLE_RECENT_REPEATERS - #define MESH_ENABLE_RECENT_REPEATERS 0 -#endif -#ifndef MAX_RECENT_REPEATERS - // Recent repeater history is only needed by repeater firmware. Other roles - // use unconditional retry decisions and skip this RAM-heavy cache. - #if !MESH_ENABLE_RECENT_REPEATERS - #define MAX_RECENT_REPEATERS 0 - #elif defined(ESP32) || defined(ESP32_PLATFORM) - #define MAX_RECENT_REPEATERS 2048 - #elif defined(NRF52_PLATFORM) - #define MAX_RECENT_REPEATERS 512 - #else - #define MAX_RECENT_REPEATERS 64 - #endif -#endif -#define RECENT_REPEATER_STORAGE_SLOTS (MAX_RECENT_REPEATERS > 0 ? MAX_RECENT_REPEATERS : 1) #define MAX_ROUTE_HASH_BYTES 3 inline bool routeHashPrefixesOverlap(const uint8_t* a, uint8_t a_len, @@ -56,11 +42,12 @@ public: private: uint8_t _hashes[MAX_PACKET_HASHES*MAX_HASH_SIZE]; int _next_idx; - uint32_t _acks[MAX_PACKET_ACKS]; + uint8_t _ack_hashes[MAX_PACKET_ACKS*MAX_HASH_SIZE]; uint8_t _ack_valid[ACK_VALID_BYTES]; - uint8_t _next_ack_idx; + int _next_ack_idx; uint32_t _direct_dups, _flood_dups; - RecentRepeaterInfo _recent_repeaters[RECENT_REPEATER_STORAGE_SLOTS]; + RecentRepeaterInfo* _recent_repeaters; + int _max_recent_repeaters; bool hasSeenHash(const uint8_t* hash) const { const uint8_t* sp = _hashes; @@ -77,15 +64,20 @@ private: _next_idx = (_next_idx + 1) % MAX_PACKET_HASHES; } - bool isAckPacket(const mesh::Packet* packet) const { - return packet->getPayloadType() == PAYLOAD_TYPE_ACK && packet->payload_len >= sizeof(uint32_t); + bool isDedicatedAckPacket(const mesh::Packet* packet) const { + if (packet->getPayloadType() == PAYLOAD_TYPE_ACK) { + return packet->payload_len >= sizeof(uint32_t); + } + return packet->getPayloadType() == PAYLOAD_TYPE_MULTIPART + && packet->payload_len >= sizeof(uint32_t) + 1 + && (packet->payload[0] & 0x0F) == PAYLOAD_TYPE_ACK; } - bool isAckSlotValid(uint8_t idx) const { + bool isAckSlotValid(int idx) const { return (_ack_valid[idx >> 3] & (uint8_t)(1U << (idx & 7))) != 0; } - void setAckSlotValid(uint8_t idx, bool valid) { + void setAckSlotValid(int idx, bool valid) { uint8_t mask = (uint8_t)(1U << (idx & 7)); if (valid) { _ack_valid[idx >> 3] |= mask; @@ -94,25 +86,27 @@ private: } } - bool hasSeenAck(uint32_t ack) const { - for (uint8_t i = 0; i < MAX_PACKET_ACKS; i++) { - if (isAckSlotValid(i) && _acks[i] == ack) { + bool hasSeenAckHash(const uint8_t* hash) const { + for (int i = 0; i < MAX_PACKET_ACKS; i++) { + if (isAckSlotValid(i) + && memcmp(&_ack_hashes[i*MAX_HASH_SIZE], hash, MAX_HASH_SIZE) == 0) { return true; } } return false; } - void storeAck(uint32_t ack) { - if (hasSeenAck(ack)) return; - _acks[_next_ack_idx] = ack; + void storeAckHash(const uint8_t* hash) { + if (hasSeenAckHash(hash)) return; + memcpy(&_ack_hashes[_next_ack_idx*MAX_HASH_SIZE], hash, MAX_HASH_SIZE); setAckSlotValid(_next_ack_idx, true); - _next_ack_idx = (uint8_t)((_next_ack_idx + 1) % MAX_PACKET_ACKS); + _next_ack_idx = (_next_ack_idx + 1) % MAX_PACKET_ACKS; } - void clearAck(uint32_t ack) { - for (uint8_t i = 0; i < MAX_PACKET_ACKS; i++) { - if (isAckSlotValid(i) && _acks[i] == ack) { + void clearAckHash(const uint8_t* hash) { + for (int i = 0; i < MAX_PACKET_ACKS; i++) { + if (isAckSlotValid(i) + && memcmp(&_ack_hashes[i*MAX_HASH_SIZE], hash, MAX_HASH_SIZE) == 0) { setAckSlotValid(i, false); return; } @@ -185,7 +179,7 @@ private: } void recordRecentRepeater(const mesh::Packet* packet) { - if (MAX_RECENT_REPEATERS == 0) { + if (_max_recent_repeaters == 0) { return; } @@ -198,14 +192,22 @@ private: } public: - SimpleMeshTables() { + // Recent-repeater storage is supplied only by repeater firmware. Keeping it + // external makes this class layout identical in every translation unit; + // role-local feature macros must never change a C++ class definition. + SimpleMeshTables(RecentRepeaterInfo* recent_repeaters = NULL, int max_recent_repeaters = 0) + : _recent_repeaters(recent_repeaters), + _max_recent_repeaters(recent_repeaters != NULL && max_recent_repeaters > 0 + ? max_recent_repeaters : 0) { memset(_hashes, 0, sizeof(_hashes)); _next_idx = 0; - memset(_acks, 0, sizeof(_acks)); + memset(_ack_hashes, 0, sizeof(_ack_hashes)); memset(_ack_valid, 0, sizeof(_ack_valid)); _next_ack_idx = 0; _direct_dups = _flood_dups = 0; - memset(_recent_repeaters, 0, sizeof(_recent_repeaters)); + if (_max_recent_repeaters > 0) { + memset(_recent_repeaters, 0, _max_recent_repeaters * sizeof(RecentRepeaterInfo)); + } } #ifdef ESP32 @@ -213,13 +215,13 @@ public: f.read(_hashes, sizeof(_hashes)); f.read((uint8_t *) &_next_idx, sizeof(_next_idx)); // ACKs are short-lived transport state and are intentionally not persisted. - memset(_acks, 0, sizeof(_acks)); + memset(_ack_hashes, 0, sizeof(_ack_hashes)); memset(_ack_valid, 0, sizeof(_ack_valid)); _next_ack_idx = 0; // Recent repeater entries are intentionally not restored across boots. // This avoids struct-layout migration issues and keeps stale path quality // stats from persisting indefinitely. - memset(_recent_repeaters, 0, sizeof(_recent_repeaters)); + clearRecentRepeaters(); } void saveTo(File f) { f.write(_hashes, sizeof(_hashes)); @@ -228,10 +230,10 @@ public: #endif bool wasSeen(const mesh::Packet* packet) override { - if (isAckPacket(packet)) { - uint32_t ack; - memcpy(&ack, packet->payload, sizeof(ack)); - if (hasSeenAck(ack)) { + if (isDedicatedAckPacket(packet)) { + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + if (hasSeenAckHash(hash)) { if (packet->isRouteDirect()) { _direct_dups++; } else { @@ -258,10 +260,10 @@ public: } void markSeen(const mesh::Packet* packet) override { - if (isAckPacket(packet)) { - uint32_t ack; - memcpy(&ack, packet->payload, sizeof(ack)); - storeAck(ack); + if (isDedicatedAckPacket(packet)) { + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + storeAckHash(hash); return; } @@ -274,10 +276,10 @@ public: } void markSent(const mesh::Packet* packet) override { - if (isAckPacket(packet)) { - uint32_t ack; - memcpy(&ack, packet->payload, sizeof(ack)); - storeAck(ack); + if (isDedicatedAckPacket(packet)) { + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + storeAckHash(hash); return; } @@ -290,10 +292,10 @@ public: } void clear(const mesh::Packet* packet) override { - if (isAckPacket(packet)) { - uint32_t ack; - memcpy(&ack, packet->payload, sizeof(ack)); - clearAck(ack); + if (isDedicatedAckPacket(packet)) { + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + clearAckHash(hash); return; } @@ -316,7 +318,7 @@ public: bool snr_locked = false, bool bypass_allow_filter = false) { (void)snr_locked; (void)bypass_allow_filter; - if (MAX_RECENT_REPEATERS == 0) { + if (_max_recent_repeaters == 0) { return false; } if (prefix == NULL || prefix_len == 0) { @@ -329,7 +331,7 @@ public: // Keep exact prefixes distinct so a 1-byte path prefix does not collapse // independent 2/3-byte repeaters that share the same first byte. - for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + for (int i = 0; i < _max_recent_repeaters; i++) { RecentRepeaterInfo& existing = _recent_repeaters[i]; if (existing.prefix_len != prefix_len || memcmp(existing.prefix, prefix, prefix_len) != 0) { continue; @@ -344,7 +346,7 @@ public: } int slot_idx = -1; - for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + for (int i = 0; i < _max_recent_repeaters; i++) { if (_recent_repeaters[i].prefix_len == 0) { slot_idx = i; break; @@ -356,7 +358,7 @@ public: #if ARDUINO uint32_t now = millis(); uint32_t oldest_age = (uint32_t)(now - _recent_repeaters[0].last_heard_millis); - for (int i = 1; i < MAX_RECENT_REPEATERS; i++) { + for (int i = 1; i < _max_recent_repeaters; i++) { uint32_t age = (uint32_t)(now - _recent_repeaters[i].last_heard_millis); if (age > oldest_age) { oldest_age = age; @@ -379,7 +381,7 @@ public: return true; } bool decrementRecentRepeaterSnrX4(const uint8_t* prefix, uint8_t prefix_len, uint8_t amount_x4 = 1) { - if (MAX_RECENT_REPEATERS == 0) { + if (_max_recent_repeaters == 0) { return false; } if (prefix == NULL || prefix_len == 0 || amount_x4 == 0) { @@ -399,11 +401,11 @@ public: return false; } int getRecentRepeaterCount() const { - if (MAX_RECENT_REPEATERS == 0) { + if (_max_recent_repeaters == 0) { return 0; } int count = 0; - for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + for (int i = 0; i < _max_recent_repeaters; i++) { if (_recent_repeaters[i].prefix_len > 0) { count++; } @@ -411,7 +413,7 @@ public: return count; } const RecentRepeaterInfo* getRecentRepeaterBySortedIdx(int idx_wanted) const { - if (MAX_RECENT_REPEATERS == 0) { + if (_max_recent_repeaters == 0) { return NULL; } if (idx_wanted < 0) { @@ -423,7 +425,7 @@ public: for (int rank = 0; rank <= idx_wanted; rank++) { const RecentRepeaterInfo* best = NULL; int best_idx = -1; - for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + for (int i = 0; i < _max_recent_repeaters; i++) { const RecentRepeaterInfo* info = &_recent_repeaters[i]; if (info->prefix_len == 0) { continue; @@ -446,7 +448,7 @@ public: } const RecentRepeaterInfo* findRecentRepeaterByHash(const uint8_t* hash, uint8_t hash_len) const { - if (MAX_RECENT_REPEATERS == 0) { + if (_max_recent_repeaters == 0) { return NULL; } if (hash == NULL || hash_len == 0) { @@ -456,7 +458,7 @@ public: // Prefer exact matches. If none exists, fall back to the longest overlapping // prefix, using highest SNR to break ties. const RecentRepeaterInfo* best = NULL; - for (int i = 0; i < MAX_RECENT_REPEATERS; i++) { + for (int i = 0; i < _max_recent_repeaters; i++) { const RecentRepeaterInfo* info = &_recent_repeaters[i]; if (info->prefix_len == 0) { continue; @@ -474,7 +476,25 @@ public: return best; } void clearRecentRepeaters() { - memset(_recent_repeaters, 0, sizeof(_recent_repeaters)); + if (_max_recent_repeaters > 0) { + memset(_recent_repeaters, 0, _max_recent_repeaters * sizeof(RecentRepeaterInfo)); + } + } + int expireRecentRepeaters(uint32_t now_millis, uint32_t max_age_millis) { + if (_max_recent_repeaters == 0) { + return 0; + } + + int expired = 0; + for (int i = 0; i < _max_recent_repeaters; i++) { + RecentRepeaterInfo& info = _recent_repeaters[i]; + if (info.prefix_len > 0 + && (uint32_t)(now_millis - info.last_heard_millis) > max_age_millis) { + memset(&info, 0, sizeof(info)); + expired++; + } + } + return expired; } void resetStats() { _direct_dups = _flood_dups = 0; } diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index 87ed4519..f1dcb62f 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -8,14 +8,16 @@ class CustomLLCC68Wrapper : public RadioLibWrapper { public: CustomLLCC68Wrapper(CustomLLCC68& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomLLCC68 *)_radio)->setFrequency(freq); - ((CustomLLCC68 *)_radio)->setSpreadingFactor(sf); - ((CustomLLCC68 *)_radio)->setBandwidth(bw); - ((CustomLLCC68 *)_radio)->setCodingRate(cr); - updatePreamble(sf); +protected: + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + return ((CustomLLCC68 *)_radio)->setFrequency(freq) == RADIOLIB_ERR_NONE + && ((CustomLLCC68 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE + && ((CustomLLCC68 *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE + && ((CustomLLCC68 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE + && updatePreamble(sf); } +public: bool setCodingRate(uint8_t cr) override { return ((CustomLLCC68 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } @@ -37,9 +39,11 @@ public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - bool setRxBoostedGainMode(bool en) override { +protected: + bool applyRxBoostedGainMode(bool en) override { return ((CustomLLCC68 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } +public: bool getRxBoostedGainMode() const override { return ((CustomLLCC68 *)_radio)->getRxBoostedGainMode(); } diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index 349aa4f9..dac91a3a 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -12,14 +12,16 @@ class CustomLR1110Wrapper : public RadioLibWrapper { public: CustomLR1110Wrapper(CustomLR1110& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomLR1110 *)_radio)->setFrequency(freq); - ((CustomLR1110 *)_radio)->setSpreadingFactor(sf); - ((CustomLR1110 *)_radio)->setBandwidth(bw); - ((CustomLR1110 *)_radio)->setCodingRate(cr); - updatePreamble(sf); +protected: + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + return ((CustomLR1110 *)_radio)->setFrequency(freq) == RADIOLIB_ERR_NONE + && ((CustomLR1110 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE + && ((CustomLR1110 *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE + && ((CustomLR1110 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE + && updatePreamble(sf); } +public: bool setCodingRate(uint8_t cr) override { return ((CustomLR1110 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } @@ -87,9 +89,11 @@ public: uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); } - bool setRxBoostedGainMode(bool en) override { +protected: + bool applyRxBoostedGainMode(bool en) override { return ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } +public: bool getRxBoostedGainMode() const override { return ((CustomLR1110 *)_radio)->getRxBoostedGainMode(); } diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index 45e275a3..2db1f616 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -9,14 +9,16 @@ class CustomSTM32WLxWrapper : public RadioLibWrapper { public: CustomSTM32WLxWrapper(CustomSTM32WLx& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomSTM32WLx *)_radio)->setFrequency(freq); - ((CustomSTM32WLx *)_radio)->setSpreadingFactor(sf); - ((CustomSTM32WLx *)_radio)->setBandwidth(bw); - ((CustomSTM32WLx *)_radio)->setCodingRate(cr); - updatePreamble(sf); +protected: + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + return ((CustomSTM32WLx *)_radio)->setFrequency(freq) == RADIOLIB_ERR_NONE + && ((CustomSTM32WLx *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE + && ((CustomSTM32WLx *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE + && ((CustomSTM32WLx *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE + && updatePreamble(sf); } +public: bool setCodingRate(uint8_t cr) override { return ((CustomSTM32WLx *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 15d8529d..99d3b164 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -12,15 +12,16 @@ class CustomSX1262Wrapper : public RadioLibWrapper { public: CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - cacheParams(freq, bw, sf, cr); - ((CustomSX1262 *)_radio)->setFrequency(freq); - ((CustomSX1262 *)_radio)->setSpreadingFactor(sf); - ((CustomSX1262 *)_radio)->setBandwidth(bw); - ((CustomSX1262 *)_radio)->setCodingRate(cr); - updatePreamble(sf); +protected: + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + return ((CustomSX1262 *)_radio)->setFrequency(freq) == RADIOLIB_ERR_NONE + && ((CustomSX1262 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE + && ((CustomSX1262 *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE + && ((CustomSX1262 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE + && updatePreamble(sf); } +public: bool setCodingRate(uint8_t cr) override { return ((CustomSX1262 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } @@ -91,12 +92,13 @@ protected: return ((CustomSX1262 *)_radio)->std_init(); } -public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - bool setRxBoostedGainMode(bool en) override { +protected: + bool applyRxBoostedGainMode(bool en) override { return ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } +public: bool getRxBoostedGainMode() const override { return ((CustomSX1262 *)_radio)->getRxBoostedGainMode(); } diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index f847992f..49ecbecf 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -12,14 +12,16 @@ class CustomSX1268Wrapper : public RadioLibWrapper { public: CustomSX1268Wrapper(CustomSX1268& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomSX1268 *)_radio)->setFrequency(freq); - ((CustomSX1268 *)_radio)->setSpreadingFactor(sf); - ((CustomSX1268 *)_radio)->setBandwidth(bw); - ((CustomSX1268 *)_radio)->setCodingRate(cr); - updatePreamble(sf); +protected: + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + return ((CustomSX1268 *)_radio)->setFrequency(freq) == RADIOLIB_ERR_NONE + && ((CustomSX1268 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE + && ((CustomSX1268 *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE + && ((CustomSX1268 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE + && updatePreamble(sf); } +public: bool setCodingRate(uint8_t cr) override { return ((CustomSX1268 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } @@ -41,9 +43,11 @@ public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } - bool setRxBoostedGainMode(bool en) override { +protected: + bool applyRxBoostedGainMode(bool en) override { return ((CustomSX1268 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } +public: bool getRxBoostedGainMode() const override { return ((CustomSX1268 *)_radio)->getRxBoostedGainMode(); } diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index cb073f91..940bd937 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -11,14 +11,16 @@ class CustomSX1276Wrapper : public RadioLibWrapper { public: CustomSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } - void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { - ((CustomSX1276 *)_radio)->setFrequency(freq); - ((CustomSX1276 *)_radio)->setSpreadingFactor(sf); - ((CustomSX1276 *)_radio)->setBandwidth(bw); - ((CustomSX1276 *)_radio)->setCodingRate(cr); - updatePreamble(sf); +protected: + bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + return ((CustomSX1276 *)_radio)->setFrequency(freq) == RADIOLIB_ERR_NONE + && ((CustomSX1276 *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE + && ((CustomSX1276 *)_radio)->setBandwidth(bw) == RADIOLIB_ERR_NONE + && ((CustomSX1276 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE + && updatePreamble(sf); } +public: bool setCodingRate(uint8_t cr) override { return ((CustomSX1276 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE; } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 00a5cca2..bad6302d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -58,6 +58,65 @@ void RadioLibWrapper::setTxPower(int8_t dbm) { _radio->setOutputPower(dbm); } +uint8_t RadioLibWrapper::beginReconfigure() { + const uint8_t base_state = state & ~STATE_INT_READY; + // On SX126x/LR11xx duty-cycle RX, BUSY may remain asserted during the sleep + // side of the cycle. Do not issue an IRQ/preamble query over SPI then; due + // scheduled work will retry as soon as the next safe listen window opens. + if ((state & STATE_INT_READY) != 0 || base_state == STATE_TX_WAIT + || isChipBusy() || isReceivingPacket()) { + return 2; + } + + const bool resume_rx = base_state == STATE_RX; + if (_rx_ps_armed) { + stopReceiveDutyCycle(); + } else if (resume_rx) { + _radio->standby(); + } + state = STATE_IDLE; + return resume_rx; +} + +void RadioLibWrapper::endReconfigure(bool resume_rx) { + if (resume_rx) startRecv(); +} + +bool RadioLibWrapper::setParams(float freq, float bw, uint8_t sf, uint8_t cr, + const uint32_t* rx_ps_timings) { + if (rx_ps_timings != NULL && !supportsRxPowerSaving()) return false; + + uint8_t resume_rx = beginReconfigure(); + if (resume_rx > 1) return false; + + bool success = applyParams(freq, bw, sf, cr); + if (success) { + cacheParams(freq, bw, sf, cr); + if (rx_ps_timings != NULL) { + _rx_ps_enabled = true; + _rx_ps_rx_us = rx_ps_timings[0]; + _rx_ps_sleep_us = rx_ps_timings[1]; + } + } + + endReconfigure(resume_rx); + return success; +} + +bool RadioLibWrapper::setRxBoostedGainMode(bool enabled) { + uint8_t resume_rx = beginReconfigure(); + if (resume_rx > 1) return false; + + bool success = applyRxBoostedGainMode(enabled); + if (success) { + _cur_rx_boosted_gain = enabled; + _rx_boosted_gain_valid = true; + } + + endReconfigure(resume_rx); + return success; +} + void RadioLibWrapper::idle() { _radio->standby(); state = STATE_IDLE; // need another startReceive() @@ -81,6 +140,7 @@ void RadioLibWrapper::resetAGC() { doResetAGC(); state = STATE_IDLE; // trigger a startReceive() + if (_rx_boosted_gain_valid) applyRxBoostedGainMode(_cur_rx_boosted_gain); // Reset noise floor sampling so it reconverges from scratch. // Without this, a stuck _noise_floor of -120 makes the sampling threshold @@ -155,9 +215,11 @@ void RadioLibWrapper::rxPsWatchdogCheck() { MESH_DEBUG_PRINTLN("RadioLibWrapper: watchdog: still stuck, hard radio reset"); if (radioDeepInit()) { _rx_ps_armed = false; // chip is factory-fresh after NRST + state = STATE_IDLE; _radio->setPacketReceivedAction(setFlag); if (_params_valid) setParams(_cur_freq, _cur_bw, _cur_sf, _cur_cr); if (_dbm_valid) _radio->setOutputPower(_cur_dbm); + if (_rx_boosted_gain_valid) applyRxBoostedGainMode(_cur_rx_boosted_gain); } state = STATE_IDLE; // re-arm (rx powersaving settings are kept in members) } @@ -297,16 +359,13 @@ bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sl return false; } + uint8_t resume_rx = beginReconfigure(); + if (resume_rx > 1) return false; + _rx_ps_enabled = enabled; _rx_ps_rx_us = rx_us; _rx_ps_sleep_us = sleep_us; - // Force the next recvRaw() to arm the requested RX mode, but don't clobber a - // completed-but-unread packet (STATE_INT_READY): recvRaw() will consume it and - // then re-arm with the new mode. Also leave an in-flight TX alone. (Same - // non-atomic guard style as resetAGC().) - if ((state & STATE_INT_READY) == 0 && (state & ~STATE_INT_READY) != STATE_TX_WAIT) { - state = STATE_IDLE; - } + endReconfigure(resume_rx); return true; } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 011b3acd..1b51c2dd 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -48,7 +48,8 @@ protected: float _cur_freq, _cur_bw; uint8_t _cur_sf, _cur_cr; int8_t _cur_dbm; - bool _params_valid, _dbm_valid; + bool _cur_rx_boosted_gain; + bool _params_valid, _dbm_valid, _rx_boosted_gain_valid; // Periodic noise-floor calibration (only while RX duty-cycle powersaving is // armed): a duty-cycled receiver can't be sampled reliably, so at least once @@ -77,6 +78,11 @@ protected: // full radio recovery: hardware reset (NRST) + re-init to boot defaults; // returns false if unsupported. Caller reapplies cached runtime params. virtual bool radioDeepInit() { return false; } + virtual bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) = 0; + virtual bool applyRxBoostedGainMode(bool) { return false; } + // 0 = reconfigure from idle, 1 = resume RX afterwards, 2 = currently busy. + uint8_t beginReconfigure(); + void endReconfigure(bool resume_rx); float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; virtual void doResetAGC(); @@ -87,7 +93,7 @@ public: _rx_ps_rx_us(RX_PS_FALLBACK_RX_US), _rx_ps_sleep_us(RX_PS_FALLBACK_SLEEP_US), _wd_last_busy(false), _wd_stage(0), _wd_strikes(0), _startrx_fails(0), _wd_last_transition(0), _wd_stuck_thresh(0), _wd_observe_until(0), _wd_observe_ms(0), - _params_valid(false), _dbm_valid(false), + _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) { n_recv = n_sent = n_recv_errors = n_wd_soft = n_wd_hard = 0; @@ -114,7 +120,10 @@ public: } bool isReceivingPassive(int interference_margin_db) override; - virtual void setParams(float freq, float bw, uint8_t sf, uint8_t cr) = 0; + // When rx_ps_timings is supplied, update and re-arm the RX duty cycle in the + // same standby transition as the modulation change. Values are {rx, sleep}. + bool setParams(float freq, float bw, uint8_t sf, uint8_t cr, + const uint32_t* rx_ps_timings = NULL); uint16_t getDefaultPreambleLength() const override { return preambleLengthForSF(_preamble_sf); } bool setPreambleLength(uint16_t len) override { return _radio->setPreambleLength(len) == RADIOLIB_ERR_NONE; } uint32_t getRngSeed(); @@ -123,7 +132,11 @@ public: virtual float getCurrentRSSI() =0; virtual uint8_t getSpreadingFactor() const { return LORA_SF; } static uint16_t preambleLengthForSF(uint8_t sf) { return sf <= 8 ? 32 : 16; } - void updatePreamble(uint8_t sf) { _preamble_sf = sf; _radio->setPreambleLength(preambleLengthForSF(sf)); } + bool updatePreamble(uint8_t sf) { + if (_radio->setPreambleLength(preambleLengthForSF(sf)) != RADIOLIB_ERR_NONE) return false; + _preamble_sf = sf; + return true; + } virtual int16_t performChannelScan(); int getNoiseFloor() const override { return _noise_floor; } @@ -156,7 +169,7 @@ public: float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, 10, packet_len); } // assume sf=10 - virtual bool setRxBoostedGainMode(bool) { return false; } + bool setRxBoostedGainMode(bool enabled); virtual bool getRxBoostedGainMode() const { return false; } }; diff --git a/test/test_mesh_tables/test_simple_mesh_tables.cpp b/test/test_mesh_tables/test_simple_mesh_tables.cpp index 6ff73ecf..ec6af7f3 100644 --- a/test/test_mesh_tables/test_simple_mesh_tables.cpp +++ b/test/test_mesh_tables/test_simple_mesh_tables.cpp @@ -1,5 +1,4 @@ #include -#define MESH_ENABLE_RECENT_REPEATERS 1 #define MAX_RECENT_REPEATERS 8 #include "helpers/SimpleMeshTables.h" @@ -35,6 +34,17 @@ static Packet makeAckPacket(uint32_t crc, bool direct = true) { return p; } +static Packet makeMultipartAckPacket(uint32_t crc, uint8_t remaining = 1, bool direct = true) { + Packet p; + p.header = (direct ? ROUTE_TYPE_DIRECT : ROUTE_TYPE_FLOOD) + | (PAYLOAD_TYPE_MULTIPART << PH_TYPE_SHIFT); + p.payload[0] = (remaining << 4) | PAYLOAD_TYPE_ACK; + memcpy(&p.payload[1], &crc, sizeof(crc)); + p.payload_len = sizeof(crc) + 1; + p.path_len = 0; + return p; +} + // ── wasSeen: pure query ─────────────────────────────────────────────────────── TEST(SimpleMeshTables, WasSeen_ReturnsFalseForUnseen) { @@ -118,19 +128,24 @@ TEST(SimpleMeshTables, AckCrcZeroIsAValidUnseenValue) { EXPECT_TRUE(t.wasSeen(&p)); } -TEST(SimpleMeshTables, AckDedupUsesCrcAndIgnoresOptionalSuffix) { +TEST(SimpleMeshTables, AckDedupUsesTheCompletePayloadIdentity) { SimpleMeshTables t; Packet first = makeAckPacket(0xC3B2A141); Packet repeated = first; + Packet different_attempt = first; first.payload[4] = 0x10; first.payload[5] = 0x20; first.payload_len = 6; - repeated.payload[4] = 0x99; - repeated.payload[5] = 0x88; + repeated.payload[4] = 0x10; + repeated.payload[5] = 0x20; repeated.payload_len = 6; + different_attempt.payload[4] = 0x14; + different_attempt.payload[5] = 0x20; + different_attempt.payload_len = 6; t.markSeen(&first); EXPECT_TRUE(t.wasSeen(&repeated)); + EXPECT_FALSE(t.wasSeen(&different_attempt)); } TEST(SimpleMeshTables, AckTrafficDoesNotEvictGeneralPacketHashes) { @@ -146,6 +161,40 @@ TEST(SimpleMeshTables, AckTrafficDoesNotEvictGeneralPacketHashes) { EXPECT_TRUE(t.wasSeen(&retained)); } +TEST(SimpleMeshTables, MultipartAckTrafficDoesNotEvictGeneralPacketHashes) { + SimpleMeshTables t; + Packet retained = makeFloodPacket(0x5B); + t.markSeen(&retained); + + for (int i = 0; i < MAX_PACKET_HASHES + 1; i++) { + Packet ack = makeMultipartAckPacket((uint32_t)i, (uint8_t)(i & 0x0F)); + t.markSeen(&ack); + } + + EXPECT_TRUE(t.wasSeen(&retained)); +} + +TEST(SimpleMeshTables, NormalAndMultipartAckTransmissionsAreDistinct) { + SimpleMeshTables t; + Packet normal = makeAckPacket(0x10203040); + Packet multipart = makeMultipartAckPacket(0x10203040); + + t.markSeen(&normal); + EXPECT_FALSE(t.wasSeen(&multipart)); + t.markSeen(&multipart); + EXPECT_TRUE(t.wasSeen(&multipart)); + EXPECT_TRUE(t.wasSeen(&normal)); +} + +TEST(SimpleMeshTables, MultipartRemainingCountIsPartOfItsIdentity) { + SimpleMeshTables t; + Packet first = makeMultipartAckPacket(0x55667788, 1); + Packet second = makeMultipartAckPacket(0x55667788, 0); + + t.markSeen(&first); + EXPECT_FALSE(t.wasSeen(&second)); +} + TEST(SimpleMeshTables, ShortAckPayloadFallsBackToSafeGeneralDedup) { SimpleMeshTables t; Packet p = makeAckPacket(0x7B); @@ -173,7 +222,8 @@ TEST(RouteHashPrefixes, MatchesSharedOneTwoOrThreeBytes) { } TEST(SimpleMeshTables, ShortFailurePrefixUpdatesOverlappingLongEntry) { - SimpleMeshTables t; + SimpleMeshTables::RecentRepeaterInfo storage[MAX_RECENT_REPEATERS]; + SimpleMeshTables t(storage, MAX_RECENT_REPEATERS); const uint8_t configured[] = {0x86, 0x0c, 0xca}; const uint8_t observed[] = {0x86}; @@ -185,6 +235,23 @@ TEST(SimpleMeshTables, ShortFailurePrefixUpdatesOverlappingLongEntry) { EXPECT_EQ(1, t.getRecentRepeaterCount()); } +TEST(SimpleMeshTables, RecentRepeatersExpireOnlyAfterTwentyFourHours) { + SimpleMeshTables::RecentRepeaterInfo storage[MAX_RECENT_REPEATERS]; + SimpleMeshTables t(storage, MAX_RECENT_REPEATERS); + const uint8_t first[] = {0x86, 0x0c, 0xca}; + const uint8_t second[] = {0x71, 0xce, 0x82}; + constexpr uint32_t twenty_four_hours = 24UL * 60UL * 60UL * 1000UL; + + ASSERT_TRUE(t.setRecentRepeater(first, 3, 12)); + ASSERT_TRUE(t.setRecentRepeater(second, 3, 8)); + EXPECT_EQ(0, t.expireRecentRepeaters(twenty_four_hours, twenty_four_hours)); + EXPECT_EQ(2, t.getRecentRepeaterCount()); + + EXPECT_EQ(2, t.expireRecentRepeaters(twenty_four_hours + 1, twenty_four_hours)); + EXPECT_EQ(0, t.getRecentRepeaterCount()); + EXPECT_EQ(nullptr, t.findRecentRepeaterByHash(first, 3)); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp index 250cc6f7..7804f398 100644 --- a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp +++ b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp @@ -2,6 +2,43 @@ #include +class TestClock : public mesh::MillisecondClock { +public: + unsigned long now = 0; + unsigned long getMillis() override { return now; } +}; + +class TestRadio : public mesh::Radio { +public: + int send_starts = 0; + + int recvRaw(uint8_t*, int) override { return 0; } + uint32_t getEstAirtimeFor(int) override { return 1; } + float packetScore(float, int) override { return 0; } + bool startSendRaw(const uint8_t*, int) override { send_starts++; return true; } + bool isSendComplete() override { return false; } + void onSendFinished() override { } + bool isInRecvMode() const override { return true; } +}; + +class TestDispatcher : public mesh::Dispatcher { + RxReservePacketManager& manager; + +protected: + mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { return ACTION_RELEASE; } + void onSendFail(mesh::Packet* packet) override { + failed_packet = packet; + free_count_during_failure = manager.getFreeCount(); + } + +public: + mesh::Packet* failed_packet = nullptr; + int free_count_during_failure = -1; + + TestDispatcher(TestRadio& radio, TestClock& clock, RxReservePacketManager& mgr) + : mesh::Dispatcher(radio, clock, mgr), manager(mgr) { } +}; + TEST(RxReservePacketManager, RejectedOutboundRemainsOwnedByCaller) { RxReservePacketManager manager(8, 4); mesh::Packet* held[6]; @@ -49,9 +86,38 @@ TEST(RxReservePacketManager, PeekExpiresStalePacketBeforeChannelSelection) { EXPECT_EQ(nullptr, manager.peekNextOutbound(30001)); EXPECT_EQ(0, manager.getOutboundTotal()); + // The manager must not silently free a packet while Dispatcher/application + // retry state can still refer to it. + EXPECT_EQ(7, manager.getFreeCount()); + EXPECT_EQ(stale, manager.getNextDroppedOutbound()); + EXPECT_EQ(nullptr, manager.getNextDroppedOutbound()); + manager.free(stale); EXPECT_EQ(8, manager.getFreeCount()); } +TEST(RxReservePacketManager, DispatcherNotifiesBeforeReleasingStaleOutbound) { + RxReservePacketManager manager(8, 4); + TestClock clock; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.begin(); + + mesh::Packet* stale = dispatcher.obtainNewPacket(); + ASSERT_NE(stale, nullptr); + stale->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT); + stale->payload[0] = 0x42; + stale->payload_len = 1; + ASSERT_TRUE(dispatcher.sendPacket(stale, 0)); + + clock.now = 30001; + dispatcher.loop(); + + EXPECT_EQ(stale, dispatcher.failed_packet); + EXPECT_EQ(7, dispatcher.free_count_during_failure); + EXPECT_EQ(8, manager.getFreeCount()); + EXPECT_EQ(0, radio.send_starts); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_region_names/test_region_name_utils.cpp b/test/test_region_names/test_region_name_utils.cpp new file mode 100644 index 00000000..05da92ec --- /dev/null +++ b/test/test_region_names/test_region_name_utils.cpp @@ -0,0 +1,20 @@ +#include + +#include + +TEST(RegionNameUtils, PublicMarkerDoesNotCreateADistinctName) { + EXPECT_TRUE(RegionNameUtils::equivalent("sea", "#sea")); + EXPECT_TRUE(RegionNameUtils::equivalent("#sea", "sea")); + EXPECT_TRUE(RegionNameUtils::equivalent("#sea", "#sea")); +} + +TEST(RegionNameUtils, DifferentAndPrivateNamesRemainDistinct) { + EXPECT_FALSE(RegionNameUtils::equivalent("sea", "w-wa")); + EXPECT_FALSE(RegionNameUtils::equivalent("sea", "$sea")); + EXPECT_FALSE(RegionNameUtils::equivalent(nullptr, "sea")); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/tiny_relay/platformio.ini b/variants/tiny_relay/platformio.ini index 82cb251f..6ba8aa28 100644 --- a/variants/tiny_relay/platformio.ini +++ b/variants/tiny_relay/platformio.ini @@ -18,6 +18,7 @@ build_src_filter = ${stm32_base.build_src_filter} [env:Tiny_Relay_repeater] extends = Tiny_Relay build_flags = ${Tiny_Relay.build_flags} + -flto ; this 224 KiB target otherwise has no room for current repeater safety features -D ADVERT_NAME='"tiny_relay Repeater"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0