Manage MediaMesh forwarding region with saved radio2 profiles

This commit is contained in:
mikecarper committed 2026-10-26 22:15:37 -07:00
1 parent a301820be3
commit 71e42b2e62
21 files changed
+831 -7

No files matched your search

+1
View File
@@ -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
+4
View File
@@ -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)
+13
View File
@@ -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
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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();
+2 -1
View File
@@ -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();
+53
View File
@@ -16,6 +16,7 @@
#include "HostKiss.h"
#include "PowerSavingDiagnostics.h"
#include "HilStartupTrace.h"
#include "FilePresence.h"
#if defined(ESP32_PLATFORM)
#include <helpers/esp32/CpuClockPolicy.h>
#include <helpers/esp32/PowerTestControls.h>
@@ -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<CommonCLI*>(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();
+8
View File
@@ -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
+56 -2
View File
@@ -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();
}
+12
View File
@@ -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
+1
View File
@@ -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(&regions[loaded_count], 0, (MAX_REGION_ENTRIES - loaded_count) * sizeof(RegionEntry));
+3 -1
View File
@@ -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);
+1
View File
@@ -89,6 +89,7 @@ public:
CommonCLI() { callbacks.owner = this; }
@PENDING_QUERY@
void loopManagement() {}
void recoverMediaMeshRegions() {}
void loop();
void applyAndSaveTxPower(int8_t, char*);
void servicePendingTxPower();
+378
View File
@@ -0,0 +1,378 @@
// Run the unchanged CommonCLI policy callback with real RegionMap, transport
// keys, packet transport codes, and platform atomic region storage.
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <helpers/RegionMap.h>
#include <helpers/RegionNameUtils.h>
#include <helpers/TxtDataHelpers.h>
#if defined(NRF52_PLATFORM)
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
#include <helpers/AtomicFileWriter.h>
#else
#include <contact_transaction_under_test.h>
#include <helpers/FilePresence.h>
#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{&regions, &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);
}
+1
View File
@@ -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() {}
+138
View File
@@ -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<MediaScope*>(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;
}
+147
View File
@@ -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 <helpers/IdentityStore.h>\n', encoding="ascii")
(cls.work / "PersistentStoreFormat.h").write_text(
'#include <helpers/PersistentStoreFormat.h>\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<uint8_t*>(out), length);
}}
void finalizeHMAC(const uint8_t* key, size_t size, void* out, size_t length) {{
NativeSHA256::finalizeHMAC(key, size, static_cast<uint8_t*>(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()
+1
View File
@@ -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; }
+6
View File
@@ -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() {
+1 -1
View File
@@ -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)
@@ -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*);