diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 7e85823..c05a9db 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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); + + // Forward CLI response to UI admin screen if admin session is active + #ifdef DISPLAY_CLASS + if (_admin_contact_idx >= 0 && _ui) { + _ui->onAdminCliResponse(from.name, text); + } + #endif } void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, @@ -650,6 +657,53 @@ 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 +762,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 +780,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 +966,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/main.cpp b/examples/companion_radio/main.cpp index b9aeb7e..f47385f 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -15,6 +15,7 @@ #include "ContactsScreen.h" #include "ChannelScreen.h" #include "SettingsScreen.h" + #include "RepeaterAdminScreen.h" extern SPIClass displaySpi; // From GxEPDDisplay.cpp, shared SPI bus TCA8418Keyboard keyboard(I2C_ADDR_KEYBOARD, &Wire); @@ -327,7 +328,7 @@ void setup() { } MESH_DEBUG_PRINTLN("setup() - radio_init() done"); - // CPU frequency scaling — drop to 80 MHz for idle mesh listening + // CPU frequency scaling — drop to 80 MHz for idle mesh listening cpuPower.begin(); MESH_DEBUG_PRINTLN("setup() - about to call fast_rng.begin()"); @@ -1049,6 +1050,56 @@ void handleKeyboardInput() { return; } + // *** REPEATER ADMIN MODE *** + if (ui_task.isOnRepeaterAdmin()) { + RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen(); + RepeaterAdminScreen::AdminState astate = admin->getState(); + bool shiftDel = (key == '\b' && keyboard.wasShiftConsumed()); + + // In password entry: Shift+Del exits, all other keys pass through normally + if (astate == RepeaterAdminScreen::STATE_PASSWORD_ENTRY) { + if (shiftDel) { + Serial.println("Nav: Back to contacts from admin login"); + ui_task.gotoContactsScreen(); + } else { + ui_task.injectKey(key); + } + return; + } + + // In menu state: Shift+Del exits to contacts, C opens compose + if (astate == RepeaterAdminScreen::STATE_MENU) { + if (shiftDel) { + 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: convert Shift+Del to exit signal, + // pass all other keys through + if (shiftDel) { + ui_task.injectKey(KEY_ADMIN_EXIT); + } else { + ui_task.injectKey(key); + } + return; + } + // Normal mode - not composing switch (key) { case 'c': @@ -1099,8 +1150,8 @@ void handleKeyboardInput() { break; case 's': - // Open settings (from home), or navigate down on channel/contacts - if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) { + // Open settings (from home), or navigate down on channel/contacts/admin + if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen() || ui_task.isOnRepeaterAdmin()) { ui_task.injectKey('s'); // Pass directly for channel/contacts scrolling } else { Serial.println("Opening settings"); @@ -1110,7 +1161,7 @@ void handleKeyboardInput() { 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"); @@ -1139,7 +1190,8 @@ void handleKeyboardInput() { break; case '\r': - // Enter = compose (only from channel or contacts screen) + // 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(); @@ -1154,8 +1206,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) { - 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 if (ui_task.isOnChannelScreen()) { composeDM = false; @@ -1175,7 +1234,7 @@ void handleKeyboardInput() { 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 index b01dc73..a57c259 100644 --- a/examples/companion_radio/ui-new/Repeateradminscreen.h +++ b/examples/companion_radio/ui-new/Repeateradminscreen.h @@ -12,6 +12,7 @@ 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 +#define KEY_ADMIN_EXIT 0xFE // Special key: Shift+Backspace exit (injected by main.cpp) class RepeaterAdminScreen : public UIScreen { public: @@ -280,31 +281,34 @@ public: switch (_state) { case STATE_PASSWORD_ENTRY: - display.print("Q:Back"); + display.print("Sh+Del:Exit"); { - const char* right = "Enter:Login"; + const char* right = "Ent: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"); + display.print("Sh+Del:Cancel"); break; case STATE_MENU: - display.print("Q:Back"); + display.print("Sh+Del:Exit"); { 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); + int leftEnd = display.getTextWidth("Sh+Del:Exit") + 2; + int rightStart = display.width() - display.getTextWidth(right) - 2; + int midX = leftEnd + (rightStart - leftEnd - display.getTextWidth(mid)) / 2; + display.setCursor(midX, footerY); + display.print(mid); + display.setCursor(rightStart, footerY); display.print(right); } break; case STATE_RESPONSE_VIEW: case STATE_ERROR: - display.print("Q:Menu"); + display.print("Sh+Del:Menu"); if (_responseLen > bodyHeight / 9) { // if scrollable const char* right = "W/S:Scrll"; display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); @@ -327,8 +331,8 @@ public: return handlePasswordInput(c); case STATE_LOGGING_IN: case STATE_COMMAND_PENDING: - // Q to cancel and go back - if (c == 'q' || c == 'Q') { + // Shift+Del to cancel and go back + if (c == KEY_ADMIN_EXIT) { _state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_MENU; return true; } @@ -370,9 +374,9 @@ private: } 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; + // Shift+Del = exit (always, regardless of password content) + if (c == KEY_ADMIN_EXIT) { + return false; // signal main.cpp to navigate back } // Enter to submit @@ -472,8 +476,8 @@ private: if (c == '\r' || c == '\n' || c == KEY_ENTER) { return executeMenuCommand((MenuItem)_menuSel); } - // Q - back to contacts - if (c == 'q' || c == 'Q') { + // Shift+Del - back to contacts + if (c == KEY_ADMIN_EXIT) { return false; // let UITask handle back navigation } // Number keys for quick selection @@ -535,8 +539,8 @@ private: _responseScroll++; return true; } - // Q - back to menu (or back to password on error) - if (c == 'q' || c == 'Q') { + // Shift+Del - back to menu (or back to password on error) + if (c == KEY_ADMIN_EXIT) { if (_state == STATE_ERROR && _permissions == 0) { // Not yet logged in, go back to password _state = STATE_PASSWORD_ENTRY; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bc0371b..9015085 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 "NotesScreen.h" +#include "RepeaterAdminScreen.h" #include "target.h" #include "GPSDutyCycle.h" #ifdef WIFI_SSID @@ -772,6 +773,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no text_reader = new TextReaderScreen(this); notes_screen = new NotesScreen(this); settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs); + repeater_admin = nullptr; // Lazy-initialized on first use to preserve heap for audio audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present setCurrScreen(splash); } @@ -1263,4 +1265,43 @@ 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) { + // Lazy-initialize on first use (same pattern as audiobook player) + if (repeater_admin == nullptr) { + repeater_admin = new RepeaterAdminScreen(this, &rtc_clock); + } + + // 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 (repeater_admin && 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 (repeater_admin && 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 88c1779..97b6134 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -57,6 +57,7 @@ class UITask : public AbstractUITask { UIScreen* notes_screen; // Notes editor screen UIScreen* settings_screen; // Settings/onboarding screen UIScreen* audiobook_screen; // Audiobook player screen (null if not available) + UIScreen* repeater_admin; // Repeater admin screen UIScreen* curr; void userLedHandler(); @@ -86,6 +87,7 @@ public: void gotoSettingsScreen(); // Navigate to settings void gotoOnboarding(); // Navigate to settings in onboarding mode void gotoAudiobookPlayer(); // Navigate to audiobook player + 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; } @@ -97,6 +99,7 @@ public: bool isOnNotesScreen() const { return curr == notes_screen; } bool isOnSettingsScreen() const { return curr == settings_screen; } bool isOnAudiobookPlayer() const { return curr == audiobook_screen; } + bool isOnRepeaterAdmin() const { return curr == repeater_admin; } uint8_t getChannelScreenViewIdx() const; void toggleBuzzer(); @@ -111,6 +114,10 @@ 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; } @@ -122,6 +129,7 @@ public: UIScreen* getSettingsScreen() const { return settings_screen; } UIScreen* getAudiobookScreen() const { return audiobook_screen; } void setAudiobookScreen(UIScreen* s) { audiobook_screen = s; } + UIScreen* getRepeaterAdminScreen() const { return repeater_admin; } // from AbstractUITask void msgRead(int msgcount) override;