From fe949235d914b8d259caf5142cc38896d1b2d392 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:16:07 +1100 Subject: [PATCH] fixed stupid persistent contacts saved bug in datastore; prelim contacts discovery function --- examples/companion_radio/DataStore.cpp | 11 +- examples/companion_radio/MyMesh.cpp | 94 +++++++++ examples/companion_radio/MyMesh.h | 23 +++ examples/companion_radio/main.cpp | 40 +++- .../companion_radio/ui-new/Contactsscreen.h | 8 +- .../companion_radio/ui-new/Discoveryscreen.h | 194 ++++++++++++++++++ examples/companion_radio/ui-new/UITask.cpp | 12 ++ examples/companion_radio/ui-new/UITask.h | 4 + 8 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 examples/companion_radio/ui-new/Discoveryscreen.h diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 3048b01..ea43d7f 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -405,11 +405,15 @@ void DataStore::saveContacts(DataStoreHost* host) { idx++; } - size_t bytesWritten = file.size(); file.close(); // --- Step 2: Verify the write completed --- + // Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close) size_t expectedBytes = recordsWritten * 152; // 152 bytes per contact record + File verify = openRead(fs, tmpPath); + size_t bytesWritten = verify ? verify.size() : 0; + if (verify) verify.close(); + if (!writeOk || bytesWritten != expectedBytes) { Serial.printf("DataStore: saveContacts ABORTED — wrote %d bytes, expected %d (%d records)\n", (int)bytesWritten, (int)expectedBytes, recordsWritten); @@ -493,10 +497,13 @@ void DataStore::saveChannels(DataStoreHost* host) { channel_idx++; } - size_t bytesWritten = file.size(); file.close(); + // Reopen read-only to get true on-disk size (SPIFFS file.size() is unreliable before close) size_t expectedBytes = channel_idx * 68; // 4 + 32 + 32 = 68 bytes per channel + File verify = openRead(fs, tmpPath); + size_t bytesWritten = verify ? verify.size() : 0; + if (verify) verify.close(); if (!writeOk || bytesWritten != expectedBytes) { Serial.printf("DataStore: saveChannels ABORTED — wrote %d bytes, expected %d\n", (int)bytesWritten, (int)expectedBytes); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4cace04..b47e06f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -357,6 +357,30 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path memcpy(p->path, path, p->path_len); } + // Buffer for on-device discovery UI + if (_discoveryActive && _discoveredCount < MAX_DISCOVERED_NODES) { + bool dup = false; + for (int i = 0; i < _discoveredCount; i++) { + if (contact.id.matches(_discovered[i].contact.id)) { + // Update existing entry with fresher data + _discovered[i].contact = contact; + _discovered[i].path_len = path_len; + _discovered[i].already_in_contacts = !is_new; + dup = true; + Serial.printf("[Discovery] Updated: %s (hops=%d)\n", contact.name, path_len); + break; + } + } + if (!dup) { + _discovered[_discoveredCount].contact = contact; + _discovered[_discoveredCount].path_len = path_len; + _discovered[_discoveredCount].already_in_contacts = !is_new; + _discoveredCount++; + Serial.printf("[Discovery] Found: %s (hops=%d, is_new=%d, total=%d)\n", + contact.name, path_len, is_new, _discoveredCount); + } + } + if (!is_new) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // only schedule lazy write for contacts that are in contacts[] } @@ -998,6 +1022,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe memset(_sent_track, 0, sizeof(_sent_track)); _sent_track_idx = 0; _admin_contact_idx = -1; + _discoveredCount = 0; + _discoveryActive = false; + _discoveryTimeout = 0; // defaults memset(&_prefs, 0, sizeof(_prefs)); @@ -2201,6 +2228,12 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + // Discovery scan timeout + if (_discoveryActive && millisHasNowPassed(_discoveryTimeout)) { + _discoveryActive = false; + Serial.printf("[Discovery] Scan complete: %d nodes found\n", _discoveredCount); + } + #ifdef DISPLAY_CLASS if (_ui) _ui->setHasConnection(_serial->isConnected()); #endif @@ -2219,4 +2252,65 @@ bool MyMesh::advert() { } else { return false; } +} + +void MyMesh::startDiscovery(uint32_t duration_ms) { + _discoveredCount = 0; + _discoveryActive = true; + _discoveryTimeout = futureMillis(duration_ms); + Serial.printf("[Discovery] Scan started (%lu ms)\n", duration_ms); + + // Pre-seed from advert_paths cache (nodes heard recently, before scan started) + for (int i = 0; i < ADVERT_PATH_TABLE_SIZE && _discoveredCount < MAX_DISCOVERED_NODES; i++) { + if (advert_paths[i].recv_timestamp == 0) continue; // empty slot + + // Look up full contact info by pubkey prefix + ContactInfo* c = lookupContactByPubKey(advert_paths[i].pubkey_prefix, sizeof(advert_paths[i].pubkey_prefix)); + if (c) { + _discovered[_discoveredCount].contact = *c; + _discovered[_discoveredCount].path_len = advert_paths[i].path_len; + _discovered[_discoveredCount].already_in_contacts = true; + _discoveredCount++; + } + } + Serial.printf("[Discovery] Pre-seeded %d nodes from cache\n", _discoveredCount); + + // Flood self-advert through mesh (not zero-hop) so repeaters + // multiple hops away hear it and respond with their own adverts + mesh::Packet* pkt; + if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { + pkt = createSelfAdvert(_prefs.node_name); + } else { + pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); + } + if (pkt) { + sendFlood(pkt); + Serial.println("[Discovery] Self-advert flooded"); + } else { + Serial.println("[Discovery] ERROR: createSelfAdvert returned NULL (packet pool full?)"); + } +} + +void MyMesh::stopDiscovery() { + _discoveryActive = false; +} + +bool MyMesh::addDiscoveredToContacts(int idx) { + if (idx < 0 || idx >= _discoveredCount) return false; + if (_discovered[idx].already_in_contacts) return true; // already there + + // Retrieve cached raw advert packet and import it + uint8_t buf[256]; + int plen = getBlobByKey(_discovered[idx].contact.id.pub_key, PUB_KEY_SIZE, buf); + if (plen > 0) { + bool ok = importContact(buf, (uint8_t)plen); + if (ok) { + _discovered[idx].already_in_contacts = true; + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + MESH_DEBUG_PRINTLN("Discovery: added contact '%s'", _discovered[idx].contact.name); + } + return ok; + } + MESH_DEBUG_PRINTLN("Discovery: no cached advert blob for contact '%s'", _discovered[idx].contact.name); + return false; } \ No newline at end of file diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 9e08601..abceb0d 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -84,6 +84,15 @@ struct AdvertPath { uint8_t path[MAX_PATH_SIZE]; }; +// Discovery scan — transient buffer for on-device node discovery +#define MAX_DISCOVERED_NODES 20 + +struct DiscoveredNode { + ContactInfo contact; + uint8_t path_len; + bool already_in_contacts; // true if contact was auto-added or already known +}; + class MyMesh : public BaseChatMesh, public DataStoreHost { public: MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL); @@ -101,6 +110,14 @@ public: void enterCLIRescue(); int getRecentlyHeard(AdvertPath dest[], int max_num); + + // Discovery scan — on-device node discovery + void startDiscovery(uint32_t duration_ms = 30000); + void stopDiscovery(); + bool isDiscoveryActive() const { return _discoveryActive; } + int getDiscoveredCount() const { return _discoveredCount; } + const DiscoveredNode& getDiscovered(int idx) const { return _discovered[idx]; } + bool addDiscoveredToContacts(int idx); // promote a discovered node into contacts // Queue a sent channel message for BLE app sync void queueSentChannelMessage(uint8_t channel_idx, uint32_t timestamp, const char* sender, const char* text); @@ -257,6 +274,12 @@ private: SentMsgTrack _sent_track[SENT_TRACK_SIZE]; int _sent_track_idx; // next slot in circular buffer int _admin_contact_idx; // contact index for active admin session (-1 if none) + + // Discovery scan state + DiscoveredNode _discovered[MAX_DISCOVERED_NODES]; + int _discoveredCount; + bool _discoveryActive; + unsigned long _discoveryTimeout; }; extern MyMesh the_mesh; \ No newline at end of file diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 01cd65d..663fce6 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -18,6 +18,7 @@ #include "ChannelScreen.h" #include "SettingsScreen.h" #include "RepeaterAdminScreen.h" + #include "DiscoveryScreen.h" #ifdef MECK_WEB_READER #include "WebReaderScreen.h" #endif @@ -1848,8 +1849,9 @@ void handleKeyboardInput() { break; case 's': - // Open settings (from home), or navigate down on channel/contacts/admin/web/map + // Open settings (from home), or navigate down on channel/contacts/admin/web/map/discovery if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin() + || ui_task.isOnDiscoveryScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -1865,6 +1867,7 @@ void handleKeyboardInput() { case 'w': // Navigate up/previous (scroll on channel screen) if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin() + || ui_task.isOnDiscoveryScreen() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -1972,6 +1975,23 @@ void handleKeyboardInput() { } drawComposeScreen(); lastComposeRefresh = millis(); + } else if (ui_task.isOnDiscoveryScreen()) { + // Discovery screen: Enter adds selected node to contacts + DiscoveryScreen* ds = (DiscoveryScreen*)ui_task.getDiscoveryScreen(); + int didx = ds->getSelectedIdx(); + if (didx >= 0 && didx < the_mesh.getDiscoveredCount()) { + const DiscoveredNode& node = the_mesh.getDiscovered(didx); + if (node.already_in_contacts) { + ui_task.showAlert("Already in contacts", 800); + } else if (the_mesh.addDiscoveredToContacts(didx)) { + char alertBuf[48]; + snprintf(alertBuf, sizeof(alertBuf), "Added: %s", node.contact.name); + ui_task.showAlert(alertBuf, 1500); + ui_task.notify(UIEventType::ack); + } else { + ui_task.showAlert("Add failed", 1000); + } + } } else { // Other screens: pass Enter as generic select ui_task.injectKey(13); @@ -2025,6 +2045,17 @@ void handleKeyboardInput() { } break; + case 'f': + // Start discovery scan from contacts screen, or rescan on discovery screen + if (ui_task.isOnContactsScreen()) { + Serial.println("Contacts: Starting discovery scan..."); + the_mesh.startDiscovery(); + ui_task.gotoDiscoveryScreen(); + } else if (ui_task.isOnDiscoveryScreen()) { + ui_task.injectKey('f'); // pass through for rescan + } + break; + case 'q': case '\b': // If channel screen reply select or path overlay is showing, dismiss it @@ -2050,6 +2081,13 @@ void handleKeyboardInput() { } } #endif + // Discovery screen: Q goes back to contacts (not home) + if (ui_task.isOnDiscoveryScreen()) { + the_mesh.stopDiscovery(); + Serial.println("Nav: Discovery -> Contacts"); + ui_task.gotoContactsScreen(); + break; + } // Go back to home screen (admin mode handled above) Serial.println("Nav: Back to home"); ui_task.gotoHomeScreen(); diff --git a/examples/companion_radio/ui-new/Contactsscreen.h b/examples/companion_radio/ui-new/Contactsscreen.h index 1cc23f2..1107fd9 100644 --- a/examples/companion_radio/ui-new/Contactsscreen.h +++ b/examples/companion_radio/ui-new/Contactsscreen.h @@ -297,17 +297,17 @@ public: display.drawRect(0, footerY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); - // Left: Q:Back + // Left: Q:Bk display.setCursor(0, footerY); - display.print("Q:Back"); + display.print("Q:Bk"); // Center: A/D:Filter const char* mid = "A/D:Filtr"; display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY); display.print(mid); - // Right: W/S:Scroll - const char* right = "W/S:Scrll"; + // Right: F:Dscvr + const char* right = "F:Dscvr"; display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); display.print(right); diff --git a/examples/companion_radio/ui-new/Discoveryscreen.h b/examples/companion_radio/ui-new/Discoveryscreen.h new file mode 100644 index 0000000..b45766f --- /dev/null +++ b/examples/companion_radio/ui-new/Discoveryscreen.h @@ -0,0 +1,194 @@ +#pragma once + +#include +#include +#include +#include + +// Forward declarations +class UITask; +class MyMesh; +extern MyMesh the_mesh; + +class DiscoveryScreen : public UIScreen { + UITask* _task; + mesh::RTCClock* _rtc; + int _scrollPos; + int _rowsPerPage; + + static char typeChar(uint8_t adv_type) { + switch (adv_type) { + case ADV_TYPE_CHAT: return 'C'; + case ADV_TYPE_REPEATER: return 'R'; + case ADV_TYPE_ROOM: return 'S'; + case ADV_TYPE_SENSOR: return 'N'; + default: return '?'; + } + } + + static const char* typeLabel(uint8_t adv_type) { + switch (adv_type) { + case ADV_TYPE_CHAT: return "Chat"; + case ADV_TYPE_REPEATER: return "Rptr"; + case ADV_TYPE_ROOM: return "Room"; + case ADV_TYPE_SENSOR: return "Sens"; + default: return "?"; + } + } + +public: + DiscoveryScreen(UITask* task, mesh::RTCClock* rtc) + : _task(task), _rtc(rtc), _scrollPos(0), _rowsPerPage(5) {} + + void resetScroll() { _scrollPos = 0; } + + int getSelectedIdx() const { return _scrollPos; } + + int render(DisplayDriver& display) override { + int count = the_mesh.getDiscoveredCount(); + bool active = the_mesh.isDiscoveryActive(); + + // === Header === + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + + char hdr[32]; + if (active) { + snprintf(hdr, sizeof(hdr), "Scanning... %d found", count); + } else { + snprintf(hdr, sizeof(hdr), "Scan done: %d found", count); + } + display.print(hdr); + + // Divider + display.drawRect(0, 11, display.width(), 1); + + // === Body — discovered node rows === + display.setTextSize(0); // tiny font for compact rows + int lineHeight = 9; + int headerHeight = 14; + int footerHeight = 14; + int maxY = display.height() - footerHeight; + int y = headerHeight; + int rowsDrawn = 0; + + if (count == 0) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(4, 28); + display.print(active ? "Listening for adverts..." : "No nodes found"); + if (!active) { + display.setCursor(4, 38); + display.print("F: Scan again Q: Back"); + } + } else { + // Center visible window around selected item + int maxVisible = (maxY - headerHeight) / lineHeight; + if (maxVisible < 3) maxVisible = 3; + int startIdx = max(0, min(_scrollPos - maxVisible / 2, + count - maxVisible)); + int endIdx = min(count, startIdx + maxVisible); + + for (int i = startIdx; i < endIdx && y + lineHeight <= maxY; i++) { + const DiscoveredNode& node = the_mesh.getDiscovered(i); + bool selected = (i == _scrollPos); + + // Highlight selected row + if (selected) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y + 5, display.width(), lineHeight); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + display.setCursor(0, y); + + // Prefix: cursor + type + char prefix[4]; + if (selected) { + snprintf(prefix, sizeof(prefix), ">%c", typeChar(node.contact.type)); + } else { + snprintf(prefix, sizeof(prefix), " %c", typeChar(node.contact.type)); + } + display.print(prefix); + + // Build right-side info: hop count + status + char rightStr[12]; + if (node.already_in_contacts) { + snprintf(rightStr, sizeof(rightStr), "%dh [+]", node.path_len); + } else { + snprintf(rightStr, sizeof(rightStr), "%dh", node.path_len); + } + int rightWidth = display.getTextWidth(rightStr) + 2; + + // Name (truncated with ellipsis) + char filteredName[32]; + display.translateUTF8ToBlocks(filteredName, node.contact.name, sizeof(filteredName)); + int nameX = display.getTextWidth(prefix) + 2; + int nameMaxW = display.width() - nameX - rightWidth - 2; + display.drawTextEllipsized(nameX, y, nameMaxW, filteredName); + + // Right-aligned info + display.setCursor(display.width() - rightWidth, y); + display.print(rightStr); + + y += lineHeight; + rowsDrawn++; + } + _rowsPerPage = (rowsDrawn > 0) ? rowsDrawn : 1; + } + + display.setTextSize(1); // restore for footer + + // === Footer === + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setColor(DisplayDriver::YELLOW); + + display.setCursor(0, footerY); + display.print("Q:Back"); + + const char* mid = "Ent:Add"; + display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY); + display.print(mid); + + const char* right = "F:Rescan"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); + + // Faster refresh while actively scanning + return active ? 1000 : 5000; + } + + bool handleInput(char c) override { + int count = the_mesh.getDiscoveredCount(); + + // W - scroll up + if (c == 'w' || c == 'W' || c == 0xF2) { + if (_scrollPos > 0) { + _scrollPos--; + return true; + } + } + + // S - scroll down + if (c == 's' || c == 'S' || c == 0xF1) { + if (_scrollPos < count - 1) { + _scrollPos++; + return true; + } + } + + // F - rescan (handled here as well as in main.cpp for consistency) + if (c == 'f') { + the_mesh.startDiscovery(); + _scrollPos = 0; + return true; + } + + // Enter - handled by main.cpp for alert feedback + + return false; // Q/back and Enter handled by main.cpp + } +}; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5fcd6f3..38fa27c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -3,6 +3,7 @@ #include "../MyMesh.h" #include "NotesScreen.h" #include "RepeaterAdminScreen.h" +#include "DiscoveryScreen.h" #include "MapScreen.h" #include "target.h" #if defined(WIFI_SSID) || defined(MECK_WIFI_COMPANION) @@ -946,6 +947,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no notes_screen = new NotesScreen(this); settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs); repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio + discovery_screen = new DiscoveryScreen(this, &rtc_clock); audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present #ifdef HAS_4G_MODEM sms_screen = new SMSScreen(this); @@ -1606,6 +1608,16 @@ void UITask::gotoRepeaterAdmin(int contactIdx) { _next_refresh = 100; } +void UITask::gotoDiscoveryScreen() { + ((DiscoveryScreen*)discovery_screen)->resetScroll(); + setCurrScreen(discovery_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + #ifdef MECK_WEB_READER void UITask::gotoWebReader() { // Lazy-initialize on first use (same pattern as audiobook player) diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index d97e673..f80c7df 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -79,6 +79,7 @@ class UITask : public AbstractUITask { UIScreen* sms_screen; // SMS messaging screen (4G variant only) #endif UIScreen* repeater_admin; // Repeater admin screen + UIScreen* discovery_screen; // Node discovery scan screen #ifdef MECK_WEB_READER UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required) #endif @@ -119,6 +120,7 @@ public: void gotoOnboarding(); // Navigate to settings in onboarding mode void gotoAudiobookPlayer(); // Navigate to audiobook player void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin + void gotoDiscoveryScreen(); // Navigate to node discovery scan void gotoMapScreen(); // Navigate to map tile screen #ifdef MECK_WEB_READER void gotoWebReader(); // Navigate to web reader (browser) @@ -147,6 +149,7 @@ public: bool isOnSettingsScreen() const { return curr == settings_screen; } bool isOnAudiobookPlayer() const { return curr == audiobook_screen; } bool isOnRepeaterAdmin() const { return curr == repeater_admin; } + bool isOnDiscoveryScreen() const { return curr == discovery_screen; } bool isOnMapScreen() const { return curr == map_screen; } #ifdef MECK_WEB_READER bool isOnWebReader() const { return curr == web_reader; } @@ -191,6 +194,7 @@ public: UIScreen* getAudiobookScreen() const { return audiobook_screen; } void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; } UIScreen* getRepeaterAdminScreen() const { return repeater_admin; } + UIScreen* getDiscoveryScreen() const { return discovery_screen; } UIScreen* getMapScreen() const { return map_screen; } #ifdef MECK_WEB_READER UIScreen* getWebReaderScreen() const { return web_reader; }