From b4983e48f065bfe076d7fe209dc3a31c597692f6 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:06:45 +1100 Subject: [PATCH] set custom contact paths --- examples/companion_radio/MyMesh.cpp | 44 + examples/companion_radio/MyMesh.h | 10 + examples/companion_radio/main.cpp | 111 ++- .../companion_radio/ui-new/Contactsscreen.h | 19 +- .../companion_radio/ui-new/Patheditorscreen.h | 805 ++++++++++++++++++ examples/companion_radio/ui-new/UITask.cpp | 19 + examples/companion_radio/ui-new/UITask.h | 4 + 7 files changed, 995 insertions(+), 17 deletions(-) create mode 100644 examples/companion_radio/ui-new/Patheditorscreen.h diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f69fa075..d85b5a7a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -873,6 +873,43 @@ bool MyMesh::uiSendTelemetryRequest(uint32_t contact_idx) { return true; } +bool MyMesh::setCustomPath(int contactIdx, const uint8_t* path, uint8_t pathLen, bool lock) { + ContactInfo contact; + if (!getContactByIdx(contactIdx, contact)) return false; + + ContactInfo* c = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE); + if (!c) return false; + + c->out_path_len = pathLen; + int byteLen = mesh::Packet::getPathByteLenFor(pathLen); + if (byteLen > MAX_PATH_SIZE) byteLen = MAX_PATH_SIZE; + memcpy(c->out_path, path, byteLen); + c->lastmod = getRTCClock()->getCurrentTime(); + + if (lock) { + c->flags |= CONTACT_FLAG_CUSTOM_PATH; + } + + MESH_DEBUG_PRINTLN("setCustomPath: contact %s, pathLen=0x%02X (%d hops, %dB/hop), lock=%d", + c->name, pathLen, pathLen & 0x3F, ((pathLen >> 6) & 3) + 1, lock); + return true; +} + +void MyMesh::clearCustomPath(int contactIdx) { + ContactInfo contact; + if (!getContactByIdx(contactIdx, contact)) return; + + ContactInfo* c = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE); + if (!c) return; + + c->out_path_len = OUT_PATH_UNKNOWN; + memset(c->out_path, 0, MAX_PATH_SIZE); + c->flags &= ~CONTACT_FLAG_CUSTOM_PATH; + c->lastmod = getRTCClock()->getCurrentTime(); + + MESH_DEBUG_PRINTLN("clearCustomPath: contact %s — reverted to auto-discovery", c->name); +} + uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_timestamp, const uint8_t *data, uint8_t len, uint8_t *reply) { if (data[0] == REQ_TYPE_GET_TELEMETRY_DATA) { @@ -1043,6 +1080,13 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i } } // let base class handle received path and data + // BUT: if this contact has a custom (manually set) path lock, don't let + // auto-discovery overwrite it. Skip the base class call entirely — ACKs + // embedded in path responses will still be delivered via separate ACK packets. + if (contact.flags & CONTACT_FLAG_CUSTOM_PATH) { + MESH_DEBUG_PRINTLN("onContactPathRecv: skipping path update for custom-path contact %s", contact.name); + return false; + } return BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len); } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 3ffea0e3..7c135847 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -70,6 +70,11 @@ #include #include +// Custom path lock flag — bit 7 of ContactInfo.flags +// When set, onContactPathRecv skips auto-updating this contact's out_path. +// Bits 0-6 remain available (bit 0 = favourite, bits 1-3 = telemetry perms). +#define CONTACT_FLAG_CUSTOM_PATH 0x80 + /* -------------------------------------------------------------------------------------- */ #define REQ_TYPE_GET_STATUS 0x01 // same as _GET_STATS @@ -158,6 +163,11 @@ public: bool uiSendTelemetryRequest(uint32_t contact_idx); int getAdminContactIdx() const { return _admin_contact_idx; } + // Custom path editor — set or clear a manually configured path for a contact + // When locked, automatic path discovery will not overwrite this contact's path. + bool setCustomPath(int contactIdx, const uint8_t* path, uint8_t pathLen, bool lock); + void clearCustomPath(int contactIdx); + protected: float getAirtimeBudgetFactor() const override; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 51e92caa..a9070fdc 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -23,6 +23,7 @@ #include "RepeaterAdminScreen.h" #include "DiscoveryScreen.h" #include "LastHeardScreen.h" + #include "PathEditorScreen.h" #ifdef MECK_WEB_READER #include "WebReaderScreen.h" #endif @@ -366,6 +367,7 @@ #include "RepeaterAdminScreen.h" #include "DiscoveryScreen.h" #include "LastHeardScreen.h" + #include "PathEditorScreen.h" static TouchDrvGT911 gt911Touch; static bool gt911Ready = false; @@ -962,6 +964,20 @@ static void lastHeardToggleContact() { return 0; } + // Path editor screen: tap to select row, tap same to activate + if (ui_task.isOnPathEditor()) { + PathEditorScreen* pe = (PathEditorScreen*)ui_task.getPathEditorScreen(); + if (pe) { + int result = pe->selectRowAtVY(vy); + if (result == 1) { + ui_task.forceRefresh(); + return 0; + } + if (result == 2) return KEY_ENTER; + } + return 0; + } + // Discovery screen: tap to select, tap same to add if (ui_task.isOnDiscoveryScreen()) { DiscoveryScreen* ds = (DiscoveryScreen*)ui_task.getDiscoveryScreen(); @@ -1232,6 +1248,11 @@ static void lastHeardToggleContact() { return 'f'; } + // Path editor: long press = Enter (select item) + if (ui_task.isOnPathEditor()) { + return KEY_ENTER; + } + // Repeater admin: long press → open keyboard for password or CLI if (ui_task.isOnRepeaterAdmin()) { RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen(); @@ -2510,6 +2531,13 @@ void loop() { char c = mapTouchLongPress(touchDownX, touchDownY); if (c) { ui_task.injectKey(c); + // Path editor: check if Save & Exit was triggered + if (ui_task.isOnPathEditor()) { + PathEditorScreen* pe = (PathEditorScreen*)ui_task.getPathEditorScreen(); + if (pe && pe->wantsExit()) { + ui_task.gotoContactsScreen(); + } + } cpuPower.setBoost(); } lastTouchEventMs = now; @@ -2522,6 +2550,13 @@ void loop() { char c = mapTouchTap(touchDownX, touchDownY); if (c) { ui_task.injectKey(c); + // Path editor: check if Save & Exit was triggered + if (ui_task.isOnPathEditor()) { + PathEditorScreen* pe = (PathEditorScreen*)ui_task.getPathEditorScreen(); + if (pe && pe->wantsExit()) { + ui_task.gotoContactsScreen(); + } + } } cpuPower.setBoost(); lastTouchEventMs = now; @@ -2728,6 +2763,13 @@ void loop() { ui_task.showVirtualKeyboard(VKB_ADMIN_CLI, "Admin Command", "", 137); } } + } else if (ui_task.isOnPathEditor()) { + // Path editor handles Enter internally + ui_task.injectKey('\r'); + PathEditorScreen* pe = (PathEditorScreen*)ui_task.getPathEditorScreen(); + if (pe && pe->wantsExit()) { + ui_task.gotoContactsScreen(); + } } else { // All other screens: pass Enter through for native handling // (settings toggle, discovery add-contact, last heard, text reader, notes file list, etc.) @@ -2735,12 +2777,26 @@ void loop() { } } else { // Non-Enter keys: remap arrows to WASD, pass others through - switch (ckb) { - case (char)0xF2: ui_task.injectKey('w'); break; // Up → scroll up - case (char)0xF1: ui_task.injectKey('s'); break; // Down → scroll down - case (char)0xF3: ui_task.injectKey('a'); break; // Left → prev channel/category - case (char)0xF4: ui_task.injectKey('d'); break; // Right → next channel/category - default: ui_task.injectKey(ckb); break; + // Special: 'p' on contacts screen opens path editor + if ((ckb == 'p' || ckb == 'P') && ui_task.isOnContactsScreen()) { + ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); + if (cs) { + int idx = cs->getSelectedContactIdx(); + if (idx >= 0) { + ui_task.gotoPathEditor(idx); + } + } + } else if ((ckb == 'q' || ckb == 'Q') && ui_task.isOnPathEditor()) { + // Q on path editor → back to contacts + ui_task.gotoContactsScreen(); + } else { + switch (ckb) { + case (char)0xF2: ui_task.injectKey('w'); break; // Up → scroll up + case (char)0xF1: ui_task.injectKey('s'); break; // Down → scroll down + case (char)0xF3: ui_task.injectKey('a'); break; // Left → prev channel/category + case (char)0xF4: ui_task.injectKey('d'); break; // Right → next channel/category + default: ui_task.injectKey(ckb); break; + } } } } @@ -3519,9 +3575,23 @@ void handleKeyboardInput() { ui_task.gotoTextReader(); break; - #ifndef HAS_4G_MODEM case 'p': - // Open audiobook player - lazy-init Audio + screen on first use + // On contacts screen: open path editor for selected contact + if (ui_task.isOnContactsScreen()) { + ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); + if (cs) { + int idx = cs->getSelectedContactIdx(); + if (idx >= 0) { + char pname[32]; + cs->getSelectedContactName(pname, sizeof(pname)); + Serial.printf("Opening path editor for %s (idx %d)\n", pname, idx); + ui_task.gotoPathEditor(idx); + } + } + break; + } + #ifndef HAS_4G_MODEM + // Otherwise: open audiobook player - lazy-init Audio + screen on first use Serial.println("Opening audiobook player"); if (!ui_task.getAudiobookScreen()) { Serial.printf("Audiobook: lazy init - free heap: %d, largest block: %d\n", @@ -3533,8 +3603,8 @@ void handleKeyboardInput() { Serial.printf("Audiobook: init complete - free heap: %d\n", ESP.getFreeHeap()); } ui_task.gotoAudiobookPlayer(); - break; #endif + break; #ifdef MECK_AUDIO_VARIANT case 'k': @@ -3653,6 +3723,7 @@ void handleKeyboardInput() { // Open settings (from home), or navigate down on channel/contacts/admin/web/map/discovery/lastheard if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin() || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() + || ui_task.isOnPathEditor() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -3672,6 +3743,7 @@ void handleKeyboardInput() { // Navigate up/previous (scroll on channel screen) if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin() || ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen() + || ui_task.isOnPathEditor() #ifdef MECK_WEB_READER || ui_task.isOnWebReader() #endif @@ -3690,6 +3762,7 @@ void handleKeyboardInput() { case 'a': // Navigate left or switch channel (on channel screen) if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen() + || ui_task.isOnPathEditor() #ifdef MECK_AUDIO_VARIANT || ui_task.isOnAlarmScreen() #endif @@ -3704,6 +3777,7 @@ void handleKeyboardInput() { case 'd': // Navigate right or switch channel (on channel screen) if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnMapScreen() + || ui_task.isOnPathEditor() #ifdef MECK_AUDIO_VARIANT || ui_task.isOnAlarmScreen() #endif @@ -3716,9 +3790,16 @@ void handleKeyboardInput() { break; case '\r': - // Select/Enter - if on contacts screen, enter DM compose for chat contacts - // or repeater admin for repeater contacts - if (ui_task.isOnContactsScreen()) { + // Select/Enter - path editor handles Enter internally + if (ui_task.isOnPathEditor()) { + ui_task.injectKey('\r'); + // Check if Save & Exit was selected + PathEditorScreen* pe = (PathEditorScreen*)ui_task.getPathEditorScreen(); + if (pe && pe->wantsExit()) { + Serial.println("PathEditor: Save & Exit — returning to contacts"); + ui_task.gotoContactsScreen(); + } + } else if (ui_task.isOnContactsScreen()) { ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); int idx = cs->getSelectedContactIdx(); uint8_t ctype = cs->getSelectedContactType(); @@ -3985,6 +4066,12 @@ void handleKeyboardInput() { ui_task.gotoContactsScreen(); break; } + // Path editor: Q goes back to contacts (discards unsaved changes) + if (ui_task.isOnPathEditor()) { + Serial.println("Nav: PathEditor -> Contacts"); + ui_task.gotoContactsScreen(); + break; + } // Alarm screen: Q/backspace routing depends on sub-mode #ifdef MECK_AUDIO_VARIANT if (ui_task.isOnAlarmScreen()) { diff --git a/examples/companion_radio/ui-new/Contactsscreen.h b/examples/companion_radio/ui-new/Contactsscreen.h index 50b62f78..0124fc30 100644 --- a/examples/companion_radio/ui-new/Contactsscreen.h +++ b/examples/companion_radio/ui-new/Contactsscreen.h @@ -300,10 +300,19 @@ public: // Reserve space for hops + age on right side char hopStr[6]; - if (contact.out_path_len == 0xFF || contact.out_path_len == 0) { - strcpy(hopStr, "D"); // direct + if (contact.out_path_len == 0xFF) { + strcpy(hopStr, "?"); // unknown path + } else if (contact.out_path_len == 0) { + bool customDirect = (contact.flags & CONTACT_FLAG_CUSTOM_PATH) != 0; + strcpy(hopStr, customDirect ? "D*" : "D"); } else { - snprintf(hopStr, sizeof(hopStr), "%d", contact.out_path_len); + int hops = contact.out_path_len & 0x3F; // lower 6 bits = hop count + bool customPath = (contact.flags & CONTACT_FLAG_CUSTOM_PATH) != 0; + if (customPath) { + snprintf(hopStr, sizeof(hopStr), "%d*", hops); // asterisk = custom/locked path + } else { + snprintf(hopStr, sizeof(hopStr), "%d", hops); + } } char ageStr[6]; @@ -352,8 +361,8 @@ public: display.setCursor(0, footerY); display.print("Q:Bk A/D:Filter"); - // Right: Tap/Ent:Select - const char* right = "Tap/Ent:Select"; + // Right: P:Path Ent:Sel + const char* right = "P:Path Ent:Sel"; display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); display.print(right); #endif diff --git a/examples/companion_radio/ui-new/Patheditorscreen.h b/examples/companion_radio/ui-new/Patheditorscreen.h new file mode 100644 index 00000000..f4a99de0 --- /dev/null +++ b/examples/companion_radio/ui-new/Patheditorscreen.h @@ -0,0 +1,805 @@ +#pragma once + +#include +#include +#include +#include + +// Forward declarations +class UITask; +class MyMesh; +extern MyMesh the_mesh; + +class PathEditorScreen : public UIScreen { +public: + enum EditorState { + STATE_MAIN, + STATE_PICK_HOP + }; + + // Main-state menu items (dynamic, built each render) + enum MenuItem { + MENU_MODE = 0, // "Mode: 1B/hop" or "Mode: 2B/hop" + // After mode: hop lines (MENU_HOP_BASE + i) + // Then: action items + MENU_HOP_BASE = 1, + // Dynamic items after hops: + MENU_ADD_HOP = 100, + MENU_SET_DIRECT, + MENU_REMOVE_LAST, + MENU_CLEAR_PATH, + MENU_SAVE_EXIT + }; + +private: + UITask* _task; + mesh::RTCClock* _rtc; + + int _contactIdx; // Index into contact table + char _contactName[32]; // Contact name for header + + EditorState _state; + int _menuSel; // Selected menu item index (0-based in visible list) + int _menuCount; // Total visible menu items + + // Path being edited (working copy) + uint8_t _pathBuf[MAX_PATH_SIZE]; + uint8_t _pathLen; // Encoded: bits[7:6]=mode, bits[5:0]=hops + int _hopCount; // Decoded hop count + int _bytesPerHop; // 1 or 2 + + // Repeater picker state + static const int MAX_REPEATERS = 200; + uint16_t* _repIdx; // Indices into contact table (PSRAM) + int _repCount; // Number of repeaters found + int _repSel; // Selected repeater in picker + int _repScroll; // Scroll offset in picker + + bool _dirty; // Path has been modified + bool _wantExit; // Set by Save & Exit — caller should navigate back + bool _directLocked; // True = path is explicitly set to direct (0 hops, locked) + + // --- helpers --- + + void decodePath() { + _hopCount = _pathLen & 0x3F; + uint8_t mode = (_pathLen >> 6) & 0x03; + _bytesPerHop = mode + 1; + } + + uint8_t encodePath() const { + uint8_t mode = (_bytesPerHop - 1) & 0x03; + return (mode << 6) | (_hopCount & 0x3F); + } + + void buildRepeaterList() { + _repCount = 0; + uint32_t numContacts = the_mesh.getNumContacts(); + ContactInfo c; + for (uint32_t i = 0; i < numContacts && _repCount < MAX_REPEATERS; i++) { + if (the_mesh.getContactByIdx(i, c)) { + if (c.type == ADV_TYPE_REPEATER) { + _repIdx[_repCount++] = (uint16_t)i; + } + } + } + } + + // Look up a contact name by matching pub_key prefix bytes + bool findNameForHop(int hopIndex, char* name, size_t nameLen) const { + if (hopIndex < 0 || hopIndex >= _hopCount) return false; + int offset = hopIndex * _bytesPerHop; + uint32_t numContacts = the_mesh.getNumContacts(); + ContactInfo c; + for (uint32_t i = 0; i < numContacts; i++) { + if (the_mesh.getContactByIdx(i, c)) { + bool match = true; + for (int b = 0; b < _bytesPerHop; b++) { + if (c.id.pub_key[b] != _pathBuf[offset + b]) { + match = false; + break; + } + } + if (match) { + strncpy(name, c.name, nameLen); + name[nameLen - 1] = '\0'; + return true; + } + } + } + return false; + } + + // Build the visible menu items list and return count + // Menu layout: + // 0: Mode selector + // 1..hopCount: each hop + // hopCount+1: Add hop + // hopCount+2: Remove last (only if hops > 0) + // hopCount+2 or +3: Clear path (only if custom path flag set or hops > 0) + // last: Save & Exit + int buildMenuCount() const { + int count = 1; // Mode selector + count += _hopCount; // One per hop + if (_hopCount < 8) count++; // Add hop (max 8 hops) + count++; // Set Direct (always visible) + if (_hopCount > 0) count++; // Remove last + if (_hopCount > 0 || _directLocked || isCustomPathSet()) count++; // Clear path + count++; // Save & Exit + return count; + } + + // Map a menu index to a MenuItem enum + MenuItem menuItemAt(int idx) const { + if (idx == 0) return MENU_MODE; + int pos = 1; + // Hop lines + for (int h = 0; h < _hopCount; h++) { + if (idx == pos) return (MenuItem)(MENU_HOP_BASE + h); + pos++; + } + // Add hop + if (_hopCount < 8) { + if (idx == pos) return MENU_ADD_HOP; + pos++; + } + // Set Direct + if (idx == pos) return MENU_SET_DIRECT; + pos++; + // Remove last + if (_hopCount > 0) { + if (idx == pos) return MENU_REMOVE_LAST; + pos++; + } + // Clear path + if (_hopCount > 0 || _directLocked || isCustomPathSet()) { + if (idx == pos) return MENU_CLEAR_PATH; + pos++; + } + // Save & Exit + return MENU_SAVE_EXIT; + } + + bool isCustomPathSet() const { + ContactInfo c; + if (!the_mesh.getContactByIdx(_contactIdx, c)) return false; + return (c.flags & CONTACT_FLAG_CUSTOM_PATH) != 0; + } + +public: + PathEditorScreen(UITask* task, mesh::RTCClock* rtc) + : _task(task), _rtc(rtc), _contactIdx(-1), _state(STATE_MAIN), + _menuSel(0), _menuCount(1), _pathLen(0), _hopCount(0), + _bytesPerHop(1), _repCount(0), _repSel(0), _repScroll(0), + _dirty(false), _wantExit(false), _directLocked(false) { + memset(_contactName, 0, sizeof(_contactName)); + memset(_pathBuf, 0, sizeof(_pathBuf)); + #if defined(ESP32) && defined(BOARD_HAS_PSRAM) + _repIdx = (uint16_t*)ps_calloc(MAX_REPEATERS, sizeof(uint16_t)); + #else + _repIdx = new uint16_t[MAX_REPEATERS](); + #endif + } + + void openForContact(int contactIdx) { + _contactIdx = contactIdx; + _state = STATE_MAIN; + _menuSel = 0; + _repSel = 0; + _repScroll = 0; + _dirty = false; + _wantExit = false; + _directLocked = false; + + // Load contact info + ContactInfo c; + if (the_mesh.getContactByIdx(contactIdx, c)) { + strncpy(_contactName, c.name, sizeof(_contactName) - 1); + _contactName[sizeof(_contactName) - 1] = '\0'; + + // Copy current path + if (c.out_path_len != OUT_PATH_UNKNOWN) { + _pathLen = c.out_path_len; + decodePath(); + int byteLen = _hopCount * _bytesPerHop; + if (byteLen > MAX_PATH_SIZE) byteLen = MAX_PATH_SIZE; + memcpy(_pathBuf, c.out_path, byteLen); + // Detect existing direct-locked path + if (_hopCount == 0 && (c.flags & CONTACT_FLAG_CUSTOM_PATH)) { + _directLocked = true; + } + } else { + _pathLen = 0; + _hopCount = 0; + _bytesPerHop = 1; + memset(_pathBuf, 0, sizeof(_pathBuf)); + } + } else { + strcpy(_contactName, "Unknown"); + _pathLen = 0; + _hopCount = 0; + _bytesPerHop = 1; + } + + _menuCount = buildMenuCount(); + } + + int render(DisplayDriver& display) override { + if (_state == STATE_PICK_HOP) { + return renderPicker(display); + } + return renderMain(display); + } + + int renderMain(DisplayDriver& display) { + char tmp[64]; + + // === Header === + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + snprintf(tmp, sizeof(tmp), "Path: %s", _contactName); + // Truncate if too long + if (display.getTextWidth(tmp) > display.width() - 4) { + snprintf(tmp, sizeof(tmp), "Path: %.12s..", _contactName); + } + display.print(tmp); + + // Show lock icon or dirty indicator on right + if (_dirty) { + const char* mod = "[*]"; + display.setCursor(display.width() - display.getTextWidth(mod) - 2, 0); + display.print(mod); + } else if (isCustomPathSet()) { + const char* lock = "[L]"; + display.setCursor(display.width() - display.getTextWidth(lock) - 2, 0); + display.print(lock); + } + + display.drawRect(0, 11, display.width(), 1); + + // === Body === + display.setTextSize(0); + int lineH = 9; + int headerH = 14; + int footerH = 14; + int maxY = display.height() - footerH; + int y = headerH; + + _menuCount = buildMenuCount(); + + // Center visible window around selected item + int maxVisible = (maxY - headerH) / lineH; + if (maxVisible < 3) maxVisible = 3; + int startIdx = max(0, min(_menuSel - maxVisible / 2, _menuCount - maxVisible)); + if (startIdx < 0) startIdx = 0; + int endIdx = min(_menuCount, startIdx + maxVisible); + + for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) { + bool selected = (i == _menuSel); + MenuItem item = menuItemAt(i); + + if (selected) { + display.setColor(DisplayDriver::LIGHT); +#if defined(LilyGo_T5S3_EPaper_Pro) + display.fillRect(0, y, display.width(), lineH); +#else + display.fillRect(0, y + 5, display.width(), lineH); +#endif + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + display.setCursor(2, y); + char prefix = selected ? '>' : ' '; + + switch (item) { + case MENU_MODE: + if (_directLocked) { + snprintf(tmp, sizeof(tmp), "%c Mode: DIRECT", prefix); + } else { + snprintf(tmp, sizeof(tmp), "%c Mode: %dB/hop", prefix, _bytesPerHop); + } + display.print(tmp); + // Show hint on right + if (!_directLocked) { + const char* hint = "(A/D)"; + display.setCursor(display.width() - display.getTextWidth(hint) - 4, y); + display.print(hint); + } + break; + + case MENU_ADD_HOP: + snprintf(tmp, sizeof(tmp), "%c + Add hop...", prefix); + display.print(tmp); + break; + + case MENU_SET_DIRECT: + if (_directLocked) { + snprintf(tmp, sizeof(tmp), "%c * Direct (set)", prefix); + } else { + snprintf(tmp, sizeof(tmp), "%c * Set Direct", prefix); + } + display.print(tmp); + break; + + case MENU_REMOVE_LAST: + snprintf(tmp, sizeof(tmp), "%c - Remove last hop", prefix); + display.print(tmp); + break; + + case MENU_CLEAR_PATH: + snprintf(tmp, sizeof(tmp), "%c Clear custom path", prefix); + display.print(tmp); + break; + + case MENU_SAVE_EXIT: + snprintf(tmp, sizeof(tmp), "%c Save & Exit", prefix); + display.print(tmp); + break; + + default: + // Hop line: MENU_HOP_BASE + hopIndex + if (item >= MENU_HOP_BASE && item < MENU_HOP_BASE + 64) { + int hopIdx = item - MENU_HOP_BASE; + char hopName[24]; + int offset = hopIdx * _bytesPerHop; + + if (findNameForHop(hopIdx, hopName, sizeof(hopName))) { + if (_bytesPerHop == 1) { + snprintf(tmp, sizeof(tmp), "%c %d: %s (%02X)", prefix, hopIdx + 1, + hopName, _pathBuf[offset]); + } else { + snprintf(tmp, sizeof(tmp), "%c %d: %s (%02X%02X)", prefix, hopIdx + 1, + hopName, _pathBuf[offset], _pathBuf[offset + 1]); + } + } else { + if (_bytesPerHop == 1) { + snprintf(tmp, sizeof(tmp), "%c %d: ??? (%02X)", prefix, hopIdx + 1, + _pathBuf[offset]); + } else { + snprintf(tmp, sizeof(tmp), "%c %d: ??? (%02X%02X)", prefix, hopIdx + 1, + _pathBuf[offset], _pathBuf[offset + 1]); + } + } + display.drawTextEllipsized(2, y, display.width() - 4, tmp); + } + break; + } + + y += lineH; + } + + // === Footer === + display.setTextSize(1); + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setColor(DisplayDriver::YELLOW); + +#if defined(LilyGo_T5S3_EPaper_Pro) + display.setCursor(0, footerY); + display.print("Swipe:Nav"); + const char* right = "Hold:Select"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); +#else + display.setCursor(0, footerY); + display.print("Q:Bk W/S:Nav"); + const char* right = "Enter:Sel"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); +#endif + + return 5000; + } + + int renderPicker(DisplayDriver& display) { + char tmp[64]; + + // === Header === + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + snprintf(tmp, sizeof(tmp), "Select Repeater (%d)", _repCount); + display.print(tmp); + + display.drawRect(0, 11, display.width(), 1); + + // === Body === + display.setTextSize(0); + int lineH = 9; + int headerH = 14; + int footerH = 14; + int maxY = display.height() - footerH; + int y = headerH; + + if (_repCount == 0) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, y); + display.print("No repeaters in contacts"); + display.setCursor(0, y + lineH); + display.print("Add repeaters first"); + } else { + int maxVisible = (maxY - headerH) / lineH; + if (maxVisible < 3) maxVisible = 3; + int startIdx = max(0, min(_repSel - maxVisible / 2, _repCount - maxVisible)); + if (startIdx < 0) startIdx = 0; + int endIdx = min(_repCount, startIdx + maxVisible); + + for (int i = startIdx; i < endIdx && y + lineH <= maxY; i++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(_repIdx[i], c)) continue; + + bool selected = (i == _repSel); + + if (selected) { + display.setColor(DisplayDriver::LIGHT); +#if defined(LilyGo_T5S3_EPaper_Pro) + display.fillRect(0, y, display.width(), lineH); +#else + display.fillRect(0, y + 5, display.width(), lineH); +#endif + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + display.setCursor(2, y); + char prefix = selected ? '>' : ' '; + + if (_bytesPerHop == 1) { + snprintf(tmp, sizeof(tmp), "%c %s (%02X)", prefix, c.name, c.id.pub_key[0]); + } else { + snprintf(tmp, sizeof(tmp), "%c %s (%02X%02X)", prefix, c.name, + c.id.pub_key[0], c.id.pub_key[1]); + } + display.drawTextEllipsized(2, y, display.width() - 4, tmp); + + y += lineH; + } + } + + // === Footer === + display.setTextSize(1); + int footerY = display.height() - 12; + display.drawRect(0, footerY - 2, display.width(), 1); + display.setColor(DisplayDriver::YELLOW); + +#if defined(LilyGo_T5S3_EPaper_Pro) + display.setCursor(0, footerY); + display.print("Swipe:Scroll"); + const char* right = "Hold:Add Back:Cancel"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); +#else + display.setCursor(0, footerY); + display.print("Q:Cancel W/S:Scroll"); + const char* right = "Enter:Add"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); +#endif + + return 5000; + } + + bool handleInput(char c) override { + if (_state == STATE_PICK_HOP) { + return handlePickerInput(c); + } + return handleMainInput(c); + } + + bool handleMainInput(char c) { + // W - scroll up + if (c == 'w' || c == 'W' || c == 0xF2) { + if (_menuSel > 0) { + _menuSel--; + return true; + } + return false; + } + + // S - scroll down + if (c == 's' || c == 'S' || c == 0xF1) { + if (_menuSel < _menuCount - 1) { + _menuSel++; + return true; + } + return false; + } + + // A/D — toggle mode (only when Mode item is selected and not direct-locked) + if (c == 'a' || c == 'A' || c == 'd' || c == 'D') { + MenuItem item = menuItemAt(_menuSel); + if (item == MENU_MODE && !_directLocked) { + // Toggle between 1-byte and 2-byte + if (_bytesPerHop == 1) { + switchMode(2); + } else { + switchMode(1); + } + _dirty = true; + _menuCount = buildMenuCount(); + return true; + } + return false; + } + + // Enter - select + if (c == 13 || c == KEY_ENTER || c == '\r') { + MenuItem item = menuItemAt(_menuSel); + + switch (item) { + case MENU_MODE: + // Toggle mode on Enter too (no-op if direct locked) + if (!_directLocked) { + if (_bytesPerHop == 1) { + switchMode(2); + } else { + switchMode(1); + } + _dirty = true; + _menuCount = buildMenuCount(); + } + return true; + + case MENU_ADD_HOP: + // Enter picker mode — adding a hop clears direct lock + _directLocked = false; + buildRepeaterList(); + _repSel = 0; + _repScroll = 0; + _state = STATE_PICK_HOP; + return true; + + case MENU_SET_DIRECT: + // Set path to direct (0 hops, locked) + _hopCount = 0; + _pathLen = 0; + memset(_pathBuf, 0, sizeof(_pathBuf)); + _directLocked = true; + _dirty = true; + _menuCount = buildMenuCount(); + return true; + + case MENU_REMOVE_LAST: + if (_hopCount > 0) { + _hopCount--; + _pathLen = encodePath(); + _dirty = true; + _menuCount = buildMenuCount(); + // Clamp selection + if (_menuSel >= _menuCount) _menuSel = _menuCount - 1; + } + return true; + + case MENU_CLEAR_PATH: + _hopCount = 0; + _pathLen = 0; + _directLocked = false; + memset(_pathBuf, 0, sizeof(_pathBuf)); + _dirty = true; + _menuCount = buildMenuCount(); + _menuSel = 0; + return true; + + case MENU_SAVE_EXIT: + savePath(); + _wantExit = true; // Signal to main.cpp to navigate back to contacts + return true; + + default: + // Hop line — no action (could add remove-specific-hop later) + break; + } + return true; + } + + // Q - back (discard changes or prompt?) + // For simplicity, just go back without saving + if (c == 'q' || c == 'Q') { + // Return to contacts screen without saving + // The UITask will handle this via the key falling through + return false; // Let UITask handle Q as back + } + + return false; + } + + bool handlePickerInput(char c) { + // W - scroll up + if (c == 'w' || c == 'W' || c == 0xF2) { + if (_repSel > 0) { + _repSel--; + return true; + } + return false; + } + + // S - scroll down + if (c == 's' || c == 'S' || c == 0xF1) { + if (_repSel < _repCount - 1) { + _repSel++; + return true; + } + return false; + } + + // Enter - add selected repeater as hop + if (c == 13 || c == KEY_ENTER || c == '\r') { + if (_repCount > 0 && _repSel >= 0 && _repSel < _repCount) { + addHopFromContact(_repIdx[_repSel]); + } + _state = STATE_MAIN; + _menuCount = buildMenuCount(); + return true; + } + + // Q - cancel picker, return to main + if (c == 'q' || c == 'Q') { + _state = STATE_MAIN; + return true; + } + + return false; + } + + // Tap-to-select for T5S3 touch + int selectRowAtVY(int vy) { + if (_state == STATE_PICK_HOP) { + return selectPickerRowAtVY(vy); + } + return selectMainRowAtVY(vy); + } + + int selectMainRowAtVY(int vy) { + if (_menuCount == 0) return 0; + const int headerH = 14, footerH = 14, lineH = 9; +#if defined(LilyGo_T5S3_EPaper_Pro) + const int bodyTop = headerH; +#else + const int bodyTop = headerH + 5; +#endif + if (vy < bodyTop || vy >= 128 - footerH) return 0; + + int maxVisible = (128 - headerH - footerH) / lineH; + if (maxVisible < 3) maxVisible = 3; + int startIdx = max(0, min(_menuSel - maxVisible / 2, _menuCount - maxVisible)); + if (startIdx < 0) startIdx = 0; + + int tappedRow = startIdx + (vy - bodyTop) / lineH; + if (tappedRow < 0 || tappedRow >= _menuCount) return 0; + if (tappedRow == _menuSel) return 2; + _menuSel = tappedRow; + return 1; + } + + int selectPickerRowAtVY(int vy) { + if (_repCount == 0) return 0; + const int headerH = 14, footerH = 14, lineH = 9; +#if defined(LilyGo_T5S3_EPaper_Pro) + const int bodyTop = headerH; +#else + const int bodyTop = headerH + 5; +#endif + if (vy < bodyTop || vy >= 128 - footerH) return 0; + + int maxVisible = (128 - headerH - footerH) / lineH; + if (maxVisible < 3) maxVisible = 3; + int startIdx = max(0, min(_repSel - maxVisible / 2, _repCount - maxVisible)); + if (startIdx < 0) startIdx = 0; + + int tappedRow = startIdx + (vy - bodyTop) / lineH; + if (tappedRow < 0 || tappedRow >= _repCount) return 0; + if (tappedRow == _repSel) return 2; + _repSel = tappedRow; + return 1; + } + + EditorState getState() const { return _state; } + bool isDirty() const { return _dirty; } + bool wantsExit() const { return _wantExit; } + +private: + void switchMode(int newBytesPerHop) { + if (newBytesPerHop == _bytesPerHop) return; + + if (_hopCount > 0) { + // Rebuild path buffer for new mode + // We need the full pub_keys to re-extract the right prefix bytes + uint8_t newBuf[MAX_PATH_SIZE]; + memset(newBuf, 0, sizeof(newBuf)); + int newHopCount = 0; + + for (int h = 0; h < _hopCount && newHopCount < 8; h++) { + int oldOffset = h * _bytesPerHop; + // Try to find the contact that matches this hop + uint32_t numContacts = the_mesh.getNumContacts(); + ContactInfo c; + bool found = false; + for (uint32_t i = 0; i < numContacts; i++) { + if (the_mesh.getContactByIdx(i, c)) { + bool match = true; + for (int b = 0; b < _bytesPerHop; b++) { + if (c.id.pub_key[b] != _pathBuf[oldOffset + b]) { + match = false; + break; + } + } + if (match) { + // Found the contact — copy new prefix size + int newOffset = newHopCount * newBytesPerHop; + for (int b = 0; b < newBytesPerHop; b++) { + newBuf[newOffset + b] = c.id.pub_key[b]; + } + newHopCount++; + found = true; + break; + } + } + } + if (!found) { + // Contact not found — copy what we can + int newOffset = newHopCount * newBytesPerHop; + int oldOff = h * _bytesPerHop; + for (int b = 0; b < newBytesPerHop; b++) { + if (b < _bytesPerHop) { + newBuf[newOffset + b] = _pathBuf[oldOff + b]; + } else { + newBuf[newOffset + b] = 0x00; // pad with zero + } + } + newHopCount++; + } + } + + _hopCount = newHopCount; + memcpy(_pathBuf, newBuf, sizeof(newBuf)); + } + + _bytesPerHop = newBytesPerHop; + _pathLen = encodePath(); + } + + void addHopFromContact(uint16_t contactTableIdx) { + if (_hopCount >= 8) return; + ContactInfo c; + if (!the_mesh.getContactByIdx(contactTableIdx, c)) return; + + int offset = _hopCount * _bytesPerHop; + if (offset + _bytesPerHop > MAX_PATH_SIZE) return; + + for (int b = 0; b < _bytesPerHop; b++) { + _pathBuf[offset + b] = c.id.pub_key[b]; + } + _hopCount++; + _pathLen = encodePath(); + _dirty = true; + } + + void savePath() { + if (_contactIdx < 0) return; + + if (_directLocked) { + // Set as direct (0 hops) with lock — prevents flood routing + the_mesh.setCustomPath(_contactIdx, _pathBuf, 0, true); + Serial.printf("PathEditor: set DIRECT path for contact %d (%s)\n", + _contactIdx, _contactName); + } else if (_hopCount > 0) { + // Set custom path with lock + the_mesh.setCustomPath(_contactIdx, _pathBuf, encodePath(), true); + Serial.printf("PathEditor: saved %d-hop %dB/hop path for contact %d (%s)\n", + _hopCount, _bytesPerHop, _contactIdx, _contactName); + } else { + // Clear custom path — revert to auto-discovery + the_mesh.clearCustomPath(_contactIdx); + Serial.printf("PathEditor: cleared custom path for contact %d (%s)\n", + _contactIdx, _contactName); + } + + // Trigger contact save to SD + the_mesh.saveContacts(); + _dirty = false; + } +}; \ 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 d4af83d4..4a4e0a00 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 "PathEditorScreen.h" #include "DiscoveryScreen.h" #include "LastHeardScreen.h" #ifdef MECK_WEB_READER @@ -1291,6 +1292,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no notes_screen = new NotesScreen(this, node_prefs); settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs); repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio + path_editor = nullptr; // Lazy-initialized on first use from contacts screen discovery_screen = new DiscoveryScreen(this, &rtc_clock); last_heard_screen = new LastHeardScreen(&rtc_clock); #if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) @@ -2776,6 +2778,23 @@ void UITask::gotoRepeaterAdminDirect(int contactIdx) { } } +void UITask::gotoPathEditor(int contactIdx) { + // Lazy-initialize on first use + if (path_editor == nullptr) { + path_editor = new PathEditorScreen(this, &rtc_clock); + } + + PathEditorScreen* editor = (PathEditorScreen*)path_editor; + editor->openForContact(contactIdx); + setCurrScreen(path_editor); + + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + void UITask::gotoDiscoveryScreen() { ((DiscoveryScreen*)discovery_screen)->resetScroll(); setCurrScreen(discovery_screen); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 8bcbcb7c..565197cb 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -94,6 +94,7 @@ class UITask : public AbstractUITask { UIScreen* sms_screen; // SMS messaging screen (4G variant only) #endif UIScreen* repeater_admin; // Repeater admin screen + UIScreen* path_editor; // Custom path editor screen (lazy-init) UIScreen* discovery_screen; // Node discovery scan screen UIScreen* last_heard_screen; // Last heard passive advert list #ifdef MECK_WEB_READER @@ -193,6 +194,7 @@ public: #endif void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin void gotoRepeaterAdminDirect(int contactIdx); // Auto-login admin (L key from conversation) + void gotoPathEditor(int contactIdx); // Navigate to custom path editor void gotoDiscoveryScreen(); // Navigate to node discovery scan void gotoLastHeardScreen(); // Navigate to last heard passive list #if HAS_GPS @@ -245,6 +247,7 @@ public: bool isOnVoiceScreen() const { return curr == voice_screen; } #endif bool isOnRepeaterAdmin() const { return curr == repeater_admin; } + bool isOnPathEditor() const { return curr == path_editor; } bool isOnDiscoveryScreen() const { return curr == discovery_screen; } bool isOnLastHeardScreen() const { return curr == last_heard_screen; } bool isOnMapScreen() const { return curr == map_screen; } @@ -319,6 +322,7 @@ public: void setVoiceScreen(UIScreen* s) { voice_screen = s; } #endif UIScreen* getRepeaterAdminScreen() const { return repeater_admin; } + UIScreen* getPathEditorScreen() const { return path_editor; } UIScreen* getDiscoveryScreen() const { return discovery_screen; } UIScreen* getLastHeardScreen() const { return last_heard_screen; } UIScreen* getMapScreen() const { return map_screen; }