Files
MeshCore/test/test_emergency_power.py
T

388 lines
18 KiB
Python

"""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 <helpers/EmergencyPowerPolicy.h>
#include <cassert>
#include <cstring>
#include <initializer_list>
#include <vector>
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<uint8_t> 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")
private += "\n" + api_block("#if MESH_ENABLE_EMERGENCY_POWER\n bool _emergency_radio_sleeping")
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 <helpers/BatteryForwarding.h>
#include <cstdio>
#include <map>
#include <string>
static uint32_t nowMs;
static uint32_t millis() { return nowMs; }
struct FILESYSTEM {
std::map<std::string, std::vector<uint8_t>> 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<uint8_t> 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()