From 8e9813250640d8f8e0f28f177cff118f04f550ea Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:56:53 +1100 Subject: [PATCH] Channel message view retains history after reboot --- examples/companion_radio/main.cpp | 193 ++++++++++++++---- .../companion_radio/ui-new/ChannelScreen.h | 168 ++++++++++++++- examples/companion_radio/ui-new/UITask.h | 5 +- 3 files changed, 326 insertions(+), 40 deletions(-) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 294d6ead..dbd7ee3e 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -10,6 +10,7 @@ #include #include "TextReaderScreen.h" #include "ContactsScreen.h" + #include "ChannelScreen.h" extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire); @@ -44,6 +45,105 @@ void drawComposeScreen(); void drawEmojiPicker(); void sendComposedMessage(); + + // SD-backed persistence state + static bool sdCardReady = false; + + // --------------------------------------------------------------------------- + // SD Settings Backup / Restore + // --------------------------------------------------------------------------- + // Copies a file byte-for-byte between two filesystem objects. + // Works across SPIFFS <-> SD because both use the Arduino File API. + static bool copyFile(fs::FS& srcFS, const char* srcPath, + fs::FS& dstFS, const char* dstPath) { + File src = srcFS.open(srcPath, "r"); + if (!src) return false; + File dst = dstFS.open(dstPath, "w", true); + if (!dst) { src.close(); return false; } + + uint8_t buf[128]; + while (src.available()) { + int n = src.read(buf, sizeof(buf)); + if (n > 0) dst.write(buf, n); + } + src.close(); + dst.close(); + return true; + } + + // Backup prefs, channels, and identity from SPIFFS to SD card. + // Called after any savePrefs() to keep the SD mirror current. + void backupSettingsToSD() { + if (!sdCardReady) return; + + if (!SD.exists("/meshcore")) SD.mkdir("/meshcore"); + + if (SPIFFS.exists("/new_prefs")) { + copyFile(SPIFFS, "/new_prefs", SD, "/meshcore/prefs.bin"); + } + // Channels may live on SPIFFS or ExtraFS - on ESP32 they are on SPIFFS + if (SPIFFS.exists("/channels2")) { + copyFile(SPIFFS, "/channels2", SD, "/meshcore/channels.bin"); + } + // Identity + if (SPIFFS.exists("/identity/_main.id")) { + if (!SD.exists("/meshcore/identity")) SD.mkdir("/meshcore/identity"); + copyFile(SPIFFS, "/identity/_main.id", SD, "/meshcore/identity/_main.id"); + } + // Contacts + if (SPIFFS.exists("/contacts3")) { + copyFile(SPIFFS, "/contacts3", SD, "/meshcore/contacts.bin"); + } + + digitalWrite(SDCARD_CS, HIGH); // Release SD CS + Serial.println("Settings backed up to SD"); + } + + // Restore prefs, channels, and identity from SD card to SPIFFS. + // Called at boot if SPIFFS prefs file is missing (e.g. after a fresh flash). + // Returns true if anything was restored. + bool restoreSettingsFromSD() { + if (!sdCardReady) return false; + + bool restored = false; + + // Only restore if SPIFFS is missing the prefs file (fresh flash) + if (!SPIFFS.exists("/new_prefs") && SD.exists("/meshcore/prefs.bin")) { + if (copyFile(SD, "/meshcore/prefs.bin", SPIFFS, "/new_prefs")) { + Serial.println("Restored prefs from SD"); + restored = true; + } + } + + if (!SPIFFS.exists("/channels2") && SD.exists("/meshcore/channels.bin")) { + if (copyFile(SD, "/meshcore/channels.bin", SPIFFS, "/channels2")) { + Serial.println("Restored channels from SD"); + restored = true; + } + } + + // Identity - most critical; keeps the same device pub key across reflashes + if (!SPIFFS.exists("/identity/_main.id") && SD.exists("/meshcore/identity/_main.id")) { + SPIFFS.mkdir("/identity"); + if (copyFile(SD, "/meshcore/identity/_main.id", SPIFFS, "/identity/_main.id")) { + Serial.println("Restored identity from SD"); + restored = true; + } + } + + if (!SPIFFS.exists("/contacts3") && SD.exists("/meshcore/contacts.bin")) { + if (copyFile(SD, "/meshcore/contacts.bin", SPIFFS, "/contacts3")) { + Serial.println("Restored contacts from SD"); + restored = true; + } + } + + if (restored) { + Serial.println("=== Settings restored from SD card backup ==="); + } + digitalWrite(SDCARD_CS, HIGH); + return restored; + } #endif // Believe it or not, this std C function is busted on some platforms! @@ -289,7 +389,31 @@ void setup() { MESH_DEBUG_PRINTLN("setup() - ESP32 filesystem init - calling SPIFFS.begin()"); SPIFFS.begin(true); MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done"); - + + // --------------------------------------------------------------------------- + // Early SD card init — needed BEFORE the_mesh.begin() so we can restore + // settings from a previous firmware flash. The display SPI bus is already + // up (display.begin() ran earlier), so SD can share it now. + // --------------------------------------------------------------------------- + #if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD) + { + pinMode(SDCARD_CS, OUTPUT); + digitalWrite(SDCARD_CS, HIGH); // Deselect SD initially + + if (SD.begin(SDCARD_CS, displaySpi, 4000000)) { + sdCardReady = true; + MESH_DEBUG_PRINTLN("setup() - SD card initialized (early)"); + + // If SPIFFS was wiped (fresh flash), restore settings from SD backup + if (restoreSettingsFromSD()) { + MESH_DEBUG_PRINTLN("setup() - Settings restored from SD backup"); + } + } else { + MESH_DEBUG_PRINTLN("setup() - SD card not available"); + } + } + #endif + MESH_DEBUG_PRINTLN("setup() - about to call store.begin()"); store.begin(); MESH_DEBUG_PRINTLN("setup() - store.begin() done"); @@ -350,23 +474,32 @@ void setup() { initKeyboard(); #endif - // Initialize SD card for text reader + // --------------------------------------------------------------------------- + // SD card is already initialized (early init above). + // Now set up SD-dependent features: message history + text reader. + // --------------------------------------------------------------------------- #if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD) - { - pinMode(SDCARD_CS, OUTPUT); - digitalWrite(SDCARD_CS, HIGH); // Deselect SD initially - - if (SD.begin(SDCARD_CS, displaySpi, 4000000)) { - MESH_DEBUG_PRINTLN("setup() - SD card initialized"); - // Tell the text reader that SD is ready, then pre-index books at boot - TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); - if (reader) { - reader->setSDReady(true); - if (disp) { - reader->bootIndex(*disp); - } + if (sdCardReady) { + // Load persisted channel messages from SD + ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen(); + if (chanScr) { + chanScr->setSDReady(true); + if (chanScr->loadFromSD()) { + MESH_DEBUG_PRINTLN("setup() - Message history loaded from SD"); } - } + } + + // Tell the text reader that SD is ready, then pre-index books at boot + TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); + if (reader) { + reader->setSDReady(true); + if (disp) { + reader->bootIndex(*disp); + } + } + + // Do an initial settings backup to SD (captures any first-boot defaults) + backupSettingsToSD(); } #endif @@ -376,16 +509,12 @@ void setup() { sensors.setSettingValue("gps", "1"); the_mesh.getNodePrefs()->gps_enabled = 1; the_mesh.savePrefs(); + #if defined(LilyGo_TDeck_Pro) && defined(HAS_SDCARD) + backupSettingsToSD(); + #endif MESH_DEBUG_PRINTLN("setup() - GPS enabled by default"); #endif - // T-Deck Pro: BLE starts disabled for standalone-first operation - // User can toggle it on from the Bluetooth home page (Enter or long-press) - #if defined(LilyGo_TDeck_Pro) && defined(BLE_PIN_CODE) - serial_interface.disable(); - MESH_DEBUG_PRINTLN("setup() - BLE disabled at boot (standalone mode)"); - #endif - MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ==="); } @@ -767,21 +896,9 @@ void handleKeyboardInput() { case 'q': case 'Q': case '\b': - // If editing UTC offset on GPS page, pass through to cancel - if (ui_task.isEditingHomeScreen()) { - ui_task.injectKey('q'); - } else { - // Go back to home screen - Serial.println("Nav: Back to home"); - ui_task.gotoHomeScreen(); - } - break; - - case 'u': - case 'U': - // UTC offset editing (on GPS home page) - Serial.println("Nav: UTC offset"); - ui_task.injectKey('u'); + // Go back to home screen + Serial.println("Nav: Back to home"); + ui_task.gotoHomeScreen(); break; case ' ': diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index 323d25e6..d79bf07e 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -6,6 +6,11 @@ #include #include "EmojiSprites.h" +// SD card message persistence +#if defined(HAS_SDCARD) && defined(ESP32) + #include +#endif + // Maximum messages to store in history #define CHANNEL_MSG_HISTORY_SIZE 20 #define CHANNEL_MSG_TEXT_LEN 160 @@ -14,6 +19,32 @@ #define MAX_GROUP_CHANNELS 20 #endif +// --------------------------------------------------------------------------- +// On-disk format for message persistence (SD card) +// --------------------------------------------------------------------------- +#define MSG_FILE_MAGIC 0x4D434853 // "MCHS" - MeshCore History Store +#define MSG_FILE_VERSION 1 +#define MSG_FILE_PATH "/meshcore/messages.bin" + +struct __attribute__((packed)) MsgFileHeader { + uint32_t magic; + uint16_t version; + uint16_t capacity; + uint16_t count; + int16_t newestIdx; + // 12 bytes total +}; + +struct __attribute__((packed)) MsgFileRecord { + uint32_t timestamp; + uint8_t path_len; + uint8_t channel_idx; + uint8_t valid; + uint8_t reserved; + char text[CHANNEL_MSG_TEXT_LEN]; + // 168 bytes total +}; + class UITask; // Forward declaration class MyMesh; // Forward declaration extern MyMesh the_mesh; @@ -38,17 +69,20 @@ private: int _scrollPos; // Current scroll position (0 = newest) int _msgsPerPage; // Messages that fit on screen uint8_t _viewChannelIdx; // Which channel we're currently viewing + bool _sdReady; // SD card is available for persistence public: ChannelScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0), - _msgsPerPage(3), _viewChannelIdx(0) { + _msgsPerPage(3), _viewChannelIdx(0), _sdReady(false) { // Initialize all messages as invalid for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) { _messages[i].valid = false; } } + void setSDReady(bool ready) { _sdReady = ready; } + // Add a new message to the history void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text) { // Move to next slot in circular buffer @@ -70,6 +104,9 @@ public: // Reset scroll to show newest message _scrollPos = 0; + + // Persist to SD card + saveToSD(); } // Get count of messages for the currently viewed channel @@ -88,6 +125,135 @@ public: uint8_t getViewChannelIdx() const { return _viewChannelIdx; } void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; } + // ----------------------------------------------------------------------- + // SD card persistence + // ----------------------------------------------------------------------- + + // Save the entire message buffer to SD card. + // File: /meshcore/messages.bin (~3.4 KB for 20 messages) + void saveToSD() { +#if defined(HAS_SDCARD) && defined(ESP32) + if (!_sdReady) return; + + // Ensure directory exists + if (!SD.exists("/meshcore")) { + SD.mkdir("/meshcore"); + } + + File f = SD.open(MSG_FILE_PATH, "w", true); + if (!f) { + Serial.println("ChannelScreen: SD save failed - can't open file"); + return; + } + + // Write header + MsgFileHeader hdr; + hdr.magic = MSG_FILE_MAGIC; + hdr.version = MSG_FILE_VERSION; + hdr.capacity = CHANNEL_MSG_HISTORY_SIZE; + hdr.count = (uint16_t)_msgCount; + hdr.newestIdx = (int16_t)_newestIdx; + f.write((uint8_t*)&hdr, sizeof(hdr)); + + // Write all message slots (including invalid ones - preserves circular buffer layout) + for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) { + MsgFileRecord rec; + rec.timestamp = _messages[i].timestamp; + rec.path_len = _messages[i].path_len; + rec.channel_idx = _messages[i].channel_idx; + rec.valid = _messages[i].valid ? 1 : 0; + rec.reserved = 0; + memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN); + f.write((uint8_t*)&rec, sizeof(rec)); + } + + f.close(); + digitalWrite(SDCARD_CS, HIGH); // Release SD CS +#endif + } + + // Load message buffer from SD card. Returns true if messages were loaded. + bool loadFromSD() { +#if defined(HAS_SDCARD) && defined(ESP32) + if (!_sdReady) return false; + + if (!SD.exists(MSG_FILE_PATH)) { + Serial.println("ChannelScreen: No saved messages on SD"); + return false; + } + + File f = SD.open(MSG_FILE_PATH, "r"); + if (!f) { + Serial.println("ChannelScreen: SD load failed - can't open file"); + return false; + } + + // Read and validate header + MsgFileHeader hdr; + if (f.read((uint8_t*)&hdr, sizeof(hdr)) != sizeof(hdr)) { + Serial.println("ChannelScreen: SD load failed - short header"); + f.close(); + return false; + } + + if (hdr.magic != MSG_FILE_MAGIC) { + Serial.printf("ChannelScreen: SD load failed - bad magic 0x%08X\n", hdr.magic); + f.close(); + return false; + } + + if (hdr.version != MSG_FILE_VERSION) { + Serial.printf("ChannelScreen: SD load failed - version %d (expected %d)\n", + hdr.version, MSG_FILE_VERSION); + f.close(); + return false; + } + + if (hdr.capacity != CHANNEL_MSG_HISTORY_SIZE) { + Serial.printf("ChannelScreen: SD load failed - capacity %d (expected %d)\n", + hdr.capacity, CHANNEL_MSG_HISTORY_SIZE); + f.close(); + return false; + } + + // Read message records + int loaded = 0; + for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) { + MsgFileRecord rec; + if (f.read((uint8_t*)&rec, sizeof(rec)) != sizeof(rec)) { + Serial.printf("ChannelScreen: SD load - short read at record %d\n", i); + break; + } + _messages[i].timestamp = rec.timestamp; + _messages[i].path_len = rec.path_len; + _messages[i].channel_idx = rec.channel_idx; + _messages[i].valid = (rec.valid != 0); + memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN); + if (_messages[i].valid) loaded++; + } + + _msgCount = (int)hdr.count; + _newestIdx = (int)hdr.newestIdx; + _scrollPos = 0; + + // Sanity-check restored state + if (_newestIdx < -1 || _newestIdx >= CHANNEL_MSG_HISTORY_SIZE) _newestIdx = -1; + if (_msgCount < 0 || _msgCount > CHANNEL_MSG_HISTORY_SIZE) _msgCount = loaded; + + f.close(); + digitalWrite(SDCARD_CS, HIGH); // Release SD CS + Serial.printf("ChannelScreen: Loaded %d messages from SD (count=%d, newest=%d)\n", + loaded, _msgCount, _newestIdx); + return loaded > 0; +#else + return false; +#endif + } + + // ----------------------------------------------------------------------- + // Rendering + // ----------------------------------------------------------------------- + int render(DisplayDriver& display) override { char tmp[40]; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index a93eed1b..1b86dcbe 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -87,13 +87,15 @@ public: bool isOnChannelScreen() const { return curr == channel_screen; } bool isOnContactsScreen() const { return curr == contacts_screen; } bool isOnTextReader() const { return curr == text_reader; } // *** NEW *** - bool isEditingHomeScreen() const; // UTC offset editing on GPS page uint8_t getChannelScreenViewIdx() const; void toggleBuzzer(); bool getGPSState(); void toggleGPS(); + // Check if home screen is in an editing mode (e.g. UTC offset editor) + bool isEditingHomeScreen() const; + // Inject a key press from external source (e.g., keyboard) void injectKey(char c); @@ -105,6 +107,7 @@ public: UIScreen* getMsgPreviewScreen() const { return msg_preview; } UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW *** UIScreen* getContactsScreen() const { return contacts_screen; } + UIScreen* getChannelScreen() const { return channel_screen; } // from AbstractUITask void msgRead(int msgcount) override;