From 2cf79451362cbb8391e12f88d9a8f35796562455 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:02:16 +1000 Subject: [PATCH] T-Watch - update settingscreen touch so rxlog displays on watch with auto-scroll updates. Added persistent channel message history with swipe to scroll up and down. --- examples/companion_radio/MyMesh.h | 2 +- examples/companion_radio/main.cpp | 28 +++++ .../companion_radio/ui-new/ChannelScreen.h | 111 ++++++++++++++++++ examples/companion_radio/ui-new/RxLogScreen.h | 14 +++ .../companion_radio/ui-new/SettingsScreen.h | 6 +- 5 files changed, 159 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 6606379a..dbb97052 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,7 +8,7 @@ #define FIRMWARE_VER_CODE 11 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "15 July 2026" +#define FIRMWARE_BUILD_DATE "17 July 2026" #endif #ifndef FIRMWARE_VERSION diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 20e304c3..e09033ee 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2164,6 +2164,11 @@ static void lastHeardToggleContact() { if (ss->isEditing()) { return 0; // Consume — don't interfere with active edit mode } +#if defined(MECK_TWATCH) + // Aim activation at the row under the finger, not just the current + // cursor, so a single long-press opens/edits the pressed row. + { int vx, vy; touchToVirtual(x, y, vx, vy); ss->selectRowAtVY(vy); } +#endif if (ss->isOnDeletableChannel()) { return 'x'; // Long press on channel row → delete } @@ -2853,6 +2858,15 @@ void setup() { Serial.println("setup() - SD features initialized"); } #endif +#if defined(MECK_TWATCH) + // Watch: load persisted channel-message history from LittleFS (no SD). + { + ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen(); + if (chanScr && chanScr->loadFromLittleFS()) { + MESH_DEBUG_PRINTLN("setup() - Message history loaded from LittleFS"); + } + } +#endif // Check if node name is still the default hex prefix (first 4 bytes of pub key) // If so, launch onboarding wizard to set name and radio preset // --------------------------------------------------------------------------- @@ -2974,6 +2988,14 @@ void otaResumeRadio() { #endif void loop() { +#if defined(MECK_TWATCH) + // Watch: flush pending channel-message history to LittleFS on a 30s debounce + // (saveToSD marks it dirty on each message; at most one ~57 KB write / 30s). + { + ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen(); + if (chanScr) chanScr->flushMessagesIfDue(30000UL); + } +#endif // T-Echo Card: lazy Codec2 init from shallow stack context. // codec2_create needs ~3KB stack for FFT/trig init. The loop task // has only 4KB total. Calling from render() (deep call chain) overflows. @@ -3963,6 +3985,12 @@ void loop() { // Channel screen: horizontal swipe opens picker instead of cycling if (ui_task.isOnChannelScreen() && (c == 'a' || c == 'd')) { ui_task.gotoChannelPickerScreen(); +#if defined(MECK_TWATCH) + } else if (ui_task.isOnChannelScreen() && (c == 'w' || c == 's')) { + // Watch: a vertical swipe pages the message view (one page/swipe). + ChannelScreen* cs = (ChannelScreen*)ui_task.getChannelScreen(); + if (cs) { cs->pageScroll(c == 'w'); ui_task.forceRefresh(); } +#endif } else { ui_task.injectKey(c); } diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index aef5c04c..53da4523 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -11,6 +11,9 @@ #if defined(HAS_SDCARD) && defined(ESP32) #include #endif +#if defined(MECK_TWATCH) + #include // watch has no SD -- message history persists to LittleFS +#endif // DM send status codes (session only). Guarded so AbstractUITask.h can carry // the same definitions without a clash. @@ -103,6 +106,9 @@ private: int _msgsPerPage; // Messages that fit on screen uint8_t _viewChannelIdx; // Which channel we're currently viewing bool _sdReady; // SD card is available for persistence +#if defined(MECK_TWATCH) + unsigned long _msgDirtySince = 0; // watch: pending LittleFS write (0 = clean) +#endif bool _showPathOverlay; // Show path detail overlay for last received msg int _pathScrollPos; // Scroll offset within path overlay hop list int _pathHopsVisible; // Hops that fit on screen (set during render) @@ -554,6 +560,13 @@ public: // Save the entire message buffer to SD card. // File: /meshcore/messages.bin (~50 KB for 300 messages) void saveToSD() { +#if defined(MECK_TWATCH) + // Watch: defer to the LittleFS flush cadence -- a ~57 KB write on every + // message would wear the flash. Timestamp the pending write; the main loop + // flushes it (see flushMessagesIfDue), and shutdown flushes directly. + if (_msgDirtySince == 0) _msgDirtySince = millis(); + return; +#endif #if defined(HAS_SDCARD) && defined(ESP32) if (!_sdReady) return; @@ -596,6 +609,94 @@ public: #endif } +#if defined(MECK_TWATCH) + // --- Watch message-history persistence (LittleFS; the watch has no SD) ------ + // Flush the ring to LittleFS. Called on an interval from the main loop and + // directly on shutdown, so traffic produces at most one write per interval. + void flushMessagesNow() { + if (!LittleFS.exists("/meshcore")) { + LittleFS.mkdir("/meshcore"); + } + File f = LittleFS.open(MSG_FILE_PATH, "w", true); + if (!f) { + Serial.println("ChannelScreen: LittleFS save failed - can't open file"); + return; + } + 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)); + 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.snr = _messages[i].snr; + rec.dm_peer_hash = _messages[i].dm_peer_hash; + memcpy(rec.path, _messages[i].path, MSG_PATH_MAX); + memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN); + f.write((uint8_t*)&rec, sizeof(rec)); + } + f.close(); + _msgDirtySince = 0; + } + + // Flush only if a write is pending and the debounce interval has elapsed. + void flushMessagesIfDue(unsigned long intervalMs) { + if (_msgDirtySince && millis() - _msgDirtySince >= intervalMs) flushMessagesNow(); + } + + // Load the ring from LittleFS at boot. Returns true if any messages loaded. + bool loadFromLittleFS() { + if (!LittleFS.exists(MSG_FILE_PATH)) { + Serial.println("ChannelScreen: no saved messages on LittleFS"); + return false; + } + File f = LittleFS.open(MSG_FILE_PATH, "r"); + if (!f) { + Serial.println("ChannelScreen: LittleFS load failed - can't open file"); + return false; + } + MsgFileHeader hdr; + if (f.read((uint8_t*)&hdr, sizeof(hdr)) != sizeof(hdr)) { f.close(); return false; } + if (hdr.magic != MSG_FILE_MAGIC) { f.close(); return false; } + if (hdr.version != MSG_FILE_VERSION) { f.close(); return false; } + if (hdr.capacity != CHANNEL_MSG_HISTORY_SIZE) { f.close(); return false; } + 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)) 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); + _messages[i].snr = rec.snr; + _messages[i].dm_peer_hash = rec.dm_peer_hash; + memcpy(_messages[i].path, rec.path, MSG_PATH_MAX); + memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN); + _messages[i].scope_idx = 0xFF; + _messages[i].send_ref = 0; + _messages[i].dm_status = DM_SEND_NONE; + _messages[i].dm_attempt = 0; + _messages[i].dm_total = 0; + if (_messages[i].valid) loaded++; + } + _msgCount = (int)hdr.count; + _newestIdx = (int)hdr.newestIdx; + _scrollPos = 0; + if (_newestIdx < -1 || _newestIdx >= CHANNEL_MSG_HISTORY_SIZE) _newestIdx = -1; + if (_msgCount < 0 || _msgCount > CHANNEL_MSG_HISTORY_SIZE) _msgCount = loaded; + f.close(); + Serial.printf("ChannelScreen: Loaded %d messages from LittleFS (count=%d, newest=%d)\n", + loaded, _msgCount, _newestIdx); + return loaded > 0; + } +#endif + // Load message buffer from SD card. Returns true if messages were loaded. bool loadFromSD() { #if defined(HAS_SDCARD) && defined(ESP32) @@ -1685,6 +1786,16 @@ public: #endif } + // Vertical-swipe scroll (watch). Pages the normal message view a screenful at + // a time; keeps single-step in reply-select / path-overlay / DM-inbox so + // precise selection isn't lost. Bounds clamping is handled by handleInput. + void pageScroll(bool older) { + char k = older ? 'w' : 's'; + bool normalView = !_replySelectMode && !_showPathOverlay && !_dmInboxMode; + int steps = (normalView && _msgsPerPage > 1) ? _msgsPerPage : 1; + for (int i = 0; i < steps; i++) handleInput(k); + } + bool handleInput(char c) override { // If overlay is showing, handle scroll and dismiss if (_showPathOverlay) { diff --git a/examples/companion_radio/ui-new/RxLogScreen.h b/examples/companion_radio/ui-new/RxLogScreen.h index c8e584f3..c581f40f 100644 --- a/examples/companion_radio/ui-new/RxLogScreen.h +++ b/examples/companion_radio/ui-new/RxLogScreen.h @@ -140,6 +140,10 @@ public: int render(DisplayDriver& display) override { int count = the_mesh.getRxLogCount(); +#if defined(MECK_TWATCH) + // Watch: no keyboard or touch scroll, so cycle through entries automatically. + if (count > 1) { _scrollPos++; if (_scrollPos >= count) _scrollPos = 0; } +#endif if (_scrollPos < 0) _scrollPos = 0; if (_scrollPos > count - 1) _scrollPos = (count > 0) ? count - 1 : 0; @@ -153,7 +157,11 @@ public: display.drawRect(0, 11, display.width(), 1); int headerHeight = 14; +#if defined(MECK_TWATCH) + int footerHeight = 2; // no footer on the watch +#else int footerHeight = 14; +#endif int maxY = display.height() - footerHeight; int y = headerHeight; @@ -180,6 +188,7 @@ public: display.setTextSize(1); // === Footer === +#if !defined(MECK_TWATCH) int footerY = display.height() - 12; display.drawRect(0, footerY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); @@ -189,8 +198,13 @@ public: #else display.print("Sh+Del:Bk W/S:Scroll"); #endif +#endif +#if defined(MECK_TWATCH) + return 3000; // watch: advance to the next packet every 3s +#else return 5000; // refresh every 5s to pick up newly received packets +#endif } bool handleInput(char c) override { diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 092e8b85..07b7fffe 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -3028,7 +3028,11 @@ public: } #endif #if defined(MECK_TWATCH) - if (_tickerActive) return 60; // marquee running -- match the channel screen + // Marquee running, or a wide row was just selected (its width was measured + // this frame): refresh fast so scrolling starts promptly instead of after + // the 1s idle interval. + if (_tickerActive || + (_tickerRow == _cursor && _tickerTextW > display.width() - 10)) return 60; #endif return _editMode != EDIT_NONE ? 700 : 1000; }