Files
Nic Boet 7df116b4f1 Fix case-sensitive filesystem build failures (Linux) for ESP32 LilyGo variants
The repo has apparently only ever been built on case-insensitive
filesystems (macOS/Windows): every #include in the codebase uses
intended PascalCase/CamelCase header names (e.g. "SettingsScreen.h",
"WiFiMQTT.h"), but 28 of the actual files on disk were saved with
inconsistent casing (e.g. "Settingsscreen.h", "wifimqtt.h"). On a
case-sensitive filesystem (Linux) this is a hard compile failure, not
a cosmetic mismatch -- confirmed by running `pio run -e meck_audio_ble`
on Gentoo Linux, which failed immediately on "target.h: No such file
or directory" and a cascade of similar errors as each fix exposed the
next one.

Root causes, two flavors of the same underlying bug:

1. Header filename casing (29 files renamed via `git mv` to preserve
   history): examples/companion_radio/ui-new/*, examples/simple_repeater/*,
   and two variant-local headers (PCF85063Clock.h, TCA8418Keyboard.h x2).
   Verified safe before renaming: every file has exactly one consistent
   intended casing across all the places that #include it (checked via
   a repo-wide scan comparing every #include against on-disk filenames,
   zero conflicts found), so each rename is a pure no-op for behavior.

2. PlatformIO config paths using the wrong case for variant directories
   that are actually lowercase on disk (variants/lilygo_tdeck_pro,
   variants/lilygo_t5s3_epaper_pro):
   - `-I variants/LilyGo_TDeck_Pro` / `-I variants/LilyGo_T5S3_EPaper_Pro`
     in build_flags (3 occurrences, including lilygo_tdeck_max's
     reference to TDeck Pro's shared headers) -- broke header resolution
     for target.h and friends.
   - `+<../variants/LilyGo_TDeck_Pro>` / `+<../variants/LilyGo_T5S3_EPaper_Pro>`
     in build_src_filter (2 occurrences) -- silently excluded the board-init
     .cpp files (TDeckBoard.cpp etc.) from compilation entirely, which
     didn't fail until the *link* stage ("undefined reference to
     radio_init()", `TDeckBoard::begin()`, etc.) since PlatformIO's glob
     just matched nothing rather than erroring.

Verified fix: `pio run -e meck_audio_ble` now compiles, links, and
produces a firmware image cleanly (RAM 53.1%, Flash 49.6%).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:44:46 -05:00

1233 lines
43 KiB
C++

#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <MeshCore.h>
// Forward declarations
#include "../AbstractUITask.h"
class MyMesh;
extern MyMesh the_mesh;
#define ADMIN_PASSWORD_MAX 32
#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 Flood Max Unscoped", "set flood.max.unscoped ", "Max hops (64=off):", CMDF_PARAM },
{ "Set Flood Adv Max", "set flood.max.advert ", "Max hops (def 8):", 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 21
// --- 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_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)
};
private:
AbstractUITask* _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;
unsigned long _lastCharAt; // millis() when last char typed (for brief reveal)
// 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;
int _responseTotalLines;
// Timing
unsigned long _cmdSentAt;
unsigned long _loginTimeoutMs; // computed timeout for login (ms), falls back to ADMIN_TIMEOUT_MS
bool _waitingForLogin;
// Password cache
static const int PWD_CACHE_SIZE = 8;
struct PwdCacheEntry {
int contactIdx;
char password[ADMIN_PASSWORD_MAX];
};
PwdCacheEntry _pwdCache[PWD_CACHE_SIZE];
int _pwdCacheCount;
// Remote telemetry (from LPP response)
float _telemVoltage; // Battery voltage in volts
float _telemTempC; // Temperature in celsius
bool _telemHasVoltage;
bool _telemHasTemp;
bool _telemRequested; // Telemetry request already sent
const char* getCachedPassword(int contactIdx) {
for (int i = 0; i < _pwdCacheCount; i++) {
if (_pwdCache[i].contactIdx == contactIdx) return _pwdCache[i].password;
}
return nullptr;
}
void cachePassword(int contactIdx, const char* pwd) {
for (int i = 0; i < _pwdCacheCount; i++) {
if (_pwdCache[i].contactIdx == contactIdx) {
strncpy(_pwdCache[i].password, pwd, ADMIN_PASSWORD_MAX - 1);
_pwdCache[i].password[ADMIN_PASSWORD_MAX - 1] = '\0';
return;
}
}
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 {
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';
}
}
// --- LPP telemetry parser ---
// Walks a CayenneLPP buffer extracting voltage and temperature.
// Format per entry: [channel 1B][type 1B][data varies]
void parseLPPTelemetry(const uint8_t* data, uint8_t len) {
const uint8_t TELEM_TYPE_VOLTAGE = 0x74; // 2 bytes, uint16 / 100 = V
const uint8_t TELEM_TYPE_TEMPERATURE = 0x67; // 2 bytes, int16 / 10 = C
int pos = 0;
while (pos + 2 < len) {
// uint8_t channel = data[pos]; // not needed
uint8_t type = data[pos + 1];
pos += 2; // skip channel + type
// Determine data size for this type
int dataSize = 0;
switch (type) {
case TELEM_TYPE_VOLTAGE: dataSize = 2; break;
case TELEM_TYPE_TEMPERATURE: dataSize = 2; break;
case 0x88: dataSize = 9; break; // GPS
case 0x75: dataSize = 2; break; // Current
case 0x68: dataSize = 1; break; // Humidity
case 0x73: dataSize = 2; break; // Pressure
case 0x76: dataSize = 2; break; // Altitude
case 0x80: dataSize = 2; break; // Power
default: return; // Unknown type, stop parsing
}
if (pos + dataSize > len) break;
if (type == TELEM_TYPE_VOLTAGE) {
uint16_t raw = ((uint16_t)data[pos] << 8) | data[pos + 1];
_telemVoltage = raw / 100.0f;
_telemHasVoltage = true;
} else if (type == TELEM_TYPE_TEMPERATURE) {
int16_t raw = ((int16_t)data[pos] << 8) | data[pos + 1];
_telemTempC = raw / 10.0f;
_telemHasTemp = true;
}
pos += dataSize;
}
}
// --- Helpers ---
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);
}
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(AbstractUITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _state(STATE_PASSWORD_ENTRY),
_contactIdx(-1), _permissions(0), _serverTime(0),
_pwdLen(0), _lastCharAt(0),
_catSel(0), _cmdSel(0), _scrollOffset(0),
_paramLen(0), _pendingCmd(nullptr),
_responseLen(0), _responseScroll(0), _responseTotalLines(0),
_cmdSentAt(0), _loginTimeoutMs(ADMIN_TIMEOUT_MS), _waitingForLogin(false), _pwdCacheCount(0),
_telemVoltage(0), _telemTempC(0),
_telemHasVoltage(false), _telemHasTemp(false), _telemRequested(false) {
_password[0] = '\0';
_repeaterName[0] = '\0';
_response[0] = '\0';
_paramBuf[0] = '\0';
}
void openForContact(int contactIdx, const char* name) {
_contactIdx = contactIdx;
strncpy(_repeaterName, name, sizeof(_repeaterName) - 1);
_repeaterName[sizeof(_repeaterName) - 1] = '\0';
_state = STATE_PASSWORD_ENTRY;
_lastCharAt = 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';
_telemHasVoltage = false;
_telemHasTemp = false;
_telemRequested = false;
const char* cached = getCachedPassword(contactIdx);
if (cached) {
strncpy(_password, cached, ADMIN_PASSWORD_MAX - 1);
_password[ADMIN_PASSWORD_MAX - 1] = '\0';
_pwdLen = strlen(_password);
} else {
_pwdLen = 0;
_password[0] = '\0';
}
}
int getContactIdx() const { return _contactIdx; }
AdminState getState() const { return _state; }
uint8_t getPermissions() const { return _permissions; }
void onLoginResult(bool success, uint8_t permissions, uint32_t server_time) {
_waitingForLogin = false;
if (success) {
_permissions = permissions;
_serverTime = server_time;
_state = STATE_CATEGORY_MENU;
cachePassword(_contactIdx, _password);
// Auto-request telemetry (battery & temperature) after login
if (!_telemRequested) {
_telemRequested = true;
bool sent = the_mesh.uiSendTelemetryRequest(_contactIdx);
Serial.printf("[Admin] Telemetry request %s for contact idx %d\n",
sent ? "sent" : "FAILED", _contactIdx);
}
} else {
snprintf(_response, sizeof(_response), "Login failed.\nCheck password.");
_responseLen = strlen(_response);
_state = STATE_ERROR;
}
}
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;
}
void onTelemetryResult(const uint8_t* data, uint8_t len) {
Serial.printf("[Admin] Telemetry response received, %d bytes:", len);
for (int i = 0; i < len && i < 32; i++) Serial.printf(" %02X", data[i]);
Serial.println();
parseLPPTelemetry(data, len);
Serial.printf("[Admin] Parsed: hasVoltage=%d (%.2fV) hasTemp=%d (%.1fC)\n",
_telemHasVoltage, _telemVoltage, _telemHasTemp, _telemTempC);
}
void poll() override {
if (_cmdSentAt > 0) {
unsigned long elapsed = millis() - _cmdSentAt;
unsigned long timeout = (_state == STATE_LOGGING_IN) ? _loginTimeoutMs : ADMIN_TIMEOUT_MS;
if ((_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) && elapsed > timeout) {
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;
}
_task->forceRefresh(); // Immediate redraw on state change
}
}
}
// =====================================================================
// Render
// =====================================================================
int render(DisplayDriver& display) override {
char tmp[64];
// --- Header ---
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
const char* hdrPrefix = (_state == STATE_PASSWORD_ENTRY || _state == STATE_LOGGING_IN)
? "Login" : "Admin";
snprintf(tmp, sizeof(tmp), "%s: %.16s", hdrPrefix, _repeaterName);
display.print(tmp);
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);
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_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 ---
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:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Exit");
renderFooterRight(display, footerY, "Hold:Type");
#else
display.print("Sh+Del:Exit");
renderFooterRight(display, footerY, "Ent:Login");
#endif
break;
case STATE_LOGGING_IN:
case STATE_COMMAND_PENDING:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Cancel");
#else
display.print("Sh+Del:Cancel");
#endif
break;
case STATE_CATEGORY_MENU:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Exit");
renderFooterMidRight(display, footerY, "Back:Exit", "Tap:Open", "Swipe:Sel");
#else
display.print("Sh+Del:Exit");
renderFooterMidRight(display, footerY, "Sh+Del:Exit", "Ent:Open", "W/S:Sel");
#endif
break;
case STATE_COMMAND_MENU:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Back");
renderFooterMidRight(display, footerY, "Back:Back", "Tap:Run", "Swipe:Sel");
#else
display.print("Sh+Del:Back");
renderFooterMidRight(display, footerY, "Sh+Del:Back", "Ent:Run", "W/S:Sel");
#endif
break;
case STATE_PARAM_ENTRY:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Cancel");
renderFooterRight(display, footerY, "Tap:Send");
#else
display.print("Sh+Del:Cancel");
renderFooterRight(display, footerY, "Ent:Send");
#endif
break;
case STATE_CONFIRM:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:No");
renderFooterRight(display, footerY, "Tap:Yes");
#else
display.print("Sh+Del:No");
renderFooterRight(display, footerY, "Ent:Yes");
#endif
break;
case STATE_RESPONSE_VIEW:
case STATE_ERROR:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Back");
if (_responseTotalLines > bodyHeight / 9) {
renderFooterRight(display, footerY, "Swipe:Scroll");
}
#else
display.print("Sh+Del:Back");
if (_responseTotalLines > bodyHeight / 9) {
renderFooterRight(display, footerY, "W/S:Scrll");
}
#endif
break;
}
if (_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) return 30000; // static text; poll()/callbacks force refresh on state change
if (_state == STATE_PASSWORD_ENTRY && _lastCharAt > 0 && (millis() - _lastCharAt) < 800) {
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_LOGGING_IN:
case STATE_COMMAND_PENDING:
if (c == KEY_ADMIN_EXIT) {
_state = (_state == STATE_LOGGING_IN) ? STATE_PASSWORD_ENTRY : STATE_COMMAND_MENU;
return true;
}
return false;
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);
}
return false;
}
private:
// --- 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);
display.setCursor(0, y);
display.print("Password:");
y += 14;
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, y);
char masked[ADMIN_PASSWORD_MAX];
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);
display.print("_");
}
bool handlePasswordInput(char c) {
if (c == KEY_ADMIN_EXIT) return false; // exit screen
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
if (_pwdLen > 0) return doLogin();
return true;
}
if (c == 0x08 || c == 0x7F) {
if (_pwdLen > 0) { _pwdLen--; _password[_pwdLen] = '\0'; _lastCharAt = 0; }
return true;
}
if (c >= 32 && c < 127 && _pwdLen < ADMIN_PASSWORD_MAX - 1) {
_password[_pwdLen++] = c;
_password[_pwdLen] = '\0';
_lastCharAt = millis();
return true;
}
return false;
}
bool doLogin();
// =====================================================================
// Category Menu (top level)
// =====================================================================
void renderCategoryMenu(DisplayDriver& display, int y, int bodyHeight) {
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
// 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);
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;
}
// Remote telemetry info line (battery & temperature)
if (_telemHasVoltage || _telemHasTemp) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, y);
char telem[48];
int tpos = 0;
if (_telemHasVoltage) {
tpos += snprintf(telem + tpos, sizeof(telem) - tpos, "Batt:%.2fV", _telemVoltage);
}
if (_telemHasTemp) {
if (tpos > 0) tpos += snprintf(telem + tpos, sizeof(telem) - tpos, " ");
tpos += snprintf(telem + tpos, sizeof(telem) - tpos, "Temp:%.1fC", _telemTempC);
}
display.print(telem);
y += lineHeight + 2;
} else if (_telemRequested) {
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, y);
display.print("Telemetry: requesting...");
y += lineHeight + 2;
}
// 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(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
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 handleCommandInput(char c) {
int count = CATEGORIES[_catSel].count;
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_cmdSel > 0) _cmdSel--;
return true;
}
if (c == 's' || c == 'S' || c == 0xF1) {
if (_cmdSel < count - 1) _cmdSel++;
return true;
}
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
return initiateCommand(&CATEGORIES[_catSel].cmds[_cmdSel]);
}
if (c == KEY_ADMIN_EXIT) {
_state = STATE_CATEGORY_MENU;
return true;
}
// 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;
}
// 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(the_mesh.getNodePrefs()->smallTextSize());
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 += the_mesh.getNodePrefs()->smallLineH() + 1;
display.setCursor(0, y);
}
if (_pendingCmd && (_pendingCmd->flags & CMDF_EXPECT_TIMEOUT)) {
display.print("Timeout response is normal.");
} else {
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap=Yes Back=No");
#else
display.print("Enter=Yes Sh+Del=No");
#endif
}
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
// =====================================================================
void renderResponse(DisplayDriver& display, int y, int bodyHeight) {
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
display.setColor((_state == STATE_ERROR) ? DisplayDriver::YELLOW : DisplayDriver::LIGHT);
int maxLines = bodyHeight / lineHeight;
int lineCount = 0;
int skipLines = _responseScroll;
const char* p = _response;
char lineBuf[80];
while (*p && lineCount < maxLines + skipLines) {
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++;
}
// 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) {
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_responseScroll > 0) { _responseScroll--; return true; }
}
if (c == 's' || c == 'S' || c == 0xF1) {
_responseScroll++; return true;
}
if (c == KEY_ADMIN_EXIT) {
if (_state == STATE_ERROR && _permissions == 0) {
_state = STATE_PASSWORD_ENTRY;
} else {
_state = STATE_COMMAND_MENU;
}
return true;
}
if (c == '\r' || c == '\n' || c == KEY_ENTER) {
_state = STATE_COMMAND_MENU;
return true;
}
return false;
}
// =====================================================================
// 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);
if (_cmdSentAt > 0) {
char elapsed[16];
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);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), lineHeight);
#else
display.fillRect(0, y + the_mesh.getNodePrefs()->smallHighlightOff(), display.width(), lineHeight);
#endif
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;
}
}
};
// =====================================================================
// Login implementation (requires MyMesh)
// =====================================================================
inline bool RepeaterAdminScreen::doLogin() {
if (_contactIdx < 0 || _pwdLen == 0) return false;
uint32_t timeout_ms = 0;
if (the_mesh.uiLoginToRepeater(_contactIdx, _password, timeout_ms)) {
_state = STATE_LOGGING_IN;
_cmdSentAt = millis();
// Add a 5s buffer over the mesh estimate to account for blocking e-ink
// refreshes (FastEPD ~1-2s per frame, VKB dismiss + login render = 2-3 frames).
// Fall back to ADMIN_TIMEOUT_MS if the estimate came back zero.
_loginTimeoutMs = (timeout_ms > 0) ? timeout_ms + 5000 : ADMIN_TIMEOUT_MS;
_waitingForLogin = true;
return true;
} else {
snprintf(_response, sizeof(_response), "Send failed.\nCheck contact path.");
_responseLen = strlen(_response);
_state = STATE_ERROR;
return true;
}
}