diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index a907a707f..eab409f40 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -265,6 +265,7 @@ jobs: python3 -B test/test_sensor_tracker_client.py -v python3 -B test/test_sensor_tracker_gps.py -v python3 -B test/test_sensor_tracker_sleep.py -v + python3 -B test/test_power_saving_deadlines.py -v - name: Verify Companion contact and channel TX routing run: | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 2ef0a8725..6d94128a3 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #if MESH_PACKET_LOGGING #include @@ -5149,12 +5150,12 @@ uint32_t MyMesh::limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep if (!timestamp || sleep_secs == 0) { return sleep_secs; } - unsigned long now = millis(); - if ((long)(now - timestamp) >= 0) { + const uint32_t now = millis(); + if ((int32_t)(now - (uint32_t)timestamp) >= 0) { return 0; } - unsigned long remaining_ms = timestamp - now; - uint32_t remaining_secs = (remaining_ms + 999UL) / 1000UL; + const uint32_t remaining_ms = (uint32_t)timestamp - now; + uint32_t remaining_secs = mesh::power::powerSaveSleepSeconds(remaining_ms); return remaining_secs < sleep_secs ? remaining_secs : sleep_secs; } @@ -5184,12 +5185,12 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { uint32_t sleep_secs = max_secs; uint32_t queue_delay_ms; if (getNextQueueWakeDelay(queue_delay_ms)) { - uint32_t queue_delay_secs = (queue_delay_ms + 999UL) / 1000UL; + uint32_t queue_delay_secs = mesh::power::powerSaveSleepSeconds(queue_delay_ms); if (queue_delay_secs < sleep_secs) sleep_secs = queue_delay_secs; } uint32_t retry_delay_ms; if (getNextRetryWakeDelay(retry_delay_ms)) { - uint32_t retry_delay_secs = (retry_delay_ms + 999UL) / 1000UL; + uint32_t retry_delay_secs = mesh::power::powerSaveSleepSeconds(retry_delay_ms); if (retry_delay_secs < sleep_secs) sleep_secs = retry_delay_secs; } sleep_secs = limitSleepToMillisTimer(next_flood_advert, sleep_secs); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 58d45230c..69b4a5a39 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #if MESH_PACKET_LOGGING #include #endif @@ -4185,12 +4186,12 @@ uint32_t MyMesh::limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep if (!timestamp || sleep_secs == 0) { return sleep_secs; } - unsigned long now = millis(); - if ((long)(now - timestamp) >= 0) { + const uint32_t now = millis(); + if ((int32_t)(now - (uint32_t)timestamp) >= 0) { return 0; } - unsigned long remaining_ms = timestamp - now; - uint32_t remaining_secs = (remaining_ms + 999UL) / 1000UL; + const uint32_t remaining_ms = (uint32_t)timestamp - now; + uint32_t remaining_secs = mesh::power::powerSaveSleepSeconds(remaining_ms); return remaining_secs < sleep_secs ? remaining_secs : sleep_secs; } @@ -4202,12 +4203,12 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { uint32_t sleep_secs = max_secs; uint32_t queue_delay_ms; if (getNextQueueWakeDelay(queue_delay_ms)) { - uint32_t queue_delay_secs = (queue_delay_ms + 999UL) / 1000UL; + uint32_t queue_delay_secs = mesh::power::powerSaveSleepSeconds(queue_delay_ms); if (queue_delay_secs < sleep_secs) sleep_secs = queue_delay_secs; } uint32_t retry_delay_ms; if (getNextRetryWakeDelay(retry_delay_ms)) { - uint32_t retry_delay_secs = (retry_delay_ms + 999UL) / 1000UL; + uint32_t retry_delay_secs = mesh::power::powerSaveSleepSeconds(retry_delay_ms); if (retry_delay_secs < sleep_secs) sleep_secs = retry_delay_secs; } if (acl.getNumClients() > 0) { diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 7b85178f9..b870658e0 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -1676,10 +1677,12 @@ uint32_t SensorMesh::limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep_secs) const { if (!timestamp || sleep_secs == 0) return sleep_secs; - unsigned long now = millis(); - if ((long)(now - timestamp) >= 0) return 0; + const uint32_t now = millis(); + if ((int32_t)(now - (uint32_t)timestamp) >= 0) return 0; - uint32_t remaining_secs = (timestamp - now + 999UL) / 1000UL; + // Keep the millis arithmetic 32-bit, including on native test hosts. + const uint32_t remaining_ms = (uint32_t)timestamp - now; + uint32_t remaining_secs = mesh::power::powerSaveSleepSeconds(remaining_ms); return remaining_secs < sleep_secs ? remaining_secs : sleep_secs; } @@ -1725,11 +1728,11 @@ uint32_t SensorMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { #endif uint32_t wake_delay_ms; if (getNextQueueWakeDelay(wake_delay_ms)) { - uint32_t wake_delay_secs = (wake_delay_ms + 999UL) / 1000UL; + uint32_t wake_delay_secs = mesh::power::powerSaveSleepSeconds(wake_delay_ms); if (wake_delay_secs < sleep_secs) sleep_secs = wake_delay_secs; } if (getNextRetryWakeDelay(wake_delay_ms)) { - uint32_t wake_delay_secs = (wake_delay_ms + 999UL) / 1000UL; + 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/src/helpers/PowerManagementUtils.h b/src/helpers/PowerManagementUtils.h index 06a01e7c7..dc647e3e3 100644 --- a/src/helpers/PowerManagementUtils.h +++ b/src/helpers/PowerManagementUtils.h @@ -5,6 +5,18 @@ namespace mesh { namespace power { +inline uint32_t powerSaveSleepSeconds(uint32_t delay_ms) { + const uint32_t seconds = delay_ms / 1000UL; +#if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) || defined(STM32_PLATFORM) + // These boards ignore seconds and idle until an interrupt. Keep sub-second + // idle eligible; periodic clock interrupts service the remaining deadline. + return seconds + (delay_ms % 1000UL != 0); +#else + // Timed sleepers must wake before a deadline, not round past its last fraction. + return seconds; +#endif +} + inline uint16_t medianVoltage(uint16_t a, uint16_t b, uint16_t c) { if (a > b) { uint16_t t = a; a = b; b = t; } if (b > c) { uint16_t t = b; b = c; c = t; } diff --git a/test/test_esp32_full_power_lifecycle.py b/test/test_esp32_full_power_lifecycle.py index ff337c89d..ec68d5628 100644 --- a/test/test_esp32_full_power_lifecycle.py +++ b/test/test_esp32_full_power_lifecycle.py @@ -24,6 +24,7 @@ FIXTURE = r''' #include #include #include +#include #include #include uint32_t now = 0; diff --git a/test/test_logging_sleep_contract.py b/test/test_logging_sleep_contract.py index b2fcbf394..f03daa0c4 100644 --- a/test/test_logging_sleep_contract.py +++ b/test/test_logging_sleep_contract.py @@ -26,6 +26,7 @@ HARNESS = r''' #include #include #include +#include #define WITH_BRIDGE 1 struct AbstractBridge { virtual bool isRunning() const = 0; }; struct MQTTBridge : AbstractBridge { @@ -153,7 +154,7 @@ class LoggingSleepContractTest(unittest.TestCase): .replace("@METHODS@", methods)) built = subprocess.run([ os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", - str(cpp), "-o", str(binary), + "-I", str(ROOT / "src"), str(cpp), "-o", str(binary), ], capture_output=True, text=True) self.assertEqual(built.returncode, 0, built.stderr) result = subprocess.run([str(binary)], capture_output=True, text=True) diff --git a/test/test_power_saving_deadlines.py b/test/test_power_saving_deadlines.py new file mode 100644 index 000000000..2884378b7 --- /dev/null +++ b/test/test_power_saving_deadlines.py @@ -0,0 +1,139 @@ +"""Run real role deadlines while preserving timed sleep and interrupt idle.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#define SENSOR_READ_INTERVAL_SECS 60 +static uint32_t now_ms = 1000; +uint32_t millis() { return now_ms; } +struct Node { + bool pending = false, queue = false, retry = false; + uint32_t queue_ms = 0, retry_ms = 0; + struct { bool battery_alert_enabled = false; } _prefs; + struct { int getNumClients() const { return 0; } } acl; + unsigned long next_flood_advert = 0, next_local_advert = 0; + unsigned long dirty_contacts_expiry = 0, next_recent_repeater_sweep = 0; + unsigned long next_battery_alert_check = 0, next_push = 0; + unsigned long radio_apply_retry_at = 0, set_radio_at = 0, revert_radio_at = 0; + uint32_t last_read_time = 100; + struct Alert { unsigned long send_expiry = 0; } alert; + Alert* alert_tasks[1] = {&alert}; + int num_alert_tasks = 0; + struct Clock { uint32_t getCurrentTime() const { return 100; } } clock; + const Clock* getRTCClock() const { return &clock; } + bool hasPendingWork() const { return pending; } + bool getNextQueueWakeDelay(uint32_t& out) const { out = queue_ms; return queue; } + bool getNextRetryWakeDelay(uint32_t& out) const { out = retry_ms; return retry; } + bool millisHasNowPassed(unsigned long at) const { + return int32_t(now_ms - uint32_t(at)) > 0; + } + uint32_t limitSleepToScheduledRadioWork(uint32_t seconds) const { return seconds; } + uint32_t limitSleepToMillisTimer(unsigned long, uint32_t) const; + uint32_t getPowerSaveSleepSeconds(uint32_t) const; +}; +@METHODS@ +int main() { + Node node; + assert(mesh::power::powerSaveSleepSeconds(0) == 0); +#if EXPECT_INTERRUPT_IDLE + assert(mesh::power::powerSaveSleepSeconds(UINT32_MAX) == 4294968U); +#else + assert(mesh::power::powerSaveSleepSeconds(UINT32_MAX) == 4294967U); +#endif + assert(node.getPowerSaveSleepSeconds(30) == 30); + assert(node.getPowerSaveSleepSeconds(0) == 0); + assert(node.limitSleepToMillisTimer(0, 30) == 30); // Disabled timer sentinel. + assert(node.limitSleepToMillisTimer(1001, 0) == 0); + node.pending = true; + assert(node.getPowerSaveSleepSeconds(30) == 0); + node.pending = false; + for (uint32_t now : {1000U, UINT32_MAX - 15U}) { + now_ms = now; + // Timed sleep ends before the deadline. Event-driven boards instead permit + // interrupt idle for every positive remainder, including across rollover. + for (uint32_t ms : {0U, 1U, 999U, 1000U, 1001U, 1999U, 2000U, + 2500U, 29999U, 30000U, 30001U, 60000U}) { + const uint32_t whole_seconds = ms / 1000; +#if EXPECT_INTERRUPT_IDLE + // Event-driven boards ignore seconds and wake at the next IRQ. + const uint32_t idle_seconds = whole_seconds + (ms % 1000 != 0); +#else + const uint32_t idle_seconds = whole_seconds; +#endif + const uint32_t expected = idle_seconds < 30 ? idle_seconds : 30; + node.queue = true; node.queue_ms = ms; + assert(node.getPowerSaveSleepSeconds(30) == expected); + node.queue = false; node.retry = true; node.retry_ms = ms; + assert(node.getPowerSaveSleepSeconds(30) == expected); + node.retry = false; + const uint32_t deadline = now + ms; + assert(deadline != 0); // Zero has a separate intentional sentinel meaning. + assert(node.limitSleepToMillisTimer(deadline, 30) == expected); + node.next_flood_advert = deadline; + assert(node.getPowerSaveSleepSeconds(30) == expected); + node.next_flood_advert = 0; + } + assert(node.limitSleepToMillisTimer(uint32_t(now - 1), 30) == 0); + node.queue = true; node.queue_ms = 2500; + node.retry = true; node.retry_ms = 1001; +#if EXPECT_INTERRUPT_IDLE + assert(node.getPowerSaveSleepSeconds(30) == 2); // Interrupt idle is permitted. +#else + assert(node.getPowerSaveSleepSeconds(30) == 1); // Earliest timed owner wins. +#endif + node.dirty_contacts_expiry = uint32_t(now + 999); +#if EXPECT_INTERRUPT_IDLE + assert(node.getPowerSaveSleepSeconds(30) == 1); +#else + assert(node.getPowerSaveSleepSeconds(30) == 0); +#endif + node.dirty_contacts_expiry = 0; + node.queue = node.retry = false; + } +} +''' + + +class PowerSavingDeadlineTests(unittest.TestCase): + def test_real_role_deadlines_preserve_timed_and_interrupt_idle(self): + with tempfile.TemporaryDirectory(prefix='meshcore-sleep-deadlines-') as directory: + work = Path(directory) + for role, filename, name in ( + ('repeater', 'examples/simple_repeater/MyMesh.cpp', 'MyMesh'), + ('room', 'examples/simple_room_server/MyMesh.cpp', 'MyMesh'), + ('sensor', 'examples/simple_sensor/SensorMesh.cpp', 'SensorMesh')): + for platform in ('', 'NRF52_PLATFORM', 'RP2040_PLATFORM', 'STM32_PLATFORM'): + with self.subTest(role=role, platform=platform or 'timed'): + source = (ROOT / filename).read_text() + methods = '\n'.join(method(source, signature) for signature in ( + f'uint32_t {name}::limitSleepToMillisTimer(', + f'uint32_t {name}::getPowerSaveSleepSeconds(')) + methods = methods.replace(name + '::', 'Node::') + cpp, exe = work / (role + '.cpp'), work / role + cpp.write_text(HARNESS.replace('@METHODS@', methods)) + sanitizers = [] if os.name == 'nt' else [ + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', + '-fno-pie', '-no-pie'] + built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', + '-Wall', '-Wextra', '-Werror', *sanitizers, + '-DEXPECT_INTERRUPT_IDLE=' + ('1' if platform else '0'), + *(['-D' + platform + '=1'] if platform else []), '-I', str(ROOT / 'src'), + str(cpp), '-o', str(exe)], capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(exe)], capture_output=True, text=True) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_rs232_heartbeat_owner.py b/test/test_rs232_heartbeat_owner.py index 1ce345cd5..7a2b9a58a 100644 --- a/test/test_rs232_heartbeat_owner.py +++ b/test/test_rs232_heartbeat_owner.py @@ -18,6 +18,7 @@ HARNESS = r''' #include #include #include +#include #define WITH_BRIDGE 1 #define WITH_RS232_BRIDGE Serial2 #define WITH_RS232_BRIDGE_UART 2 @@ -310,7 +311,7 @@ class RS232HeartbeatOwnerTests(unittest.TestCase): flags.append("-DWITH_MQTT_BRIDGE=1") built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", - *flags, str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=60) + *flags, "-I", str(ROOT / "src"), str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=60) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) diff --git a/test/test_sensor_tracker_sleep.py b/test/test_sensor_tracker_sleep.py index a384137cc..cb716adac 100644 --- a/test/test_sensor_tracker_sleep.py +++ b/test/test_sensor_tracker_sleep.py @@ -15,6 +15,7 @@ ROOT = Path(__file__).resolve().parents[1] DEADLINES = r''' #include #include +#include #define SENSOR_READ_INTERVAL_SECS 60 bool logging=false; namespace mesh {bool isUsbLoggingWatchdogArmed(){return logging;}} @@ -98,13 +99,18 @@ int main(){ &radio_driver.calibration,&s.queue_due,&s.retry_due}){ *due=true;assert(s.hasPendingWork()&&s.getPowerSaveSleepSeconds(30)==0);*due=false; } - s.queue_delay_set=true;s.queue_delay=2500;assert(s.getPowerSaveSleepSeconds(30)==3); + #if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) || defined(STM32_PLATFORM) + constexpr unsigned queue_idle=3,retry_idle=2; + #else + constexpr unsigned queue_idle=2,retry_idle=1; + #endif + s.queue_delay_set=true;s.queue_delay=2500;assert(s.getPowerSaveSleepSeconds(30)==queue_idle); #if MESH_ENABLE_SENSOR_TRACKER assert(s.trackerNeedsRadio()); #endif s.queue_delay_set=false;s.retry_delay_set=true;s.retry_delay=1500; - assert(s.getPowerSaveSleepSeconds(30)==2);s.retry_delay_set=false; - s.dirty_contacts_expiry=now_ms+1001;assert(s.getPowerSaveSleepSeconds(30)==2); + assert(s.getPowerSaveSleepSeconds(30)==retry_idle);s.retry_delay_set=false; + s.dirty_contacts_expiry=now_ms+1001;assert(s.getPowerSaveSleepSeconds(30)==retry_idle); s.dirty_contacts_expiry=0;s.last_read_time=45; assert(s.getPowerSaveSleepSeconds(30)==5);s.last_read_time=40; assert(s.hasPendingWork()&&s.getPowerSaveSleepSeconds(30)==0); @@ -245,7 +251,7 @@ class SensorTrackerSleepTest(unittest.TestCase): "-Wno-unused-parameter", "-Wno-unused-function", "-Wno-unused-variable", *(["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []), - *[f"-D{define}" for define in defines], "-I", str(path), + *[f"-D{define}" for define in defines], "-I", str(path), "-I", str(ROOT / "src"), str(cpp), "-o", str(binary), ], capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) @@ -261,9 +267,10 @@ class SensorTrackerSleepTest(unittest.TestCase): methods += "\n#if MESH_ENABLE_SENSOR_TRACKER\n" + extract_braced( tracker, "bool SensorMesh::trackerNeedsRadio(") + "\n#endif\n" for enabled in (0, 1): - with self.subTest(enabled=enabled): - self.compile_run(DEADLINES.replace("@METHODS@", methods), - (f"MESH_ENABLE_SENSOR_TRACKER={enabled}",)) + for platform in ((), ("NRF52_PLATFORM=1",)): + with self.subTest(enabled=enabled, platform=platform): + self.compile_run(DEADLINES.replace("@METHODS@", methods), + (f"MESH_ENABLE_SENSOR_TRACKER={enabled}", *platform)) def test_actual_main_timer_allocation_queue_failure_and_platform_paths(self): main = (ROOT / "examples/simple_sensor/main.cpp").read_text()