mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-11-01 10:21:59 +00:00
refactor(companion): our MyMesh additions in their own headers
Contacts and rooms, the scope list, discovery and ping, APC and screenshots move out of MyMesh.cpp to headers included at its bottom, as MyMeshBot.h and MyMeshCli.h are, so upstream merges meet less of our code. The feature matrix gains the 4.26" in its layout tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,11 +25,15 @@ What decides it in ui-new (look for these when adding a screen):
|
||||
|
||||
| Test | Where it's true |
|
||||
| --- | --- |
|
||||
| `Features::IS_EINK`, `display.isEink()` | E-ink L, E-ink P, E-ink 4.2", T-Echo Lite |
|
||||
| `expandHistory()`: 9 or more lines | E-ink L, E-ink P, E-ink 4.2" |
|
||||
| `Features::IS_EINK`, `display.isEink()` | E-ink L, E-ink P, E-ink 4.2", E-ink 4.26", T-Echo Lite |
|
||||
| `expandHistory()`: 9 or more lines | E-ink L, E-ink P, E-ink 4.2", E-ink 4.26" |
|
||||
| `height() >= 18 * lineHeight` | E-ink P, E-ink 4.2" |
|
||||
| `width() >= 40 * charWidth` ("wide") | E-ink 4.2" |
|
||||
| `isLandscape()` | Small, E-ink L, E-ink 4.2" |
|
||||
| `width() >= 40 * charWidth` ("wide") | E-ink 4.2", E-ink 4.26" |
|
||||
| `isLandscape()` | Small, E-ink L, E-ink 4.2", E-ink 4.26" |
|
||||
|
||||
The tables below have no E-ink 4.26" column: it gets what the E-ink 4.2"
|
||||
column says, except what hangs on the height test above (the keyboard's
|
||||
taller keys).
|
||||
|
||||
Cells: **yes**, **—** (not there), a short note where it differs, or the
|
||||
build flag it needs.
|
||||
|
||||
@@ -448,134 +448,6 @@ int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) {
|
||||
return max_num;
|
||||
}
|
||||
|
||||
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->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;
|
||||
}
|
||||
|
||||
void MyMesh::onContactPathUpdated(const ContactInfo &contact) {
|
||||
out_frame[0] = PUSH_CODE_PATH_UPDATED;
|
||||
memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE);
|
||||
@@ -755,141 +627,6 @@ bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
|
||||
return true;
|
||||
}
|
||||
|
||||
void MyMesh::setPrimaryScope(const char* name) {
|
||||
// Keep the legacy fields in sync too -- inert for rebuildRepeatScopes()
|
||||
// once /scopes1 exists, but still what a pre-list save file round-trips,
|
||||
// and cheap to maintain.
|
||||
strncpy(_prefs.default_scope_name, name, sizeof(_prefs.default_scope_name) - 1);
|
||||
_prefs.default_scope_name[sizeof(_prefs.default_scope_name) - 1] = '\0';
|
||||
if (_prefs.default_scope_name[0] == '\0') {
|
||||
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
|
||||
} else {
|
||||
char hashtag[1 + sizeof(_prefs.default_scope_name)];
|
||||
snprintf(hashtag, sizeof(hashtag), "#%s", _prefs.default_scope_name);
|
||||
TransportKeyStore temp;
|
||||
TransportKey key;
|
||||
temp.getAutoKeyFor(0, hashtag, key);
|
||||
memcpy(_prefs.default_scope_key, key.key, sizeof(key.key));
|
||||
}
|
||||
|
||||
if (_prefs.default_scope_name[0] == '\0') {
|
||||
_scope_list.default_idx = 0; // "*"
|
||||
} else {
|
||||
// Find-or-create a matching named entry (e.g. the app driving this
|
||||
// remotely via CMD_SET_DEFAULT_FLOOD_SCOPE), then mark it default.
|
||||
uint8_t idx = 0;
|
||||
for (uint8_t i = 0; i < _scope_list.count; i++) {
|
||||
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);
|
||||
// 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);
|
||||
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 idx = _scope_list.add(name);
|
||||
if (idx && _store) _store->saveScopeList(_scope_list);
|
||||
return idx;
|
||||
}
|
||||
|
||||
void MyMesh::renameScope(uint8_t idx, const char* name) {
|
||||
if (idx < 1 || idx > _scope_list.count || !name || !name[0]) return;
|
||||
ScopeEntry& e = _scope_list.entries[idx - 1];
|
||||
StrHelper::strncpy(e.name, name, sizeof(e.name));
|
||||
ScopeList::deriveKey(e.name, e.key);
|
||||
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
|
||||
}
|
||||
|
||||
void MyMesh::removeScope(uint8_t idx) {
|
||||
if (idx < 1 || idx > _scope_list.count) return;
|
||||
_scope_list.remove(idx);
|
||||
// repeat_extra_scope_mask bit (i) tracks list index (i+1) -- shift down the
|
||||
// same way ScopeList::remove() shifted entries[], dropping the removed bit.
|
||||
uint16_t old_mask = _prefs.repeat_extra_scope_mask, new_mask = 0;
|
||||
for (uint8_t i = 0; i < ScopeList::MAX_SCOPE_ENTRIES; i++) { // one bit per possible list entry, not per MAX_REPEAT_SCOPES active slot
|
||||
if (!(old_mask & (1u << i))) continue;
|
||||
uint8_t list_idx = i + 1;
|
||||
if (list_idx < idx) new_mask |= (1u << i);
|
||||
else if (list_idx > idx) new_mask |= (1u << (i - 1));
|
||||
// list_idx == idx: the removed one, dropped
|
||||
}
|
||||
_prefs.repeat_extra_scope_mask = new_mask;
|
||||
// Any channel pointed at the removed entry (or shifted ones) needs the same
|
||||
// fix-up ScopeList::remove() applied to default_idx.
|
||||
for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) {
|
||||
uint8_t ci = _prefs.ch_scope_idx[i];
|
||||
if (ci == NodePrefs::CH_SCOPE_DEFAULT) continue; // follows the default, fixed up above
|
||||
if (ci == idx) _prefs.ch_scope_idx[i] = 0;
|
||||
else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1;
|
||||
}
|
||||
// Live Share's own scope (stored as list index + 1; 0 = follow the target).
|
||||
if (_prefs.loc_share_scope) {
|
||||
uint8_t li = _prefs.loc_share_scope - 1;
|
||||
if (li == idx) _prefs.loc_share_scope = 0;
|
||||
else if (li > idx) _prefs.loc_share_scope--;
|
||||
}
|
||||
syncLegacyDefaultScope(); // ScopeList::remove() may have moved or cleared the default
|
||||
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();
|
||||
}
|
||||
|
||||
void MyMesh::setDefaultScope(uint8_t 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);
|
||||
savePrefs();
|
||||
rebuildRepeatScopes();
|
||||
}
|
||||
|
||||
void MyMesh::setChannelScope(uint8_t channel_idx, uint8_t idx) {
|
||||
if (channel_idx >= NodePrefs::MAX_SCOPED_CHANNELS) return;
|
||||
_prefs.ch_scope_idx[channel_idx] = idx == NodePrefs::CH_SCOPE_DEFAULT ? idx : _scope_list.clamp(idx);
|
||||
}
|
||||
|
||||
void MyMesh::rebuildRepeatScopes() {
|
||||
repeat_scope_count = 0;
|
||||
|
||||
TransportKey primary = _scope_list.key(_scope_list.default_idx);
|
||||
if (!primary.isNull()) repeat_scopes[repeat_scope_count++] = primary;
|
||||
|
||||
for (uint8_t i = 0; i < _scope_list.count && repeat_scope_count < MAX_REPEAT_SCOPES; i++) {
|
||||
if (!(_prefs.repeat_extra_scope_mask & (1u << i))) continue;
|
||||
uint8_t list_idx = i + 1;
|
||||
TransportKey k = _scope_list.key(list_idx);
|
||||
if (!k.isNull()) repeat_scopes[repeat_scope_count++] = k;
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis) {
|
||||
if (scope.isNull()) {
|
||||
sendFlood(pkt, delay_millis, _prefs.path_hash_mode + 1);
|
||||
@@ -1278,93 +1015,6 @@ namespace {
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t in_path_len, uint8_t* out_path, uint8_t out_path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) {
|
||||
if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 4) {
|
||||
uint32_t tag;
|
||||
@@ -1413,121 +1063,6 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
|
||||
#define CTL_TYPE_NODE_DISCOVER_REQ 0x80
|
||||
#define CTL_TYPE_NODE_DISCOVER_RESP 0x90
|
||||
|
||||
void MyMesh::sendNodeDiscoverReq() {
|
||||
uint8_t data[10];
|
||||
data[0] = CTL_TYPE_NODE_DISCOVER_REQ;
|
||||
data[1] = (1 << ADV_TYPE_REPEATER) | (1 << ADV_TYPE_SENSOR) | (1 << ADV_TYPE_ROOM);
|
||||
getRNG()->random(&data[2], 4);
|
||||
memcpy(&_pending_node_discover_tag, &data[2], 4);
|
||||
_pending_node_discover_until = futureMillis(8000);
|
||||
_discover_count = 0;
|
||||
uint32_t since = 0;
|
||||
memcpy(&data[6], &since, 4);
|
||||
auto pkt = createControlData(data, sizeof(data));
|
||||
if (pkt) sendZeroHop(pkt);
|
||||
}
|
||||
|
||||
int MyMesh::getDiscoverResults(DiscoverResult dest[], int max_count) {
|
||||
int n = min(_discover_count, max_count);
|
||||
memcpy(dest, _discover_results, n * sizeof(DiscoverResult));
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── Ping/Trace functionality ─────────────────────────────────────────────────
|
||||
|
||||
uint32_t MyMesh::sendPing(const uint8_t* dest_pubkey, uint8_t hash_width) {
|
||||
if (hash_width == 0 || hash_width > 2) return 0;
|
||||
|
||||
// Generate random tag and auth code
|
||||
uint32_t tag, auth;
|
||||
getRNG()->random((uint8_t*)&tag, 4);
|
||||
getRNG()->random((uint8_t*)&auth, 4);
|
||||
|
||||
// Find a free slot in ping results
|
||||
int slot = -1;
|
||||
for (int i = 0; i < PING_RESULT_MAX; i++) {
|
||||
if (!_ping_results[i].received && _ping_results[i].tag == 0) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Initialize ping result tracking
|
||||
PingResult& result = _ping_results[slot];
|
||||
result.tag = tag;
|
||||
result.auth_code = auth;
|
||||
result.snr_out_x4 = 0;
|
||||
result.snr_back_x4 = 0;
|
||||
result.rtt_ms = 0;
|
||||
result.received = false;
|
||||
result.sent_ms = millis();
|
||||
|
||||
// Create path hash from destination public key
|
||||
uint8_t path_len = hash_width;
|
||||
uint8_t path[MAX_PATH_SIZE];
|
||||
memcpy(path, dest_pubkey, path_len);
|
||||
|
||||
// Create and send trace packet
|
||||
auto pkt = createTrace(tag, auth, hash_width - 1); // flags = hash_width - 1
|
||||
if (pkt) {
|
||||
sendDirect(pkt, path, path_len);
|
||||
return tag;
|
||||
}
|
||||
|
||||
// Failed to create packet
|
||||
memset(&result, 0, sizeof(result));
|
||||
return 0;
|
||||
}
|
||||
|
||||
void MyMesh::setPingCallback(PingCallback cb, void* arg) {
|
||||
_ping_callback = cb;
|
||||
_ping_callback_arg = arg;
|
||||
}
|
||||
|
||||
void MyMesh::clearPingResult(uint32_t tag) {
|
||||
if (tag == 0) return;
|
||||
for (int i = 0; i < PING_RESULT_MAX; i++) {
|
||||
if (_ping_results[i].tag == tag) {
|
||||
memset(&_ping_results[i], 0, sizeof(_ping_results[i]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MyMesh::PingResult* MyMesh::getPingResult(uint32_t tag) {
|
||||
for (int i = 0; i < PING_RESULT_MAX; i++) {
|
||||
if (_ping_results[i].tag == tag) {
|
||||
return &_ping_results[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// True when this (tag, responder) pair was already seen recently; records it
|
||||
// otherwise. A discover response is often heard more than once — the zero-hop
|
||||
// direct copy and a re-flooded copy relayed by another repeater carry
|
||||
// different packet hashes, so the mesh duplicate filter passes both. Without
|
||||
// this, the standalone scan appended the same node twice and an app-driven
|
||||
// discover had both copies forwarded, so the app listed one repeater as two.
|
||||
bool MyMesh::isDupDiscoverResp(uint32_t tag, const uint8_t* pub_key) {
|
||||
for (int i = 0; i < DISCOVER_SEEN_MAX; i++) {
|
||||
DiscoverSeen& e = _disc_seen[i];
|
||||
if (e.until != 0 && !millisHasNowPassed(e.until)
|
||||
&& e.tag == tag && memcmp(e.pk, pub_key, sizeof(e.pk)) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
DiscoverSeen& s = _disc_seen[_disc_seen_head];
|
||||
_disc_seen_head = (_disc_seen_head + 1) % DISCOVER_SEEN_MAX;
|
||||
s.tag = tag;
|
||||
memcpy(s.pk, pub_key, sizeof(s.pk));
|
||||
s.until = futureMillis(10000); // copies of one response arrive within seconds
|
||||
return false;
|
||||
}
|
||||
|
||||
void MyMesh::onControlDataRecv(mesh::Packet *packet) {
|
||||
// If we have an active standalone discover, check if this is a matching response.
|
||||
// Tag matching provides isolation — no isBLEConnected check needed.
|
||||
@@ -1707,105 +1242,6 @@ uint32_t MyMesh::calcDirectTimeoutMillisFor(uint32_t pkt_airtime_millis, uint8_t
|
||||
(path_hash_count + 1));
|
||||
}
|
||||
|
||||
// Adaptive Power Control. tx_power_dbm is the user-set ceiling; APC drives the
|
||||
// radio's *actual* TX power within [APC_MIN_DBM, ceiling] to hold the link margin
|
||||
// near a target. The link-quality signal is the SNR of a returning confirmation —
|
||||
// either a direct-message ACK or our own channel/flood packet heard rebroadcast by
|
||||
// a repeater (both are the *reverse* link, a good proxy on a roughly symmetric RF
|
||||
// neighbourhood). No protocol change required.
|
||||
//
|
||||
// The margin is measured *above this SF's demodulation floor* (not an absolute
|
||||
// SNR), so the same target works across SF7–SF12. A single SNR sample is noisy, so
|
||||
// we smooth it with an EWMA and step proportionally to the error (capped), with a
|
||||
// deadband to avoid hunting. A lost confirmation is an ambiguous signal for power,
|
||||
// so we step up gradually and only jump to the ceiling after a short streak.
|
||||
#define APC_MIN_DBM -9 // lower bound (SX126x PA floor)
|
||||
#define APC_TARGET_MARGIN_DB 6.0f // desired SNR margin above the SF demod floor
|
||||
#define APC_DEADBAND_DB 2.0f // hold while the smoothed margin is within ±this
|
||||
#define APC_EWMA_ALPHA 0.4f // smoothing weight for each SNR sample
|
||||
#define APC_MAX_STEP_DB 2 // cap on a single power adjustment (stability)
|
||||
#define APC_FAIL_STEP_DB 4 // power bump on one lost confirmation
|
||||
#define APC_FAIL_CEIL_HITS 2 // consecutive losses → jump straight to the ceiling
|
||||
// How long to wait for a repeater to rebroadcast our channel/flood packet before
|
||||
// treating it as un-heard (flood retransmit delays are randomised over a few s).
|
||||
#define APC_FLOOD_ECHO_WINDOW_MS 6000
|
||||
|
||||
void MyMesh::applyApc() {
|
||||
_apc_cur_dbm = _prefs.tx_power_dbm; // start at the ceiling
|
||||
_apc_margin_ewma = APC_TARGET_MARGIN_DB; // assume on-target until samples say otherwise
|
||||
_apc_fail_count = 0;
|
||||
_apc_flood_pending = false;
|
||||
radio_driver.setTxPower(_apc_cur_dbm);
|
||||
}
|
||||
|
||||
// Feed one reverse-link SNR sample (ACK or heard flood echo) into the controller.
|
||||
void MyMesh::apcSampleSnr(float snr) {
|
||||
_apc_fail_count = 0; // a confirmation clears the failure streak
|
||||
|
||||
float margin = snr - radio_driver.snrFloorForSF(_prefs.sf);
|
||||
_apc_margin_ewma += APC_EWMA_ALPHA * (margin - _apc_margin_ewma); // smooth
|
||||
|
||||
float err = _apc_margin_ewma - APC_TARGET_MARGIN_DB; // +ve = more margin than needed
|
||||
if (fabsf(err) <= APC_DEADBAND_DB) return; // inside deadband → hold
|
||||
|
||||
int step = (int)lroundf(err * 0.5f); // proportional (~half the error)
|
||||
if (step > APC_MAX_STEP_DB) step = APC_MAX_STEP_DB;
|
||||
if (step < -APC_MAX_STEP_DB) step = -APC_MAX_STEP_DB;
|
||||
if (step == 0) return;
|
||||
|
||||
int8_t target = _apc_cur_dbm - step; // surplus margin → lower power
|
||||
if (target < APC_MIN_DBM) target = APC_MIN_DBM;
|
||||
if (target > _prefs.tx_power_dbm) target = _prefs.tx_power_dbm;
|
||||
if (target == _apc_cur_dbm) return;
|
||||
|
||||
int8_t delta = target - _apc_cur_dbm;
|
||||
_apc_cur_dbm = target;
|
||||
radio_driver.setTxPower(_apc_cur_dbm);
|
||||
_apc_margin_ewma += (float)delta; // anticipate the margin shift (≈1 dB TX → 1 dB SNR)
|
||||
}
|
||||
|
||||
// A send got no confirmation (missed ACK, or a flood no repeater echoed). Ramp up.
|
||||
void MyMesh::apcOnFailure() {
|
||||
if (_apc_cur_dbm >= _prefs.tx_power_dbm) return; // already at the ceiling
|
||||
|
||||
int8_t target;
|
||||
if (++_apc_fail_count >= APC_FAIL_CEIL_HITS) {
|
||||
target = _prefs.tx_power_dbm; // repeated losses → restore full reliability
|
||||
} else {
|
||||
target = _apc_cur_dbm + APC_FAIL_STEP_DB;
|
||||
if (target > _prefs.tx_power_dbm) target = _prefs.tx_power_dbm;
|
||||
}
|
||||
_apc_cur_dbm = target;
|
||||
_apc_margin_ewma = APC_TARGET_MARGIN_DB; // reset so the next sample doesn't trim straight back
|
||||
radio_driver.setTxPower(_apc_cur_dbm);
|
||||
}
|
||||
|
||||
// Remember a channel/flood packet we just originated so a heard rebroadcast can be
|
||||
// matched as positive feedback (and its absence as a failure). The hash excludes
|
||||
// the mutable path, so it matches across repeater rebroadcasts.
|
||||
void MyMesh::apcTrackFloodSend(const mesh::Packet* pkt) {
|
||||
pkt->calculatePacketHash(_apc_flood_hash);
|
||||
_apc_flood_len = pkt->payload_len;
|
||||
_apc_flood_deadline = futureMillis(APC_FLOOD_ECHO_WINDOW_MS);
|
||||
_apc_flood_pending = true;
|
||||
}
|
||||
|
||||
// Arm the UI "relayed into mesh" tracker for a channel send (independent of APC).
|
||||
// A repeater rebroadcast heard within the window = relayed; no echo = simply not
|
||||
// shown as relayed (NOT a failure — direct/0-hop neighbours never echo).
|
||||
void MyMesh::trackRelaySend(const mesh::Packet* pkt) {
|
||||
RelaySlot& s = _relay[_relay_head];
|
||||
if (!s.pending) _relay_active++; // overwriting an empty slot adds one pending
|
||||
pkt->calculatePacketHash(s.hash);
|
||||
s.len = pkt->payload_len;
|
||||
s.deadline = futureMillis(APC_FLOOD_ECHO_WINDOW_MS);
|
||||
_relay_seq = (_relay_seq == 0xFFFFFFFFu) ? 1 : _relay_seq + 1; // never 0 (0 = "no relay")
|
||||
s.seq = _relay_seq;
|
||||
s.pending = true;
|
||||
_last_relay_seq = _relay_seq;
|
||||
_relay_head = (_relay_head + 1) % RELAY_RING;
|
||||
}
|
||||
|
||||
void MyMesh::onSendTimeout() {
|
||||
if (apcActive()) apcOnFailure();
|
||||
}
|
||||
@@ -3173,84 +2609,6 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef ENABLE_SCREENSHOT
|
||||
void MyMesh::handleScreenshotRequest() {
|
||||
#ifdef DISPLAY_CLASS
|
||||
extern UITask ui_task; // main.cpp -- the Listener may be the UI Core, not UITask
|
||||
#ifdef UI_SCREENSHOT_RGB565
|
||||
// A colour UI renders a frame for it (RGB565, display type 2).
|
||||
int w, h;
|
||||
uint16_t* px = ui_task.captureFrame(w, h);
|
||||
if (!px) {
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
return;
|
||||
}
|
||||
sendScreenshotResponse(SCREENSHOT_TYPE_RGB565, 0, (uint16_t)w, (uint16_t)h, (const uint8_t*)px, (uint32_t)w * h * 2);
|
||||
free(px);
|
||||
#else
|
||||
if (!ui_task.hasDisplay()) {
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
return;
|
||||
}
|
||||
|
||||
DisplayDriver* display = ui_task.getDisplay();
|
||||
if (!display) {
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t* buffer = display->getBuffer();
|
||||
uint16_t bufferSize = display->getBufferSize();
|
||||
|
||||
if (buffer && bufferSize > 0) {
|
||||
sendScreenshotResponse(display->getDisplayType(), display->screenshotRotation(),
|
||||
(uint16_t)display->screenshotWidth(), (uint16_t)display->screenshotHeight(),
|
||||
buffer, bufferSize);
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
}
|
||||
#endif
|
||||
#else
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
#endif
|
||||
}
|
||||
|
||||
void MyMesh::sendScreenshotResponse(uint8_t displayType, uint8_t rotation, uint16_t width, uint16_t height,
|
||||
const uint8_t* buffer, uint32_t bufferSize) {
|
||||
// Frame format (11-byte header, little-endian multi-byte fields):
|
||||
// [0] resp_code = RESP_CODE_SCREENSHOT
|
||||
// [1] display_type (0=OLED page-based, 1=e-ink row-major MSB-first 1=white,
|
||||
// 2=RGB565 little-endian, row-major -- colour screens)
|
||||
// [2] rotation (0-3, GxEPD2/Adafruit_GFX value; only meaningful for e-ink)
|
||||
// [3..4] width (uint16 LE — GxEPD2-reported visible width)
|
||||
// [5..6] height (uint16 LE — GxEPD2-reported visible height)
|
||||
// [7..8] chunk_idx (uint16 LE)
|
||||
// [9..10] total_chunks (uint16 LE)
|
||||
// [11..] chunk data
|
||||
const int HEADER_SIZE = 11;
|
||||
const uint32_t MAX_DATA_PER_FRAME = MAX_FRAME_SIZE - HEADER_SIZE;
|
||||
const uint16_t totalChunks = (uint16_t)((bufferSize + MAX_DATA_PER_FRAME - 1) / MAX_DATA_PER_FRAME);
|
||||
|
||||
for (uint16_t chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) {
|
||||
int i = 0;
|
||||
out_frame[i++] = RESP_CODE_SCREENSHOT;
|
||||
out_frame[i++] = displayType;
|
||||
out_frame[i++] = rotation;
|
||||
out_frame[i++] = width & 0xFF; out_frame[i++] = width >> 8;
|
||||
out_frame[i++] = height & 0xFF; out_frame[i++] = height >> 8;
|
||||
out_frame[i++] = chunkIdx & 0xFF; out_frame[i++] = chunkIdx >> 8;
|
||||
out_frame[i++] = totalChunks & 0xFF; out_frame[i++] = totalChunks >> 8;
|
||||
|
||||
const uint32_t offset = (uint32_t)chunkIdx * MAX_DATA_PER_FRAME;
|
||||
const uint32_t chunkSize = min(MAX_DATA_PER_FRAME, bufferSize - offset);
|
||||
memcpy(&out_frame[i], buffer + offset, chunkSize);
|
||||
i += chunkSize;
|
||||
|
||||
_serial->writeFrame(out_frame, i);
|
||||
}
|
||||
}
|
||||
#endif // ENABLE_SCREENSHOT
|
||||
|
||||
static bool save_filter(const ContactInfo& c) {
|
||||
return c.type != ADV_TYPE_NONE; // don't save the transient/anon entries
|
||||
}
|
||||
@@ -3580,5 +2938,10 @@ bool MyMesh::hasPendingWork() const {
|
||||
return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0;
|
||||
}
|
||||
|
||||
#include "MyMeshContacts.h"
|
||||
#include "MyMeshScope.h"
|
||||
#include "MyMeshPing.h"
|
||||
#include "MyMeshApc.h"
|
||||
#include "MyMeshScreenshot.h"
|
||||
#include "MyMeshBot.h"
|
||||
#include "MyMeshCli.h"
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#pragma once
|
||||
// Adaptive power control, and the relay tracking behind the UI's "relayed" mark.
|
||||
// Not part of upstream MyMesh.cpp: included at its bottom, after all class
|
||||
// definitions (as MyMeshBot.h and MyMeshCli.h).
|
||||
|
||||
// Adaptive Power Control. tx_power_dbm is the user-set ceiling; APC drives the
|
||||
// radio's *actual* TX power within [APC_MIN_DBM, ceiling] to hold the link margin
|
||||
// near a target. The link-quality signal is the SNR of a returning confirmation —
|
||||
// either a direct-message ACK or our own channel/flood packet heard rebroadcast by
|
||||
// a repeater (both are the *reverse* link, a good proxy on a roughly symmetric RF
|
||||
// neighbourhood). No protocol change required.
|
||||
//
|
||||
// The margin is measured *above this SF's demodulation floor* (not an absolute
|
||||
// SNR), so the same target works across SF7–SF12. A single SNR sample is noisy, so
|
||||
// we smooth it with an EWMA and step proportionally to the error (capped), with a
|
||||
// deadband to avoid hunting. A lost confirmation is an ambiguous signal for power,
|
||||
// so we step up gradually and only jump to the ceiling after a short streak.
|
||||
#define APC_MIN_DBM -9 // lower bound (SX126x PA floor)
|
||||
#define APC_TARGET_MARGIN_DB 6.0f // desired SNR margin above the SF demod floor
|
||||
#define APC_DEADBAND_DB 2.0f // hold while the smoothed margin is within ±this
|
||||
#define APC_EWMA_ALPHA 0.4f // smoothing weight for each SNR sample
|
||||
#define APC_MAX_STEP_DB 2 // cap on a single power adjustment (stability)
|
||||
#define APC_FAIL_STEP_DB 4 // power bump on one lost confirmation
|
||||
#define APC_FAIL_CEIL_HITS 2 // consecutive losses → jump straight to the ceiling
|
||||
// How long to wait for a repeater to rebroadcast our channel/flood packet before
|
||||
// treating it as un-heard (flood retransmit delays are randomised over a few s).
|
||||
#define APC_FLOOD_ECHO_WINDOW_MS 6000
|
||||
|
||||
void MyMesh::applyApc() {
|
||||
_apc_cur_dbm = _prefs.tx_power_dbm; // start at the ceiling
|
||||
_apc_margin_ewma = APC_TARGET_MARGIN_DB; // assume on-target until samples say otherwise
|
||||
_apc_fail_count = 0;
|
||||
_apc_flood_pending = false;
|
||||
radio_driver.setTxPower(_apc_cur_dbm);
|
||||
}
|
||||
|
||||
// Feed one reverse-link SNR sample (ACK or heard flood echo) into the controller.
|
||||
void MyMesh::apcSampleSnr(float snr) {
|
||||
_apc_fail_count = 0; // a confirmation clears the failure streak
|
||||
|
||||
float margin = snr - radio_driver.snrFloorForSF(_prefs.sf);
|
||||
_apc_margin_ewma += APC_EWMA_ALPHA * (margin - _apc_margin_ewma); // smooth
|
||||
|
||||
float err = _apc_margin_ewma - APC_TARGET_MARGIN_DB; // +ve = more margin than needed
|
||||
if (fabsf(err) <= APC_DEADBAND_DB) return; // inside deadband → hold
|
||||
|
||||
int step = (int)lroundf(err * 0.5f); // proportional (~half the error)
|
||||
if (step > APC_MAX_STEP_DB) step = APC_MAX_STEP_DB;
|
||||
if (step < -APC_MAX_STEP_DB) step = -APC_MAX_STEP_DB;
|
||||
if (step == 0) return;
|
||||
|
||||
int8_t target = _apc_cur_dbm - step; // surplus margin → lower power
|
||||
if (target < APC_MIN_DBM) target = APC_MIN_DBM;
|
||||
if (target > _prefs.tx_power_dbm) target = _prefs.tx_power_dbm;
|
||||
if (target == _apc_cur_dbm) return;
|
||||
|
||||
int8_t delta = target - _apc_cur_dbm;
|
||||
_apc_cur_dbm = target;
|
||||
radio_driver.setTxPower(_apc_cur_dbm);
|
||||
_apc_margin_ewma += (float)delta; // anticipate the margin shift (≈1 dB TX → 1 dB SNR)
|
||||
}
|
||||
|
||||
// A send got no confirmation (missed ACK, or a flood no repeater echoed). Ramp up.
|
||||
void MyMesh::apcOnFailure() {
|
||||
if (_apc_cur_dbm >= _prefs.tx_power_dbm) return; // already at the ceiling
|
||||
|
||||
int8_t target;
|
||||
if (++_apc_fail_count >= APC_FAIL_CEIL_HITS) {
|
||||
target = _prefs.tx_power_dbm; // repeated losses → restore full reliability
|
||||
} else {
|
||||
target = _apc_cur_dbm + APC_FAIL_STEP_DB;
|
||||
if (target > _prefs.tx_power_dbm) target = _prefs.tx_power_dbm;
|
||||
}
|
||||
_apc_cur_dbm = target;
|
||||
_apc_margin_ewma = APC_TARGET_MARGIN_DB; // reset so the next sample doesn't trim straight back
|
||||
radio_driver.setTxPower(_apc_cur_dbm);
|
||||
}
|
||||
|
||||
// Remember a channel/flood packet we just originated so a heard rebroadcast can be
|
||||
// matched as positive feedback (and its absence as a failure). The hash excludes
|
||||
// the mutable path, so it matches across repeater rebroadcasts.
|
||||
void MyMesh::apcTrackFloodSend(const mesh::Packet* pkt) {
|
||||
pkt->calculatePacketHash(_apc_flood_hash);
|
||||
_apc_flood_len = pkt->payload_len;
|
||||
_apc_flood_deadline = futureMillis(APC_FLOOD_ECHO_WINDOW_MS);
|
||||
_apc_flood_pending = true;
|
||||
}
|
||||
|
||||
// Arm the UI "relayed into mesh" tracker for a channel send (independent of APC).
|
||||
// A repeater rebroadcast heard within the window = relayed; no echo = simply not
|
||||
// shown as relayed (NOT a failure — direct/0-hop neighbours never echo).
|
||||
void MyMesh::trackRelaySend(const mesh::Packet* pkt) {
|
||||
RelaySlot& s = _relay[_relay_head];
|
||||
if (!s.pending) _relay_active++; // overwriting an empty slot adds one pending
|
||||
pkt->calculatePacketHash(s.hash);
|
||||
s.len = pkt->payload_len;
|
||||
s.deadline = futureMillis(APC_FLOOD_ECHO_WINDOW_MS);
|
||||
_relay_seq = (_relay_seq == 0xFFFFFFFFu) ? 1 : _relay_seq + 1; // never 0 (0 = "no relay")
|
||||
s.seq = _relay_seq;
|
||||
s.pending = true;
|
||||
_last_relay_seq = _relay_seq;
|
||||
_relay_head = (_relay_head + 1) % RELAY_RING;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
#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->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);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
#pragma once
|
||||
// Node discovery and ping (trace) from the UI.
|
||||
// Not part of upstream MyMesh.cpp: included at its bottom, after all class
|
||||
// definitions (as MyMeshBot.h and MyMeshCli.h).
|
||||
|
||||
void MyMesh::sendNodeDiscoverReq() {
|
||||
uint8_t data[10];
|
||||
data[0] = CTL_TYPE_NODE_DISCOVER_REQ;
|
||||
data[1] = (1 << ADV_TYPE_REPEATER) | (1 << ADV_TYPE_SENSOR) | (1 << ADV_TYPE_ROOM);
|
||||
getRNG()->random(&data[2], 4);
|
||||
memcpy(&_pending_node_discover_tag, &data[2], 4);
|
||||
_pending_node_discover_until = futureMillis(8000);
|
||||
_discover_count = 0;
|
||||
uint32_t since = 0;
|
||||
memcpy(&data[6], &since, 4);
|
||||
auto pkt = createControlData(data, sizeof(data));
|
||||
if (pkt) sendZeroHop(pkt);
|
||||
}
|
||||
|
||||
int MyMesh::getDiscoverResults(DiscoverResult dest[], int max_count) {
|
||||
int n = min(_discover_count, max_count);
|
||||
memcpy(dest, _discover_results, n * sizeof(DiscoverResult));
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── Ping/Trace functionality ─────────────────────────────────────────────────
|
||||
|
||||
uint32_t MyMesh::sendPing(const uint8_t* dest_pubkey, uint8_t hash_width) {
|
||||
if (hash_width == 0 || hash_width > 2) return 0;
|
||||
|
||||
// Generate random tag and auth code
|
||||
uint32_t tag, auth;
|
||||
getRNG()->random((uint8_t*)&tag, 4);
|
||||
getRNG()->random((uint8_t*)&auth, 4);
|
||||
|
||||
// Find a free slot in ping results
|
||||
int slot = -1;
|
||||
for (int i = 0; i < PING_RESULT_MAX; i++) {
|
||||
if (!_ping_results[i].received && _ping_results[i].tag == 0) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (slot < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Initialize ping result tracking
|
||||
PingResult& result = _ping_results[slot];
|
||||
result.tag = tag;
|
||||
result.auth_code = auth;
|
||||
result.snr_out_x4 = 0;
|
||||
result.snr_back_x4 = 0;
|
||||
result.rtt_ms = 0;
|
||||
result.received = false;
|
||||
result.sent_ms = millis();
|
||||
|
||||
// Create path hash from destination public key
|
||||
uint8_t path_len = hash_width;
|
||||
uint8_t path[MAX_PATH_SIZE];
|
||||
memcpy(path, dest_pubkey, path_len);
|
||||
|
||||
// Create and send trace packet
|
||||
auto pkt = createTrace(tag, auth, hash_width - 1); // flags = hash_width - 1
|
||||
if (pkt) {
|
||||
sendDirect(pkt, path, path_len);
|
||||
return tag;
|
||||
}
|
||||
|
||||
// Failed to create packet
|
||||
memset(&result, 0, sizeof(result));
|
||||
return 0;
|
||||
}
|
||||
|
||||
void MyMesh::setPingCallback(PingCallback cb, void* arg) {
|
||||
_ping_callback = cb;
|
||||
_ping_callback_arg = arg;
|
||||
}
|
||||
|
||||
void MyMesh::clearPingResult(uint32_t tag) {
|
||||
if (tag == 0) return;
|
||||
for (int i = 0; i < PING_RESULT_MAX; i++) {
|
||||
if (_ping_results[i].tag == tag) {
|
||||
memset(&_ping_results[i], 0, sizeof(_ping_results[i]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MyMesh::PingResult* MyMesh::getPingResult(uint32_t tag) {
|
||||
for (int i = 0; i < PING_RESULT_MAX; i++) {
|
||||
if (_ping_results[i].tag == tag) {
|
||||
return &_ping_results[i];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// True when this (tag, responder) pair was already seen recently; records it
|
||||
// otherwise. A discover response is often heard more than once — the zero-hop
|
||||
// direct copy and a re-flooded copy relayed by another repeater carry
|
||||
// different packet hashes, so the mesh duplicate filter passes both. Without
|
||||
// this, the standalone scan appended the same node twice and an app-driven
|
||||
// discover had both copies forwarded, so the app listed one repeater as two.
|
||||
bool MyMesh::isDupDiscoverResp(uint32_t tag, const uint8_t* pub_key) {
|
||||
for (int i = 0; i < DISCOVER_SEEN_MAX; i++) {
|
||||
DiscoverSeen& e = _disc_seen[i];
|
||||
if (e.until != 0 && !millisHasNowPassed(e.until)
|
||||
&& e.tag == tag && memcmp(e.pk, pub_key, sizeof(e.pk)) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
DiscoverSeen& s = _disc_seen[_disc_seen_head];
|
||||
_disc_seen_head = (_disc_seen_head + 1) % DISCOVER_SEEN_MAX;
|
||||
s.tag = tag;
|
||||
memcpy(s.pk, pub_key, sizeof(s.pk));
|
||||
s.until = futureMillis(10000); // copies of one response arrive within seconds
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
#pragma once
|
||||
// The shared scope list: the primary / default scope, per-channel scopes and
|
||||
// the repeat scopes they make.
|
||||
// Not part of upstream MyMesh.cpp: included at its bottom, after all class
|
||||
// definitions (as MyMeshBot.h and MyMeshCli.h).
|
||||
|
||||
void MyMesh::setPrimaryScope(const char* name) {
|
||||
// Keep the legacy fields in sync too -- inert for rebuildRepeatScopes()
|
||||
// once /scopes1 exists, but still what a pre-list save file round-trips,
|
||||
// and cheap to maintain.
|
||||
strncpy(_prefs.default_scope_name, name, sizeof(_prefs.default_scope_name) - 1);
|
||||
_prefs.default_scope_name[sizeof(_prefs.default_scope_name) - 1] = '\0';
|
||||
if (_prefs.default_scope_name[0] == '\0') {
|
||||
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
|
||||
} else {
|
||||
char hashtag[1 + sizeof(_prefs.default_scope_name)];
|
||||
snprintf(hashtag, sizeof(hashtag), "#%s", _prefs.default_scope_name);
|
||||
TransportKeyStore temp;
|
||||
TransportKey key;
|
||||
temp.getAutoKeyFor(0, hashtag, key);
|
||||
memcpy(_prefs.default_scope_key, key.key, sizeof(key.key));
|
||||
}
|
||||
|
||||
if (_prefs.default_scope_name[0] == '\0') {
|
||||
_scope_list.default_idx = 0; // "*"
|
||||
} else {
|
||||
// Find-or-create a matching named entry (e.g. the app driving this
|
||||
// remotely via CMD_SET_DEFAULT_FLOOD_SCOPE), then mark it default.
|
||||
uint8_t idx = 0;
|
||||
for (uint8_t i = 0; i < _scope_list.count; i++) {
|
||||
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);
|
||||
// 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);
|
||||
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 idx = _scope_list.add(name);
|
||||
if (idx && _store) _store->saveScopeList(_scope_list);
|
||||
return idx;
|
||||
}
|
||||
|
||||
void MyMesh::renameScope(uint8_t idx, const char* name) {
|
||||
if (idx < 1 || idx > _scope_list.count || !name || !name[0]) return;
|
||||
ScopeEntry& e = _scope_list.entries[idx - 1];
|
||||
StrHelper::strncpy(e.name, name, sizeof(e.name));
|
||||
ScopeList::deriveKey(e.name, e.key);
|
||||
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
|
||||
}
|
||||
|
||||
void MyMesh::removeScope(uint8_t idx) {
|
||||
if (idx < 1 || idx > _scope_list.count) return;
|
||||
_scope_list.remove(idx);
|
||||
// repeat_extra_scope_mask bit (i) tracks list index (i+1) -- shift down the
|
||||
// same way ScopeList::remove() shifted entries[], dropping the removed bit.
|
||||
uint16_t old_mask = _prefs.repeat_extra_scope_mask, new_mask = 0;
|
||||
for (uint8_t i = 0; i < ScopeList::MAX_SCOPE_ENTRIES; i++) { // one bit per possible list entry, not per MAX_REPEAT_SCOPES active slot
|
||||
if (!(old_mask & (1u << i))) continue;
|
||||
uint8_t list_idx = i + 1;
|
||||
if (list_idx < idx) new_mask |= (1u << i);
|
||||
else if (list_idx > idx) new_mask |= (1u << (i - 1));
|
||||
// list_idx == idx: the removed one, dropped
|
||||
}
|
||||
_prefs.repeat_extra_scope_mask = new_mask;
|
||||
// Any channel pointed at the removed entry (or shifted ones) needs the same
|
||||
// fix-up ScopeList::remove() applied to default_idx.
|
||||
for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) {
|
||||
uint8_t ci = _prefs.ch_scope_idx[i];
|
||||
if (ci == NodePrefs::CH_SCOPE_DEFAULT) continue; // follows the default, fixed up above
|
||||
if (ci == idx) _prefs.ch_scope_idx[i] = 0;
|
||||
else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1;
|
||||
}
|
||||
// Live Share's own scope (stored as list index + 1; 0 = follow the target).
|
||||
if (_prefs.loc_share_scope) {
|
||||
uint8_t li = _prefs.loc_share_scope - 1;
|
||||
if (li == idx) _prefs.loc_share_scope = 0;
|
||||
else if (li > idx) _prefs.loc_share_scope--;
|
||||
}
|
||||
syncLegacyDefaultScope(); // ScopeList::remove() may have moved or cleared the default
|
||||
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();
|
||||
}
|
||||
|
||||
void MyMesh::setDefaultScope(uint8_t 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);
|
||||
savePrefs();
|
||||
rebuildRepeatScopes();
|
||||
}
|
||||
|
||||
void MyMesh::setChannelScope(uint8_t channel_idx, uint8_t idx) {
|
||||
if (channel_idx >= NodePrefs::MAX_SCOPED_CHANNELS) return;
|
||||
_prefs.ch_scope_idx[channel_idx] = idx == NodePrefs::CH_SCOPE_DEFAULT ? idx : _scope_list.clamp(idx);
|
||||
}
|
||||
|
||||
void MyMesh::rebuildRepeatScopes() {
|
||||
repeat_scope_count = 0;
|
||||
|
||||
TransportKey primary = _scope_list.key(_scope_list.default_idx);
|
||||
if (!primary.isNull()) repeat_scopes[repeat_scope_count++] = primary;
|
||||
|
||||
for (uint8_t i = 0; i < _scope_list.count && repeat_scope_count < MAX_REPEAT_SCOPES; i++) {
|
||||
if (!(_prefs.repeat_extra_scope_mask & (1u << i))) continue;
|
||||
uint8_t list_idx = i + 1;
|
||||
TransportKey k = _scope_list.key(list_idx);
|
||||
if (!k.isNull()) repeat_scopes[repeat_scope_count++] = k;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
// Screenshots over the companion protocol (ENABLE_SCREENSHOT).
|
||||
// Not part of upstream MyMesh.cpp: included at its bottom, after all class
|
||||
// definitions (as MyMeshBot.h and MyMeshCli.h).
|
||||
|
||||
#ifdef ENABLE_SCREENSHOT
|
||||
void MyMesh::handleScreenshotRequest() {
|
||||
#ifdef DISPLAY_CLASS
|
||||
extern UITask ui_task; // main.cpp -- the Listener may be the UI Core, not UITask
|
||||
#ifdef UI_SCREENSHOT_RGB565
|
||||
// A colour UI renders a frame for it (RGB565, display type 2).
|
||||
int w, h;
|
||||
uint16_t* px = ui_task.captureFrame(w, h);
|
||||
if (!px) {
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
return;
|
||||
}
|
||||
sendScreenshotResponse(SCREENSHOT_TYPE_RGB565, 0, (uint16_t)w, (uint16_t)h, (const uint8_t*)px, (uint32_t)w * h * 2);
|
||||
free(px);
|
||||
#else
|
||||
if (!ui_task.hasDisplay()) {
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
return;
|
||||
}
|
||||
|
||||
DisplayDriver* display = ui_task.getDisplay();
|
||||
if (!display) {
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t* buffer = display->getBuffer();
|
||||
uint16_t bufferSize = display->getBufferSize();
|
||||
|
||||
if (buffer && bufferSize > 0) {
|
||||
sendScreenshotResponse(display->getDisplayType(), display->screenshotRotation(),
|
||||
(uint16_t)display->screenshotWidth(), (uint16_t)display->screenshotHeight(),
|
||||
buffer, bufferSize);
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
}
|
||||
#endif
|
||||
#else
|
||||
writeErrFrame(ERR_CODE_UNSUPPORTED_CMD);
|
||||
#endif
|
||||
}
|
||||
|
||||
void MyMesh::sendScreenshotResponse(uint8_t displayType, uint8_t rotation, uint16_t width, uint16_t height,
|
||||
const uint8_t* buffer, uint32_t bufferSize) {
|
||||
// Frame format (11-byte header, little-endian multi-byte fields):
|
||||
// [0] resp_code = RESP_CODE_SCREENSHOT
|
||||
// [1] display_type (0=OLED page-based, 1=e-ink row-major MSB-first 1=white,
|
||||
// 2=RGB565 little-endian, row-major -- colour screens)
|
||||
// [2] rotation (0-3, GxEPD2/Adafruit_GFX value; only meaningful for e-ink)
|
||||
// [3..4] width (uint16 LE — GxEPD2-reported visible width)
|
||||
// [5..6] height (uint16 LE — GxEPD2-reported visible height)
|
||||
// [7..8] chunk_idx (uint16 LE)
|
||||
// [9..10] total_chunks (uint16 LE)
|
||||
// [11..] chunk data
|
||||
const int HEADER_SIZE = 11;
|
||||
const uint32_t MAX_DATA_PER_FRAME = MAX_FRAME_SIZE - HEADER_SIZE;
|
||||
const uint16_t totalChunks = (uint16_t)((bufferSize + MAX_DATA_PER_FRAME - 1) / MAX_DATA_PER_FRAME);
|
||||
|
||||
for (uint16_t chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) {
|
||||
int i = 0;
|
||||
out_frame[i++] = RESP_CODE_SCREENSHOT;
|
||||
out_frame[i++] = displayType;
|
||||
out_frame[i++] = rotation;
|
||||
out_frame[i++] = width & 0xFF; out_frame[i++] = width >> 8;
|
||||
out_frame[i++] = height & 0xFF; out_frame[i++] = height >> 8;
|
||||
out_frame[i++] = chunkIdx & 0xFF; out_frame[i++] = chunkIdx >> 8;
|
||||
out_frame[i++] = totalChunks & 0xFF; out_frame[i++] = totalChunks >> 8;
|
||||
|
||||
const uint32_t offset = (uint32_t)chunkIdx * MAX_DATA_PER_FRAME;
|
||||
const uint32_t chunkSize = min(MAX_DATA_PER_FRAME, bufferSize - offset);
|
||||
memcpy(&out_frame[i], buffer + offset, chunkSize);
|
||||
i += chunkSize;
|
||||
|
||||
_serial->writeFrame(out_frame, i);
|
||||
}
|
||||
}
|
||||
#endif // ENABLE_SCREENSHOT
|
||||
Reference in New Issue
Block a user