mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-09-14 15:16:40 +00:00
feat(companion): contact expiry + prune, and scope-list follow-up fixes
Settings > Contacts gains "Expire" (Off/7d/30d/90d) and a "Prune now" action
that counts first and asks before removing anything. A contact with no
advert/update within the threshold is eligible; favourites are always kept.
Thresholds and their labels live in one NodePrefs table so the age shown and
the age enforced can't drift. SCHEMA_SENTINEL -> 0xC0DE002C (sizeof unchanged
at 2824, confirmed on native and a real WioTrackerL1 build).
Also fixes four bugs in the scope list from 89c02eea:
- removeScope() saved /scopes1 but never savePrefs(), so the ch_scope_idx[]
and repeat_extra_scope_mask fix-ups it makes were lost on reboot, leaving
shifted entries against unshifted indices.
- CMD_SET_DEFAULT_FLOOD_SCOPE wrote the legacy fields directly instead of
going through setPrimaryScope(), so the app's default-scope setting had
nothing reading it once sends resolved through the list.
- The reverse direction was stale too: an on-device "Set as default" never
refreshed default_scope_name/key, so CMD_GET_DEFAULT_FLOOD_SCOPE reported
a scope the device had stopped using. New syncLegacyDefaultScope().
- Upgrading from the old single Scope field set the default (so DMs kept it)
but left every channel unscoped, since "*" means unscoped, not "inherit".
loadScopeList() now reports when it migrated and begin() seeds the channels
that already exist, leaving empty slots alone.
"Set default" -> "Set as default", and setting it now says what it governs
("Default: DMs + relay") rather than leaving the [default] marker to imply
more than it does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -627,6 +627,14 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
|||||||
rd(&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask));
|
rd(&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask));
|
||||||
rd(_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx));
|
rd(_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx));
|
||||||
|
|
||||||
|
// → 0xC0DE002C: append contact_expiry_idx. A pre-0x2C file has a stray
|
||||||
|
// sentinel byte here; clamp anything outside the real option range (see
|
||||||
|
// NodePrefs::contactExpiryDays) back to 0/Off -- an upgrader must opt into
|
||||||
|
// pruning deliberately, not inherit a garbage index that happens to alias a
|
||||||
|
// real option.
|
||||||
|
rd(&_prefs.contact_expiry_idx, sizeof(_prefs.contact_expiry_idx));
|
||||||
|
if (_prefs.contact_expiry_idx >= NodePrefs::CONTACT_EXPIRY_COUNT) _prefs.contact_expiry_idx = 0;
|
||||||
|
|
||||||
// Schema sentinel: bumped on layout changes. Mismatch means an older file
|
// Schema sentinel: bumped on layout changes. Mismatch means an older file
|
||||||
// (or a different schema); rd() already zero-inits any fields not present,
|
// (or a different schema); rd() already zero-inits any fields not present,
|
||||||
// so we just log it — next savePrefs writes the current sentinel.
|
// so we just log it — next savePrefs writes the current sentinel.
|
||||||
@@ -693,6 +701,10 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
|||||||
_prefs.repeat_extra_scope_mask = 0;
|
_prefs.repeat_extra_scope_mask = 0;
|
||||||
memset(_prefs.ch_scope_idx, 0, sizeof(_prefs.ch_scope_idx));
|
memset(_prefs.ch_scope_idx, 0, sizeof(_prefs.ch_scope_idx));
|
||||||
}
|
}
|
||||||
|
// 0xC0DE002B → 0xC0DE002C: contact_expiry_idx appended. Deliberately no
|
||||||
|
// entry here -- unlike the scope fields above it has a small closed set of
|
||||||
|
// legal values, so the unconditional range clamp at its rd() already turns
|
||||||
|
// any stray pre-0x2C byte back into 0/Off on every load.
|
||||||
}
|
}
|
||||||
|
|
||||||
file.close();
|
file.close();
|
||||||
@@ -862,6 +874,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
|||||||
file.write((uint8_t *)&_prefs.msg_wake_screen_off, sizeof(_prefs.msg_wake_screen_off));
|
file.write((uint8_t *)&_prefs.msg_wake_screen_off, sizeof(_prefs.msg_wake_screen_off));
|
||||||
file.write((uint8_t *)&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask));
|
file.write((uint8_t *)&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask));
|
||||||
file.write((uint8_t *)_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx));
|
file.write((uint8_t *)_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx));
|
||||||
|
file.write((uint8_t *)&_prefs.contact_expiry_idx, sizeof(_prefs.contact_expiry_idx));
|
||||||
|
|
||||||
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
|
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
|
||||||
// the one we check: once the flash fills, writes return 0, so a good
|
// the one we check: once the flash fills, writes return 0, so a good
|
||||||
@@ -1110,7 +1123,7 @@ void DataStore::saveChannels(DataStoreHost* host) {
|
|||||||
bool DataStore::loadScopeList(ScopeList& list, const NodePrefs& prefs) {
|
bool DataStore::loadScopeList(ScopeList& list, const NodePrefs& prefs) {
|
||||||
File file = openRead("/scopes1");
|
File file = openRead("/scopes1");
|
||||||
if (file) {
|
if (file) {
|
||||||
uint8_t hdr[2];
|
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);
|
bool success = (file.read(hdr, 2) == 2);
|
||||||
uint8_t count = success ? hdr[1] : 0;
|
uint8_t count = success ? hdr[1] : 0;
|
||||||
if (count > ScopeList::MAX_SCOPE_ENTRIES) count = 0; // corrupt header -- start empty rather than overrun entries[]
|
if (count > ScopeList::MAX_SCOPE_ENTRIES) count = 0; // corrupt header -- start empty rather than overrun entries[]
|
||||||
@@ -1127,17 +1140,20 @@ bool DataStore::loadScopeList(ScopeList& list, const NodePrefs& prefs) {
|
|||||||
file.close();
|
file.close();
|
||||||
list.count = loaded;
|
list.count = loaded;
|
||||||
list.default_idx = list.clamp(hdr[0]);
|
list.default_idx = list.clamp(hdr[0]);
|
||||||
return true;
|
return false; // the file was already there -- nothing migrated this boot
|
||||||
}
|
}
|
||||||
|
|
||||||
// No /scopes1 yet -- one-time migration of an existing single
|
// No /scopes1 yet -- one-time migration of an existing single
|
||||||
// default_scope_name/key (Settings > Radio > Scope, pre-list) into list
|
// default_scope_name/key (Settings > Radio > Scope, pre-list) into list
|
||||||
// entry 1, so an already-configured device keeps sending under the same
|
// entry 1 and mark it default, which covers DMs and the relay filter. The
|
||||||
// scope after upgrading. A never-configured device just stays at the
|
// caller finishes the job for channels by seeding their per-channel picks
|
||||||
// default-constructed ScopeList (empty, default_idx 0 == "*").
|
// 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.count = 0;
|
||||||
list.default_idx = 0;
|
list.default_idx = 0;
|
||||||
if (prefs.default_scope_name[0] != '\0') {
|
bool migrated = (prefs.default_scope_name[0] != '\0');
|
||||||
|
if (migrated) {
|
||||||
ScopeEntry& e = list.entries[0];
|
ScopeEntry& e = list.entries[0];
|
||||||
StrHelper::strncpy(e.name, prefs.default_scope_name, sizeof(e.name));
|
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
|
memcpy(e.key, prefs.default_scope_key, sizeof(e.key)); // already-derived key, no need to re-derive
|
||||||
@@ -1145,7 +1161,7 @@ bool DataStore::loadScopeList(ScopeList& list, const NodePrefs& prefs) {
|
|||||||
list.default_idx = 1;
|
list.default_idx = 1;
|
||||||
}
|
}
|
||||||
saveScopeList(list); // write /scopes1 so this migration runs only once
|
saveScopeList(list); // write /scopes1 so this migration runs only once
|
||||||
return true;
|
return migrated; // caller seeds the existing channels with entry 1
|
||||||
}
|
}
|
||||||
|
|
||||||
void DataStore::saveScopeList(const ScopeList& list) {
|
void DataStore::saveScopeList(const ScopeList& list) {
|
||||||
|
|||||||
@@ -49,10 +49,16 @@ public:
|
|||||||
// /scopes1: the shared named-scope list (see ScopeList.h). `prefs` is only
|
// /scopes1: the shared named-scope list (see ScopeList.h). `prefs` is only
|
||||||
// read, for a one-time migration of a pre-existing single
|
// read, for a one-time migration of a pre-existing single
|
||||||
// default_scope_name/key into list entry 1 -- the file is authoritative
|
// default_scope_name/key into list entry 1 -- the file is authoritative
|
||||||
// once it exists. Returns true if the file existed or the migration ran
|
// once it exists.
|
||||||
// (i.e. `list` reflects real prior configuration); false only means a
|
//
|
||||||
// genuinely fresh, unconfigured device (list left at its default: empty,
|
// Returns true ONLY when that legacy migration just ran, i.e. this boot is
|
||||||
// default_idx 0 == "*").
|
// 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);
|
bool loadScopeList(ScopeList& list, const NodePrefs& prefs);
|
||||||
void saveScopeList(const ScopeList& list);
|
void saveScopeList(const ScopeList& list);
|
||||||
void migrateToSecondaryFS();
|
void migrateToSecondaryFS();
|
||||||
|
|||||||
@@ -474,6 +474,63 @@ bool MyMesh::setContactFavourite(const uint8_t* pub_key, bool fav) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared staleness test for the Settings > Contacts "Prune now" sweep.
|
||||||
|
//
|
||||||
|
// Caveats on lastmod, deliberately accepted: it's "last touched", not purely
|
||||||
|
// "last heard over the air" -- setContactFavourite() above bumps it, and the
|
||||||
|
// app can write it outright (CMD_IMPORT_CONTACT / the contact-update handler).
|
||||||
|
// Every one of those pushes a contact *away* from being pruned, never towards
|
||||||
|
// it, so the error only ever errs on the side of keeping data.
|
||||||
|
//
|
||||||
|
// Favourites are always exempt; a contact never actually heard from
|
||||||
|
// (lastmod==0, shouldn't happen for a real saved contact, but a defensive
|
||||||
|
// check costs nothing) or whose lastmod reads ahead of "now" (clock skew, or
|
||||||
|
// an unset RTC after a battery pull) is left alone rather than guessed at
|
||||||
|
// either way.
|
||||||
|
static bool contactIsStale(const ContactInfo& ci, uint32_t now, uint32_t threshold_secs) {
|
||||||
|
if (ci.flags & 0x01) return false; // favourite bit, same one setContactFavourite() writes
|
||||||
|
if (ci.lastmod == 0 || now < ci.lastmod) return false;
|
||||||
|
return (now - ci.lastmod) >= threshold_secs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Threshold in seconds for the configured expiry index, or 0 when expiry is
|
||||||
|
// Off -- the one place the NodePrefs table is read, so count and prune can't
|
||||||
|
// disagree about which contacts are in scope.
|
||||||
|
uint32_t MyMesh::staleContactThresholdSecs() const {
|
||||||
|
return (uint32_t)NodePrefs::contactExpiryDays(_prefs.contact_expiry_idx) * 86400UL;
|
||||||
|
}
|
||||||
|
|
||||||
|
int MyMesh::countStaleContacts() {
|
||||||
|
uint32_t threshold_secs = staleContactThresholdSecs();
|
||||||
|
if (threshold_secs == 0) return 0;
|
||||||
|
uint32_t now = getRTCClock()->getCurrentTime();
|
||||||
|
int count = 0;
|
||||||
|
int n = getNumContacts();
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
ContactInfo ci;
|
||||||
|
if (getContactByIdx(MAX_ANON_CONTACTS + i, ci) && contactIsStale(ci, now, threshold_secs)) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
int MyMesh::pruneStaleContacts() {
|
||||||
|
uint32_t threshold_secs = staleContactThresholdSecs();
|
||||||
|
if (threshold_secs == 0) return 0;
|
||||||
|
uint32_t now = getRTCClock()->getCurrentTime();
|
||||||
|
int removed = 0;
|
||||||
|
// Walk backwards: removeContact() compacts the array by shifting everything
|
||||||
|
// *after* the removed slot down one, so entries at lower indices keep their
|
||||||
|
// positions and a descending scan never revisits or skips one. (Forwards
|
||||||
|
// would need a restart after every delete.)
|
||||||
|
for (int i = getNumContacts() - 1; i >= 0; i--) {
|
||||||
|
ContactInfo ci;
|
||||||
|
if (getContactByIdx(MAX_ANON_CONTACTS + i, ci) && contactIsStale(ci, now, threshold_secs)) {
|
||||||
|
if (deleteContactByKey(ci.id.pub_key)) removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
|
void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
|
||||||
out_frame[0] = PUSH_CODE_PATH_UPDATED;
|
out_frame[0] = PUSH_CODE_PATH_UPDATED;
|
||||||
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
|
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
|
||||||
@@ -703,12 +760,28 @@ void MyMesh::setPrimaryScope(const char* name) {
|
|||||||
if (strcmp(_scope_list.entries[i].name, _prefs.default_scope_name) == 0) { idx = i + 1; break; }
|
if (strcmp(_scope_list.entries[i].name, _prefs.default_scope_name) == 0) { idx = i + 1; break; }
|
||||||
}
|
}
|
||||||
if (idx == 0) idx = _scope_list.add(_prefs.default_scope_name);
|
if (idx == 0) idx = _scope_list.add(_prefs.default_scope_name);
|
||||||
_scope_list.default_idx = idx; // still 0 ("*") if the list was full
|
// add() returns 0 only when the list is full. Keep the previous default
|
||||||
|
// rather than taking that as "*": silently dropping to unscoped would put
|
||||||
|
// traffic on the air outside any region, which is a louder failure than
|
||||||
|
// ignoring a request we had no room to honour.
|
||||||
|
if (idx != 0) _scope_list.default_idx = idx;
|
||||||
}
|
}
|
||||||
if (_store) _store->saveScopeList(_scope_list);
|
if (_store) _store->saveScopeList(_scope_list);
|
||||||
rebuildRepeatScopes();
|
rebuildRepeatScopes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MyMesh::syncLegacyDefaultScope() {
|
||||||
|
uint8_t idx = _scope_list.default_idx;
|
||||||
|
if (idx == 0) { // "*" -- no default scope, same as the field never being set
|
||||||
|
memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name));
|
||||||
|
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
|
||||||
|
} else {
|
||||||
|
const ScopeEntry& e = _scope_list.entries[idx - 1];
|
||||||
|
StrHelper::strncpy(_prefs.default_scope_name, e.name, sizeof(_prefs.default_scope_name));
|
||||||
|
memcpy(_prefs.default_scope_key, e.key, sizeof(_prefs.default_scope_key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uint8_t MyMesh::addScope(const char* name) {
|
uint8_t MyMesh::addScope(const char* name) {
|
||||||
uint8_t idx = _scope_list.add(name);
|
uint8_t idx = _scope_list.add(name);
|
||||||
if (idx && _store) _store->saveScopeList(_scope_list);
|
if (idx && _store) _store->saveScopeList(_scope_list);
|
||||||
@@ -721,6 +794,10 @@ void MyMesh::renameScope(uint8_t idx, const char* name) {
|
|||||||
StrHelper::strncpy(e.name, name, sizeof(e.name));
|
StrHelper::strncpy(e.name, name, sizeof(e.name));
|
||||||
ScopeList::deriveKey(e.name, e.key);
|
ScopeList::deriveKey(e.name, e.key);
|
||||||
if (_store) _store->saveScopeList(_scope_list);
|
if (_store) _store->saveScopeList(_scope_list);
|
||||||
|
if (_scope_list.default_idx == idx) { // renaming the default changes its key too
|
||||||
|
syncLegacyDefaultScope();
|
||||||
|
savePrefs();
|
||||||
|
}
|
||||||
rebuildRepeatScopes(); // this entry's key may be repeat_scopes[]'s default or an extra slot
|
rebuildRepeatScopes(); // this entry's key may be repeat_scopes[]'s default or an extra slot
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,13 +822,23 @@ void MyMesh::removeScope(uint8_t idx) {
|
|||||||
if (ci == idx) _prefs.ch_scope_idx[i] = 0;
|
if (ci == idx) _prefs.ch_scope_idx[i] = 0;
|
||||||
else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1;
|
else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1;
|
||||||
}
|
}
|
||||||
|
syncLegacyDefaultScope(); // ScopeList::remove() may have moved or cleared the default
|
||||||
if (_store) _store->saveScopeList(_scope_list);
|
if (_store) _store->saveScopeList(_scope_list);
|
||||||
|
// The fix-ups above live in NodePrefs, not in /scopes1, so both files have to
|
||||||
|
// be written here. Saving only the list would leave the two out of step after
|
||||||
|
// a reboot: the entries would be shifted down but every channel's saved index
|
||||||
|
// (and the repeater's mask) would still point at the pre-delete positions, so
|
||||||
|
// they'd silently resolve to the wrong scope -- the same class of bug the
|
||||||
|
// stray-bits clamp in DataStore guards against, just from the other side.
|
||||||
|
savePrefs();
|
||||||
rebuildRepeatScopes();
|
rebuildRepeatScopes();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MyMesh::setDefaultScope(uint8_t idx) {
|
void MyMesh::setDefaultScope(uint8_t idx) {
|
||||||
_scope_list.default_idx = _scope_list.clamp(idx);
|
_scope_list.default_idx = _scope_list.clamp(idx);
|
||||||
|
syncLegacyDefaultScope(); // so CMD_GET_DEFAULT_FLOOD_SCOPE answers with what we now use
|
||||||
if (_store) _store->saveScopeList(_scope_list);
|
if (_store) _store->saveScopeList(_scope_list);
|
||||||
|
savePrefs();
|
||||||
rebuildRepeatScopes();
|
rebuildRepeatScopes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,9 +893,13 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk
|
|||||||
sendFloodScoped(send_scope, pkt, delay_millis);
|
sendFloodScoped(send_scope, pkt, delay_millis);
|
||||||
} else {
|
} else {
|
||||||
// Resolve THIS channel's own scope-list pick (Messages > channel context
|
// Resolve THIS channel's own scope-list pick (Messages > channel context
|
||||||
// menu > Scope:), falling back to the list's default if this channel has
|
// menu > Scope:). Index 0 is "*", which means unscoped -- NOT "inherit the
|
||||||
// none of its own or can't be identified (e.g. a bot/room send path that
|
// default": the default is what DMs and the relay filter's primary slot
|
||||||
// doesn't go through a slot in channels[]).
|
// use, and what a channel is seeded with on upgrade, but once a channel
|
||||||
|
// has a pick that pick is the whole story. The list default is only the
|
||||||
|
// fallback for a channel we can't identify at all (findChannelIdx() == -1,
|
||||||
|
// e.g. a send whose secret isn't in channels[]), where there's no pick to
|
||||||
|
// read in the first place.
|
||||||
int channel_idx = findChannelIdx(channel);
|
int channel_idx = findChannelIdx(channel);
|
||||||
uint8_t list_idx = (channel_idx >= 0 && channel_idx < NodePrefs::MAX_SCOPED_CHANNELS)
|
uint8_t list_idx = (channel_idx >= 0 && channel_idx < NodePrefs::MAX_SCOPED_CHANNELS)
|
||||||
? _prefs.ch_scope_idx[channel_idx] : _scope_list.default_idx;
|
? _prefs.ch_scope_idx[channel_idx] : _scope_list.default_idx;
|
||||||
@@ -1819,7 +1910,9 @@ void MyMesh::begin(bool has_display) {
|
|||||||
|
|
||||||
// load persisted prefs
|
// load persisted prefs
|
||||||
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
|
_store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon);
|
||||||
_store->loadScopeList(_scope_list, _prefs);
|
// 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();
|
rebuildRepeatScopes();
|
||||||
|
|
||||||
// sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain
|
// sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain
|
||||||
@@ -1871,6 +1964,20 @@ void MyMesh::begin(bool has_display) {
|
|||||||
addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel
|
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
|
applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set
|
||||||
applyApc(); // sets TX power to the ceiling and arms APC if enabled
|
applyApc(); // sets TX power to the ceiling and arms APC if enabled
|
||||||
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
|
radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);
|
||||||
@@ -2925,19 +3032,29 @@ void MyMesh::handleCmdFrame(size_t len) {
|
|||||||
// avoid reading into the key (or past the frame) when no NUL is present.
|
// avoid reading into the key (or past the frame) when no NUL is present.
|
||||||
int n = (int)strnlen((char *) &cmd_frame[1], 31);
|
int n = (int)strnlen((char *) &cmd_frame[1], 31);
|
||||||
if (n > 0 && n < 31) {
|
if (n > 0 && n < 31) {
|
||||||
strcpy(_prefs.default_scope_name, (char *) &cmd_frame[1]);
|
// Must go through setPrimaryScope(), not straight into the legacy
|
||||||
|
// fields: since the scope list landed, every send and the relay filter
|
||||||
|
// resolve through _scope_list, so writing default_scope_name/key alone
|
||||||
|
// would leave the app's request with nothing reading it.
|
||||||
|
setPrimaryScope((char *) &cmd_frame[1]);
|
||||||
|
// Honour the key the app derived rather than the one setPrimaryScope()
|
||||||
|
// re-derived from the name. They agree today (same "#name" -> SHA256),
|
||||||
|
// but the app is the authority on its own regions, and a silent
|
||||||
|
// mismatch here would be an on-air difference nothing surfaces.
|
||||||
memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16);
|
memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16);
|
||||||
rebuildRepeatScopes(); // slot 0 of the relay filter tracks this key
|
if (_scope_list.default_idx >= 1) {
|
||||||
|
memcpy(_scope_list.entries[_scope_list.default_idx - 1].key, &cmd_frame[1+31], 16);
|
||||||
|
if (_store) _store->saveScopeList(_scope_list);
|
||||||
|
rebuildRepeatScopes(); // slot 0 of the relay filter tracks this key
|
||||||
|
}
|
||||||
savePrefs();
|
savePrefs();
|
||||||
writeOKFrame();
|
writeOKFrame();
|
||||||
} else {
|
} else {
|
||||||
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); // set default scope to null
|
setPrimaryScope(""); // clears the legacy fields and points the list back at "*"
|
||||||
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
|
savePrefs(); // setPrimaryScope() writes /scopes1 and rebuilds the relay filter
|
||||||
rebuildRepeatScopes(); // drop it from the relay filter too, not just from sends
|
|
||||||
savePrefs();
|
|
||||||
writeOKFrame();
|
writeOKFrame();
|
||||||
}
|
}
|
||||||
} else if (cmd_frame[0] == CMD_GET_DEFAULT_FLOOD_SCOPE) {
|
} else if (cmd_frame[0] == CMD_GET_DEFAULT_FLOOD_SCOPE) {
|
||||||
|
|||||||
@@ -140,6 +140,22 @@ public:
|
|||||||
bool addDiscoveredContact(const uint8_t* pub_key, const char* name, uint8_t type);
|
bool addDiscoveredContact(const uint8_t* pub_key, const char* name, uint8_t type);
|
||||||
bool deleteContactByKey(const uint8_t* pub_key);
|
bool deleteContactByKey(const uint8_t* pub_key);
|
||||||
|
|
||||||
|
// Settings > Contacts > "Expire" / "Prune now" -- a contact with no
|
||||||
|
// advert/update (ContactInfo::lastmod) within NodePrefs::contactExpiryDays(
|
||||||
|
// _prefs.contact_expiry_idx) days is eligible for removal; index 0 = Off.
|
||||||
|
// Favourites are always exempt. Both take the threshold from that one
|
||||||
|
// NodePrefs table, the same one the Settings row labels itself from.
|
||||||
|
uint32_t staleContactThresholdSecs() const; // 0 when expiry is Off
|
||||||
|
// How many contacts pruneStaleContacts() would remove right now, without
|
||||||
|
// removing anything -- backs the confirm dialog. 0 whenever expiry is Off.
|
||||||
|
// Not const: BaseChatMesh::getContactByIdx() isn't either.
|
||||||
|
int countStaleContacts();
|
||||||
|
// Removes every non-favourite contact whose lastmod is older than the
|
||||||
|
// configured threshold. Returns the number actually removed. The contacts
|
||||||
|
// file is written lazily, like every other contact edit (see
|
||||||
|
// dirty_contacts_expiry / flushDirtyContacts()).
|
||||||
|
int pruneStaleContacts();
|
||||||
|
|
||||||
// Ping/Trace functionality
|
// Ping/Trace functionality
|
||||||
#define PING_RESULT_MAX 4
|
#define PING_RESULT_MAX 4
|
||||||
typedef void (*PingCallback)(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms);
|
typedef void (*PingCallback)(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms);
|
||||||
@@ -362,6 +378,14 @@ public:
|
|||||||
// rebuildRepeatScopes() and persists the list.
|
// rebuildRepeatScopes() and persists the list.
|
||||||
void setPrimaryScope(const char* name);
|
void setPrimaryScope(const char* name);
|
||||||
|
|
||||||
|
// Mirrors the list's current default entry back into the legacy
|
||||||
|
// NodePrefs::default_scope_name/key pair. Those two fields are the only
|
||||||
|
// shape CMD_GET_DEFAULT_FLOOD_SCOPE can report, so any on-device change to
|
||||||
|
// which entry is default (or a rename/delete that moves it) has to refresh
|
||||||
|
// them -- otherwise the app keeps showing, and re-sending, a scope the
|
||||||
|
// device stopped using. Callers persist prefs themselves.
|
||||||
|
void syncLegacyDefaultScope();
|
||||||
|
|
||||||
// Rebuilds repeat_scopes[]/repeat_scope_count from the scope list's current
|
// Rebuilds repeat_scopes[]/repeat_scope_count from the scope list's current
|
||||||
// default entry (slot 0) plus repeat_extra_scope_mask (Tools > Repeater >
|
// default entry (slot 0) plus repeat_extra_scope_mask (Tools > Repeater >
|
||||||
// Extra scopes -- a toggle over the same list). Call after loading prefs +
|
// Extra scopes -- a toggle over the same list). Call after loading prefs +
|
||||||
|
|||||||
@@ -498,6 +498,13 @@ struct NodePrefs { // persisted to file
|
|||||||
// memset and an older prefs file (no bytes here at all) mean "on", which is
|
// memset and an older prefs file (no bytes here at all) mean "on", which is
|
||||||
// the default -- a positive flag would read back as off for every upgrader.
|
// the default -- a positive flag would read back as off for every upgrader.
|
||||||
uint8_t fav_sort_off; // 0 = favourites first in every list (default), 1 = natural order
|
uint8_t fav_sort_off; // 0 = favourites first in every list (default), 1 = natural order
|
||||||
|
// Settings > Contacts > "Expire" + "Prune now". Index into
|
||||||
|
// contactExpiryDays()/contactExpiryLabel() below (0=Off/never, 1=7d, 2=30d,
|
||||||
|
// 3=90d) -- a contact whose ContactInfo::lastmod is older than this is
|
||||||
|
// eligible for the manual Prune-now sweep. Favourites are always exempt
|
||||||
|
// regardless of age. On-disk position is the struct's append-only tail (see
|
||||||
|
// the serialization tripwire below), same as fav_sort_off above.
|
||||||
|
uint8_t contact_expiry_idx; // 0 = off (default)
|
||||||
|
|
||||||
// ── Advert ─────────────────────────────────────────────────────────────
|
// ── Advert ─────────────────────────────────────────────────────────────
|
||||||
uint8_t advert_loc_policy;
|
uint8_t advert_loc_policy;
|
||||||
@@ -579,6 +586,21 @@ struct NodePrefs { // persisted to file
|
|||||||
// tuning only — not persisted.
|
// tuning only — not persisted.
|
||||||
static const uint16_t TRAIL_AUTOPAUSE_MOVE_M = 15;
|
static const uint16_t TRAIL_AUTOPAUSE_MOVE_M = 15;
|
||||||
|
|
||||||
|
// Contact-expiry thresholds (days) for contact_expiry_idx. Single source of
|
||||||
|
// truth for both the Settings > Contacts "Expire" row's label and the age
|
||||||
|
// MyMesh::countStaleContacts()/pruneStaleContacts() actually applies, so the
|
||||||
|
// number the user picks and the one enforced can't drift apart. 0 = off,
|
||||||
|
// which is also the clamp target for any out-of-range saved index.
|
||||||
|
static const uint8_t CONTACT_EXPIRY_COUNT = 4;
|
||||||
|
static uint16_t contactExpiryDays(uint8_t idx) {
|
||||||
|
static const uint16_t D[CONTACT_EXPIRY_COUNT] = { 0, 7, 30, 90 };
|
||||||
|
return D[idx < CONTACT_EXPIRY_COUNT ? idx : 0];
|
||||||
|
}
|
||||||
|
static const char* contactExpiryLabel(uint8_t idx) {
|
||||||
|
static const char* L[CONTACT_EXPIRY_COUNT] = { "Off", "7d", "30d", "90d" };
|
||||||
|
return L[idx < CONTACT_EXPIRY_COUNT ? idx : 0];
|
||||||
|
}
|
||||||
|
|
||||||
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
|
// Tail sentinel written at the end of /new_prefs. Bump the low byte when
|
||||||
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
|
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
|
||||||
// older saves are detected on load and skipped (zero-init defaults kept).
|
// older saves are detected on load and skipped (zero-init defaults kept).
|
||||||
@@ -588,7 +610,7 @@ struct NodePrefs { // persisted to file
|
|||||||
// repeat_* fields) instead of at the tail, which shifted every field after
|
// repeat_* fields) instead of at the tail, which shifted every field after
|
||||||
// them by 25 bytes when loading an older file. Never released, but a dev
|
// them by 25 bytes when loading an older file. Never released, but a dev
|
||||||
// build wrote it, so the number must not be reused for anything else.
|
// build wrote it, so the number must not be reused for anything else.
|
||||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE002B;
|
static const uint32_t SCHEMA_SENTINEL = 0xC0DE002C;
|
||||||
|
|
||||||
// Bit-index for each home page. Used by page_order (entries store bit+1) and
|
// Bit-index for each home page. Used by page_order (entries store bit+1) and
|
||||||
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
|
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
|
||||||
@@ -733,6 +755,10 @@ struct NodePrefs { // persisted to file
|
|||||||
// earlier bump -- confirmed via a real sim_companion_radio (native) build
|
// earlier bump -- confirmed via a real sim_companion_radio (native) build
|
||||||
// and a real WioTrackerL1_companion_solo_dual (nRF52/ARM) build, sizeof
|
// and a real WioTrackerL1_companion_solo_dual (nRF52/ARM) build, sizeof
|
||||||
// 2824 on both.
|
// 2824 on both.
|
||||||
|
// contact_expiry_idx (0xC0DE002C) landed in existing padding elsewhere in
|
||||||
|
// the struct -- confirmed via a real sim_companion_radio (native) build and a
|
||||||
|
// real WioTrackerL1_companion_solo_dual (nRF52/ARM) build, sizeof unchanged
|
||||||
|
// at 2824 on both.
|
||||||
static_assert(sizeof(NodePrefs) == 2824,
|
static_assert(sizeof(NodePrefs) == 2824,
|
||||||
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
|
"NodePrefs layout changed — sync DataStore save/load + clamp, bump "
|
||||||
"SCHEMA_SENTINEL, then update this size (see steps above).");
|
"SCHEMA_SENTINEL, then update this size (see steps above).");
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class SettingsScreen : public UIScreen {
|
|||||||
KEYBOARD_CARDKB_COMPACT,
|
KEYBOARD_CARDKB_COMPACT,
|
||||||
#endif
|
#endif
|
||||||
// Contacts section
|
// Contacts section
|
||||||
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, FAV_SORT,
|
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, FAV_SORT, EXPIRE_AFTER, PRUNE_NOW,
|
||||||
// Messages section
|
// Messages section
|
||||||
SECTION_MESSAGES,
|
SECTION_MESSAGES,
|
||||||
DM_RESEND,
|
DM_RESEND,
|
||||||
@@ -136,6 +136,8 @@ class SettingsScreen : public UIScreen {
|
|||||||
static const int SOUND_COUNT = 4;
|
static const int SOUND_COUNT = 4;
|
||||||
static const char* AD_SCOPE_LABELS[2];
|
static const char* AD_SCOPE_LABELS[2];
|
||||||
static const int AD_SCOPE_COUNT = 2;
|
static const int AD_SCOPE_COUNT = 2;
|
||||||
|
// ("Expire" reads its labels straight from NodePrefs::contactExpiryLabel(),
|
||||||
|
// which is also where MyMesh takes the matching day count from.)
|
||||||
#if FEAT_FULL_REFRESH_SETTING
|
#if FEAT_FULL_REFRESH_SETTING
|
||||||
static const char* EINK_FULL_REFRESH_LABELS[5];
|
static const char* EINK_FULL_REFRESH_LABELS[5];
|
||||||
static const int EINK_FULL_REFRESH_COUNT = 5;
|
static const int EINK_FULL_REFRESH_COUNT = 5;
|
||||||
@@ -681,6 +683,12 @@ class SettingsScreen : public UIScreen {
|
|||||||
display.print("Favs top");
|
display.print("Favs top");
|
||||||
display.setCursor(valCol(display), y);
|
display.setCursor(valCol(display), y);
|
||||||
display.print((p && p->fav_sort_off) ? "OFF" : "ON");
|
display.print((p && p->fav_sort_off) ? "OFF" : "ON");
|
||||||
|
} else if (item == EXPIRE_AFTER) {
|
||||||
|
display.print("Expire");
|
||||||
|
display.setCursor(valCol(display), y);
|
||||||
|
display.print(NodePrefs::contactExpiryLabel(p ? p->contact_expiry_idx : 0));
|
||||||
|
} else if (item == PRUNE_NOW) {
|
||||||
|
display.print("Prune now"); // action row: Enter counts + confirms + removes
|
||||||
} else if (item == DM_RESEND) {
|
} else if (item == DM_RESEND) {
|
||||||
display.print("Resend");
|
display.print("Resend");
|
||||||
display.setCursor(valCol(display), y);
|
display.setCursor(valCol(display), y);
|
||||||
@@ -721,6 +729,12 @@ class SettingsScreen : public UIScreen {
|
|||||||
int _scope_rename_idx = -2;
|
int _scope_rename_idx = -2;
|
||||||
bool _scope_delete_confirm_active = false;
|
bool _scope_delete_confirm_active = false;
|
||||||
|
|
||||||
|
// Contacts > "Prune now" confirm -- a separate PopupMenu from
|
||||||
|
// _scope_action_menu since this one pops up directly over the flat
|
||||||
|
// Settings list (PRUNE_NOW's own row), not nested inside a sub-screen.
|
||||||
|
PopupMenu _prune_confirm;
|
||||||
|
char _prune_confirm_title[40];
|
||||||
|
|
||||||
int renderScopeMgmt(DisplayDriver& display) {
|
int renderScopeMgmt(DisplayDriver& display) {
|
||||||
display.setColor(DisplayDriver::LIGHT);
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
display.drawCenteredHeader("SCOPE", true, _scope_action_menu.active);
|
display.drawCenteredHeader("SCOPE", true, _scope_action_menu.active);
|
||||||
@@ -767,6 +781,7 @@ public:
|
|||||||
_scope_rename_idx = -2;
|
_scope_rename_idx = -2;
|
||||||
_scope_action_menu.active = false;
|
_scope_action_menu.active = false;
|
||||||
_scope_delete_confirm_active = false;
|
_scope_delete_confirm_active = false;
|
||||||
|
_prune_confirm.active = false;
|
||||||
resetList();
|
resetList();
|
||||||
_editor.freq.active = false;
|
_editor.freq.active = false;
|
||||||
}
|
}
|
||||||
@@ -802,6 +817,7 @@ public:
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (_picker.menu.active) _picker.menu.render(display);
|
if (_picker.menu.active) _picker.menu.render(display);
|
||||||
|
if (_prune_confirm.active) _prune_confirm.render(display);
|
||||||
|
|
||||||
return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000;
|
return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000;
|
||||||
}
|
}
|
||||||
@@ -869,8 +885,14 @@ public:
|
|||||||
auto res = _scope_action_menu.handleInput(c);
|
auto res = _scope_action_menu.handleInput(c);
|
||||||
if (res == PopupMenu::SELECTED) {
|
if (res == PopupMenu::SELECTED) {
|
||||||
int sel = _scope_action_menu.selectedIndex();
|
int sel = _scope_action_menu.selectedIndex();
|
||||||
if (sel == 0) { // Set default
|
if (sel == 0) { // Set as default
|
||||||
the_mesh.setDefaultScope((uint8_t)_scope_action_idx);
|
the_mesh.setDefaultScope((uint8_t)_scope_action_idx);
|
||||||
|
// "Default" is the protocol's own word (CMD_SET_DEFAULT_FLOOD_SCOPE,
|
||||||
|
// and what the app shows), but on its own it doesn't say default
|
||||||
|
// for *what* -- channels carry their own pick and don't inherit it.
|
||||||
|
// Spell out the two things it actually governs, at the moment the
|
||||||
|
// user sets it, rather than leaving the list marker to imply more.
|
||||||
|
_task->showAlert("Default: DMs + relay", 1400);
|
||||||
} else if (sel == 1 && _scope_action_idx >= 1) { // Rename
|
} else if (sel == 1 && _scope_action_idx >= 1) { // Rename
|
||||||
_scope_rename_idx = _scope_action_idx;
|
_scope_rename_idx = _scope_action_idx;
|
||||||
_kb->begin(sl.name((uint8_t)_scope_action_idx), 23);
|
_kb->begin(sl.name((uint8_t)_scope_action_idx), 23);
|
||||||
@@ -895,7 +917,7 @@ public:
|
|||||||
_scope_action_idx = _scope_mgmt_sel;
|
_scope_action_idx = _scope_mgmt_sel;
|
||||||
bool is_named = _scope_action_idx >= 1;
|
bool is_named = _scope_action_idx >= 1;
|
||||||
_scope_action_menu.begin("Scope", is_named ? 3 : 1);
|
_scope_action_menu.begin("Scope", is_named ? 3 : 1);
|
||||||
_scope_action_menu.addItem("Set default");
|
_scope_action_menu.addItem("Set as default");
|
||||||
if (is_named) { _scope_action_menu.addItem("Rename"); _scope_action_menu.addItem("Delete"); }
|
if (is_named) { _scope_action_menu.addItem("Rename"); _scope_action_menu.addItem("Delete"); }
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -951,6 +973,18 @@ public:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contacts > "Prune now" confirm
|
||||||
|
if (_prune_confirm.active) {
|
||||||
|
auto res = _prune_confirm.handleInput(c);
|
||||||
|
if (res == PopupMenu::SELECTED && _prune_confirm.selectedIndex() == 0) { // "Remove"
|
||||||
|
int n = the_mesh.pruneStaleContacts();
|
||||||
|
char msg[24];
|
||||||
|
snprintf(msg, sizeof(msg), "Removed %d contact%s", n, n == 1 ? "" : "s");
|
||||||
|
_task->showAlert(msg, 1400);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (c == KEY_CANCEL) {
|
if (c == KEY_CANCEL) {
|
||||||
_task->savePrefsIfDirty(_dirty);
|
_task->savePrefsIfDirty(_dirty);
|
||||||
_task->gotoHomeScreen();
|
_task->gotoHomeScreen();
|
||||||
@@ -1212,6 +1246,26 @@ public:
|
|||||||
_dirty = true;
|
_dirty = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (_selected == EXPIRE_AFTER && p && (left || right || enter)) {
|
||||||
|
const int n_opt = NodePrefs::CONTACT_EXPIRY_COUNT;
|
||||||
|
int idx = (p->contact_expiry_idx < n_opt) ? p->contact_expiry_idx : 0;
|
||||||
|
idx = (idx + (left ? n_opt - 1 : 1)) % n_opt;
|
||||||
|
p->contact_expiry_idx = (uint8_t)idx;
|
||||||
|
_dirty = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_selected == PRUNE_NOW && enter) {
|
||||||
|
int n = the_mesh.countStaleContacts();
|
||||||
|
if (n == 0) {
|
||||||
|
// Two different "nothing happened" reasons, told apart so the row
|
||||||
|
// doesn't look broken when the threshold simply isn't set yet.
|
||||||
|
_task->showAlert(p && p->contact_expiry_idx == 0 ? "Expire is Off" : "No inactive contacts", 1400);
|
||||||
|
} else {
|
||||||
|
snprintf(_prune_confirm_title, sizeof(_prune_confirm_title), "Remove %d contact%s?", n, n == 1 ? "" : "s");
|
||||||
|
_prune_confirm.beginConfirm(_prune_confirm_title, "Remove");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (isMsgSlot(_selected) && enter) {
|
if (isMsgSlot(_selected) && enter) {
|
||||||
int slot = msgSlotIndex(_selected);
|
int slot = msgSlotIndex(_selected);
|
||||||
_edit_slot = slot;
|
_edit_slot = slot;
|
||||||
|
|||||||
@@ -3277,7 +3277,8 @@ void UITask::onContactRemoved(const uint8_t* pub_key) {
|
|||||||
// so a channel re-added at a freed slot can't inherit the old one's settings.
|
// so a channel re-added at a freed slot can't inherit the old one's settings.
|
||||||
// If you add such a field, add its cleanup below (and mark it in NodePrefs.h).
|
// If you add such a field, add its cleanup below (and mark it in NodePrefs.h).
|
||||||
// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*,
|
// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*,
|
||||||
// ch_notif_override/ch_notif_muted, ch_fav_bitmask, favourite_contacts/_kinds.
|
// ch_notif_override/ch_notif_muted, ch_fav_bitmask, favourite_contacts/_kinds,
|
||||||
|
// ch_scope_idx.
|
||||||
void UITask::onChannelRemoved(uint8_t channel_idx) {
|
void UITask::onChannelRemoved(uint8_t channel_idx) {
|
||||||
if (!_node_prefs) return;
|
if (!_node_prefs) return;
|
||||||
bool changed = false;
|
bool changed = false;
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
- **The Clock/Lock dashboard gets a separate "Altitude (GPS)" field**, alongside the existing barometric one (now labelled "Altitude (Baro)") — the original single Altitude field only ever read a barometric sensor's telemetry, showing `--` on any board without one even with a perfectly good GPS fix.
|
- **The Clock/Lock dashboard gets a separate "Altitude (GPS)" field**, alongside the existing barometric one (now labelled "Altitude (Baro)") — the original single Altitude field only ever read a barometric sensor's telemetry, showing `--` on any board without one even with a perfectly good GPS fix.
|
||||||
- **Received messages now show how many hops they actually took to reach you**, right in the message list — the same tiny digit-icon a sent message already uses for its repeater/echo count, now shown for incoming DMs and channel posts too, using the hop path the mesh already records for them.
|
- **Received messages now show how many hops they actually took to reach you**, right in the message list — the same tiny digit-icon a sent message already uses for its repeater/echo count, now shown for incoming DMs and channel posts too, using the hop path the mesh already records for them.
|
||||||
- **Scope is now a shared, freely-definable list, not one device-wide text field.** Settings › Radio › Scope manages a small named list (`*`/wildcard always first, plus a movable default) instead of a single free-typed name. Each channel picks exactly one scope of its own — a `Scope: <name>` row in the channel's context menu, matching the phone app's own per-channel region picker — and the channel's history title shows the tag when it's set to anything but `*`. Tools › Repeater's "Extra scopes" now multi-selects from the same list instead of comma-typing region names, capped at the same 4 active relay scopes as before. DMs and any not-yet-assigned channel keep using the list's current default, so an existing single-scope setup carries over unchanged on upgrade.
|
- **Scope is now a shared, freely-definable list, not one device-wide text field.** Settings › Radio › Scope manages a small named list (`*`/wildcard always first, plus a movable default) instead of a single free-typed name. Each channel picks exactly one scope of its own — a `Scope: <name>` row in the channel's context menu, matching the phone app's own per-channel region picker — and the channel's history title shows the tag when it's set to anything but `*`. Tools › Repeater's "Extra scopes" now multi-selects from the same list instead of comma-typing region names, capped at the same 4 active relay scopes as before. A channel set to `*` sends unscoped; DMs and the repeater's primary relay slot follow the list's default entry. Upgrading carries an existing single-scope setup over unchanged: the old Scope becomes the default entry and every channel you already had is seeded with it, so nothing changes on the air.
|
||||||
|
- **Settings › Contacts can now clean out contacts you haven't heard from in a while.** New "Expire" option (Off/7d/30d/90d) plus a "Prune now" action that shows exactly how many contacts would be removed before you confirm. Favourites are always kept regardless of age.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user