Files
MeshCore-Solo/examples/companion_radio/MyMeshContacts.h
T
JakubandClaude Opus 5.5 120ddfc830 feat(route): trace per link, to the node or the last repeater, find where it stops; channel order re-sorts on entry
Core: PathEditor results are links (node k hearing k-1 and back); toNode()
off traces only the repeaters (a companion answers only with repeat on);
probe() traces to each repeater in turn after no reply; failures carry a
reason; an empty list saves "direct". PingEngine takes over a slot left past
its timeout. A kept route: the dropped heard path isn't announced to the app
nor bumps lastmod; an app update keeps the flag only if it leaves the route.
Marker boxes sized for every dot and name (overflow).

ui-lvgl Route: one line per node with out / back dB coloured by quality, tap
a hop to move / remove, tap the node to include it, Keep in the header, Find
where beside a no-reply status. ui-new Path: To node / Find break rows,
result alerts, cursor kept on its action.

Channels: ui-new re-sorts the list on entering it, cursor on the same channel.
Docs: Path and trace, Channel order. Sim hooks: open route, add channel,
open messages.

Checked in the browser sim (L2 + ui-new peer + repeater); not on hardware.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:20:55 +02:00

247 lines
10 KiB
C

#pragma once
// Contacts and rooms from the UI: add / delete / favourite, telemetry
// permissions, path reset, factory reset, stale-contact pruning, room passwords.
// Not part of upstream MyMesh.cpp: included at its bottom, after all class
// definitions (as MyMeshBot.h and MyMeshCli.h).
bool MyMesh::addDiscoveredContact(const uint8_t* pub_key, const char* name, uint8_t type) {
if (lookupContactByPubKey(pub_key, PUB_KEY_SIZE)) return true; // already a contact
ContactInfo c;
memset(&c, 0, sizeof(c));
c.id = mesh::Identity(pub_key);
strncpy(c.name, name ? name : "", sizeof(c.name) - 1);
c.name[sizeof(c.name) - 1] = '\0';
c.type = type;
c.out_path_len = OUT_PATH_UNKNOWN; // no path yet → flood until one is learned
c.lastmod = getRTCClock()->getCurrentTime();
c.shared_secret_valid = false;
if (!addContact(c)) return false; // contacts table full
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
// Full delete sequence shared by CMD_REMOVE_CONTACT and the on-device Nearby menu:
// drop the contact, its persisted blob and any saved room login, run the UI cleanup
// (favourite slot / Locator / Live Share target), then schedule the lazy write.
bool MyMesh::deleteContactByKey(const uint8_t* pub_key) {
ContactInfo* recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!recipient || !removeContact(*recipient)) return false;
_store->deleteBlobByKey(pub_key, PUB_KEY_SIZE);
forgetRoomPassword(pub_key);
if (_listener) _listener->onContactRemoved(pub_key);
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
// lastmod is bumped so the app's next 'since'-filtered contact sync picks the
// change up; the write itself is lazy, like every other contact edit.
bool MyMesh::setContactFavourite(const uint8_t* pub_key, bool fav) {
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
if (fav) c->flags |= 0x01; else c->flags &= ~0x01;
c->lastmod = getRTCClock()->getCurrentTime();
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
bool MyMesh::setContactTelemetry(const uint8_t* pub_key, uint8_t perms) {
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
c->flags = (c->flags & ~0x0E) | ((perms & 0x07) << 1);
c->lastmod = getRTCClock()->getCurrentTime();
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
bool MyMesh::factoryReset() {
bool was_on = _serial && _serial->isEnabled();
if (_serial) {
MESH_DEBUG_PRINTLN("Factory reset: disabling serial interface to prevent reconnects (BLE/WiFi)");
_serial->disable();
}
if (_store->formatFileSystem()) return true;
if (was_on) _serial->enable(); // nothing erased: the link as it was
return false;
}
// lastmod bumped (unlike CMD_RESET_PATH, which the app sent itself) so the
// app's copy of the contact drops the old path too.
bool MyMesh::resetContactPath(const uint8_t* pub_key) {
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
c->out_path_len = OUT_PATH_UNKNOWN;
c->flags &= ~CONTACT_FLAG_ROUTE_PINNED; // a deliberate reset ends a kept route
c->lastmod = getRTCClock()->getCurrentTime();
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
bool MyMesh::setContactPinned(const uint8_t* pub_key, bool pin) {
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
if (pin) c->flags |= CONTACT_FLAG_ROUTE_PINNED; else c->flags &= ~CONTACT_FLAG_ROUTE_PINNED;
c->lastmod = getRTCClock()->getCurrentTime();
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
return true;
}
// A route typed on the device (ui-core/PathEditor.h): `hops` hashes of
// `hash_size` bytes into the contact's out_path (0 hops: direct), the packed length byte as
// the mesh stores a learned path (top 2 bits: size - 1). lastmod bumped, as
// for resetContactPath(), so the app's copy follows.
bool MyMesh::setContactPath(const uint8_t* pub_key, const uint8_t* path, uint8_t hash_size, uint8_t hops, bool pin) {
if (hash_size < 1 || hash_size > 3 || hops > 63 || hops * hash_size > MAX_PATH_SIZE) return false;
ContactInfo* c = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!c) return false;
memcpy(c->out_path, path, hops * hash_size);
c->out_path_len = (uint8_t)(((hash_size - 1) << 6) | hops);
if (pin) c->flags |= CONTACT_FLAG_ROUTE_PINNED; else c->flags &= ~CONTACT_FLAG_ROUTE_PINNED;
c->lastmod = getRTCClock()->getCurrentTime();
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY);
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;
}
bool MyMesh::getRoomPassword(const uint8_t* pub_key, char* out_password, uint8_t max_len) {
File f = _store->openRead(ROOM_PW_FILE);
if (!f) return false;
RoomPwRec rec;
bool found = false;
while (f.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) {
if (memcmp(rec.key, pub_key, 4) == 0) {
strncpy(out_password, rec.pw, max_len - 1);
out_password[max_len - 1] = 0;
found = true;
break;
}
}
f.close();
return found;
}
bool MyMesh::saveRoomPassword(const uint8_t* pub_key, const char* password) {
// The table is tiny (<= MAX_SAVED_ROOM_PASSWORDS * 20 bytes), so just load
// it whole, update/append/evict in RAM, then rewrite -- simpler and just
// as crash-safe as a record seek given how rarely this runs (once per new
// room login).
RoomPwRec recs[MAX_SAVED_ROOM_PASSWORDS];
int count = 0;
File rf = _store->openRead(ROOM_PW_FILE);
if (rf) {
RoomPwRec rec;
while (count < MAX_SAVED_ROOM_PASSWORDS && rf.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) {
if (memcmp(rec.key, pub_key, 4) != 0) { // drop stale entry for this key -- replaced below
recs[count++] = rec;
}
}
rf.close();
}
RoomPwRec new_rec;
memcpy(new_rec.key, pub_key, 4);
strncpy(new_rec.pw, password, sizeof(new_rec.pw) - 1);
new_rec.pw[sizeof(new_rec.pw) - 1] = 0;
if (count < MAX_SAVED_ROOM_PASSWORDS) {
recs[count++] = new_rec;
} else { // table full and not already present -- evict oldest (front)
memmove(&recs[0], &recs[1], sizeof(RoomPwRec) * (MAX_SAVED_ROOM_PASSWORDS - 1));
recs[MAX_SAVED_ROOM_PASSWORDS - 1] = new_rec;
}
// Write to a temp file and atomically swap it over /room_pw, so an
// interrupted save leaves the previous good table intact rather than a
// truncated mix (mirrors how contacts/channels are persisted).
File wf = _store->openWrite(ROOM_PW_TMP);
if (!wf) return false;
size_t want = sizeof(RoomPwRec) * count;
bool ok = (wf.write((uint8_t *)recs, want) == want);
wf.close();
if (!ok) { _store->removeFile(ROOM_PW_TMP); return false; } // keep previous good file
return _store->commitFile(ROOM_PW_TMP, ROOM_PW_FILE);
}
void MyMesh::forgetRoomPassword(const uint8_t* pub_key) {
RoomPwRec recs[MAX_SAVED_ROOM_PASSWORDS];
int count = 0;
File rf = _store->openRead(ROOM_PW_FILE);
if (!rf) return;
RoomPwRec rec;
bool removed = false;
while (count < MAX_SAVED_ROOM_PASSWORDS && rf.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) {
if (memcmp(rec.key, pub_key, 4) == 0) {
removed = true;
} else {
recs[count++] = rec;
}
}
rf.close();
if (!removed) return; // nothing to do, avoid a pointless rewrite
File wf = _store->openWrite(ROOM_PW_TMP);
if (!wf) return;
size_t want = sizeof(RoomPwRec) * count;
bool ok = (wf.write((uint8_t *)recs, want) == want);
wf.close();
if (!ok) { _store->removeFile(ROOM_PW_TMP); return; } // keep previous good file
_store->commitFile(ROOM_PW_TMP, ROOM_PW_FILE);
}