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