From 92b9a1cc64c8b77257a555c274e9450b6ec2dd43 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 04:03:34 -0700 Subject: [PATCH] Separate tracker RF leases from timed maintenance wakes --- examples/simple_sensor/SensorMesh.cpp | 8 + examples/simple_sensor/Tracker.cpp | 5 +- src/Dispatcher.h | 21 ++ test/fixtures/sensor_tracker_client.h | 2 + test/fixtures/sensor_tracker_radio_lease.cpp | 310 +++++++++++++++++++ test/test_sensor_tracker_sleep.py | 88 ++++++ 6 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/sensor_tracker_radio_lease.cpp diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index b870658e0..1ef554e44 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1731,6 +1731,14 @@ uint32_t SensorMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { uint32_t wake_delay_secs = mesh::power::powerSaveSleepSeconds(wake_delay_ms); if (wake_delay_secs < sleep_secs) sleep_secs = wake_delay_secs; } +#if MESH_ENABLE_SENSOR_TRACKER && MESH_ENABLE_TX_BEACONS + // The normal queue query omits RF maintenance while radio_available is + // false. A tracker's deliberate RF sleep must not hide its next beacon. + if (tracker.enabled && getNextBeaconWakeDelay(wake_delay_ms)) { + uint32_t wake_delay_secs = mesh::power::powerSaveSleepSeconds(wake_delay_ms); + if (wake_delay_secs < sleep_secs) sleep_secs = wake_delay_secs; + } +#endif if (getNextRetryWakeDelay(wake_delay_ms)) { uint32_t wake_delay_secs = mesh::power::powerSaveSleepSeconds(wake_delay_ms); if (wake_delay_secs < sleep_secs) sleep_secs = wake_delay_secs; diff --git a/examples/simple_sensor/Tracker.cpp b/examples/simple_sensor/Tracker.cpp index 233c6e20a..433fec98c 100644 --- a/examples/simple_sensor/Tracker.cpp +++ b/examples/simple_sensor/Tracker.cpp @@ -86,7 +86,10 @@ bool SensorMesh::trackerNeedsRadio() const { || _cli.hasActiveUserGpioTimer() || hasOutbound() || _mgr->getOutboundTotal() != 0) return true; uint32_t delay; - if (getNextQueueWakeDelay(delay) || getNextRetryWakeDelay(delay)) return true; + if (hasPendingRadioWork() || getNextRetryWakeDelay(delay)) return true; +#if MESH_ENABLE_TX_BEACONS + if (getNextBeaconWakeDelay(delay) && delay == 0) return true; +#endif if (tracker_pending || tracker_acquiring || (tracker_awake_until && !millisHasNowPassed(tracker_awake_until))) return true; return !tracker_storage_fault && getRTCClock()->getCurrentTime() >= tracker_next_check; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 508206e2a..7ad322234 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -577,6 +577,27 @@ protected: // This includes queue schedules, delayed inbound processing, airtime-budget // waits, and short channel-busy deferrals. bool getNextQueueWakeDelay(uint32_t& delay_millis) const; + // Timed MCU wakes are not RF leases: an idle tracker can suspend the radio + // across AGC maintenance and a future beacon, but not actual packet work. + bool hasPendingRadioWork() const { + if (hasOutbound() || outbound_restore_cr || _mgr->getOutboundTotal() != 0 + || _radio->isCarrierWaveActive() || _radio->isMorseTxActive() + || _radio->isMorseRxActive()) return true; + uint32_t ignored; + return _mgr->getNextInboundTime(_ms->getMillis(), ignored) + || (radio_available && _radio->getReceiveRetryDelay(_ms->getMillis(), ignored)); + } +#if MESH_ENABLE_TX_BEACONS + // A deliberately suspended tracker must still wake before the beacon CLI + // consumes its one admission attempt. Other unavailable radios retain the + // existing getNextQueueWakeDelay() policy. + bool getNextBeaconWakeDelay(uint32_t& delay_millis) const { + if (!beacon_wake_enabled) return false; + const int32_t wait = int32_t(beacon_wake_at - uint32_t(_ms->getMillis())); + delay_millis = wait > 0 ? uint32_t(wait) : 0; + return true; + } +#endif bool hasQueuedWorkDue() const { uint32_t delay_millis; return getNextQueueWakeDelay(delay_millis) && delay_millis == 0; diff --git a/test/fixtures/sensor_tracker_client.h b/test/fixtures/sensor_tracker_client.h index bb66a11ac..df4080d27 100644 --- a/test/fixtures/sensor_tracker_client.h +++ b/test/fixtures/sensor_tracker_client.h @@ -199,6 +199,8 @@ public: bool hasPendingOtaApply() const { return pending_ota; } bool isDualRadioActive() const { return dual; } bool getNextQueueWakeDelay(uint32_t& value) const { value = 1; return queue_wake; } + bool hasPendingRadioWork() const { return queue_wake; } + bool getNextBeaconWakeDelay(uint32_t&) const { return false; } bool getNextRetryWakeDelay(uint32_t& value) const { value = 1; return retry_wake; } void setRadioAvailable(bool value) { available = value; } void updateAdvertTimer() { ++advert_timer_updates; } diff --git a/test/fixtures/sensor_tracker_radio_lease.cpp b/test/fixtures/sensor_tracker_radio_lease.cpp new file mode 100644 index 000000000..0d25a58fd --- /dev/null +++ b/test/fixtures/sensor_tracker_radio_lease.cpp @@ -0,0 +1,310 @@ +// Execute tracker RF ownership against the real Dispatcher and packet manager. +// Only the radio/clock and CLI beacon schedule are hardware-boundary adapters. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +constexpr uint32_t SENSOR_READ_INTERVAL_SECS = 60; +namespace mesh { +bool isUsbLoggingWatchdogArmed() { return false; } +class Mesh : public Dispatcher { +public: + Mesh(Radio& radio, MillisecondClock& clock, PacketManager& manager) + : Dispatcher(radio, clock, manager) {} + void loop() { Dispatcher::loop(); } +}; +} +struct Clock : mesh::MillisecondClock { + uint32_t now; + explicit Clock(uint32_t initial = 1000) : now(initial) {} + unsigned long getMillis() override { return now; } +}; +struct RTC { uint32_t now = 100; uint32_t getCurrentTime() const { return now; } }; +struct Board { + bool isUsbDataConnected() const { return false; } + bool isOTAUpdateRunning() const { return false; } + bool isRadioTestActive() const { return false; } +}; +struct Radio : mesh::Radio { + mesh::RadioProfiles profiles_state; + bool asleep = false, sending = false, complete = false, beacon_owned = false; + bool receive_retry = false, carrier = false, morse_tx = false, morse_rx = false; + unsigned starts = 0, wakes = 0, sleeps = 0, agc = 0, beacon_sends = 0; + mesh::RadioParamApplyResult restore_cr = mesh::RadioParamApplyResult::APPLIED; + Radio() { + auto& p = profiles_state.primary; + p.freq = 906.5; p.bw = 62.5; p.sf = 7; p.cr = 5; p.preamble = 32; + } + mesh::RadioProfiles* profiles() override { return &profiles_state; } + const mesh::RadioProfiles* profiles() const override { return &profiles_state; } + void begin() override { assert(!asleep); ++starts; } + int recvRaw(uint8_t*, int) override { return 0; } + float packetScore(float, int) override { return 1; } + uint32_t getEstAirtimeFor(int) override { return 100; } + bool startSendRaw(const uint8_t*, int) override { + assert(!asleep); sending = true; complete = false; + if (beacon_owned) ++beacon_sends; + return true; + } + bool isSendComplete() override { return complete; } + void onSendFinished() override { sending = false; } + bool isInRecvMode() const override { return !asleep && !sending; } + bool isReceiving() override { return false; } + bool isCarrierWaveActive() const override { return carrier; } + bool isMorseTxActive() const override { return morse_tx; } + bool isMorseRxActive() const override { return morse_rx; } + bool setTrackerSleep(bool sleeping) override { + assert(!sending && !beacon_owned); + asleep = sleeping; sleeping ? ++sleeps : ++wakes; return true; + } + bool recoverRadio(bool) override { return true; } + bool isWatchdogObserving() const { return false; } + bool getReceiveRetryDelay(uint32_t, uint32_t& delay) const override { + delay = 2000; return receive_retry; + } + bool setCodingRate(uint8_t) override { return true; } + mesh::RadioParamApplyResult tryRestoreCodingRate(uint8_t) override { return restore_cr; } + void resetAGC() override { assert(!asleep && !beacon_owned); ++agc; } + bool tryResetAGC() override { resetAGC(); return true; } + bool validateProfile(const mesh::RadioProfileParams& params) const override { + return mesh::RadioProfiles::valid(params); + } + bool supportsMeshtext() const override { return true; } +#if MESH_ENABLE_TX_BEACONS + uint32_t getBeaconAirtime(const mesh::RadioProfileParams&, int) override { return 100; } + mesh::RadioParamApplyResult prepareBeaconTransmit(const mesh::RadioProfileParams&, int8_t) override { + beacon_owned = true; return mesh::RadioParamApplyResult::APPLIED; + } + bool isBeaconRadioOwned() const override { return beacon_owned; } + bool isBeaconChannelBusy() override { return false; } + mesh::RadioParamApplyResult restoreBeaconReceive() override { + beacon_owned = false; return mesh::RadioParamApplyResult::APPLIED; + } +#endif +}; +class SensorMesh; +struct CLI { + SensorMesh* owner = nullptr; + bool scheduled = false; + unsigned attempts = 0; + bool hasActiveUserGpioTimer() const { return false; } + void loop(); +}; +class SensorMesh : public mesh::Mesh { +public: + Clock& clock; + Radio& radio_driver; + Board board; + RTC rtc; + CLI _cli{this}; + struct { bool enabled = true; } tracker; + struct { uint8_t powersaving_enabled = 1; } prefs; + bool tracker_pending = false, tracker_acquiring = false, tracker_storage_fault = false; + bool tracker_radio_asleep = false, saved_radio_apply_pending = false, temp_radio_applied = false; + unsigned long tracker_awake_until = 0, set_radio_at = 0, revert_radio_at = 0; + unsigned long next_flood_advert = 0, next_local_advert = 0, dirty_contacts_expiry = 0; + unsigned long radio_apply_retry_at = 0; + uint32_t tracker_next_check = 1900, last_read_time = 100; + struct Alert { unsigned long send_expiry = 0; } alert; + Alert* alert_tasks[1] = {&alert}; + int num_alert_tasks = 0, interval = 4000; + bool retry = false; + SensorMesh(Radio& radio, Clock& time, StaticPoolPacketManager& manager) + : mesh::Mesh(radio, time, manager), clock(time), radio_driver(radio) { begin(); } + RTC* getRTCClock() const { return const_cast(&rtc); } + bool millisHasNowPassed(unsigned long timestamp) const { + return int32_t(clock.now - uint32_t(timestamp)) >= 0; + } + bool isAnyTempRadioActive() const { return false; } + bool hasPendingOtaApply() const { return false; } + bool getNextRetryWakeDelay(uint32_t& delay) const { delay = 1500; return retry; } + bool hasRetryWorkDue() const { return false; } + int getAGCResetInterval() const override { return interval; } + bool useAdaptiveAGC() const override { return prefs.powersaving_enabled != 0; } + mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { return ACTION_RELEASE; } + using mesh::Dispatcher::getNextQueueWakeDelay; + using mesh::Dispatcher::hasPendingRadioWork; + using mesh::Dispatcher::setRadioAvailable; + using mesh::Dispatcher::isRadioAvailable; +#if MESH_ENABLE_TX_BEACONS + using mesh::Dispatcher::getNextBeaconWakeDelay; +#endif +#if MESH_ENABLE_FOREIGN_RADIO + using mesh::Dispatcher::queueForeignRadioFrame; +#endif + bool trackerNeedsRadio() const; + void wakeTrackerRadio(); + bool isMillisTimerDue(unsigned long) const; + uint32_t limitSleepToMillisTimer(unsigned long, uint32_t) const; + bool hasPendingWork() const; + uint32_t getPowerSaveSleepSeconds(uint32_t) const; + void loopBeforeService(); + void sleepIfIdle() { @SLEEP_BLOCK@ } +}; +@METHODS@ + +void CLI::loop() { +#if MESH_ENABLE_TX_BEACONS + uint32_t delay; + if (scheduled && owner->getNextBeaconWakeDelay(delay) && delay == 0) { + // BeaconManager consumes one admission attempt, even if the radio is + // unavailable. Exercise that same boundary with the real queue API. + ++attempts; + const uint8_t bytes[] = {0x15, 0, 0x23}; + const auto params = owner->radio_driver.profiles_state.primary; + assert(owner->queueBeaconRadioFrame(bytes, sizeof(bytes), params, 14) + && "due beacon must wake RF before the CLI admission attempt"); + owner->setBeaconWakeDelay(owner->clock.now, 86400000, true); + } +#endif +} +struct Fixture { + Clock clock; + Radio radio; + StaticPoolPacketManager manager{8}; + SensorMesh sensor{radio, clock, manager}; + explicit Fixture(uint32_t initial = 1000) : clock(initial) { g_mock_millis = clock.now; } + void advance(uint32_t elapsed) { clock.now += elapsed; g_mock_millis = clock.now; } +}; +static mesh::Packet* packet(Fixture& f) { + auto* p = f.sensor.obtainNewPacket(); assert(p); + p->header = ROUTE_TYPE_FLOOD | (PAYLOAD_TYPE_GRP_TXT << PH_TYPE_SHIFT); + p->tx_radio = mesh::RADIO_TX_PRIMARY; p->payload_len = 1; p->payload[0] = 0x12; + return p; +} +static void agc_deadlines() { + for (bool powersaving : {false, true}) { + Fixture f; f.sensor.prefs.powersaving_enabled = powersaving; + f.sensor.mesh::Mesh::loop(); + uint32_t delay = 0; + assert(f.sensor.getNextQueueWakeDelay(delay) == powersaving); + if (powersaving) assert(delay == 4000); + assert(!f.sensor.trackerNeedsRadio() && "a future AGC wake is not an RF lease"); + f.advance(4001); + assert(!f.sensor.trackerNeedsRadio() && "due AGC is not an RF lease either"); + f.sensor.mesh::Mesh::loop(); assert(f.radio.agc == 1); + f.sensor.sleepIfIdle(); + assert(f.sensor.tracker_radio_asleep && !f.sensor.isRadioAvailable()); + const auto starts = f.radio.starts; + for (unsigned pass = 0; pass < 3; ++pass) { + f.advance(60000); + assert(!f.sensor.trackerNeedsRadio()); + assert(!f.sensor.getNextQueueWakeDelay(delay)); + assert(f.sensor.getPowerSaveSleepSeconds(30) == 30); + f.sensor.loopBeforeService(); + assert(f.sensor.tracker_radio_asleep && f.radio.starts == starts); + } + f.sensor.tracker_next_check = f.sensor.rtc.now; + f.sensor.loopBeforeService(); + assert(!f.sensor.tracker_radio_asleep && f.sensor.isRadioAvailable()); + assert(f.radio.starts == starts + 1 && f.radio.agc == 1); + if (powersaving) assert(f.sensor.getNextQueueWakeDelay(delay) && delay == 4000); + } +} +static void radio_leases() { + { + Fixture f; auto* p = packet(f); + assert(f.sensor.sendPacket(p, 0, 5000)); + assert(f.sensor.trackerNeedsRadio()); + f.sensor.sleepIfIdle(); assert(!f.sensor.tracker_radio_asleep); + } + { + Fixture f; auto* p = packet(f); + f.manager.queueInbound(p, f.clock.now + 5000); + assert(!f.manager.getOutboundTotal() && f.sensor.trackerNeedsRadio()); + f.sensor.sleepIfIdle(); assert(!f.sensor.tracker_radio_asleep); + } + { + Fixture f; f.radio.receive_retry = true; + assert(f.sensor.trackerNeedsRadio()); + f.sensor.sleepIfIdle(); assert(!f.sensor.tracker_radio_asleep); + f.sensor.setRadioAvailable(false); f.sensor.tracker_radio_asleep = true; + assert(!f.sensor.trackerNeedsRadio()); // Stale maintenance cannot wake dormant RF. + } + { + Fixture f; f.sensor.retry = true; + assert(f.sensor.trackerNeedsRadio()); + f.sensor.sleepIfIdle(); assert(!f.sensor.tracker_radio_asleep); + } + for (unsigned owner = 0; owner < 3; ++owner) { + Fixture f; + f.radio.carrier = owner == 0; f.radio.morse_tx = owner == 1; f.radio.morse_rx = owner == 2; + assert(f.sensor.trackerNeedsRadio()); + f.sensor.sleepIfIdle(); assert(!f.sensor.tracker_radio_asleep); + } + { + Fixture f; auto* p = packet(f); p->tx_cr = 6; + assert(f.sensor.sendPacket(p, 0)); + f.advance(1); f.sensor.mesh::Mesh::loop(); + assert(f.radio.sending && f.sensor.trackerNeedsRadio()); + f.radio.complete = true; f.radio.restore_cr = mesh::RadioParamApplyResult::BUSY; + f.advance(100); f.sensor.mesh::Mesh::loop(); + assert(!f.sensor.hasOutbound() && !f.manager.getOutboundTotal()); + assert(f.sensor.trackerNeedsRadio() && "pending CR restoration retains the radio lease"); + f.sensor.sleepIfIdle(); assert(!f.sensor.tracker_radio_asleep); + f.radio.restore_cr = mesh::RadioParamApplyResult::APPLIED; + f.advance(251); f.sensor.mesh::Mesh::loop(); + assert(!f.sensor.trackerNeedsRadio()); + } +#if MESH_ENABLE_FOREIGN_RADIO + { + Fixture f; const uint8_t bytes[] = {1, 2, 3}; + auto& p = f.radio.profiles_state; + p.secondary.params = p.primary; p.secondary.params.protocol = mesh::RadioProfileProtocol::Meshtext; + p.secondary.mode = mesh::RadioProfileMode::RxTx; + assert(f.sensor.queueForeignRadioFrame(bytes, sizeof(bytes), 1, p.generation[1])); + assert(f.sensor.hasPendingRadioWork()); + } +#endif +} +static void beacon_deadlines() { +#if MESH_ENABLE_TX_BEACONS + for (bool wrap : {false, true}) { + Fixture f(wrap ? UINT32_MAX - 1500U : 1000U); + f.sensor._cli.scheduled = true; + f.sensor.setBeaconWakeDelay(f.clock.now, 2500, true); + assert(!f.sensor.trackerNeedsRadio()); + f.sensor.sleepIfIdle(); + assert(f.sensor.tracker_radio_asleep && !f.sensor.isRadioAvailable()); + uint32_t delay = 0; + assert(!f.sensor.getNextQueueWakeDelay(delay)); // Normal unavailable-radio policy is unchanged. + assert(f.sensor.getNextBeaconWakeDelay(delay) && delay == 2500); +#if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) || defined(STM32_PLATFORM) + constexpr unsigned sleep = 3; +#else + constexpr unsigned sleep = 2; +#endif + assert(f.sensor.getPowerSaveSleepSeconds(30) == sleep + && "RF suspension must not hide the next beacon MCU wake"); + f.advance(2499); f.sensor.loopBeforeService(); + assert(f.sensor.tracker_radio_asleep && f.sensor._cli.attempts == 0); + f.advance(1); + assert(f.sensor.getPowerSaveSleepSeconds(30) == 0); + f.sensor.loopBeforeService(); + assert(!f.sensor.tracker_radio_asleep && f.sensor.isRadioAvailable()); + assert(f.sensor._cli.attempts == 1 && f.radio.beacon_sends == 1); + assert(f.sensor.trackerNeedsRadio()); f.sensor.sleepIfIdle(); + assert(!f.sensor.tracker_radio_asleep); + f.radio.complete = true; f.advance(100); f.sensor.loopBeforeService(); + assert(!f.sensor.hasOutbound() && !f.sensor.trackerNeedsRadio()); + f.sensor.sleepIfIdle(); assert(f.sensor.tracker_radio_asleep); + f.sensor.setBeaconWakeDelay(f.clock.now, 0, false); + assert(!f.sensor.getNextBeaconWakeDelay(delay)); + assert(f.sensor.getPowerSaveSleepSeconds(30) == 30); + } +#endif +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!strcmp(argv[1], "agc")) agc_deadlines(); + else if (!strcmp(argv[1], "leases")) radio_leases(); + else if (!strcmp(argv[1], "beacons")) beacon_deadlines(); + else assert(false); + puts("PASS: tracker radio lease"); +} diff --git a/test/test_sensor_tracker_sleep.py b/test/test_sensor_tracker_sleep.py index 2b3bb1073..f400301e3 100644 --- a/test/test_sensor_tracker_sleep.py +++ b/test/test_sensor_tracker_sleep.py @@ -1,5 +1,6 @@ """Execute Sensor tracker sleep gates, real wake arming and radio handoff.""" from pathlib import Path +import os import re import shutil import subprocess @@ -9,6 +10,7 @@ import unittest from test_gps_upstream_adaptations import ARDUINO from test_replay_reset_integration import extract_braced +from cpp_source import body ROOT = Path(__file__).resolve().parents[1] @@ -57,6 +59,7 @@ public: bool hasQueuedWorkDue()const{return queue_due;} bool hasRetryWorkDue()const{return retry_due;} bool getNextQueueWakeDelay(uint32_t& out)const{out=queue_delay;return queue_delay_set;} + bool hasPendingRadioWork()const{return queue_delay_set;} bool getNextRetryWakeDelay(uint32_t& out)const{out=retry_delay;return retry_delay_set;} bool isMillisTimerDue(unsigned long timestamp)const; uint32_t limitSleepToMillisTimer(unsigned long timestamp,uint32_t sleep_secs)const; @@ -308,6 +311,91 @@ class SensorTrackerSleepTest(unittest.TestCase): with self.subTest(defines=defines): self.compile_run(source, defines) + def cross_layer_source(self, mutation=None): + sensor = (ROOT / "examples/simple_sensor/SensorMesh.cpp").read_text(encoding="ascii") + tracker = (ROOT / "examples/simple_sensor/Tracker.cpp").read_text(encoding="ascii") + if mutation == "old-radio-lease": + lease = " if (hasPendingRadioWork() || getNextRetryWakeDelay(delay)) return true;" + self.assertEqual(tracker.count(lease), 1) + tracker = tracker.replace(lease, + " if (getNextQueueWakeDelay(delay) || getNextRetryWakeDelay(delay)) return true;", 1) + elif mutation == "no-beacon-radio-wake": + due = " if (getNextBeaconWakeDelay(delay) && delay == 0) return true;" + self.assertEqual(tracker.count(due), 1) + tracker = tracker.replace(due, "", 1) + elif mutation == "no-beacon-mcu-wake": + start = "#if MESH_ENABLE_SENSOR_TRACKER && MESH_ENABLE_TX_BEACONS\n" + self.assertEqual(sensor.count(start), 1) + begin = sensor.index(start) + end = sensor.index("#endif", begin) + len("#endif") + sensor = sensor[:begin] + sensor[end:] + methods = "\n".join(body(sensor, signature) for signature in ( + "bool SensorMesh::isMillisTimerDue(", "uint32_t SensorMesh::limitSleepToMillisTimer(", + "bool SensorMesh::hasPendingWork(", "uint32_t SensorMesh::getPowerSaveSleepSeconds(")) + methods += "\n" + "\n".join(body(tracker, signature) for signature in ( + "bool SensorMesh::trackerNeedsRadio(", "void SensorMesh::wakeTrackerRadio(")) + # Execute the actual wake-before-CLI order. Application sensor work + # after Mesh::loop has separate tracker client/GPS regression coverage. + loop = body(sensor, "void SensorMesh::loop()") + end = " mesh::Mesh::loop();" + self.assertEqual(loop.count(end), 1) + methods += "\n" + loop[:loop.index(end) + len(end)].replace( + "void SensorMesh::loop()", "void SensorMesh::loopBeforeService()", 1) + "\n}\n" + source = (ROOT / "test/fixtures/sensor_tracker_radio_lease.cpp").read_text(encoding="ascii") + return source.replace("@METHODS@", methods).replace("@SLEEP_BLOCK@", body( + tracker, "if (!trackerNeedsRadio() && !radio_driver.isWatchdogObserving()")) + + def run_cross_layer(self, defines, scenarios=("agc", "leases", "beacons"), mutation=None): + with tempfile.TemporaryDirectory(prefix="meshcore-tracker-radio-lease-") as directory: + path = Path(directory) + source, binary = path / "lease.cpp", path / "lease" + source.write_text(self.cross_layer_source(mutation), encoding="ascii") + result = subprocess.run([ + self.compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", + "-Wno-unused-parameter", "-DMESH_ENABLE_SENSOR_TRACKER=1", *defines, + *(["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + if sys.platform.startswith("linux") else []), + "-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"), + str(ROOT / "src/Dispatcher.cpp"), str(ROOT / "src/DispatcherBeacon.cpp"), + str(ROOT / "src/Packet.cpp"), str(ROOT / "src/helpers/StaticPoolPacketManager.cpp"), + str(source), "-o", str(binary), + ], capture_output=True, text=True, timeout=90) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + env = dict(os.environ, ASAN_OPTIONS="detect_leaks=0") + for scenario in scenarios: + result = subprocess.run([str(binary), scenario], capture_output=True, + text=True, env=env, timeout=20) + if mutation: + self.assertNotEqual(result.returncode, 0, "old behavior escaped cross-layer regression") + self.assertIn("Assertion", result.stderr) + self.assertIn({ + "old-radio-lease": "a future AGC wake is not an RF lease", + "no-beacon-radio-wake": "due beacon must wake RF before the CLI admission attempt", + "no-beacon-mcu-wake": "RF suspension must not hide the next beacon MCU wake", + }[mutation], result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS: tracker radio lease", result.stdout) + + def test_real_dispatcher_distinguishes_rf_leases_from_mcu_deadlines(self): + for beacons, foreign, platform in ((0, 0, ()), (1, 0, ()), (1, 1, ()), + (1, 0, ("-DNRF52_PLATFORM=1",))): + with self.subTest(beacons=beacons, foreign=foreign, platform=platform): + self.run_cross_layer((f"-DMESH_ENABLE_TX_BEACONS={beacons}", + f"-DMESH_ENABLE_MESHTEXT_CHAT={foreign}", *platform)) + + def test_negative_control_agc_deadline_is_not_a_tracker_rf_lease(self): + self.run_cross_layer(("-DMESH_ENABLE_TX_BEACONS=0", "-DMESH_ENABLE_MESHTEXT_CHAT=0"), + ("agc",), "old-radio-lease") + + def test_negative_control_due_beacon_wakes_radio_before_cli(self): + self.run_cross_layer(("-DMESH_ENABLE_TX_BEACONS=1", "-DMESH_ENABLE_MESHTEXT_CHAT=0"), + ("beacons",), "no-beacon-radio-wake") + + def test_negative_control_suspended_radio_keeps_beacon_mcu_deadline(self): + self.run_cross_layer(("-DMESH_ENABLE_TX_BEACONS=1", "-DMESH_ENABLE_MESHTEXT_CHAT=0"), + ("beacons",), "no-beacon-mcu-wake") + if __name__ == "__main__": unittest.main()