"""Exercise OTA channel persistence through the production common prefs store.""" from pathlib import Path import os import re import subprocess import tempfile import unittest from test_common_usb_debug import HARNESS from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] MAIN = r''' struct Capture { std::vector bytes; std::map offsets; size_t write(const uint8_t* data, size_t size) { offsets[data] = bytes.size(); bytes.insert(bytes.end(), data, data + size); return size; } size_t offset(const void* field) const { return offsets.at(field); } }; void seed(CommonCLI& cli) { auto& p = cli.prefs; strcpy(p.node_name, "OTA channel node"); strcpy(p.password, "channel-pass"); strcpy(p.owner_info, "owner information"); p.node_lat = 40.125; p.node_lon = -105.25; p.freq = 920.25f; p.bw = 125; p.sf = 9; p.cr = 6; p.tx_power_dbm = 14; p.primary_radio_preamble = 96; p.gps_enabled = 1; p.gps_interval = 300; p.gps_sync_interval_hours = 336; p.espnow_bridge_enabled = 0; p.usb_logging_enabled = 0; p.usb_debug_enabled = 1; p.trace_when_repeat_off = 1; setDefaultDirectRetryPrefs(&p); } void assertExistingPrefs(const NodePrefs& actual, const NodePrefs& expected) { assert(strcmp(actual.node_name, expected.node_name) == 0); assert(strcmp(actual.password, expected.password) == 0); assert(strcmp(actual.owner_info, expected.owner_info) == 0); assert(actual.node_lat == expected.node_lat && actual.node_lon == expected.node_lon); assert(actual.freq == expected.freq && actual.bw == expected.bw); assert(actual.sf == expected.sf && actual.cr == expected.cr); assert(actual.tx_power_dbm == expected.tx_power_dbm); assert(actual.primary_radio_preamble == expected.primary_radio_preamble); assert(actual.gps_enabled == expected.gps_enabled && actual.gps_interval == expected.gps_interval); assert(actual.gps_sync_interval_hours == expected.gps_sync_interval_hours); assert(actual.espnow_bridge_enabled == expected.espnow_bridge_enabled); assert(actual.usb_logging_enabled == expected.usb_logging_enabled); assert(actual.usb_debug_enabled == expected.usb_debug_enabled); assert(actual.trace_when_repeat_off == expected.trace_when_repeat_off); } void testRoundTripsAndLayout() { CommonCLI cli; seed(cli); assert(cli.prefs.ota_channel == 0); Capture original; assert(writeCommonPrefsImage(original, &cli.prefs)); const size_t channel_offset = original.offset(&cli.prefs.ota_channel); const size_t trace_offset = original.offset(&cli.prefs.trace_when_repeat_off); assert(channel_offset == trace_offset + sizeof(cli.prefs.trace_when_repeat_off)); assert(channel_offset + sizeof(cli.prefs.ota_channel) + 3 == original.bytes.size()); assert(original.bytes[channel_offset + 1] == 0); // reserved ESP-NOW profile assert(original.offset(&cli.prefs.rs232_bridge_enabled) == channel_offset + 2); assert(original.bytes[channel_offset + 2] == 0 && original.bytes.back() == 0); // The established common core remains at its public file offsets. assert(original.offset(cli.prefs.node_name) == 4); assert(original.offset(&cli.prefs.freq) == 72); assert(original.offset(&cli.prefs.sf) == 112); assert(original.offset(&cli.prefs.bw) == 116); for (uint8_t channel : {0, 1, 2}) { cli.prefs.ota_channel = channel; cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common); assert(cli._common_save_succeeded); const auto image = cli.fs.files.at("/com_prefs"); Capture checked; assert(writeCommonPrefsImage(checked, &cli.prefs)); assert(checked.bytes == image); // Checked and ordinary writers agree. assert(image.size() == original.bytes.size() && image[channel_offset] == channel); for (size_t i = 0; i < image.size(); ++i) { assert(i == channel_offset || image[i] == original.bytes[i]); } CommonCLI reader; reader.prefs.ota_channel = 2; // A reused preference object must be replaced. reader.fs.files["/com_prefs"] = image; reader.loadPrefsInt(&reader.fs, "/com_prefs"); assert(reader.prefs.ota_channel == channel); assertExistingPrefs(reader.prefs, cli.prefs); } } void testLegacyTruncatedAndCorruptImages() { CommonCLI writer; seed(writer); writer.prefs.ota_channel = 2; Capture captured; assert(writeCommonPrefsImage(captured, &writer.prefs)); const size_t channel_offset = captured.offset(&writer.prefs.ota_channel); const size_t gps_offset = captured.offset(&writer.prefs.gps_sync_interval_hours); const size_t debug_offset = captured.offset(&writer.prefs.usb_debug_enabled); const size_t trace_offset = captured.offset(&writer.prefs.trace_when_repeat_off); const auto current = captured.bytes; CommonCLI reader; // Derive the previous image from the append boundary, without hardcoding its size. reader.fs.files["/com_prefs"] = current; reader.loadPrefsInt(&reader.fs, "/com_prefs"); assert(reader.prefs.ota_channel == 2); reader.fs.files["/com_prefs"] = {current.begin(), current.begin() + channel_offset}; reader.loadPrefsInt(&reader.fs, "/com_prefs"); assert(reader.prefs.ota_channel == 0); assertExistingPrefs(reader.prefs, writer.prefs); for (size_t length = 0; length <= channel_offset; ++length) { reader.prefs.ota_channel = 2; reader.prefs.usb_debug_enabled = 1; reader.prefs.trace_when_repeat_off = 1; reader.prefs.gps_sync_interval_hours = 336; reader.fs.files["/com_prefs"] = {current.begin(), current.begin() + length}; reader.loadPrefsInt(&reader.fs, "/com_prefs"); assert(reader.prefs.ota_channel == 0); assert(reader.prefs.gps_sync_interval_hours == (length >= gps_offset + 2 ? 336 : 0)); assert(reader.prefs.usb_debug_enabled == (length > debug_offset ? 1 : 0)); assert(reader.prefs.trace_when_repeat_off == (length > trace_offset ? 1 : 0)); } for (unsigned invalid = 3; invalid <= 255; ++invalid) { auto corrupt = current; corrupt[channel_offset] = static_cast(invalid); reader.prefs.ota_channel = 2; reader.fs.files["/com_prefs"] = corrupt; reader.loadPrefsInt(&reader.fs, "/com_prefs"); assert(reader.prefs.ota_channel == 0); assertExistingPrefs(reader.prefs, writer.prefs); } // A file reporting a full size can still return a short/error read at the new byte. reader.fs.files["/com_prefs"] = current; reader.fs.fail_read_after = static_cast(channel_offset); reader.prefs.ota_channel = 2; reader.loadPrefsInt(&reader.fs, "/com_prefs"); assert(reader.prefs.ota_channel == 0); assertExistingPrefs(reader.prefs, writer.prefs); reader.fs.fail_read_after = -1; reader.prefs.ota_channel = 2; reader.loadPrefsInt(&reader.fs, "/missing"); assert(reader.prefs.ota_channel == 0); reader.fs.fail_read_open = true; reader.prefs.ota_channel = 2; reader.loadPrefsInt(&reader.fs, "/com_prefs"); assert(reader.prefs.ota_channel == 0); } void testFailedTransactionsRetainPreviousChannel() { for (uint8_t previous_channel : {1, 2}) { for (int fault : {0, 1, 2, 3, 4, 5}) { #ifdef WITH_MQTT_BRIDGE // Observer finish() checks reopening and size; standard writers also read CRCs. if (fault == 3) continue; #endif CommonCLI cli; seed(cli); cli.prefs.ota_channel = previous_channel; cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common); assert(cli._common_save_succeeded); const auto previous = cli.fs.files.at("/com_prefs"); const NodePrefs expected = cli.prefs; Capture capture; assert(writeCommonPrefsImage(capture, &cli.prefs)); const size_t channel_offset = capture.offset(&cli.prefs.ota_channel); if (fault == 0) cli.fs.fail_write = true; if (fault == 1) cli.fs.fail_write_after = static_cast(channel_offset); if (fault == 2) cli.fs.fail_read_open = true; if (fault == 3) cli.fs.fail_read_after = static_cast(channel_offset); if (fault == 4) cli.fs.fail_rename = 1; if (fault == 5) cli.fs.fail_rename_from = {"/com_prefs.tmp"}; cli.prefs.ota_channel = previous_channel == 1 ? 2 : 1; assert(!cli.trySavePrefs()); assert(cli.fs.files.at("/com_prefs") == previous); cli.fs.fail_write = false; cli.fs.fail_write_after = -1; cli.fs.fail_read_open = false; cli.fs.fail_read_after = -1; cli.fs.fail_rename = 0; cli.fs.fail_rename_from.clear(); assert(cli.recoverCommonPrefsFiles(&cli.fs)); assert(cli.fs.files.at("/com_prefs") == previous); cli.loadPrefsInt(&cli.fs, "/com_prefs"); assert(cli.prefs.ota_channel == previous_channel); assertExistingPrefs(cli.prefs, expected); } } } int main() { testRoundTripsAndLayout(); testLegacyTruncatedAndCorruptImages(); testFailedTransactionsRetainPreviousChannel(); } ''' def persistence_harness(): source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="utf-8") header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="utf-8") fields = header.split("class NodePrefs :", 1)[1].split("private:", 1)[0] fields = "\n".join(re.findall( r"^\s*(?:float|double|char|u?int(?:8|16|32)_t)\s+[^;]+;", fields, re.M)) constants = "\n".join(re.findall( r"^\s*#define\s+\w+\s+(?:0x[\dA-Fa-f]+|\d+)\s*$", header, re.M)) constants += "\n" + "\n".join(re.findall( r"^static const size_t LEGACY_\w+\s*=[^;]+;", source, re.M)) constants += "\n" + re.search( r"^static constexpr uint8_t DEFAULT_ADVERT_LOC_POLICY\s*=[^;]+;", header, re.M).group() helpers = "\n".join(extract_braced(source, signature) for signature in ( "static bool bwMatches(", "static bool isValidLoRaBandwidth(", "static float defaultLoRaBandwidth(", "static void markDirectRetryPrefsValid(", "static void applyFloodRetryPreset(", "static void applyDirectRetryPreset(", "static void setDefaultDirectRetryPrefs(", "static bool directRetryPrefsValid(")) helpers = '#include \n' + helpers methods = "\n".join(extract_braced(source, signature) for signature in ( "static const char* commonPrefsSaveResultName(", "bool CommonCLI::recoverCommonPrefsFiles(", "bool CommonCLI::saveCommonPrefsImageAtomically(", "void CommonCLI::savePrefs(FILESYSTEM*", "void CommonCLI::savePrefs(PrefsSaveRouting::Scope scope)", "bool CommonCLI::trySavePrefs(", "void CommonCLI::loadPrefsInt(")) code = HARNESS[:HARNESS.index("struct Capture {")] + MAIN replacements = { "FIELDS": fields, "CONSTANTS": constants, "HELPERS": helpers, "LEGACY": extract_braced(header, "struct LegacyObserverTail") + ";", "SERIALIZER": "template\n" + extract_braced( source, "static bool writeCommonPrefsImage("), "STORE": extract_braced(source, "class CommonPrefsFileStore"), "METHODS": methods, "DYNAMIC": "", "SET_DEBUG": "", "SET_MASTER": "", "GET_DEBUG": "", "GET_MASTER": "", } for key, value in replacements.items(): code = code.replace("@" + key + "@", value) return code class OtaChannelPersistenceTests(unittest.TestCase): def test_actual_serializers_loads_and_failed_commits_for_every_platform(self): code = persistence_harness() sanitizer_flags = [] if os.name == "nt" else [ "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] variants = [("ENABLE_OTA", platform) for platform in ( "NRF52_PLATFORM", "STM32_PLATFORM", "ESP32_PLATFORM", "RP2040_PLATFORM")] variants.append(("ENABLE_OTA", "ESP32_PLATFORM", "WITH_MQTT_BRIDGE")) variants.extend((("NRF52_PLATFORM",), ("ESP32_PLATFORM", "WITH_MQTT_BRIDGE"))) with tempfile.TemporaryDirectory(prefix="common-ota-channel-") as directory: work = Path(directory) transaction = (ROOT / "src/helpers/ContactFileTransaction.h").read_text() (work / "ContactFileTransaction.h").write_text(transaction.replace( '#include "IdentityStore.h"', '#include ')) (work / "test.cpp").write_text(code, encoding="utf-8") for macros in variants: with self.subTest(macros=macros): exe = work / ("test.exe" if os.name == "nt" else "test") built = subprocess.run([ os.environ.get("CXX", "g++"), "-std=c++17", *["-D" + macro + "=1" for macro in macros], *sanitizer_flags, "-I" + str(work), "-I" + str(ROOT / "test/fixtures/radio_profiles/mocks"), "-I" + str(ROOT / "src"), "-I" + str(ROOT / "src/helpers"), str(work / "test.cpp"), "-o", str(exe)], capture_output=True, text=True, timeout=60) self.assertEqual(built.returncode, 0, built.stderr) tested = subprocess.run([str(exe)], capture_output=True, text=True, timeout=10) self.assertEqual(tested.returncode, 0, tested.stderr) if __name__ == "__main__": unittest.main()