mirror of
https://github.com/pelgraine/Meck.git
synced 2026-07-06 01:31:17 +02:00
7df116b4f1
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>
1233 lines
43 KiB
C++
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;
|
|
}
|
|
} |