diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index fae82d1..9b19d52 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -46,4 +46,8 @@ public: virtual void showAlert(const char* text, int duration_millis) {} virtual void forceRefresh() {} virtual void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) {} + + // Repeater admin callbacks (from MyMesh) + virtual void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) {} + virtual void onAdminCliResponse(const char* from_name, const char* text) {} }; \ No newline at end of file diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 5667df3..eb9c815 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -476,7 +476,7 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) { } } - return false; // never filter — let normal processing continue + return false; // never filter — let normal processing continue } void MyMesh::sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis) { @@ -523,6 +523,13 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3 const char *text) { markConnectionActive(from); // in case this is from a server, and we have a connection queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text); + +#ifdef DISPLAY_CLASS + // Route CLI responses to admin UI if active + if (_ui && _admin_contact_idx >= 0) { + _ui->onAdminCliResponse(from.name, text); + } +#endif } void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, @@ -650,6 +657,55 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) { return true; } +bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password) { + ContactInfo contact; + if (!getContactByIdx(contact_idx, contact)) return false; + + ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE); + if (!recipient) return false; + + uint32_t est_timeout; + int result = sendLogin(*recipient, password, est_timeout); + + if (result == MSG_SEND_FAILED) { + MESH_DEBUG_PRINTLN("UI: Admin login send failed to %s", recipient->name); + return false; + } + + clearPendingReqs(); + memcpy(&pending_login, recipient->id.pub_key, 4); + _admin_contact_idx = contact_idx; + + MESH_DEBUG_PRINTLN("UI: Admin login sent to %s (%s), timeout=%dms", + recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct", + est_timeout); + return true; +} + +bool MyMesh::uiSendCliCommand(uint32_t contact_idx, const char* command) { + ContactInfo contact; + if (!getContactByIdx(contact_idx, contact)) return false; + + ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE); + if (!recipient) return false; + + uint32_t timestamp = getRTCClock()->getCurrentTimeUnique(); + uint32_t est_timeout; + int result = sendCommandData(*recipient, timestamp, 0, command, est_timeout); + + if (result == MSG_SEND_FAILED) { + MESH_DEBUG_PRINTLN("UI: CLI command send failed to %s: %s", recipient->name, command); + return false; + } + + _admin_contact_idx = contact_idx; + + MESH_DEBUG_PRINTLN("UI: CLI command sent to %s (%s): %s, timeout=%dms", + recipient->name, result == MSG_SEND_SENT_FLOOD ? "flood" : "direct", + command, est_timeout); + return true; +} + 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) { @@ -708,6 +764,11 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, out_frame[i++] = 0; // legacy: is_admin = false memcpy(&out_frame[i], contact.id.pub_key, 6); i += 6; // pub_key_prefix + +#ifdef DISPLAY_CLASS + // Notify UI of successful legacy login + if (_ui) _ui->onAdminLoginResult(true, 0, tag); +#endif } else if (data[4] == RESP_SERVER_LOGIN_OK) { // new login response uint16_t keep_alive_secs = ((uint16_t)data[5]) * 16; if (keep_alive_secs > 0) { @@ -721,11 +782,21 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, i += 4; // NEW: include server timestamp out_frame[i++] = data[7]; // NEW (v7): ACL permissions out_frame[i++] = data[12]; // FIRMWARE_VER_LEVEL + +#ifdef DISPLAY_CLASS + // Notify UI of successful login + if (_ui) _ui->onAdminLoginResult(true, data[6], tag); +#endif } else { out_frame[i++] = PUSH_CODE_LOGIN_FAIL; out_frame[i++] = 0; // reserved memcpy(&out_frame[i], contact.id.pub_key, 6); i += 6; // pub_key_prefix + +#ifdef DISPLAY_CLASS + // Notify UI of login failure + if (_ui) _ui->onAdminLoginResult(false, 0, 0); +#endif } _serial->writeFrame(out_frame, i); } else if (len > 4 && // check for status response @@ -897,6 +968,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe memset(send_scope.key, 0, sizeof(send_scope.key)); memset(_sent_track, 0, sizeof(_sent_track)); _sent_track_idx = 0; + _admin_contact_idx = -1; // defaults memset(&_prefs, 0, sizeof(_prefs)); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 199e43b..3ce7f6f 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -108,6 +108,12 @@ public: // Send a direct message from the UI (no BLE dependency) bool uiSendDirectMessage(uint32_t contact_idx, const char* text); + // Repeater admin - UI-initiated operations + bool uiLoginToRepeater(uint32_t contact_idx, const char* password); + bool uiSendCliCommand(uint32_t contact_idx, const char* command); + int getAdminContactIdx() const { return _admin_contact_idx; } + + protected: float getAirtimeBudgetFactor() const override; int getInterferenceThreshold() const override; @@ -247,6 +253,7 @@ 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) }; 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 691ff51..c1b6aa8 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 "RepeaterAdminScreen.h" extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire); @@ -630,6 +631,56 @@ void handleKeyboardInput() { return; } + // *** REPEATER ADMIN MODE *** + if (ui_task.isOnRepeaterAdmin()) { + RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen(); + RepeaterAdminScreen::AdminState astate = admin->getState(); + + // In password entry, pass all printable chars and special keys through + // Q only navigates back if password is empty (handleInput returns false) + if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) { + if (key == 'q' || key == 'Q') { + // Try passing to screen - if not handled (empty password), navigate back + bool handled = admin->handleInput(key); + if (!handled) { + Serial.println("Nav: Back to contacts from admin login"); + ui_task.gotoContactsScreen(); + } + ui_task.forceRefresh(); + } else { + ui_task.injectKey(key); + } + return; + } + + // In menu state + if (astate == RepeaterAdminScreen::STATE_MENU) { + if (key == 'q' || key == 'Q') { + Serial.println("Nav: Back to contacts from admin menu"); + ui_task.gotoContactsScreen(); + return; + } + // C key: allow entering compose mode from admin menu + if (key == 'c' || key == 'C') { + composeDM = false; + composeDMContactIdx = -1; + composeMode = true; + composeBuffer[0] = '\0'; + composePos = 0; + drawComposeScreen(); + lastComposeRefresh = millis(); + return; + } + // All other keys pass to admin screen + ui_task.injectKey(key); + return; + } + + // In waiting/response/error states, pass all keys through + ui_task.injectKey(key); + return; + } + // Normal mode - not composing switch (key) { case 'c': @@ -690,7 +741,7 @@ void handleKeyboardInput() { case 'w': case 'W': // Navigate up/previous (scroll on channel screen) - if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) { ui_task.injectKey('w'); // Pass directly for channel/contacts switching } else { Serial.println("Nav: Previous"); @@ -701,7 +752,7 @@ void handleKeyboardInput() { case 's': case 'S': // Navigate down/next (scroll on channel screen) - if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) { ui_task.injectKey('s'); // Pass directly for channel/contacts switching } else { Serial.println("Nav: Next"); @@ -733,6 +784,7 @@ void handleKeyboardInput() { case '\r': // Select/Enter - if on contacts screen, enter DM compose for chat contacts + // or repeater admin for repeater contacts if (ui_task.isOnContactsScreen()) { ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen(); int idx = cs->getSelectedContactIdx(); @@ -747,9 +799,15 @@ void handleKeyboardInput() { Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx); drawComposeScreen(); lastComposeRefresh = millis(); + } else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) { + // Open repeater admin screen + char rname[32]; + cs->getSelectedContactName(rname, sizeof(rname)); + Serial.printf("Opening repeater admin for %s (idx %d)\n", rname, idx); + ui_task.gotoRepeaterAdmin(idx); } else if (idx >= 0) { - // Non-chat contact selected (repeater, room, etc.) - future use - Serial.printf("Selected non-chat contact type=%d idx=%d\n", ctype, idx); + // Non-chat, non-repeater contact (room, sensor, etc.) - future use + Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx); } } else { Serial.println("Nav: Enter/Select"); @@ -760,7 +818,7 @@ void handleKeyboardInput() { case 'q': case 'Q': case '\b': - // Go back to home screen + // Go back to home screen (admin mode handled above) Serial.println("Nav: Back to home"); ui_task.gotoHomeScreen(); break; diff --git a/examples/companion_radio/ui-new/Repeateradminscreen.h b/examples/companion_radio/ui-new/Repeateradminscreen.h new file mode 100644 index 0000000..a4f297a --- /dev/null +++ b/examples/companion_radio/ui-new/Repeateradminscreen.h @@ -0,0 +1,550 @@ +#pragma once + +#include +#include +#include + +// Forward declarations +class UITask; +class MyMesh; +extern MyMesh the_mesh; + +#define ADMIN_PASSWORD_MAX 32 +#define ADMIN_RESPONSE_MAX 512 // CLI responses can be multi-line +#define ADMIN_TIMEOUT_MS 15000 // 15s timeout for login/commands + +class RepeaterAdminScreen : public UIScreen { +public: + enum AdminState { + STATE_PASSWORD_ENTRY, // Typing admin password + STATE_LOGGING_IN, // Waiting for login response + STATE_MENU, // Main admin menu + STATE_COMMAND_PENDING, // Waiting for CLI response + STATE_RESPONSE_VIEW, // Displaying CLI response + STATE_ERROR // Error state (timeout, send fail) + }; + + // Menu items + enum MenuItem { + MENU_CLOCK_SYNC = 0, + MENU_ADVERT, + MENU_NEIGHBORS, + MENU_GET_CLOCK, + MENU_GET_VER, + MENU_GET_STATUS, + MENU_COUNT + }; + +private: + UITask* _task; + mesh::RTCClock* _rtc; + + AdminState _state; + int _contactIdx; // Contact table index of the repeater + char _repeaterName[32]; // Cached repeater name + uint8_t _permissions; // Login permissions (0=guest, 3=admin) + uint32_t _serverTime; // Server timestamp from login response + + // Password entry + char _password[ADMIN_PASSWORD_MAX]; + int _pwdLen; + + // Menu + int _menuSel; // Currently selected menu item + + // Response buffer + char _response[ADMIN_RESPONSE_MAX]; + int _responseLen; + int _responseScroll; // Scroll offset for long responses + + // Timing + unsigned long _cmdSentAt; // millis() when command was sent + bool _waitingForLogin; + + static const char* menuLabel(MenuItem m) { + switch (m) { + case MENU_CLOCK_SYNC: return "Clock Sync"; + case MENU_ADVERT: return "Send Advert"; + case MENU_NEIGHBORS: return "Neighbors"; + case MENU_GET_CLOCK: return "Get Clock"; + case MENU_GET_VER: return "Version"; + case MENU_GET_STATUS: return "Get Status"; + default: return "?"; + } + } + + static const char* menuCommand(MenuItem m) { + switch (m) { + case MENU_CLOCK_SYNC: return "clock sync"; + case MENU_ADVERT: return "advert"; + case MENU_NEIGHBORS: return "neighbors"; + case MENU_GET_CLOCK: return "clock"; + case MENU_GET_VER: return "ver"; + case MENU_GET_STATUS: return "get status"; + default: return ""; + } + } + + // Format epoch as HH:MM:SS + static void formatTime(char* buf, size_t bufLen, uint32_t epoch) { + if (epoch == 0) { + strncpy(buf, "--:--:--", bufLen); + return; + } + uint32_t secs = epoch % 60; + uint32_t mins = (epoch / 60) % 60; + uint32_t hrs = (epoch / 3600) % 24; + snprintf(buf, bufLen, "%02d:%02d:%02d", (int)hrs, (int)mins, (int)secs); + } + +public: + RepeaterAdminScreen(UITask* task, mesh::RTCClock* rtc) + : _task(task), _rtc(rtc), _state(STATE_PASSWORD_ENTRY), + _contactIdx(-1), _permissions(0), _serverTime(0), + _pwdLen(0), _menuSel(0), + _responseLen(0), _responseScroll(0), + _cmdSentAt(0), _waitingForLogin(false) { + _password[0] = '\0'; + _repeaterName[0] = '\0'; + _response[0] = '\0'; + } + + // Called when entering the screen for a specific repeater contact + void openForContact(int contactIdx, const char* name) { + _contactIdx = contactIdx; + strncpy(_repeaterName, name, sizeof(_repeaterName) - 1); + _repeaterName[sizeof(_repeaterName) - 1] = '\0'; + + // Reset state + _state = STATE_PASSWORD_ENTRY; + _pwdLen = 0; + _password[0] = '\0'; + _menuSel = 0; + _permissions = 0; + _serverTime = 0; + _responseLen = 0; + _responseScroll = 0; + _response[0] = '\0'; + _waitingForLogin = false; + } + + int getContactIdx() const { return _contactIdx; } + AdminState getState() const { return _state; } + + // Called by UITask when a login response is received + void onLoginResult(bool success, uint8_t permissions, uint32_t server_time) { + _waitingForLogin = false; + if (success) { + _permissions = permissions; + _serverTime = server_time; + _state = STATE_MENU; + } else { + snprintf(_response, sizeof(_response), "Login failed.\nCheck password."); + _responseLen = strlen(_response); + _state = STATE_ERROR; + } + } + + // Called by UITask when a CLI response is received + void onCliResponse(const char* text) { + if (_state != STATE_COMMAND_PENDING) return; + + int tlen = strlen(text); + if (tlen >= ADMIN_RESPONSE_MAX) tlen = ADMIN_RESPONSE_MAX - 1; + memcpy(_response, text, tlen); + _response[tlen] = '\0'; + _responseLen = tlen; + _responseScroll = 0; + _state = STATE_RESPONSE_VIEW; + } + + // Poll for timeouts + void poll() override { + if ((_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) && + _cmdSentAt > 0 && (millis() - _cmdSentAt) > ADMIN_TIMEOUT_MS) { + snprintf(_response, sizeof(_response), "Timeout - no response."); + _responseLen = strlen(_response); + _state = STATE_ERROR; + } + } + + int render(DisplayDriver& display) override { + char tmp[64]; + + // === Header === + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, 0); + + // Truncate name if needed to fit header + snprintf(tmp, sizeof(tmp), "Admin: %.16s", _repeaterName); + display.print(tmp); + + // Show permissions if logged in + if (_state >= STATE_MENU && _state <= STATE_RESPONSE_VIEW) { + const char* perm = (_permissions & 0x03) >= 3 ? "ADM" : + (_permissions & 0x03) >= 2 ? "R/W" : "R/O"; + display.setCursor(display.width() - display.getTextWidth(perm) - 2, 0); + display.print(perm); + } + + display.drawRect(0, 11, display.width(), 1); // divider + + // === Body - depends on state === + int bodyY = 14; + int footerY = display.height() - 12; + int bodyHeight = footerY - bodyY - 4; + + switch (_state) { + case STATE_PASSWORD_ENTRY: + renderPasswordEntry(display, bodyY); + break; + case STATE_LOGGING_IN: + renderWaiting(display, bodyY, "Logging in..."); + break; + case STATE_MENU: + renderMenu(display, bodyY, bodyHeight); + break; + case STATE_COMMAND_PENDING: + renderWaiting(display, bodyY, "Waiting..."); + break; + case STATE_RESPONSE_VIEW: + renderResponse(display, bodyY, bodyHeight); + break; + case STATE_ERROR: + renderResponse(display, bodyY, bodyHeight); // reuse response renderer for errors + break; + } + + // === Footer === + display.drawRect(0, footerY - 2, display.width(), 1); + display.setColor(DisplayDriver::YELLOW); + display.setTextSize(1); + display.setCursor(0, footerY); + + switch (_state) { + case STATE_PASSWORD_ENTRY: + display.print("Q:Back"); + { + const char* right = "Enter:Login"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); + } + break; + case STATE_LOGGING_IN: + case STATE_COMMAND_PENDING: + display.print("Q:Cancel"); + break; + case STATE_MENU: + display.print("Q:Back"); + { + const char* mid = "W/S:Sel"; + display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY); + display.print(mid); + const char* right = "Ent:Run"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); + } + break; + case STATE_RESPONSE_VIEW: + case STATE_ERROR: + display.print("Q:Menu"); + if (_responseLen > bodyHeight / 9) { // if scrollable + const char* right = "W/S:Scrll"; + display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); + display.print(right); + } + break; + } + + return (_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) ? 1000 : 5000; + } + + bool handleInput(char c) override { + switch (_state) { + case STATE_PASSWORD_ENTRY: + return handlePasswordInput(c); + case STATE_LOGGING_IN: + case STATE_COMMAND_PENDING: + // Q to cancel and go back + if (c == 'q' || c == 'Q') { + _state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_MENU; + return true; + } + return false; + case STATE_MENU: + return handleMenuInput(c); + case STATE_RESPONSE_VIEW: + case STATE_ERROR: + return handleResponseInput(c); + } + return false; + } + +private: + // --- Password Entry --- + void renderPasswordEntry(DisplayDriver& display, int y) { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, y); + display.print("Password:"); + + y += 14; + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, y); + + // Show asterisks for password characters + char masked[ADMIN_PASSWORD_MAX]; + int i; + for (i = 0; i < _pwdLen && i < ADMIN_PASSWORD_MAX - 1; i++) { + masked[i] = '*'; + } + masked[i] = '\0'; + display.print(masked); + + // Cursor indicator + display.print("_"); + } + + bool handlePasswordInput(char c) { + // Q without any password typed = go back (return false to signal "not handled") + if ((c == 'q' || c == 'Q') && _pwdLen == 0) { + return false; + } + + // Enter to submit + if (c == '\r' || c == '\n' || c == KEY_ENTER) { + if (_pwdLen > 0) { + return doLogin(); + } + return true; + } + + // Backspace + if (c == 0x08 || c == 0x7F) { + if (_pwdLen > 0) { + _pwdLen--; + _password[_pwdLen] = '\0'; + } + return true; + } + + // Printable character + if (c >= 32 && c < 127 && _pwdLen < ADMIN_PASSWORD_MAX - 1) { + _password[_pwdLen++] = c; + _password[_pwdLen] = '\0'; + return true; + } + + return false; + } + + bool doLogin(); // Defined below, calls into MyMesh + + // --- Menu --- + void renderMenu(DisplayDriver& display, int y, int bodyHeight) { + display.setTextSize(0); // tiny font for compact rows + int lineHeight = 9; + + // Show server time comparison if available + if (_serverTime > 0) { + char ourTime[12], srvTime[12]; + uint32_t now = _rtc->getCurrentTime(); + formatTime(ourTime, sizeof(ourTime), now); + formatTime(srvTime, sizeof(srvTime), _serverTime); + + int drift = (int)(now - _serverTime); + char driftStr[24]; + if (abs(drift) < 2) { + snprintf(driftStr, sizeof(driftStr), "Synced"); + } else { + snprintf(driftStr, sizeof(driftStr), "Drift:%+ds", drift); + } + + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, y); + char info[48]; + snprintf(info, sizeof(info), "Rpt:%s Us:%s %s", srvTime, ourTime, driftStr); + display.print(info); + y += lineHeight + 2; + } + + // Menu items + for (int i = 0; i < MENU_COUNT && y + lineHeight <= display.height() - 16; i++) { + bool selected = (i == _menuSel); + + 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(2, y); + char label[32]; + snprintf(label, sizeof(label), "%s %s", selected ? ">" : " ", menuLabel((MenuItem)i)); + display.print(label); + + y += lineHeight; + } + + display.setTextSize(1); + } + + bool handleMenuInput(char c) { + // W/up - scroll up + if (c == 'w' || c == 'W' || c == 0xF2) { + if (_menuSel > 0) _menuSel--; + return true; + } + // S/down - scroll down + if (c == 's' || c == 'S' || c == 0xF1) { + if (_menuSel < MENU_COUNT - 1) _menuSel++; + return true; + } + // Enter - execute selected command + if (c == '\r' || c == '\n' || c == KEY_ENTER) { + return executeMenuCommand((MenuItem)_menuSel); + } + // Q - back to contacts + if (c == 'q' || c == 'Q') { + return false; // let UITask handle back navigation + } + // Number keys for quick selection + if (c >= '1' && c <= '0' + MENU_COUNT) { + _menuSel = c - '1'; + return executeMenuCommand((MenuItem)_menuSel); + } + return false; + } + + bool executeMenuCommand(MenuItem item); // Defined below, calls into MyMesh + + // --- Response View --- + void renderResponse(DisplayDriver& display, int y, int bodyHeight) { + display.setTextSize(0); // tiny font for more content + int lineHeight = 9; + + display.setColor((_state == STATE_ERROR) ? DisplayDriver::YELLOW : DisplayDriver::LIGHT); + + // Render response text with word wrapping and scroll support + int maxLines = bodyHeight / lineHeight; + int lineCount = 0; + int skipLines = _responseScroll; + + const char* p = _response; + char lineBuf[80]; + int lineWidth = display.width() - 4; + + while (*p && lineCount < maxLines + skipLines) { + // Extract next line (up to newline or screen width) + int i = 0; + while (*p && *p != '\n' && i < 79) { + lineBuf[i++] = *p++; + } + lineBuf[i] = '\0'; + if (*p == '\n') p++; + + if (lineCount >= skipLines && lineCount < skipLines + maxLines) { + display.setCursor(2, y); + display.print(lineBuf); + y += lineHeight; + } + lineCount++; + } + + display.setTextSize(1); + } + + bool handleResponseInput(char c) { + // W/up - scroll up + if (c == 'w' || c == 'W' || c == 0xF2) { + if (_responseScroll > 0) { + _responseScroll--; + return true; + } + } + // S/down - scroll down + if (c == 's' || c == 'S' || c == 0xF1) { + _responseScroll++; + return true; + } + // Q - back to menu (or back to password on error) + if (c == 'q' || c == 'Q') { + if (_state == STATE_ERROR && _permissions == 0) { + // Not yet logged in, go back to password + _state = STATE_PASSWORD_ENTRY; + } else { + _state = STATE_MENU; + } + return true; + } + // Enter - also go back to menu + if (c == '\r' || c == '\n' || c == KEY_ENTER) { + _state = STATE_MENU; + return true; + } + return false; + } + + // --- Waiting spinner --- + void renderWaiting(DisplayDriver& display, int y, const char* msg) { + display.setTextSize(1); + display.setColor(DisplayDriver::YELLOW); + + int cx = (display.width() - display.getTextWidth(msg)) / 2; + int cy = y + 20; + display.setCursor(cx, cy); + display.print(msg); + + // Show elapsed time + if (_cmdSentAt > 0) { + char elapsed[16]; + unsigned long secs = (millis() - _cmdSentAt) / 1000; + snprintf(elapsed, sizeof(elapsed), "%lus", secs); + display.setColor(DisplayDriver::LIGHT); + display.setCursor((display.width() - display.getTextWidth(elapsed)) / 2, cy + 14); + display.print(elapsed); + } + } +}; + +// --- Implementations that require MyMesh (the_mesh is declared extern above) --- + +inline bool RepeaterAdminScreen::doLogin() { + if (_contactIdx < 0 || _pwdLen == 0) return false; + + if (the_mesh.uiLoginToRepeater(_contactIdx, _password)) { + _state = STATE_LOGGING_IN; + _cmdSentAt = millis(); + _waitingForLogin = true; + return true; + } else { + snprintf(_response, sizeof(_response), "Send failed.\nCheck contact path."); + _responseLen = strlen(_response); + _state = STATE_ERROR; + return true; + } +} + +inline bool RepeaterAdminScreen::executeMenuCommand(MenuItem item) { + if (_contactIdx < 0) return false; + + const char* cmd = menuCommand(item); + if (cmd[0] == '\0') return false; + + if (the_mesh.uiSendCliCommand(_contactIdx, cmd)) { + _state = STATE_COMMAND_PENDING; + _cmdSentAt = millis(); + _response[0] = '\0'; + _responseLen = 0; + _responseScroll = 0; + return true; + } else { + snprintf(_response, sizeof(_response), "Send failed."); + _responseLen = strlen(_response); + _state = STATE_ERROR; + return true; + } +} \ 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 1cc44f1..b1c80b1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2,6 +2,7 @@ #include #include "../MyMesh.h" #include "target.h" +#include "RepeaterAdminScreen.h" #ifdef WIFI_SSID #include #endif @@ -608,6 +609,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no channel_screen = new ChannelScreen(this, &rtc_clock); contacts_screen = new ContactsScreen(this, &rtc_clock); text_reader = new TextReaderScreen(this); + repeater_admin = new RepeaterAdminScreen(this, &rtc_clock); setCurrScreen(splash); } @@ -1043,4 +1045,38 @@ void UITask::addSentChannelMessage(uint8_t channel_idx, const char* sender, cons // Add to channel history with path_len=0 (local message) ((ChannelScreen *) channel_screen)->addMessage(channel_idx, 0, sender, formattedMsg); +} + +void UITask::gotoRepeaterAdmin(int contactIdx) { + // Get contact name for the screen header + ContactInfo contact; + char name[32] = "Unknown"; + if (the_mesh.getContactByIdx(contactIdx, contact)) { + strncpy(name, contact.name, sizeof(name) - 1); + name[sizeof(name) - 1] = '\0'; + } + + RepeaterAdminScreen* admin = (RepeaterAdminScreen*)repeater_admin; + admin->openForContact(contactIdx, name); + setCurrScreen(repeater_admin); + + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} + +void UITask::onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) { + if (isOnRepeaterAdmin()) { + ((RepeaterAdminScreen*)repeater_admin)->onLoginResult(success, permissions, server_time); + _next_refresh = 100; // trigger re-render + } +} + +void UITask::onAdminCliResponse(const char* from_name, const char* text) { + if (isOnRepeaterAdmin()) { + ((RepeaterAdminScreen*)repeater_admin)->onCliResponse(text); + _next_refresh = 100; // trigger re-render + } } \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 00c2151..1afd051 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -54,6 +54,7 @@ class UITask : public AbstractUITask { UIScreen* channel_screen; // Channel message history screen UIScreen* contacts_screen; // Contacts list screen UIScreen* text_reader; // *** NEW: Text reader screen *** + UIScreen* repeater_admin; // Repeater admin screen UIScreen* curr; void userLedHandler(); @@ -79,6 +80,7 @@ public: void gotoChannelScreen(); // Navigate to channel message screen void gotoContactsScreen(); // Navigate to contacts list void gotoTextReader(); // *** NEW: Navigate to text reader *** + void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin void showAlert(const char* text, int duration_millis) override; void forceRefresh() override { _next_refresh = 100; } int getMsgCount() const { return _msgcount; } @@ -87,6 +89,7 @@ public: bool isOnChannelScreen() const { return curr == channel_screen; } bool isOnContactsScreen() const { return curr == contacts_screen; } bool isOnTextReader() const { return curr == text_reader; } // *** NEW *** + bool isOnRepeaterAdmin() const { return curr == repeater_admin; } uint8_t getChannelScreenViewIdx() const; void toggleBuzzer(); @@ -98,12 +101,17 @@ public: // Add a sent message to the channel screen history void addSentChannelMessage(uint8_t channel_idx, const char* sender, const char* text) override; + + // Repeater admin callbacks + void onAdminLoginResult(bool success, uint8_t permissions, uint32_t server_time) override; + void onAdminCliResponse(const char* from_name, const char* text) override; // Get current screen for checking state UIScreen* getCurrentScreen() const { return curr; } UIScreen* getMsgPreviewScreen() const { return msg_preview; } UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW *** UIScreen* getContactsScreen() const { return contacts_screen; } + UIScreen* getRepeaterAdminScreen() const { return repeater_admin; } // from AbstractUITask void msgRead(int msgcount) override;