From 7321f858d8b31a9bed5573bfa3c60b1160b026f5 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 26 Oct 2026 22:21:35 -0700 Subject: [PATCH] Omit unused forwarding region automation from Full Companions --- src/helpers/RadioProfileCLI.cpp | 14 +++++---- src/helpers/RadioProfileCLI.h | 14 +++++++++ test/test_radio_profile_cli.py | 50 +++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 102836771..5127ab7ea 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -92,14 +92,17 @@ bool secondaryOverlaps(uint32_t primary_hz, float secondary_mhz, uint32_t cleara return (primary_hz > secondary_hz ? primary_hz - secondary_hz : secondary_hz - primary_hz) <= clearance_hz; } +#if !defined(COMPANION_RADIO_FULL) bool isMediaMeshProfile(const RadioProfileConfig& config) { return config.mode != RadioProfileMode::Off && config.params.freq >= 911.1495f && config.params.freq <= 911.1505f && (config.params.bw == 250.0f || config.params.bw == 125.0f) && config.params.sf == 7 && config.params.cr == 5; } +#endif } +#if !defined(COMPANION_RADIO_FULL) void RadioProfileCLI::setMediaMeshScopeHandler(MediaMeshScopeHandler handler, void* context) { media_scope_handler_ = handler; media_scope_context_ = context; @@ -129,6 +132,7 @@ void RadioProfileCLI::retryMediaMeshScope() { media_scope_pending_ = !media_scope_handler_(media_scope_context_, media_scope_enabled_, true); media_scope_retry_ms_ = media_scope_pending_ ? millis() + 1000 : 0; } +#endif RadioProfileCLI::ImageReadResult RadioProfileCLI::readImage( const char* path, uint8_t* bytes, size_t size) { @@ -934,7 +938,7 @@ void RadioProfileCLI::appendPrimaryChirpWarning(char* reply, size_t capacity, ui void RadioProfileCLI::appendSecondaryWarnings(char* reply, size_t capacity, const RadioProfiles& preview) const { if (!capacity) return; - if (media_scope_pending_) appendReply(reply, capacity, "; MediaMesh scope save pending"); + if (mediaMeshScopePending()) appendReply(reply, capacity, "; MediaMesh scope save pending"); if (preview.enabled() && RadioProfiles::valid(preview.primary) && RadioProfiles::valid(preview.secondary.params)) { const uint32_t primary_hz = frequencyHz(preview.primary.freq); @@ -1188,7 +1192,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, p.recommendationReady() ? " ready" : " pending", (unsigned long)p.switchBudgetUs()); appendChirpWarning(reply, capacity, p); } - if (media_scope_pending_) appendReply(reply, capacity, "; MediaMesh scope save pending"); + if (mediaMeshScopePending()) appendReply(reply, capacity, "; MediaMesh scope save pending"); return true; } if ((base || temporary) && verb == Get && !*args) { @@ -1199,7 +1203,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, } if (base && saved_.mode == RadioProfileMode::Off) { snprintf(reply, capacity, "> off (%s)", manual_secondary_ ? "manual" : "automatic allowed"); - if (media_scope_pending_) appendReply(reply, capacity, "; MediaMesh scope save pending"); + if (mediaMeshScopePending()) appendReply(reply, capacity, "; MediaMesh scope save pending"); return true; } #endif @@ -1227,7 +1231,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, publish_pending_ = true; publish_after_ms_ = millis() + 2000; publish(); snprintf(reply, capacity, "OK - automatic radio2 permitted in 2s"); - if (media_scope_pending_) appendReply(reply, capacity, "; MediaMesh scope save pending"); + if (mediaMeshScopePending()) appendReply(reply, capacity, "; MediaMesh scope save pending"); return true; } #endif @@ -1251,7 +1255,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, for (auto& s : schedules_) if (base || s.temporary) s = {}; publish_pending_ = true; publish_after_ms_ = millis() + 2000; publish(); snprintf(reply, capacity, "OK - %s in 2s", saved_.mode != RadioProfileMode::Off ? "saved radio2 restored" : "single radio"); - if (media_scope_pending_) appendReply(reply, capacity, "; MediaMesh scope save pending"); + if (mediaMeshScopePending()) appendReply(reply, capacity, "; MediaMesh scope save pending"); return true; } if (scheduled && (verb == Get || verb == Delete)) { diff --git a/src/helpers/RadioProfileCLI.h b/src/helpers/RadioProfileCLI.h index 56513fa87..41e1b1dbb 100644 --- a/src/helpers/RadioProfileCLI.h +++ b/src/helpers/RadioProfileCLI.h @@ -61,6 +61,7 @@ class RadioProfileCLI { // readable backup. Read/I/O failures and an intact backup remain read-only. bool recoverable_corrupt_store_ = false; bool publish_pending_ = false; +#if !defined(COMPANION_RADIO_FULL) MediaMeshScopeHandler media_scope_handler_ = nullptr; void* media_scope_context_ = nullptr; uint32_t media_scope_retry_ms_ = 0; @@ -68,6 +69,15 @@ class RadioProfileCLI { bool mediaMeshScopeReady(const RadioProfileConfig& config) const; void updateMediaMeshScope(); void retryMediaMeshScope(); + bool mediaMeshScopePending() const { return media_scope_pending_; } +#else + // Full Companions have no forwarding RegionMap. Do not retain unreachable + // infrastructure callbacks, state or failure text in their tight images. + bool mediaMeshScopeReady(const RadioProfileConfig&) const { return true; } + void updateMediaMeshScope() {} + void retryMediaMeshScope() {} + bool mediaMeshScopePending() const { return false; } +#endif #if MESH_RADIO_SWITCH_TUNING RadioSwitchTuning fast_saved_; uint32_t fast_retry_ms_ = 0; @@ -131,7 +141,11 @@ class RadioProfileCLI { #endif void begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc, bool infrastructure_replies = false); // Bind only after RegionMap::load(), so boot reconciliation is not discarded. +#if !defined(COMPANION_RADIO_FULL) void setMediaMeshScopeHandler(MediaMeshScopeHandler handler, void* context); +#else + void setMediaMeshScopeHandler(MediaMeshScopeHandler, void*) {} +#endif void servicePendingTuning(); bool hasPendingTuning() const { #if MESH_RADIO_SWITCH_TUNING diff --git a/test/test_radio_profile_cli.py b/test/test_radio_profile_cli.py index 5191f6150..01d6d49b6 100644 --- a/test/test_radio_profile_cli.py +++ b/test/test_radio_profile_cli.py @@ -9,6 +9,56 @@ import unittest ROOT = Path(__file__).resolve().parents[1] class RadioProfileCLITest(unittest.TestCase): + def test_full_companion_keeps_rf_presets_without_unused_region_code(self): + compiler = shutil.which('g++') or shutil.which('clang++') + self.assertIsNotNone(compiler) + code = r''' +#define main existing_cli_main +#include "test/fixtures/radio_profiles/cli_test.cpp" +#undef main +int main() { + Fixture f; + MediaScope scope; + f.cli.setMediaMeshScopeHandler(MediaScope::update, &scope); + f.cmd("set radio2 mediamesh"); + f.advance(2000); + assert(f.radio.p.enabled() && fabsf(f.radio.p.secondary.params.freq - 911.150f) < 0.0001f); +#if defined(COMPANION_RADIO_FULL) + assert(!scope.commits && !scope.checks); +#else + assert(scope.enabled && scope.durable && scope.commits); +#endif + f.cmd("set radio2 off"); + assert(!scope.enabled); + printf("%zu", sizeof(mesh::RadioProfileCLI)); +} +''' + sizes = {} + with tempfile.TemporaryDirectory(prefix='profile-region-role-') as work: + source = Path(work)/'role.cpp' + source.write_text(code, encoding='ascii') + for full in (False, True): + binary = Path(work)/('companion' if full else 'infrastructure') + flags = ['-DCOMPANION_RADIO_FULL=1'] if full else [] + compiled = subprocess.run([ + compiler, '-std=c++17', '-Os', '-ffunction-sections', + '-fdata-sections', '-Wl,--gc-sections', + '-DMESH_ENABLE_FLEET_CONTROL=0', '-DMESH_ENABLE_CARRIER_TOOLS=0', + '-DMESH_ENABLE_MESHTEXT_CHAT=0', '-DMESH_ENABLE_HOST_KISS=0', + '-DMESH_ENABLE_ETHERNET_TUNNEL=0', *flags, + '-I', str(ROOT), '-I', str(ROOT/'test/fixtures/radio_profiles/mocks'), + '-I', str(ROOT/'test/mocks'), '-I', str(ROOT/'src'), + str(ROOT/'src/helpers/RadioProfileCLI.cpp'), str(source), + '-o', str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(run.returncode, 0, run.stdout+run.stderr) + sizes[full] = int(run.stdout) + image = binary.read_bytes() + for marker in (b'MediaMesh scope save pending', b'MediaMesh region unavailable or full'): + self.assertEqual(marker in image, not full) + self.assertLess(sizes[True], sizes[False]) + def test_commands_persistence_and_timers(self): compiler = shutil.which('g++') or shutil.which('clang++') self.assertIsNotNone(compiler)