Fix OTA resume and radio state recovery

Preserve resumed nRF52 flash pages and keep filtered OTA catalogs incomplete until full pages arrive. Bound stuck radio preparation, report companion setting failures, retain safe rollback, and codify conservative advert penalties with regression tests.
This commit is contained in:
mikecarper committed 2026-09-15 12:17:54 -07:00
1 parent 62ce8d3320
commit e4e0de8a79
22 files changed
+1175 -59

No files matched your search

+2
View File
@@ -82,6 +82,7 @@ jobs:
sudo apt-get install -y libssl-dev
python3 -B test/test_companion_tx_routing.py -v
python3 -B test/test_companion_preferences_transaction.py -v
python3 -B test/test_companion_radio_settings_transaction.py -v
python3 -B test/test_contact_cache.py -v
- name: Verify receive recovery and pinned radio status transport
@@ -111,6 +112,7 @@ jobs:
python3 -B test/test_ota_apply_delivery.py -v
python3 -B test/test_ota_install_preflight.py -v
python3 -B test/test_ota_store_resume.py -v
python3 -B test/test_ota_catalog_lifetime.py -v
python3 -B test/test_ota_store_flash_nrf52_hybrid.py -v
python3 -B test/test_ota_dev_staging.py -v
python3 -B test/test_ota_dev_reboot_guard.py -v
+6
View File
@@ -56,6 +56,12 @@ physical distance.
- A full public key that exceeds the normal level in one window receives a
first strike. If it exceeds that level again in the immediately following
window, it enters the bad list. A quiet intervening window breaks this chain.
- Escalation is deliberately conservative: once a key enters the bad list,
a later shorter route does **not** undo that decision, even if the increased
quota makes the current window fall below the normal level. This applies to
shorter duplicate and new adverts, including a matching-prefix key's route.
The existing restriction remains until seven-day recovery, manual clear, or
reboot; a shorter route alone never erases a recorded violation.
- The bad list matches **all 32 public-key bytes**, not the prefix. Different
full keys sharing a prefix share the ordinary forwarding quota, but their
received counts are not combined to accuse an innocent key of ongoing abuse.
+4 -2
View File
@@ -509,8 +509,10 @@ fetched per-mid only on commit (Section 8.3).
Fragment numbers are canonical pages of the complete catalog sorted by `manifest_id`. `filter_target` may
remove rows from a requested page (and can therefore produce an empty fragment), but it never renumbers pages
or changes `frag_total`. This keeps missing-fragment recovery unambiguous when filtered and unfiltered queries
for the same `{seeder, set_digest}` are overheard together.
or changes `frag_total`. Receivers derive the unfiltered page count and each page's row count from the
source's advertised `n_motas`. A page counts as received only if both counts match that complete geometry;
filtered rows are still cached, but omitted rows leave the page eligible for an unfiltered recovery query.
This also prevents legacy filtered replies which renumber pages from completing an unfiltered catalog.
### 8.2 Anti-storm (mandatory at mesh scale)
+77 -28
View File
@@ -2015,7 +2015,7 @@ void MyMesh::configureRadioFromPrefs() {
radio_driver.setCADScanTimeoutMillis(_prefs.cad_scan_timeout_ms);
_radio->setCADEnabled(_prefs.cad_enabled != 0);
if (!saved_radio_apply_pending) {
radio_driver.setTxPower(_prefs.tx_power_dbm);
saved_radio_apply_pending = !radio_driver.setTxPower(_prefs.tx_power_dbm);
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
}
const bool fem_gain_changed = board.canControlLoRaFemLna()
@@ -2062,6 +2062,14 @@ bool MyMesh::applySavedRadioParams() {
void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf,
uint8_t cr, uint8_t repeat,
BaseSerialInterface* route) {
const float previous_freq = _prefs.freq;
const float previous_bw = _prefs.bw;
const uint8_t previous_sf = _prefs.sf;
const uint8_t previous_cr = _prefs.cr;
const uint8_t previous_repeat = _prefs.client_repeat;
const uint32_t previous_rx_us = _prefs.rx_ps_rx_us;
const uint32_t previous_sleep_us = _prefs.rx_ps_sleep_us;
const bool previous_recovery_pending = saved_radio_apply_pending;
_prefs.sf = sf;
_prefs.cr = cr;
_prefs.freq = freq;
@@ -2070,9 +2078,28 @@ void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf,
recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw,
_prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us,
&_prefs.rx_ps_sleep_us);
savePrefs();
if (!savePrefs()) {
_prefs.freq = previous_freq;
_prefs.bw = previous_bw;
_prefs.sf = previous_sf;
_prefs.cr = previous_cr;
_prefs.client_repeat = previous_repeat;
_prefs.rx_ps_rx_us = previous_rx_us;
_prefs.rx_ps_sleep_us = previous_sleep_us;
// Hardware accepted the candidate before this save. Restore the durable
// tuple now, or let the existing backed-off recovery finish once RX is
// idle. A failed save must never acknowledge a persistent radio change.
const bool restored = applySavedRadioParams();
saved_radio_apply_pending = !restored || previous_recovery_pending;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
writeErrFrame(ERR_CODE_FILE_IO_ERROR, route);
return;
}
saved_radio_apply_pending = false;
// Preserve an outstanding power rollback; the full saved-radio recovery
// checks both the tuple and TX power before clearing this latch.
saved_radio_apply_pending = previous_recovery_pending;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
@@ -3247,8 +3274,8 @@ void MyMesh::serviceTempRadio() {
if (hasOutbound() || !retry_ready) return;
mesh::RadioParamApplyResult result = tryApplyRadioParams(
_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
if (result == mesh::RadioParamApplyResult::APPLIED) {
radio_driver.setTxPower(_prefs.tx_power_dbm);
if (result == mesh::RadioParamApplyResult::APPLIED
&& radio_driver.setTxPower(_prefs.tx_power_dbm)) {
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
_temp_radio_set_at = 0;
_temp_radio_revert_at = 0;
@@ -3766,10 +3793,11 @@ void MyMesh::execCommand(char* cmd, char* reply) {
if (!wcParseLong(value, -9, MAX_LORA_TX_POWER, parsed)) {
snprintf(reply, 160, "Error: TX power must be -9 to %d", MAX_LORA_TX_POWER);
} else {
_prefs.tx_power_dbm = static_cast<int8_t>(parsed);
if (_radio_available) radio_driver.setTxPower(_prefs.tx_power_dbm);
savePrefs();
strcpy(reply, "OK");
const auto result = applyAndSaveTxPower(static_cast<int8_t>(parsed));
strcpy(reply, result == RadioSettingResult::Saved ? "OK"
: result == RadioSettingResult::RadioRejected
? "Error: radio busy or TX power rejected"
: "Error: TX power could not be saved");
}
return;
}
@@ -4811,10 +4839,10 @@ void MyMesh::handleCmdFrame(size_t len) {
if (power < -9 || power > MAX_LORA_TX_POWER) {
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
} else {
_prefs.tx_power_dbm = power;
savePrefs();
if (_radio_available) radio_driver.setTxPower(_prefs.tx_power_dbm);
writeOKFrame();
const auto result = applyAndSaveTxPower(power);
if (result == RadioSettingResult::Saved) writeOKFrame();
else writeErrFrame(result == RadioSettingResult::RadioRejected
? ERR_CODE_BAD_STATE : ERR_CODE_FILE_IO_ERROR);
}
} else if (cmd_frame[0] == CMD_SET_TUNING_PARAMS) {
if (len < 9) {
@@ -6288,6 +6316,27 @@ bool MyMesh::handleExtraFsHilCommand(const char* command, char* reply,
#endif
#endif
MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power) {
const int8_t previous = _prefs.tx_power_dbm;
if (_radio_available && !radio_driver.setTxPower(power)) {
return RadioSettingResult::RadioRejected;
}
_prefs.tx_power_dbm = power;
if (savePrefs()) return RadioSettingResult::Saved;
_prefs.tx_power_dbm = previous;
if (_radio_available && !radio_driver.setTxPower(previous)) {
// An RX packet can arrive between the apply and its failed flash commit.
// Keep retrying the durable power rather than leaving the rejected value
// active until reboot. Recovery also restores the saved tuple, so an
// active TempRadio lease deliberately delays it until the lease ends.
saved_radio_apply_pending = true;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
}
return RadioSettingResult::SaveFailed;
}
bool MyMesh::applyAndSaveFemRxGain(bool enabled) {
if (!board.canControlLoRaFemLna()) return false;
@@ -8025,9 +8074,8 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" OK\r\n");
}
} else if (strncmp(config, "tx ", 3) == 0) {
_prefs.tx_power_dbm = constrain(atoi(config + 3), -9, MAX_LORA_TX_POWER);
savePrefs();
terminalOutput().print(" OK - reboot to apply\r\n");
handleCommand(command, 0, local_reply);
terminalOutput().printf(" %s\r\n", local_reply);
} else if (strncmp(config, "freq ", 5) == 0) {
handleCommand(command, 0, local_reply);
terminalOutput().printf(" %s\r\n", local_reply);
@@ -8411,19 +8459,14 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp,
snprintf(reply, reply_capacity, "Error: TX power must be -9 to %d",
MAX_LORA_TX_POWER);
} else {
const int8_t previous = _prefs.tx_power_dbm;
const int8_t requested = static_cast<int8_t>(parsed);
if (_radio_available && !radio_driver.setTxPower(requested)) {
const auto result = applyAndSaveTxPower(requested);
if (result == RadioSettingResult::RadioRejected) {
strcpy(reply, "Error: radio busy or TX power rejected");
} else if (result == RadioSettingResult::SaveFailed) {
strcpy(reply, "Error: TX power could not be saved");
} else {
_prefs.tx_power_dbm = requested;
if (!savePrefs()) {
_prefs.tx_power_dbm = previous;
if (_radio_available) radio_driver.setTxPower(previous);
strcpy(reply, "Error: TX power changed but save failed");
} else {
strcpy(reply, "OK");
}
strcpy(reply, "OK");
}
}
return true;
@@ -8849,8 +8892,8 @@ void MyMesh::loop() {
// 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);
if (applySavedRadioParams()
&& radio_driver.setTxPower(_prefs.tx_power_dbm)) {
saved_radio_apply_pending = false;
radio_apply_retry_at = 0;
radio_apply_failures = 0;
@@ -8938,6 +8981,12 @@ bool MyMesh::hasPendingWork() const {
|| sign_data != NULL
|| hasQueuedWorkDue() || hasRetryWorkDue()
|| (saved_radio_apply_pending
#if COMPANION_FEATURE_TEMP_RADIO
// Match loop()'s restore gate: a lease-blocked rollback is not
// runnable work. The lease's own timers below wake it when due.
&& !_temp_radio_applied && _temp_radio_set_at == 0
&& _temp_radio_revert_at == 0
#endif
&& (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at)))
|| contact_write_needs_polling
|| (emergency_client_repeat_packet != NULL
+2
View File
@@ -384,6 +384,8 @@ private:
bool applyAndSaveFemRxGain(bool enabled);
bool applyAndSaveFemTxGain(bool enabled);
bool applyAndSaveRxBoostedGain(bool enabled);
enum class RadioSettingResult { Saved, RadioRejected, SaveFailed };
RadioSettingResult applyAndSaveTxPower(int8_t power);
bool handleCadCommand(const char* command, char* reply, size_t reply_size);
bool saveBluetoothNameOverride(const char* name);
bool applyAndSaveBluetoothName(const char* value, char* reply,
+36 -1
View File
@@ -27,6 +27,13 @@ namespace mesh {
#define MIN_CAD_FAIL_RETRY_DELAY_MS 50UL
#define MIN_CAD_FAIL_MAX_DURATION_MS 500UL
#define RADIO_PREPARE_BUSY_GRACE_MS 8000UL
static uint32_t validPrepareBusyAirtime(uint32_t airtime) {
// RadioLib estimates originate as 32-bit microseconds. An impossible or
// error-sentinel estimate must not retain a failed packet for days.
return airtime <= UINT32_MAX / 1000 ? airtime : 0;
}
static uint32_t scaleCADDelayForQueue(uint32_t normal_delay, int ready_count,
uint32_t minimum_delay) {
@@ -65,6 +72,7 @@ void Dispatcher::begin() {
last_meshcore_recv_millis = 0;
_err_flags = 0;
outbound_radio_retry_at = 0;
outbound_radio_prepare_deadline = 0;
outbound_radio_retry_pending = false;
outbound_radio_retry_used = false;
outbound_cancellation = OutboundCancellation::None;
@@ -192,6 +200,7 @@ bool Dispatcher::scheduleOutboundRadioRetry() {
outbound_radio_retry_used = true;
outbound_radio_retry_pending = true;
outbound_radio_prepare_deadline = 0;
outbound_radio_retry_at = futureMillis(getCADFailRetryDelay());
MESH_DEBUG_PRINTLN("%s Dispatcher: retrying packet once after radio fault",
getLogDateTime());
@@ -216,6 +225,7 @@ void Dispatcher::failOutboundTransmit() {
outbound = NULL;
outbound_radio_retry_pending = false;
outbound_radio_retry_used = false;
outbound_radio_prepare_deadline = 0;
outbound_cancellation = OutboundCancellation::None;
}
@@ -377,9 +387,33 @@ void Dispatcher::loop() {
const auto prepared = _radio->prepareTransmitProfile(outbound->radio_profile,
outbound->radio_reply && outbound->radio_reply_force);
if (prepared == RadioParamApplyResult::BUSY) {
if (outbound_radio_prepare_deadline == 0) {
// A frame already being received may legitimately own either scan
// profile. Allow its longest full-packet airtime plus recovery grace.
// This timer covers only prepare BUSY, not ordinary CAD backoff.
uint32_t airtime = validPrepareBusyAirtime(_radio->getProfileAirtime(0, MAX_TRANS_UNIT));
if (isDualRadioActive()) {
const uint32_t other = validPrepareBusyAirtime(_radio->getProfileAirtime(1, MAX_TRANS_UNIT));
if (other > airtime) airtime = other;
}
const uint32_t timeout = airtime * 3 / 2 + RADIO_PREPARE_BUSY_GRACE_MS;
outbound_radio_prepare_deadline = _ms->getMillis() + timeout;
if (outbound_radio_prepare_deadline == 0) outbound_radio_prepare_deadline = 1;
} else if (millisHasNowPassed(outbound_radio_prepare_deadline)) {
// Retaining outbound disables the non-RX watchdog, and this backoff
// bypasses the liveness watchdog. Release ownership before recovery
// so even a failed reset cannot pin all later queue entries forever.
_err_flags |= ERR_EVENT_RADIO_WATCHDOG;
MESH_DEBUG_PRINTLN("%s Dispatcher: radio retry profile remained busy", getLogDateTime());
failOutboundTransmit();
_radio->recoverRadio(true);
radio_nonrx_start = _ms->getMillis();
return;
}
outbound_radio_retry_at = futureMillis(10);
return;
}
outbound_radio_prepare_deadline = 0;
if (prepared == RadioParamApplyResult::FAILED || !isPacketRadioCurrent(outbound)
|| !allowPacketTransmit(outbound)) {
@@ -1027,7 +1061,8 @@ bool Dispatcher::sendPacket(Packet* packet, uint8_t priority, uint32_t delay_mil
// Utility function -- handles the case where millis() wraps around back to zero
// 2's complement arithmetic will handle any unsigned subtraction up to HALF the word size (32-bits in this case)
bool Dispatcher::millisHasNowPassed(unsigned long timestamp) const {
return (long)(_ms->getMillis() - timestamp) > 0;
// millis is a 32-bit counter even on native hosts where long is 64 bits.
return (int32_t)((uint32_t)_ms->getMillis() - (uint32_t)timestamp) > 0;
}
unsigned long Dispatcher::futureMillis(int millis_from_now) const {
+1
View File
@@ -281,6 +281,7 @@ class Dispatcher {
Packet* outbound; // current outbound packet
unsigned long outbound_expiry, outbound_start, total_air_time, rx_air_time;
unsigned long outbound_radio_retry_at;
uint32_t outbound_radio_prepare_deadline = 0;
#ifdef RADIO_LIVENESS_SOFT_ONLY
unsigned long rx_watchdog_window_start;
#else
+5 -2
View File
@@ -328,8 +328,11 @@ void Mesh::loop() {
for (int i = 0; i < TOTAL_FLOOD_RETRY_SLOTS; ++i) {
if (!_flood_retries[i].active) continue;
const auto* packet = _flood_retries[i].queued ? _flood_retries[i].packet : _flood_retries[i].trigger_packet;
if ((_flood_retries[i].waiting_final_echo && _flood_retries[i].packet
&& !isPacketRadioCurrent(_flood_retries[i].packet))
// Final flood echoes also retain metadata only. Release the old
// session's retry key even though there is no packet left to inspect.
const uint8_t profile = i / MAX_FLOOD_RETRY_SLOTS;
if ((_flood_retries[i].waiting_final_echo
&& _retry_radio_generations[profile] != p->generation[profile])
|| (packet && !isPacketRadioCurrent(packet))) retireFloodRetrySlot(i);
}
for (int i = _mgr->getOutboundTotal() - 1; i >= 0; --i) {
+3
View File
@@ -130,6 +130,9 @@ void FloodAdvertLimiter::observe(const uint8_t* key, const uint8_t* hash, uint8_
// The normal level belongs to the prefix, including its shortest RX path.
// Keep that minimum on every colliding full key for consistent window-close
// abuse accounting, but never combine their received counts into a strike.
// Be conservative once BAD is set: a later shorter path can raise the normal
// quota, but cannot retract an already-triggered penalty or recorded violation.
// Only seven-day recovery, an explicit clear, or reboot removes that penalty.
if (hops < entry->min_hops) entry->min_hops = hops;
for (size_t i = 0; i < _capacity; ++i) {
if ((_entries[i].flags & ACTIVE) && memcmp(_entries[i].key, key, PREFIX_BYTES) == 0
+22 -10
View File
@@ -861,7 +861,8 @@ void OtaManager::invalidateCatalogSeeder(const uint8_t* seeder) {
row.last_ms = 0;
for (uint8_t k = 0; k < row.n_seeders; k++) {
if (row.seeder_have[k] > row.have_max) row.have_max = row.seeder_have[k];
if (row.seeder_last_ms[k] > row.last_ms) row.last_ms = row.seeder_last_ms[k];
if (k == 0 || (uint32_t)(_now_ms - row.seeder_last_ms[k])
< (uint32_t)(_now_ms - row.last_ms)) row.last_ms = row.seeder_last_ms[k];
}
i++;
}
@@ -888,7 +889,9 @@ void OtaManager::handleAdv(const uint8_t* m, uint16_t n) {
int slot = -1, lru = 0; // find/insert the source (LRU evict)
for (int i = 0; i < _n_src; i++) {
if (memcmp(_sources[i].seeder, a.seeder_id, 4) == 0) { slot = i; break; }
if (_sources[i].last_ms < _sources[lru].last_ms) lru = i;
// Compare elapsed ages: raw millis ordering reverses when uptime wraps.
if ((uint32_t)(_now_ms - _sources[i].last_ms)
> (uint32_t)(_now_ms - _sources[lru].last_ms)) lru = i;
}
bool fresh = (slot < 0);
if (fresh) {
@@ -994,18 +997,25 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) {
if (memcmp(_sources[i].digest, hv.set_digest, 4) != 0) return;
break;
}
// PASSIVE: every node caches rows it overhears. A source is catalogued only after EVERY advertised
// fragment arrived; otherwise a timed recovery QUERY asks for just the missing bitmap.
// PASSIVE: every node caches rows it overhears. Only complete canonical pages
// count toward discovery: filtered replies have the same fragment positions,
// but omit rows and cannot suppress a later unfiltered query for those pages.
const uint8_t per = (uint8_t)((MAX_PACKET_PAYLOAD - 12) / OTA_HAVE_ROW_BYTES);
for (uint8_t i = 0; i < _n_src; i++) {
Source& s = _sources[i];
if (memcmp(s.seeder, hv.seeder_id, 4) != 0 || memcmp(s.digest, hv.set_digest, 4) != 0) continue;
if (s.have_total != hv.frag_total) {
s.have_total = hv.frag_total;
const uint8_t total = (uint8_t)((s.n_motas + per - 1) / per);
if (s.have_total != total) {
s.have_total = total;
s.have_mask = 0;
s.have_catalog = false;
}
s.have_mask |= 1UL << hv.frag_idx;
uint32_t full = hv.frag_total >= 32 ? UINT32_MAX : ((1UL << hv.frag_total) - 1);
if (hv.frag_total == total && hv.frag_idx < total) {
const uint16_t remaining = s.n_motas - (uint16_t)hv.frag_idx * per;
const uint8_t expected_rows = remaining < per ? (uint8_t)remaining : per;
if (hv.n_rows == expected_rows) s.have_mask |= 1UL << hv.frag_idx;
}
uint32_t full = total >= 32 ? UINT32_MAX : ((1UL << total) - 1);
s.have_catalog = s.have_mask == full;
if (s.have_catalog) {
s.query_pending = false;
@@ -1026,7 +1036,8 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) {
int slot = -1, lru = 0; // upsert into the catalog (dedup by mid)
for (int i = 0; i < _n_cat; i++) {
if (memcmp(catalog[i].mid, mid, 4) == 0) { slot = i; break; }
if (catalog[i].last_ms < catalog[lru].last_ms) lru = i;
if ((uint32_t)(_now_ms - catalog[i].last_ms)
> (uint32_t)(_now_ms - catalog[lru].last_ms)) lru = i;
}
if (slot < 0) {
if (_n_cat >= catalogCapacity() && expandCatalog()) catalog = catalogData();
@@ -1056,7 +1067,8 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) {
c.have_max = 0; c.last_ms = 0;
for (uint8_t k = 0; k < c.n_seeders; k++) {
if (c.seeder_have[k] > c.have_max) c.have_max = c.seeder_have[k];
if (c.seeder_last_ms[k] > c.last_ms) c.last_ms = c.seeder_last_ms[k];
if (k == 0 || (uint32_t)(_now_ms - c.seeder_last_ms[k])
< (uint32_t)(_now_ms - c.last_ms)) c.last_ms = c.seeder_last_ms[k];
}
if (wantRow(mid, target, fwver, codec, flags)) startFetch(mid, target);
}
+10 -1
View File
@@ -69,6 +69,7 @@ void OtaStoreFlashNrf52::reset_session() {
_ram_len = 0;
_total = 0;
_hybrid = false;
_preserve_payload_pages = false;
_pay_idx = 0;
_flushed = false;
_io_ok = true;
@@ -309,7 +310,14 @@ uint8_t* OtaStoreFlashNrf52::write_slot(uint32_t pos) {
if (page == 0) return _meta_page + pos;
if (page > _pay_idx) {
if (!flush_pay()) return nullptr;
_pay_idx = page; memset(_pay_page, 0xFF, PG); // advance, fresh page
_pay_idx = page;
// A resumed page can contain verified blocks on either side of this hole.
// Those blocks retain their leaf markers, so erasing their cached bytes here
// would silently corrupt them when this page is checkpointed or finalized.
if (_preserve_payload_pages)
memcpy(_pay_page, (const uint8_t*)(uintptr_t)(_write_start + page * PG), PG);
else
memset(_pay_page, 0xFF, PG); // fresh capture, no committed bytes yet
}
if (page == _pay_idx) return _pay_page + (pos - page * PG);
return nullptr; // page < _pay_idx: already flushed
@@ -598,6 +606,7 @@ bool OtaStoreFlashNrf52::reopen() {
_ram_len = 0;
_total = total;
_hybrid = false;
_preserve_payload_pages = true;
memcpy(_meta_page, p, PG); // load page 0 (header+manifest+leaves) into RAM to continue
// The tail is deferred until finalize and can still hold old flash bytes.
// Reconstruct only fixed framing; the manager must verify the manifest and
+3
View File
@@ -22,6 +22,8 @@
// (set_meta_size enforces it; true for <= ~979 blocks, i.e. any realistic MeshCore image).
// - `_pay_page` is a single sliding buffer for one payload page (index >= 1). It advances
// monotonically with the (mostly in-order) block stream and flushes the page it leaves behind.
// After reopen, newly opened pages load their existing flash bytes to preserve committed blocks
// which share a page with missing blocks; only fresh captures initialize pages to erased bytes.
// Rare out-of-order writes to an already-flushed page go straight to flash as a safe read-modify-
// write (flash_nrf5x erases before programming, so re-touching a page never violates the
// writes-per-word limit - it just costs one extra erase).
@@ -42,6 +44,7 @@ class OtaStoreFlashNrf52 : public OtaStore {
uint32_t _ram_len = 0; // logical suffix backed by the fixed SRAM arena
uint32_t _total = 0; // staged container size (0 = none)
bool _hybrid = false; // flash prefix + reset-retained SRAM suffix
bool _preserve_payload_pages = false; // reopened pages can contain committed blocks around later holes
bool _flushed = false; // finalize() committed everything to flash
bool _io_ok = true; // cleared on a failed/out-of-bounds flash write or readback mismatch
bool _planned_bootloader = false; // manifest-kind decision made before begin()/any erase
+183
View File
@@ -0,0 +1,183 @@
#include <helpers/ota/OtaManager.h>
#include <helpers/ota/OtaByteIO.h>
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <vector>
using namespace mesh::ota;
static bool send(void*, const uint8_t*, uint16_t, bool) { return true; }
static void advert(OtaManager& manager, uint32_t source, uint8_t count = 1) {
AdvMsg message{};
wr_u32le(message.seeder_id, source);
wr_u32le(message.set_digest, 123);
message.n_motas = count;
uint8_t wire[24];
manager.on_message(wire, encode_adv(wire, sizeof(wire), message));
}
static void have(OtaManager& manager, uint32_t source, uint32_t mid) {
uint8_t row[OTA_HAVE_ROW_BYTES] = {};
wr_u32le(row, mid);
wr_u32le(row + 4, 1234);
row[12] = CODEC_FULL; row[13] = MFLAG_FULL;
row[14] = 1;
HaveMsg message{};
wr_u32le(message.seeder_id, source);
wr_u32le(message.set_digest, 123);
message.frag_total = 1; message.n_rows = 1; message.rows = row;
uint8_t wire[64];
manager.on_message(wire, encode_have(wire, sizeof(wire), message));
}
static const OtaManager::CatRow* row(OtaManager& manager, uint32_t mid) {
for (unsigned i = 0; i < manager.catalogCount(); ++i) {
const auto* entry = manager.catalogRow(i);
if (rd_u32le(entry->mid) == mid) return entry;
}
return nullptr;
}
using Messages = std::vector<std::vector<uint8_t>>;
static bool capture(void* context, const uint8_t* message, uint16_t length, bool) {
static_cast<Messages*>(context)->emplace_back(message, message + length);
return true;
}
class MixedCatalogSource : public MotaSource {
uint8_t _count;
public:
explicit MixedCatalogSource(uint8_t count) : _count(count) {}
uint8_t count() override { return _count; }
bool describe(uint8_t index, MotaDesc& out) override {
if (index >= _count) return false;
wr_u32le(out.mid, 100u + index);
// Three pages: fully matching, partially matching, and empty for target 1.
out.target_id = _count == 2 ? index + 1u
: index < 10 ? 1u : index < 20 ? 1u + index % 2u : 2u;
out.fw_version = 1; out.codec_id = CODEC_FULL; out.flags = MFLAG_FULL;
out.block_size_log2 = 7; out.block_count = 1;
out.leaves_off = 8 + MOTA_MFL; out.payload_off = out.leaves_off + 4;
out.payload_size = 128; out.total_size = out.payload_off + out.payload_size + 5;
return true;
}
bool read(uint8_t, uint32_t, uint8_t*, uint32_t) override { return false; }
};
static void filtered_catalog_remains_queryable(int scenario, bool automatic) {
Messages from_server, from_client;
OtaManager server, client;
server.begin(0, capture, &from_server);
client.begin(0, capture, &from_client);
client.set_archive_interest(automatic);
uint8_t sid[4] = {1, 2, 3, 4}; server.set_seeder_id(sid);
MixedCatalogSource source(scenario == 4 ? 2 : 24);
assert(server.add_source(&source));
server.announce();
assert(from_server.size() == 1);
AdvMsg adv{};
assert(decode_adv(from_server[0].data(), from_server[0].size(), adv));
if (scenario != 8) client.on_message(from_server[0].data(), from_server[0].size());
from_server.clear();
QueryMsg query{};
memcpy(query.seeder_id, sid, 4); memcpy(query.set_digest, adv.set_digest, 4);
query.filter_target = scenario == 6 ? 99 : 1;
uint8_t wire[256];
server.on_message(wire, encode_query(wire, sizeof(wire), query));
Messages filtered = from_server;
if (scenario == 7) {
// Legacy filtered servers packed only the matching rows into fewer pages.
filtered.pop_back();
for (auto& message : filtered) {
HaveMsg legacy{};
assert(decode_have(message.data(), message.size(), legacy));
legacy.frag_total = 2;
const uint16_t length = encode_have(wire, sizeof(wire), legacy);
message.assign(wire, wire + length);
}
}
for (const auto& message : filtered) client.on_message(message.data(), message.size());
assert(client.catalogCount() == (scenario == 4 ? 1 : scenario == 6 ? 0 : 15));
if (scenario == 8) {
// Passive rows arriving before a beacon must not confer completeness on its new digest.
client.on_message(wire, encode_adv(wire, sizeof(wire), adv));
assert(client.catalogCount() == 0);
}
if (automatic) {
client.set_clock(5000);
client.loop();
} else {
client.queryAll();
}
assert(from_client.size() == 1);
QueryMsg recovery{};
assert(decode_query(from_client[0].data(), from_client[0].size(), recovery));
assert(recovery.filter_target == 0);
assert(recovery.want_fragments == (scenario == 4 ? 1u : scenario == 5 ? 6u : scenario == 8 ? 0u : 7u));
from_server.clear();
server.on_message(from_client[0].data(), from_client[0].size());
for (const auto& message : from_server) client.on_message(message.data(), message.size());
assert(client.catalogCount() == source.count());
from_client.clear();
// Late filtered packets must not downgrade already-complete pages either.
for (const auto& message : filtered) client.on_message(message.data(), message.size());
client.queryAll();
client.set_clock(5000 + OTA_CATALOG_RETRY_MS + 1);
client.loop();
assert(from_client.empty());
}
int main(int argc, char** argv) {
assert(argc == 3);
const int scenario = atoi(argv[1]);
const bool rollover = atoi(argv[2]) != 0;
if (scenario >= 4) {
filtered_catalog_remains_queryable(scenario, rollover);
return 0;
}
const uint32_t old = rollover ? 0xFFFF0000u : 1000u;
const uint32_t recent = rollover ? 0u : 100000u;
OtaManager manager;
manager.begin(1234, send, nullptr);
if (scenario == 0) {
for (unsigned i = 1; i <= OTA_MAX_SOURCES; ++i) {
manager.set_clock(old + i * 100);
advert(manager, i); have(manager, i, i);
}
manager.set_clock(recent);
advert(manager, 1); // freshest source is no longer the eviction victim
manager.set_clock(recent + 10);
advert(manager, OTA_MAX_SOURCES + 1);
assert(row(manager, 1));
assert(!row(manager, 2));
assert(manager.sourceCount() == OTA_MAX_SOURCES);
} else if (scenario == 1) {
for (unsigned i = 1; i <= OTA_MAX_CATALOG; ++i) {
manager.set_clock(old + i * 100);
have(manager, 1, i);
}
manager.set_clock(recent);
have(manager, 1, 1);
manager.set_clock(recent + 10);
have(manager, 1, OTA_MAX_CATALOG + 1);
assert(row(manager, 1));
assert(!row(manager, 2));
assert(manager.catalogCount() == OTA_MAX_CATALOG);
} else {
manager.set_clock(old);
have(manager, 1, 1);
manager.set_clock(recent);
have(manager, 2, 1);
if (scenario == 2) {
assert(row(manager, 1)->last_ms == recent);
} else {
manager.set_clock(recent + 10);
have(manager, 3, 1);
manager.set_clock(recent + 20);
advert(manager, 3, 0); // withdrawal recomputes newest surviving source
assert(row(manager, 1)->last_ms == recent);
assert(row(manager, 1)->n_seeders == 2);
}
}
}
@@ -286,6 +286,90 @@ static void dirty_flash_partial_checkpoint_finishes(bool reboot) {
CHECK(std::memcmp(active.data(), bytes.data(), total) == 0);
}
static void resumed_partial_payload_pages_preserve_committed_prefixes() {
using namespace mesh::ota;
const uint32_t total = 4u * MOTA_NRF52_FLASH_PAGE;
for (uint32_t prefix : {MOTA_NRF52_FLASH_PAGE + 1u,
MOTA_NRF52_FLASH_PAGE + 1024u,
2u * MOTA_NRF52_FLASH_PAGE,
2u * MOTA_NRF52_FLASH_PAGE + 227u}) {
reset_target_memory();
auto bytes = patterned_container(total);
std::memcpy(bytes.data() + total - 5u, MOTA_TRAILER, 5u);
{
OtaStoreFlashNrf52 original;
CHECK(original.begin(total));
CHECK(original.write(0, bytes.data(), prefix));
CHECK(original.write(total - 5u, MOTA_TRAILER, 5u));
original.checkpoint();
}
OtaStoreFlashNrf52 resumed;
CHECK(resumed.reopen());
CHECK(resumed.write(prefix, bytes.data() + prefix, total - prefix - 5u));
CHECK(resumed.finalize());
std::vector<uint8_t> output(total);
CHECK(resumed.read(0, output.data(), total));
CHECK(output == bytes);
CHECK(std::memcmp(resumed.data(), bytes.data(), total) == 0);
}
}
static void resumed_out_of_order_writes_survive_another_checkpoint() {
using namespace mesh::ota;
reset_target_memory();
const uint32_t total = 4u * MOTA_NRF52_FLASH_PAGE;
auto bytes = patterned_container(total);
std::memcpy(bytes.data() + total - 5u, MOTA_TRAILER, 5u);
{
OtaStoreFlashNrf52 original;
CHECK(original.begin(total));
CHECK(original.write(0, bytes.data(), total));
original.checkpoint();
}
const uint32_t offsets[] = {MOTA_NRF52_FLASH_PAGE + 11u,
3u * MOTA_NRF52_FLASH_PAGE + 17u,
2u * MOTA_NRF52_FLASH_PAGE + 25u,
MOTA_NRF52_FLASH_PAGE + 53u};
{
OtaStoreFlashNrf52 resumed;
CHECK(resumed.reopen());
for (uint32_t offset : offsets) {
for (unsigned i = 0; i < 13; ++i) bytes[offset + i] ^= 0x5A;
CHECK(resumed.write(offset, bytes.data() + offset, 13));
}
resumed.checkpoint();
}
OtaStoreFlashNrf52 resumed_again;
CHECK(resumed_again.reopen());
// Cross a page boundary after the second restart, preserving bytes on both sides.
const uint32_t offset = 2u * MOTA_NRF52_FLASH_PAGE - 7u;
for (unsigned i = 0; i < 19; ++i) bytes[offset + i] ^= 0xA5;
CHECK(resumed_again.write(offset, bytes.data() + offset, 19));
CHECK(resumed_again.finalize());
std::vector<uint8_t> output(total);
CHECK(resumed_again.read(0, output.data(), total));
CHECK(output == bytes);
CHECK(std::memcmp(resumed_again.data(), bytes.data(), total) == 0);
}
static void fresh_begin_after_reopen_does_not_retain_old_payload_bytes() {
using namespace mesh::ota;
reset_target_memory();
const uint32_t total = 3u * MOTA_NRF52_FLASH_PAGE;
const auto bytes = patterned_container(total);
OtaStoreFlashNrf52 store;
CHECK(store.begin(total));
CHECK(store.write(0, bytes.data(), total));
store.checkpoint();
CHECK(store.reopen());
CHECK(store.begin(total));
const uint32_t offset = MOTA_NRF52_FLASH_PAGE + 128u;
CHECK(store.write(offset, bytes.data() + offset, 16));
uint8_t preceding[128];
CHECK(store.read(MOTA_NRF52_FLASH_PAGE, preceding, sizeof(preceding)));
for (uint8_t byte : preceding) CHECK(byte == 0xFF);
}
int main() {
map_target_region(FLASH_MAP_START, FLASH_MAP_SIZE);
map_target_region(RAM_MAP_START, RAM_MAP_SIZE);
@@ -303,6 +387,12 @@ int main() {
std::puts("PASS: dirty flash partial checkpoint resumes and finishes");
dirty_flash_partial_checkpoint_finishes(false);
std::puts("PASS: dirty flash partial checkpoint continues without reboot");
std::puts("7 OtaStoreFlashNrf52 hybrid lifecycle checks passed");
resumed_partial_payload_pages_preserve_committed_prefixes();
std::puts("PASS: resumed payload pages preserve checkpointed prefixes");
resumed_out_of_order_writes_survive_another_checkpoint();
std::puts("PASS: resumed out-of-order writes survive repeated checkpoints");
fresh_begin_after_reopen_does_not_retain_old_payload_bytes();
std::puts("PASS: fresh captures after reopen discard stale payload bytes");
std::puts("10 OtaStoreFlashNrf52 hybrid lifecycle checks passed");
return 0;
}
+257 -2
View File
@@ -6,19 +6,29 @@ class DualProfileTestRadio : public RetryCodingRateRadio {
int busy_profile = -1;
bool fail_next_send = false;
bool hold_prepare_busy = false;
bool receive_mode = true, recovery_succeeds = true, clear_prepare_on_recovery = false;
bool carrier = false, carrier_on_receive = false;
unsigned carrier_services = 0, recoveries = 0;
bool isCarrierWaveActive() const override { return carrier; }
bool isInRecvMode() const override { return !carrier && !sending; }
bool isInRecvMode() const override { return receive_mode && !carrier && !sending; }
void loop() override { ++carrier_services; }
bool recoverRadio(bool) override { ++recoveries; return true; }
bool recoverRadio(bool) override {
++recoveries;
if (recovery_succeeds && clear_prepare_on_recovery) {
hold_prepare_busy = false;
receive_mode = true;
}
return recovery_succeeds;
}
uint32_t estimated_airtime = 10;
uint32_t secondary_airtime = 0;
float receive_score = 0;
float packetScore(float, int) override { return receive_score; }
TraceTestClock* sensing_clock = nullptr;
uint32_t sensing_delay = 0;
bool distinguish_airtime = false;
uint32_t getProfileAirtime(uint8_t profile, int bytes, uint8_t cr = 0) override {
if (profile == 1 && secondary_airtime) return secondary_airtime;
return distinguish_airtime ? (profile ? 70 : 900)
: mesh::Radio::getProfileAirtime(profile, bytes, cr);
}
@@ -79,6 +89,7 @@ class DualProfileTestMesh : public RetryCodingRateMesh {
bool cross_filter_allows = true;
float ota_speed = 1.0f;
bool suppress_tx = false;
bool enqueue_on_send_fail = false;
unsigned send_failures = 0;
unsigned send_completions = 0;
void onSendComplete(mesh::Packet* packet) override {
@@ -88,11 +99,24 @@ class DualProfileTestMesh : public RetryCodingRateMesh {
unsigned tx_failure_logs = 0;
void logTxFail(mesh::Packet*, int) override { ++tx_failure_logs; }
unsigned direct_successes = 0, direct_failures = 0;
unsigned flood_successes = 0, flood_failures = 0;
void onDirectRetrySucceeded(const uint8_t*, uint8_t, int8_t) override { ++direct_successes; }
void onDirectRetryFailed(const uint8_t*, uint8_t) override { ++direct_failures; }
void onFloodRetryEvent(const char* event, const mesh::Packet*, uint32_t, uint8_t) override {
if (strcmp(event, "good") == 0) ++flood_successes;
if (strcmp(event, "failure") == 0) ++flood_failures;
}
void onSendFail(mesh::Packet* packet) override {
++send_failures;
RetryCodingRateMesh::onSendFail(packet);
if (enqueue_on_send_fail) {
enqueue_on_send_fail = false;
auto* replacement = obtainNewPacket();
if (replacement) {
*replacement = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
sendPacket(replacement, 0);
}
}
}
bool allowPacketTransmit(const mesh::Packet* packet) const override {
return !suppress_tx && RetryCodingRateMesh::allowPacketTransmit(packet);
@@ -268,6 +292,157 @@ TEST_F(DualProfileTest, RadioFaultRetryRetainsBoundedBusyEscapeAndSingleRetryLim
EXPECT_TRUE(radio.transmissions.empty());
}
TEST_F(DualProfileTest, StuckRadioRetryPreparationReleasesPacketAndRecoversQueue) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_TRUE(node.hasOutbound());
ASSERT_NE(nullptr, queue()); // later work must not inherit the stuck packet
radio.hold_prepare_busy = true;
radio.receive_mode = false;
radio.clear_prepare_on_recovery = true;
tick(500); // arm only after the first BUSY preparation
tick(8000);
EXPECT_TRUE(node.hasOutbound());
EXPECT_EQ(radio.recoveries, 0u);
tick(20);
EXPECT_FALSE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 1u);
EXPECT_EQ(node.tx_failure_logs, 1u);
EXPECT_EQ(radio.recoveries, 1u);
EXPECT_EQ(manager.getOutboundTotal(), 1);
EXPECT_EQ(manager.getFreeCount(), 39);
tick(); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
EXPECT_EQ(manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, FailedBusyRecoveryDoesNotKeepRetryOwnershipOrDisableWatchdogs) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_TRUE(node.hasOutbound());
radio.hold_prepare_busy = true;
radio.receive_mode = false;
radio.recovery_succeeds = false;
tick(500);
tick(9000);
EXPECT_FALSE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 1u);
EXPECT_EQ(manager.getFreeCount(), 40);
EXPECT_EQ(radio.recoveries, 1u);
tick(9000); // the ordinary non-RX watchdog is no longer suppressed
EXPECT_EQ(radio.recoveries, 2u);
ASSERT_NE(nullptr, queue());
radio.recovery_succeeds = radio.clear_prepare_on_recovery = true;
tick(9000);
EXPECT_EQ(radio.recoveries, 3u);
ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
EXPECT_EQ(manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, BusyTimeoutPreservesPacketsEnqueuedByFailureCallback) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_TRUE(node.hasOutbound());
node.enqueue_on_send_fail = true;
radio.hold_prepare_busy = true;
radio.clear_prepare_on_recovery = true;
tick(500);
tick(9000);
EXPECT_FALSE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 1u);
EXPECT_EQ(manager.getOutboundTotal(), 1);
EXPECT_EQ(manager.getFreeCount(), 39);
tick(); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
EXPECT_EQ(node.send_completions, 1u);
EXPECT_EQ(manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, InvalidPrepareAirtimeDoesNotCreateUnboundedDeadline) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick(); ASSERT_TRUE(node.hasOutbound());
radio.estimated_airtime = UINT32_MAX;
radio.secondary_airtime = 20000;
radio.hold_prepare_busy = true;
tick(500);
tick(30000); // invalid primary must not erase the secondary's valid RX grace
EXPECT_TRUE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 0u);
tick(8001);
EXPECT_FALSE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 1u);
EXPECT_EQ(radio.recoveries, 1u);
EXPECT_EQ(manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, FinitePrepareBusyAndCadBackoffHaveSeparateAllowances) {
node.flood_attempts = 0;
radio.config.cross = mesh::RadioCrossMode::Off;
radio.fail_next_send = true;
ASSERT_NE(nullptr, queue());
tick();
radio.hold_prepare_busy = true;
tick(500);
tick(5000);
radio.hold_prepare_busy = false;
radio.busy_profile = 0;
tick(1000); // successful preparation clears the first BUSY allowance
radio.hold_prepare_busy = true;
tick(500);
tick(7000); // exceeds the original deadline, but not the new allowance
EXPECT_TRUE(node.hasOutbound());
EXPECT_EQ(node.send_failures, 0u);
EXPECT_EQ(radio.recoveries, 0u);
radio.hold_prepare_busy = false;
radio.busy_profile = -1;
tick(500); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
EXPECT_EQ(manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, PrepareBusyAllowanceCoversLongFramesAndMillisWrap) {
TraceTestClock wrap_clock;
wrap_clock.now = UINT32_MAX - 1000UL;
DualProfileTestRadio wrap_radio;
StaticPoolPacketManager wrap_manager(40);
DualProfileTestMesh wrap_node(wrap_radio, wrap_clock, rng, rtc, wrap_manager, tables);
wrap_node.begin();
wrap_node.flood_attempts = 0;
wrap_radio.config.cross = mesh::RadioCrossMode::Off;
wrap_radio.estimated_airtime = 20000;
wrap_radio.fail_next_send = true;
auto* packet = wrap_node.obtainNewPacket();
ASSERT_NE(packet, nullptr);
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
ASSERT_TRUE(wrap_node.sendFlood(packet));
auto advance = [&](uint32_t ms) {
wrap_clock.now = uint32_t(wrap_clock.now + ms);
wrap_node.loop();
};
advance(1); ASSERT_TRUE(wrap_node.hasOutbound());
wrap_radio.hold_prepare_busy = true;
advance(500);
advance(30000); // a slow full frame must not hit an eight-second fixed timeout
EXPECT_TRUE(wrap_node.hasOutbound());
EXPECT_EQ(wrap_radio.recoveries, 0u);
EXPECT_EQ(wrap_node.send_failures, 0u);
advance(8001); // 1.5 * 20 seconds + eight seconds, crossing millis wrap
EXPECT_FALSE(wrap_node.hasOutbound());
EXPECT_EQ(wrap_radio.recoveries, 1u);
EXPECT_EQ(wrap_node.send_failures, 1u);
EXPECT_EQ(wrap_manager.getFreeCount(), 40);
}
TEST_F(DualProfileTest, EchoDuringRadioFaultBackoffCancelsDirectAndFloodRetry) {
radio.config.cross = mesh::RadioCrossMode::Off;
radio.receive_score = 1; // strong echo is processed without a score delay
@@ -999,6 +1174,86 @@ TEST_F(DualProfileTest, ExpiredSecondaryPacketsAreDiscarded) {
EXPECT_EQ(40, manager.getFreeCount());
}
TEST_F(DualProfileTest, FinalFloodEchoWaitOnlyExpiresWhenItsOwnProfileChanges) {
node.flood_attempts = 1;
for (uint8_t origin : {0, 1}) for (unsigned change = 0; change < 3; ++change) {
SCOPED_TRACE(testing::Message() << "origin=" << unsigned(origin) << " change=" << change);
radio.config.cross = mesh::RadioCrossMode::Off;
tick();
auto* packet = node.obtainNewPacket(); ASSERT_NE(nullptr, packet);
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
packet->radio_profile = origin;
packet->tx_radio = origin ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_PRIMARY;
ASSERT_TRUE(node.sendFlood(packet));
tick(); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
ASSERT_EQ(manager.getOutboundTotal(), 1);
mesh::Packet echo = *manager.getOutboundByIdx(0);
echo.radio_bound = false; echo.radio_local = false;
echo.setPathHashCount(echo.getPathHashCount() + 1);
tick(10000); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
ASSERT_EQ(manager.getOutboundTotal(), 0);
ASSERT_EQ(manager.getFreeCount(), 40); // final echo waits own metadata only
const auto successes = node.flood_successes;
if (change == 1) {
radio.config.cross = mesh::RadioCrossMode::On;
} else if ((change == 2 ? origin : origin ^ 1) == 0) {
auto primary = radio.config.primary; primary.freq += 1;
radio.config.setPrimary(primary, false);
} else {
auto secondary = radio.config.secondary; secondary.params.freq += 1;
radio.config.setSecondary(secondary, false);
}
tick();
node.receivePacket(&echo);
EXPECT_EQ(node.flood_successes, successes + (change == 2 ? 0 : 1));
EXPECT_EQ(node.flood_failures, 0u); // profile changes are not link failures
EXPECT_EQ(manager.getFreeCount(), 40);
}
}
TEST_F(DualProfileTest, ExpiredFinalFloodEchoCannotReserveKeyOnReplacementChannel) {
node.flood_attempts = 1;
radio.config.cross = mesh::RadioCrossMode::Off;
for (uint8_t origin : {0, 1}) {
SCOPED_TRACE(testing::Message() << "origin=" << unsigned(origin));
auto send = [&] {
auto* packet = node.obtainNewPacket();
EXPECT_NE(nullptr, packet);
if (!packet) return;
*packet = makeFloodPacket(PAYLOAD_TYPE_GRP_TXT);
packet->radio_profile = origin;
packet->tx_radio = origin ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_PRIMARY;
EXPECT_TRUE(node.sendFlood(packet));
};
send();
tick(); radio.complete = true; tick();
tick(10000); radio.complete = true; tick();
ASSERT_EQ(manager.getOutboundTotal(), 0);
ASSERT_EQ(manager.getFreeCount(), 40);
if (origin) {
const auto previous = radio.config.secondary;
radio.config.setSecondary({}, false);
tick();
radio.config.setSecondary(previous, false);
} else {
auto primary = radio.config.primary; primary.freq += 1;
radio.config.setPrimary(primary, false);
}
tick();
send(); // exact same logical packet, sent in a new radio session
tick(); ASSERT_TRUE(radio.sending);
radio.complete = true; tick();
EXPECT_EQ(manager.getOutboundTotal(), 1); // fresh channel owns its own retry
node.cancelAllFloodRetries();
tick(10000);
EXPECT_EQ(node.flood_failures, 0u);
EXPECT_EQ(manager.getFreeCount(), 40);
}
}
TEST_F(DualProfileTest, EchoOnOneProfileDoesNotCancelOtherProfileRetry) {
ASSERT_NE(nullptr, queue());
tick(); radio.complete = true; tick();
@@ -0,0 +1,365 @@
"""Execute Companion radio commands with production commit/recovery methods."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / 'examples/companion_radio/MyMesh.cpp'
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <initializer_list>
#include <vector>
#include <helpers/CLICommandUtils.h>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define MAX_LORA_TX_POWER 22
#define COMPANION_FEATURE_TEMP_RADIO 0
#define COMMAND_RADIO_APPLY_TIMEOUT_MS 3000
@CODES@
namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; }
using Apply = mesh::RadioParamApplyResult;
static uint32_t now_ms = 100;
struct Clock { uint32_t getMillis() const { return now_ms; } } clock_source;
struct BaseSerialInterface {
BaseSerialInterface* current = this;
BaseSerialInterface* disconnected = nullptr;
bool busy = false;
BaseSerialInterface* captureReplyRoute() { return current; }
bool isReplyRouteAvailable(BaseSerialInterface* route) { return route && route != disconnected; }
bool isReplyRouteWriteBusy(BaseSerialInterface*) { return busy; }
};
struct Prefs {
float freq=915, bw=250;
uint8_t sf=7, cr=5, client_repeat=0, rx_ps_level=1, rx_ps_preamble=16;
uint32_t rx_ps_rx_us=700, rx_ps_sleep_us=25000;
int8_t tx_power_dbm=3;
bool rx_boosted_gain=false;
};
void recalcRxPowerSavingFromLevel(uint8_t,uint8_t sf,float bw,uint8_t,
uint32_t* rx,uint32_t* sleep) {
*rx=sf*100; *sleep=uint32_t(bw*100);
}
struct Driver {
int8_t power=3;
unsigned calls=0;
std::deque<bool> accepted;
bool setTxPower(int8_t requested) {
++calls;
bool ok=true;
if (!accepted.empty()) { ok=accepted.front(); accepted.pop_front(); }
if (ok) power=requested;
return ok;
}
void setRxBoostedGainMode(bool) {}
} radio_driver;
@RETRY_DELAY@
struct MyMesh {
Prefs _prefs, durable, live;
BaseSerialInterface serial, original, other;
BaseSerialInterface* _serial=&serial;
Clock* _ms=&clock_source;
bool storage_accepts=true, _radio_available=true, outbound=false;
unsigned saves=0, applies=0;
std::deque<Apply> accepted;
bool saved_radio_apply_pending=false, command_radio_apply_pending=false;
uint32_t radio_apply_retry_at=0, command_radio_apply_deadline=0;
uint8_t radio_apply_failures=0;
float command_radio_freq=0, command_radio_bw=0;
uint8_t command_radio_sf=0, command_radio_cr=0, command_radio_repeat=0;
BaseSerialInterface* command_radio_reply_route=nullptr;
uint8_t cmd_frame[180]={};
struct Reply { uint8_t error; BaseSerialInterface* route; };
std::vector<Reply> replies;
@RESULT_ENUM@;
MyMesh() { serial.current=&original; radio_driver=Driver(); now_ms=100; }
uint32_t futureMillis(uint32_t delay) const { return now_ms+delay; }
bool millisHasNowPassed(uint32_t deadline) const { return int32_t(now_ms-deadline)>=0; }
bool hasOutbound() const { return outbound; }
bool isValidClientRepeatFreq(uint32_t) const { return true; }
bool savePrefs() { ++saves; if(!storage_accepts)return false; durable=_prefs; return true; }
void writeOKFrame(BaseSerialInterface* route=nullptr) { replies.push_back({0,route?route:serial.current}); }
void writeErrFrame(uint8_t code,BaseSerialInterface* route=nullptr) { replies.push_back({code,route?route:serial.current}); }
Apply tryApplyRadioParams(float freq,float bw,uint8_t sf,uint8_t cr) {
++applies;
Apply result=Apply::APPLIED;
if(!accepted.empty()) { result=accepted.front(); accepted.pop_front(); }
if(result==Apply::APPLIED) { live.freq=freq;live.bw=bw;live.sf=sf;live.cr=cr; }
return result;
}
bool applySavedRadioParams();
RadioSettingResult applyAndSaveTxPower(int8_t);
void finishRadioParamApply(float,float,uint8_t,uint8_t,uint8_t,BaseSerialInterface* = nullptr);
void servicePendingRadioParamApply();
void cancelPendingRadioParamApply();
void frame(size_t len) { @FRAME_BRANCHES@ }
void recover() { @RECOVERY@ }
bool command(const char* command,char* reply) {
const size_t reply_capacity=160;
@CLI_BRANCH@
return false;
}
void powerFrame(uint8_t power,size_t size=2) {
cmd_frame[0]=CMD_SET_RADIO_TX_POWER;cmd_frame[1]=power;frame(size);
}
void tupleFrame() {
cmd_frame[0]=CMD_SET_RADIO_PARAMS;
uint32_t freq=920000,bw=125000;
memcpy(cmd_frame+1,&freq,4);memcpy(cmd_frame+5,&bw,4);
cmd_frame[9]=9;cmd_frame[10]=6;cmd_frame[11]=1;
frame(12);
}
void expectError(uint8_t code,BaseSerialInterface* route=nullptr) {
assert(replies.size()==1 && replies[0].error==code);
assert(replies[0].route==(route?route:&original));
}
void expectOriginalPrefs() {
assert(_prefs.freq==915 && _prefs.bw==250 && _prefs.sf==7 && _prefs.cr==5);
assert(_prefs.client_repeat==0 && _prefs.rx_ps_rx_us==700 && _prefs.rx_ps_sleep_us==25000);
assert(durable.freq==915 && durable.bw==250 && durable.sf==7 && durable.cr==5);
}
};
@METHODS@
int main() {
// Reject unavailable/busy hardware before either persisting or reporting it.
{
MyMesh m;radio_driver.accepted={false};m.powerFrame(17);
m.expectError(ERR_CODE_BAD_STATE);
assert(m.saves==0 && m._prefs.tx_power_dbm==3 && radio_driver.power==3);
}
for (int value : {-10,23}) {
MyMesh m;m.powerFrame(uint8_t(value));m.expectError(ERR_CODE_ILLEGAL_ARG);
assert(m.saves==0 && radio_driver.calls==0);
}
{
MyMesh m;m.powerFrame(17,1);m.expectError(ERR_CODE_ILLEGAL_ARG);
assert(m.saves==0 && radio_driver.calls==0);
}
{
MyMesh m;m.powerFrame(17);m.expectError(0);
assert(m.saves==1 && m.durable.tx_power_dbm==17 && radio_driver.power==17);
}
{
MyMesh m;m._radio_available=false;m.powerFrame(17);m.expectError(0);
assert(m.durable.tx_power_dbm==17 && radio_driver.calls==0);
}
// A failed commit restores RAM and the physical output-power setting.
{
MyMesh m;m.storage_accepts=false;m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3);
assert(radio_driver.calls==2 && !m.saved_radio_apply_pending);
}
// RX may become busy during flash I/O. Retain recovery through a rejected
// rollback and through a later successful tuple restore but failed power apply.
{
MyMesh m;m.storage_accepts=false;radio_driver.accepted={true,false,false,true};
m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR);
assert(m._prefs.tx_power_dbm==3 && radio_driver.power==17 && m.saved_radio_apply_pending);
m.recover();assert(m.saved_radio_apply_pending && radio_driver.power==17);
assert(m.radio_apply_failures==1 && m.radio_apply_retry_at>now_ms);
unsigned calls=radio_driver.calls;m.recover();assert(radio_driver.calls==calls);
now_ms=m.radio_apply_retry_at;m.recover();
assert(!m.saved_radio_apply_pending && radio_driver.power==3 && m.saves==1);
assert(m.replies.size()==1); // no later success for the failed command
}
// ASCII and framed setters share identical hardware/durability semantics.
{
MyMesh m;char reply[160]={};radio_driver.accepted={false};
assert(m.command("set tx 17",reply) && strstr(reply,"Error"));
assert(m.saves==0 && m._prefs.tx_power_dbm==3);
m.storage_accepts=false;
assert(m.command("set tx 17",reply) && strstr(reply,"saved"));
assert(strstr(reply,"Error") && radio_driver.power==3);
assert(m.command("set tx 17junk",reply) && strstr(reply,"Error"));
assert(m.saves==1);
}
// Both immediate and deferred tuple changes must commit before OK.
{
MyMesh m;m.tupleFrame();m.expectError(0);
assert(m._prefs.freq==920 && m.durable.freq==920 && m.live.freq==920);
assert(m.durable.rx_ps_rx_us==900 && m.durable.rx_ps_sleep_us==12500);
assert(!m.saved_radio_apply_pending);
}
{
MyMesh m;m.storage_accepts=false;m.tupleFrame();m.expectError(ERR_CODE_FILE_IO_ERROR);
m.expectOriginalPrefs();assert(m.live.freq==915 && !m.saved_radio_apply_pending);
}
{
MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame();
assert(m.replies.empty() && m.command_radio_apply_pending && m.saves==0);
m.serial.current=&m.other;m.servicePendingRadioParamApply();m.expectError(0);
assert(!m.command_radio_apply_pending && m.durable.freq==920);
}
{
MyMesh m;m.storage_accepts=false;m.accepted={Apply::BUSY,Apply::APPLIED,Apply::BUSY};
m.tupleFrame();assert(m.replies.empty() && m.command_radio_apply_pending);
m.serial.current=&m.other;m.servicePendingRadioParamApply();
m.expectError(ERR_CODE_FILE_IO_ERROR);m.expectOriginalPrefs();
assert(m.saved_radio_apply_pending && m.live.freq==920 && !m.command_radio_apply_pending);
m.recover();assert(!m.saved_radio_apply_pending && m.live.freq==915);
assert(m.saves==1 && m.replies.size()==1);
}
// Disconnect cancels a deferred command without persisting its candidate.
{
MyMesh m;m.accepted={Apply::BUSY};m.tupleFrame();
m.serial.disconnected=&m.original;m.servicePendingRadioParamApply();
assert(!m.command_radio_apply_pending && m.replies.empty() && m.saves==0);
m.expectOriginalPrefs();
}
// Tuple completion must not swallow an outstanding power rollback.
{
MyMesh m;m.saved_radio_apply_pending=true;radio_driver.power=17;
m.tupleFrame();m.expectError(0);assert(m.saved_radio_apply_pending);
m.recover();assert(!m.saved_radio_apply_pending && radio_driver.power==3);
assert(m.live.freq==920 && m.durable.freq==920);
}
puts("Companion radio-setting commit and recovery regressions passed");
}
'''
SLEEP_HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstddef>
static uint32_t now_ms=100;
struct Serial {
bool hasPendingIO() const { return false; }
bool isConnected() const { return false; }
};
struct {
bool isWatchdogObserving() const { return false; }
bool isCalibratingNoiseFloor() const { return false; }
} radio_driver;
struct MyMesh {
bool _radio_available=true, _iter_started=false, command_radio_apply_pending=false;
bool binary_trace_pending=false, saved_radio_apply_pending=false;
uint32_t radio_apply_retry_at=0, _scheduled_reboot_at=0;
uint32_t emergency_client_repeat_send_at=0, dirty_contacts_expiry=0;
uint8_t dirty_contacts_failures=0;
const void *pending_serial_reply_route=nullptr, *sign_data=nullptr;
const void *emergency_client_repeat_packet=nullptr;
Serial* _serial=nullptr;
#if COMPANION_FEATURE_TEMP_RADIO
bool _temp_radio_applied=false;
uint32_t _temp_radio_set_at=0, _temp_radio_revert_at=0, _temp_radio_retry_at=0;
#endif
bool millisHasNowPassed(uint32_t at) const { return int32_t(now_ms-at)>=0; }
bool isDualRadioActive() const { return false; }
bool isContactWriteDue() const { return false; }
bool hasPendingOtaApply() const { return false; }
bool hasQueuedWorkDue() const { return false; }
bool hasRetryWorkDue() const { return false; }
bool hasPendingWork() const;
};
@PENDING_WORK@
int main() {
MyMesh m;
assert(!m.hasPendingWork());
m.saved_radio_apply_pending=true;
assert(m.hasPendingWork());
m.radio_apply_retry_at=now_ms+10;
assert(!m.hasPendingWork());
now_ms+=10;
assert(m.hasPendingWork());
m.radio_apply_retry_at=0;
#if COMPANION_FEATURE_TEMP_RADIO
// An already active lease can last hours; a rejected power-save rollback
// must not keep spinning solely because saved radio restoration is pending.
m._temp_radio_applied=true;
m._temp_radio_revert_at=now_ms+3600000;
assert(!m.hasPendingWork());
// A future scheduled TempRadio entry also blocks normal-radio restoration.
m._temp_radio_applied=false;m._temp_radio_revert_at=0;
m._temp_radio_set_at=now_ms+20;
assert(!m.hasPendingWork());
now_ms=m._temp_radio_set_at;
assert(m.hasPendingWork()); // the entry timer, not the blocked restore
m._temp_radio_set_at=0;m._temp_radio_applied=true;
m._temp_radio_revert_at=now_ms+20;
assert(!m.hasPendingWork());
now_ms=m._temp_radio_revert_at;
assert(m.hasPendingWork()); // lease expiry still gets serviced
m._temp_radio_applied=false;m._temp_radio_revert_at=0;
assert(m.hasPendingWork()); // rollback is runnable after the lease clears
#endif
m.saved_radio_apply_pending=false;
assert(!m.hasPendingWork());
}
'''
class CompanionRadioSettingsTransactionTests(unittest.TestCase):
def test_lease_blocked_recovery_does_not_prevent_sleep(self):
text = SOURCE.read_text(encoding='utf-8')
pending = extract_braced(text, 'bool MyMesh::hasPendingWork() const')
harness = SLEEP_HARNESS.replace('@PENDING_WORK@', pending)
with tempfile.TemporaryDirectory(prefix='mesh-companion-radio-sleep-') as tmp:
cpp = Path(tmp) / 'test.cpp'
binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test')
cpp.write_text(harness, encoding='utf-8')
for macros in (['-DCOMPANION_FEATURE_TEMP_RADIO=0'],
['-DCOMPANION_FEATURE_TEMP_RADIO=1'],
['-DCOMPANION_FEATURE_TEMP_RADIO=1', '-DNRF52_PLATFORM=1']):
with self.subTest(macros=macros):
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
'-Wall', '-Wextra', '-Werror', *macros,
str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
def test_production_commands_commit_and_restore(self):
text = SOURCE.read_text(encoding='utf-8')
header = (SOURCE.parent / 'MyMesh.h').read_text(encoding='utf-8')
codes = '\n'.join(re.findall(
r'^#define (?:CMD_SET_RADIO_PARAMS|CMD_SET_RADIO_TX_POWER|ERR_CODE_\w+)\s+\d+',
text, flags=re.MULTILINE))
methods = '\n'.join(extract_braced(text, signature) for signature in (
'bool MyMesh::applySavedRadioParams(',
'MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(',
'void MyMesh::finishRadioParamApply(',
'void MyMesh::cancelPendingRadioParamApply(',
'void MyMesh::servicePendingRadioParamApply(',
))
frame_branches = ' else '.join(extract_braced(text, f'if (cmd_frame[0] == {command})')
for command in ('CMD_SET_RADIO_PARAMS', 'CMD_SET_RADIO_TX_POWER'))
replacements = {
'@CODES@': codes,
'@RESULT_ENUM@': extract_braced(header, 'enum class RadioSettingResult'),
'@RETRY_DELAY@': extract_braced(text, 'static uint32_t nextRadioApplyRetryDelay('),
'@METHODS@': methods,
'@FRAME_BRANCHES@': frame_branches,
'@CLI_BRANCH@': extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'),
'@RECOVERY@': extract_braced(text, 'if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()'),
}
harness = HARNESS
for marker, value in replacements.items():
harness = harness.replace(marker, value)
with tempfile.TemporaryDirectory(prefix='mesh-companion-radio-settings-') as tmp:
cpp = Path(tmp) / 'test.cpp'
binary = Path(tmp) / ('test.exe' if os.name == 'nt' else 'test')
cpp.write_text(harness, encoding='utf-8')
subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-Wno-sign-compare', # existing frame parser compares size_t and byte offsets
'-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
def test_all_tx_power_frontends_use_checked_handler(self):
text = SOURCE.read_text(encoding='utf-8')
terminal = extract_braced(text, 'if (strncmp(config, "tx ", 3) == 0)')
self.assertIn('handleCommand(command, 0, local_reply)', terminal)
web = extract_braced(text, 'if (strcmp(key, "tx") == 0)')
self.assertIn('applyAndSaveTxPower(', web)
self.assertIn('RadioSettingResult::SaveFailed',
extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'))
if __name__ == '__main__':
unittest.main()
+8 -3
View File
@@ -94,14 +94,19 @@ assert re.search(
# Terminal-specific handlers keep precedence, then both the generic `set`
# fallthrough and the final unknown-command fallthrough delegate to the shared
# framed/rescue command surface. The explicit frequency branch also delegates
# to the strict shared frequency parser; it is not a third fallthrough.
# framed/rescue command surface. Frequency and power also delegate explicitly
# to the strict, failure-aware shared handlers; they are not extra fallthroughs.
freq_branch = re.search(
r'else if \(strncmp\(config, "freq ", 5\) == 0\) \{([^{}]*)\}', terminal
)
assert freq_branch is not None
assert "handleCommand(command, 0, local_reply)" in freq_branch.group(1)
assert terminal.replace(freq_branch.group(0), "").count(
tx_branch = re.search(
r'else if \(strncmp\(config, "tx ", 3\) == 0\) \{([^{}]*)\}', terminal
)
assert tx_branch is not None
assert "handleCommand(command, 0, local_reply)" in tx_branch.group(1)
assert terminal.replace(freq_branch.group(0), "").replace(tx_branch.group(0), "").count(
"handleCommand(command, 0, local_reply)"
) == 2
assert terminal.index('strncmp(config, "tx ", 3)') < terminal.index(
@@ -120,6 +120,46 @@ TEST_F(FloodAdvertLimit, AggregatePrefixExcessDoesNotBlameAnInnocentFullKey) {
}
}
TEST_F(FloodAdvertLimit, LateShorterDuplicateKeepsPenaltyUntilSevenDayRecovery) {
for (uint32_t start : {0U, UINT32_MAX - W / 2}) {
limiter.reset();
bad(start); // consecutive over-limit windows at eight hops
uint8_t hash[MAX_HASH_SIZE] = {1};
limiter.observe(key.data(), hash, 1, start + W + 1, true);
Limiter::LimitedEntry row;
ASSERT_EQ(1U, limiter.listLimited(start + W + 1, 0, &row, 1));
EXPECT_EQ(1, row.hops);
EXPECT_EQ(10, row.quota);
EXPECT_TRUE(row.reasons & Limiter::BadListRule);
EXPECT_EQ(D7 - 1, row.recovery_ms);
// Raising the allowance does not forgive the escalation, including when
// the now-compliant window ends. New adverts still obey the 12-hour rule.
EXPECT_EQ(Decision::BadList, receive(4, 1, start + W + 2));
EXPECT_TRUE(limiter.isBad(key.data(), start + 2 * W));
EXPECT_TRUE(limiter.isBad(key.data(), start + W + D7 - 1));
EXPECT_FALSE(limiter.isBad(key.data(), start + W + D7));
}
}
TEST_F(FloodAdvertLimit, ShorterNewAndMatchingPrefixAdvertsCannotLiftPenalty) {
bad();
auto collision = key;
collision[31] = 1;
EXPECT_EQ(Decision::Allow, receive(1, 1, W + 1, collision.data()));
EXPECT_FALSE(limiter.isBad(collision.data(), W + 1));
EXPECT_TRUE(limiter.isBad(key.data(), W + 1));
EXPECT_EQ(Decision::BadList, receive(4, 0, W + 2));
Limiter::LimitedEntry row;
ASSERT_EQ(1U, limiter.listLimited(W + 2, 0, &row, 1));
EXPECT_EQ(0, row.hops);
EXPECT_EQ(10, row.quota);
EXPECT_TRUE(row.reasons & Limiter::BadListRule);
EXPECT_EQ(0, memcmp(row.key, key.data(), PUB_KEY_SIZE));
EXPECT_EQ(Decision::BadList, receive(5, 0, W + H12 - 1));
EXPECT_EQ(Decision::Allow, receive(6, 0, W + H12));
EXPECT_TRUE(limiter.isBad(key.data(), W + H12));
}
TEST_F(FloodAdvertLimit, ShortestPrefixPathAlsoDefinesTheNormalRecoveryLevel) {
auto collision = key;
collision[31] = 1;
+6
View File
@@ -102,7 +102,12 @@ struct MyMesh {
int value=0; int* _ms=&value; int* _radio=&value; int* _mgr=&value;
uint16_t _err_flags=0;
bool _radio_available=false, save_ok=true;
bool saved_radio_apply_pending=false;
unsigned radio_apply_retry_at=0;
uint8_t radio_apply_failures=0;
int resets=0, saves=0;
enum class RadioSettingResult { Saved, RadioRejected, SaveFailed };
RadioSettingResult applyAndSaveTxPower(int8_t);
bool handleCommand(const char*,uint32_t,char*);
bool handleDirectCommand(const char*,char*,size_t);
void onCLICommandRecv(const ContactInfo&,mesh::Packet*,uint32_t,const char*,char*);
@@ -303,6 +308,7 @@ int main() {
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
implementation = "\n".join(extract_braced(source, signature) for signature in (
"static bool isCompanionRadioPrefsCommand(",
"MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(",
"bool MyMesh::handleDirectCommand(", "bool MyMesh::handleCommand(",
"void MyMesh::onCLICommandRecv("))
for export, webconfig in ((0, False), (1, False), (0, True), (1, True)):
+11 -7
View File
@@ -4043,7 +4043,7 @@ TEST(OtaCatalog, IncompleteCatalogRetriesOnlyMissingFragment) {
client.set_archive_interest(true);
uint8_t sid[4] = {0x44, 0x33, 0x22, 0x11};
uint8_t digest[4] = {0xDE, 0xAD, 0xBE, 0xEF};
AdvMsg adv{}; memcpy(adv.seeder_id, sid, 4); memcpy(adv.set_digest, digest, 4); adv.n_motas = 3;
AdvMsg adv{}; memcpy(adv.seeder_id, sid, 4); memcpy(adv.set_digest, digest, 4); adv.n_motas = 23;
uint8_t wire[MAX_PACKET_PAYLOAD];
uint16_t n = encode_adv(wire, sizeof(wire), adv);
client.set_clock(100);
@@ -4057,13 +4057,17 @@ TEST(OtaCatalog, IncompleteCatalogRetriesOnlyMissingFragment) {
sent.items.clear();
auto deliver_have = [&](uint8_t frag) {
uint8_t row[OTA_HAVE_ROW_BYTES] = {0};
row[0] = (uint8_t)(frag + 1); row[1] = 0x77;
wr_u32le(row + 4, SIM_TARGET_ID); wr_u32le(row + 8, 0x01000000u + frag);
row[12] = CODEC_FULL; row[13] = MFLAG_FULL; row[14] = 1;
const uint8_t count = frag == 2 ? 3 : 10;
uint8_t rows[10 * OTA_HAVE_ROW_BYTES] = {0};
for (uint8_t index = 0; index < count; ++index) {
uint8_t* row = rows + index * OTA_HAVE_ROW_BYTES;
row[0] = (uint8_t)(frag * 10 + index + 1); row[1] = 0x77;
wr_u32le(row + 4, SIM_TARGET_ID); wr_u32le(row + 8, 0x01000000u + frag);
row[12] = CODEC_FULL; row[13] = MFLAG_FULL; row[14] = 1;
}
HaveMsg have{};
memcpy(have.seeder_id, sid, 4); memcpy(have.set_digest, digest, 4);
have.frag_idx = frag; have.frag_total = 3; have.n_rows = 1; have.rows = row;
have.frag_idx = frag; have.frag_total = 3; have.n_rows = count; have.rows = rows;
uint16_t have_len = encode_have(wire, sizeof(wire), have);
ASSERT_GT(have_len, 0);
client.on_message(wire, have_len);
@@ -4080,7 +4084,7 @@ TEST(OtaCatalog, IncompleteCatalogRetriesOnlyMissingFragment) {
sent.items.clear();
deliver_have(1);
EXPECT_EQ(client.catalogCount(), 3);
EXPECT_EQ(client.catalogCount(), 23);
client.set_clock(5000 + OTA_CATALOG_RETRY_MS * 2 + 2);
client.loop();
EXPECT_TRUE(sent.items.empty()); // complete means no further catalog retries
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Exercise OTA catalog lifetime, rollover, and filtered-page recovery."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class OtaCatalogLifetimeTest(unittest.TestCase):
def test_source_and_catalog_ownership_survive_clock_rollover(self):
with tempfile.TemporaryDirectory(prefix="ota-catalog-") as directory:
path = Path(directory)
flags = [] if os.name == "nt" else ["-fsanitize=address,undefined"]
tinf = path / "tinf.o"
subprocess.run([shutil.which("cc") or "gcc", "-DENABLE_OTA=1", *flags,
"-c", str(ROOT / "src/helpers/ota/OtaTinf.c"),
"-o", str(tinf)], check=True)
sources = ["OtaManager.cpp", "OtaProtocol.cpp", "MotaContainer.cpp",
"MerkleTree.cpp", "OtaDeflate.cpp"]
binary = path / "catalog.exe"
result = subprocess.run([
"c++", "-std=c++17", *flags, "-DENABLE_OTA=1",
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
str(ROOT / "test/fixtures/ota_catalog_lifetime/test.cpp"),
*[str(ROOT / "src/helpers/ota" / name) for name in sources],
str(ROOT / "src/Utils.cpp"), str(tinf), "-o", str(binary),
], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
for rollover in (0, 1):
for scenario in range(9):
with self.subTest(scenario=scenario, rollover=rollover):
subprocess.run([str(binary), str(scenario), str(rollover)],
check=True, timeout=10)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -51,10 +51,10 @@ class OtaStoreFlashNrf52HybridTest(unittest.TestCase):
)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn(
"7 OtaStoreFlashNrf52 hybrid lifecycle checks passed",
"10 OtaStoreFlashNrf52 hybrid lifecycle checks passed",
checked.stdout,
)
self.assertEqual(checked.stdout.count("PASS:"), 7)
self.assertEqual(checked.stdout.count("PASS:"), 10)
if __name__ == "__main__":