Files
MeshCore/test/test_ota_speed.py
T
mikecarper db1c4704e0 Fix firmware budget regressions and flood alias delimiters
Share nRF52 checked-record reads and command-family lookups without changing
storage formats, error protection, fleet authorization, or delegated scopes.
Use the existing bounded Merkle proof workspace for qualified ESP32 repeaters
while retaining source capacity, wire format, and every enabled feature.
Restore advert tab/CRLF delimiters and expose nested qualification build logs.

Validation: all 1932 native CI test cases; exact canonical RAK4631 Full,
Wio-E5 Mini, Station G3 Full MQTT, and Wireless Tracker Full wrappers;
linked RAK crypto/workspace checks and retained Reader code equivalence;
focused persistence, authorization, source-proof, and RAM policy regressions.
2026-10-21 03:02:46 -07:00

172 lines
7.8 KiB
Python

"""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 <cassert>
#include <cstring>
#include <helpers/ota/OtaSpeedConfig.h>
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()