From 0c7dec9f1989aa7c511ba62aa18b08bd19e30806 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 02:06:53 -0700 Subject: [PATCH] Add an hourly emergency battery sleep schedule for infrastructure nodes --- .github/workflows/run-unit-tests.yml | 4 + docs/cli_commands.md | 63 ++++- docs/solarxiao_power.md | 21 ++ examples/simple_repeater/MyMesh.h | 16 ++ examples/simple_repeater/main.cpp | 20 ++ examples/simple_room_server/MyMesh.h | 16 ++ examples/simple_room_server/main.cpp | 30 +- examples/simple_sensor/SensorMesh.h | 16 ++ examples/simple_sensor/main.cpp | 15 + scripts/client_acl_role.py | 28 ++ src/MeshCore.h | 10 + src/helpers/BatteryForwarding.h | 13 +- src/helpers/CommonCLI.cpp | 9 + src/helpers/CommonCLI.h | 24 +- src/helpers/CommonCLI_Emergency.cpp | 146 ++++++++++ src/helpers/ESP32Board.cpp | 31 +++ src/helpers/ESP32Board.h | 4 + src/helpers/EmergencyOtaWork.h | 38 +++ src/helpers/EmergencyPowerConfig.h | 11 + src/helpers/EmergencyPowerPolicy.h | 122 ++++++++ src/helpers/EmergencyPowerRuntime.h | 59 ++++ src/helpers/NRF52Board.cpp | 28 +- src/helpers/NRF52Board.h | 4 + src/helpers/WirelessControl.h | 51 ++++ test/test_battery_forwarding.py | 84 +++++- test/test_client_acl_role.py | 21 ++ test/test_emergency_power.py | 386 ++++++++++++++++++++++++++ test/test_emergency_power_ota.py | 155 +++++++++++ test/test_emergency_power_runtime.py | 360 ++++++++++++++++++++++++ test/test_emergency_sleep_backends.py | 268 ++++++++++++++++++ 30 files changed, 2020 insertions(+), 33 deletions(-) create mode 100644 src/helpers/CommonCLI_Emergency.cpp create mode 100644 src/helpers/EmergencyOtaWork.h create mode 100644 src/helpers/EmergencyPowerConfig.h create mode 100644 src/helpers/EmergencyPowerPolicy.h create mode 100644 src/helpers/EmergencyPowerRuntime.h create mode 100644 test/test_emergency_power.py create mode 100644 test/test_emergency_power_ota.py create mode 100644 test/test_emergency_power_runtime.py create mode 100644 test/test_emergency_sleep_backends.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 052aeb281..6bbe16c88 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -595,6 +595,10 @@ jobs: python3 -B test/test_regulator_recovery.py -v python3 -B test/test_regulator_settings.py -v python3 -B test/test_battery_forwarding.py -v + python3 -B test/test_emergency_power.py -v + python3 -B test/test_emergency_power_runtime.py -v + python3 -B test/test_emergency_power_ota.py -v + python3 -B test/test_emergency_sleep_backends.py -v python3 -B test/test_battery_calibration.py -v python3 -B test/test_battery_calibration_store.py -v python3 -B test/test_power_saving_diagnostics.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 6094581f3..567edd71c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -3024,8 +3024,8 @@ send text.flood checking ridge link - `set battery.alert.critical <0-99>` **Defaults:** -- `battery.alert.low`: `20` -- `battery.alert.critical`: `10` +- `battery.alert.low`: `25` +- `battery.alert.critical`: `15` **Note:** The low threshold must be greater than the critical threshold. Alerts at or below the critical threshold use `CRITICAL BATTERY` in the message; both severities use the same 12-hour resend cooldown. @@ -3052,12 +3052,17 @@ readings remain available. - `set flood.battery ` - `get battery.forward` -`set repeat.battery on` enables the saved repeat threshold, initially **10%**. -`set flood.battery on` enables the saved flood threshold, initially **20%**. +`set repeat.battery on` enables the saved repeat threshold, initially **15%**. +`set flood.battery on` enables the saved flood threshold, initially **25%**. A number both enables the control and sets its threshold. `off` disables it while retaining that threshold for the next `on`. Changes are saved and take effect without a reboot. +An older forwarding record with exactly the former 10% repeat / 20% flood +defaults adopts 15% / 25% on upgrade. Its enabled controls and voltage endpoints +stay unchanged. Other saved thresholds are retained; newly selected custom +10% / 20% settings also remain available. + Below the repeat threshold, the node stops LoRa retransmissions of received traffic. Below the flood threshold, it stops LoRa retransmissions of received flood traffic but may still @@ -3071,8 +3076,8 @@ limits. Exporting radio reception to WiFi, ESP-NOW, or RS232 is unchanged. Forwarding resumes automatically after the measured percentage reaches two percentage points above the applicable threshold, capped at 100%. With the -initial thresholds, flood forwarding pauses below 20% and resumes at 22%; all -relay forwarding pauses below 10% and resumes at 12%. This recovery margin +initial thresholds, flood forwarding pauses below 25% and resumes at 27%; all +relay forwarding pauses below 15% and resumes at 17%. This recovery margin avoids repeatedly switching forwarding on and off near a threshold. Battery voltage is sampled every 30 seconds while either control is enabled, @@ -3093,12 +3098,54 @@ get battery.forward To choose different thresholds and later disable the feature: ```text -set repeat.battery 15 -set flood.battery 25 +set repeat.battery 12 +set flood.battery 22 set repeat.battery off set flood.battery off ``` +#### Emergency battery schedule + +Supported ESP32 and nRF52 Repeater, Room Server and Sensor images enter the +final battery-saving stage at **5% or less**, independently of the optional +forwarding gates. Automatic mode returns to normal at **7% or more**. An +invalid battery reading cannot activate it, or falsely clear an existing low +battery state. Percentage uses the same configured voltage endpoints as +`battery.forward`; it does not prove that a physical battery is connected. + +In emergency mode, normal work runs during the first **15 minutes of every +UTC hour** (`:00:00` through `:14:59`). For the remaining 45 minutes the LoRa +radio sleeps and normal mesh, sensor and screen work is suspended. The CPU +uses timed sleep with brief watchdog, clock, voltage-protection and command +maintenance wakes. Active firmware updates and unfinished critical operations +defer sleep. Existing hard low-voltage protection remains in force. + +```text +get power.emergency +set power.emergency on +set power.awake 30 +set power.awake 0 +set power.emergency auto +``` + +`on` saves manual emergency mode, including across reboots. `auto` restores +battery-controlled entry and is the default. The initial transition allows +five seconds for its reply. `power.awake` keeps the node up for the next given +number of minutes (0 cancels; maximum 10080), even outside the hourly window. +That override is held in RAM and ends on reboot; it never disables hard +low-voltage shutdown. Use it before maintenance that needs continuous uptime. + +`get power.emergency` reports the mode, battery estimate, desired `window`, +actual radio ownership, seconds until the next awake window, and remaining +hold. With an unset clock it reports `clock=uptime` and follows the same +15/45-minute cycle from boot until a valid UTC clock is available. Queued +future traffic does not keep a critically low node awake indefinitely. + +This schedule uses resumable timed sleep, preserving identity and settings. +WiFi/ESP-NOW services are temporarily stopped and their previous live subset +is restored when the node wakes. Companion builds and flash-constrained +profiles that omit battery-forwarding controls do not add this feature. + #### Set the battery percentage voltage range The forwarding thresholds use a voltage-derived percentage. On infrastructure diff --git a/docs/solarxiao_power.md b/docs/solarxiao_power.md index 0543e4a34..a663c0255 100644 --- a/docs/solarxiao_power.md +++ b/docs/solarxiao_power.md @@ -246,6 +246,27 @@ measurement or entry into ship mode. Read the corresponding status afterward. See the [bench acceptance report](test-results/solarxiao_power_2026-10-21.md) for exact firmware identity, measured readouts and test limits. +## Emergency battery schedule + +The [emergency schedule](cli_commands.md#emergency-battery-schedule) keeps the +radio and normal work awake for the first 15 minutes of each UTC hour, with +timed sleep for the remaining 45 minutes. It starts automatically at 5% and +recovers at 7%. Forwarding thresholds now default to 25% for floods and 15% +for all relayed traffic; those forwarding gates remain opt-in. + +```text +get power.emergency +set power.emergency on +set power.awake 30 +set power.emergency auto +``` + +The percentage follows the configured self battery voltage endpoints used by +`battery.forward`. On MPPT carriers this is the XIAO ADC, not an estimate of +the separate carrier battery's charge from INA260/LTC4162. Check the actual +voltage source before relying on automatic entry. Manual mode and timed wake +work independently of the charger. Hard low-voltage protection still applies. + ## Conversion defaults and carrier revisions The supplied [Standard schematic](https://github.com/wehooper4/Meshtastic-Hardware/blob/main/XaioSeries/SolarXaio/MPPT/Standard/Schematic_SolarXaio_MPPT_2025-10-23.pdf) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8448b7e62..f1edc4d0b 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -1277,6 +1278,21 @@ public: NodePrefs* getNodePrefs() { return &_prefs; } +#if MESH_ENABLE_EMERGENCY_POWER + mesh::power::EmergencyOtaWork _emergency_ota_work; + CommonCLI& getPowerCLI() { return _cli; } + bool emergencyPowerBusy() const { +#if defined(ENABLE_OTA) + if (_emergency_ota_work.busy(mesh::ota::ota_context_if_active(), millis())) return true; +#endif +#if MESH_ENABLE_DIRECT_OTA_ADMIN + if (direct_ota_admin.awaitingPeer()) return true; +#endif + return hasPendingOtaApply() || _cli.hasPendingRadioSettings() + || _cli.hasActiveUserGpioTimer() || dirty_contacts_expiry != 0 + || (saved_radio_apply_pending && !temp_radio_applied) || _ota_update_at != 0; + } +#endif bool isHiddenNode() const { return _prefs.advert_interval == 0 && _prefs.flood_advert_interval == 0; } diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 45ec9755a..93a945a79 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #if MESH_PACKET_LOGGING #include @@ -727,6 +728,23 @@ void loop() { #endif serviceCommandInterfaces(); +#if MESH_ENABLE_EMERGENCY_POWER + static mesh::power::EmergencyPowerRuntime emergency_power; +#if defined(ESP32_PLATFORM) + board.servicePowerTestControls(); +#endif + rtc_clock.tick(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.loop(); +#endif + bool emergency_busy = command[0]; +#if MESH_USB_CONSOLE_COOPERATIVE + emergency_busy = emergency_busy || the_mesh.hasPendingSerialOutput(); +#endif + if (emergency_power.service(the_mesh, radio_driver, board, + the_mesh.getPowerCLI(), emergency_busy, the_mesh.emergencyPowerBusy())) return; +#endif + #if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_) && !defined(DISPLAY_CLASS) // Hold the user button to power off the SenseCAP Solar repeater. int btnState = digitalRead(PIN_USER_BTN); @@ -778,7 +796,9 @@ void loop() { // Report the guard that really stopped this iteration, without probing // later guards or changing their short-circuit hardware access. #if defined(ESP32_PLATFORM) +#if !MESH_ENABLE_EMERGENCY_POWER board.servicePowerTestControls(); +#endif mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz, the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive()); #endif diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index bf81bfff0..3ad07fcec 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -3,6 +3,10 @@ #include // needed for PlatformIO #include #include +#include +#if MESH_ENABLE_EMERGENCY_POWER && defined(ENABLE_OTA) +#include +#endif #include "RoomServerFeatures.h" #if defined(NRF52_PLATFORM) @@ -553,6 +557,18 @@ public: NodePrefs* getNodePrefs() { return &_prefs; } +#if MESH_ENABLE_EMERGENCY_POWER + mesh::power::EmergencyOtaWork _emergency_ota_work; + CommonCLI& getPowerCLI() { return _cli; } + bool emergencyPowerBusy() const { +#if defined(ENABLE_OTA) + if (_emergency_ota_work.busy(mesh::ota::ota_context_if_active(), millis())) return true; +#endif + return hasPendingOtaApply() || _cli.hasPendingRadioSettings() + || _cli.hasActiveUserGpioTimer() || dirty_contacts_expiry != 0 + || (saved_radio_apply_pending && !temp_radio_applied) || _ota_update_at != 0; + } +#endif #ifdef DISPLAY_ACTIVITY_DASHBOARD RadioActivityWindow* getActivityWindow() { return &_activity; } diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 10d89c391..f5cc3339f 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #if MESH_PACKET_LOGGING #include #endif @@ -452,6 +453,28 @@ void loop() { } #endif + mesh::power::serviceRegulatorSettings(); +#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER + mesh::solar::service(); +#endif + board.loop(); + +#if MESH_ENABLE_EMERGENCY_POWER + static mesh::power::EmergencyPowerRuntime emergency_power; +#if defined(ESP32_PLATFORM) + board.servicePowerTestControls(); +#endif + rtc_clock.tick(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.loop(); +#endif + bool emergency_busy = command[0]; +#if MESH_USB_CONSOLE_COOPERATIVE + emergency_busy = emergency_busy || the_mesh.hasPendingSerialOutput(); +#endif + if (emergency_power.service(the_mesh, radio_driver, board, + the_mesh.getPowerCLI(), emergency_busy, the_mesh.emergencyPowerBusy())) return; +#endif the_mesh.loop(); const bool morse_timing = mesh::morseTimingActive(&radio_driver); #if defined(USE_LR2021) @@ -466,11 +489,6 @@ void loop() { #endif #endif rtc_clock.tick(); - mesh::power::serviceRegulatorSettings(); -#if defined(MESH_SOLARXIAO_POWER) && MESH_SOLARXIAO_POWER - mesh::solar::service(); -#endif - board.loop(); if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot(); #if MESH_USB_CONSOLE_COOPERATIVE #if MESH_ENABLE_HOST_KISS @@ -489,7 +507,9 @@ void loop() { && !morse_timing) board.prepareProfileIdleCycle(); #endif #if defined(ESP32_PLATFORM) +#if !MESH_ENABLE_EMERGENCY_POWER board.servicePowerTestControls(); +#endif mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz, the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive()); #endif diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 78688ff4f..0fe2cded5 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -3,6 +3,10 @@ #include // needed for PlatformIO #include #include +#include +#if MESH_ENABLE_EMERGENCY_POWER && defined(ENABLE_OTA) +#include +#endif #include "TimeSeriesData.h" @@ -86,6 +90,18 @@ public: const char* getRole() override { return FIRMWARE_ROLE; } const char* getNodeName() { return _prefs.node_name; } NodePrefs* getNodePrefs() { return &_prefs; } +#if MESH_ENABLE_EMERGENCY_POWER + mesh::power::EmergencyOtaWork _emergency_ota_work; + CommonCLI& getPowerCLI() { return _cli; } + bool emergencyPowerBusy() const { +#if defined(ENABLE_OTA) + if (_emergency_ota_work.busy(mesh::ota::ota_context_if_active(), millis())) return true; +#endif + return hasPendingOtaApply() || _cli.hasPendingRadioSettings() + || _cli.hasActiveUserGpioTimer() || dirty_contacts_expiry != 0 + || (saved_radio_apply_pending && !temp_radio_applied); + } +#endif uint32_t getPowerSaveSleepSeconds(uint32_t max_secs) const; uint32_t getPowerSaveSleepMilliseconds(uint32_t max_ms) const; bool isTrackerModeEnabled() const { diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 17a806895..caf200835 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #if defined(NRF52_PLATFORM) && MESH_ENABLE_SENSOR_TRACKER #include static SoftwareTimer tracker_wake_timer; @@ -412,6 +413,18 @@ void loop() { mesh::power::serviceRegulatorSettings(); board.loop(); // let the board feed its watchdog, run periodic housekeeping +#if MESH_ENABLE_EMERGENCY_POWER + static mesh::power::EmergencyPowerRuntime emergency_power; +#if defined(ESP32_PLATFORM) + board.servicePowerTestControls(); +#endif + rtc_clock.tick(); +#ifdef HAS_EXTERNAL_WATCHDOG + external_watchdog.loop(); +#endif + if (emergency_power.service(the_mesh, radio_driver, board, + the_mesh.getPowerCLI(), command[0], the_mesh.emergencyPowerBusy())) return; +#endif the_mesh.loop(); const bool morse_timing = mesh::morseTimingActive(&radio_driver); #if defined(USE_LR2021) @@ -442,7 +455,9 @@ void loop() { #endif #if defined(ESP32_PLATFORM) +#if !MESH_ENABLE_EMERGENCY_POWER board.servicePowerTestControls(); +#endif mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz, the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive()); #endif diff --git a/scripts/client_acl_role.py b/scripts/client_acl_role.py index 995884297..c638d6407 100644 --- a/scripts/client_acl_role.py +++ b/scripts/client_acl_role.py @@ -22,6 +22,32 @@ OTHER_ROLES = ("simple_room_server", "simple_sensor", "companion_radio", "simple "kiss_modem", "partition_expander", "esp32_partition_migrator", "esp32_partition_legacy_seed") COMPANION_DEFINITION = "MESH_ROLE_COMPANION" COMPANION_SOURCES = ("../examples/companion_radio/main.cpp", "../examples/companion_radio/MyMesh.cpp") +EMERGENCY_DEFINITION = "MESH_ENABLE_EMERGENCY_POWER" + + +def emergency_power_policy(src_filter, flags=(), cppdefines=()): + """Enable scheduled battery sleep only on complete supported infrastructure roles.""" + values = SOURCE_POLICY.flag_defines(flags, cppdefines) + if EMERGENCY_DEFINITION in values: + value = str(values[EMERGENCY_DEFINITION]).strip("\"'() ") + if value not in ("0", "1"): + raise ValueError(EMERGENCY_DEFINITION + " override must be 0 or 1") + return {} + if not any(name in values for name in ("ESP32_PLATFORM", "NRF52_PLATFORM")): + return {} + if str(values.get("MESH_ENABLE_BATTERY_FORWARDING", "1")).strip("\"'() ") == "0": + return {} + selected = SOURCE_POLICY.source_selected + text = src_filter if isinstance(src_filter, str) else " ".join(map(str, src_filter)) + includes = re.findall(r"\+<\.\./examples/([^>]+)>", text.replace("\\", "/")) + for role, mesh_source in (("simple_repeater", "MyMesh.cpp"), + ("simple_room_server", "MyMesh.cpp"), + ("simple_sensor", "SensorMesh.cpp")): + if includes and all(path == role or path.startswith(role + "/") for path in includes) \ + and all(selected(src_filter, "../examples/" + role + "/" + source) + for source in ("main.cpp", mesh_source)): + return {EMERGENCY_DEFINITION: 1} + return {} def companion_role_policy(src_filter, flags=(), cppdefines=()): @@ -83,6 +109,8 @@ def install(build_env): build_env.get("CPPDEFINES", ())) definitions.update(companion_role_policy(src_filter, build_env.get("BUILD_FLAGS", ()), build_env.get("CPPDEFINES", ()))) + definitions.update(emergency_power_policy(src_filter, build_env.get("BUILD_FLAGS", ()), + build_env.get("CPPDEFINES", ()))) if definitions: build_env.AppendUnique(CPPDEFINES=list(definitions.items())) diff --git a/src/MeshCore.h b/src/MeshCore.h index 3ea29e65d..423d370b4 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -6,6 +6,7 @@ #include "helpers/UsbLogging.h" #include "helpers/BatteryChargeSupport.h" #include "helpers/BatteryCalibration.h" +#include "helpers/EmergencyPowerConfig.h" #include "helpers/RegulatorMode.h" #include "helpers/PowerSavingDiagnostics.h" #include "helpers/LedPolicy.h" @@ -202,6 +203,15 @@ public: return false; } virtual void enterDeepSleep(uint32_t secs) { (void)secs; } +#if MESH_ENABLE_EMERGENCY_POWER + // Resumable CPU sleep after the caller has suspended RF and drained owners. + // Capability describes the backend, not whether this attempt can sleep. + virtual bool supportsEmergencySleep() const { return false; } + virtual bool emergencySleepMilliseconds(uint32_t millisecs) { + (void)millisecs; + return false; + } +#endif // Called by example setup() functions to signal that boot is complete. // Boards may override to stop a boot-indicator LED sequence or similar. // Default no-op: boards that don't care need not implement anything. diff --git a/src/helpers/BatteryForwarding.h b/src/helpers/BatteryForwarding.h index 1c5aadc31..1745e5823 100644 --- a/src/helpers/BatteryForwarding.h +++ b/src/helpers/BatteryForwarding.h @@ -24,8 +24,8 @@ static constexpr size_t RecordSize = 16; struct Settings { uint8_t enabled = 0; - uint8_t repeatBelow = 10; - uint8_t floodBelow = 20; + uint8_t repeatBelow = 15; + uint8_t floodBelow = 25; // Zero keeps the board's compiled endpoints. nRF52 uses its voltage policy. uint16_t emptyMv = 0; uint16_t fullMv = 0; @@ -54,7 +54,7 @@ inline uint16_t checksum(const uint8_t* data, size_t size) { inline void encode(uint8_t* out, const Settings& s) { memset(out, 0, RecordSize); - out[0] = 'B'; out[1] = 'F'; out[2] = 'R'; out[3] = 1; + out[0] = 'B'; out[1] = 'F'; out[2] = 'R'; out[3] = 2; out[4] = s.enabled; out[5] = s.repeatBelow; out[6] = s.floodBelow; out[8] = uint8_t(s.emptyMv); out[9] = uint8_t(s.emptyMv >> 8); out[10] = uint8_t(s.fullMv); out[11] = uint8_t(s.fullMv >> 8); @@ -65,13 +65,18 @@ inline void encode(uint8_t* out, const Settings& s) { inline bool decode(const uint8_t* in, size_t size, Settings& s) { s = Settings{}; if (!in || size != RecordSize || in[0] != 'B' || in[1] != 'F' - || in[2] != 'R' || in[3] != 1 || in[7] || in[12] || in[13] + || in[2] != 'R' || (in[3] != 1 && in[3] != 2) || in[7] || in[12] || in[13] || checksum(in, 14) != uint16_t(in[14] | (uint16_t(in[15]) << 8))) return false; Settings candidate; candidate.enabled = in[4]; candidate.repeatBelow = in[5]; candidate.floodBelow = in[6]; candidate.emptyMv = uint16_t(in[8] | (uint16_t(in[9]) << 8)); candidate.fullMv = uint16_t(in[10] | (uint16_t(in[11]) << 8)); if (!validSettings(candidate)) return false; + // Upgrade the old default pair once; v2 preserves an explicit 10/20 choice. + if (in[3] == 1 && candidate.repeatBelow == 10 && candidate.floodBelow == 20) { + candidate.repeatBelow = 15; + candidate.floodBelow = 25; + } s = candidate; return true; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index cb84f8e54..7377de554 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -222,6 +222,9 @@ void CommonCLI::loop() { #if MESH_ENABLE_BATTERY_FORWARDING loopBatteryForwarding(); #endif +#if MESH_ENABLE_EMERGENCY_POWER + loopEmergencyPower(); +#endif #if MESH_ENABLE_MORSE_RX if (_morse_reply.pending()) { const ClientInfo* actor = _acl->getClient(_morse_reply.actor(), PUB_KEY_SIZE); @@ -1012,6 +1015,9 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) { void CommonCLI::loadPrefs(FILESYSTEM* fs) { #if MESH_ENABLE_BATTERY_FORWARDING loadBatteryForwarding(fs); +#endif +#if MESH_ENABLE_EMERGENCY_POWER + loadEmergencyPower(fs); #endif mesh::hilStartupTrace("prefs_profiles_begin"); _radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true); @@ -3563,6 +3569,9 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re #if MESH_ENABLE_BATTERY_FORWARDING if (handleBatteryForwardingCommand(command, reply)) return; #endif +#if MESH_ENABLE_EMERGENCY_POWER + if (handleEmergencyPowerCommand(command, reply)) return; +#endif #if defined(WITH_UDP_BRIDGE) if (handleUDPBridgeCommand(sender_timestamp, command, reply)) return; #endif diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index e9b73c346..8fcbdc448 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -19,6 +19,8 @@ #include #include #include +#include +#include #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) #include #endif @@ -91,8 +93,8 @@ static constexpr uint8_t DEFAULT_ADVERT_LOC_POLICY = ADVERT_LOC_PREFS; #define FLOOD_RETRY_ADVERT_DEFAULT 0 #define FLOOD_RETRY_GROUP_MAX_PATH_DEFAULT 1 -#define BATTERY_ALERT_LOW_PERCENT_DEFAULT 20 -#define BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT 10 +#define BATTERY_ALERT_LOW_PERCENT_DEFAULT 25 +#define BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT 15 #ifndef FLOOD_RETRY_PREFIX_SLOTS #define FLOOD_RETRY_PREFIX_SLOTS 8 @@ -938,6 +940,15 @@ class CommonCLI { void loadBatteryForwarding(FILESYSTEM*) {} void loopBatteryForwarding(bool = false) const {} bool handleBatteryForwardingCommand(const char*, char*) { return false; } +#endif +#if MESH_ENABLE_EMERGENCY_POWER + FILESYSTEM* _emergency_power_fs = nullptr; + mesh::emergencypower::State _emergency_power; + bool _emergency_radio_sleeping = false; + void loadEmergencyPower(FILESYSTEM* fs); + bool saveEmergencyPower(const mesh::emergencypower::Settings& settings); + void loopEmergencyPower(); + bool handleEmergencyPowerCommand(const char* command, char* reply); #endif uint16_t batteryEmptyMv() const; uint16_t batteryFullMv() const; @@ -1110,6 +1121,15 @@ public: #else bool batteryAllowsForward(bool) const { return true; } bool batteryAllowsTransmit(const mesh::Packet*) const { return true; } +#endif +#if MESH_ENABLE_EMERGENCY_POWER + uint32_t emergencySleepMilliseconds(); + bool emergencyActive() const { return _emergency_power.active(); } + void setEmergencyRadioSleeping(bool sleeping) { _emergency_radio_sleeping = sleeping; } +#else + uint32_t emergencySleepMilliseconds() { return 0; } + bool emergencyActive() const { return false; } + void setEmergencyRadioSleeping(bool) {} #endif uint8_t batteryPercent(uint16_t mv) const; bool hasActiveUserGpioTimer() const; diff --git a/src/helpers/CommonCLI_Emergency.cpp b/src/helpers/CommonCLI_Emergency.cpp new file mode 100644 index 000000000..74b1bd407 --- /dev/null +++ b/src/helpers/CommonCLI_Emergency.cpp @@ -0,0 +1,146 @@ +#include "CommonCLI.h" + +#if MESH_ENABLE_EMERGENCY_POWER +#include "CheckedSettingsFile.h" +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#include "AtomicFileWriter.h" +#else +#include "ContactFileTransaction.h" +#endif + +namespace { +const char* const EMERGENCY_POWER_PATH = "/power_emergency"; + +bool readEmergencyPower(FILESYSTEM* fs, const char* path, + mesh::emergencypower::Settings& settings) { + uint8_t bytes[mesh::emergencypower::RecordSize]; + return mesh::storage::readFixedSettingsFile(fs, path, bytes, sizeof(bytes)) + && mesh::emergencypower::decode(bytes, sizeof(bytes), settings); +} + +bool emergencyCommand(const char* command, const char* prefix, const char*& value) { + const size_t length = strlen(prefix); + if (strncmp(command, prefix, length) != 0 + || (command[length] && command[length] != ' ' && command[length] != '\t')) return false; + value = command + length; + while (*value == ' ' || *value == '\t') ++value; + return true; +} + +bool emergencyValue(const char* value, const char* expected) { + const size_t length = strlen(expected); + if (strncmp(value, expected, length) != 0) return false; + value += length; + while (*value == ' ' || *value == '\t') ++value; + return !*value; +} +} + +void CommonCLI::loadEmergencyPower(FILESYSTEM* fs) { + _emergency_power_fs = fs; + mesh::emergencypower::Settings loaded; +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + if (!mesh::ContactFileTransaction::recover(fs, EMERGENCY_POWER_PATH)) + _emergency_power_fs = nullptr; + else +#endif + if (!readEmergencyPower(fs, EMERGENCY_POWER_PATH, loaded)) + readEmergencyPower(fs, "/power_emergency.bak", loaded); + _emergency_power.begin(loaded, millis()); + _emergency_radio_sleeping = false; +} + +bool CommonCLI::saveEmergencyPower(const mesh::emergencypower::Settings& settings) { + if (!_emergency_power_fs) return false; + uint8_t bytes[mesh::emergencypower::RecordSize]; + mesh::emergencypower::encode(bytes, settings); +#if defined(NRF52_PLATFORM) + return mesh::storage::writeCheckedSettingsFile(_emergency_power_fs, EMERGENCY_POWER_PATH, + bytes, sizeof(bytes)) == mesh::AtomicFileWriter::Outcome::Committed; +#else +#if defined(STM32_PLATFORM) + mesh::AtomicFileWriter writer(_emergency_power_fs, EMERGENCY_POWER_PATH); +#else + mesh::ContactFileTransaction writer(_emergency_power_fs, EMERGENCY_POWER_PATH); +#endif + if (!writer || writer.write(bytes, sizeof(bytes)) != sizeof(bytes) || !writer.commit()) return false; + uint8_t verified[sizeof(bytes)]; + return mesh::storage::readFixedSettingsFile(_emergency_power_fs, EMERGENCY_POWER_PATH, + verified, sizeof(verified)) && memcmp(bytes, verified, sizeof(bytes)) == 0; +#endif +} + +void CommonCLI::loopEmergencyPower() { + const uint32_t now = millis(); + if (!_emergency_power.due(now)) return; + const uint16_t mv = _board->getBattMilliVolts(); + _emergency_power.sample(now, mv, batteryPercent(mv), + mesh::batteryforward::validEndpoints(batteryEmptyMv(), batteryFullMv())); +} + +uint32_t CommonCLI::emergencySleepMilliseconds() { + loopEmergencyPower(); + return _emergency_power.sleepMilliseconds(millis(), _rtc->getCurrentTime()); +} + +bool CommonCLI::handleEmergencyPowerCommand(const char* command, char* reply) { + if (!command || !reply) return false; + const char* value; + if (emergencyCommand(command, "get power.emergency", value)) { + if (*value) snprintf(reply, 157, "Error: use get power.emergency"); + else { + const uint32_t sleep_ms = emergencySleepMilliseconds(); + char battery[12] = "unknown"; + if (_emergency_power.valid) + snprintf(battery, sizeof(battery), "%u%%", unsigned(_emergency_power.percentage)); + snprintf(reply, 157, "> mode=%s active=%s battery=%s clock=%s window=%s radio=%s wake=%lus hold=%lus", + _emergency_power.settings.manual ? "manual" : "auto", emergencyActive() ? "on" : "off", + battery, mesh::emergencypower::State::usesRtc(_rtc->getCurrentTime()) ? "rtc" : "uptime", + sleep_ms ? "sleep" : "awake", _emergency_radio_sleeping ? "sleep" : "awake", + static_cast((sleep_ms + 999UL) / 1000UL), + static_cast((_emergency_power.holdRemaining(millis()) + 999UL) / 1000UL)); + } + return true; + } + if (emergencyCommand(command, "set power.emergency", value)) { + const bool manual = emergencyValue(value, "on"); + if (!manual && !emergencyValue(value, "auto")) { + snprintf(reply, 157, "Error: use set power.emergency on|auto"); + return true; + } + if (manual && !_board->supportsEmergencySleep()) { + snprintf(reply, 157, "Error: emergency sleep unsupported on this board"); + return true; + } + mesh::emergencypower::Settings candidate; + candidate.manual = manual; + if (candidate.manual != _emergency_power.settings.manual && !saveEmergencyPower(candidate)) + snprintf(reply, 157, "Error: emergency settings save unconfirmed; active policy unchanged"); + else { + _emergency_power.setManual(manual, millis()); + snprintf(reply, 157, "OK - emergency %s", manual ? "on" : "auto"); + } + return true; + } + if (!emergencyCommand(command, "set power.awake", value)) return false; + uint16_t minutes = 0; + const char* p = value; + bool digits = false; + while (*p >= '0' && *p <= '9') { + digits = true; + const unsigned number = unsigned(minutes) * 10U + unsigned(*p++ - '0'); + if (number > mesh::emergencypower::MaximumAwakeMinutes) { + snprintf(reply, 157, "Error: use set power.awake 0-10080 (minutes)"); + return true; + } + minutes = uint16_t(number); + } + while (*p == ' ' || *p == '\t') ++p; + if (!digits || *p) snprintf(reply, 157, "Error: use set power.awake 0-10080 (minutes)"); + else { + _emergency_power.stayAwake(minutes, millis()); + snprintf(reply, 157, "OK - awake for %u minutes", unsigned(minutes)); + } + return true; +} +#endif // MESH_ENABLE_EMERGENCY_POWER diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index fd51a2cf1..b5591fc66 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -8,6 +8,9 @@ #include "FileRead.h" #include "UserGpioPinPolicy.h" #include +#if MESH_ENABLE_EMERGENCY_POWER +#include +#endif namespace mesh { namespace esp32_clock { @@ -1408,6 +1411,34 @@ void ESP32Board::powerOff() { enterDeepSleep(0); // Do not wakeup } +#if MESH_ENABLE_EMERGENCY_POWER +bool ESP32Board::emergencySleepMilliseconds(uint32_t millisecs) { + // The caller owns radio suspension and reversible wireless shutdown. Never + // force a live WiFi/Bluetooth stack through explicit light sleep. + if (millisecs == 0 || isRadioTestActive() || xPortInIsrContext() + || xTaskGetSchedulerState() != taskSCHEDULER_RUNNING + || !mesh::esp32::manualLightSleepReady()) return false; + if (millisecs > 10000UL) millisecs = 10000UL; + const esp_err_t watchdog_status = esp_task_wdt_status(nullptr); + const bool watched = watchdog_status == ESP_OK; + if (!watched && watchdog_status != ESP_ERR_NOT_FOUND + && watchdog_status != ESP_ERR_INVALID_STATE) return false; +#if defined(CONFIG_ESP_TASK_WDT_TIMEOUT_S) && CONFIG_ESP_TASK_WDT_TIMEOUT_S > 0 + if (watched && millisecs > uint32_t(CONFIG_ESP_TASK_WDT_TIMEOUT_S) * 500UL) + millisecs = uint32_t(CONFIG_ESP_TASK_WDT_TIMEOUT_S) * 500UL; +#endif + if (watched && esp_task_wdt_reset() != ESP_OK) return false; + if (esp_sleep_enable_timer_wakeup(uint64_t(millisecs) * 1000ULL) != ESP_OK) + return false; + // Add no radio/GPIO wake. Existing button sources remain untouched. The + // normal sleep path installs its own next timer; never leave ours armed. + const esp_err_t slept = esp_light_sleep_start(); + const esp_err_t cleared = esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_TIMER); + const bool watchdog_ok = !watched || esp_task_wdt_reset() == ESP_OK; + return slept == ESP_OK && cleared == ESP_OK && watchdog_ok; +} +#endif + void ESP32Board::enterDeepSleep(uint32_t secs) { shutdownPeripherals(); diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index a29e79ed1..f9d9f1854 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -349,6 +349,10 @@ public: virtual void shutdownPeripherals(); virtual void powerOff() override; virtual void enterDeepSleep(uint32_t secs) override; +#if MESH_ENABLE_EMERGENCY_POWER + bool supportsEmergencySleep() const override { return true; } + bool emergencySleepMilliseconds(uint32_t millisecs) override; +#endif uint32_t getIRQGpio() override { #ifdef P_LORA_DIO_1 diff --git a/src/helpers/EmergencyOtaWork.h b/src/helpers/EmergencyOtaWork.h new file mode 100644 index 000000000..9ab84191c --- /dev/null +++ b/src/helpers/EmergencyOtaWork.h @@ -0,0 +1,38 @@ +#pragma once + +#include "EmergencyPowerConfig.h" +#include + +#if MESH_ENABLE_EMERGENCY_POWER +namespace mesh { namespace power { + +class EmergencyOtaWork { + mutable uint32_t served_at_ = 0, serve_hold_ms_ = 0; +public: + template + bool busy(const Context* context, uint32_t now) const { + bool receiving = false, cleanup = false; + if (context) { + const auto& manager = context->manager; + using Manager = decltype(context->manager); + const auto state = manager.fetchState(); + receiving = state == Manager::WANT_MANIFEST || state == Manager::WANT_LEAVES + || state == Manager::VERIFYING_STAGED || state == Manager::FETCHING; + if (manager.pendingServeJobs() || manager.pendingManifestJobs()) { + // Keep listening between a proof and the peer's next block request. + // Idle self-serving/catalog adverts never renew this bounded hold. + const uint32_t retry = manager.fetchRetryTimeoutMs(); + serve_hold_ms_ = retry < 30000UL ? 30000UL : retry; + served_at_ = now; + } +#if defined(OTA_SD_STORE) + cleanup = context->sdCacheFetching(); +#endif + } + if (serve_hold_ms_ && uint32_t(now - served_at_) >= serve_hold_ms_) serve_hold_ms_ = 0; + return receiving || cleanup || serve_hold_ms_ != 0; + } +}; + +}} // namespace mesh::power +#endif diff --git a/src/helpers/EmergencyPowerConfig.h b/src/helpers/EmergencyPowerConfig.h new file mode 100644 index 000000000..d20366e6d --- /dev/null +++ b/src/helpers/EmergencyPowerConfig.h @@ -0,0 +1,11 @@ +#pragma once + +// The source-role build policy enables this for supported infrastructure +// images. Companion and constrained profiles retain their existing footprint. +#ifndef MESH_ENABLE_EMERGENCY_POWER +#define MESH_ENABLE_EMERGENCY_POWER 0 +#endif + +#if MESH_ENABLE_EMERGENCY_POWER != 0 && MESH_ENABLE_EMERGENCY_POWER != 1 +#error "MESH_ENABLE_EMERGENCY_POWER must be 0 or 1" +#endif diff --git a/src/helpers/EmergencyPowerPolicy.h b/src/helpers/EmergencyPowerPolicy.h new file mode 100644 index 000000000..58bdbce70 --- /dev/null +++ b/src/helpers/EmergencyPowerPolicy.h @@ -0,0 +1,122 @@ +#pragma once + +#include +#include + +namespace mesh { namespace emergencypower { + +static constexpr uint32_t SampleIntervalMs = 30000; +static constexpr uint32_t HourMs = 3600000; +static constexpr uint32_t AwakeWindowMs = 900000; +static constexpr uint32_t ReplyHoldMs = 5000; +static constexpr uint32_t PlausibleEpoch = 1577836800; // 2020-01-01 UTC +static constexpr uint16_t MaximumAwakeMinutes = 10080; +static constexpr size_t RecordSize = 8; + +struct Settings { + bool manual = false; +}; + +inline uint16_t checksum(const uint8_t* data, size_t size) { + uint16_t crc = 0xffff; + while (size--) { + crc ^= uint16_t(*data++) << 8; + for (unsigned bit = 0; bit < 8; ++bit) + crc = (crc & 0x8000) ? uint16_t((crc << 1) ^ 0x1021) : uint16_t(crc << 1); + } + return crc; +} + +inline void encode(uint8_t* out, const Settings& settings) { + out[0] = 'P'; out[1] = 'E'; out[2] = 'M'; out[3] = 1; + out[4] = settings.manual ? 1 : 0; out[5] = 0; + const uint16_t crc = checksum(out, 6); + out[6] = uint8_t(crc); out[7] = uint8_t(crc >> 8); +} + +inline bool decode(const uint8_t* in, size_t size, Settings& settings) { + settings = Settings{}; + if (!in || size != RecordSize || in[0] != 'P' || in[1] != 'E' + || in[2] != 'M' || in[3] != 1 || in[4] > 1 || in[5] + || checksum(in, 6) != uint16_t(in[6] | uint16_t(in[7]) << 8)) return false; + settings.manual = in[4] != 0; + return true; +} + +struct State { + Settings settings; + uint32_t sampledAt = 0; + uint16_t millivolts = 0; + uint8_t percentage = 0; + bool sampled = false; + bool valid = false; + bool lowBattery = false; + uint32_t phaseMs = 0; + uint32_t lastTick = 0; + uint32_t activationStarted = 0; + uint32_t awakeStarted = 0; + uint32_t awakeDuration = 0; + bool activationHold = false; + + void begin(const Settings& loaded, uint32_t now) { + *this = State{}; + settings = loaded; + phaseMs = now % HourMs; + lastTick = now; + if (active()) holdReplies(now); + } + bool active() const { return settings.manual || lowBattery; } + bool due(uint32_t now) const { + return !sampled || uint32_t(now - sampledAt) >= SampleIntervalMs; + } + void holdReplies(uint32_t now) { + activationStarted = now; + activationHold = true; + } + void setManual(bool manual, uint32_t now) { + if (settings.manual == manual) return; + settings.manual = manual; + if (active()) holdReplies(now); + } + void sample(uint32_t now, uint16_t mv, uint8_t pct, bool known) { + const bool was_active = active(); + sampled = true; sampledAt = now; millivolts = mv; percentage = pct; + valid = known && pct <= 100 && mv >= 2000 && mv <= 20000; + // A failed reading cannot wake a node already protecting its battery. + if (valid) lowBattery = lowBattery ? pct < 7 : pct <= 5; + if (!was_active && active()) holdReplies(now); + } + bool stayAwake(uint16_t minutes, uint32_t now) { + if (minutes > MaximumAwakeMinutes) return false; + awakeStarted = now; + awakeDuration = uint32_t(minutes) * 60000UL; + return true; + } + uint32_t holdRemaining(uint32_t now) { + uint32_t remaining = 0; + if (activationHold) { + const uint32_t elapsed = uint32_t(now - activationStarted); + if (elapsed >= ReplyHoldMs) activationHold = false; + else remaining = ReplyHoldMs - elapsed; + } + if (awakeDuration) { + const uint32_t elapsed = uint32_t(now - awakeStarted); + if (elapsed >= awakeDuration) awakeDuration = 0; + else if (awakeDuration - elapsed > remaining) remaining = awakeDuration - elapsed; + } + return remaining; + } + static bool usesRtc(uint32_t epoch) { return epoch >= PlausibleEpoch; } + uint32_t sleepMilliseconds(uint32_t now, uint32_t epoch) { + // Accumulate the boot-relative phase across millis() wrap. RTC corrections + // change only the hourly window, never the duration of an awake lease. + phaseMs = (phaseMs + uint32_t(now - lastTick) % HourMs) % HourMs; + lastTick = now; + const uint32_t hold = holdRemaining(now); + if (!active() || hold) return 0; + const uint32_t phase = usesRtc(epoch) ? (epoch % 3600UL) * 1000UL : phaseMs; + return phase < AwakeWindowMs ? 0 : HourMs - phase; + } +}; + +}} // namespace mesh::emergencypower diff --git a/src/helpers/EmergencyPowerRuntime.h b/src/helpers/EmergencyPowerRuntime.h new file mode 100644 index 000000000..3a79508af --- /dev/null +++ b/src/helpers/EmergencyPowerRuntime.h @@ -0,0 +1,59 @@ +#pragma once + +#include "EmergencyPowerConfig.h" + +#if MESH_ENABLE_EMERGENCY_POWER +#include +#include "WirelessControl.h" + +namespace mesh { namespace power { + +class EmergencyPowerRuntime { + bool radio_asleep_ = false; + bool radio_was_available_ = false; +public: + template + bool service(Mesh& mesh, Radio& radio, Board& board, CLI& cli, + bool entry_busy, bool work_required = false) { + const uint32_t budget = cli.emergencySleepMilliseconds(); + if (!board.supportsEmergencySleep()) return false; + const bool active_operation = work_required || board.isOTAUpdateRunning() + || board.isRadioTestActive() || radio.isMorseTxActive() || radio.isMorseRxActive(); + if (budget == 0 || active_operation) { + if (radio_asleep_) { + if (radio_was_available_) { + if (!radio.setTrackerSleep(false)) radio.recoverRadio(true); + mesh.setRadioAvailable(true); + } + radio_asleep_ = false; + cli.setEmergencyRadioSleeping(false); + } + wireless::control().resumeAfterEmergency(); + return false; + } + if (!radio_asleep_) { + // Complete live operations and command replies before taking ownership. + // Queued future traffic cannot keep a critically low node awake forever. + if (entry_busy || mesh.hasOutbound()) return false; + if (!wireless::control().suspendForEmergency()) return false; + radio_was_available_ = mesh.isRadioAvailable(); + if (radio_was_available_) { + if (!radio.setTrackerSleep(true)) return false; + mesh.setRadioAvailable(false); + } + radio_asleep_ = true; + cli.setEmergencyRadioSleeping(true); + } + // Brief maintenance wakes retain voltage protection and watchdogs. Normal + // radio/mesh/sensor/UI work remains suspended throughout the off window. + if (!board.emergencySleepMilliseconds(budget > 10000 ? 10000 : budget)) { + // A transient SDK/driver refusal must not turn battery protection into + // a busy loop. Keep RF ownership and retry after yielding the CPU. + delay(10); + } + return true; + } +}; + +}} // namespace mesh::power +#endif diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index b8a24bece..ca2bb253e 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -312,7 +312,7 @@ float NRF52Board::getAdcMultiplier() const { return mesh::MainBoard::getAdcMultiplier(); } -#if NRF52_WATCHDOG_TIMEOUT_SECONDS > 0 +#if NRF52_WATCHDOG_TIMEOUT_SECONDS > 0 || MESH_ENABLE_EMERGENCY_POWER static void reloadWatchdogChannels() { // Ordinarily only RR0 is enabled. If a bootloader left the watchdog running // with a different reload channel, service every enabled channel. @@ -927,6 +927,32 @@ void NRF52Board::powerOff() { } } +#if MESH_ENABLE_EMERGENCY_POWER +bool NRF52Board::emergencySleepMilliseconds(uint32_t millisecs) { + // Blocking the sketch lets the existing RTOS idle/RTC machinery sleep the + // CPU. SYSTEMOFF cannot provide a timed wake on every supported nRF52 board. + if (millisecs == 0 || watchdog_disable_reset_pending || isRadioTestActive() + || __get_IPSR() != 0 || __get_PRIMASK() != 0 || __get_BASEPRI() != 0 + || __get_FAULTMASK() != 0 || nrf_nvic_state.__cr_flag != 0 + || xTaskGetSchedulerState() != taskSCHEDULER_RUNNING) return false; + if (millisecs > 10000UL) millisecs = 10000UL; + if (NRF_WDT->RUNSTATUS != 0) { + // A bootloader may have left a shorter watchdog than this build's default. + // Half its actual timeout leaves room for scheduling and wake processing. + const uint32_t watchdog_budget_ms = (NRF_WDT->CRV / 32768UL) * 500UL; + if (watchdog_budget_ms == 0) return false; + if (millisecs > watchdog_budget_ms) millisecs = watchdog_budget_ms; + } + const TickType_t ticks = pdMS_TO_TICKS(millisecs); + if (ticks == 0) return false; + // Service inherited watchdogs even when this build does not start one. + if (NRF_WDT->RUNSTATUS != 0) reloadWatchdogChannels(); + vTaskDelay(ticks); + if (NRF_WDT->RUNSTATUS != 0) reloadWatchdogChannels(); + return true; +} +#endif + void NRF52Board::enterDeepSleep(uint32_t secs) { if (secs == 0) { powerOff(); // Not wake up diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 68f4a2531..f066f152f 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -80,6 +80,10 @@ public: virtual void shutdownPeripherals(); virtual void powerOff() override; virtual void enterDeepSleep(uint32_t secs) override; +#if MESH_ENABLE_EMERGENCY_POWER + bool supportsEmergencySleep() const override { return true; } + bool emergencySleepMilliseconds(uint32_t millisecs) override; +#endif virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override; virtual bool stopOTAUpdate(char reply[]) override; diff --git a/src/helpers/WirelessControl.h b/src/helpers/WirelessControl.h index 8f8e1520e..7da2abca5 100644 --- a/src/helpers/WirelessControl.h +++ b/src/helpers/WirelessControl.h @@ -4,6 +4,7 @@ #include #include #include +#include "EmergencyPowerConfig.h" namespace mesh { namespace wireless { @@ -59,6 +60,10 @@ class Control { uint8_t scope_ = 0, target_ = 0, saved_ = 0; bool pending_ = false, started_ = false, force_ = false, independent_requester_ = false; bool saved_valid_ = false, master_off_ = false; +#if MESH_ENABLE_EMERGENCY_POWER + bool emergency_suspended_ = false; + uint8_t emergency_enabled_ = 0, emergency_blocked_ = 0; +#endif uint32_t due_ = 0; const char* error_ = nullptr; @@ -73,6 +78,9 @@ public: } bool masterOff() const { return master_off_; } bool allowService(uint8_t service) { +#if MESH_ENABLE_EMERGENCY_POWER + if (emergency_suspended_) return false; +#endif if (master_off_ || pending_) return false; blocked_.store(blocked_.load() & ~service); return true; @@ -81,6 +89,9 @@ public: // disabled 2.4 GHz for this boot. Leave the other services disabled. Do not // race a pending shutdown, which could otherwise turn OTA off afterwards. bool allowWiFiForOTA() { +#if MESH_ENABLE_EMERGENCY_POWER + if (emergency_suspended_) return false; +#endif if (pending_) return false; blocked_.store(blocked_.load() & ~WiFi); master_off_ = false; @@ -90,6 +101,40 @@ public: } uint8_t enabled() const { return backend_ ? backend_->enabled() : 0; } +#if MESH_ENABLE_EMERGENCY_POWER + // A scheduled battery sleep borrows the live services without changing the + // user's saved wireless policy or the ordinary off/on restore snapshot. + bool suspendForEmergency() { + if (!backend_) return true; + if (pending_) return false; + if (!emergency_suspended_) { + emergency_enabled_ = enabled(); + emergency_blocked_ = blocked_.load(); + emergency_suspended_ = true; + blocked_.store(emergency_blocked_ | All); + } + const uint8_t order[] = {WiFi, EspNow, Bluetooth}; + for (uint8_t service : order) { + if ((emergency_enabled_ & service) && backend_->set(service, false) != Result::Done) + return false; + } + return enabled() == 0; + } + + bool resumeAfterEmergency() { + if (!emergency_suspended_) return true; + const uint8_t order[] = {EspNow, WiFi, Bluetooth}; + for (uint8_t service : order) { + if (!(emergency_enabled_ & service)) continue; + blocked_.store(blocked_.load() & ~service); + if (backend_->set(service, true) != Result::Done) return false; + } + blocked_.store(emergency_blocked_); + emergency_suspended_ = false; + return true; + } +#endif + bool handle(const char* text, char* reply, size_t size, uint32_t now, uint8_t requester) { const auto command = parse(text); if (command.action == Action::None || !reply || !size) return false; @@ -106,6 +151,12 @@ public: else snprintf(reply, size, "wifi=%s, bluetooth=%s, espnow=%s%s%s", state(WiFi), state(Bluetooth), state(EspNow), pending_ ? " (pending)" : "", error_ ? error_ : ""); return true; } +#if MESH_ENABLE_EMERGENCY_POWER + if (emergency_suspended_) { + snprintf(reply, size, "Error: emergency sleep; use set power.awake first"); + return true; + } +#endif if (!(available & command.scope)) { snprintf(reply, size, "Error: requested wireless service unavailable in this build/boot"); return true; diff --git a/test/test_battery_forwarding.py b/test/test_battery_forwarding.py index 0b72138b1..83d266a15 100644 --- a/test/test_battery_forwarding.py +++ b/test/test_battery_forwarding.py @@ -565,17 +565,17 @@ static bool handleVoltagePolicyCommand(const char*, char* reply, size_t capacity self.exercise(r''' CLI cli; assert(validSettings(cli.settings)); - assert(cli.settings.enabled == 0 && cli.settings.repeatBelow == 10 - && cli.settings.floodBelow == 20 && cli.settings.emptyMv == 0 && cli.settings.fullMv == 0); + assert(cli.settings.enabled == 0 && cli.settings.repeatBelow == 15 + && cli.settings.floodBelow == 25 && cli.settings.emptyMv == 0 && cli.settings.fullMv == 0); cli.state.update(1, 3000, 0, true, cli.settings); assert(cli.state.allows(false) && cli.state.allows(true)); - assert(cli.run("get repeat.battery") && strcmp(cli.reply, "> off below=10%") == 0); - assert(cli.run("get flood.battery") && strcmp(cli.reply, "> off below=20%") == 0); + assert(cli.run("get repeat.battery") && strcmp(cli.reply, "> off below=15%") == 0); + assert(cli.run("get flood.battery") && strcmp(cli.reply, "> off below=25%") == 0); assert(cli.saveCalls == 0); for (unsigned threshold = 1; threshold <= 100; ++threshold) { const std::string command = "set repeat.battery " + std::to_string(threshold); cli.ok(command.c_str()); - assert(cli.settings.repeatBelow == threshold && cli.settings.floodBelow == 20); + assert(cli.settings.repeatBelow == threshold && cli.settings.floodBelow == 25); assert(cli.settings.enabled == Repeat); } cli.ok("set repeat.battery 13"); @@ -683,21 +683,21 @@ static bool handleVoltagePolicyCommand(const char*, char* reply, size_t capacity Settings settings; settings.enabled = Repeat | Flood; State state; - state.update(1, 3240, 20, true, settings); + state.update(1, 3300, 25, true, settings); assert(state.allows(false) && state.allows(true)); - state.update(2, 3228, 19, true, settings); + state.update(2, 3288, 24, true, settings); assert(state.allows(false) && !state.allows(true)); - state.update(3, 3120, 10, true, settings); + state.update(3, 3180, 15, true, settings); assert(state.allows(false) && !state.allows(true)); - state.update(4, 3108, 9, true, settings); + state.update(4, 3168, 14, true, settings); assert(!state.allows(false) && !state.allows(true)); - state.update(5, 3132, 11, true, settings); + state.update(5, 3192, 16, true, settings); assert(!state.allows(false) && !state.allows(true)); - state.update(6, 3144, 12, true, settings); + state.update(6, 3204, 17, true, settings); assert(state.allows(false) && !state.allows(true)); - state.update(7, 3252, 21, true, settings); + state.update(7, 3312, 26, true, settings); assert(state.allows(false) && !state.allows(true)); - state.update(8, 3264, 22, true, settings); + state.update(8, 3324, 27, true, settings); assert(state.allows(false) && state.allows(true)); ''') @@ -838,6 +838,64 @@ static bool handleVoltagePolicyCommand(const char*, char* reply, size_t capacity assert(decode(bytes, RecordSize, actual) && same(actual, Settings{})); ''') + def test_v1_default_pair_migrates_once_and_v2_custom_legacy_pair_is_preserved(self): + self.exercise(r''' + auto encodeVersion = [&](uint8_t* bytes, const Settings& settings, uint8_t version) { + encode(bytes, settings); + bytes[3] = version; + const uint16_t crc = checksum(bytes, 14); + bytes[14] = uint8_t(crc); bytes[15] = uint8_t(crc >> 8); + }; + for (uint8_t enabled = 0; enabled <= (Repeat | Flood); ++enabled) { + Settings saved; + saved.enabled = enabled; + saved.emptyMv = 6000; saved.fullMv = 8400; + saved.repeatBelow = 10; saved.floodBelow = 20; + uint8_t bytes[RecordSize]; encodeVersion(bytes, saved, 1); + Settings loaded; + assert(decode(bytes, sizeof(bytes), loaded)); + assert(loaded.repeatBelow == 15 && loaded.floodBelow == 25); + assert(loaded.enabled == enabled && loaded.emptyMv == 6000 && loaded.fullMv == 8400); + encode(bytes, saved); + assert(bytes[3] == 2 && decode(bytes, sizeof(bytes), loaded) && same(saved, loaded)); + encodeVersion(bytes, saved, 1); + bytes[3] = 2; // Version is covered by the record checksum. + assert(!decode(bytes, sizeof(bytes), loaded)); + for (uint8_t version : {uint8_t(0), uint8_t(3), uint8_t(255)}) { + encodeVersion(bytes, saved, version); + assert(!decode(bytes, sizeof(bytes), loaded) && same(loaded, Settings{})); + } + } + for (unsigned repeat = 1; repeat <= 100; ++repeat) { + for (unsigned flood = 1; flood <= 100; ++flood) { + Settings saved; + saved.enabled = Repeat | Flood; + saved.repeatBelow = uint8_t(repeat); saved.floodBelow = uint8_t(flood); + uint8_t bytes[RecordSize]; encodeVersion(bytes, saved, 1); + Settings loaded; + assert(decode(bytes, sizeof(bytes), loaded)); + if (repeat == 10 && flood == 20) { + assert(loaded.repeatBelow == 15 && loaded.floodBelow == 25); + } else assert(same(saved, loaded)); + encode(bytes, saved); + assert(decode(bytes, sizeof(bytes), loaded) && same(saved, loaded)); + } + } + Settings custom; + custom.enabled = Repeat | Flood; + custom.repeatBelow = 10; custom.floodBelow = 20; + uint8_t bytes[RecordSize]; encode(bytes, custom); + Settings loaded; + assert(decode(bytes, sizeof(bytes), loaded) && same(custom, loaded)); + State state; + state.update(0, 3240, 20, true, loaded); + assert(state.allows(false) && state.allows(true)); + state.update(1, 3120, 10, true, loaded); + assert(state.allows(false) && !state.allows(true)); + state.update(2, 3108, 9, true, loaded); + assert(!state.allows(false) && !state.allows(true)); +''') + if __name__ == "__main__": unittest.main() diff --git a/test/test_client_acl_role.py b/test/test_client_acl_role.py index 46be48bdc..e660a4a7c 100644 --- a/test/test_client_acl_role.py +++ b/test/test_client_acl_role.py @@ -19,6 +19,27 @@ FIXTURE = ROOT / "test/fixtures/client_acl_spiffs" class ClientAclRoleTests(unittest.TestCase): + def test_emergency_sleep_is_scoped_to_supported_infrastructure(self): + definition = POLICY.EMERGENCY_DEFINITION + for role in ("simple_repeater", "simple_room_server", "simple_sensor"): + source = "+ +<../examples/" + role + "/*.cpp>" + for platform in ("NRF52_PLATFORM", "ESP32_PLATFORM"): + flags = "-D" + platform + self.assertEqual(POLICY.emergency_power_policy(source, flags), {definition: 1}) + self.assertEqual(POLICY.emergency_power_policy( + source, flags + " -DMESH_ENABLE_BATTERY_FORWARDING=0"), {}) + self.assertEqual(POLICY.emergency_power_policy( + source, flags + " -D" + definition + "=0"), {}) + self.assertEqual(POLICY.emergency_power_policy(source, "-DSTM32_PLATFORM"), {}) + self.assertEqual(POLICY.emergency_power_policy( + source + " +<../examples/companion_radio/*.cpp>", "-DNRF52_PLATFORM"), {}) + for source in ("", "+<*>", "+<../examples/custom_role/*.cpp>", + "+<../examples/companion_radio/*.cpp>", + "+<../examples/simple_repeater/main.cpp>"): + self.assertEqual(POLICY.emergency_power_policy(source, "-DNRF52_PLATFORM"), {}) + with self.assertRaises(ValueError): + POLICY.emergency_power_policy("", "-D" + definition + "=2") + def test_complete_exclusive_companion_role_uses_one_global_definition(self): definition = POLICY.COMPANION_DEFINITION for role in ("+<../examples/companion_radio>", diff --git a/test/test_emergency_power.py b/test/test_emergency_power.py new file mode 100644 index 000000000..bf74e3d12 --- /dev/null +++ b/test/test_emergency_power.py @@ -0,0 +1,386 @@ +"""Exercise the emergency scheduler and actual CommonCLI boundary without hardware.""" +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] +POLICY_PREFIX = r''' +#include +#include +#include +#include +#include +using namespace mesh::emergencypower; +static constexpr uint32_t epoch = PlausibleEpoch; +''' + + +class EmergencyPowerTests(unittest.TestCase): + def exercise(self, body, prefix=POLICY_PREFIX, flags=()): + with tempfile.TemporaryDirectory(prefix="emergency-power-") as folder: + work = Path(folder) + source, binary = work / "main.cpp", work / "check" + source.write_text(prefix + "\nint main() {\n" + body + "\n}\n", encoding="ascii") + command = [os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g", + "-Wall", "-Wextra", "-Werror", "-Wno-unused-function", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", *flags, + "-I", str(ROOT / "src"), str(source), "-o", str(binary)] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_exact_low_boundary_hysteresis_invalid_readings_and_reply_hold(self): + self.exercise(r''' + for (unsigned pct = 0; pct <= 100; ++pct) { + State state; + state.begin(Settings{}, 100); + assert(!state.active() && state.due(100)); + state.sample(100, 3000, uint8_t(pct), true); + assert(state.active() == (pct <= 5)); + assert(!state.due(30099) && state.due(30100)); + } + State state; + state.begin(Settings{}, 0); + state.sample(0, 3060, 5, true); + assert(state.active() && state.holdRemaining(0) == 5000); + assert(state.sleepMilliseconds(4999, epoch + 1800) == 0); + assert(state.sleepMilliseconds(5000, epoch + 1800) == 1800000); + state.sample(5001, 3072, 6, true); + assert(state.active() && state.sleepMilliseconds(5001, epoch + 1800) == 1800000); + for (uint16_t mv : {uint16_t(0), uint16_t(1999), uint16_t(20001), uint16_t(65535)}) { + state.sample(5002, mv, 100, true); + assert(!state.valid && state.active()); + } + state.sample(5003, 4200, 255, true); + assert(!state.valid && state.active()); + state.sample(5004, 4200, 100, false); + assert(!state.valid && state.active()); + state.sample(5005, 3084, 7, true); + assert(!state.active() && state.sleepMilliseconds(5005, epoch + 1800) == 0); + state.sample(5006, 0, 0, true); + assert(!state.active()); + state.sample(5007, 3000, 0, false); + assert(!state.active()); + state.sample(5008, 3060, 5, true); + assert(state.active() && state.holdRemaining(5008) == 5000); + state.sample(5009, 3060, 5, true); + assert(state.holdRemaining(5009) == 4999); // Sampling never renews an existing activation. +''') + + def test_hour_boundaries_clock_corrections_and_boot_relative_wrap(self): + self.exercise(r''' + Settings manual; manual.manual = true; + State state; + state.begin(manual, 0); + assert(state.sleepMilliseconds(5000, epoch + 899) == 0); + assert(state.sleepMilliseconds(5001, epoch + 900) == 2700000); + assert(state.sleepMilliseconds(5002, epoch + 3599) == 1000); + assert(state.sleepMilliseconds(5003, epoch + 3600) == 0); + assert(state.sleepMilliseconds(5004, epoch + 7200 + 1800) == 1800000); + assert(state.sleepMilliseconds(5005, epoch + 600) == 0); // RTC rollback selects the real hour. + assert(state.sleepMilliseconds(900000, 0) == 2700000); + assert(state.sleepMilliseconds(3599999, PlausibleEpoch - 1) == 1); + assert(state.sleepMilliseconds(3600000, 0) == 0); + assert(State::usesRtc(epoch) && !State::usesRtc(epoch - 1)); + + const uint32_t boot = 0xfffff000U; + state.begin(manual, boot); + uint32_t now = boot; + uint32_t expected = boot % HourMs; + for (uint32_t delta : {uint32_t(5000), uint32_t(899999), uint32_t(3600000), + uint32_t(0xf0000000), uint32_t(0x10000100)}) { + now += delta; + expected = (expected + delta % HourMs) % HourMs; + const uint32_t want = expected < AwakeWindowMs ? 0 : HourMs - expected; + assert(state.sleepMilliseconds(now, 0) == want); + assert(state.phaseMs == expected); + } +''') + + def test_manual_transitions_awake_lease_renew_cancel_limits_and_wrap(self): + self.exercise(r''' + State state; + state.begin(Settings{}, 0); + state.setManual(true, 100); + assert(state.active() && state.holdRemaining(100) == 5000); + state.setManual(true, 101); + assert(state.holdRemaining(101) == 4999); + assert(state.sleepMilliseconds(5100, epoch + 1800) == 1800000); + assert(state.stayAwake(1, 5101)); + assert(state.sleepMilliseconds(65100, epoch + 3599) == 0); + assert(state.sleepMilliseconds(65101, epoch + 1800) == 1800000); + assert(!state.awakeDuration); + assert(state.sleepMilliseconds(5101, epoch + 1800) == 1800000); // Expired lease stays expired. + assert(state.stayAwake(10080, 0xfffffff0U)); + assert(state.holdRemaining(0xfffffff0U) == 604800000); + assert(!state.stayAwake(10081, 100) && state.awakeDuration == 604800000); + assert(state.sleepMilliseconds(uint32_t(0xfffffff0U + 604799999U), epoch + 1800) == 0); + assert(state.sleepMilliseconds(uint32_t(0xfffffff0U + 604800000U), epoch + 1800) == 1800000); + assert(state.stayAwake(2, 200)); + assert(state.stayAwake(1, 300)); + assert(state.holdRemaining(400) == 59900); + assert(state.stayAwake(0, 400) && state.holdRemaining(400) == 0); + state.sample(401, 3060, 5, true); + state.setManual(false, 402); // Auto still protects the low battery, with time to send this reply. + assert(state.active() && state.holdRemaining(402) == 5000); + state.sample(403, 3084, 7, true); + assert(!state.active()); + state.setManual(true, 404); + state.sample(405, 4200, 100, true); + assert(state.active()); + state.setManual(false, 406); + assert(!state.active()); +''') + + def test_checked_record_roundtrip_all_bit_flips_lengths_and_semantics(self): + self.exercise(r''' + for (bool manual : {false, true}) { + Settings expected; expected.manual = manual; + uint8_t bytes[RecordSize]; encode(bytes, expected); + Settings actual; + assert(decode(bytes, sizeof(bytes), actual) && actual.manual == manual); + for (size_t n = 0; n < RecordSize; ++n) { + for (unsigned bit = 0; bit < 8; ++bit) { + uint8_t corrupted[RecordSize]; memcpy(corrupted, bytes, sizeof(bytes)); + corrupted[n] ^= uint8_t(1U << bit); + actual.manual = true; + assert(!decode(corrupted, sizeof(corrupted), actual) && !actual.manual); + } + assert(!decode(bytes, n, actual) && !actual.manual); + } + std::vector oversized(bytes, bytes + RecordSize); oversized.push_back(0); + assert(!decode(oversized.data(), oversized.size(), actual)); + assert(!decode(nullptr, RecordSize, actual)); + for (size_t n : {size_t(3), size_t(4), size_t(5)}) { + uint8_t invalid[RecordSize]; memcpy(invalid, bytes, sizeof(bytes)); invalid[n] = 2; + const uint16_t crc = checksum(invalid, 6); + invalid[6] = uint8_t(crc); invalid[7] = uint8_t(crc >> 8); + assert(!decode(invalid, sizeof(invalid), actual) && !actual.manual); + } + } +''') + + def runtime_prefix(self): + header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii") + def api_block(marker): + start = header.index(marker) + return header[start:header.index("#endif", start) + len("#endif")] + private = api_block("#if MESH_ENABLE_EMERGENCY_POWER\n FILESYSTEM* _emergency_power_fs") + public = api_block("#if MESH_ENABLE_EMERGENCY_POWER\n uint32_t emergencySleepMilliseconds") + implementation = (ROOT / "src/helpers/CommonCLI_Emergency.cpp").read_text(encoding="ascii") + implementation = re.sub(r"^#include[^\n]*\n", "", implementation, flags=re.MULTILINE) + battery = (ROOT / "src/helpers/CommonCLI_Battery.cpp").read_text(encoding="ascii") + battery_methods = "\n".join(extract_braced(battery, signature) for signature in ( + "uint16_t CommonCLI::batteryEmptyMv(", "uint16_t CommonCLI::batteryFullMv(", + "uint8_t CommonCLI::batteryPercent(")) + checked = (ROOT / "src/helpers/CheckedSettingsFile.h").read_text(encoding="ascii") + checked_write = extract_braced(checked, "MESH_SETTINGS_FILE_SHARED inline AtomicFileWriter::Outcome writeCheckedSettingsFile(") + prefix = POLICY_PREFIX + r''' +#include +#include +#include +#include +static uint32_t nowMs; +static uint32_t millis() { return nowMs; } +struct FILESYSTEM { + std::map> files; + bool shortWrite = false, failCommit = false, badRead = false, corruptCommit = false; + bool recoverOk = true; + unsigned commits = 0, writes = 0; +}; +namespace mesh { +class AtomicFileWriter { + FILESYSTEM* fs; + std::string path; + std::vector bytes; +public: + enum class Outcome { NotCommitted, Committed, Indeterminate }; + AtomicFileWriter(FILESYSTEM* f, const char* p) : fs(f), path(p) {} + explicit operator bool() const { return fs != nullptr; } + size_t write(const uint8_t* p, size_t n) { + ++fs->writes; + const size_t written = fs->shortWrite ? n - 1 : n; + bytes.assign(p, p + written); return written; + } + bool commit() { + ++fs->commits; + if (fs->failCommit) return false; + fs->files[path] = bytes; + if (fs->corruptCommit) fs->files[path][0] ^= 1; + return true; + } + Outcome outcome() const { return Outcome::NotCommitted; } +}; +class ContactFileTransaction : public AtomicFileWriter { +public: + using AtomicFileWriter::AtomicFileWriter; + static bool recover(FILESYSTEM* fs, const char*) { return fs && fs->recoverOk; } +}; +namespace storage { +static bool readFixedSettingsFile(FILESYSTEM* fs, const char* path, uint8_t* bytes, size_t n) { + if (!fs || fs->badRead) return false; + auto found = fs->files.find(path); + if (found == fs->files.end() || found->second.size() != n) return false; + memcpy(bytes, found->second.data(), n); return true; +} +#define MESH_SETTINGS_FILE_SHARED +''' + checked_write + r''' +#undef MESH_SETTINGS_FILE_SHARED +}} +struct Board { + uint16_t mv = 4200; + unsigned reads = 0; + bool emergencySupported = true; + uint16_t getBattMilliVolts() { ++reads; return mv; } + bool supportsEmergencySleep() const { return emergencySupported; } +}; +struct Clock { + uint32_t time = epoch + 1800; + uint32_t getCurrentTime() { return time; } +}; +class CommonCLI { +public: + Board* _board; + Clock* _rtc; + mesh::batteryforward::Settings _battery_forward_settings; +''' + private + "\n" + public + r''' + uint16_t batteryEmptyMv() const; + uint16_t batteryFullMv() const; + uint8_t batteryPercent(uint16_t) const; + CommonCLI(Board& board, Clock& rtc) : _board(&board), _rtc(&rtc) {} +}; +''' + battery_methods + "\n" + implementation + return prefix + + def test_actual_cli_sampling_independent_status_and_runtime_lease(self): + self.exercise(r''' + Board board; Clock clock; FILESYSTEM fs; + CommonCLI cli(board, clock); + nowMs = 0; board.mv = 3060; + cli.loadEmergencyPower(&fs); + assert(cli._battery_forward_settings.enabled == 0); + assert(cli.emergencySleepMilliseconds() == 0 && cli.emergencyActive() && board.reads == 1); + nowMs = 5000; + assert(cli.emergencySleepMilliseconds() == 1800000 && board.reads == 1); + char reply[160] = {}; + cli.setEmergencyRadioSleeping(false); + assert(cli.handleEmergencyPowerCommand("get power.emergency", reply)); + assert(strstr(reply, "mode=auto active=on battery=5% clock=rtc window=sleep radio=awake")); + assert(strstr(reply, "wake=1800s hold=0s")); + cli.setEmergencyRadioSleeping(true); + assert(cli.handleEmergencyPowerCommand("get power.emergency \t", reply)); + assert(strstr(reply, "radio=sleep")); + nowMs = 5001; + assert(cli.handleEmergencyPowerCommand("get power.emergency", reply)); + assert(cli._emergency_power.holdRemaining(nowMs) == 0); // Polls do not postpone sleep. + assert(cli.handleEmergencyPowerCommand("set power.awake 1 \t", reply)); + assert(!strcmp(reply, "OK - awake for 1 minutes") && fs.writes == 0); + assert(cli.emergencySleepMilliseconds() == 0); + nowMs = 65000; + assert(cli.emergencySleepMilliseconds() == 0); + nowMs = 65001; + assert(cli.emergencySleepMilliseconds() == 1800000); + board.mv = 0; nowMs = 95001; + assert(cli.emergencySleepMilliseconds() == 1800000 && cli.emergencyActive()); + clock.time = 0; + assert(cli.handleEmergencyPowerCommand("get power.emergency", reply)); + assert(strstr(reply, "battery=unknown clock=uptime")); + board.mv = 3084; nowMs += 30000; + assert(cli.emergencySleepMilliseconds() == 0 && !cli.emergencyActive()); + cli._battery_forward_settings.emptyMv = 6000; + cli._battery_forward_settings.fullMv = 8400; + board.mv = 6120; nowMs += 30000; + assert(cli.emergencySleepMilliseconds() == 0 && cli.emergencyActive()); + assert(cli._emergency_power.percentage == 5); + assert(cli.handleEmergencyPowerCommand("set power.awake 10080", reply)); + assert(cli._emergency_power.awakeDuration == 604800000); + assert(cli.handleEmergencyPowerCommand("set power.awake 0", reply)); + assert(!cli._emergency_power.awakeDuration); +''', self.runtime_prefix(), flags=("-DESP32_PLATFORM", "-DMESH_ENABLE_EMERGENCY_POWER=1")) + + def test_actual_cli_checked_save_reboot_fallback_and_failure_keeps_candidate_inactive(self): + body = r''' + Board board; Clock clock; FILESYSTEM fs; + nowMs = 100; + CommonCLI cli(board, clock); cli.loadEmergencyPower(&fs); + char reply[160]; + assert(cli.handleEmergencyPowerCommand("set power.emergency on", reply)); + assert(!strcmp(reply, "OK - emergency on") && cli.emergencyActive()); + assert(fs.commits == 1 && cli._emergency_power.settings.manual); + nowMs += 5000; + assert(cli.emergencySleepMilliseconds() == 1800000); + assert(cli.handleEmergencyPowerCommand("set power.emergency on", reply)); + assert(fs.commits == 1 && cli._emergency_power.holdRemaining(nowMs) == 0); + CommonCLI reboot(board, clock); reboot.loadEmergencyPower(&fs); + assert(reboot.emergencyActive() && reboot.emergencySleepMilliseconds() == 0); + assert(!reboot._emergency_power.awakeDuration); // Awake lease is deliberately runtime-only. + fs.files["/power_emergency.bak"] = fs.files["/power_emergency"]; + fs.files["/power_emergency"][0] ^= 1; + CommonCLI backup(board, clock); backup.loadEmergencyPower(&fs); + assert(backup.emergencyActive()); + fs.files["/power_emergency.bak"][0] ^= 1; + CommonCLI corrupt(board, clock); corrupt.loadEmergencyPower(&fs); + assert(!corrupt.emergencyActive()); + fs.files.clear(); + for (unsigned failure = 0; failure < 4; ++failure) { + fs.shortWrite = failure == 0; fs.failCommit = failure == 1; + fs.badRead = failure == 2; fs.corruptCommit = failure == 3; + assert(cli.handleEmergencyPowerCommand("set power.emergency auto", reply)); + assert(strstr(reply, "save unconfirmed") && cli._emergency_power.settings.manual); + assert(cli._emergency_power.holdRemaining(nowMs) == 0); + } + fs.shortWrite = fs.failCommit = fs.badRead = fs.corruptCommit = false; + assert(cli.handleEmergencyPowerCommand("set power.emergency auto\t", reply)); + assert(!strcmp(reply, "OK - emergency auto") && !cli.emergencyActive()); + CommonCLI fresh(board, clock); fresh.loadEmergencyPower(&fs); + assert(!fresh._emergency_power.settings.manual); + unsigned writes = fs.writes; + board.emergencySupported = false; + assert(fresh.handleEmergencyPowerCommand("set power.emergency on", reply)); + assert(strstr(reply, "unsupported") && !fresh.emergencyActive() && fs.writes == writes); + board.emergencySupported = true; + for (const char* command : {"set power.emergency off", "set power.emergency", "set power.emergency on extra", + "get power.emergency x", "set power.awake", "set power.awake -1", "set power.awake +1", + "set power.awake 10081", "set power.awake 99999999999999", "set power.awake 1.0", + "set power.awake 1 2", "set power.awake 1min", "set power.awake \n"}) { + assert(fresh.handleEmergencyPowerCommand(command, reply)); + assert(!strncmp(reply, "Error:", 6) && fs.writes == writes && !fresh.emergencyActive()); + } + for (const char* command : {"get power.emergencyx", "set power.emergencyx on", "set power.awakex 1", + "get power.awake", "set unrelated on", ""}) { + strcpy(reply, "unchanged"); + assert(!fresh.handleEmergencyPowerCommand(command, reply) && !strcmp(reply, "unchanged")); + } + assert(!fresh.handleEmergencyPowerCommand(nullptr, reply)); + assert(!fresh.handleEmergencyPowerCommand("get power.emergency", nullptr)); +''' + for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM", "STM32_PLATFORM"): + with self.subTest(platform=platform): + self.exercise(body, self.runtime_prefix(), flags=("-D" + platform, + "-DMESH_ENABLE_EMERGENCY_POWER=1")) + + def test_disabled_actual_declarations_and_source_retain_no_state_or_io(self): + prefix = self.runtime_prefix() + # A missing implementation at link time would reveal a retained runtime + # call; the public disabled API is entirely inline and stateless. + self.exercise(r''' + Board board; Clock clock; FILESYSTEM fs; + CommonCLI cli(board, clock); + cli.setEmergencyRadioSleeping(true); + assert(!cli.emergencyActive() && cli.emergencySleepMilliseconds() == 0); + assert(board.reads == 0 && fs.writes == 0); + static_assert(sizeof(CommonCLI) == sizeof(Board*) + sizeof(Clock*) + 8, + "disabled emergency policy must not retain its scheduler or filesystem"); +''', prefix, flags=("-DSTM32_PLATFORM", "-DMESH_ENABLE_EMERGENCY_POWER=0")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_emergency_power_ota.py b/test/test_emergency_power_ota.py new file mode 100644 index 000000000..a2f5f0bdb --- /dev/null +++ b/test/test_emergency_power_ota.py @@ -0,0 +1,155 @@ +"""Exercise the real role guards and OTA work hold without allocating OTA storage.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + + +ROOT = Path(__file__).resolve().parents[1] + + +class EmergencyOtaTests(unittest.TestCase): + def test_actual_three_role_guards_active_transfers_and_bounded_server_hold(self): + enum_source = (ROOT / "src/helpers/ota/OtaManager.h").read_text(encoding="ascii") + enum = extract_braced(enum_source, "enum FetchState : uint8_t") + ";" + prefix = r''' +#include +#include +#include +#include +static uint32_t nowMs; +static uint32_t millis() { return nowMs; } +namespace mesh { namespace ota { +struct Manager { + @ENUM@ + FetchState state = IDLE; + uint8_t serveJobs = 0, manifestJobs = 0; + uint32_t retryMs = 5000; + FetchState fetchState() const { return state; } + uint8_t pendingServeJobs() const { return serveJobs; } + uint8_t pendingManifestJobs() const { return manifestJobs; } + uint32_t fetchRetryTimeoutMs() const { return retryMs; } +}; +struct Context { + Manager manager; + bool sdFetching = false; + bool serving = true; + uint32_t stagedSize = 100000; + bool sdCacheFetching() const { return sdFetching; } +}; +static Context* active = nullptr; +static Context* ota_context_if_active() { return active; } +Context& ota_ctx() = delete; // Merely checking power must never allocate a dynamic context. +}} +struct CLI { + bool radio = false, gpio = false; + bool hasPendingRadioSettings() const { return radio; } + bool hasActiveUserGpioTimer() const { return gpio; } +}; +struct Peer { + bool waiting = false; + bool awaitingPeer() const { return waiting; } +}; +'''.replace("@ENUM@", enum) + for index, path in enumerate(("examples/simple_repeater/MyMesh.h", + "examples/simple_room_server/MyMesh.h", + "examples/simple_sensor/SensorMesh.h")): + method = extract_braced((ROOT / path).read_text(encoding="ascii"), + "bool emergencyPowerBusy() const") + prefix += r''' +struct Role@INDEX@ { + mesh::power::EmergencyOtaWork _emergency_ota_work; + CLI _cli; + Peer direct_ota_admin; + bool apply = false, saved_radio_apply_pending = false, temp_radio_applied = false; + uint32_t dirty_contacts_expiry = 0, _ota_update_at = 0; + bool hasPendingOtaApply() const { return apply; } + @METHOD@ +}; +'''.replace("@INDEX@", str(index)).replace("@METHOD@", method) + prefix += r''' +template void exercise() { + using namespace mesh::ota; + Role role; + active = nullptr; nowMs = 0; + assert(!role.emergencyPowerBusy()); + role.apply = true; assert(role.emergencyPowerBusy()); role.apply = false; + role._cli.radio = true; assert(role.emergencyPowerBusy()); role._cli.radio = false; + role._cli.gpio = true; assert(role.emergencyPowerBusy()); role._cli.gpio = false; + role.dirty_contacts_expiry = 1; assert(role.emergencyPowerBusy()); role.dirty_contacts_expiry = 0; + role.saved_radio_apply_pending = true; + assert(role.emergencyPowerBusy()); + role.temp_radio_applied = true; assert(!role.emergencyPowerBusy()); + role.saved_radio_apply_pending = role.temp_radio_applied = false; +#ifdef ENABLE_OTA + Context context; active = &context; + for (auto state : {Manager::IDLE, Manager::COMPLETE, Manager::FAILED, Manager::PAUSED}) { + context.manager.state = state; + assert(!role.emergencyPowerBusy()); // Existing images, idle serving and paused sessions are not activity. + } + for (auto state : {Manager::WANT_MANIFEST, Manager::WANT_LEAVES, + Manager::VERIFYING_STAGED, Manager::FETCHING}) { + context.manager.state = state; + assert(role.emergencyPowerBusy()); + context.manager.state = Manager::COMPLETE; + assert(!role.emergencyPowerBusy()); + } +#ifdef OTA_SD_STORE + context.sdFetching = true; + assert(role.emergencyPowerBusy()); // Let serviceSdCache finalize/reset its completed receive owner. + context.sdFetching = false; assert(!role.emergencyPowerBusy()); +#endif + context.manager.serveJobs = 1; + assert(role.emergencyPowerBusy()); // Activity at millis()==0 must arm a real hold. + context.manager.serveJobs = 0; nowMs = 29999; + assert(role.emergencyPowerBusy()); + nowMs = 30000; assert(!role.emergencyPowerBusy()); + nowMs = 0; assert(!role.emergencyPowerBusy()); // Retired hold cannot resurrect after wrap. + context.manager.manifestJobs = 1; context.manager.retryMs = 60000; + nowMs = 0xfffffff0U; + assert(role.emergencyPowerBusy()); + nowMs += 100; + assert(role.emergencyPowerBusy()); // Actual queued manifest work renews the hold. + context.manager.manifestJobs = 0; + nowMs += 59999; assert(role.emergencyPowerBusy()); + ++nowMs; assert(!role.emergencyPowerBusy()); + context.manager.serveJobs = 1; + assert(role.emergencyPowerBusy()); + context.manager.serveJobs = 0; active = nullptr; + nowMs += 60000; assert(!role.emergencyPowerBusy()); // Released dynamic context is never reacquired. +#endif +} +int main() { + exercise(); exercise(); exercise(); + mesh::ota::active = nullptr; + Role0 repeater; Role1 room; + repeater._ota_update_at = 1; room._ota_update_at = 1; + assert(repeater.emergencyPowerBusy() && room.emergencyPowerBusy()); + repeater._ota_update_at = 0; +#if MESH_ENABLE_DIRECT_OTA_ADMIN + repeater.direct_ota_admin.waiting = true; assert(repeater.emergencyPowerBusy()); +#endif +} +''' + for flags in (("-DENABLE_OTA", "-DOTA_SD_STORE", "-DMESH_ENABLE_DIRECT_OTA_ADMIN=1"), + ("-DENABLE_OTA", "-DMESH_ENABLE_DIRECT_OTA_ADMIN=0"), + ("-DMESH_ENABLE_DIRECT_OTA_ADMIN=0",)): + with self.subTest(flags=flags), tempfile.TemporaryDirectory(prefix="emergency-ota-") as folder: + source, binary = Path(folder) / "main.cpp", Path(folder) / "check" + source.write_text(prefix, encoding="ascii") + result = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-function", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-omit-frame-pointer", "-fno-pie", "-no-pie", "-DMESH_ENABLE_EMERGENCY_POWER=1", + *flags, "-I", str(ROOT / "src"), str(source), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_emergency_power_runtime.py b/test/test_emergency_power_runtime.py new file mode 100644 index 000000000..81fe9f27b --- /dev/null +++ b/test/test_emergency_power_runtime.py @@ -0,0 +1,360 @@ +"""Run actual emergency RF/wireless ownership and restoration with fake hardware.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = r''' +#include +#include +#include +#include +#include +#include +using namespace mesh::wireless; +static std::vector events; +void delay(uint32_t milliseconds) { + events.push_back("backoff:" + std::to_string(milliseconds)); +} +static std::string serviceName(uint8_t bit) { + return bit == WiFi ? "wifi" : bit == EspNow ? "espnow" : "ble"; +} +struct Wireless : Backend { + uint8_t active = All, supported = All, sessions = Independent; + uint8_t pendingOn = 0, pendingOff = 0, failedOn = 0, failedOff = 0; + uint8_t available() const override { return supported; } + uint8_t enabled() const override { return active; } + uint8_t clients() const override { return sessions; } + Result set(uint8_t bit, bool on) override { + events.push_back(serviceName(bit) + (on ? ":on" : ":off")); + if ((on ? pendingOn : pendingOff) & bit) return Result::Pending; + if ((on ? failedOn : failedOff) & bit) return Result::Failed; + if (on) active |= bit; else active &= ~bit; + return Result::Done; + } +}; +struct Mesh { + bool available = true, inFlight = false; + unsigned queuedFuturePackets = 20; + bool hasOutbound() const { return inFlight; } + bool isRadioAvailable() const { return available; } + void setRadioAvailable(bool value) { + events.push_back(value ? "mesh:on" : "mesh:off"); available = value; + } +}; +struct Radio { + bool morseTx = false, morseRx = false, failSleep = false, failWake = false; + bool recoveryOk = true; + bool isMorseTxActive() const { return morseTx; } + bool isMorseRxActive() const { return morseRx; } + bool setTrackerSleep(bool sleeping) { + events.push_back(sleeping ? "radio:sleep" : "radio:wake"); + return !(sleeping ? failSleep : failWake); + } + bool recoverRadio(bool hard) { + assert(hard); events.push_back("radio:recover"); return recoveryOk; + } +}; +struct Board { + bool supported = true, ota = false, radioTest = false, sleepOk = true; + unsigned sleeps = 0; + uint32_t lastBudget = 0; + bool supportsEmergencySleep() const { return supported; } + bool isOTAUpdateRunning() const { return ota; } + bool isRadioTestActive() const { return radioTest; } + bool emergencySleepMilliseconds(uint32_t budget) { + events.push_back("cpu:" + std::to_string(budget)); + ++sleeps; lastBudget = budget; return sleepOk; + } +}; +struct CLI { + uint32_t budget = 2700000; + bool radioSleeping = false; + uint32_t emergencySleepMilliseconds() const { return budget; } + void setEmergencyRadioSleeping(bool value) { + events.push_back(value ? "status:sleep" : "status:awake"); radioSleeping = value; + } +}; +struct Fixture { + Wireless wireless; + Mesh mesh; + Radio radio; + Board board; + CLI cli; + mesh::power::EmergencyPowerRuntime runtime; + Fixture() { control().begin(wireless); } + bool service(bool entryBusy = false, bool workRequired = false) { + return runtime.service(mesh, radio, board, cli, entryBusy, workRequired); + } +}; +static void command(Control& c, const char* text, uint32_t now = 100) { + char reply[160] = {}; + assert(c.handle(text, reply, sizeof(reply), now, Independent)); + assert(strncmp(reply, "OK", 2) == 0); + c.service(now + 250); + assert(!c.pending()); +} +static void refused(Control& c, const char* text) { + char reply[160] = {}; + assert(c.handle(text, reply, sizeof(reply), 1000, Independent)); + assert(strstr(reply, "emergency sleep") && !c.pending()); +} +int main(int argc, char** argv) { + assert(argc == 2); + const int scenario = std::atoi(argv[1]); + if (scenario == 0) { + Fixture f; + assert(f.service()); + assert((events == std::vector{"wifi:off", "espnow:off", "ble:off", + "radio:sleep", "mesh:off", "status:sleep", "cpu:10000"})); + assert(!f.mesh.available && f.cli.radioSleeping && f.wireless.active == 0); + events.clear(); f.cli.budget = 417; + assert(f.service(true)); // Reply-drain/partial-input guard does not steal already-suspended RF. + assert((events == std::vector{"cpu:417"})); + events.clear(); f.cli.budget = 0; + assert(!f.service()); + assert((events == std::vector{"radio:wake", "mesh:on", "status:awake", + "espnow:on", "wifi:on", "ble:on"})); + assert(f.mesh.available && !f.cli.radioSleeping && f.wireless.active == All); + events.clear(); assert(!f.service() && events.empty()); + } else if (scenario == 1) { + Fixture f; + f.board.supported = false; assert(!f.service()); + f.board.supported = true; + assert(!f.service(true)); + assert(!f.service(false, true)); + f.board.ota = true; assert(!f.service()); f.board.ota = false; + f.board.radioTest = true; assert(!f.service()); f.board.radioTest = false; + f.mesh.inFlight = true; assert(!f.service()); f.mesh.inFlight = false; + f.radio.morseTx = true; assert(!f.service()); f.radio.morseTx = false; + f.radio.morseRx = true; assert(!f.service()); f.radio.morseRx = false; + assert(events.empty() && f.mesh.available && !f.cli.radioSleeping && !f.board.sleeps); + // Queued future packets are intentionally not consulted: normal mesh work + // stays suspended, so an off-hour backlog cannot prevent battery protection. + assert(f.mesh.queuedFuturePackets == 20 && f.service()); + assert(f.board.sleeps == 1 && !f.mesh.available); + } else if (scenario == 2) { + Fixture f; + f.radio.failSleep = true; + assert(!f.service() && f.mesh.available && !f.cli.radioSleeping && !f.board.sleeps); + assert(f.wireless.active == 0); + f.radio.failSleep = false; events.clear(); + assert(f.service() && !f.mesh.available && f.board.sleeps == 1); + f.radio.failWake = true; f.radio.recoveryOk = false; + f.cli.budget = 0; events.clear(); + assert(!f.service()); + assert((events == std::vector{"radio:wake", "radio:recover", "mesh:on", + "status:awake", "espnow:on", "wifi:on", "ble:on"})); + // Return ownership to Dispatcher even after hardware recovery reports an + // error, so its ordinary watchdog is not permanently disabled. + assert(f.mesh.available && !f.cli.radioSleeping); + } else if (scenario == 3) { + Fixture f; + for (unsigned cause = 0; cause != 5; ++cause) { + assert(f.service() && !f.mesh.available); + const unsigned sleeps = f.board.sleeps; + f.board.ota = cause == 0; + f.board.radioTest = cause == 1; + f.radio.morseTx = cause == 2; + f.radio.morseRx = cause == 3; + const bool work = cause == 4; + assert(!f.service(false, work)); + assert(f.mesh.available && !f.cli.radioSleeping && f.wireless.active == All); + assert(f.board.sleeps == sleeps); + assert(!f.service(false, work)); + assert(f.board.sleeps == sleeps); // Active work also prevents immediate re-entry. + f.board.ota = f.board.radioTest = f.radio.morseTx = f.radio.morseRx = false; + } + } else if (scenario == 4) { + Wireless b; Control c; c.begin(b); + assert(c.suspendForEmergency()); + assert(b.active == 0 && c.blocked(All)); + assert(!c.allowService(Bluetooth)); + assert(!c.allowService(WiFi | EspNow)); + assert(!c.allowWiFiForOTA()); + assert(c.blocked(All) && !c.masterOff()); + for (const char* text : {"set wifi on", "set espnow on", "set 2.4ghz on all", + "set 2.4ghz off force"}) refused(c, text); + char reply[160]; + assert(c.handle("get 2.4ghz", reply, sizeof(reply), 1000, Independent)); + assert(strstr(reply, "wifi=off") && strstr(reply, "bluetooth=off")); + assert(c.resumeAfterEmergency() && b.active == All && !c.blocked(All)); + assert(c.allowService(Bluetooth) && c.allowWiFiForOTA()); + } else if (scenario == 5) { + for (uint8_t mask = 0; mask <= All; ++mask) { + Wireless b; b.active = mask; + Control c; c.begin(b); + command(c, "set 2.4ghz off"); command(c, "set 2.4ghz on", 500); + assert(b.active == mask && !c.masterOff()); + for (uint8_t bit : {uint8_t(WiFi), uint8_t(Bluetooth), uint8_t(EspNow)}) + assert(c.blocked(bit) == !(mask & bit)); + assert(c.suspendForEmergency() && b.active == 0 && c.blocked(All)); + assert(c.resumeAfterEmergency() && b.active == mask && !c.masterOff()); + for (uint8_t bit : {uint8_t(WiFi), uint8_t(Bluetooth), uint8_t(EspNow)}) + assert(c.blocked(bit) == !(mask & bit)); + const size_t calls = events.size(); + assert(c.resumeAfterEmergency() && events.size() == calls); + } + Wireless b; b.active = WiFi | Bluetooth; + Control c; c.begin(b); + command(c, "set 2.4ghz off"); + assert(c.masterOff() && b.active == 0); + const size_t calls = events.size(); + assert(c.suspendForEmergency() && c.resumeAfterEmergency()); + assert(c.masterOff() && c.blocked(All) && b.active == 0 && events.size() == calls); + char reply[160]; + assert(c.handle("set wifi on", reply, sizeof(reply), 500, Independent)); + assert(strstr(reply, "2.4ghz is off")); + command(c, "set 2.4ghz on", 1000); + assert(b.active == (WiFi | Bluetooth)); // Original ordinary restore snapshot survived. + } else if (scenario == 6) { + Fixture f; + f.wireless.pendingOff = WiFi; + assert(!f.service() && f.wireless.active == All && !f.board.sleeps && f.mesh.available); + refused(control(), "set wifi on"); + assert(!control().allowService(WiFi) && !control().allowWiFiForOTA()); + f.wireless.pendingOff = 0; + f.wireless.failedOff = EspNow; + assert(!f.service() && f.wireless.active == (EspNow | Bluetooth)); + assert(f.mesh.available && !f.cli.radioSleeping && !f.board.sleeps); + // The hour/lease can change before RF ever went to sleep. Restore services + // already stopped by the incomplete suspend, then allow a later retry. + f.wireless.failedOff = 0; f.cli.budget = 0; + assert(!f.service() && f.wireless.active == All); + f.cli.budget = 2700000; assert(f.service()); + f.cli.budget = 0; f.wireless.pendingOn = EspNow; + assert(!f.service() && f.mesh.available && f.wireless.active == 0); + refused(control(), "set 2.4ghz on all"); + assert(!control().allowService(Bluetooth)); + f.wireless.pendingOn = 0; f.wireless.failedOn = WiFi; + assert(!f.service() && f.wireless.active == EspNow); + refused(control(), "set wifi on"); + f.wireless.failedOn = 0; + assert(!f.service() && f.wireless.active == All && !control().blocked(All)); + } else if (scenario == 7) { + Fixture f; + char reply[160]; + assert(control().handle("set wifi off", reply, sizeof(reply), 100, Independent)); + assert(control().pending()); + assert(!f.service() && events.empty() && f.mesh.available); + control().service(350); + assert(f.wireless.active == (Bluetooth | EspNow) && !control().pending()); + assert(f.service() && f.wireless.active == 0); + f.cli.budget = 0; + assert(!f.service() && f.wireless.active == (Bluetooth | EspNow)); + assert(control().blocked(WiFi) && !control().blocked(Bluetooth | EspNow)); + } else if (scenario == 8) { + Fixture f; + f.mesh.available = false; // Sensor Tracker already owns this radio's sleep. + assert(f.service() && f.cli.radioSleeping && f.board.sleeps == 1); + for (const auto& event : events) + assert(event != "radio:sleep" && event != "mesh:off"); + events.clear(); f.cli.budget = 0; + assert(!f.service() && !f.mesh.available); + for (const auto& event : events) + assert(event != "radio:wake" && event != "radio:recover" && event != "mesh:on"); + assert(f.wireless.active == All); + } else if (scenario == 9) { + Fixture f; + f.board.sleepOk = false; + assert(f.service() && !f.mesh.available && f.cli.radioSleeping); + assert(events.back() == "backoff:10"); + events.clear(); + assert(f.service() && !f.mesh.available && f.cli.radioSleeping); + assert((events == std::vector{"cpu:10000", "backoff:10"})); + // A later successful attempt keeps the existing suspension and skips backoff. + events.clear(); f.board.sleepOk = true; + assert(f.service()); + assert((events == std::vector{"cpu:10000"})); + // An awake lease still wakes RF after a failed CPU wait. + events.clear(); f.cli.budget = 0; + assert(!f.service() && f.mesh.available && !f.cli.radioSleeping); + assert(f.wireless.active == All); + } else assert(false); +} +''' + + +class EmergencyPowerRuntimeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.folder = tempfile.TemporaryDirectory(prefix="emergency-runtime-") + work = Path(cls.folder.name) + source, cls.binary = work / "main.cpp", work / "check" + (work / "Arduino.h").write_text( + "#pragma once\n#include \nvoid delay(uint32_t);\n", encoding="ascii") + source.write_text(HARNESS, encoding="ascii") + result = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-omit-frame-pointer", + "-fno-pie", "-no-pie", "-DMESH_ENABLE_EMERGENCY_POWER=1", "-I", str(work), + "-I", str(ROOT / "src"), + str(source), "-o", str(cls.binary)], capture_output=True, text=True, timeout=60) + if result.returncode: + cls.folder.cleanup() + raise AssertionError(result.stdout + result.stderr) + + @classmethod + def tearDownClass(cls): + cls.folder.cleanup() + + def scenario(self, number): + result = subprocess.run([str(self.binary), str(number)], capture_output=True, + text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_successful_sleep_wake_order_repeat_ownership_and_ten_second_budget(self): + self.scenario(0) + + def test_live_work_prevents_entry_but_future_queue_does_not(self): + self.scenario(1) + + def test_radio_refusal_and_failed_wake_return_dispatcher_watchdog_ownership(self): + self.scenario(2) + + def test_critical_work_started_while_suspended_wakes_before_further_cpu_sleep(self): + self.scenario(3) + + def test_all_wireless_start_paths_are_refused_during_emergency_ownership(self): + self.scenario(4) + + def test_wireless_subset_blocked_mask_master_off_and_saved_snapshot_survive(self): + self.scenario(5) + + def test_async_pending_errors_and_cancelled_partial_suspend_restore_correctly(self): + self.scenario(6) + + def test_existing_wireless_request_finishes_before_emergency_ownership(self): + self.scenario(7) + + def test_sensor_tracker_radio_ownership_is_not_stolen(self): + self.scenario(8) + + def test_cpu_sleep_refusal_yields_without_resuming_normal_rf_work(self): + self.scenario(9) + + def test_room_maintenance_runs_before_emergency_early_return(self): + source = (ROOT / "examples/simple_room_server/main.cpp").read_text() + loop = source[source.index("void loop() {"):] + gate = loop.index("emergency_power.service(") + for maintenance in ("mesh::power::serviceRegulatorSettings();", + "mesh::solar::service();", "board.loop();"): + self.assertEqual(loop.count(maintenance), 1, maintenance) + self.assertLess(loop.index(maintenance), gate, maintenance) + + def test_esp_uart_console_restore_stays_live_during_emergency_sleep(self): + for role in ("simple_repeater", "simple_room_server", "simple_sensor"): + source = (ROOT / f"examples/{role}/main.cpp").read_text() + loop = source[source.index("void loop() {"):] + gate = loop.index("emergency_power.service(") + service = "board.servicePowerTestControls();" + self.assertLess(loop.index(service), gate, role) + self.assertIn("#if !MESH_ENABLE_EMERGENCY_POWER\n " + service + "\n#endif", + loop[gate:], role) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_emergency_sleep_backends.py b/test/test_emergency_sleep_backends.py new file mode 100644 index 000000000..3d065f62e --- /dev/null +++ b/test/test_emergency_sleep_backends.py @@ -0,0 +1,268 @@ +"""Execute resumable emergency CPU sleeps with native RTOS/SDK boundaries.""" + +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + + +ROOT = Path(__file__).resolve().parents[1] +SANITIZERS = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + +HARNESS = r''' +#include +#include +#include +#include +#include +static std::vector events; +static bool radio_test = false, interrupt_context = false, wireless_ready = true; +static unsigned primask = 0, basepri = 0, faultmask = 0; +static unsigned __get_IPSR() { return interrupt_context; } +static unsigned __get_PRIMASK() { return primask; } +static unsigned __get_BASEPRI() { return basepri; } +static unsigned __get_FAULTMASK() { return faultmask; } +static bool xPortInIsrContext() { return interrupt_context; } +static constexpr int taskSCHEDULER_RUNNING = 2; +static int scheduler = taskSCHEDULER_RUNNING; +static int xTaskGetSchedulerState() { return scheduler; } +static struct { unsigned __cr_flag = 0; } nrf_nvic_state; +using TickType_t = uint32_t; +static uint32_t tick_ms = 1, delayed_ticks = 0; +#define pdMS_TO_TICKS(ms) ((ms) / tick_ms) +static void vTaskDelay(TickType_t ticks) { + assert(ticks != 0); + delayed_ticks = ticks; + events.push_back("delay"); +} +static unsigned nrf_feeds = 0; +static constexpr uint32_t WDT_RR_RR_Reload = 0x6e524635; +struct ReloadRegister { + uint32_t value = 0; + void operator=(uint32_t reload) { + assert(reload == WDT_RR_RR_Reload); + value = reload; ++nrf_feeds; events.push_back("nrf_feed"); + } +}; +static struct { + uint32_t RUNSTATUS = 0, CRV = 60 * 32768UL, RREN = 1; + ReloadRegister RR[8]{}; +} nrf_watchdog; +#define NRF_WDT (&nrf_watchdog) +@NRF_RELOAD@ + +using esp_err_t = int; +static constexpr int ESP_OK = 0, ESP_ERR_NOT_FOUND = 1, ESP_ERR_INVALID_STATE = 2; +static constexpr int ESP_SLEEP_WAKEUP_TIMER = 3; +static esp_err_t task_watchdog_status = ESP_ERR_INVALID_STATE; +static unsigned task_watchdog_feeds = 0, fail_watchdog_feed = 0; +static esp_err_t esp_task_wdt_status(void*) { return task_watchdog_status; } +static esp_err_t esp_task_wdt_reset() { + events.push_back("task_feed"); + return ++task_watchdog_feeds == fail_watchdog_feed ? 9 : ESP_OK; +} +static uint64_t timer_us = 0, last_armed_us = 0; +static unsigned timer_calls = 0, sleep_calls = 0, clear_calls = 0; +static uint32_t other_wake_sources = 0x71; +static int timer_error = 0, sleep_error = 0, clear_error = 0; +static esp_err_t esp_sleep_enable_timer_wakeup(uint64_t value) { + events.push_back("timer"); ++timer_calls; + if (timer_error) return timer_error; + timer_us = last_armed_us = value; + return ESP_OK; +} +static esp_err_t esp_light_sleep_start() { + events.push_back("sleep"); ++sleep_calls; + return sleep_error; +} +static esp_err_t esp_sleep_disable_wakeup_source(int source) { + assert(source == ESP_SLEEP_WAKEUP_TIMER); // Never disable GPIO/ALL sources. + events.push_back("clear_timer"); ++clear_calls; + if (clear_error) return clear_error; + timer_us = 0; + return ESP_OK; +} +namespace mesh { namespace esp32 { +static bool manualLightSleepReady() { return wireless_ready; } +} } +struct Base { + @DEFAULT_SUPPORT@ + @DEFAULT_SLEEP@ +}; +struct NRF52Board : Base { + bool watchdog_disable_reset_pending = false; + bool isRadioTestActive() const { return radio_test; } + @NRF_SUPPORT@ + bool emergencySleepMilliseconds(uint32_t millisecs) override; +}; +struct ESP32Board : Base { + bool isRadioTestActive() const { return radio_test; } + @ESP_SUPPORT@ + bool emergencySleepMilliseconds(uint32_t millisecs) override; +}; +@NRF_SLEEP@ +@ESP_SLEEP@ +static void reset() { + events.clear(); radio_test = interrupt_context = false; wireless_ready = true; + primask = basepri = faultmask = 0; nrf_nvic_state.__cr_flag = 0; + scheduler = taskSCHEDULER_RUNNING; + tick_ms = 1; delayed_ticks = nrf_feeds = 0; + nrf_watchdog = {0, 60 * 32768UL}; + task_watchdog_status = ESP_ERR_INVALID_STATE; + task_watchdog_feeds = fail_watchdog_feed = 0; + timer_us = last_armed_us = 0; timer_calls = sleep_calls = clear_calls = 0; + timer_error = sleep_error = clear_error = 0; other_wake_sources = 0x71; +} +int main() { + Base unsupported; + assert(!unsupported.supportsEmergencySleep()); + assert(!unsupported.emergencySleepMilliseconds(1000)); + NRF52Board nrf; ESP32Board esp; + Base* supported[] = {&nrf, &esp}; + for (Base* board : supported) assert(board->supportsEmergencySleep()); + for (Base* board : supported) { + reset(); assert(!board->emergencySleepMilliseconds(0)); assert(events.empty()); + radio_test = true; + assert(!board->emergencySleepMilliseconds(100)); assert(events.empty()); + reset(); interrupt_context = true; + assert(!board->emergencySleepMilliseconds(100)); assert(events.empty()); + reset(); scheduler = 0; + assert(!board->emergencySleepMilliseconds(100)); assert(events.empty()); + } + for (unsigned* masked : {&primask, &basepri, &faultmask, &nrf_nvic_state.__cr_flag}) { + reset(); *masked = 1; + assert(!nrf.emergencySleepMilliseconds(100)); assert(events.empty()); + } + reset(); nrf.watchdog_disable_reset_pending = true; + assert(!nrf.emergencySleepMilliseconds(100)); assert(events.empty()); + nrf.watchdog_disable_reset_pending = false; + reset(); tick_ms = 100; + assert(!nrf.emergencySleepMilliseconds(1)); assert(events.empty()); + reset(); assert(nrf.emergencySleepMilliseconds(123)); + assert(delayed_ticks == 123 && !nrf_watchdog.RUNSTATUS && !nrf_feeds); + assert(events == std::vector{"delay"}); + reset(); assert(nrf.emergencySleepMilliseconds(UINT32_MAX)); + assert(delayed_ticks == 10000); // Bound the maintenance absence. + reset(); nrf_watchdog.RUNSTATUS = 1; + assert(nrf.emergencySleepMilliseconds(10000)); + assert(delayed_ticks == 10000 && nrf_feeds == 2); + assert((events == std::vector{"nrf_feed", "delay", "nrf_feed"})); + reset(); nrf_watchdog.RUNSTATUS = 1; nrf_watchdog.RREN = 0x82; + assert(nrf.emergencySleepMilliseconds(100)); assert(nrf_feeds == 4); + assert(nrf_watchdog.RR[1].value == WDT_RR_RR_Reload); + assert(nrf_watchdog.RR[7].value == WDT_RR_RR_Reload); + assert(nrf_watchdog.RR[0].value == 0 && nrf_watchdog.RR[6].value == 0); + reset(); nrf_watchdog.RUNSTATUS = 1; nrf_watchdog.CRV = 2 * 32768; + assert(nrf.emergencySleepMilliseconds(10000)); + assert(delayed_ticks == 1000 && nrf_feeds == 2); // Short inherited watchdog. + reset(); nrf_watchdog.RUNSTATUS = 1; nrf_watchdog.CRV = 32767; + assert(!nrf.emergencySleepMilliseconds(10000)); assert(events.empty()); + + reset(); wireless_ready = false; + assert(!esp.emergencySleepMilliseconds(100)); assert(events.empty()); + reset(); task_watchdog_status = 99; + assert(!esp.emergencySleepMilliseconds(100)); assert(events.empty()); + reset(); assert(esp.emergencySleepMilliseconds(UINT32_MAX)); + assert(sleep_calls == 1 && timer_calls == 1 && clear_calls == 1); + assert(last_armed_us == 10000000ULL); + assert(!timer_us && !task_watchdog_feeds && other_wake_sources == 0x71); + assert((events == std::vector{"timer", "sleep", "clear_timer"})); + reset(); task_watchdog_status = ESP_ERR_NOT_FOUND; + assert(esp.emergencySleepMilliseconds(250)); assert(!task_watchdog_feeds); + reset(); task_watchdog_status = ESP_OK; + assert(esp.emergencySleepMilliseconds(10000)); + assert(last_armed_us == 2000000ULL); + assert(task_watchdog_feeds == 2 && other_wake_sources == 0x71); + assert((events == std::vector{"task_feed", "timer", "sleep", "clear_timer", "task_feed"})); + for (int* failed : {&sleep_error, &clear_error}) { + reset(); *failed = 9; + assert(!esp.emergencySleepMilliseconds(100)); + assert(sleep_calls == 1 && clear_calls == 1 && other_wake_sources == 0x71); + } + reset(); timer_us = 555; timer_error = 9; + assert(!esp.emergencySleepMilliseconds(100)); + assert(timer_us == 555 && sleep_calls == 0 && clear_calls == 0); + reset(); task_watchdog_status = ESP_OK; fail_watchdog_feed = 1; + assert(!esp.emergencySleepMilliseconds(100)); + assert(timer_calls == 0 && sleep_calls == 0 && clear_calls == 0); + reset(); task_watchdog_status = ESP_OK; fail_watchdog_feed = 2; + assert(!esp.emergencySleepMilliseconds(100)); assert(clear_calls == 1 && !timer_us); +} +''' + + +class EmergencySleepBackendTests(unittest.TestCase): + def test_actual_platform_waits_and_cleanup(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + sources = {name: (ROOT / path).read_text() for name, path in ( + ("core", "src/MeshCore.h"), + ("nrf", "src/helpers/NRF52Board.cpp"), + ("nrf_header", "src/helpers/NRF52Board.h"), + ("esp", "src/helpers/ESP32Board.cpp"), + ("esp_header", "src/helpers/ESP32Board.h"))} + code = HARNESS + for marker, source, signature in ( + ("DEFAULT_SUPPORT", "core", "virtual bool supportsEmergencySleep() const"), + ("DEFAULT_SLEEP", "core", "virtual bool emergencySleepMilliseconds("), + ("NRF_RELOAD", "nrf", "static void reloadWatchdogChannels("), + ("NRF_SUPPORT", "nrf_header", "bool supportsEmergencySleep() const override"), + ("ESP_SUPPORT", "esp_header", "bool supportsEmergencySleep() const override"), + ("NRF_SLEEP", "nrf", "bool NRF52Board::emergencySleepMilliseconds("), + ("ESP_SLEEP", "esp", "bool ESP32Board::emergencySleepMilliseconds(")): + code = code.replace("@" + marker + "@", extract_braced(sources[source], signature)) + with tempfile.TemporaryDirectory(prefix="emergency-sleep-backends-") as directory: + work = Path(directory) + cpp, binary = work / "test.cpp", work / "test" + cpp.write_text(code) + subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-DCONFIG_ESP_TASK_WDT_TIMEOUT_S=4", *SANITIZERS, + str(cpp), "-o", str(binary)], check=True, + capture_output=True, text=True, timeout=60) + subprocess.run([str(binary)], check=True, capture_output=True, + text=True, timeout=20) + + def test_disabled_feature_has_no_new_base_virtuals(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler) + core = (ROOT / "src/MeshCore.h").read_text() + start = core.index("#if MESH_ENABLE_EMERGENCY_POWER\n", core.index("class MainBoard")) + block = core[start:core.index("#endif", start) + len("#endif")] + code = r''' +#include +#include +#include +#include +struct Board { +@API@ +}; +template struct HasApi : std::false_type {}; +template struct HasApi().supportsEmergencySleep())>> : std::true_type {}; +static_assert(HasApi::value == bool(MESH_ENABLE_EMERGENCY_POWER)); +int main() { +#if MESH_ENABLE_EMERGENCY_POWER + Board b; assert(!b.supportsEmergencySleep() && !b.emergencySleepMilliseconds(1)); +#endif +} +'''.replace("@API@", block) + with tempfile.TemporaryDirectory(prefix="emergency-sleep-feature-") as directory: + work = Path(directory) + cpp, binary = work / "test.cpp", work / "test" + cpp.write_text(code) + for enabled in (0, 1): + subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + f"-DMESH_ENABLE_EMERGENCY_POWER={enabled}", str(cpp), + "-o", str(binary)], check=True, + capture_output=True, text=True, timeout=60) + subprocess.run([str(binary)], check=True, capture_output=True, + text=True, timeout=20) + + +if __name__ == "__main__": + unittest.main()