diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 5f0a83a94..625ef3c72 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -268,6 +268,7 @@ jobs: python3 -B test/test_management_feature_gate.py -v python3 -B test/test_carrier_wave.py -v python3 -B test/test_radio_profile_cli.py -v + python3 -B test/test_mediamesh_scope_integration.py -v python3 -B test/test_radio_switch_tuning_cli.py -v python3 -B test/test_radio_switch_tuning_transaction.py -v python3 -B test/test_radio_switch_driver_controls.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 472431ae4..1f793625f 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -15,6 +15,10 @@ For a saved MediaMesh second profile, use `set radio2 mediamesh` (911.150 MHz, 250 kHz, SF7, CR 4/5, `rxtx`, automatic preamble), or `set radio2 mediamesh-range` for 125 kHz. These leave the primary profile and crossing policy unchanged. Both radio2 and `radio2.cross` default to `off`. +On repeaters, room servers and sensors, either preset also saves an allowed +`#mediamesh` forwarding region. `set radio2 off` or `set radio2 auto` disables +that permission while retaining the region entry. Default/home regions, +wildcard permissions and outgoing app scopes stay unchanged. See [MediaMesh presets](radio_profiles.md#mediamesh-presets). For supported Full Companions, see [Meshtext channel chat](radio_profiles.md#meshtext-channel-chat) diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 32f1589c3..36f8e6c4d 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -64,6 +64,19 @@ The RF settings match the presets documented by and [Wadamesh TAK](https://github.com/jade-on-mesh/wadamesh-tak/blob/main/MEDIAMESH.md). Nodes exchanging media need matching radio settings. +On repeaters, room servers and sensors, either preset also creates and enables +the public `#mediamesh` region for scoped flood forwarding, saving the permission +automatically. Later RF tuple changes keep that permission. `set radio2 off` or +`set radio2 auto` disables it but retains the region entry; selecting either +MediaMesh preset enables it again. A full region table rejects the preset +before changing the radio settings. Remote changes apply after the command's +acknowledgement, alongside the profile change. + +This permission allows matching incoming scoped floods; it does not select the +default scope for outgoing traffic or enable crossing between profiles. +Default/home regions, the wildcard policy and app-selected scopes stay +unchanged. Companions need no additional forwarding-region permission. + Both leave the primary profile and `radio2.cross` unchanged. The center is 625 kHz above a 910.525 MHz primary. Automatic preamble remains scan-aware; neither preset fixes it at 32 symbols. See [preamble](#preamble) for the diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 5b46651db..5d770a7a2 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -4039,7 +4039,8 @@ void MyMesh::begin(FILESYSTEM *fs) { if (!guest_login.enabled()) acl.removeGuestClients(); mesh::hilStartupTrace("mesh_acl_ready"); // TODO: key_store.begin(); - region_map.load(_fs); + const bool regions_loaded = region_map.load(_fs); + _cli.onRegionsLoaded(_fs, regions_loaded); mesh::hilStartupTrace("mesh_regions_ready"); #if !defined(PORTABLE_MQTT_OBSERVER) #if MESH_ENABLE_FLOOD_RULE_ENGINE diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 14e83d1cd..4721bd129 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -2795,7 +2795,8 @@ void MyMesh::begin(FILESYSTEM *fs) { getRNG()->random((uint8_t*)&room_web_boot_id, sizeof(room_web_boot_id)); if (room_web_boot_id == 0) room_web_boot_id = 1; #endif - region_map.load(_fs); + const bool regions_loaded = region_map.load(_fs); + _cli.onRegionsLoaded(_fs, regions_loaded); _clock_sync.begin(_fs); if (mesh::loadRoomTopic(_fs, room_topic)) { if (room_topic[0] != 0) activateRoomTopic(); diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 8804ec727..76ebaa054 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1357,7 +1357,8 @@ void SensorMesh::begin(FILESYSTEM* fs) { _cli.beginManagement(*this, _fs); acl.load(_fs, self_id); - region_map.load(_fs); + const bool regions_loaded = region_map.load(_fs); + _cli.onRegionsLoaded(_fs, regions_loaded); _clock_sync.begin(_fs); updateGpsTelemetryPolicy(); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index a7ae5a238..aab50a352 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -16,6 +16,7 @@ #include "HostKiss.h" #include "PowerSavingDiagnostics.h" #include "HilStartupTrace.h" +#include "FilePresence.h" #if defined(ESP32_PLATFORM) #include #include @@ -166,6 +167,57 @@ static bool isGpioConfig(const char* config) { } #endif +bool CommonCLI::mediaMeshRegionStoreMissing(FILESYSTEM* fs) { + if (!fs) return false; + static const char* const paths[] = {"/regions2", "/regions2.tmp", "/regions2.bak"}; + for (const char* path : paths) { +#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM) + bool present; + if (!mesh::filePresence(fs, path, present) || present) return false; +#else + if (fs->exists(path)) return false; +#endif + } + return true; +} + +bool CommonCLI::updateMediaMeshScope(void* context, bool enable, bool commit) { + auto* cli = static_cast(context); + if (!cli || !cli->_region_map || !cli->_callbacks || !cli->_media_regions_ready) return false; + auto* region = cli->_region_map->findByName("mediamesh"); + if (!commit) { + return !enable || region || cli->_region_map->getCount() < MAX_REGION_ENTRIES; + } + if (!region) { + if (!enable) return true; + region = cli->_region_map->putRegion("mediamesh", 0); + if (!region) return false; + } + if (enable) region->flags &= ~REGION_DENY_FLOOD; + else region->flags |= REGION_DENY_FLOOD; + // Retry storage even when the RAM flags already match: the previous attempt + // may have failed after this mutation. RadioProfileCLI owns paced retries. + return cli->_callbacks->saveRegions(); +} + +void CommonCLI::onRegionsLoaded(FILESYSTEM* fs, bool loaded) { + _management_fs = fs; + _media_regions_ready = fs && (loaded || mediaMeshRegionStoreMissing(fs)); + _media_regions_retry_ms = millis() + 1000; + _radio_profiles.setMediaMeshScopeHandler(updateMediaMeshScope, this); +} + +void CommonCLI::recoverMediaMeshRegions() { + if (_media_regions_ready || !_management_fs || !_region_map || !_callbacks + || (int32_t)(millis() - _media_regions_retry_ms) < 0) return; + _media_regions_retry_ms = millis() + 1000; + const bool loaded = _region_map->load(_management_fs); + if (!loaded && !mediaMeshRegionStoreMissing(_management_fs)) return; + _media_regions_ready = true; + if (loaded) _callbacks->onDefaultRegionChanged(_region_map->getDefaultRegion()); + _radio_profiles.setMediaMeshScopeHandler(updateMediaMeshScope, this); +} + void CommonCLI::loop() { #if MESH_ENABLE_BATTERY_FORWARDING loopBatteryForwarding(); @@ -180,6 +232,7 @@ void CommonCLI::loop() { } #endif if (!mesh::morseTimingActive(_callbacks->getProfileRadio())) { + recoverMediaMeshRegions(); servicePendingTxPower(); servicePendingRxSettings(); _radio_profiles.loop(); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 24a4bcac0..5da5f4f97 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -953,6 +953,11 @@ class CommonCLI { #endif SensorManager* _sensors; RegionMap* _region_map; + uint32_t _media_regions_retry_ms = 0; + bool _media_regions_ready = false; + static bool mediaMeshRegionStoreMissing(FILESYSTEM* fs); + void recoverMediaMeshRegions(); + static bool updateMediaMeshScope(void* context, bool enable, bool commit); ClientACL* _acl; char tmp[COMMON_CLI_TMP_LEN]; #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) @@ -1072,6 +1077,9 @@ public: uint16_t preamble); mesh::RadioProfileCLI& radioProfiles() { return _radio_profiles; } const mesh::RadioProfileCLI& radioProfiles() const { return _radio_profiles; } + // Bind only after the role has loaded its saved regions, so boot-time + // radio2 scope reconciliation does not get replaced by the region image. + void onRegionsLoaded(FILESYSTEM* fs, bool loaded); mesh::MorseReplyHandoff& morseReply() { return _morse_reply; } const mesh::MorseReplyHandoff& morseReply() const { return _morse_reply; } #if MESH_ENABLE_FLEET_CONTROL diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index d5bec064c..102836771 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -92,6 +92,42 @@ 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; } +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; +} +} + +void RadioProfileCLI::setMediaMeshScopeHandler(MediaMeshScopeHandler handler, void* context) { + media_scope_handler_ = handler; + media_scope_context_ = context; + media_scope_pending_ = false; + updateMediaMeshScope(); +} + +bool RadioProfileCLI::mediaMeshScopeReady(const RadioProfileConfig& config) const { + return !media_scope_handler_ || !isMediaMeshProfile(config) + || media_scope_handler_(media_scope_context_, true, false); +} + +void RadioProfileCLI::updateMediaMeshScope() { + // A held/unreadable radio store is unknown intent, not a saved Off choice. + if (!media_scope_handler_ || hold_) return; + if (isMediaMeshProfile(saved_)) media_scope_enabled_ = true; + else if (saved_.mode == RadioProfileMode::Off) media_scope_enabled_ = false; + else return; // Custom MediaMesh tuning retains the saved region permission. + media_scope_pending_ = true; + media_scope_retry_ms_ = 0; + retryMediaMeshScope(); +} + +void RadioProfileCLI::retryMediaMeshScope() { + if (!media_scope_pending_ || !media_scope_handler_ || hold_ + || (media_scope_retry_ms_ && (int32_t)(millis() - media_scope_retry_ms_) < 0)) return; + media_scope_pending_ = !media_scope_handler_(media_scope_context_, media_scope_enabled_, true); + media_scope_retry_ms_ = media_scope_pending_ ? millis() + 1000 : 0; } RadioProfileCLI::ImageReadResult RadioProfileCLI::readImage( @@ -722,6 +758,7 @@ bool RadioProfileCLI::stageRemoteSchedule(unsigned slot, const Schedule& schedul bool RadioProfileCLI::applyReplyMutation() { const auto kind = remote_mutation_; + if (kind == RemoteMutation::Saved && !mediaMeshScopeReady(remote_config_)) return false; if ((kind == RemoteMutation::Saved || kind == RemoteMutation::Off) && !commitSavedImage(ReplyPath)) return false; remote_mutation_ = RemoteMutation::None; @@ -775,6 +812,7 @@ bool RadioProfileCLI::applyReplyMutation() { } // loop() owns publishing, after all completion/failure callbacks have // returned and Dispatcher no longer owns either response packet. + if (kind == RemoteMutation::Saved || kind == RemoteMutation::Off) updateMediaMeshScope(); return true; } @@ -801,6 +839,7 @@ void RadioProfileCLI::servicePendingTuning() { void RadioProfileCLI::loop() { const uint32_t now = millis(); servicePendingTuning(); + retryMediaMeshScope(); if (!radio_ || !radio_->profiles()) return; const uint32_t elapsed = now - last_ms_; last_ms_ = now; @@ -836,13 +875,15 @@ void RadioProfileCLI::loop() { if (s.active && !s.temporary && epoch >= s.start && (!next || s.start < next->start)) next = &s; } if (!next) break; - if (!save(next->config, primary_preamble_, cross_, true)) { + if (!mediaMeshScopeReady(next->config) + || !save(next->config, primary_preamble_, cross_, true)) { // Keep the requested time and order intact. A failed earlier entry must // not be retried after a later one and overwrite the newer settings. schedule_retry_ms_ = 60000; break; } saved_ = next->config; manual_secondary_ = true; noteSecondaryManualChange(); *next = {}; + updateMediaMeshScope(); } for (auto& s : schedules_) { if (!s.active || !s.temporary || s.started || epoch < s.start || temp_active_ || temp_pending_ || hasReplyMutation()) continue; @@ -893,6 +934,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 (preview.enabled() && RadioProfiles::valid(preview.primary) && RadioProfiles::valid(preview.secondary.params)) { const uint32_t primary_hz = frequencyHz(preview.primary.freq); @@ -1146,6 +1188,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"); return true; } if ((base || temporary) && verb == Get && !*args) { @@ -1156,6 +1199,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"); return true; } #endif @@ -1179,9 +1223,11 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, } noteSecondaryManualChange(); saved_ = {}; manual_secondary_ = false; + updateMediaMeshScope(); 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"); return true; } #endif @@ -1197,11 +1243,15 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, return true; } noteSecondaryManualChange(); - if (base) { saved_ = {}; manual_secondary_ = true; schedule_retry_ms_ = 0; } + if (base) { + saved_ = {}; manual_secondary_ = true; schedule_retry_ms_ = 0; + updateMediaMeshScope(); + } temp_pending_ = temp_active_ = false; temp_remaining_ms_ = 0; 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"); return true; } if (scheduled && (verb == Get || verb == Delete)) { @@ -1276,6 +1326,9 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, if (valid) valid = preview.automaticPreambleFits(); if (!valid) { snprintf(reply, capacity, "Error: use %s f,bw,sf,cr,rx|rxtx%s[,preamble|auto]", key, scheduled_temp ? ",start,end (UTC or +minutes)" : scheduled ? ",start (UTC or +minutes)" : temporary ? ",minutes" : ""); return true; } + if (base && !mediaMeshScopeReady(config)) { + cli::formatError(reply, capacity, "MediaMesh region unavailable or full"); return true; + } if (scheduled) { if (!epoch || start <= epoch || (uint64_t)(start-epoch) * 1000 > 0x7fffffffULL || (scheduled_temp && (end <= start || (uint64_t)(end-epoch) * 1000 > 0x7fffffffULL))) { @@ -1340,6 +1393,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, noteSecondaryManualChange(); saved_ = config; manual_secondary_ = true; + updateMediaMeshScope(); publish_pending_ = true; publish_after_ms_ = millis() + 2000; publish(); } diff --git a/src/helpers/RadioProfileCLI.h b/src/helpers/RadioProfileCLI.h index a53d1aa33..56513fa87 100644 --- a/src/helpers/RadioProfileCLI.h +++ b/src/helpers/RadioProfileCLI.h @@ -12,6 +12,9 @@ namespace mesh { // separate versioned image so existing preference offsets stay compatible. class RadioProfileCLI { public: + // Infrastructure binds its loaded region map. Check calls cannot mutate it; + // commit calls update live permission and report whether it was persisted. + using MediaMeshScopeHandler = bool (*)(void* context, bool enable, bool commit); struct Schedule { RadioProfileConfig config; uint32_t start = 0, end = 0; @@ -58,6 +61,13 @@ class RadioProfileCLI { // readable backup. Read/I/O failures and an intact backup remain read-only. bool recoverable_corrupt_store_ = false; bool publish_pending_ = false; + MediaMeshScopeHandler media_scope_handler_ = nullptr; + void* media_scope_context_ = nullptr; + uint32_t media_scope_retry_ms_ = 0; + bool media_scope_pending_ = false, media_scope_enabled_ = false; + bool mediaMeshScopeReady(const RadioProfileConfig& config) const; + void updateMediaMeshScope(); + void retryMediaMeshScope(); #if MESH_RADIO_SWITCH_TUNING RadioSwitchTuning fast_saved_; uint32_t fast_retry_ms_ = 0; @@ -120,6 +130,8 @@ class RadioProfileCLI { static void configureBootTuning(FILESYSTEM*, Radio&) {} #endif void begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc, bool infrastructure_replies = false); + // Bind only after RegionMap::load(), so boot reconciliation is not discarded. + void setMediaMeshScopeHandler(MediaMeshScopeHandler handler, void* context); void servicePendingTuning(); bool hasPendingTuning() const { #if MESH_RADIO_SWITCH_TUNING diff --git a/src/helpers/RegionMap.cpp b/src/helpers/RegionMap.cpp index e5eb8baa8..4c9d50147 100644 --- a/src/helpers/RegionMap.cpp +++ b/src/helpers/RegionMap.cpp @@ -188,6 +188,7 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) { if (loaded_next == 0xFFFF && loaded_count < MAX_REGION_ENTRIES) success = false; if (success) { + _store->invalidateCache(); memcpy(regions, loaded, loaded_count * sizeof(RegionEntry)); if (loaded_count < MAX_REGION_ENTRIES) { memset(®ions[loaded_count], 0, (MAX_REGION_ENTRIES - loaded_count) * sizeof(RegionEntry)); diff --git a/src/helpers/TransportKeyStore.h b/src/helpers/TransportKeyStore.h index e3ba15243..9b8f8b746 100644 --- a/src/helpers/TransportKeyStore.h +++ b/src/helpers/TransportKeyStore.h @@ -19,10 +19,12 @@ class TransportKeyStore { int num_cache; void putCache(uint16_t id, const TransportKey& key); - void invalidateCache() { num_cache = 0; } public: TransportKeyStore() { num_cache = 0; } + // Region reloads can assign an existing ID to a different public name. + // Discard only derived/cached keys; stored private keys remain untouched. + void invalidateCache() { num_cache = 0; } void getAutoKeyFor(uint16_t id, const char* name, TransportKey& dest); int loadKeysFor(uint16_t id, TransportKey keys[], int max_num); bool saveKeysFor(uint16_t id, const TransportKey keys[], int num); diff --git a/test/fixtures/common_cli_tx_pending.cpp b/test/fixtures/common_cli_tx_pending.cpp index 8bb97fe0b..8265ef749 100644 --- a/test/fixtures/common_cli_tx_pending.cpp +++ b/test/fixtures/common_cli_tx_pending.cpp @@ -89,6 +89,7 @@ public: CommonCLI() { callbacks.owner = this; } @PENDING_QUERY@ void loopManagement() {} + void recoverMediaMeshRegions() {} void loop(); void applyAndSaveTxPower(int8_t, char*); void servicePendingTxPower(); diff --git a/test/fixtures/mediamesh_scope_integration.cpp b/test/fixtures/mediamesh_scope_integration.cpp new file mode 100644 index 000000000..a395629df --- /dev/null +++ b/test/fixtures/mediamesh_scope_integration.cpp @@ -0,0 +1,378 @@ +// Run the unchanged CommonCLI policy callback with real RegionMap, transport +// keys, packet transport codes, and platform atomic region storage. +#include +#include +#include +#include +#include +#include +#include +#if defined(NRF52_PLATFORM) +#define ATOMIC_FILE_WRITER_IMPLEMENTATION +#include +#else +#include +#include +#endif + +class RadioProfilesAdapter { +public: + using Handler = bool (*)(void*, bool, bool); + Handler handler = nullptr; + void* context = nullptr; + unsigned bindings = 0; + void setMediaMeshScopeHandler(Handler value, void* owner) { + ++bindings; + handler = value; + context = owner; + } + bool update(bool enable, bool commit) { return handler(context, enable, commit); } +}; + +class MyMesh { +public: + RegionMap& region_map; + TransportKey default_scope{}; + explicit MyMesh(RegionMap& regions) : region_map(regions) {} + void onDefaultRegionChanged(const RegionEntry* region); +}; + +class RegionCallbacks { + RegionMap& regions_; + MemoryFS& fs_; +public: + MyMesh role; + unsigned save_calls = 0; + unsigned default_changes = 0; + uint16_t last_default = 0; + RegionCallbacks(RegionMap& regions, MemoryFS& fs) : regions_(regions), fs_(fs), role(regions) {} + bool saveRegions() { ++save_calls; return regions_.save(&fs_); } + void onDefaultRegionChanged(const RegionEntry* region) { + ++default_changes; + last_default = region ? region->id : 0; + role.onDefaultRegionChanged(region); + } +}; + +class CommonCLI { + RegionMap* _region_map; + RegionCallbacks* _callbacks; + FILESYSTEM* _management_fs = nullptr; + uint32_t _media_regions_retry_ms = 0; + bool _media_regions_ready = false; + static bool mediaMeshRegionStoreMissing(FILESYSTEM* fs); + static bool updateMediaMeshScope(void* context, bool enable, bool commit); +public: + RadioProfilesAdapter _radio_profiles; + CommonCLI(RegionMap* regions, RegionCallbacks* callbacks) + : _region_map(regions), _callbacks(callbacks) {} + void onRegionsLoaded(FILESYSTEM* fs, bool loaded); + void recoverMediaMeshRegions(); +}; + +#include "scope_under_test.inc" + +static void require(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "FAIL: %s\n", message); + std::exit(1); + } +} + +struct Scenario { + TransportKeyStore keys; + MemoryFS fs; + RegionMap regions{keys}; + RegionCallbacks callbacks{regions, fs}; + CommonCLI cli{®ions, &callbacks}; + + Scenario() { + g_mock_millis = 0; + metadata_filesystem = &fs; + cli.onRegionsLoaded(&fs, false); + require(cli._radio_profiles.handler != nullptr + && cli._radio_profiles.context == &cli, + "actual onRegionsLoaded binds the callback to this CLI"); + } + bool update(bool enable, bool commit = true) { + return cli._radio_profiles.update(enable, commit); + } + bool durable_allowed() { + TransportKeyStore restarted_keys; + RegionMap restarted(restarted_keys); + require(restarted.load(&fs), "reboot loads committed region table"); + const auto* region = restarted.findByName("mediamesh"); + require(region != nullptr, "reboot retains the MediaMesh scope entry"); + return (region->flags & REGION_DENY_FLOOD) == 0; + } +}; + +static mesh::Packet media_packet() { + mesh::Packet packet; + packet.header = ROUTE_TYPE_TRANSPORT_FLOOD | (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT); + const uint8_t payload[] = {0x43, 0x56, 0x03, 0x00, 0xc0, 0xdb, 0xff}; + std::memcpy(packet.payload, payload, sizeof(payload)); + packet.payload_len = sizeof(payload); + // Independent Python hashlib/hmac vector for SHA256('#mediamesh')[:16] + // and HMAC-SHA256(key, payload_type || payload)[:2]. + const uint8_t bytes[] = {0x74, 0xb3}; + std::memcpy(&packet.transport_codes[0], bytes, sizeof(bytes)); + packet.transport_codes[1] = 0; + return packet; +} + +static void scope_filter() { + Scenario test; + auto* unrelated = test.regions.putRegion("#local", 0); + require(unrelated != nullptr, "create independent scope"); + unrelated->flags = REGION_DENY_FLOOD | REGION_DENY_DIRECT; + test.regions.setDefaultRegion(unrelated); + test.regions.setHomeRegion(unrelated); + test.regions.getWildcard().flags = REGION_DENY_FLOOD | REGION_DENY_DIRECT; + require(test.regions.save(&test.fs), "write independent scope baseline"); + const auto baseline = test.fs.files; + require(test.update(true, false), "preflight has capacity"); + require(test.regions.getCount() == 1 && test.fs.files == baseline + && test.callbacks.save_calls == 0, "preflight does not mutate or save"); + mesh::Packet packet = media_packet(); + require(test.regions.findMatch(&packet, REGION_DENY_FLOOD) == nullptr, + "unknown MediaMesh scope is blocked before selecting the profile"); + require(test.update(true), "preset enables and saves MediaMesh scope"); + auto* media = test.regions.findByName("mediamesh"); + require(media && !std::strcmp(media->name, "mediamesh") && media->parent == 0, + "new public MediaMesh scope has the exact name and root parent"); + TransportKey key; + require(test.regions.getTransportKeysFor(*media, &key, 1) == 1, + "derive actual automatic transport key"); + const uint8_t expected_key[] = {0xb9, 0xec, 0x7b, 0x4d, 0x14, 0xca, 0x1f, 0x9b, 0x4b, 0x4b, 0xc4, 0xa5, 0x26, 0x6e, 0x15, 0x12}; + require(!std::memcmp(key.key, expected_key, sizeof(key.key)), + "public MediaMesh key matches independent SHA256 vector"); + require(key.calcTransportCode(&packet) == packet.transport_codes[0], + "actual packet code matches independent HMAC vector"); + require(test.regions.findMatch(&packet, REGION_DENY_FLOOD) == media, + "MediaMesh packets pass through the actual region flood filter"); + require(test.durable_allowed(), "enabled scope survives reboot"); + require(test.regions.getDefaultRegion()->id == unrelated->id + && test.regions.getHomeRegion()->id == unrelated->id + && test.regions.getWildcard().flags == (REGION_DENY_FLOOD | REGION_DENY_DIRECT) + && unrelated->flags == (REGION_DENY_FLOOD | REGION_DENY_DIRECT), + "scope enrollment does not change the primary/default/home/wildcard policy"); + require(test.update(false), "radio2 off denies and saves the scope"); + require(test.regions.findMatch(&packet, REGION_DENY_FLOOD) == nullptr, + "radio2 off stops forwarding MediaMesh-scoped packets"); + require(!test.durable_allowed(), "disabled scope survives reboot"); +} + +static void existing_policy() { + Scenario test; + auto* parent = test.regions.putRegion("#parent", 0); + auto* media = test.regions.putRegion("#mediamesh", parent->id); + auto* child = test.regions.putRegion("child", media->id); + require(parent && media && child, "create pre-existing scope hierarchy"); + const auto parent_id = parent->id, media_id = media->id, child_id = child->id; + media->flags = REGION_DENY_FLOOD | REGION_DENY_DIRECT | 0x80; + test.regions.setDefaultRegion(media); + test.regions.setHomeRegion(child); + test.regions.getWildcard().flags = REGION_DENY_FLOOD; + require(test.update(true), "enable existing hashed scope"); + require(test.regions.getCount() == 3 && media->id == media_id + && media->parent == parent_id && media->flags == (REGION_DENY_DIRECT | 0x80) + && !std::strcmp(media->name, "#mediamesh") + && child->id == child_id && child->parent == media_id, + "existing entry is neither duplicated, renamed, reparented nor stripped"); + require(test.update(false), "disable existing scope"); + require(media->flags == (REGION_DENY_FLOOD | REGION_DENY_DIRECT | 0x80) + && media->parent == parent_id && test.regions.getCount() == 3 + && test.regions.getDefaultRegion()->id == media_id + && test.regions.getHomeRegion()->id == child_id + && test.regions.getWildcard().flags == REGION_DENY_FLOOD, + "disable preserves direct policy, hierarchy, home/default and wildcard"); +} + +static void full_table() { + Scenario test; + for (int i = 0; i < MAX_REGION_ENTRIES; ++i) { + char name[31]; + std::snprintf(name, sizeof(name), "region%d", i); + require(test.regions.putRegion(name, 0) != nullptr, "fill region table"); + } + require(!test.update(true, false), "full table rejects a new scope at preflight"); + require(!test.update(true), "full table cannot silently enroll a missing scope"); + require(test.update(false, false) && test.update(false), + "missing scope disable succeeds even on a full table"); + require(test.regions.getCount() == MAX_REGION_ENTRIES && test.fs.files.empty() + && test.callbacks.save_calls == 0, "failed enrollment has no side effects"); + // Use the real existing entry, including a leading hash, at full capacity. + auto* entry = test.regions.findByName("region0"); + std::strcpy(entry->name, "#mediamesh"); + require(test.update(true, false) && test.update(true), + "full table can enable its existing MediaMesh entry"); + require(test.regions.getCount() == MAX_REGION_ENTRIES && test.durable_allowed(), + "existing full-table scope is saved without an extra allocation"); +} + +static void save_retry() { + Scenario test; + auto* entry = test.regions.putRegion("mediamesh", 0); + require(entry && test.regions.save(&test.fs), "save denied scope baseline"); + require(!test.durable_allowed(), "baseline is denied after reboot"); + test.fs.fail_write_open = true; + require(!test.update(true), "enable reports an actual region save failure"); + require(!(entry->flags & REGION_DENY_FLOOD) && !test.durable_allowed(), + "failed save keeps old durable state and dirty new RAM flags"); + const unsigned failed_calls = test.callbacks.save_calls; + test.fs.fail_write_open = false; + require(test.update(true) && test.callbacks.save_calls == failed_calls + 1 + && test.durable_allowed(), "retry saves even though RAM is already enabled"); + test.fs.fail_write_after = 5; + require(!test.update(false), "disable reports short-write failure"); + test.fs.fail_write_after = -1; + require((entry->flags & REGION_DENY_FLOOD) && test.durable_allowed(), + "short-write retains old enabled durable state"); + const unsigned disable_calls = test.callbacks.save_calls; + require(test.update(false) && test.callbacks.save_calls == disable_calls + 1 + && !test.durable_allowed(), "retry saves even though RAM is already disabled"); +} + +static void invalid_context() { + Scenario test; + auto callback = test.cli._radio_profiles.handler; + require(!callback(nullptr, true, false) && !callback(nullptr, true, true), + "missing callback context fails safely"); + CommonCLI missing_map(nullptr, &test.callbacks); + missing_map.onRegionsLoaded(&test.fs, false); + require(!missing_map._radio_profiles.update(true, true), "missing map is rejected"); + CommonCLI missing_callbacks(&test.regions, nullptr); + missing_callbacks.onRegionsLoaded(&test.fs, false); + require(!missing_callbacks._radio_profiles.update(true, true), "missing save callback is rejected"); + require(test.regions.getCount() == 0 && test.fs.files.empty(), + "invalid callback contexts do not alter policy"); +} + +static void unreadable_boot() { + Scenario test; + auto* local = test.regions.putRegion("#local", 0); + auto* media = test.regions.putRegion("mediamesh", local->id); + require(local && media, "build saved hierarchy before read failure"); + local->flags = REGION_DENY_DIRECT; + const uint16_t local_id = local->id; + test.regions.setDefaultRegion(local); + test.regions.setHomeRegion(local); + require(test.regions.save(&test.fs), "save pre-existing hierarchy"); + const auto baseline = test.fs.files; + const auto writes = test.fs.open_writes; + + TransportKeyStore boot_keys; + RegionMap boot_regions(boot_keys); + auto* fallback = boot_regions.putRegion("#bootdefault", 0); + require(fallback && fallback->id == local_id, "boot fallback reuses the stored default ID"); + TransportKey cached_fallback; + require(boot_regions.getTransportKeysFor(*fallback, &cached_fallback, 1) == 1, + "cache fallback public key before the delayed reload"); + RegionCallbacks callbacks(boot_regions, test.fs); + CommonCLI boot(&boot_regions, &callbacks); + test.fs.fail_read_open = true; + const bool loaded = boot_regions.load(&test.fs); + require(!loaded, "simulate actual unreadable region startup"); + boot.onRegionsLoaded(&test.fs, loaded); + for (bool enable : {false, true}) { + require(!boot._radio_profiles.update(enable, false) + && !boot._radio_profiles.update(enable, true), + "unknown existing region state rejects checks and commits"); + } + require(test.fs.files == baseline && test.fs.open_writes == writes + && callbacks.save_calls == 0 && boot_regions.getCount() == 1, + "failed startup leaves committed hierarchy byte-exact"); + g_mock_millis = 1000; + boot.recoverMediaMeshRegions(); + require(test.fs.files == baseline && boot._radio_profiles.bindings == 1, + "failed background reload does not recreate the table"); + test.fs.fail_read_open = false; + g_mock_millis = 1999; + boot.recoverMediaMeshRegions(); + require(boot_regions.getCount() == 1, "background reload is paced"); + g_mock_millis = 2000; + boot.recoverMediaMeshRegions(); + require(boot_regions.getCount() == 2 && boot._radio_profiles.bindings == 2 + && callbacks.default_changes == 1 && callbacks.last_default == local_id, + "successful reload restores hierarchy and cached outgoing default scope"); + // Independent hashlib.sha256(b"#local").digest()[:16] vector. + const TransportKey expected_default{{0xd2, 0xd3, 0x5f, 0xa7, 0x6b, 0xe9, 0x87, 0x5e, 0xd2, 0x54, 0xdb, 0x80, 0x39, 0x74, 0x83, 0xa5}}; + require(std::memcmp(cached_fallback.key, expected_default.key, sizeof(expected_default.key)) + && !std::memcmp(callbacks.role.default_scope.key, expected_default.key, + sizeof(expected_default.key)), + "actual default-region callback derives the reloaded name, not the stale same-ID key"); + require(test.fs.files == baseline && test.fs.open_writes == writes, + "successful read alone does not rewrite storage"); + require(boot._radio_profiles.update(true, false) + && boot._radio_profiles.update(true, true), + "recovered region state accepts normal enrollment without a reboot"); + require(boot_regions.findByName("mediamesh")->parent == local_id + && boot_regions.getDefaultRegion()->id == local_id + && boot_regions.getHomeRegion()->id == local_id, + "late enrollment preserves the recovered hierarchy and routing selections"); + boot.recoverMediaMeshRegions(); + require(boot._radio_profiles.bindings == 2 && callbacks.default_changes == 1, + "healthy maps do not reload or rebind every loop"); +} + +static void malformed_boot() { + for (const char* path : {"/regions2", "/regions2.tmp", "/regions2.bak"}) { + Scenario test; + test.fs.files[path] = {0xaa, 0xbb, 0xcc}; + const auto before = test.fs.files; + test.cli.onRegionsLoaded(&test.fs, false); + require(!test.update(true, false) && !test.update(true) + && !test.update(false), + "malformed image or transaction sibling cannot look like a fresh store"); + require(test.fs.files == before && test.fs.open_writes == 0 + && test.callbacks.save_calls == 0 && test.regions.getCount() == 0, + "automatic enrollment leaves unknown bytes and siblings untouched"); + // Ordinary reads may recover a .bak path, so exercise repeated malformed + // reads on the public image and orphan staging path where no rename occurs. + if (std::strcmp(path, "/regions2.bak")) { + for (unsigned attempt = 1; attempt <= 3; ++attempt) { + g_mock_millis = attempt * 1000; + test.cli.recoverMediaMeshRegions(); + require(test.fs.files == before && test.fs.open_writes == 0, + "repeated malformed reads never overwrite the unknown region image"); + } + } + } +} + +static void metadata_boot() { +#if defined(ESP32_PLATFORM) + for (const char* path : {"/regions2", "/regions2.tmp", "/regions2.bak"}) { + Scenario test; + test.fs.metadata_errors.insert(path); + test.cli.onRegionsLoaded(&test.fs, false); + require(!test.update(true, false) && !test.update(true), + "metadata error cannot prove a fresh missing store"); + g_mock_millis = 1000; + test.cli.recoverMediaMeshRegions(); + require(!test.update(true) && test.fs.files.empty() && test.fs.open_writes == 0, + "metadata errors remain read-only across background retries"); + test.fs.metadata_errors.clear(); + g_mock_millis = 2000; + test.cli.recoverMediaMeshRegions(); + require(test.update(true, false) && test.update(true) && test.durable_allowed(), + "conclusively fresh store recovers automatically after metadata heals"); + } +#endif +} + +int main(int argc, char** argv) { + require(argc == 2, "select one test scenario"); + const char* name = argv[1]; + if (!std::strcmp(name, "scope-filter")) scope_filter(); + else if (!std::strcmp(name, "existing-policy")) existing_policy(); + else if (!std::strcmp(name, "full-table")) full_table(); + else if (!std::strcmp(name, "save-retry")) save_retry(); + else if (!std::strcmp(name, "invalid-context")) invalid_context(); + else if (!std::strcmp(name, "unreadable-boot")) unreadable_boot(); + else if (!std::strcmp(name, "malformed-boot")) malformed_boot(); + else if (!std::strcmp(name, "metadata-boot")) metadata_boot(); + else require(false, "known scenario"); + std::printf("PASS: %s\n", name); +} diff --git a/test/fixtures/morse_reply_loop.cpp b/test/fixtures/morse_reply_loop.cpp index b27e70db5..d767f623f 100644 --- a/test/fixtures/morse_reply_loop.cpp +++ b/test/fixtures/morse_reply_loop.cpp @@ -26,6 +26,7 @@ struct CommonCLI { unsigned management_loops = 0, tx_services = 0; CommonCLI(ACL& acl, Callbacks& callbacks) : _acl(&acl), _callbacks(&callbacks) {} void loopBatteryForwarding(bool = false) {} + void recoverMediaMeshRegions() {} void loopManagement() { ++management_loops; } void servicePendingTxPower() { ++tx_services; } void servicePendingRxSettings() {} diff --git a/test/fixtures/radio_profiles/cli_test.cpp b/test/fixtures/radio_profiles/cli_test.cpp index deccdfd75..2d847332d 100644 --- a/test/fixtures/radio_profiles/cli_test.cpp +++ b/test/fixtures/radio_profiles/cli_test.cpp @@ -548,6 +548,143 @@ static void mediaMeshPresets() { assert(!invalid.radio.p.enabled() && invalid.fs.files.empty()); } +struct MediaScope { + bool available = true, fail_save = false, enabled = false, durable = false; + unsigned checks = 0, commits = 0; + static bool update(void* context, bool enable, bool commit) { + auto& self = *static_cast(context); + if (!commit) { ++self.checks; return !enable || self.available; } + ++self.commits; + if (enable && !self.available) return false; + self.enabled = enable; + if (self.fail_save) return false; + self.durable = enable; + return true; + } +}; + +static void mediaMeshScopeLifecycle() { + for (const char* command : {"set radio2 mediamesh", "set radio2 mediamesh-range"}) { + Fixture f(true); + MediaScope scope; + f.cli.setMediaMeshScopeHandler(MediaScope::update, &scope); + assert(!scope.enabled && !scope.durable); + const auto before = f.fs.files; + const unsigned commits = scope.commits; + scope.available = false; + assert(strstr(f.cmd(command, false), "region unavailable or full")); + assert(f.fs.files == before && scope.commits == commits); + scope.available = true; + f.fs.fail_write = true; + f.cmd(command, false); + assert(!scope.enabled && scope.commits == commits); + f.fs.fail_write = false; + scope.fail_save = true; + assert(strstr(f.cmd(command), "scope save pending")); + assert(scope.enabled && !scope.durable); + assert(strstr(f.cmd("get radio2.status"), "scope save pending")); + const unsigned pending_commits = scope.commits; + f.advance(999); + assert(scope.commits == pending_commits); + scope.fail_save = false; + f.advance(1); + assert(scope.durable && scope.commits == pending_commits+1); + assert(!strstr(f.cmd("get radio2.status"), "scope save pending")); + + // An interrupted region save is repaired from the committed radio2 preset + // only after its saved region map has been supplied by infrastructure. + Radio reboot; + mesh::RadioProfileCLI restored; + restored.begin(&f.fs, &reboot, &f.clock, true); + MediaScope loaded; + restored.setMediaMeshScopeHandler(MediaScope::update, &loaded); + assert(loaded.enabled && loaded.durable); + + // Explicit RF tuning after a preset keeps the region's saved permission. + const unsigned enabled_commits = scope.commits; + f.cmd("set radio2 912.5,250,8,5,rxtx"); + assert(scope.enabled && scope.durable && scope.commits == enabled_commits); + Radio custom_radio; + mesh::RadioProfileCLI custom; + custom.begin(&f.fs, &custom_radio, &f.clock, true); + const unsigned loaded_commits = loaded.commits; + custom.setMediaMeshScopeHandler(MediaScope::update, &loaded); + assert(loaded.enabled && loaded.durable && loaded.commits == loaded_commits); + f.cmd("set radio2 off"); + assert(!scope.enabled && !scope.durable); + f.cmd(command); + assert(scope.enabled && scope.durable); + + // Off wins after a reboot even if the old region save was interrupted. + scope.fail_save = true; + assert(strstr(f.cmd("set radio2 off"), "scope save pending")); + assert(!scope.enabled && scope.durable); + Radio off_radio; + mesh::RadioProfileCLI off; + off.begin(&f.fs, &off_radio, &f.clock, true); + loaded.enabled = loaded.durable = true; + off.setMediaMeshScopeHandler(MediaScope::update, &loaded); + assert(!loaded.enabled && !loaded.durable); + } + for (bool delivered : {false, true}) { + Fixture f(true); + MediaScope scope; + f.cli.setMediaMeshScopeHandler(MediaScope::update, &scope); + const auto before = f.fs.files; + const unsigned commits = scope.commits; + f.cli.beginReplyCommand(); + f.cmd("set radio2 mediamesh"); + f.cli.endReplyCommand(); + assert(!scope.enabled && scope.commits == commits); + f.advance(2000); + assert(!scope.enabled && scope.commits == commits); + f.cli.finishReplyMutation(delivered); + f.advance(1); + assert(scope.enabled == delivered && scope.durable == delivered); + if (!delivered) assert(f.fs.files == before); + if (delivered) { + f.cli.beginReplyCommand(); f.cmd("set radio2 off"); f.cli.endReplyCommand(); + assert(scope.enabled); + f.cli.finishReplyMutation(false); f.advance(1); + assert(scope.enabled && scope.durable); + f.cli.beginReplyCommand(); f.cmd("set radio2 off"); f.cli.endReplyCommand(); + f.cli.finishReplyMutation(true); f.advance(1); + assert(!scope.enabled && !scope.durable); + } + } + // Capacity can change while the old profile is retained for a remote reply. + Fixture f(true); + MediaScope scope; + f.cli.setMediaMeshScopeHandler(MediaScope::update, &scope); + const auto before = f.fs.files; + f.cli.beginReplyCommand(); f.cmd("set radio2 mediamesh"); f.cli.endReplyCommand(); + scope.available = false; + f.cli.finishReplyMutation(true); f.advance(1); + assert(f.cli.hasReplyMutation() && !scope.enabled); + assert(f.fs.files.find("/radio_profiles") == f.fs.files.end()); + scope.available = true; + f.advance(1000); + assert(!f.cli.hasReplyMutation() && scope.enabled && scope.durable); + + // Unknown radio intent must not disable an existing scope during recovery. + const auto valid_image = f.fs.files["/radio_profiles"]; + for (bool unreadable : {false, true}) { + f.fs.files["/radio_profiles"] = valid_image; + if (unreadable) f.fs.fail_read_open = true; + else f.fs.files["/radio_profiles"].resize(1); + Radio held_radio; + mesh::RadioProfileCLI held; + held.begin(&f.fs, &held_radio, &f.clock, true); + MediaScope held_scope; + held_scope.enabled = held_scope.durable = true; + held.setMediaMeshScopeHandler(MediaScope::update, &held_scope); + assert(held_scope.enabled && held_scope.durable && !held_scope.commits); + held.loop(); + assert(held_scope.enabled && !held_scope.commits); + f.fs.fail_read_open = false; + } +} + int main(int argc, char** argv) { static_assert(uint8_t(mesh::RadioCrossMode::Auto)==0 && uint8_t(mesh::RadioCrossMode::On)==1 && uint8_t(mesh::RadioCrossMode::Off)==2, "saved crossover encoding must remain compatible"); @@ -594,6 +731,7 @@ int main(int argc, char** argv) { else if (!strcmp(argv[1], "relative_schedule")) relativeSchedules(); else if (!strcmp(argv[1], "spectrum_overlap")) spectrumOverlap(); else if (!strcmp(argv[1], "mediamesh")) mediaMeshPresets(); + else if (!strcmp(argv[1], "media_scope")) mediaMeshScopeLifecycle(); else return 2; return 0; } diff --git a/test/test_mediamesh_scope_integration.py b/test/test_mediamesh_scope_integration.py new file mode 100644 index 000000000..fbcc411b3 --- /dev/null +++ b/test/test_mediamesh_scope_integration.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""MediaMesh scope policy through the real CommonCLI and RegionMap methods.""" + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +from cpp_source import body +from test_region_map_transactions import region_implementation + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/mediamesh_scope_integration.cpp" +REGION_FIXTURE = ROOT / "test/fixtures/region_map_transactions" + + +def implementation(): + region = (ROOT / "src/helpers/RegionMap.cpp").read_text(encoding="ascii") + generated = region_implementation() + for signature in ("int RegionMap::getTransportKeysFor(", + "RegionEntry* RegionMap::findMatch("): + generated += "\n" + body(region, signature) + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") + generated += "\n" + body(common, "bool CommonCLI::mediaMeshRegionStoreMissing(") + generated += "\n" + body(common, "bool CommonCLI::updateMediaMeshScope(") + generated += "\n" + body(common, "void CommonCLI::onRegionsLoaded(") + generated += "\n" + body(common, "void CommonCLI::recoverMediaMeshRegions(") + repeater = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="ascii") + generated += "\n" + body(repeater, "void MyMesh::onDefaultRegionChanged(") + packet = (ROOT / "src/Packet.cpp").read_text(encoding="ascii") + generated += "\nnamespace mesh {\n" + body(packet, "Packet::Packet(") + "\n}\n" + return generated + + +class MediaMeshScopeIntegrationTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + raise AssertionError("a native C++ compiler is required") + cls.temporary = tempfile.TemporaryDirectory(prefix="meshcore-mediamesh-scope-") + cls.work = Path(cls.temporary.name) + (cls.work / "scope_under_test.inc").write_text(implementation(), encoding="ascii") + (cls.work / "contact_transaction_under_test.h").write_text( + (ROOT / "src/helpers/ContactFileTransaction.h").read_text(encoding="ascii"), + encoding="ascii") + (cls.work / "IdentityStore.h").write_text( + '#include \n', encoding="ascii") + (cls.work / "PersistentStoreFormat.h").write_text( + '#include \n', encoding="ascii") + # The host SHA-256 implementation computes real digests. Only adapt its + # typed output pointers to the firmware library's void* signature. + (cls.work / "SHA256.h").write_text(f''' +#pragma once +#define SHA256 NativeSHA256 +#include "{ROOT / 'test/mocks/SHA256.h'}" +#undef SHA256 +class SHA256 : public NativeSHA256 {{ public: + void finalize(void* out, size_t length) {{ + NativeSHA256::finalize(static_cast(out), length); + }} + void finalizeHMAC(const uint8_t* key, size_t size, void* out, size_t length) {{ + NativeSHA256::finalizeHMAC(key, size, static_cast(out), length); + }} +}}; +''', encoding="ascii") + cls.binaries = {} + for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM"): + binary = cls.work / platform + command = [ + compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-sign-compare", "-Wno-unused-function", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-D" + platform + "=1", "-I", str(cls.work), + "-I", str(REGION_FIXTURE / "mocks"), + "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + str(FIXTURE), str(ROOT / "src/helpers/TransportKeyStore.cpp"), + "-o", str(binary), + ] + result = subprocess.run(command, capture_output=True, text=True, timeout=60) + if result.returncode: + cls.temporary.cleanup() + raise AssertionError(result.stdout + result.stderr) + cls.binaries[platform] = binary + + @classmethod + def tearDownClass(cls): + cls.temporary.cleanup() + + def run_case(self, case): + environment = os.environ.copy() + environment["ASAN_OPTIONS"] = "detect_leaks=1:abort_on_error=1" + environment["UBSAN_OPTIONS"] = "halt_on_error=1:print_stacktrace=1" + for platform, binary in self.binaries.items(): + with self.subTest(platform=platform): + result = subprocess.run([str(binary), case], capture_output=True, + text=True, timeout=60, env=environment) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS: " + case, result.stdout) + + def test_real_scope_key_filter_and_reboot_persistence(self): + self.run_case("scope-filter") + + def test_existing_hierarchy_and_nonflood_policy_are_preserved(self): + self.run_case("existing-policy") + + def test_full_region_table_preflight_and_missing_scope_disable(self): + self.run_case("full-table") + + def test_save_failures_retry_even_when_ram_flags_already_match(self): + self.run_case("save-retry") + + def test_invalid_callback_context_is_refused(self): + self.run_case("invalid-context") + + def test_unreadable_existing_regions_are_held_then_reloaded_automatically(self): + self.run_case("unreadable-boot") + + def test_malformed_regions_and_transaction_siblings_are_not_overwritten(self): + self.run_case("malformed-boot") + + def test_metadata_errors_cannot_look_like_a_fresh_region_store(self): + self.run_case("metadata-boot") + + def test_all_infrastructure_roles_bind_after_loading_regions(self): + for directory, filename, classname in ( + ("simple_repeater", "MyMesh.cpp", "MyMesh"), + ("simple_room_server", "MyMesh.cpp", "MyMesh"), + ("simple_sensor", "SensorMesh.cpp", "SensorMesh")): + with self.subTest(role=directory): + source = (ROOT / "examples" / directory / filename).read_text(encoding="ascii") + begin = body(source, "void " + classname + "::begin(") + self.assertLess(begin.index("_cli.loadPrefs(_fs)"), + begin.index("region_map.load(_fs)")) + self.assertLess(begin.index("region_map.load(_fs)"), + begin.index("_cli.onRegionsLoaded(_fs, regions_loaded)")) + self.assertIn("const bool regions_loaded = region_map.load(_fs);", begin) + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") + loop = body(common, "void CommonCLI::loop()") + guarded = body(loop, "if (!mesh::morseTimingActive(") + self.assertIn("recoverMediaMeshRegions();", guarded) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_loop_policy.py b/test/test_morse_loop_policy.py index 7b3c23915..48194e893 100644 --- a/test/test_morse_loop_policy.py +++ b/test/test_morse_loop_policy.py @@ -174,6 +174,7 @@ struct CommonCLI { struct Profiles { unsigned loops=0; void loop() { ++loops; } } _radio_profiles; unsigned management=0, tx_services=0, rx_services=0; void loopBatteryForwarding(bool = false) {} + void recoverMediaMeshRegions() {} void loopManagement() { ++management; } void servicePendingTxPower() { ++tx_services; } void servicePendingRxSettings() { ++rx_services; } diff --git a/test/test_radio_profile_auto.py b/test/test_radio_profile_auto.py index 1506b2d64..aae9bad99 100644 --- a/test/test_radio_profile_auto.py +++ b/test/test_radio_profile_auto.py @@ -207,6 +207,12 @@ static void automaticPermission() { f.cmd("del radioat2 all"); f.cmd("set radio2 auto"); f.advance(2000); assert(f.cli.canAutoSecondary()); + MediaScope scope; + f.cli.setMediaMeshScopeHandler(MediaScope::update, &scope); + f.cmd("set radio2 mediamesh"); f.advance(2000); + assert(scope.enabled && scope.durable); + f.cmd("set radio2 auto"); + assert(!scope.enabled && !scope.durable); } static void unmanagedTakeover() { diff --git a/test/test_radio_profile_cli.py b/test/test_radio_profile_cli.py index 4d3768cbb..5191f6150 100644 --- a/test/test_radio_profile_cli.py +++ b/test/test_radio_profile_cli.py @@ -27,7 +27,7 @@ class RadioProfileCLITest(unittest.TestCase): '-o', str(exe)], check=True, capture_output=True, text=True) for scenario in (None, 'permanent_order', 'temporary_boundary', 'lease_creation', 'save_failure', 'schedule_permutations', 'storage_isolation', - 'relative_schedule', 'spectrum_overlap', 'mediamesh'): + 'relative_schedule', 'spectrum_overlap', 'mediamesh', 'media_scope'): with self.subTest(fleet=fleet_enabled, carrier=carrier_enabled, scenario=scenario or 'commands_persistence_and_timers'): subprocess.run([str(exe)] + ([scenario] if scenario else []), check=True) diff --git a/test/test_radio_rx_settings_transaction.py b/test/test_radio_rx_settings_transaction.py index 70d9e25d7..60ff1aa71 100644 --- a/test/test_radio_rx_settings_transaction.py +++ b/test/test_radio_rx_settings_transaction.py @@ -151,6 +151,7 @@ public: void loopManagement() {} void servicePendingTxPower() {} void loop(); + void recoverMediaMeshRegions() {} Result applyRxSetting(uint8_t); void servicePendingRxSettings(); bool saveRxSetting(uint8_t, Result, char*);