"""Exercise production OTA speed CLI/persistence without an OTA workspace.""" from pathlib import Path import shutil import subprocess import tempfile import unittest ROOT = Path(__file__).resolve().parents[1] HARNESS = r''' #include #include #include using namespace mesh::ota; int main() { MemoryFS fs; char reply[160]; auto command = [&](const char* text, const char* prefix) { memset(reply, 0, sizeof reply); assert(handleSpeedCommand(text, reply, sizeof reply)); assert(!strncmp(reply, prefix, strlen(prefix))); }; beginSpeedConfig(&fs); command("get ota.speed", "> ota.speed=1x packet=1x"); assert(handleSpeedCommand("get ota.speed", reply, sizeof reply, 0.25f)); assert(!strcmp(reply, "> ota.speed=1x packet=0.25x")); assert(effectivePacketPace(0.25f) == 0.25f); assert(effectivePacketPace(1.0f) == 1.0f); for (const auto* bad : {"", "0", "-1", "0.049", "3.01", "nan", "inf", "1e0", "1abc", "1 2"}) { const std::string text = std::string("set ota.speed ") + bad; command(text.c_str(), "ERR"); assert(speedFactor() == 1.0f && fs.files.empty()); } command("set ota.speed .05", "OK ota.speed=0.05x (saved) packet=0.05x"); assert(handleSpeedCommand("get ota.speed", reply, sizeof reply, 0.25f)); assert(!strcmp(reply, "> ota.speed=0.05x packet=0.05x")); assert(validSpeed(speedFactor())); beginSpeedConfig(&fs); command("get ota.speed", "> ota.speed=0.05x packet=0.05x"); command("ota config speed 3", "OK ota.speed=3x (saved) packet=1x"); command("ota config speed", "> ota.speed=3x packet=1x"); command("ota cfg speed 0.5", "OK ota.speed=0.5x (saved) packet=0.5x"); command("ota speed", "> ota.speed=0.5x packet=0.5x"); command("get ota.speed 1", "ERR"); assert(!handleSpeedCommand("get ota.speed.extra", reply, sizeof reply)); assert(!handleSpeedCommand("ota config advert 10", reply, sizeof reply)); const auto good = fs.files["/ota_speed"]; fs.fail_write = true; command("set ota.speed 2", "ERR"); assert(speedFactor() == 0.5f && fs.files["/ota_speed"] == good); fs.fail_write = false; fs.fail_rename = 2; // cannot promote temp; restore primary from backup command("set ota.speed 2", "ERR"); assert(speedFactor() == 0.5f && fs.files["/ota_speed"] == good); fs.files["/ota_speed.bak"] = good; fs.files.erase("/ota_speed"); // power cut in rename gap beginSpeedConfig(&fs); assert(speedFactor() == 0.5f && fs.files["/ota_speed"] == good); fs.files["/ota_speed.bak"] = good; fs.files.erase("/ota_speed"); fs.fail_rename = 1; // valid backup remains authoritative if repair fails beginSpeedConfig(&fs); assert(speedFactor() == 0.5f); command("set ota.speed 2", "ERR"); assert(fs.files["/ota_speed.bak"] == good); beginSpeedConfig(&fs); assert(speedFactor() == 0.5f && fs.files["/ota_speed"] == good); fs.files["/ota_speed"][4] ^= 1; // damaged image is not partly adopted beginSpeedConfig(&fs); assert(speedFactor() == 1.0f); command("set ota.speed 2", "OK ota.speed=2x (saved)"); assert(speedFactor() == 2.0f); beginSpeedConfig(&fs); assert(speedFactor() == 2.0f); fs.files.clear(); beginSpeedConfig(&fs); command("ota set speed 1.125", "OK ota.speed=1.125x"); beginSpeedConfig(&fs); assert(speedFactor() == 1.125f); } ''' class OtaSpeedTest(unittest.TestCase): def test_commands_range_storage_and_failure_recovery(self): compiler = shutil.which("g++") or shutil.which("clang++") self.assertIsNotNone(compiler) with tempfile.TemporaryDirectory() as directory: source = Path(directory) / "speed.cpp" source.write_text(HARNESS) binary = Path(directory) / "speed" result = subprocess.run([ compiler, "-std=c++17", "-Wall", "-Wextra", "-DENABLE_OTA=1", "-I", str(ROOT / "test/fixtures/radio_profiles/mocks"), "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), str(ROOT / "src/helpers/ota/OtaSpeedConfig.cpp"), str(source), "-o", str(binary), ], text=True, capture_output=True) self.assertEqual(result.returncode, 0, result.stderr) subprocess.run([str(binary)], check=True) def test_nrf52_metadata_open_and_short_reads_preserve_committed_speed(self): mock = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text() mock = mock.replace("bool fail_write = false;", "bool stat_error=false;\n bool fail_write = false;") mock = mock.replace("bool mkdir(const char*)", "void _lockFS() {}\n void _unlockFS() {}\n" " MemoryFS* _getFS() { return this; }\n" " bool mkdir(const char*)") mock += ("\nstruct lfs_info {};\nconstexpr int LFS_ERR_NOENT=-2;\n" "inline int lfs_stat(MemoryFS* fs, const char* path, lfs_info*) {\n" " if (fs->stat_error) return -5;\n" " return fs->files.count(path) ? 0 : LFS_ERR_NOENT;\n}\n") faults = r''' const auto committed = fs.files; for (unsigned fault = 0; fault < 3; ++fault) { fs.stat_error = fault == 0; fs.fail_read_open = fault == 1; fs.fail_read_after = fault == 2 ? 4 : -1; beginSpeedConfig(&fs); assert(speedFactor() == 1.0f); // Unreadable is not a corrupt image to replace. command("set ota.speed 2", "ERR"); assert(fs.files == committed); fs.stat_error = fs.fail_read_open = false; fs.fail_read_after = -1; beginSpeedConfig(&fs); assert(speedFactor() == 1.125f); } fs.files["/ota_speed"].pop_back(); // A complete, wrong-sized image is invalid. beginSpeedConfig(&fs); assert(speedFactor() == 1.0f); command("set ota.speed 2", "OK ota.speed=2x (saved)"); beginSpeedConfig(&fs); assert(speedFactor() == 2.0f); ''' source = HARNESS.replace(" assert(speedFactor() == 1.125f);\n}", " assert(speedFactor() == 1.125f);\n" + faults + "}") compiler = shutil.which("g++") or shutil.which("clang++") self.assertIsNotNone(compiler) with tempfile.TemporaryDirectory() as directory: work = Path(directory) (work / "helpers").mkdir() (work / "helpers/IdentityStore.h").write_text(mock) cpp = work / "speed.cpp" cpp.write_text(source) binary = work / "speed" result = subprocess.run([ compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-sign-compare", "-DENABLE_OTA=1", "-DNRF52_PLATFORM=1", "-I", directory, "-I", str(ROOT / "test/fixtures/radio_profiles/mocks"), "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), str(ROOT / "src/helpers/ota/OtaSpeedConfig.cpp"), str(cpp), "-o", str(binary), ], text=True, capture_output=True) self.assertEqual(result.returncode, 0, result.stderr) subprocess.run([str(binary)], check=True) def test_shared_role_wiring_and_idle_context_access(self): common = (ROOT / "src/helpers/CommonCLI.cpp").read_text() companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() for text in (common, companion): self.assertIn("mesh::ota::beginSpeedConfig(", text) self.assertIn("mesh::ota::handleSpeedCommand(command, reply,", text) cli = (ROOT / "src/helpers/ota/OtaCli.cpp").read_text() self.assertLess(cli.index("handleSpeedCommand(command"), cli.index("ota_acquire_context(reply")) mesh = (ROOT / "src/Mesh.cpp").read_text() self.assertIn("syncOtaTiming(context->manager)", mesh) self.assertIn("syncOtaTiming(ota::ota_ctx().manager)", mesh) self.assertIn("manager.set_speed(getOtaSpeedFactor())", mesh) self.assertIn("_ota_announce_timer.arm(_ms->getMillis(), gap, ota_speed)", mesh) if __name__ == "__main__": unittest.main()