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:
Jakub
2026-09-12 21:55:46 +02:00
co-authored by Claude Opus 5
parent 89c02eea27
commit 1d5dbe2f9a
8 changed files with 273 additions and 28 deletions
+23 -7
View File
@@ -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) {
+10 -4
View File
@@ -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();
+128 -11
View File
@@ -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) {
+24
View File
@@ -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 +
+27 -1
View File
@@ -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;
+2 -1
View File
@@ -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
View File
@@ -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