"""Run the production battery forwarding policy, CLI and record under sanitizers.""" 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] PREFIX = r''' #include #include #include #include #include #include using namespace mesh::batteryforward; static bool same(const Settings& a, const Settings& b) { return a.enabled == b.enabled && a.repeatBelow == b.repeatBelow && a.floodBelow == b.floodBelow && a.emptyMv == b.emptyMv && a.fullMv == b.fullMv; } struct CLI { Settings settings; State state; Settings saved; bool saveOk = true; bool external = false; unsigned saveCalls = 0; uint16_t empty = 3000, full = 4200; char reply[192] = {}; bool run(const char* command) { strcpy(reply, "untouched"); return handleCommand(settings, state, empty, full, external, command, reply, sizeof(reply), [&](const Settings& candidate) { ++saveCalls; saved = candidate; return saveOk; }); } void ok(const char* command) { const unsigned before = saveCalls; assert(run(command)); assert(strcmp(reply, "OK - saved") == 0 && saveCalls == before + 1); assert(same(settings, saved)); } void error(const char* command) { const Settings before = settings; const unsigned calls = saveCalls; assert(run(command)); assert(strncmp(reply, "Error:", 6) == 0); assert(same(settings, before) && saveCalls == calls); } }; ''' class BatteryForwardingTests(unittest.TestCase): def exercise(self, body, prefix=PREFIX, flags=()): with tempfile.TemporaryDirectory(prefix="battery-forwarding-") 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 runtime_prefix(self, extra_members=""): """Keep the production admission/sampling/CLI methods; fake only board/save boundaries.""" source = (ROOT / "src/helpers/CommonCLI_Battery.cpp").read_text(encoding="ascii") methods = "\n".join(extract_braced(source, signature) for signature in ( "uint16_t CommonCLI::batteryEmptyMv(", "uint16_t CommonCLI::batteryFullMv(", "uint8_t CommonCLI::batteryPercent(", "bool CommonCLI::batteryAllowsTransmit(", "bool CommonCLI::batteryAllowsForward(", "void CommonCLI::loopBatteryForwarding(", "bool CommonCLI::handleBatteryForwardingCommand(")) return PREFIX + r''' static uint32_t nowMs = 0; static uint32_t millis() { return nowMs; } namespace mesh { struct Packet { bool forwarded = false, flood = false; bool isRouteFlood() const { return flood; } }; namespace power { static uint16_t configuredEmptyMv = 6000, configuredFullMv = 8400; static uint16_t configuredEmpty() { return configuredEmptyMv; } static uint16_t configuredFull() { return configuredFullMv; } } } struct Board { uint16_t mv = 3000; unsigned reads = 0; float multiplier = 1.0f; bool adcSupported = true; uint16_t getBattMilliVolts() { ++reads; return uint16_t(float(mv) * multiplier); } float getAdcMultiplier() const { return adcSupported ? multiplier : 0.0f; } bool setAdcMultiplier(float value) { if (!adcSupported) return false; multiplier = value == 0.0f ? 1.0f : value; return true; } }; struct CommonCLI { Board* _board; Settings _battery_forward_settings; mutable State _battery_forward_state; bool saveOk = true; unsigned saveCalls = 0; Settings saved; explicit CommonCLI(Board& board) : _board(&board) {} bool saveBatteryForwarding(const Settings& settings) { ++saveCalls; saved = settings; return saveOk; } uint16_t batteryEmptyMv() const; uint16_t batteryFullMv() const; uint8_t batteryPercent(uint16_t mv) const; bool batteryAllowsTransmit(const mesh::Packet* packet) const; bool batteryAllowsForward(bool flood) const; void loopBatteryForwarding(bool force = false) const; bool handleBatteryForwardingCommand(const char* command, char* reply); EXTRA_MEMBERS }; '''.replace("EXTRA_MEMBERS", extra_members) + methods def disabled_runtime_prefix(self): """Compile the real disabled declarations and entire battery implementation. No board or filesystem implementation is provided: a retained sampler, persistence path, or policy member makes this fixture fail to compile. """ header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii") start = header.index("#if MESH_ENABLE_BATTERY_FORWARDING\n FILESYSTEM* _battery_forward_fs") end = header.index("#if defined(WITH_ESPNOW_BRIDGE)", start) private_api = header[start:end] start = header.index("#if MESH_ENABLE_BATTERY_FORWARDING\n bool batteryAllowsForward") end = header.index(" bool hasActiveUserGpioTimer()", start) public_api = header[start:end] implementation = (ROOT / "src/helpers/CommonCLI_Battery.cpp").read_text(encoding="ascii") implementation = re.sub(r"^#include[^\n]*\n", "", implementation, flags=re.MULTILINE) return r''' #include #include #include #include namespace mesh { struct Packet; } struct FILESYSTEM; class CommonCLI { public: ''' + private_api + public_api + r''' }; static_assert(sizeof(CommonCLI) == 1, "disabled forwarding must not retain state"); ''' + implementation def test_disabled_actual_common_cli_has_no_storage_adc_or_forwarding_commands(self): prefix = self.disabled_runtime_prefix() for extra_flags, empty, full in (((), 3000, 4200), (("-DBATT_MIN_MILLIVOLTS=6000", "-DBATT_MAX_MILLIVOLTS=8400"), 6000, 8400)): with self.subTest(endpoints=(empty, full)): self.exercise(r''' static_assert(MESH_ENABLE_BATTERY_FORWARDING == 0, "explicit opt-out"); CommonCLI cli; cli.loadBatteryForwarding(reinterpret_cast(1)); cli.loopBatteryForwarding(); cli.loopBatteryForwarding(true); assert(cli.batteryAllowsForward(false) && cli.batteryAllowsForward(true)); assert(cli.batteryAllowsTransmit(nullptr)); assert(cli.batteryAllowsTransmit(reinterpret_cast(1))); char reply[] = "untouched"; for (const char* command : {"get repeat.battery", "set repeat.battery on", "get flood.battery", "set flood.battery 20", "get battery.forward", "set battery.empty 3200", "get battery.full"}) { assert(!cli.handleBatteryForwardingCommand(command, reply)); assert(strcmp(reply, "untouched") == 0); } assert(cli.batteryEmptyMv() == EMPTY && cli.batteryFullMv() == FULL); assert(cli.batteryPercent(EMPTY - 1) == 0); assert(cli.batteryPercent(EMPTY) == 0); assert(cli.batteryPercent((EMPTY + FULL) / 2) == 50); assert(cli.batteryPercent(FULL) == 100); assert(cli.batteryPercent(FULL + 1) == 100); '''.replace("EMPTY", str(empty)).replace("FULL", str(full)), prefix=prefix, flags=("-DSTM32_PLATFORM", "-DMESH_ENABLE_BATTERY_FORWARDING=0", *extra_flags)) def test_stm32_explicit_opt_in_keeps_real_commands_and_admission(self): self.exercise(r''' static_assert(MESH_ENABLE_BATTERY_FORWARDING == 1, "explicit opt-in"); Board board; board.mv = 3108; CommonCLI cli(board); char reply[160] = {}; assert(cli.handleBatteryForwardingCommand("set repeat.battery 15", reply)); assert(strcmp(reply, "OK - saved") == 0 && board.reads == 1 && cli.saveCalls == 1); assert(!cli.batteryAllowsForward(false) && !cli.batteryAllowsForward(true)); assert(cli.handleBatteryForwardingCommand("get battery.forward", reply)); assert(strcmp(reply, "> battery=9% mv=3108 repeat=blocked flood=blocked") == 0); assert(cli.handleBatteryForwardingCommand("set repeat.battery off", reply)); assert(cli.batteryAllowsForward(false) && cli.batteryAllowsForward(true)); ''', prefix=self.runtime_prefix(), flags=("-DSTM32_PLATFORM", "-DMESH_ENABLE_BATTERY_FORWARDING=1")) def test_actual_set_command_voltage_policy_branch_refreshes_profile_endpoints_and_calibration(self): source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") setter = extract_braced(source, "void CommonCLI::handleSetCmd(") branch = extract_braced(setter, "if (mesh::power::handleVoltagePolicyCommand(") prefix = self.runtime_prefix("void dispatchVoltage(char* command, char* reply);") prefix += r''' namespace mesh { namespace power { static bool voltageSaveOk = true; static unsigned voltageSaves = 0; static Board* calibratedBoard = nullptr; // This is the existing voltage-policy parser/save boundary. The dispatch block // and battery refresh below are extracted unchanged from production. static bool handleVoltagePolicyCommand(const char* command, char* reply, size_t capacity) { uint16_t empty = configuredEmptyMv, full = configuredFullMv; float calibration = calibratedBoard->multiplier; if (strcmp(command, "set battery.profile lifepo4") == 0) { empty = 2700; full = 3550; } else if (strcmp(command, "set battery.full 4200") == 0) full = 4200; else if (strcmp(command, "set battery.empty 3300") == 0) empty = 3300; else if (strcmp(command, "set adc.multiplier 1.2") == 0) calibration = 1.2f; else if (strcmp(command, "set adc.multiplier 1.0") == 0) calibration = 1.0f; else return false; ++voltageSaves; if (!voltageSaveOk) snprintf(reply, capacity, "Error: voltage setting could not be saved"); else { configuredEmptyMv = empty; configuredFullMv = full; calibratedBoard->multiplier = calibration; snprintf(reply, capacity, "OK - saved"); } return true; } }} void CommonCLI::dispatchVoltage(char* command, char* reply) { ''' + branch + r''' strcpy(reply, "fallthrough"); } ''' self.exercise(r''' Board board; board.mv = 3500; CommonCLI cli(board); mesh::power::calibratedBoard = &board; mesh::power::configuredEmptyMv = 3000; mesh::power::configuredFullMv = 4200; cli._battery_forward_settings.enabled = Repeat; cli._battery_forward_settings.repeatBelow = 60; nowMs = 100; assert(!cli.batteryAllowsForward(false) && board.reads == 1); assert(cli._battery_forward_state.percentage == 41); char reply[160]; auto dispatch = [&](const char* text) { char command[80]; strcpy(command, text); nowMs += 1; // Every command is much earlier than the 30-second sample expiry. cli.dispatchVoltage(command, reply); assert(strncmp(reply, "OK", 2) == 0); }; dispatch("set battery.profile lifepo4"); assert(board.reads == 2 && cli._battery_forward_state.percentage == 94); assert(cli._battery_forward_state.sampledAt == nowMs && cli.batteryAllowsForward(false)); dispatch("set battery.full 4200"); assert(board.reads == 3 && cli._battery_forward_state.percentage == 53); assert(!cli.batteryAllowsForward(false)); dispatch("set battery.empty 3300"); assert(board.reads == 4 && cli._battery_forward_state.percentage == 22); assert(!cli.batteryAllowsForward(false)); dispatch("set adc.multiplier 1.2"); assert(board.reads == 5 && cli._battery_forward_state.millivolts == 4200); assert(cli._battery_forward_state.percentage == 100 && cli.batteryAllowsForward(false)); mesh::power::voltageSaveOk = false; const uint32_t sampledAt = cli._battery_forward_state.sampledAt; char failed[] = "set adc.multiplier 1.0"; ++nowMs; cli.dispatchVoltage(failed, reply); assert(strncmp(reply, "Error:", 6) == 0 && board.reads == 5); assert(mesh::power::voltageSaves == 5 && board.multiplier == 1.2f); assert(cli._battery_forward_state.sampledAt == sampledAt && cli.batteryAllowsForward(false)); char unknown[] = "set unrelated on"; cli.dispatchVoltage(unknown, reply); assert(strcmp(reply, "fallthrough") == 0 && board.reads == 5); ''', prefix=prefix, flags=("-DNRF52_PLATFORM", "-DNRF52_POWER_MANAGEMENT")) def test_actual_adc_get_set_fall_back_when_nrf52_voltage_policy_is_unavailable(self): source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") setter = extract_braced(source, "void CommonCLI::handleSetCmd(") getter = extract_braced(source, "void CommonCLI::handleGetCmd(") def policy_route(method): start = method.index(" const bool adc_multiplier_command =") return method[start:method.index("#endif", start)] set_branch = extract_braced(setter, 'if (memcmp(config, "adc.multiplier ", 15) == 0)') get_branch = extract_braced(getter, 'if (configKeyEquals(config, "adc.multiplier"))') prefix = self.runtime_prefix(r''' struct Prefs { float adc_multiplier = 0.0f; } prefs; Prefs* _prefs = &prefs; unsigned prefsSaves = 0; bool trySavePrefs() { ++prefsSaves; return true; } bool applyAndSaveAdcMultiplier(float multiplier, char* reply, size_t capacity); void dispatchSet(char* command, char* reply); void dispatchGet(char* command, char* reply); ''') prefix += r''' #include #include namespace mesh { namespace power { static bool policyAvailable = false; static unsigned policyCalls = 0; static bool voltagePolicySupported() { return policyAvailable; } static bool setConfiguredAdcMultiplier(float, char* reply, size_t capacity) { ++policyCalls; snprintf(reply, capacity, "%s", policyAvailable ? "OK - saved" : "Error: voltage policy unsupported"); return policyAvailable; } static bool handleVoltagePolicyCommand(const char*, char* reply, size_t capacity) { ++policyCalls; snprintf(reply, capacity, "%s", policyAvailable ? "> policy" : "Error: voltage policy unsupported"); return true; } }} ''' prefix += extract_braced(source, "static bool configKeyEquals(") + "\n" prefix += extract_braced(source, "bool CommonCLI::applyAndSaveAdcMultiplier(") + "\n" prefix += "void CommonCLI::dispatchSet(char* command, char* reply) {\n" prefix += "const char* config = command + 4;\n" + policy_route(setter) + set_branch + "\n}\n" prefix += "void CommonCLI::dispatchGet(char* command, char* reply) {\n" prefix += "const char* config = command + 4;\n" + policy_route(getter) + get_branch + "\n}\n" self.exercise(r''' Board board; CommonCLI cli(board); char reply[160]; char get[] = "get adc.multiplier"; cli.dispatchGet(get, reply); assert(!strcmp(reply, "> 1.000") && mesh::power::policyCalls == 0); char set[] = "set adc.multiplier 1.2"; cli.dispatchSet(set, reply); assert(!strncmp(reply, "OK", 2) && cli.prefsSaves == 1); assert(board.multiplier == 1.2f && cli.prefs.adc_multiplier == 1.2f); assert(mesh::power::policyCalls == 0); cli.dispatchGet(get, reply); assert(!strcmp(reply, "> 1.200") && mesh::power::policyCalls == 0); char profile[] = "set battery.profile lifepo4"; cli.dispatchSet(profile, reply); assert(strstr(reply, "voltage policy unsupported") && mesh::power::policyCalls == 1); char full[] = "get battery.full"; cli.dispatchGet(full, reply); assert(strstr(reply, "voltage policy unsupported") && mesh::power::policyCalls == 2); board.adcSupported = false; cli.dispatchGet(get, reply); assert(!strcmp(reply, "Error: unsupported") && mesh::power::policyCalls == 2); cli.dispatchSet(set, reply); assert(!strcmp(reply, "Error: unsupported") && mesh::power::policyCalls == 2); board.adcSupported = true; mesh::power::policyAvailable = true; cli.dispatchGet(get, reply); assert(!strcmp(reply, "> policy") && mesh::power::policyCalls == 3); ''', prefix=prefix, flags=("-DNRF52_PLATFORM", "-DNRF52_POWER_MANAGEMENT")) def test_actual_set_command_adc_branch_resamples_success_and_skips_rejected_changes(self): source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") setter = extract_braced(source, "void CommonCLI::handleSetCmd(") branch = extract_braced(setter, 'if (memcmp(config, "adc.multiplier ", 15) == 0)') prefix = self.runtime_prefix(r''' struct Prefs { float adc_multiplier = 0.0f; } prefs; Prefs* _prefs = &prefs; unsigned prefsSaves = 0; bool prefsSaveOk = true; bool trySavePrefs() { ++prefsSaves; return prefsSaveOk; } bool applyAndSaveAdcMultiplier(float multiplier, char* reply, size_t capacity); void dispatchAdc(char* command, char* reply); ''') prefix += "\n#include \n#include \n" prefix += extract_braced(source, "bool CommonCLI::applyAndSaveAdcMultiplier(") + "\n" prefix += "void CommonCLI::dispatchAdc(char* command, char* reply) {\n" prefix += "const char* config = &command[4];\n" + branch + "\n}\n" self.exercise(r''' Board board; board.mv = 3500; CommonCLI cli(board); cli._battery_forward_settings.enabled = Repeat; cli._battery_forward_settings.repeatBelow = 60; nowMs = 100; assert(!cli.batteryAllowsForward(false) && board.reads == 1); assert(cli._battery_forward_state.percentage == 41); char reply[160]; char calibrate[] = "set adc.multiplier 1.2"; ++nowMs; cli.dispatchAdc(calibrate, reply); assert(strncmp(reply, "OK", 2) == 0 && cli.prefsSaves == 1 && board.reads == 2); assert(cli.prefs.adc_multiplier == 1.2f && cli._battery_forward_state.millivolts == 4200); assert(cli._battery_forward_state.sampledAt == nowMs && cli.batteryAllowsForward(false)); char malformed[] = "set adc.multiplier 1.2junk"; cli.dispatchAdc(malformed, reply); assert(strncmp(reply, "Error:", 6) == 0 && cli.prefsSaves == 1 && board.reads == 2); board.adcSupported = false; char unsupported[] = "set adc.multiplier 1.0"; cli.dispatchAdc(unsupported, reply); assert(strcmp(reply, "Error: unsupported") == 0 && cli.prefsSaves == 1 && board.reads == 2); assert(cli.prefs.adc_multiplier == 1.2f && cli.batteryAllowsForward(false)); board.adcSupported = true; cli.prefsSaveOk = false; char failed[] = "set adc.multiplier 1.0"; cli.dispatchAdc(failed, reply); assert(strstr(reply, "save unconfirmed") && cli.prefsSaves == 2 && board.reads == 2); assert(cli.prefs.adc_multiplier == 1.2f && board.multiplier == 1.2f); assert(cli.batteryAllowsForward(false)); cli.prefsSaveOk = true; char reset[] = "set adc.multiplier 0"; cli.dispatchAdc(reset, reply); assert(strcmp(reply, "OK - defaults (saved)") == 0); assert(cli.prefsSaves == 3 && board.reads == 3 && cli._battery_forward_state.percentage == 41); assert(!cli.batteryAllowsForward(false)); ''', prefix=prefix, flags=("-DESP32_PLATFORM",)) def test_actual_status_whitespace_forces_fresh_sample_but_invalid_suffix_does_not(self): self.exercise(r''' Board board; CommonCLI cli(board); char reply[157]; board.mv = 4200; assert(cli.handleBatteryForwardingCommand("get battery.forward \t", reply)); assert(board.reads == 1 && strstr(reply, "battery=100% mv=4200")); board.mv = 3000; assert(cli.handleBatteryForwardingCommand("get battery.forward x", reply)); assert(strncmp(reply, "Error:", 6) == 0 && board.reads == 1); assert(!cli.handleBatteryForwardingCommand("get battery.forwarder", reply) && board.reads == 1); assert(cli.handleBatteryForwardingCommand("get battery.forward\t", reply)); assert(board.reads == 2 && strstr(reply, "battery=0% mv=3000")); assert(cli.saveCalls == 0); ''', prefix=self.runtime_prefix(), flags=("-DESP32_PLATFORM",)) def test_actual_runtime_admission_samples_before_first_packet_and_after_30_seconds(self): self.exercise(r''' Board board; CommonCLI cli(board); mesh::Packet own, relayed; own.flood = true; relayed.forwarded = true; const CommonCLI& admission = cli; for (unsigned i = 0; i < 5; ++i) { cli.loopBatteryForwarding(); assert(admission.batteryAllowsForward(false)); assert(admission.batteryAllowsForward(true)); } assert(board.reads == 0 && !cli._battery_forward_state.sampled); cli._battery_forward_settings.enabled = Repeat | Flood; board.mv = 3108; // 9 percent: both policies must be applied before the CLI loop runs. nowMs = 100; assert(admission.batteryAllowsTransmit(nullptr) && admission.batteryAllowsTransmit(&own)); assert(board.reads == 0); // Locally owned traffic never invokes the relay battery gate. assert(!admission.batteryAllowsTransmit(&relayed) && board.reads == 1); assert(cli._battery_forward_state.sampledAt == 100 && cli._battery_forward_state.percentage == 9); relayed.flood = true; assert(!admission.batteryAllowsTransmit(&relayed) && board.reads == 1); nowMs = 30099; board.mv = 4200; cli.loopBatteryForwarding(); assert(!admission.batteryAllowsForward(false) && board.reads == 1); nowMs = 30100; assert(admission.batteryAllowsTransmit(&relayed) && board.reads == 2); assert(cli._battery_forward_state.sampledAt == 30100 && cli._battery_forward_state.percentage == 100); cli.loopBatteryForwarding(); assert(admission.batteryAllowsForward(false) && board.reads == 2); nowMs = 200000; // The next retry must refresh after a long sleep, before the main CLI loop. board.mv = 3108; assert(!admission.batteryAllowsTransmit(&relayed) && board.reads == 3); assert(admission.batteryAllowsTransmit(&own) && admission.batteryAllowsTransmit(nullptr)); assert(board.reads == 3); ''', prefix=self.runtime_prefix(), flags=("-DESP32_PLATFORM",)) def test_actual_runtime_cli_immediately_resamples_saved_policy_and_status(self): self.exercise(r''' Board board; board.mv = 3300; // 25 percent with the ordinary 3000/4200 endpoints. CommonCLI cli(board); char reply[157]; nowMs = 123; assert(cli.handleBatteryForwardingCommand("set repeat.battery 30", reply)); assert(strcmp(reply, "OK - saved") == 0 && board.reads == 1 && cli.saveCalls == 1); assert(!cli.batteryAllowsForward(false) && !cli.batteryAllowsForward(true) && board.reads == 1); board.mv = 4200; assert(cli.handleBatteryForwardingCommand("get battery.forward", reply)); assert(strstr(reply, "battery=100% mv=4200 repeat=allowed flood=allowed")); assert(board.reads == 2 && cli.saveCalls == 1); board.mv = 3108; cli.saveOk = false; assert(cli.handleBatteryForwardingCommand("set repeat.battery off", reply)); assert(strstr(reply, "save unconfirmed") && board.reads == 2 && cli.saveCalls == 2); assert(cli._battery_forward_settings.enabled == Repeat && cli._battery_forward_state.percentage == 100); cli.saveOk = true; assert(cli.handleBatteryForwardingCommand("set repeat.battery off", reply)); assert(board.reads == 3 && cli._battery_forward_settings.enabled == 0); assert(cli.batteryAllowsForward(false) && cli.batteryAllowsForward(true)); nowMs += 100000; cli.loopBatteryForwarding(); assert(board.reads == 3); // Disabled policies do not poll the ADC. assert(cli.handleBatteryForwardingCommand("set flood.battery 20", reply)); assert(board.reads == 4 && cli._battery_forward_state.percentage == 9); mesh::Packet direct, flood, own; direct.forwarded = flood.forwarded = true; flood.flood = own.flood = true; assert(cli.batteryAllowsTransmit(&direct)); assert(!cli.batteryAllowsTransmit(&flood)); assert(cli.batteryAllowsTransmit(&own)); // A missing or impossible battery reading immediately removes both gates. board.mv = 0; assert(cli.handleBatteryForwardingCommand("get battery.forward", reply)); assert(strstr(reply, "battery=unknown mv=0 repeat=allowed flood=allowed")); assert(cli.batteryAllowsTransmit(&flood) && board.reads == 5); ''', prefix=self.runtime_prefix(), flags=("-DESP32_PLATFORM",)) def test_actual_runtime_custom_voltage_endpoints_resample_and_compiled_two_cell_defaults(self): self.exercise(r''' Board board; CommonCLI cli(board); board.mv = 7200; assert(cli.batteryEmptyMv() == 6000 && cli.batteryFullMv() == 8400); assert(cli.batteryPercent(board.mv) == 50); char reply[157]; assert(cli.handleBatteryForwardingCommand("set repeat.battery 55", reply)); assert(!cli.batteryAllowsForward(false) && board.reads == 1); assert(cli.handleBatteryForwardingCommand("set battery.empty 5000", reply)); assert(strcmp(reply, "OK - saved") == 0 && cli.batteryEmptyMv() == 5000); assert(cli.batteryFullMv() == 8400 && cli._battery_forward_state.percentage == 64); assert(cli.batteryAllowsForward(false) && board.reads == 2); assert(cli.handleBatteryForwardingCommand("set battery.full 10000", reply)); assert(cli.batteryFullMv() == 10000 && cli._battery_forward_state.percentage == 44); assert(!cli.batteryAllowsForward(false) && board.reads == 3); ''', prefix=self.runtime_prefix(), flags=("-DESP32_PLATFORM", "-DBATT_MIN_MILLIVOLTS=6000", "-DBATT_MAX_MILLIVOLTS=8400")) def test_actual_nrf52_runtime_uses_shared_voltage_policy_and_preserves_existing_endpoint_cli(self): self.exercise(r''' Board board; board.mv = 7200; CommonCLI cli(board); char reply[157] = "untouched"; cli._battery_forward_settings.emptyMv = 3000; cli._battery_forward_settings.fullMv = 4200; assert(cli.batteryEmptyMv() == 6000 && cli.batteryFullMv() == 8400 && cli.batteryPercent(7200) == 50); for (const char* command : {"get battery.empty", "get battery.full", "set battery.empty 3300", "set battery.full 4200"}) { assert(!cli.handleBatteryForwardingCommand(command, reply)); assert(strcmp(reply, "untouched") == 0 && board.reads == 0 && cli.saveCalls == 0); } assert(cli.handleBatteryForwardingCommand("set repeat.battery 60", reply)); assert(!cli.batteryAllowsForward(false) && board.reads == 1); mesh::power::configuredFullMv = 9000; cli.loopBatteryForwarding(true); assert(cli._battery_forward_state.percentage == 40 && board.reads == 2); mesh::power::configuredFullMv = 8000; cli.loopBatteryForwarding(true); assert(cli._battery_forward_state.percentage == 60 && !cli.batteryAllowsForward(false)); board.mv = 7240; cli.loopBatteryForwarding(true); assert(cli._battery_forward_state.percentage == 62 && cli.batteryAllowsForward(false)); ''', prefix=self.runtime_prefix(), flags=("-DNRF52_PLATFORM", "-DNRF52_POWER_MANAGEMENT")) def test_defaults_are_off_and_thresholds_are_independent_and_persistent_in_cli(self): 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); 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.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.enabled == Repeat); } cli.ok("set repeat.battery 13"); cli.ok("set flood.battery 37"); assert(cli.settings.enabled == (Repeat | Flood)); cli.ok("set repeat.battery off"); assert(cli.settings.enabled == Flood && cli.settings.repeatBelow == 13); cli.ok("set repeat.battery on"); cli.ok("set flood.battery off"); assert(cli.settings.enabled == Repeat && cli.settings.floodBelow == 37); cli.ok("set flood.battery on"); assert(cli.settings.enabled == (Repeat | Flood)); assert(cli.settings.repeatBelow == 13 && cli.settings.floodBelow == 37); for (unsigned threshold = 1; threshold <= 100; ++threshold) { const std::string command = "set flood.battery " + std::to_string(threshold); cli.ok(command.c_str()); assert(cli.settings.floodBelow == threshold && cli.settings.repeatBelow == 13); } ''') def test_cli_rejects_invalid_ranges_suffixes_extra_arguments_and_overflow(self): self.exercise(r''' CLI cli; for (const char* value : {"", "0", "101", "-1", "+10", "10.0", "10%", "15x", "off on", "on off", "15 20", "10 ", "10\t", "ON", "OFF", "4294967296", "1844674407370955161600000000000000"}) { for (const char* key : {"repeat.battery", "flood.battery"}) { const std::string command = std::string("set ") + key + " " + value; cli.error(command.c_str()); } } cli.error("get repeat.battery 10"); cli.error("get flood.battery off"); cli.error("get battery.forward now"); cli.error("set battery.forward on"); for (const char* command : {"get repeat.batterys", "set repeat.battery2 10", "get flood.batteryx", "get battery.forwarder", "repeat.battery", "Get repeat.battery", ""}) { assert(!cli.run(command)); assert(strcmp(cli.reply, "untouched") == 0 && cli.saveCalls == 0); } cli.ok("set repeat.battery\t17"); cli.ok("set flood.battery 21"); assert(cli.settings.repeatBelow == 17 && cli.settings.floodBelow == 21); uint16_t parsed = 1234; assert(!parseNumber(nullptr, 1, 100, parsed) && parsed == 1234); assert(!parseNumber("", 1, 100, parsed) && parsed == 1234); assert(parseNumber("00015", 1, 100, parsed) && parsed == 15); ''') def test_command_null_and_bounded_reply_contract(self): self.exercise(r''' CLI cli; unsigned calls = 0; auto save = [&](const Settings&) { ++calls; return true; }; char guard[] = {'A', 'B', 'C'}; assert(!handleCommand(cli.settings, cli.state, 3000, 4200, false, nullptr, guard + 1, 1, save)); assert(!handleCommand(cli.settings, cli.state, 3000, 4200, false, "get repeat.battery", nullptr, 1, save)); assert(!handleCommand(cli.settings, cli.state, 3000, 4200, false, "get repeat.battery", guard + 1, 0, save)); assert(calls == 0 && guard[0] == 'A' && guard[1] == 'B' && guard[2] == 'C'); for (const char* command : {"get repeat.battery", "get battery.forward", "set flood.battery 999"}) { assert(handleCommand(cli.settings, cli.state, 3000, 4200, false, command, guard + 1, 1, save)); assert(guard[0] == 'A' && guard[1] == '\0' && guard[2] == 'C' && calls == 0); } ''') def test_failed_persistence_leaves_active_candidate_and_sampling_unchanged(self): self.exercise(r''' CLI cli; cli.ok("set repeat.battery 15"); cli.state.update(123, 3108, 9, true, cli.settings); const Settings before = cli.settings; const State sampled = cli.state; cli.saveOk = false; const unsigned calls = cli.saveCalls; assert(cli.run("set repeat.battery off")); assert(strstr(cli.reply, "save unconfirmed") && strstr(cli.reply, "active policy unchanged")); assert(cli.saveCalls == calls + 1 && same(cli.settings, before)); assert(cli.saved.enabled == 0 && cli.saved.repeatBelow == 15); assert(cli.state.sampledAt == sampled.sampledAt && cli.state.millivolts == sampled.millivolts && cli.state.percentage == sampled.percentage && cli.state.valid == sampled.valid && cli.state.repeatBlocked == sampled.repeatBlocked && cli.state.floodBlocked == sampled.floodBlocked); assert(!cli.state.allows(false)); cli.saveOk = true; cli.ok("set repeat.battery off"); // The CLI owns persistence; its caller must immediately re-evaluate the sample. assert(cli.state.repeatBlocked && cli.state.sampledAt == 123); cli.state.update(124, 3108, 9, true, cli.settings); assert(cli.state.allows(false) && cli.state.allows(true)); cli.ok("set flood.battery 30"); cli.state.update(125, 3108, 9, true, cli.settings); assert(cli.state.allows(false) && !cli.state.allows(true)); ''') def test_threshold_is_strictly_below_with_two_point_hysteresis_capped_at_100(self): self.exercise(r''' for (unsigned threshold = 1; threshold <= 100; ++threshold) { for (unsigned pct = 0; pct <= 100; ++pct) { const unsigned resume = threshold < 99 ? threshold + 2 : 100; assert(State::blocked(false, true, true, pct, threshold) == (pct < threshold)); assert(State::blocked(true, true, true, pct, threshold) == (pct < resume)); assert(!State::blocked(true, false, true, pct, threshold)); assert(!State::blocked(true, true, false, pct, threshold)); } } Settings settings; settings.enabled = Repeat | Flood; State state; state.update(1, 3240, 20, true, settings); assert(state.allows(false) && state.allows(true)); state.update(2, 3228, 19, true, settings); assert(state.allows(false) && !state.allows(true)); state.update(3, 3120, 10, true, settings); assert(state.allows(false) && !state.allows(true)); state.update(4, 3108, 9, true, settings); assert(!state.allows(false) && !state.allows(true)); state.update(5, 3132, 11, true, settings); assert(!state.allows(false) && !state.allows(true)); state.update(6, 3144, 12, true, settings); assert(state.allows(false) && !state.allows(true)); state.update(7, 3252, 21, true, settings); assert(state.allows(false) && !state.allows(true)); state.update(8, 3264, 22, true, settings); assert(state.allows(false) && state.allows(true)); ''') def test_invalid_unknown_battery_fails_open_and_sampling_handles_clock_wrap(self): self.exercise(r''' Settings settings; settings.enabled = Repeat | Flood; for (uint16_t mv : {uint16_t(0), uint16_t(1999), uint16_t(20001), uint16_t(65535)}) { State state; state.update(1, 3000, 0, true, settings); assert(!state.allows(false)); state.update(2, mv, 0, true, settings); assert(!state.valid && state.allows(false) && state.allows(true)); } for (unsigned pct : {101U, 255U}) { State state; state.update(1, 3000, 0, true, settings); state.update(2, 3000, pct, true, settings); assert(!state.valid && state.allows(false) && state.allows(true)); } State state; assert(state.due(0)); state.update(0xfffffff0U, 3000, 0, true, settings); assert(!state.due(0xfffffff0U) && !state.due(0x751fU)); assert(state.due(0x7520U)); state.update(7, 3000, 0, false, settings); assert(!state.valid && state.allows(false) && state.allows(true)); CLI cli; cli.state = state; assert(cli.run("get battery.forward") && strstr(cli.reply, "battery=unknown")); assert(strstr(cli.reply, "repeat=allowed flood=allowed") && cli.saveCalls == 0); ''') def test_voltage_endpoints_allow_two_cell_conversion_and_reject_invalid_pairs(self): self.exercise(r''' CLI cli; assert(cli.run("get battery.empty") && strcmp(cli.reply, "> 3000") == 0); assert(cli.run("get battery.full") && strcmp(cli.reply, "> 4200") == 0); cli.error("set battery.empty 4200"); cli.error("set battery.empty 4101"); cli.ok("set battery.empty 4100"); assert(cli.settings.emptyMv == 4100 && cli.settings.fullMv == 4200); cli.empty = cli.settings.emptyMv; cli.ok("set battery.full 8400"); cli.full = cli.settings.fullMv; cli.ok("set battery.empty 6000"); assert(cli.settings.emptyMv == 6000 && cli.settings.fullMv == 8400); assert(percent(6000, 6000, 8400) == 0 && percent(8400, 6000, 8400) == 100); assert(percent(7200, 6000, 8400) == 50 && percent(7180, 6000, 8400) == 49); assert(percent(0, 6000, 8400) == 0 && percent(65535, 6000, 8400) == 100); assert(percent(3000, 4200, 4200) == 100 && percent(3000, 4200, 3000) == 100); for (const char* value : {"0", "1999", "20001", "65536", "-1", "4.2", "4200mV", "4200 4300"}) { for (const char* key : {"battery.empty", "battery.full"}) { const std::string command = std::string("set ") + key + " " + value; cli.error(command.c_str()); } } assert(validEndpoints(2000, 2100) && validEndpoints(2000, 20000)); assert(!validEndpoints(1999, 4200) && !validEndpoints(3000, 3099)); assert(!validEndpoints(19901, 20000) && !validEndpoints(2000, 20001)); ''') def test_nrf52_external_voltage_policy_falls_through_without_writing(self): self.exercise(r''' CLI cli; cli.external = true; for (const char* command : {"get battery.empty", "get battery.full", "set battery.empty 3300", "set battery.full 8400", "set battery.empty nonsense"}) { assert(!cli.run(command)); assert(strcmp(cli.reply, "untouched") == 0 && cli.saveCalls == 0); assert(same(cli.settings, Settings{})); } cli.ok("set repeat.battery 17"); cli.ok("set flood.battery 28"); assert(cli.run("get repeat.battery") && strcmp(cli.reply, "> on below=17%") == 0); assert(cli.run("get flood.battery") && strcmp(cli.reply, "> on below=28%") == 0); ''') def test_record_roundtrip_truncation_bit_corruption_and_semantic_validation(self): self.exercise(r''' Settings expected; expected.enabled = Repeat | Flood; expected.repeatBelow = 17; expected.floodBelow = 43; expected.emptyMv = 6000; expected.fullMv = 8400; uint8_t bytes[RecordSize]; encode(bytes, expected); Settings actual; assert(decode(bytes, sizeof(bytes), actual) && same(actual, expected)); assert(bytes[8] == 0x70 && bytes[9] == 0x17 && bytes[10] == 0xd0 && bytes[11] == 0x20); for (size_t byte = 0; byte < RecordSize; ++byte) { for (unsigned bit = 0; bit < 8; ++bit) { uint8_t corrupted[RecordSize]; memcpy(corrupted, bytes, sizeof(bytes)); corrupted[byte] ^= uint8_t(1U << bit); actual = expected; assert(!decode(corrupted, sizeof(corrupted), actual)); assert(same(actual, Settings{})); } } for (size_t size = 0; size < RecordSize; ++size) { std::vector truncated(bytes, bytes + size); actual = expected; assert(!decode(truncated.data(), size, actual) && same(actual, Settings{})); } std::vector oversized(bytes, bytes + RecordSize); oversized.push_back(0); actual = expected; assert(!decode(oversized.data(), oversized.size(), actual) && same(actual, Settings{})); actual = expected; assert(!decode(nullptr, RecordSize, actual) && same(actual, Settings{})); auto rejectRecord = [&](const uint8_t* input) { uint8_t invalid[RecordSize]; memcpy(invalid, input, sizeof(invalid)); const uint16_t crc = checksum(invalid, 14); invalid[14] = uint8_t(crc); invalid[15] = uint8_t(crc >> 8); actual = expected; assert(!decode(invalid, sizeof(invalid), actual) && same(actual, Settings{})); }; for (size_t reserved : {size_t(7), size_t(12), size_t(13)}) { uint8_t invalid[RecordSize]; memcpy(invalid, bytes, sizeof(bytes)); invalid[reserved] = 1; rejectRecord(invalid); } for (unsigned fault = 0; fault < 7; ++fault) { Settings invalid = expected; if (fault == 0) invalid.enabled = 4; if (fault == 1) invalid.repeatBelow = 0; if (fault == 2) invalid.repeatBelow = 101; if (fault == 3) invalid.floodBelow = 0; if (fault == 4) invalid.floodBelow = 101; if (fault == 5) invalid.emptyMv = 0; if (fault == 6) invalid.fullMv = 6099; assert(!validSettings(invalid)); uint8_t invalidBytes[RecordSize]; encode(invalidBytes, invalid); rejectRecord(invalidBytes); } encode(bytes, Settings{}); assert(decode(bytes, RecordSize, actual) && same(actual, Settings{})); ''') if __name__ == "__main__": unittest.main()