Merge PR #7 with reviewed ExpressLRS power and memory fixes

Integrate ExpressLRS TX module support and its stacked ESP32 heap changes.
Use the approved Linkflow calibration (17-30 dBm), preserve the PA drive
and output path after radio recovery, and keep LoRa OTA enabled.

Preserve stored ACLs and filters on allocation failure, release owned
client/filter buffers, service heap OTA contexts on Companions, release
self-serving workspaces after TempRadio, and reset staged-resume state
when a context is released. Keep manual staging and active operations
alive. Account for all moved allocations in the runtime RAM gate.

Validation: 1,393 native cases; radio-power, heap-context, ACL persistence,
shared-queue transfer, display/inbox, radio receive, and memory regressions.
Firmware builds passed for Linkflow, Heltec V2 Companion, T-Beam MQTT
repeater, Heltec V4 R8 MQTT repeater, RAK4631 repeater, and Indicator Full.
Physical verification awaits access to the currently offline lab Pi.
This commit is contained in:
mikecarper
2026-09-10 22:06:16 -07:00
26 changed files with 1072 additions and 74 deletions
+4 -3
View File
@@ -160,14 +160,15 @@ static void listing_does_not_mutate_acl() {
mesh::LocalIdentity self;
acl.load(&fs, self);
for (unsigned i = 0; i < 5; ++i) add(acl, i, uint8_t(i));
const auto before = acl;
std::vector<ClientInfo> before;
for (int i = 0; i < acl.getNumClients(); ++i) before.push_back(*acl.getClientByIdx(i));
const auto writes = fs.bytes_written;
for (const char* command : {"get acl", "get acl 2", "get acl 3", "get acl 0"}) {
query(acl, command);
}
CHECK(acl.getNumClients() == before.getNumClients());
CHECK(acl.getNumClients() == int(before.size()));
for (int i = 0; i < acl.getNumClients(); ++i) {
CHECK(memcmp(acl.getClientByIdx(i), before.getClientByIdx(i), sizeof(ClientInfo)) == 0);
CHECK(memcmp(acl.getClientByIdx(i), &before[i], sizeof(ClientInfo)) == 0);
}
CHECK(fs.bytes_written == writes);
}
+30 -1
View File
@@ -10,6 +10,11 @@
std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #condition); std::exit(1); \
} } while (0)
static bool fail_client_allocation = false;
void* operator new[](std::size_t size, const std::nothrow_t&) noexcept {
return fail_client_allocation ? nullptr : ::operator new[](size);
}
static const uint8_t KEY[PUB_KEY_SIZE] = {0x12, 0x57, 0xae, 0xe5};
static const char* PRIMARY = mesh::CLIENT_LOGIN_REPLAY_PRIMARY_PATH;
static const char* TEMP = mesh::CLIENT_LOGIN_REPLAY_TEMP_PATH;
@@ -447,8 +452,32 @@ static void clamp_backup_cleanup_failure_does_not_mutate_live() {
CHECK(fs.files == before && fs.bytes_written == 0 && client->last_timestamp == 1000);
}
static void allocation_failure_preserves_saved_clients() {
FakeFilesystem fs;
{
ClientACL original;
original.load(&fs, SELF);
CHECK(original.putClient(mesh::Identity(KEY), PERM_ACL_ADMIN));
CHECK(original.save(&fs));
}
const auto saved = fs.files;
fail_client_allocation = true;
ClientACL unavailable;
fail_client_allocation = false;
unavailable.load(&fs, SELF);
CHECK(unavailable.getNumClients() == 0);
CHECK(!unavailable.putClient(mesh::Identity(SECOND_KEY), PERM_ACL_ADMIN));
const auto writes = fs.bytes_written;
CHECK(!unavailable.save(&fs));
CHECK(fs.files == saved && fs.bytes_written == writes);
ClientACL recovered;
recovered.load(&fs, SELF);
CHECK(recovered.getNumClients() == 1 && recovered.getClient(KEY, PUB_KEY_SIZE));
}
int main() {
const struct { const char* name; void (*run)(); } tests[] = {
{"allocation failure preserves clients", allocation_failure_preserves_saved_clients},
{"missing read differs from empty file", missing_read_is_not_empty_file},
{"first admin and monotonic retries", first_admin_and_retries},
{"reboot preserves ceiling", reboot_preserves_ceiling},
@@ -478,5 +507,5 @@ int main() {
test.run();
std::printf("PASS: %s\n", test.name);
}
std::puts("24 ClientACL SPIFFS checks passed");
std::puts("25 ClientACL SPIFFS checks passed");
}
+2 -2
View File
@@ -25,8 +25,8 @@ class ClientAclSpiffsTest(unittest.TestCase):
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn("24 ClientACL SPIFFS checks passed", checked.stdout)
self.assertEqual(checked.stdout.count("PASS:"), 24)
self.assertIn("25 ClientACL SPIFFS checks passed", checked.stdout)
self.assertEqual(checked.stdout.count("PASS:"), 25)
if __name__ == "__main__":
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Exercise the production DAC PA wrapper with a recording radio transport."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class ElrsPowerTest(unittest.TestCase):
def test_linkflow_uses_approved_calibration_and_keeps_ota(self):
target = (ROOT / "variants/geprc_linkflow_900/target.cpp").read_text()
levels = re.findall(r"\{\s*(\d+),\s*(\d+)\s*\}", target)
self.assertEqual(levels, [("17", "0"), ("20", "22"), ("24", "50"),
("27", "75"), ("30", "130"), ("33", "225")])
config = (ROOT / "variants/geprc_linkflow_900/platformio.ini").read_text()
self.assertIn("MIN_LORA_TX_POWER=17", config)
self.assertIn("MAX_LORA_TX_POWER=30", config)
self.assertNotIn("-<helpers/ota/>", config)
unflags = config.split("build_unflags =", 1)[1].split("build_src_filter", 1)[0]
self.assertNotIn("ENABLE_OTA", unflags)
def test_startup_recovery_limits_and_radio_errors(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp)
(path / "Arduino.h").write_text("#include <cstdint>\n#include <cstddef>\ninline void dacWrite(uint8_t, uint8_t) {}\n")
(path / "CustomSX1276Wrapper.h").write_text(r'''
#pragma once
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_ERR_INVALID_OUTPUT_POWER -1
namespace mesh { struct MainBoard {}; }
struct CustomSX1276 {
int power = 17, error = 0, calls = 0;
bool rfo = false;
int16_t setOutputPower(int8_t dbm, bool use_rfo) {
++calls;
if (error) return error;
power = dbm; rfo = use_rfo; return 0;
}
};
class CustomSX1276Wrapper {
protected:
CustomSX1276* _radio;
int8_t cached = 0;
virtual int16_t applyCachedTxPower(int8_t dbm) = 0;
public:
CustomSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard&) : _radio(&radio) {}
bool setTxPower(int8_t dbm) {
if (applyCachedTxPower(dbm)) return false;
cached = dbm; return true;
}
bool recover() {
_radio->power = 17; _radio->rfo = false;
return applyCachedTxPower(cached) == 0;
}
};
''')
# Preserve the production wrapper; substitute only its base transport.
(path / "DacPaSX1276Wrapper.h").write_text(
(ROOT / "src/helpers/radiolib/DacPaSX1276Wrapper.h").read_text())
(path / "test.cpp").write_text(r'''
#include "DacPaSX1276Wrapper.h"
#include <cassert>
struct Driver : DacPaSX1276Wrapper {
using DacPaSX1276Wrapper::DacPaSX1276Wrapper;
int gain = -1, writes = 0;
void writeGainControl(uint8_t code) override { gain = code; ++writes; }
};
int main() {
mesh::MainBoard board;
CustomSX1276 radio;
const DacPaLevel levels[] = {{10,30}, {17,50}, {24,80}, {30,130}, {33,225}};
Driver fixed(radio, board, 26, levels, 5, 30);
assert(fixed.beginPowerControl(17) && radio.power == 2 && fixed.gain == 50);
assert(fixed.setTxPower(30) && fixed.gain == 130);
assert(fixed.recover() && radio.power == 2 && fixed.gain == 130);
for (int dbm = -128; dbm <= 127; ++dbm) {
const int writes = fixed.writes, calls = radio.calls;
const bool valid = dbm >= 10 && dbm <= 30;
assert(fixed.setTxPower(dbm) == valid);
if (!valid) assert(fixed.writes == writes && radio.calls == calls);
}
assert(fixed.setTxPower(23) && fixed.gain == 50);
radio.error = -7;
const int writes = fixed.writes;
assert(!fixed.setTxPower(24) && fixed.writes == writes);
assert(!fixed.beginPowerControl(17) && fixed.writes == writes);
radio.error = 0;
const int8_t steps[] = {2,6,9,10,12};
Driver stepped(radio, board, 26, levels, 5, 30, steps, true);
assert(stepped.beginPowerControl(24) && radio.power == 9 && radio.rfo);
assert(stepped.recover() && radio.power == 9 && radio.rfo);
Driver rfo(radio, board, 26, levels, 5, 30, nullptr, true, -4);
assert(rfo.beginPowerControl(17) && rfo.recover() && radio.power == -4 && radio.rfo);
Driver capped(radio, board, 26, levels, 5, 23);
assert(capped.beginPowerControl(33) && capped.gain == 50);
assert(!capped.setTxPower(24));
Driver impossible(radio, board, 26, levels, 5, 9);
assert(!impossible.beginPowerControl(17) && impossible.writes == 0);
Driver empty(radio, board, 26, nullptr, 0);
assert(!empty.beginPowerControl(17) && empty.writes == 0);
}
''')
binary = path / "power.exe"
flags = [] if os.name == "nt" else ["-fsanitize=address,undefined"]
result = subprocess.run(["c++", "-std=c++17", *flags, "-I", temp,
str(path / "test.cpp"), "-o", str(binary)],
text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
subprocess.run([str(binary)], check=True)
if __name__ == "__main__":
unittest.main()
+19
View File
@@ -66,6 +66,25 @@ def esp_fixture(path, modern=False, fragmented=False):
class FirmwareRamTest(unittest.TestCase):
def test_heap_tables_and_ota_remain_in_runtime_budget(self):
policy = ram.requirements("ESP32_PLATFORM", {
"ENABLE_OTA": 1, "OTA_HEAP_CONTEXT": 1,
}, "GEPRC_Linkflow_900_repeater")
self.assertEqual(policy["components"]["client_table"], 32 * 320 + 16)
self.assertEqual(policy["components"]["flood_filter_table"], 63 * 200 + 16)
self.assertEqual(policy["components"]["ota_context"], 16384 + 16)
self.assertGreaterEqual(policy["required_contiguous_bytes"], 16384 + 16)
reduced = ram.requirements("STM32_PLATFORM", {
"MAX_CLIENTS": 2, "FLOOD_PACKET_FILTER_SLOTS": 8,
}, "wio_repeater")
self.assertEqual(reduced["components"]["client_table"], 2 * 320 + 16)
self.assertEqual(reduced["components"]["flood_filter_table"], 8 * 40 + 16)
companion = ram.requirements("NRF52_PLATFORM", {}, "t114_companion_radio_ble")
self.assertNotIn("client_table", companion["components"])
self.assertNotIn("flood_filter_table", companion["components"])
sensor = ram.requirements("NRF52_PLATFORM", {}, "t114_sensor")
self.assertEqual(sensor["components"]["client_table"], 32 * 320 + 16)
def test_browser_terminal_reserves_internal_session_and_psram_aware_scrollback(self):
defines = {"ENABLE_USB_INTERFACE": 1, "WIFI_SSID": "", "DISPLAY_CLASS": "SSD1306Display"}
base = ram.requirements("ESP32_PLATFORM", {**defines, "WEBCONFIG_DISABLED": 1}, "v4_companion")
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Run the real heap-backed OTA context and its lifetime boundaries on host."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
from test_t096_full_memory import method
ROOT = Path(__file__).resolve().parents[1]
class OtaHeapTest(unittest.TestCase):
def test_build_policy_only_selects_classic_esp32_and_respects_storage_owner(self):
class Env(dict):
def BoardConfig(self):
return {"build.mcu": self["mcu"]}
def Append(self, **values):
for key, value in values.items():
self.setdefault(key, []).extend(value)
script = (ROOT / "scripts/esp32_ota_heap_context.py").read_text()
for mcu in ("esp32", "esp32s3", "esp32s2", "esp32c3", "nrf52840"):
env = Env(mcu=mcu)
exec(script, {"env": env, "Import": lambda _: None})
self.assertEqual(env.get("CPPDEFINES", []),
[("OTA_HEAP_CONTEXT", 1)] if mcu == "esp32" else [])
for flags in ("-DOTA_SHARED_COMPANION_QUEUE=1", ["-D", "OTA_SHARED_COMPANION_QUEUE=1"],
["-DOTA_HEAP_CONTEXT=1"]):
env = Env(mcu="esp32", BUILD_FLAGS=flags)
exec(script, {"env": env, "Import": lambda _: None})
self.assertNotIn("CPPDEFINES", env)
env = Env(mcu="esp32", CPPDEFINES=[("OTA_SHARED_COMPANION_QUEUE", 1)])
exec(script, {"env": env, "Import": lambda _: None})
self.assertEqual(env["CPPDEFINES"], [("OTA_SHARED_COMPANION_QUEUE", 1)])
def test_context_releases_self_serve_but_preserves_live_operations(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp)
source = path / "test.cpp"
absent_context = method((ROOT / "src/Mesh.cpp").read_text(),
"if (!ota::ota_context_if_active())")
# Exercise the production absence guard with install support enabled,
# independently of the source-only storage used by this host fixture.
absent_context = absent_context.replace("#if !defined(OTA_SEEDER_ONLY)", "#if 1")
(path / "mesh_absence.h").write_text(
"namespace ota = mesh::ota;\nstruct MeshMaintenance {\n"
"bool _ota_temp_was_active = true, _ota_resumed = true, _ota_autoinstall_tried = true;\n"
"void service() {\n" + absent_context + "\n}\n};\n")
source.write_text(r'''
#include <helpers/ota/OtaContext.h>
#include <cassert>
#include <new>
#include "mesh_absence.h"
using namespace mesh::ota;
static bool fail_allocation = false;
void* operator new(std::size_t size, const std::nothrow_t&) noexcept {
return fail_allocation ? nullptr : ::operator new(size);
}
namespace mesh { namespace ota {
bool ota_self_firmware(SelfFwInfo& info) { info = SelfFwInfo(); return false; }
} }
static bool send(void*, const uint8_t*, uint16_t, bool) { return true; }
int main() {
MeshMaintenance maintenance;
maintenance.service();
assert(!maintenance._ota_temp_was_active && !maintenance._ota_resumed
&& !maintenance._ota_autoinstall_tried);
char reply[160] = {};
assert(!ota_acquire_context(reply, sizeof(reply)));
ota_begin_context(123, send, nullptr, "test", nullptr);
fail_allocation = true;
assert(!ota_acquire_context(reply, sizeof(reply)));
assert(strstr(reply, "out of memory") && !ota_context_if_active());
fail_allocation = false;
for (int cycle = 0; cycle < 16; ++cycle) {
ota_service_temp_radio_context(true);
assert(ota_context_if_active());
auto& c = ota_ctx();
c.manager.set_max_hops(7);
c.autoinstall = OtaContext::AUTOINSTALL_TRUSTED;
c.serving = true;
c.serve_self_leaves = static_cast<uint8_t*>(malloc(64));
c.serve_self_proof = static_cast<uint8_t*>(malloc(64));
assert(c.ensureServeBuffer());
ota_service_temp_radio_context(true);
assert(ota_context_if_active() == &c);
ota_service_temp_radio_context(false);
assert(!ota_context_if_active());
assert(ota_hop_limit() == 7);
}
assert(ota_acquire_context(reply, sizeof(reply)));
assert(ota_ctx().autoinstall == OtaContext::AUTOINSTALL_TRUSTED);
assert(ota_ctx().manager.max_hops() == 7);
ota_ctx().apply_pending = true;
ota_service_temp_radio_context(false);
assert(ota_context_if_active());
ota_ctx().apply_pending = false;
ota_ctx().folder_active = true;
ota_service_temp_radio_context(false);
assert(ota_context_if_active());
ota_ctx().folder_active = false;
ota_ctx().folder_dest = reinterpret_cast<FolderMotaStore*>(1);
ota_service_temp_radio_context(false);
assert(ota_context_if_active());
ota_ctx().folder_dest = nullptr;
// Manual staging is a series of CLI commands, even outside TempRadio.
ota_ctx().serve_expected = 100;
assert(ota_ctx().ensureServeBuffer());
ota_ctx().serve_buf[0] = 0x42;
ota_service_temp_radio_context(false);
assert(ota_context_if_active() && ota_ctx().serve_buf[0] == 0x42);
ota_ctx().serve_expected = 0;
ota_service_temp_radio_context(false);
assert(!ota_context_if_active());
}
''')
flags = [] if os.name == "nt" else ["-fsanitize=address,undefined"]
tinf = path / "tinf.o"
subprocess.run([shutil.which("cc") or "gcc", "-DENABLE_OTA=1", *flags, "-c",
str(ROOT / "src/helpers/ota/OtaTinf.c"), "-o", str(tinf)], check=True)
sources = ["OtaContext.cpp", "OtaManager.cpp", "OtaProtocol.cpp",
"MotaContainer.cpp", "MerkleTree.cpp", "OtaDeflate.cpp"]
binary = path / "heap.exe"
result = subprocess.run([
"c++", "-std=c++17", *flags, "-DENABLE_OTA=1",
"-DOTA_HEAP_CONTEXT=1", "-DESP32_PLATFORM=1", "-DOTA_SEEDER_ONLY=1",
"-I", str(ROOT / "src"), "-I", str(ROOT / "test/mocks"),
str(source), *[str(ROOT / "src/helpers/ota" / name) for name in sources],
str(ROOT / "src/Utils.cpp"), str(tinf), "-o", str(binary),
], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
subprocess.run([str(binary)], check=True)
if __name__ == "__main__":
unittest.main()