Files
MeshCore/test/test_ota_channel_persistence.py
T

282 lines
13 KiB
Python

"""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<uint8_t> bytes;
std::map<const void*, size_t> 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<uint8_t>(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<int>(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<int>(channel_offset);
if (fault == 2) cli.fs.fail_read_open = true;
if (fault == 3) cli.fs.fail_read_after = static_cast<int>(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("))
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<typename Writer>\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 <helpers/IdentityStore.h>'))
(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()