mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-17 04:04:19 +00:00
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:
+4
-3
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user