diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 7c8fe830..e5da075d 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1484,8 +1484,8 @@ void handleKeyboardInput() { return; } - // In menu state: Shift+Del exits to contacts, C opens compose - if (astate == RepeaterAdminScreen::STATE_MENU) { + // In category menu (top level): Shift+Del exits to contacts, C opens compose + if (astate == RepeaterAdminScreen::STATE_CATEGORY_MENU) { if (shiftDel) { Serial.println("Nav: Back to contacts from admin menu"); ui_task.gotoContactsScreen(); @@ -1507,8 +1507,9 @@ void handleKeyboardInput() { return; } - // In waiting/response/error states: convert Shift+Del to exit signal, - // pass all other keys through + // All other states (command menu, param entry, confirm, waiting, + // response, error): convert Shift+Del to exit signal and let the + // screen handle back-navigation internally if (shiftDel) { ui_task.injectKey(KEY_ADMIN_EXIT); } else { diff --git a/examples/companion_radio/ui-new/Repeateradminscreen.h b/examples/companion_radio/ui-new/Repeateradminscreen.h index a57c2597..694b9d19 100644 --- a/examples/companion_radio/ui-new/Repeateradminscreen.h +++ b/examples/companion_radio/ui-new/Repeateradminscreen.h @@ -10,32 +10,160 @@ class MyMesh; extern MyMesh the_mesh; #define ADMIN_PASSWORD_MAX 32 -#define ADMIN_RESPONSE_MAX 512 // CLI responses can be multi-line +#define ADMIN_PARAM_MAX 64 // Max length for set-command parameter input +#define ADMIN_RESPONSE_MAX 1024 // CLI responses can be multi-line / large neighbor lists #define ADMIN_TIMEOUT_MS 15000 // 15s timeout for login/commands #define KEY_ADMIN_EXIT 0xFE // Special key: Shift+Backspace exit (injected by main.cpp) +// --- Command definition flags --- +#define CMDF_PARAM 0x01 // Command needs user parameter input appended +#define CMDF_CONFIRM 0x02 // Needs y/n confirmation before send +#define CMDF_EXPECT_TIMEOUT 0x04 // Timeout is expected success (reboot/OTA) +#define CMDF_PARSE_NEIGHBORS 0x08 // Response should be parsed as neighbor list + +// --- Single command definition --- +struct AdminCmdDef { + const char* label; // Menu display label + const char* cmd; // CLI command string (full, or prefix if CMDF_PARAM) + const char* paramHint; // Prompt for parameter input (NULL if not needed) + uint8_t flags; +}; + +// ===================================================================== +// Command tables per category +// ===================================================================== + +// --- Clock & Adverts --- +static const AdminCmdDef CMD_OPERATIONAL[] = { + { "Clock Sync", "clock sync", nullptr, 0 }, + { "Send Advert", "advert", nullptr, 0 }, + { "Get Clock", "clock", nullptr, 0 }, +}; +#define CMD_OPERATIONAL_COUNT 3 + +// --- Neighbors --- +static const AdminCmdDef CMD_NEIGHBORS[] = { + { "View Neighbors", "neighbors", nullptr, CMDF_PARSE_NEIGHBORS }, + { "Remove Neighbor", "neighbor.remove ", "Pubkey hex prefix:", CMDF_PARAM }, +}; +#define CMD_NEIGHBORS_COUNT 2 + +// --- Info --- +static const AdminCmdDef CMD_INFO[] = { + { "Version", "ver", nullptr, 0 }, + { "Board", "board", nullptr, 0 }, +}; +#define CMD_INFO_COUNT 2 + +// --- Get Config (read-only queries) --- +static const AdminCmdDef CMD_GET_CONFIG[] = { + { "Get Name", "get name", nullptr, 0 }, + { "Get TX Power", "get tx", nullptr, 0 }, + { "Get AF", "get af", nullptr, 0 }, + { "Get Repeat", "get repeat", nullptr, 0 }, + { "Get Radio", "get radio", nullptr, 0 }, + { "Get Flood Max", "get flood.max", nullptr, 0 }, + { "Get RX Delay", "get rxdelay", nullptr, 0 }, + { "Get TX Delay", "get txdelay", nullptr, 0 }, + { "Get Direct TX Delay", "get direct.txdelay", nullptr, 0 }, + { "Get Int Thresh", "get int.thresh", nullptr, 0 }, + { "Get AGC Reset Int", "get agc.reset.interval", nullptr, 0 }, + { "Get Multi Acks", "get multi.acks", nullptr, 0 }, + { "Get Advert Int", "get advert.interval", nullptr, 0 }, + { "Get Flood Adv Int", "get flood.advert.interval", nullptr, 0 }, + { "Get Guest Password", "get guest.password", nullptr, 0 }, + { "Get Allow R/O", "get allow.read.only", nullptr, 0 }, + { "Get ADC Multiplier", "get adc.multiplier", nullptr, 0 }, +}; +#define CMD_GET_CONFIG_COUNT 17 + +// --- Set Config (write commands, all need parameter input) --- +static const AdminCmdDef CMD_SET_CONFIG[] = { + { "Set Name", "set name ", "Name:", CMDF_PARAM }, + { "Set TX Power", "set tx ", "TX power (dBm):", CMDF_PARAM }, + { "Set AF", "set af ", "Airtime factor:", CMDF_PARAM }, + { "Set Repeat", "set repeat ", "on/off:", CMDF_PARAM }, + { "Set Flood Max", "set flood.max ", "Max hops (0-64):", CMDF_PARAM }, + { "Set RX Delay", "set rxdelay ", "Base (0=off):", CMDF_PARAM }, + { "Set TX Delay", "set txdelay ", "Factor:", CMDF_PARAM }, + { "Set Direct TX Delay", "set direct.txdelay ", "Factor:", CMDF_PARAM }, + { "Set Int Thresh", "set int.thresh ", "dB (0=off, def 14):", CMDF_PARAM }, + { "Set AGC Reset Int", "set agc.reset.interval ", "Seconds (0=off):", CMDF_PARAM }, + { "Set Multi Acks", "set multi.acks ", "0 or 1:", CMDF_PARAM }, + { "Set Advert Interval", "set advert.interval ", "Minutes (0=off):", CMDF_PARAM }, + { "Set Flood Adv Int", "set flood.advert.interval ", "Hours (0=off, 3-48):", CMDF_PARAM }, + { "Set Guest Password", "set guest.password ", "Password:", CMDF_PARAM }, + { "Set Allow R/O", "set allow.read.only ", "on/off:", CMDF_PARAM }, + { "Set ADC Multiplier", "set adc.multiplier ", "Factor (0=default):", CMDF_PARAM }, + { "Set Radio", "set radio ", "freq,bw,sf,cr:", CMDF_PARAM }, + { "Temp Radio", "tempradio ", "freq,bw,sf,cr,mins:", CMDF_PARAM }, + { "Change Admin Pwd", "password ", "New password:", CMDF_PARAM | CMDF_CONFIRM }, +}; +#define CMD_SET_CONFIG_COUNT 19 + +// --- Power --- +static const AdminCmdDef CMD_POWER[] = { + { "Powersaving Status", "powersaving", nullptr, 0 }, + { "Powersaving On", "powersaving on", nullptr, 0 }, + { "Powersaving Off", "powersaving off", nullptr, 0 }, +}; +#define CMD_POWER_COUNT 3 + +// --- System --- +static const AdminCmdDef CMD_SYSTEM[] = { + { "Reboot", "reboot", nullptr, CMDF_CONFIRM | CMDF_EXPECT_TIMEOUT }, + { "Start OTA", "start ota", nullptr, CMDF_CONFIRM | CMDF_EXPECT_TIMEOUT }, +}; +#define CMD_SYSTEM_COUNT 2 + +// ===================================================================== +// Category table +// ===================================================================== +enum AdminCategory { + CAT_OPERATIONAL = 0, + CAT_NEIGHBORS, + CAT_GET_CONFIG, + CAT_SET_CONFIG, + CAT_POWER, + CAT_REBOOT_OTA, + CAT_FW_INFO, + CAT_COUNT +}; + +struct AdminCategoryDef { + const char* label; + const AdminCmdDef* cmds; + int count; +}; + +static const AdminCategoryDef CATEGORIES[CAT_COUNT] = { + { "Clock & Adverts", CMD_OPERATIONAL, CMD_OPERATIONAL_COUNT }, + { "Neighbors", CMD_NEIGHBORS, CMD_NEIGHBORS_COUNT }, + { "Get Config", CMD_GET_CONFIG, CMD_GET_CONFIG_COUNT }, + { "Set Config", CMD_SET_CONFIG, CMD_SET_CONFIG_COUNT }, + { "Powersaving", CMD_POWER, CMD_POWER_COUNT }, + { "Reboot & Start OTA", CMD_SYSTEM, CMD_SYSTEM_COUNT }, + { "Firmware & Device Info", CMD_INFO, CMD_INFO_COUNT }, +}; + +// ===================================================================== +// Screen class +// ===================================================================== + 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_CATEGORY_MENU, // Top-level category selection + STATE_COMMAND_MENU, // Commands within selected category + STATE_PARAM_ENTRY, // Typing parameter for a set command + STATE_CONFIRM, // Confirmation prompt for destructive commands 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; @@ -49,21 +177,29 @@ private: // Password entry char _password[ADMIN_PASSWORD_MAX]; int _pwdLen; - unsigned long _lastCharAt; // millis() when last char typed (for brief reveal) + unsigned long _lastCharAt; // millis() when last char typed (for brief reveal) - // Menu - int _menuSel; // Currently selected menu item + // Menu navigation + int _catSel; // Selected category index + int _cmdSel; // Selected command index within category + int _scrollOffset; // Scroll offset for long command lists + + // Parameter entry for set commands + char _paramBuf[ADMIN_PARAM_MAX]; + int _paramLen; + const AdminCmdDef* _pendingCmd; // The command awaiting parameter / confirmation // Response buffer char _response[ADMIN_RESPONSE_MAX]; int _responseLen; - int _responseScroll; // Scroll offset for long responses + int _responseScroll; + int _responseTotalLines; // Timing - unsigned long _cmdSentAt; // millis() when command was sent + unsigned long _cmdSentAt; bool _waitingForLogin; - // Password cache - remembers passwords per repeater within session + // Password cache static const int PWD_CACHE_SIZE = 8; struct PwdCacheEntry { int contactIdx; @@ -72,7 +208,6 @@ private: PwdCacheEntry _pwdCache[PWD_CACHE_SIZE]; int _pwdCacheCount; - // Look up cached password for a contact, returns nullptr if not found const char* getCachedPassword(int contactIdx) { for (int i = 0; i < _pwdCacheCount; i++) { if (_pwdCache[i].contactIdx == contactIdx) return _pwdCache[i].password; @@ -80,9 +215,7 @@ private: return nullptr; } - // Save password to cache (update existing or add new, evict oldest if full) void cachePassword(int contactIdx, const char* pwd) { - // Update existing entry for (int i = 0; i < _pwdCacheCount; i++) { if (_pwdCache[i].contactIdx == contactIdx) { strncpy(_pwdCache[i].password, pwd, ADMIN_PASSWORD_MAX - 1); @@ -90,89 +223,189 @@ private: return; } } - // Add new entry, evict oldest if full if (_pwdCacheCount < PWD_CACHE_SIZE) { int slot = _pwdCacheCount++; _pwdCache[slot].contactIdx = contactIdx; strncpy(_pwdCache[slot].password, pwd, ADMIN_PASSWORD_MAX - 1); _pwdCache[slot].password[ADMIN_PASSWORD_MAX - 1] = '\0'; } else { - // Shift entries down to evict oldest - for (int i = 0; i < PWD_CACHE_SIZE - 1; i++) { - _pwdCache[i] = _pwdCache[i + 1]; - } + for (int i = 0; i < PWD_CACHE_SIZE - 1; i++) _pwdCache[i] = _pwdCache[i + 1]; _pwdCache[PWD_CACHE_SIZE - 1].contactIdx = contactIdx; strncpy(_pwdCache[PWD_CACHE_SIZE - 1].password, pwd, ADMIN_PASSWORD_MAX - 1); _pwdCache[PWD_CACHE_SIZE - 1].password[ADMIN_PASSWORD_MAX - 1] = '\0'; } } - 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 "?"; - } - } + // --- Helpers --- - 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; - } + 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); } + static void formatRelativeTime(char* buf, size_t bufLen, uint32_t seconds) { + if (seconds < 60) { + snprintf(buf, bufLen, "%ds ago", (int)seconds); + } else if (seconds < 3600) { + snprintf(buf, bufLen, "%dm ago", (int)(seconds / 60)); + } else if (seconds < 86400) { + int h = seconds / 3600; + int m = (seconds % 3600) / 60; + if (m > 0) snprintf(buf, bufLen, "%dh%dm ago", h, m); + else snprintf(buf, bufLen, "%dh ago", h); + } else { + snprintf(buf, bufLen, "%dd%dh ago", (int)(seconds / 86400), (int)((seconds % 86400) / 3600)); + } + } + + int countLines(const char* text) { + int lines = 0; + const char* p = text; + while (*p) { + while (*p && *p != '\n') p++; + if (*p == '\n') p++; + lines++; + } + return lines; + } + + // Parse raw neighbor response into formatted display text + void parseNeighborResponse(const char* raw) { + char* dp = _response; + int remaining = ADMIN_RESPONSE_MAX - 1; + uint32_t now = _rtc->getCurrentTime(); + int count = 0; + + const char* p = raw; + while (*p && remaining > 80) { + char line[128]; + int li = 0; + while (*p && *p != '\n' && li < 127) line[li++] = *p++; + line[li] = '\0'; + if (*p == '\n') p++; + if (li == 0) continue; + + // Parse: {hex}:{timestamp}:{snr*4} + char hexPart[20] = {0}; + uint32_t timestamp = 0; + int snrX4 = 0; + + char* sep1 = strchr(line, ':'); + if (!sep1) { + int n = snprintf(dp, remaining, "%s\n", line); + dp += n; remaining -= n; count++; + continue; + } + + int hexLen = sep1 - line; + if (hexLen > 19) hexLen = 19; + memcpy(hexPart, line, hexLen); + hexPart[hexLen] = '\0'; + + char* afterSep1 = sep1 + 1; + char* sep2 = strchr(afterSep1, ':'); + if (sep2) { + *sep2 = '\0'; + timestamp = strtoul(afterSep1, NULL, 10); + snrX4 = atoi(sep2 + 1); + } else { + timestamp = strtoul(afterSep1, NULL, 10); + } + + // Resolve hex prefix to contact name + const char* nodeName = NULL; + uint8_t pubkeyBytes[PUB_KEY_SIZE]; + int byteLen = hexLen / 2; + if (byteLen > 0 && byteLen <= PUB_KEY_SIZE) { + if (mesh::Utils::fromHex(pubkeyBytes, byteLen, hexPart)) { + ContactInfo* contact = the_mesh.lookupContactByPubKey(pubkeyBytes, byteLen); + if (contact && contact->name[0] != '\0') nodeName = contact->name; + } + } + + // Format SNR (snr*4 encoded) + char snrStr[12]; + if (snrX4 % 4 == 0) { + snprintf(snrStr, sizeof(snrStr), "%ddB", snrX4 / 4); + } else { + snprintf(snrStr, sizeof(snrStr), "%d.%ddB", snrX4 / 4, ((abs(snrX4) % 4) * 10) / 4); + } + + // Format relative time + char timeStr[24]; + if (timestamp > 0 && now > timestamp) { + formatRelativeTime(timeStr, sizeof(timeStr), now - timestamp); + } else if (timestamp > 0) { + strncpy(timeStr, "just now", sizeof(timeStr)); + } else { + strncpy(timeStr, "?", sizeof(timeStr)); + } + + int n; + if (nodeName) { + n = snprintf(dp, remaining, "%.18s %s %s\n", nodeName, snrStr, timeStr); + } else { + n = snprintf(dp, remaining, "<%s> %s %s\n", hexPart, snrStr, timeStr); + } + dp += n; remaining -= n; count++; + } + + // Remove trailing newline + if (dp > _response && *(dp - 1) == '\n') { *(dp - 1) = '\0'; dp--; } + + // Prepend count header + char header[48]; + snprintf(header, sizeof(header), "Neighbors: %d\n", count); + int headerLen = strlen(header); + int contentLen = dp - _response; + if (headerLen + contentLen < ADMIN_RESPONSE_MAX - 1) { + memmove(_response + headerLen, _response, contentLen + 1); + memcpy(_response, header, headerLen); + _responseLen = headerLen + contentLen; + } else { + _responseLen = contentLen; + } + } + public: RepeaterAdminScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc), _state(STATE_PASSWORD_ENTRY), _contactIdx(-1), _permissions(0), _serverTime(0), - _pwdLen(0), _lastCharAt(0), _menuSel(0), - _responseLen(0), _responseScroll(0), + _pwdLen(0), _lastCharAt(0), + _catSel(0), _cmdSel(0), _scrollOffset(0), + _paramLen(0), _pendingCmd(nullptr), + _responseLen(0), _responseScroll(0), _responseTotalLines(0), _cmdSentAt(0), _waitingForLogin(false), _pwdCacheCount(0) { _password[0] = '\0'; _repeaterName[0] = '\0'; _response[0] = '\0'; + _paramBuf[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; _lastCharAt = 0; - _menuSel = 0; + _catSel = 0; + _cmdSel = 0; + _scrollOffset = 0; _permissions = 0; _serverTime = 0; _responseLen = 0; _responseScroll = 0; + _responseTotalLines = 0; _response[0] = '\0'; _waitingForLogin = false; + _pendingCmd = nullptr; + _paramLen = 0; + _paramBuf[0] = '\0'; - // Pre-fill from password cache if available const char* cached = getCachedPassword(contactIdx); if (cached) { strncpy(_password, cached, ADMIN_PASSWORD_MAX - 1); @@ -187,14 +420,13 @@ public: 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; - cachePassword(_contactIdx, _password); // remember for next time + _state = STATE_CATEGORY_MENU; + cachePassword(_contactIdx, _password); } else { snprintf(_response, sizeof(_response), "Login failed.\nCheck password."); _responseLen = strlen(_response); @@ -202,78 +434,85 @@ public: } } - // Called by UITask when a CLI response is received void onCliResponse(const char* text) { if (_state != STATE_COMMAND_PENDING) return; + // Special parsing for neighbor responses + if (_pendingCmd && (_pendingCmd->flags & CMDF_PARSE_NEIGHBORS)) { + parseNeighborResponse(text); + _responseScroll = 0; + _responseTotalLines = countLines(_response); + _state = STATE_RESPONSE_VIEW; + 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; + _responseTotalLines = countLines(_response); _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; + if (_pendingCmd && (_pendingCmd->flags & CMDF_EXPECT_TIMEOUT)) { + snprintf(_response, sizeof(_response), "Command sent.\nTimeout is expected\n(device is rebooting/updating)."); + _responseLen = strlen(_response); + _responseTotalLines = countLines(_response); + _state = STATE_RESPONSE_VIEW; + } else { + snprintf(_response, sizeof(_response), "Timeout - no response."); + _responseLen = strlen(_response); + _state = STATE_ERROR; + } } } + // ===================================================================== + // Render + // ===================================================================== + int render(DisplayDriver& display) override { char tmp[64]; - // === Header === + // --- 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" : + if (_state >= STATE_CATEGORY_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 + display.drawRect(0, 11, display.width(), 1); - // === Body - depends on state === int bodyY = 14; int footerY = display.height() - 12; int bodyHeight = footerY - bodyY - 4; + // --- Body --- 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; + case STATE_PASSWORD_ENTRY: renderPasswordEntry(display, bodyY); break; + case STATE_LOGGING_IN: renderWaiting(display, bodyY, "Logging in..."); break; + case STATE_CATEGORY_MENU: renderCategoryMenu(display, bodyY, bodyHeight); break; + case STATE_COMMAND_MENU: renderCommandMenu(display, bodyY, bodyHeight); break; + case STATE_PARAM_ENTRY: renderParamEntry(display, bodyY); break; + case STATE_CONFIRM: renderConfirm(display, bodyY); 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); break; } - // === Footer === + // --- Footer --- display.drawRect(0, footerY - 2, display.width(), 1); display.setColor(DisplayDriver::YELLOW); display.setTextSize(1); @@ -282,72 +521,97 @@ public: switch (_state) { case STATE_PASSWORD_ENTRY: display.print("Sh+Del:Exit"); - { - const char* right = "Ent:Login"; - display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY); - display.print(right); - } + renderFooterRight(display, footerY, "Ent:Login"); break; + case STATE_LOGGING_IN: case STATE_COMMAND_PENDING: display.print("Sh+Del:Cancel"); break; - case STATE_MENU: + + case STATE_CATEGORY_MENU: display.print("Sh+Del:Exit"); - { - const char* mid = "W/S:Sel"; - const char* right = "Ent:Run"; - 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); - } + renderFooterMidRight(display, footerY, "Sh+Del:Exit", "Ent:Open", "W/S:Sel"); break; + + case STATE_COMMAND_MENU: + display.print("Sh+Del:Back"); + renderFooterMidRight(display, footerY, "Sh+Del:Back", "Ent:Run", "W/S:Sel"); + break; + + case STATE_PARAM_ENTRY: + display.print("Sh+Del:Cancel"); + renderFooterRight(display, footerY, "Ent:Send"); + break; + + case STATE_CONFIRM: + display.print("Sh+Del:No"); + renderFooterRight(display, footerY, "Ent:Yes"); + break; + case STATE_RESPONSE_VIEW: case STATE_ERROR: - 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); - display.print(right); + display.print("Sh+Del:Back"); + if (_responseTotalLines > bodyHeight / 9) { + renderFooterRight(display, footerY, "W/S:Scrll"); } break; } if (_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) return 1000; - // During password reveal, refresh when the reveal expires if (_state == STATE_PASSWORD_ENTRY && _lastCharAt > 0 && (millis() - _lastCharAt) < 800) { - return _lastCharAt + 800 - millis() + 50; // refresh shortly after reveal ends + return _lastCharAt + 800 - millis() + 50; } return 5000; } + // ===================================================================== + // Input dispatch + // ===================================================================== + bool handleInput(char c) override { switch (_state) { - case STATE_PASSWORD_ENTRY: - return handlePasswordInput(c); + case STATE_PASSWORD_ENTRY: return handlePasswordInput(c); case STATE_LOGGING_IN: case STATE_COMMAND_PENDING: - // Shift+Del to cancel and go back if (c == KEY_ADMIN_EXIT) { - _state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_MENU; + _state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_COMMAND_MENU; return true; } return false; - case STATE_MENU: - return handleMenuInput(c); + case STATE_CATEGORY_MENU: return handleCategoryInput(c); + case STATE_COMMAND_MENU: return handleCommandInput(c); + case STATE_PARAM_ENTRY: return handleParamInput(c); + case STATE_CONFIRM: return handleConfirmInput(c); case STATE_RESPONSE_VIEW: - case STATE_ERROR: - return handleResponseInput(c); + case STATE_ERROR: return handleResponseInput(c); } return false; } private: - // --- Password Entry --- + + // --- Footer helpers --- + void renderFooterRight(DisplayDriver& display, int footerY, const char* text) { + display.setCursor(display.width() - display.getTextWidth(text) - 2, footerY); + display.print(text); + } + + void renderFooterMidRight(DisplayDriver& display, int footerY, + const char* left, const char* right, const char* mid) { + int leftEnd = display.getTextWidth(left) + 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); + } + + // ===================================================================== + // Password Entry + // ===================================================================== + void renderPasswordEntry(DisplayDriver& display, int y) { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); @@ -358,78 +622,58 @@ private: display.setColor(DisplayDriver::YELLOW); display.setCursor(0, y); - // Show asterisks for password characters, with brief reveal of last char char masked[ADMIN_PASSWORD_MAX]; - int i; bool revealing = (_pwdLen > 0 && (millis() - _lastCharAt) < 800); int revealIdx = revealing ? _pwdLen - 1 : -1; + int i; for (i = 0; i < _pwdLen && i < ADMIN_PASSWORD_MAX - 1; i++) { masked[i] = (i == revealIdx) ? _password[i] : '*'; } masked[i] = '\0'; display.print(masked); - - // Cursor indicator display.print("_"); } bool handlePasswordInput(char c) { - // Shift+Del = exit (always, regardless of password content) - if (c == KEY_ADMIN_EXIT) { - return false; // signal main.cpp to navigate back - } - - // Enter to submit + if (c == KEY_ADMIN_EXIT) return false; // exit screen if (c == '\r' || c == '\n' || c == KEY_ENTER) { - if (_pwdLen > 0) { - return doLogin(); - } + if (_pwdLen > 0) return doLogin(); return true; } - - // Backspace if (c == 0x08 || c == 0x7F) { - if (_pwdLen > 0) { - _pwdLen--; - _password[_pwdLen] = '\0'; - _lastCharAt = 0; // no reveal after delete - } + if (_pwdLen > 0) { _pwdLen--; _password[_pwdLen] = '\0'; _lastCharAt = 0; } return true; } - - // Printable character if (c >= 32 && c < 127 && _pwdLen < ADMIN_PASSWORD_MAX - 1) { _password[_pwdLen++] = c; _password[_pwdLen] = '\0'; - _lastCharAt = millis(); // start brief reveal + _lastCharAt = millis(); return true; } - return false; } - bool doLogin(); // Defined below, calls into MyMesh + bool doLogin(); - // --- Menu --- - void renderMenu(DisplayDriver& display, int y, int bodyHeight) { - display.setTextSize(0); // tiny font for compact rows + // ===================================================================== + // Category Menu (top level) + // ===================================================================== + + void renderCategoryMenu(DisplayDriver& display, int y, int bodyHeight) { + display.setTextSize(0); int lineHeight = 9; - // Show server time comparison if available + // Clock drift info line 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); - } - + 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]; @@ -438,80 +682,267 @@ private: 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); - + // Render categories + for (int i = 0; i < CAT_COUNT && y + lineHeight <= display.height() - 16; i++) { + bool isSystem = (i == CAT_REBOOT_OTA); + renderMenuItem(display, y, lineHeight, i == _catSel, CATEGORIES[i].label, isSystem && (i != _catSel)); y += lineHeight; } + display.setTextSize(1); + } + + bool handleCategoryInput(char c) { + if (c == 'w' || c == 'W' || c == 0xF2) { + if (_catSel > 0) _catSel--; + return true; + } + if (c == 's' || c == 'S' || c == 0xF1) { + if (_catSel < CAT_COUNT - 1) _catSel++; + return true; + } + if (c == '\r' || c == '\n' || c == KEY_ENTER) { + _cmdSel = 0; + _scrollOffset = 0; + _state = STATE_COMMAND_MENU; + return true; + } + if (c == KEY_ADMIN_EXIT) return false; // exit screen + // Number keys 1-7 + if (c >= '1' && c <= '0' + CAT_COUNT) { + _catSel = c - '1'; + _cmdSel = 0; + _scrollOffset = 0; + _state = STATE_COMMAND_MENU; + return true; + } + return false; + } + + // ===================================================================== + // Command Menu (within a category) + // ===================================================================== + + void renderCommandMenu(DisplayDriver& display, int y, int bodyHeight) { + display.setTextSize(0); + int lineHeight = 9; + const AdminCategoryDef& cat = CATEGORIES[_catSel]; + + // Category title + display.setColor(DisplayDriver::GREEN); + display.setCursor(0, y); + display.print(cat.label); + y += lineHeight + 2; + + // Compute visible window + int maxVisible = (display.height() - 16 - y) / lineHeight; + if (maxVisible < 1) maxVisible = 1; + if (_cmdSel < _scrollOffset) _scrollOffset = _cmdSel; + if (_cmdSel >= _scrollOffset + maxVisible) _scrollOffset = _cmdSel - maxVisible + 1; + + bool isSystemCat = (_catSel == CAT_REBOOT_OTA); + + for (int i = _scrollOffset; i < cat.count && (y + lineHeight <= display.height() - 16); i++) { + bool warn = isSystemCat || (cat.cmds[i].flags & (CMDF_CONFIRM | CMDF_EXPECT_TIMEOUT)); + renderMenuItem(display, y, lineHeight, i == _cmdSel, cat.cmds[i].label, warn && (i != _cmdSel)); + y += lineHeight; + } + + // Scroll indicators + if (_scrollOffset > 0) { + display.setColor(DisplayDriver::YELLOW); + display.setCursor(display.width() - 8, 14 + lineHeight + 2); + display.print("^"); + } + if (_scrollOffset + maxVisible < cat.count) { + display.setColor(DisplayDriver::YELLOW); + display.setCursor(display.width() - 8, display.height() - 16 - lineHeight); + display.print("v"); + } display.setTextSize(1); } - bool handleMenuInput(char c) { - // W/up - scroll up + bool handleCommandInput(char c) { + int count = CATEGORIES[_catSel].count; if (c == 'w' || c == 'W' || c == 0xF2) { - if (_menuSel > 0) _menuSel--; + if (_cmdSel > 0) _cmdSel--; return true; } - // S/down - scroll down if (c == 's' || c == 'S' || c == 0xF1) { - if (_menuSel < MENU_COUNT - 1) _menuSel++; + if (_cmdSel < count - 1) _cmdSel++; return true; } - // Enter - execute selected command if (c == '\r' || c == '\n' || c == KEY_ENTER) { - return executeMenuCommand((MenuItem)_menuSel); + return initiateCommand(&CATEGORIES[_catSel].cmds[_cmdSel]); } - // Shift+Del - back to contacts if (c == KEY_ADMIN_EXIT) { - return false; // let UITask handle back navigation + _state = STATE_CATEGORY_MENU; + return true; } - // Number keys for quick selection - if (c >= '1' && c <= '0' + MENU_COUNT) { - _menuSel = c - '1'; - return executeMenuCommand((MenuItem)_menuSel); + // Number keys for quick select (1-9 within category) + if (c >= '1' && c <= '9') { + int idx = c - '1'; + if (idx < count) { + _cmdSel = idx; + return initiateCommand(&CATEGORIES[_catSel].cmds[_cmdSel]); + } } return false; } - bool executeMenuCommand(MenuItem item); // Defined below, calls into MyMesh + // Decide what to do when a command is selected + bool initiateCommand(const AdminCmdDef* cmd) { + _pendingCmd = cmd; + + // If it needs parameter input, go to param entry first + if (cmd->flags & CMDF_PARAM) { + _paramLen = 0; + _paramBuf[0] = '\0'; + _state = STATE_PARAM_ENTRY; + return true; + } + + // If it needs confirmation (but no param), show confirm dialog + if (cmd->flags & CMDF_CONFIRM) { + _state = STATE_CONFIRM; + return true; + } + + // Otherwise send immediately + return sendCommand(cmd->cmd); + } + + // ===================================================================== + // Parameter Entry + // ===================================================================== + + void renderParamEntry(DisplayDriver& display, int y) { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, y); + if (_pendingCmd) display.print(_pendingCmd->label); + + y += 14; + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, y); + if (_pendingCmd && _pendingCmd->paramHint) display.print(_pendingCmd->paramHint); + + y += 14; + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, y); + display.print(_paramBuf); + display.print("_"); + } + + bool handleParamInput(char c) { + if (c == KEY_ADMIN_EXIT) { + _state = STATE_COMMAND_MENU; + return true; + } + if (c == '\r' || c == '\n' || c == KEY_ENTER) { + if (_paramLen == 0) return true; // don't send empty + + // If this command also needs confirmation, go to confirm now + if (_pendingCmd && (_pendingCmd->flags & CMDF_CONFIRM)) { + _state = STATE_CONFIRM; + return true; + } + + // Build and send: prefix + param + return sendParamCommand(); + } + if (c == 0x08 || c == 0x7F) { + if (_paramLen > 0) { _paramLen--; _paramBuf[_paramLen] = '\0'; } + return true; + } + if (c >= 32 && c < 127 && _paramLen < ADMIN_PARAM_MAX - 1) { + _paramBuf[_paramLen++] = c; + _paramBuf[_paramLen] = '\0'; + return true; + } + return false; + } + + bool sendParamCommand() { + if (!_pendingCmd || _contactIdx < 0) return false; + char fullCmd[160]; + snprintf(fullCmd, sizeof(fullCmd), "%s%s", _pendingCmd->cmd, _paramBuf); + return sendCommand(fullCmd); + } + + // ===================================================================== + // Confirmation + // ===================================================================== + + void renderConfirm(DisplayDriver& display, int y) { + display.setTextSize(1); + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, y); + display.print("Confirm:"); + + y += 16; + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, y); + if (_pendingCmd) display.print(_pendingCmd->label); + + y += 14; + display.setTextSize(0); + display.setCursor(0, y); + + // Show the param value if one was collected + if (_pendingCmd && (_pendingCmd->flags & CMDF_PARAM) && _paramLen > 0) { + char preview[80]; + snprintf(preview, sizeof(preview), "Value: %s", _paramBuf); + display.print(preview); + y += 10; + display.setCursor(0, y); + } + + if (_pendingCmd && (_pendingCmd->flags & CMDF_EXPECT_TIMEOUT)) { + display.print("Timeout response is normal."); + } else { + display.print("Enter=Yes Sh+Del=No"); + } + + display.setTextSize(1); + } + + bool handleConfirmInput(char c) { + if (c == '\r' || c == '\n' || c == KEY_ENTER) { + if (_pendingCmd && (_pendingCmd->flags & CMDF_PARAM) && _paramLen > 0) { + return sendParamCommand(); + } else if (_pendingCmd) { + return sendCommand(_pendingCmd->cmd); + } + return false; + } + if (c == KEY_ADMIN_EXIT || c == 'n' || c == 'N') { + _state = STATE_COMMAND_MENU; + return true; + } + return false; + } + + // ===================================================================== + // Response View + // ===================================================================== - // --- Response View --- void renderResponse(DisplayDriver& display, int y, int bodyHeight) { - display.setTextSize(0); // tiny font for more content + display.setTextSize(0); 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++; - } + while (*p && *p != '\n' && i < 79) lineBuf[i++] = *p++; lineBuf[i] = '\0'; if (*p == '\n') p++; @@ -523,63 +954,113 @@ private: lineCount++; } + // Scroll indicators + if (_responseScroll > 0) { + display.setColor(DisplayDriver::YELLOW); + display.setCursor(display.width() - 8, 14); + display.print("^"); + } + if (lineCount > skipLines + maxLines) { + display.setColor(DisplayDriver::YELLOW); + display.setCursor(display.width() - 8, y - lineHeight); + display.print("v"); + } + display.setTextSize(1); } bool handleResponseInput(char c) { - // W/up - scroll up if (c == 'w' || c == 'W' || c == 0xF2) { - if (_responseScroll > 0) { - _responseScroll--; - return true; - } + if (_responseScroll > 0) { _responseScroll--; return true; } } - // S/down - scroll down if (c == 's' || c == 'S' || c == 0xF1) { - _responseScroll++; - return true; + _responseScroll++; return true; } - // 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; } else { - _state = STATE_MENU; + _state = STATE_COMMAND_MENU; } return true; } - // Enter - also go back to menu if (c == '\r' || c == '\n' || c == KEY_ENTER) { - _state = STATE_MENU; + _state = STATE_COMMAND_MENU; return true; } return false; } - // --- Waiting spinner --- + // ===================================================================== + // Waiting + // ===================================================================== + 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); + snprintf(elapsed, sizeof(elapsed), "%lus", (millis() - _cmdSentAt) / 1000); display.setColor(DisplayDriver::LIGHT); display.setCursor((display.width() - display.getTextWidth(elapsed)) / 2, cy + 14); display.print(elapsed); } } + + // ===================================================================== + // Shared menu item renderer + // ===================================================================== + + void renderMenuItem(DisplayDriver& display, int y, int lineHeight, + bool selected, const char* label, bool warn) { + if (selected) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y + 5, display.width(), lineHeight); + display.setColor(DisplayDriver::DARK); + } else if (warn) { + display.setColor(DisplayDriver::YELLOW); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + display.setCursor(2, y); + char buf[40]; + snprintf(buf, sizeof(buf), "%s %s", selected ? ">" : " ", label); + display.print(buf); + } + + // ===================================================================== + // Send command to repeater + // ===================================================================== + + bool sendCommand(const char* cmd) { + if (_contactIdx < 0 || !cmd || cmd[0] == '\0') return false; + + if (the_mesh.uiSendCliCommand(_contactIdx, cmd)) { + _state = STATE_COMMAND_PENDING; + _cmdSentAt = millis(); + _response[0] = '\0'; + _responseLen = 0; + _responseScroll = 0; + _responseTotalLines = 0; + return true; + } else { + snprintf(_response, sizeof(_response), "Send failed."); + _responseLen = strlen(_response); + _state = STATE_ERROR; + return true; + } + } }; -// --- Implementations that require MyMesh (the_mesh is declared extern above) --- +// ===================================================================== +// Login implementation (requires MyMesh) +// ===================================================================== inline bool RepeaterAdminScreen::doLogin() { if (_contactIdx < 0 || _pwdLen == 0) return false; @@ -595,25 +1076,4 @@ inline bool RepeaterAdminScreen::doLogin() { _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