diff --git a/docs/solo/developer/feature-matrix.md b/docs/solo/developer/feature-matrix.md index ce8ab9e1..c09e296f 100644 --- a/docs/solo/developer/feature-matrix.md +++ b/docs/solo/developer/feature-matrix.md @@ -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. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 7e1491d1..19d0dbe7 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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" diff --git a/examples/companion_radio/MyMeshApc.h b/examples/companion_radio/MyMeshApc.h new file mode 100644 index 00000000..a3413b38 --- /dev/null +++ b/examples/companion_radio/MyMeshApc.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; +} diff --git a/examples/companion_radio/MyMeshContacts.h b/examples/companion_radio/MyMeshContacts.h new file mode 100644 index 00000000..968e95f2 --- /dev/null +++ b/examples/companion_radio/MyMeshContacts.h @@ -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); +} diff --git a/examples/companion_radio/MyMeshPing.h b/examples/companion_radio/MyMeshPing.h new file mode 100644 index 00000000..6c54e357 --- /dev/null +++ b/examples/companion_radio/MyMeshPing.h @@ -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; +} diff --git a/examples/companion_radio/MyMeshScope.h b/examples/companion_radio/MyMeshScope.h new file mode 100644 index 00000000..5d690978 --- /dev/null +++ b/examples/companion_radio/MyMeshScope.h @@ -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; + } +} diff --git a/examples/companion_radio/MyMeshScreenshot.h b/examples/companion_radio/MyMeshScreenshot.h new file mode 100644 index 00000000..5efef8db --- /dev/null +++ b/examples/companion_radio/MyMeshScreenshot.h @@ -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