diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 3eaccc7c..6201954d 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -622,6 +622,19 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no if (sentinel != NodePrefs::SCHEMA_SENTINEL) { MESH_DEBUG_PRINTLN("prefs schema sentinel mismatch: got 0x%08X, expected 0x%08X — re-saving on next change", (unsigned)sentinel, (unsigned)NodePrefs::SCHEMA_SENTINEL); + // 0xC0DE002A (v1.27) → 0xC0DE002B: repeat_extra_scope_mask + ch_scope_idx + // appended. Unlike the range-clamped fields, these can't be left with whatever + // stray bytes rd() picked up from a pre-0x2B file's own sentinel tail: + // every bit/byte value is "valid" (any mask or index could be a real pick), + // so garbage here isn't caught by a range clamp -- it just silently + // masquerades as a real one, and can even reactivate later once the scope + // list grows long enough to reach an index that used to be out of range. + // Zero both outright on this transition; a fresh scope list is empty + // anyway, so there's nothing genuine to lose. + if (sentinel < 0xC0DE002B) { + _prefs.repeat_extra_scope_mask = 0; + memset(_prefs.ch_scope_idx, 0, sizeof(_prefs.ch_scope_idx)); + } } file.close(); @@ -1037,33 +1050,48 @@ void DataStore::saveChannels(DataStoreHost* host) { } } -void DataStore::loadScopeList(ScopeList& list) { +bool DataStore::loadScopeList(ScopeList& list, const NodePrefs& prefs) { File file = openRead("/scopes1"); - if (!file) { - // No /scopes1 -- fresh device, stays at the default-constructed ScopeList - // (empty, default_idx 0 == "*"). - list.count = 0; - list.default_idx = 0; - return; + if (file) { + uint8_t hdr[2] = { 0, 0 }; // default_idx is read back below even if the header read fails + bool success = (file.read(hdr, 2) == 2); + uint8_t count = success ? hdr[1] : 0; + if (count > ScopeList::MAX_SCOPE_ENTRIES) count = 0; // corrupt header -- start empty rather than overrun entries[] + + uint8_t loaded = 0; + for (uint8_t i = 0; i < count; i++) { + ScopeEntry e; + bool ok = (file.read((uint8_t *)e.name, sizeof(e.name)) == sizeof(e.name)); + ok = ok && (file.read(e.key, sizeof(e.key)) == sizeof(e.key)); + if (!ok) break; // truncated file -- keep whatever loaded fine so far + e.name[sizeof(e.name) - 1] = '\0'; + list.entries[loaded++] = e; + } + file.close(); + list.count = loaded; + list.default_idx = list.clamp(hdr[0]); + return false; // the file was already there -- nothing migrated this boot } - uint8_t hdr[2] = { 0, 0 }; // default_idx is read back below even if the header read fails - bool success = (file.read(hdr, 2) == 2); - uint8_t count = success ? hdr[1] : 0; - if (count > ScopeList::MAX_SCOPE_ENTRIES) count = 0; // corrupt header -- start empty rather than overrun entries[] - - uint8_t loaded = 0; - for (uint8_t i = 0; i < count; i++) { - ScopeEntry e; - bool ok = (file.read((uint8_t *)e.name, sizeof(e.name)) == sizeof(e.name)); - ok = ok && (file.read(e.key, sizeof(e.key)) == sizeof(e.key)); - if (!ok) break; // truncated file -- keep whatever loaded fine so far - e.name[sizeof(e.name) - 1] = '\0'; - list.entries[loaded++] = e; + // No /scopes1 yet -- one-time migration of an existing single + // default_scope_name/key (Settings > Radio > Scope, pre-list) into list + // entry 1 and mark it default, which covers DMs and the relay filter. The + // caller finishes the job for channels by seeding their per-channel picks + // once channels[] is loaded (see this function's return value). A + // never-configured device just stays at the default-constructed ScopeList + // (empty, default_idx 0 == "*"). + list.count = 0; + list.default_idx = 0; + bool migrated = (prefs.default_scope_name[0] != '\0'); + if (migrated) { + ScopeEntry& e = list.entries[0]; + StrHelper::strncpy(e.name, prefs.default_scope_name, sizeof(e.name)); + memcpy(e.key, prefs.default_scope_key, sizeof(e.key)); // already-derived key, no need to re-derive + list.count = 1; + list.default_idx = 1; } - file.close(); - list.count = loaded; - list.default_idx = list.clamp(hdr[0]); + saveScopeList(list); // write /scopes1 so this migration runs only once + return migrated; // caller seeds the existing channels with entry 1 } void DataStore::saveScopeList(const ScopeList& list) { diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index bf20455a..b0035de3 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -46,10 +46,20 @@ public: // deleted slot is simply absent from the file, not written as empty). bool loadChannels(DataStoreHost* host); void saveChannels(DataStoreHost* host); - // /scopes1: the shared named-scope list (see ScopeList.h). A device with no - // file yet just starts with the default-constructed ScopeList (empty, - // default_idx 0 == "*"). - void loadScopeList(ScopeList& list); + // /scopes1: the shared named-scope list (see ScopeList.h). `prefs` is only + // read, for a one-time migration of a pre-existing single + // default_scope_name/key into list entry 1 -- the file is authoritative + // once it exists. + // + // Returns true ONLY when that legacy migration just ran, i.e. this boot is + // the first on a device that had a Scope configured the old way. The caller + // uses that to seed the channels that already exist with the migrated entry + // (see MyMesh::begin) -- channels carry their own scope now, so without the + // seed an upgrader's channel traffic would silently drop to unscoped even + // though their DMs kept the old scope. Returns false when /scopes1 was + // already there, and on a genuinely fresh device with nothing to migrate + // (list left empty, default_idx 0 == "*"). + bool loadScopeList(ScopeList& list, const NodePrefs& prefs); void saveScopeList(const ScopeList& list); void migrateToSecondaryFS(); uint8_t getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 69d6cfc1..5b590379 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1910,7 +1910,9 @@ void MyMesh::begin(bool has_display) { // load persisted prefs _store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon); - _store->loadScopeList(_scope_list); + // True only on the first boot after upgrading a device that had the old + // single Scope field set -- acted on once the channels are loaded, below. + bool scope_migrated_legacy = _store->loadScopeList(_scope_list, _prefs); rebuildRepeatScopes(); // sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain @@ -1962,6 +1964,20 @@ void MyMesh::begin(bool has_display) { addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel } + // First boot after upgrading from the single device-wide Scope field: every + // channel now carries its own pick, and an unset pick means "*" == unscoped, + // not "inherit the default". Left alone, an upgrader's channel traffic would + // quietly go out unscoped while their DMs kept the old scope. Seed only the + // slots that actually hold a channel today -- a blanket fill would also hand + // the scope to whatever channel gets created in an empty slot later on. + if (scope_migrated_legacy && _scope_list.default_idx >= 1) { + for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) { + ChannelDetails ch; + if (getChannel(i, ch) && ch.name[0]) _prefs.ch_scope_idx[i] = _scope_list.default_idx; + } + savePrefs(); + } + applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set applyApc(); // sets TX power to the ceiling and arms APC if enabled radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);