diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 9b19d52..8b5e74e 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -40,7 +40,8 @@ public: void enableSerial() { _serial->enable(); } void disableSerial() { _serial->disable(); } virtual void msgRead(int msgcount) = 0; - virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0; + virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, + const uint8_t* path = nullptr) = 0; virtual void notify(UIEventType t = UIEventType::none) = 0; virtual void loop() = 0; virtual void showAlert(const char* text, int duration_millis) {} diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 52c88d0..2f8820a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -439,7 +439,8 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe // we only want to show text messages on display, not cli data bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN; if (should_display && _ui) { - _ui->newMsg(path_len, from.name, text, offline_queue_len); + const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr; + _ui->newMsg(path_len, from.name, text, offline_queue_len, msg_path); if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::contactMessage); //buzz if enabled } #endif @@ -525,11 +526,11 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3 queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text); // Forward CLI response to UI admin screen if admin session is active - #ifdef DISPLAY_CLASS +#ifdef DISPLAY_CLASS if (_admin_contact_idx >= 0 && _ui) { _ui->onAdminCliResponse(from.name, text); } - #endif +#endif } void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, @@ -581,7 +582,8 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe channel_name = channel_details.name; } if (_ui) { - _ui->newMsg(path_len, channel_name, text, offline_queue_len); + const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr; + _ui->newMsg(path_len, channel_name, text, offline_queue_len, msg_path); if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::channelMessage); //buzz if enabled } #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 55e92a9..4d6141c 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,11 +8,11 @@ #define FIRMWARE_VER_CODE 8 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "17 Feb 2026" +#define FIRMWARE_BUILD_DATE "20 Feb 2026" #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v0.9.0A" +#define FIRMWARE_VERSION "Meck v0.9.1A" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 308c487..bcf6a30 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1217,6 +1217,11 @@ void handleKeyboardInput() { Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx); } } else if (ui_task.isOnChannelScreen()) { + // Don't enter compose if path overlay is showing + ChannelScreen* chScr2 = (ChannelScreen*)ui_task.getChannelScreen(); + if (chScr2 && chScr2->isShowingPathOverlay()) { + break; + } composeDM = false; composeDMContactIdx = -1; composeChannelIdx = ui_task.getChannelScreenViewIdx(); @@ -1234,6 +1239,14 @@ void handleKeyboardInput() { case 'q': case '\b': + // If channel screen path overlay is showing, dismiss it instead of going home + if (ui_task.isOnChannelScreen()) { + ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen(); + if (chScr && chScr->isShowingPathOverlay()) { + ui_task.injectKey('q'); + break; + } + } // Go back to home screen (admin mode handled above) Serial.println("Nav: Back to home"); ui_task.gotoHomeScreen(); @@ -1249,6 +1262,13 @@ void handleKeyboardInput() { // UTC offset edit (home screen GPS page handles this) ui_task.injectKey('u'); break; + + case 'v': + // View path overlay (channel screen only) + if (ui_task.isOnChannelScreen()) { + ui_task.injectKey('v'); + } + break; default: Serial.printf("Unhandled key in normal mode: '%c' (0x%02X)\n", key, key); diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index 4ce9630..61c99e8 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -14,6 +14,7 @@ // Maximum messages to store in history #define CHANNEL_MSG_HISTORY_SIZE 300 #define CHANNEL_MSG_TEXT_LEN 160 +#define MSG_PATH_MAX 8 // Max repeater hops stored per message #ifndef MAX_GROUP_CHANNELS #define MAX_GROUP_CHANNELS 20 @@ -23,7 +24,7 @@ // 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_VERSION 2 #define MSG_FILE_PATH "/meshcore/messages.bin" struct __attribute__((packed)) MsgFileHeader { @@ -41,8 +42,9 @@ struct __attribute__((packed)) MsgFileRecord { uint8_t channel_idx; uint8_t valid; uint8_t reserved; + uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes (first byte of pub key) char text[CHANNEL_MSG_TEXT_LEN]; - // 168 bytes total + // 176 bytes total }; class UITask; // Forward declaration @@ -55,6 +57,7 @@ public: uint32_t timestamp; uint8_t path_len; uint8_t channel_idx; // Which channel this message belongs to + uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes char text[CHANNEL_MSG_TEXT_LEN]; bool valid; }; @@ -70,21 +73,24 @@ 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 + bool _showPathOverlay; // Show path detail overlay for last received msg public: ChannelScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc), _msgCount(0), _newestIdx(-1), _scrollPos(0), - _msgsPerPage(6), _viewChannelIdx(0), _sdReady(false) { + _msgsPerPage(6), _viewChannelIdx(0), _sdReady(false), _showPathOverlay(false) { // Initialize all messages as invalid for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) { _messages[i].valid = false; + memset(_messages[i].path, 0, MSG_PATH_MAX); } } 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) { + void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text, + const uint8_t* path_bytes = nullptr) { // Move to next slot in circular buffer _newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE; @@ -94,6 +100,13 @@ public: msg->channel_idx = channel_idx; msg->valid = true; + // Store path hop hashes + memset(msg->path, 0, MSG_PATH_MAX); + if (path_bytes && path_len > 0 && path_len != 0xFF) { + int n = path_len < MSG_PATH_MAX ? path_len : MSG_PATH_MAX; + memcpy(msg->path, path_bytes, n); + } + // Sanitize emoji: replace UTF-8 emoji sequences with single-byte escape codes // The text already contains "Sender: message" format emojiSanitize(text, msg->text, CHANNEL_MSG_TEXT_LEN); @@ -104,6 +117,7 @@ public: // Reset scroll to show newest message _scrollPos = 0; + _showPathOverlay = false; // Dismiss overlay on new message // Persist to SD card saveToSD(); @@ -123,7 +137,23 @@ public: int getMessageCount() const { return _msgCount; } uint8_t getViewChannelIdx() const { return _viewChannelIdx; } - void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; } + void setViewChannelIdx(uint8_t idx) { _viewChannelIdx = idx; _scrollPos = 0; _showPathOverlay = false; } + bool isShowingPathOverlay() const { return _showPathOverlay; } + + // Find the newest RECEIVED message for the current channel + // (path_len != 0 means received, path_len 0 = locally sent) + ChannelMessage* getNewestReceivedMsg() { + for (int i = 0; i < _msgCount; i++) { + int idx = _newestIdx - i; + while (idx < 0) idx += CHANNEL_MSG_HISTORY_SIZE; + idx = idx % CHANNEL_MSG_HISTORY_SIZE; + if (_messages[idx].valid && _messages[idx].channel_idx == _viewChannelIdx + && _messages[idx].path_len != 0) { + return &_messages[idx]; + } + } + return nullptr; + } // ----------------------------------------------------------------------- // SD card persistence @@ -163,6 +193,7 @@ public: rec.channel_idx = _messages[i].channel_idx; rec.valid = _messages[i].valid ? 1 : 0; rec.reserved = 0; + 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)); } @@ -228,6 +259,7 @@ public: _messages[i].path_len = rec.path_len; _messages[i].channel_idx = rec.channel_idx; _messages[i].valid = (rec.valid != 0); + memcpy(_messages[i].path, rec.path, MSG_PATH_MAX); memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN); if (_messages[i].valid) loaded++; } @@ -280,6 +312,120 @@ public: // Divider line display.drawRect(0, 11, display.width(), 1); + // --- Path detail overlay --- + if (_showPathOverlay) { + display.setTextSize(0); + int lineH = 9; + int y = 14; + + ChannelMessage* msg = getNewestReceivedMsg(); + if (!msg) { + display.setCursor(0, y); + display.setColor(DisplayDriver::LIGHT); + display.print("No received messages"); + } else { + // Message preview (first ~30 chars) + display.setCursor(0, y); + display.setColor(DisplayDriver::LIGHT); + char preview[32]; + strncpy(preview, msg->text, 31); + preview[31] = '\0'; + display.print(preview); + y += lineH; + + // Age + uint32_t age = _rtc->getCurrentTime() - msg->timestamp; + display.setCursor(0, y); + display.setColor(DisplayDriver::YELLOW); + if (age < 60) sprintf(tmp, "Age: %ds", age); + else if (age < 3600) sprintf(tmp, "Age: %dm", age / 60); + else if (age < 86400) sprintf(tmp, "Age: %dh", age / 3600); + else sprintf(tmp, "Age: %dd", age / 86400); + display.print(tmp); + y += lineH; + + // Route type + display.setCursor(0, y); + uint8_t plen = msg->path_len; + if (plen == 0xFF) { + display.setColor(DisplayDriver::LIGHT); + display.print("Route: Direct"); + } else if (plen == 0) { + display.setColor(DisplayDriver::LIGHT); + display.print("Route: Local/Sent"); + } else { + display.setColor(DisplayDriver::GREEN); + sprintf(tmp, "Route: %d hop%s", plen, plen == 1 ? "" : "s"); + display.print(tmp); + } + y += lineH + 2; + + // Show each hop resolved against contacts + if (plen > 0 && plen != 0xFF) { + int displayHops = plen < MSG_PATH_MAX ? plen : MSG_PATH_MAX; + int maxY = display.height() - 26; + + for (int h = 0; h < displayHops && y + lineH <= maxY; h++) { + uint8_t hopHash = msg->path[h]; + display.setCursor(0, y); + display.setColor(DisplayDriver::LIGHT); + sprintf(tmp, " %d: ", h + 1); + display.print(tmp); + + // Try to resolve: prefer repeaters, then any contact + bool resolved = false; + int numContacts = the_mesh.getNumContacts(); + ContactInfo contact; + + // First pass: repeaters only + for (uint32_t ci = 0; ci < numContacts && !resolved; ci++) { + if (the_mesh.getContactByIdx(ci, contact)) { + if (contact.id.pub_key[0] == hopHash && contact.type == ADV_TYPE_REPEATER) { + display.setColor(DisplayDriver::GREEN); + display.print(contact.name); + resolved = true; + } + } + } + // Second pass: any contact type + if (!resolved) { + for (uint32_t ci = 0; ci < numContacts; ci++) { + if (the_mesh.getContactByIdx(ci, contact)) { + if (contact.id.pub_key[0] == hopHash) { + display.setColor(DisplayDriver::YELLOW); + display.print(contact.name); + resolved = true; + break; + } + } + } + } + // Fallback: show hex hash + if (!resolved) { + display.setColor(DisplayDriver::LIGHT); + sprintf(tmp, "?%02X", hopHash); + display.print(tmp); + } + y += lineH; + } + } + } + + // Overlay footer + display.setTextSize(1); + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setCursor(0, footerY); + display.setColor(DisplayDriver::YELLOW); + display.print("Q:Back"); + +#if AUTO_OFF_MILLIS == 0 + return 5000; +#else + return 1000; +#endif + } + if (channelMsgCount == 0) { display.setTextSize(0); // Tiny font for body text display.setCursor(0, 20); @@ -508,11 +654,11 @@ public: display.setCursor(0, footerY); display.setColor(DisplayDriver::YELLOW); - // Left side: Q:Back A/D:Ch - display.print("Q:Back A/D:Ch"); + // Left side: abbreviated controls + display.print("Q:Bck A/D:Ch V:Pth"); - // Right side: Entr:New - const char* rightText = "Entr:New"; + // Right side: Ent:New + const char* rightText = "Ent:New"; display.setCursor(display.width() - display.getTextWidth(rightText) - 2, footerY); display.print(rightText); @@ -524,8 +670,26 @@ public: } bool handleInput(char c) override { + // If overlay is showing, only handle dismiss + if (_showPathOverlay) { + if (c == 'q' || c == 'Q' || c == '\b' || c == 'v' || c == 'V') { + _showPathOverlay = false; + return true; + } + return true; // Consume all keys while overlay is up + } + int channelMsgCount = getMessageCountForChannel(); + // V - show path detail for last received message + if (c == 'v' || c == 'V') { + if (getNewestReceivedMsg() != nullptr) { + _showPathOverlay = true; + return true; + } + return false; // No received messages to show + } + // W or KEY_PREV - scroll up (older messages) if (c == 0xF2 || c == 'w' || c == 'W') { if (_scrollPos + _msgsPerPage < channelMsgCount) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ae65368..aed39c7 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -915,7 +915,8 @@ void UITask::msgRead(int msgcount) { } } -void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { +void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, + const uint8_t* path) { _msgcount = msgcount; // Add to preview screen (for notifications on non-keyboard devices) @@ -933,8 +934,8 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i } } - // Add to channel history screen with channel index - ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text); + // Add to channel history screen with channel index and path data + ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path); #if defined(LilyGo_TDeck_Pro) // T-Deck Pro: Don't interrupt user with popup - just show brief notification diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 5a9cca5..1471e8d 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -139,7 +139,8 @@ public: // from AbstractUITask void msgRead(int msgcount) override; - void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override; + void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, + const uint8_t* path = nullptr) override; void notify(UIEventType t = UIEventType::none) override; void loop() override; diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index e281223..4ab9a15 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -80,7 +80,7 @@ build_flags = -D PIN_DISPLAY_BL=45 -D PIN_USER_BTN=0 -D CST328_PIN_RST=38 - -D FIRMWARE_VERSION='"Meck v0.9.0A"' + -D FIRMWARE_VERSION='"Meck v0.9.1A"' -D ARDUINO_LOOP_STACK_SIZE=32768 build_src_filter = ${esp32_base.build_src_filter} +<../variants/LilyGo_TDeck_Pro> @@ -140,7 +140,7 @@ build_flags = -D MAX_GROUP_CHANNELS=20 -D OFFLINE_QUEUE_SIZE=256 -D NO_OTA=1 - -D FIRMWARE_VERSION='"Meck v0.9.0A-NB"' + -D FIRMWARE_VERSION='"Meck v0.9.1A-NB"' ; === NO BLE_PIN_CODE, NO WIFI_SSID === ; By omitting these, the preprocessor selects ArduinoSerialInterface (USB only). ; The BLE stack is never initialized, never linked, and the ESP32-S3 BT/WiFi