feat(ui-core,ui-lvgl): remote admin session in the Core, L2 admin screen

AdminSession (login with saved password, CLI round trips, timeouts, field
table, typed values) moves into ui-core; UiCore offers login answers and
CLI replies to it first. ui-new's Tools > Admin becomes a view over it.
L2: node detail > Admin with tabs and per-field popups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-25 19:59:00 +02:00
co-authored by Claude Opus 5.5
parent 79fd5a50fb
commit e2399b5e0d
11 changed files with 1091 additions and 673 deletions
+14
View File
@@ -52,6 +52,20 @@ Confirmed: green dot inside the ring, waypoints much better, download works, set
- [ ] L1 (ui-new): Messages > Channels add / edit / delete / mute / pin still work (now through the shared ChannelControl)
- [ ] L1: deleting a contact from the app still unpins it from the dial and clears its mute / melody (cleanup moved into the Core)
## New: Repeater admin (2026-09-25)
- [ ] Nearby > a repeater or room server you have in contacts > "Admin" (the Fav button is just a star there)
- [ ] First time: password field + keyboard; wrong password → "Login failed", back on the node; right one → tabs System / Radio / Routing / Actions
- [ ] Next time: logs in by itself with the saved password (no keyboard); a node you just used opens straight away
- [ ] Out of range: "No answer to the login" after a while, and the saved password is forgotten (asks again next time)
- [ ] Name / Owner info: "Reading..." then a text field with the current value; ✓ sends it, reply popup "OK"
- [ ] Admin password: set a new one → the saved password follows (next login works without typing)
- [ ] Routing > Repeat switch; Advert interval / Flood advert / Max hops with − / +; Radio > SF / Bandwidth / Coding rate choices, Frequency typed, TX power − / +
- [ ] Actions: Send advert / zero-hop / Sync clock give a reply; Reboot and Start OTA ask first (red button)
- [ ] Custom command: e.g. `ver`, `neighbors` (long replies scroll)
- [ ] "Reading... (tap to stop)" stops waiting when tapped
- [ ] Room server login from the phone app / L1 still works while this exists (only admin logins go to the new session)
- [ ] L1 (ui-new): Tools > Admin works as before (login, saved password, typed values, confirm reboot); it now also skips the login for the node you just used
## Basics after the_mesh moved to PSRAM (`MESH_IN_PSRAM`)
- [ ] Boots normally, contacts and channels are all there
- [ ] Messages arrive and send (channel + DM), delivery ticks work
+1 -1
View File
@@ -110,7 +110,7 @@ call the same ones, so e.g. "marking a channel read" has one implementation.
### 5. Declarative settings schema
`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h`.
`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h`. Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it.
Replaces the per-item code in `SettingsScreen`:
@@ -0,0 +1,409 @@
#pragma once
// Remote admin of a repeater / room server you have admin rights on -- the
// on-device equivalent of the app's "repeater admin" (docs/cli_commands.md).
// Shared by ui-new's Tools > Admin and ui-lvgl's Admin screen: the frontends
// draw; this owns the login (saved password, silent retry, self-healing on a
// wrong / stale password), the CLI round trips with their timeouts, and the
// field table.
//
// Fields: {label, get_cmd, set_prefix, kind}
// set_prefix == null -> action: send get_cmd literally ("reboot")
// get_cmd == null -> set-only: the frontend asks for text, sendSet()
// both -> get, then edit, then set
// both null -> "Custom command...": free text, sendRaw()
// Typed kinds (on/off, number, the four views of "radio f,bw,sf,cr") fetch
// the value into `value` / `radio_*`; the frontend edits those, sendValue()
// sends the set. Results arrive as Outcomes, one at a time, via take().
#include <helpers/ClientACL.h> // PERM_ACL_ADMIN / PERM_ACL_ROLE_MASK
namespace admin {
enum Kind : uint8_t { K_TEXT, K_ONOFF, K_NUMBER, K_RADIO_FREQ, K_RADIO_BW, K_RADIO_SF, K_RADIO_CR };
struct Field {
const char* label; const char* get_cmd; const char* set_prefix;
Kind kind;
float min_val, max_val, step; // K_NUMBER (K_RADIO_FREQ: range)
// Explicit ctor: the nRF52 toolchain's C++ standard predates aggregates with
// default member initializers, and most rows are plain {label, get, set}.
Field(const char* l, const char* g, const char* s, Kind k = K_TEXT, float mn = 0, float mx = 0, float st = 1)
: label(l), get_cmd(g), set_prefix(s), kind(k), min_val(mn), max_val(mx), step(st) {}
bool isCustom() const { return !get_cmd && !set_prefix; }
bool isAction() const { return get_cmd && !set_prefix; }
bool isSetOnly() const { return !get_cmd && set_prefix; }
bool isRadio() const { return kind >= K_RADIO_FREQ; }
};
enum Tab : uint8_t { TAB_SYSTEM, TAB_RADIO, TAB_ROUTING, TAB_ACTIONS, TAB_COUNT };
static const char* const TAB_LABELS[TAB_COUNT] = { "System", "Radio", "Routing", "Actions" };
static const Field SYSTEM_FIELDS[] = {
{ "Name", "get name", "set name" },
{ "Owner info", "get owner.info", "set owner.info" },
{ "Admin password", nullptr, "password" },
};
// The four radio rows share "get radio" / "set radio": each fetches and
// re-sends the whole f,bw,sf,cr tuple, only its own part edited.
static const Field RADIO_FIELDS[] = {
{ "Frequency (MHz)", "get radio", "set radio", K_RADIO_FREQ, 150.0f, 2500.0f },
{ "Bandwidth (kHz)", "get radio", "set radio", K_RADIO_BW },
{ "Spreading factor", "get radio", "set radio", K_RADIO_SF },
{ "Coding rate", "get radio", "set radio", K_RADIO_CR },
{ "TX power (dBm)", "get tx", "set tx", K_NUMBER, -9, 30, 1 },
};
static const Field ROUTING_FIELDS[] = {
{ "Repeat", "get repeat", "set repeat", K_ONOFF },
{ "Advert interval (min)", "get advert.interval", "set advert.interval", K_NUMBER, 0, 240, 2 },
{ "Flood advert interval (h)", "get flood.advert.interval", "set flood.advert.interval", K_NUMBER, 0, 168, 1 },
{ "Max hops", "get flood.max", "set flood.max", K_NUMBER, 0, 64, 1 },
};
static const Field ACTION_FIELDS[] = {
{ "Send advert", "advert", nullptr },
{ "Send zero-hop advert", "advert.zerohop", nullptr },
{ "Sync clock", "clock sync", nullptr },
{ "Reboot", "reboot", nullptr }, // confirmPrompt()
{ "Start OTA", "start ota", nullptr }, // confirmPrompt()
{ "Custom command...", nullptr, nullptr },
};
static int rowCount(int tab) {
switch (tab) {
case TAB_SYSTEM: return sizeof(SYSTEM_FIELDS) / sizeof(Field);
case TAB_RADIO: return sizeof(RADIO_FIELDS) / sizeof(Field);
case TAB_ROUTING: return sizeof(ROUTING_FIELDS) / sizeof(Field);
default: return sizeof(ACTION_FIELDS) / sizeof(Field);
}
}
static const Field& field(int tab, int row) {
switch (tab) {
case TAB_SYSTEM: return SYSTEM_FIELDS[row];
case TAB_RADIO: return RADIO_FIELDS[row];
case TAB_ROUTING: return ROUTING_FIELDS[row];
default: return ACTION_FIELDS[row];
}
}
// Actions that take an unattended node out of service for a while (OTA into
// DFU mode, reboot offline for seconds): the frontend confirms these first.
static const char* confirmPrompt(const Field& f) {
if (!f.isAction()) return nullptr;
if (!strcmp(f.get_cmd, "reboot")) return "Reboot node?";
if (!strcmp(f.get_cmd, "start ota")) return "Start OTA update?";
return nullptr;
}
// Completions for the custom command: every remotely usable command from
// docs/cli_commands.md (serial-only ones and the region sub-grammar
// excluded). Entries taking a value end in a space.
static const char* const CLI_COMMANDS[] = {
"reboot", "poweroff", "shutdown", "clkreboot", "clock", "clock sync",
"advert", "advert.zerohop", "start ota", "erase",
"neighbors", "neighbor.remove ", "discover.neighbors",
"ver", "board",
"get radio", "set radio ", "get tx", "set tx ", "tempradio ",
"get freq", "set freq ", "get radio.rxgain", "set radio.rxgain ",
"get name", "set name ", "get lat", "set lat ", "get lon", "set lon ",
"get owner.info", "set owner.info ", "get adc.multiplier", "set adc.multiplier ",
"get public.key", "get role",
"powersaving", "powersaving on", "powersaving off",
"get repeat", "set repeat ",
"get path.hash.mode", "set path.hash.mode ",
"get loop.detect", "set loop.detect ",
"get txdelay", "set txdelay ", "get direct.txdelay", "set direct.txdelay ",
"get rxdelay", "set rxdelay ", "get dutycycle", "set dutycycle ",
"get af", "set af ", "get int.thresh", "set int.thresh ",
"get agc.reset.interval", "set agc.reset.interval ",
"get multi.acks", "set multi.acks ",
"get flood.advert.interval", "set flood.advert.interval ",
"get advert.interval", "set advert.interval ",
"get flood.max", "set flood.max ",
"get flood.max.unscoped", "set flood.max.unscoped ",
"get flood.max.advert", "set flood.max.advert ",
"setperm ", "get acl", "get allow.read.only", "set allow.read.only ",
"region", "region load", "region save", "region list ",
"gps", "gps sync", "gps setloc", "gps advert ",
"sensor list", "sensor get ", "sensor set ",
"get bridge.type", "get bridge.enabled", "set bridge.enabled ",
"get bridge.delay", "set bridge.delay ", "get bridge.source", "set bridge.source ",
"get bridge.baud", "set bridge.baud ", "get bridge.channel", "set bridge.channel ",
"get bridge.secret", "set bridge.secret ",
"get pwrmgt.support", "get pwrmgt.source", "get pwrmgt.bootreason", "get pwrmgt.bootmv",
"password ", "get guest.password", "set guest.password ",
};
static const int CLI_COMMAND_COUNT = sizeof(CLI_COMMANDS) / sizeof(CLI_COMMANDS[0]);
// "freq,bw,sf,cr" (a "get radio" value) -> parts.
static bool parseRadio(const char* val, float& freq, float& bw, uint8_t& sf, uint8_t& cr) {
char tmp[48];
strncpy(tmp, val, sizeof(tmp) - 1);
tmp[sizeof(tmp) - 1] = '\0';
char* parts[4];
char* p = tmp;
for (int i = 0; i < 4; i++) {
parts[i] = p;
if (i < 3) {
char* c = strchr(p, ',');
if (!c) return false;
*c = '\0';
p = c + 1;
}
}
freq = strtof(parts[0], nullptr);
bw = strtof(parts[1], nullptr);
sf = (uint8_t)atoi(parts[2]);
cr = (uint8_t)atoi(parts[3]);
return true;
}
static void trimRight(char* s) {
size_t n = strlen(s);
while (n > 0 && (s[n - 1] == '\n' || s[n - 1] == '\r' || s[n - 1] == ' ')) s[--n] = '\0';
}
} // namespace admin
class AdminSession {
public:
enum State : uint8_t {
IDLE, // no target
NEED_PASSWORD, // no saved password: the frontend asks, then login()
LOGGING_IN,
READY, // logged in as admin, nothing in flight
WAITING, // a command is in flight
};
enum Outcome : uint8_t {
NONE,
LOGGED_IN,
NOT_ADMIN, // right password, not an admin: session ends
LOGIN_FAILED, // wrong password (forgotten) or not sent: session ends
LOGIN_TIMEOUT, // no answer (a stale saved password is silence, not a nack): forgotten, session ends
SEND_FAILED,
REPLY, // reply() holds the node's answer to show
VALUE_READY, // a typed field's value is in value / radio_*: edit, then sendValue()
TEXT_READY, // text() holds the current value to edit, then sendSet()
FETCH_FAILED, // a typed fetch got no / a bad answer
TIMEOUT, // a command got no answer (a text fetch still gives TEXT_READY, empty)
};
// Open a session with `ci`: straight to READY if it's the node last logged
// into, else a silent login with its saved password, else NEED_PASSWORD.
void start(const ContactInfo& ci) {
end();
_target = ci;
_outcome = NONE;
if (_ok && memcmp(_ok_prefix, ci.id.pub_key, 4) == 0) { _state = READY; return; }
char pw[sizeof(_pw)];
if (the_mesh.getRoomPassword(ci.id.pub_key, pw, sizeof(pw))) login(pw);
else _state = NEED_PASSWORD;
}
bool login(const char* password) {
snprintf(_pw, sizeof(_pw), "%s", password ? password : "");
uint32_t est = 0;
if (!the_mesh.sendRoomLogin(_target, _pw, est)) {
_state = IDLE;
_outcome = LOGIN_FAILED;
return false;
}
_state = LOGGING_IN;
_deadline_ms = millis() + est + 4000;
return true;
}
// Leave (the frontend closed the screen): stop tracking a login in flight,
// so a late answer isn't taken as some other screen's login.
void end() {
if (_state == LOGGING_IN) the_mesh.cancelUiPendingLogin(_target.id.pub_key);
_state = IDLE;
_purpose = P_PLAIN;
_field = nullptr;
}
// Give up the current wait: a login ends the session, a text fetch still
// opens the editor (blank), anything else is dropped.
void cancelWait() {
if (_state == LOGGING_IN) { end(); return; }
if (_state != WAITING) return;
_state = READY;
if (_purpose == P_TEXT) { _text[0] = '\0'; _outcome = TEXT_READY; }
_purpose = P_PLAIN;
}
// Tap / Enter on a field (confirmPrompt() already answered). Set-only and
// custom rows need text first: TEXT_READY straight away (text() empty for
// set-only, the last custom command for custom).
bool run(const admin::Field& f) {
if (_state != READY) return false;
_field = &f;
if (f.isCustom()) { snprintf(_text, sizeof(_text), "%s", _last_custom); _outcome = TEXT_READY; return true; }
if (f.isSetOnly()) { _text[0] = '\0'; _outcome = TEXT_READY; return true; }
if (f.isAction()) return send(f.get_cmd, P_PLAIN);
return send(f.get_cmd, f.kind == admin::K_TEXT ? P_TEXT : P_VALUE);
}
// The edited text for the field run() opened: "<set_prefix> <text>", or the
// command itself for the custom row.
bool sendSet(const char* text) {
if (!_field) return false;
if (_field->isCustom()) {
snprintf(_last_custom, sizeof(_last_custom), "%s", text);
return text[0] ? send(text, P_PLAIN) : false;
}
if (!_field->set_prefix) return false;
char cmd[sizeof(_cmd)];
snprintf(cmd, sizeof(cmd), "%s %s", _field->set_prefix, text);
return send(cmd, P_PLAIN);
}
// The edited typed value (value / radio_*) for the field run() fetched.
bool sendValue() {
if (!_field || !_field->set_prefix) return false;
char cmd[sizeof(_cmd)];
if (_field->isRadio())
snprintf(cmd, sizeof(cmd), "%s %.3f,%.3f,%d,%d", _field->set_prefix, radio_freq, radio_bw, (int)radio_sf, (int)radio_cr);
else if (_field->kind == admin::K_ONOFF)
snprintf(cmd, sizeof(cmd), "%s %s", _field->set_prefix, value != 0 ? "on" : "off");
else
snprintf(cmd, sizeof(cmd), "%s %d", _field->set_prefix, (int)value);
return send(cmd, P_PLAIN);
}
// Frontend step for a K_NUMBER / K_ONOFF value, kept in range.
void stepValue(int dir) {
if (!_field) return;
if (_field->kind == admin::K_ONOFF) { value = value != 0 ? 0 : 1; return; }
float nv = value + dir * _field->step;
if (nv >= _field->min_val && nv <= _field->max_val) value = nv;
}
// ── From the mesh (via UiCore); true when the answer was this session's ──
bool onLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {
if (_state != LOGGING_IN || memcmp(_target.id.pub_key, pub_key, 4) != 0) return false;
if (success && (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN) {
memcpy(_ok_prefix, pub_key, 4);
_ok = true;
the_mesh.saveRoomPassword(pub_key, _pw);
_state = READY;
_outcome = LOGGED_IN;
} else if (success) {
_state = IDLE; // the password is right: keep it, retyping won't help
_outcome = NOT_ADMIN;
} else {
the_mesh.forgetRoomPassword(pub_key); // wrong / stale: ask afresh next time
_state = IDLE;
_outcome = LOGIN_FAILED;
}
return true;
}
bool onReply(const uint8_t* pub_key, const char* text) {
if (_state != WAITING || memcmp(_target.id.pub_key, pub_key, 4) != 0) return false;
_state = READY;
// "get" replies come back as "> value" (CommonCLI::handleGetCmd); the
// plain reply keeps it (a typed custom "get" echoes the wire reply).
const char* val = (text[0] == '>' && text[1] == ' ') ? text + 2 : text;
uint8_t purpose = _purpose;
_purpose = P_PLAIN;
if (purpose == P_VALUE) {
_outcome = takeValue(val) ? VALUE_READY : FETCH_FAILED;
return true;
}
if (purpose == P_TEXT) {
snprintf(_text, sizeof(_text), "%s", val);
admin::trimRight(_text);
_outcome = TEXT_READY;
return true;
}
// "password <new>" echoes "password now: <value>" (CommonCLI): that's the
// password needed from now on, so keep the saved one in step.
static const char PW_ECHO[] = "password now: ";
if (!strncmp(text, PW_ECHO, sizeof(PW_ECHO) - 1)) {
char pw[32];
snprintf(pw, sizeof(pw), "%s", text + sizeof(PW_ECHO) - 1);
admin::trimRight(pw);
the_mesh.saveRoomPassword(_target.id.pub_key, pw);
}
snprintf(_reply, sizeof(_reply), "%s", text);
_outcome = REPLY;
return true;
}
void loop() {
if ((_state != LOGGING_IN && _state != WAITING) || (int32_t)(millis() - _deadline_ms) < 0) return;
if (_state == LOGGING_IN) {
the_mesh.cancelUiPendingLogin(_target.id.pub_key); // a late answer mustn't land elsewhere
the_mesh.forgetRoomPassword(_target.id.pub_key);
_state = IDLE;
_outcome = LOGIN_TIMEOUT;
return;
}
_state = READY;
if (_purpose == P_TEXT) { _text[0] = '\0'; _outcome = TEXT_READY; } // set it blind
else if (_purpose == P_VALUE) _outcome = FETCH_FAILED;
else _outcome = TIMEOUT;
_purpose = P_PLAIN;
}
// The next result (NONE when nothing happened since the last call).
Outcome take() { Outcome o = _outcome; _outcome = NONE; return o; }
State state() const { return _state; }
bool active() const { return _state != IDLE; }
bool fetching() const { return _state == WAITING && _purpose != P_PLAIN; }
const ContactInfo& target() const { return _target; }
const admin::Field* currentField() const { return _field; }
const char* reply() const { return _reply; }
const char* text() const { return _text; }
// Typed editor values (see VALUE_READY)
float value = 0;
float radio_freq = 0, radio_bw = 0;
uint8_t radio_sf = 0, radio_cr = 0;
private:
enum Purpose : uint8_t { P_PLAIN, P_VALUE, P_TEXT };
bool send(const char* cmd, uint8_t purpose) {
snprintf(_cmd, sizeof(_cmd), "%s", cmd);
uint32_t est = 0;
if (!the_mesh.sendAdminCommand(_target, _cmd, est)) { _outcome = SEND_FAILED; return false; }
_state = WAITING;
_purpose = purpose;
_deadline_ms = millis() + est + 4000; // margin for a slow multi-hop reply
return true;
}
bool takeValue(const char* val) {
if (!_field) return false;
switch (_field->kind) {
case admin::K_ONOFF:
value = memcmp(val, "on", 2) == 0 ? 1 : 0;
return true;
case admin::K_NUMBER:
// Clamped: a value set some other way may sit outside the row's range,
// and every step from there would be rejected.
value = constrain((float)atoi(val), _field->min_val, _field->max_val);
return true;
default:
return _field->isRadio() && admin::parseRadio(val, radio_freq, radio_bw, radio_sf, radio_cr);
}
}
State _state = IDLE;
Outcome _outcome = NONE;
uint8_t _purpose = P_PLAIN;
ContactInfo _target;
const admin::Field* _field = nullptr;
char _pw[16] = "";
char _cmd[161] = "";
char _text[161] = "";
char _last_custom[161] = "";
char _reply[200] = "";
uint32_t _deadline_ms = 0;
uint8_t _ok_prefix[4] = {0}; // last node logged into as admin
bool _ok = false;
};
+11 -3
View File
@@ -24,6 +24,7 @@
#include "TrailEngine.h"
#include "WaypointModel.h"
#include "ChannelControl.h"
#include "AdminSession.h"
class UiCore : public MyMesh::Listener {
public:
@@ -51,6 +52,7 @@ public:
live_share.loop();
locator.loop();
trail.loop();
admin.loop();
}
UiEventQueue events; // Core → frontend; drained by the frontend's loop()
@@ -62,6 +64,7 @@ public:
LiveShareEngine live_share; // [LOC] auto-share session + peers' shared positions
LocatorEngine locator; // active target, geofence crossings, proximity beeper
TrailEngine trail; // GPS trail store, sampling, auto-pause, shutdown save
AdminSession admin; // remote repeater / room admin: login + CLI round trips
// ── Models ────────────────────────────────────────────────────────────────
MessageHistory history; // channel + DM rings, delivery state, channel unread
@@ -236,11 +239,16 @@ public:
live_share.onSharedLocation(pub_key, name, lat_1e6, lon_1e6, ts, verified);
}
// Not yet extracted -- the frontend still owns these.
// Login answers go to the admin session when it's the one waiting, else to
// the frontend (room server logins from its messages screen).
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override {
_host->onRoomLoginResult(pub_key, success, permissions);
if (!admin.onLoginResult(pub_key, success, permissions)) _host->onRoomLoginResult(pub_key, success, permissions);
_host->onAdminStateChanged();
}
void onAdminReply(const uint8_t* pub_key, const char* text) override {
admin.onReply(pub_key, text);
_host->onAdminStateChanged();
}
void onAdminReply(const uint8_t* pub_key, const char* text) override { _host->onAdminReply(pub_key, text); }
// A contact / channel went away (deleted here, from the app, or evicted):
// drop every pref keyed on it, then let the frontend refresh.
void onContactRemoved(const uint8_t* pub_key) override {
@@ -19,9 +19,14 @@ public:
virtual bool isViewingDM(const uint8_t* pub_key) { (void)pub_key; return false; }
virtual void onViewedHistoryGrew(bool channel) { (void)channel; }
// ── Not yet extracted (screen sessions, device controls, prefs cleanup) ──
// A room server login the admin session wasn't waiting for (the frontend's
// room screen sent it).
virtual void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {}
virtual void onAdminReply(const uint8_t* pub_key, const char* text) {}
// The admin session (UiCore::admin) got an answer: poll admin.take() soon
// (redraw now rather than at the next scheduled refresh).
virtual void onAdminStateChanged() {}
// ── Not yet extracted (device controls, prefs cleanup) ──
virtual void onContactRemoved(const uint8_t* pub_key) {}
virtual void onChannelRemoved(uint8_t channel_idx) {}
virtual void botSetGPS(bool on) {}
@@ -0,0 +1,423 @@
#pragma once
// Remote admin of a repeater / room server (node detail > Admin) -- ui-new's
// Tools > Admin. The session (login with the saved password, CLI round trips,
// timeouts, field table) is the UI Core's AdminSession; this draws it:
// password entry, tabs of fields, and a popup per field -- a switch, a
// -/+ stepper, a row of choices (SF / bandwidth / coding rate), or a text
// field with the keyboard (text, frequency, custom command); replies in a popup.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after ChannelScreen.h.
namespace adminview {
static uint8_t s_tab = 0;
static lv_obj_t* s_status = nullptr; // "Logging in..." / "Waiting for reply..."
static lv_obj_t* s_list = nullptr; // the tab's rows
static lv_obj_t* s_tabs = nullptr;
static lv_obj_t* s_pw = nullptr; // password field (NEED_PASSWORD)
static lv_obj_t* s_kb = nullptr;
static lv_obj_t* s_val_lbl = nullptr; // stepper value in the popup
static lv_obj_t* s_choice = nullptr; // SF / BW / CR choices in the popup
static const admin::Field* s_confirm = nullptr; // Reboot / OTA waiting for "yes"
static char s_btn_map[24][8];
static const char* s_map[28];
enum : uint8_t { V_MINUS, V_PLUS, V_SAVE, V_TOGGLE, V_CONFIRM, V_CLOSE };
static void clear() { s_status = s_list = s_tabs = s_pw = s_kb = s_val_lbl = s_choice = nullptr; }
// Choice buttons for a radio field: labels into s_btn_map, map into s_map
// (a "\n" after every `per_row`), selected index out.
static int radioChoices(const admin::Field& f, const AdminSession& S, int& per_row) {
int n = 0, sel = 0, m = 0;
if (f.kind == admin::K_RADIO_SF) {
per_row = 8;
for (int v = 5; v <= 12; v++, n++) { snprintf(s_btn_map[n], 8, "%d", v); if (v == S.radio_sf) sel = n; }
} else if (f.kind == admin::K_RADIO_CR) {
per_row = 4;
for (int v = 5; v <= 8; v++, n++) { snprintf(s_btn_map[n], 8, "4/%d", v); if (v == S.radio_cr) sel = n; }
} else {
per_row = 5;
sel = nearestBwIndex(S.radio_bw);
for (; n < LORA_BW_OPT_COUNT; n++) snprintf(s_btn_map[n], 8, "%g", (double)LORA_BW_OPTS[n]);
}
for (int i = 0; i < n; i++) {
if (i && i % per_row == 0) s_map[m++] = "\n";
s_map[m++] = s_btn_map[i];
}
s_map[m] = "";
return sel;
}
} // namespace adminview
static void onAdminTab(lv_event_t* e) {
s_ui->adminTab((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onAdminRow(lv_event_t* e) { s_ui->adminRow((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onAdminValue(lv_event_t* e) { s_ui->adminValue((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onAdminChoice(lv_event_t* e) {
s_ui->adminChoice((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onAdminPwKb(lv_event_t* e) { s_ui->adminLogin(lv_event_get_code(e) == LV_EVENT_READY); }
static void onAdminTextKb(lv_event_t* e) { s_ui->adminTextDone(lv_event_get_code(e) == LV_EVENT_READY); }
static void onAdminCancelWait(lv_event_t* e) { (void)e; s_ui->adminCancelWait(); }
// Node detail > Admin.
void UITask::openAdmin(const uint8_t* pub_key) {
ContactInfo ci;
if (!MessageHistory::contactByPrefix(pub_key, ci)) return;
_core->admin.start(ci);
adminview::s_tab = admin::TAB_SYSTEM;
_screen = SCR_ADMIN;
buildAdmin();
}
void UITask::adminLeave() {
_core->admin.end();
navClosePopup();
_screen = SCR_NODE;
buildNode();
}
void UITask::buildAdmin() {
using namespace adminview;
AdminSession& S = _core->admin;
lv_obj_t* body = newScreen(S.target().name, true);
lv_obj_set_style_pad_row(body, 4, 0);
clear();
AdminSession::State st = S.state();
if (st == AdminSession::NEED_PASSWORD) {
label(body, "Admin password for this node", THEME_FONT_SMALL, theme::TEXT_MUTED);
s_pw = wifiField(body, "Password", true);
lv_obj_remove_event_cb(s_pw, onWifiField);
lv_textarea_set_max_length(s_pw, 15);
lv_obj_add_state(s_pw, LV_STATE_FOCUSED);
s_kb = kb::create(lv_screen_active(), _prefs);
lv_obj_set_size(s_kb, LV_PCT(100), 124);
lv_obj_align(s_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_keyboard_set_textarea(s_kb, s_pw);
lv_obj_add_event_cb(s_kb, onAdminPwKb, LV_EVENT_READY, NULL);
lv_obj_add_event_cb(s_kb, onAdminPwKb, LV_EVENT_CANCEL, NULL);
return;
}
if (st == AdminSession::LOGGING_IN || st == AdminSession::IDLE) {
s_status = label(body, st == AdminSession::LOGGING_IN ? LV_SYMBOL_REFRESH " Logging in..." : "", THEME_FONT_BODY, theme::TEXT);
return;
}
static const char* TABS[admin::TAB_COUNT + 1];
for (int i = 0; i < admin::TAB_COUNT; i++) TABS[i] = admin::TAB_LABELS[i];
TABS[admin::TAB_COUNT] = "";
s_tabs = lv_buttonmatrix_create(body);
lv_buttonmatrix_set_map(s_tabs, TABS);
lv_buttonmatrix_set_button_ctrl_all(s_tabs, LV_BUTTONMATRIX_CTRL_CHECKABLE);
lv_buttonmatrix_set_one_checked(s_tabs, true);
lv_buttonmatrix_set_button_ctrl(s_tabs, s_tab, LV_BUTTONMATRIX_CTRL_CHECKED);
lv_obj_set_size(s_tabs, LV_PCT(100), 34);
lv_obj_set_style_pad_all(s_tabs, 0, 0);
lv_obj_set_style_pad_column(s_tabs, 4, 0);
lv_obj_set_style_bg_opa(s_tabs, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(s_tabs, 0, 0);
lv_obj_set_style_bg_color(s_tabs, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
lv_obj_set_style_bg_color(s_tabs, lv_color_hex(theme::ACCENT_DIM), LV_PART_ITEMS | LV_STATE_CHECKED);
lv_obj_set_style_text_color(s_tabs, lv_color_hex(theme::TEXT), LV_PART_ITEMS);
lv_obj_set_style_text_font(s_tabs, THEME_FONT_SMALL, LV_PART_ITEMS);
lv_obj_set_style_shadow_width(s_tabs, 0, LV_PART_ITEMS);
lv_obj_set_style_radius(s_tabs, 8, LV_PART_ITEMS);
lv_obj_add_event_cb(s_tabs, onAdminTab, LV_EVENT_VALUE_CHANGED, NULL);
s_status = label(body, "", THEME_FONT_SMALL, theme::ACCENT);
lv_obj_add_flag(s_status, LV_OBJ_FLAG_CLICKABLE); // tap: stop waiting
lv_obj_add_event_cb(s_status, onAdminCancelWait, LV_EVENT_CLICKED, NULL);
lv_obj_add_flag(s_status, LV_OBJ_FLAG_HIDDEN);
s_list = lv_obj_create(body);
styleSurface(s_list, theme::BG);
lv_obj_set_width(s_list, LV_PCT(100));
lv_obj_set_flex_grow(s_list, 1);
lv_obj_set_flex_flow(s_list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(s_list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(s_list, LV_SCROLLBAR_MODE_ACTIVE);
for (int i = 0; i < admin::rowCount(s_tab); i++) {
const admin::Field& f = admin::field(s_tab, i);
const char* hint = f.isCustom() ? "Any CLI command" : f.isAction() ? nullptr
: f.isSetOnly() ? "Set a new one" : "Tap to read and change";
lv_obj_t* row = listRow(s_list, f.label, hint, onAdminRow, (void*)(uintptr_t)i);
if (f.isAction() && admin::confirmPrompt(f))
lv_obj_align(label(row, LV_SYMBOL_WARNING, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
}
refreshAdminStatus();
}
void UITask::refreshAdminStatus() {
using namespace adminview;
if (!s_status || !s_list) return;
AdminSession& S = _core->admin;
bool waiting = S.state() == AdminSession::WAITING;
if (waiting) {
lv_label_set_text(s_status, S.fetching() ? LV_SYMBOL_REFRESH " Reading... (tap to stop)"
: LV_SYMBOL_REFRESH " Waiting for reply... (tap to stop)");
lv_obj_remove_flag(s_status, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(s_status, LV_OBJ_FLAG_HIDDEN);
}
if (waiting) lv_obj_add_state(s_list, LV_STATE_DISABLED); else lv_obj_remove_state(s_list, LV_STATE_DISABLED);
}
void UITask::adminTab(int tab) {
if (tab < 0 || tab >= admin::TAB_COUNT) return;
adminview::s_tab = (uint8_t)tab;
buildAdmin();
}
void UITask::adminLogin(bool ok) {
using namespace adminview;
if (!ok) { adminLeave(); return; }
if (!s_pw) return;
_core->admin.login(lv_textarea_get_text(s_pw)); // a send failure comes back as LOGIN_FAILED
buildAdmin();
}
void UITask::adminCancelWait() {
_core->admin.cancelWait();
refreshAdminStatus();
}
void UITask::adminRow(int row) {
using namespace adminview;
AdminSession& S = _core->admin;
if (S.state() != AdminSession::READY || row < 0 || row >= admin::rowCount(s_tab)) return;
const admin::Field& f = admin::field(s_tab, row);
const char* prompt = admin::confirmPrompt(f);
if (prompt) { // takes the node out of service for a while: ask first
s_confirm = &f;
lv_obj_t* panel = navPopupPanel(prompt, false);
lv_obj_t* t = label(panel, "The node is offline until it comes back; nobody can reach it through it meanwhile.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
lv_obj_t* b = lv_button_create(panel);
lv_obj_set_size(b, LV_PCT(100), 40);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::FAIL), 0);
lv_obj_add_event_cb(b, onAdminValue, LV_EVENT_CLICKED, (void*)(uintptr_t)V_CONFIRM);
lv_obj_center(label(b, f.label, THEME_FONT_BODY, theme::TEXT));
return;
}
S.run(f);
refreshAdminStatus();
}
// Popup buttons: stepper, save, confirm.
void UITask::adminValue(uint8_t act) {
using namespace adminview;
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
switch (act) {
case V_MINUS: case V_PLUS:
S.stepValue(act == V_PLUS ? 1 : -1);
if (s_val_lbl) lv_label_set_text_fmt(s_val_lbl, "%d", (int)S.value);
break;
case V_TOGGLE:
S.stepValue(1);
break;
case V_SAVE:
navClosePopup();
if (f) S.sendValue();
refreshAdminStatus();
break;
case V_CONFIRM:
navClosePopup();
if (s_confirm) S.run(*s_confirm);
s_confirm = nullptr;
refreshAdminStatus();
break;
default:
navClosePopup();
break;
}
}
void UITask::adminChoice(int idx) {
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
if (!f || idx < 0) return;
if (f->kind == admin::K_RADIO_SF) S.radio_sf = (uint8_t)(5 + idx);
else if (f->kind == admin::K_RADIO_CR) S.radio_cr = (uint8_t)(5 + idx);
else if (f->kind == admin::K_RADIO_BW && idx < LORA_BW_OPT_COUNT) S.radio_bw = LORA_BW_OPTS[idx];
}
static lv_obj_t* adminPopupButton(lv_obj_t* parent, const char* text, uint8_t act, bool accent) {
lv_obj_t* b = lv_button_create(parent);
lv_obj_set_height(b, 40);
lv_obj_set_flex_grow(b, 1);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(accent ? theme::ACCENT_DIM : theme::SURFACE), 0);
lv_obj_add_event_cb(b, onAdminValue, LV_EVENT_CLICKED, (void*)(uintptr_t)act);
lv_obj_center(label(b, text, act == adminview::V_MINUS || act == adminview::V_PLUS ? THEME_FONT_LARGE : THEME_FONT_BODY, theme::TEXT));
return b;
}
static lv_obj_t* adminRowBox(lv_obj_t* parent) {
lv_obj_t* r = lv_obj_create(parent);
styleSurface(r, theme::BG);
lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(r, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(r, theme::GAP, 0);
return r;
}
// A typed value arrived: its editor.
void UITask::adminValuePopup() {
using namespace adminview;
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
if (!f) return;
if (f->kind == admin::K_RADIO_FREQ) { // typed, like text
char fs[16];
snprintf(fs, sizeof(fs), "%.3f", S.radio_freq);
adminTextPopup(fs, true);
return;
}
lv_obj_t* panel = navPopupPanel(f->label, false);
s_val_lbl = s_choice = nullptr;
if (f->kind == admin::K_ONOFF) {
lv_obj_t* r = adminRowBox(panel);
label(r, "Off", THEME_FONT_BODY, theme::TEXT_MUTED);
lv_obj_t* sw = lv_switch_create(r);
lv_obj_set_size(sw, 56, 28);
if (S.value != 0) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onAdminValue, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)V_TOGGLE);
label(r, "On", THEME_FONT_BODY, theme::TEXT_MUTED);
} else if (f->kind == admin::K_NUMBER) {
lv_obj_t* r = adminRowBox(panel);
adminPopupButton(r, LV_SYMBOL_MINUS, V_MINUS, false);
s_val_lbl = label(r, "", THEME_FONT_LARGE, theme::TEXT);
lv_obj_set_width(s_val_lbl, 80);
lv_obj_set_style_text_align(s_val_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text_fmt(s_val_lbl, "%d", (int)S.value);
adminPopupButton(r, LV_SYMBOL_PLUS, V_PLUS, false);
} else { // SF / bandwidth / coding rate
int per_row = 1;
int sel = radioChoices(*f, S, per_row);
int rows = f->kind == admin::K_RADIO_BW ? 2 : 1;
s_choice = lv_buttonmatrix_create(panel);
lv_buttonmatrix_set_map(s_choice, s_map);
lv_buttonmatrix_set_button_ctrl_all(s_choice, LV_BUTTONMATRIX_CTRL_CHECKABLE);
lv_buttonmatrix_set_one_checked(s_choice, true);
lv_buttonmatrix_set_button_ctrl(s_choice, sel, LV_BUTTONMATRIX_CTRL_CHECKED);
lv_obj_set_size(s_choice, LV_PCT(100), rows * 34 + (rows - 1) * 4);
lv_obj_set_style_pad_all(s_choice, 0, 0);
lv_obj_set_style_pad_gap(s_choice, 4, 0);
lv_obj_set_style_bg_opa(s_choice, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(s_choice, 0, 0);
lv_obj_set_style_bg_color(s_choice, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
lv_obj_set_style_bg_color(s_choice, lv_color_hex(theme::ACCENT_DIM), LV_PART_ITEMS | LV_STATE_CHECKED);
lv_obj_set_style_text_color(s_choice, lv_color_hex(theme::TEXT), LV_PART_ITEMS);
lv_obj_set_style_text_font(s_choice, THEME_FONT_SMALL, LV_PART_ITEMS);
lv_obj_set_style_shadow_width(s_choice, 0, LV_PART_ITEMS);
lv_obj_set_style_radius(s_choice, 8, LV_PART_ITEMS);
lv_obj_add_event_cb(s_choice, onAdminChoice, LV_EVENT_VALUE_CHANGED, NULL);
}
if (f->isRadio()) {
lv_obj_t* note = label(panel, "The node leaves your network if it no longer matches it.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP);
lv_obj_set_width(note, LV_PCT(100));
}
lv_obj_t* r = adminRowBox(panel);
adminPopupButton(r, LV_SYMBOL_OK " Save", V_SAVE, true);
}
// Text to edit (a fetched value, a new password, a custom command, the frequency).
void UITask::adminTextPopup(const char* text, bool digits) {
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
lv_obj_t* panel = navPopupPanel(f ? f->label : "Command", false);
lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard
_nav_ta = lv_textarea_create(panel);
lv_textarea_set_one_line(_nav_ta, true);
lv_textarea_set_max_length(_nav_ta, 150);
if (digits) lv_textarea_set_accepted_chars(_nav_ta, "0123456789.");
if (f && f->isCustom()) lv_textarea_set_placeholder_text(_nav_ta, "e.g. get advert.interval");
lv_textarea_set_text(_nav_ta, text);
lv_obj_set_size(_nav_ta, LV_PCT(100), 34);
lv_obj_set_style_border_width(_nav_ta, 1, 0);
lv_obj_set_style_pad_ver(_nav_ta, 6, 0);
lv_obj_set_style_pad_hor(_nav_ta, 10, 0);
lv_obj_set_scrollbar_mode(_nav_ta, LV_SCROLLBAR_MODE_OFF);
lv_obj_set_style_border_color(_nav_ta, lv_color_hex(theme::ACCENT), LV_PART_CURSOR | LV_STATE_FOCUSED);
lv_obj_set_style_border_width(_nav_ta, 2, LV_PART_CURSOR | LV_STATE_FOCUSED);
lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED);
_nav_kb = kb::create(_nav_overlay, _prefs);
lv_obj_set_size(_nav_kb, LV_PCT(100), 124);
lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_keyboard_set_textarea(_nav_kb, _nav_ta);
if (digits) kb::apply(_nav_kb, kb::L_SYM);
lv_obj_add_event_cb(_nav_kb, onAdminTextKb, LV_EVENT_READY, NULL);
lv_obj_add_event_cb(_nav_kb, onAdminTextKb, LV_EVENT_CANCEL, NULL);
}
void UITask::adminTextDone(bool ok) {
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
if (ok && _nav_ta && f) {
const char* t = lv_textarea_get_text(_nav_ta);
if (f->kind == admin::K_RADIO_FREQ) {
float v = strtof(t, nullptr);
if (v < f->min_val || v > f->max_val) { showToast("Frequency out of range"); return; }
S.radio_freq = v;
S.sendValue();
} else {
S.sendSet(t);
}
}
navClosePopup();
refreshAdminStatus();
}
void UITask::adminReplyPopup(const char* text) {
bool long_text = strlen(text) > 90; // e.g. "neighbors": full height, scrolls
lv_obj_t* panel = navPopupPanel("Reply", long_text);
lv_obj_t* box = panel;
if (long_text) {
box = lv_obj_create(panel);
styleSurface(box, theme::BG);
lv_obj_set_width(box, LV_PCT(100));
lv_obj_set_flex_grow(box, 1);
lv_obj_set_scrollbar_mode(box, LV_SCROLLBAR_MODE_ACTIVE);
}
lv_obj_t* l = label(box, text[0] ? text : "(empty)", THEME_FONT_BODY, theme::TEXT);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
lv_obj_t* r = adminRowBox(panel);
adminPopupButton(r, "OK", adminview::V_CLOSE, false);
}
// From loop() on SCR_ADMIN: the session's results.
void UITask::adminPoll() {
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
switch (S.take()) {
case AdminSession::NONE: return;
case AdminSession::LOGGED_IN: showToast("Logged in as admin", 1500); buildAdmin(); break;
case AdminSession::NOT_ADMIN: showToast("Not an admin on this node"); adminLeave(); break;
case AdminSession::LOGIN_FAILED: showToast("Login failed (wrong password?)"); adminLeave(); break;
case AdminSession::LOGIN_TIMEOUT: showToast("No answer to the login (out of range?)"); adminLeave(); break;
case AdminSession::SEND_FAILED: showToast("Couldn't send"); break;
case AdminSession::FETCH_FAILED: showToast("Couldn't read the value; try again"); break;
case AdminSession::TIMEOUT: showToast("No reply (timeout)"); break;
case AdminSession::REPLY: adminReplyPopup(S.reply()); break;
case AdminSession::VALUE_READY: adminValuePopup(); break;
case AdminSession::TEXT_READY:
if (f && f->get_cmd && !S.text()[0]) showToast("Couldn't read the value; enter a new one");
adminTextPopup(S.text(), false);
break;
}
refreshAdminStatus();
}
+10 -2
View File
@@ -175,6 +175,7 @@ void UITask::loop() {
}
if (_screen == SCR_MAP) mapLoop();
if (_screen == SCR_WIFI) pollWifiScan();
if (_screen == SCR_ADMIN) adminPoll();
if (_screen == SCR_CLOCK && (int32_t)(millis() - _next_clock_ms) >= 0) {
_next_clock_ms = millis() + 100; // stopwatch tenths
refreshClock();
@@ -457,6 +458,7 @@ void UITask::back() {
case SCR_THREAD: if (_nav_overlay) navClosePopup(); else showChats(); break;
case SCR_CONTACTS: showChats(); break;
case SCR_CHANNEL_EDIT: showChats(); break;
case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break;
case SCR_SETTINGS: showHome(); break;
case SCR_SETTINGS_NAV: showSettings(); break;
case SCR_CLOCK: showHome(); break;
@@ -805,7 +807,7 @@ void UITask::buildContacts() {
// model, since it is a different set: who is in range, not who is known.
static NearbyModel::Entry s_node; // the node open in SCR_NODE (a copy: the list re-sorts)
enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV };
enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN };
static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onNearbySort(lv_event_t* e) { (void)e; s_ui->toggleNearbySort(); }
@@ -1115,7 +1117,9 @@ void UITask::buildNode() {
if (contact && e.type == ADV_TYPE_CHAT) actionButton(acts, LV_SYMBOL_ENVELOPE " Message", NODE_MSG, true);
if (e.has_key) actionButton(acts, LV_SYMBOL_LOOP " Ping", NODE_PING, false);
if (e.lat_e6 != 0 || e.lon_e6 != 0) actionButton(acts, UI_SYMBOL_COMPASS, NODE_NAV, false);
if (contact) actionButton(acts, e.fav ? UI_SYMBOL_STAR " Unfav" : UI_SYMBOL_STAR " Fav", NODE_FAV, false);
bool admin = contact && (e.type == ADV_TYPE_REPEATER || e.type == ADV_TYPE_ROOM);
if (contact) actionButton(acts, admin ? UI_SYMBOL_STAR : e.fav ? UI_SYMBOL_STAR " Unfav" : UI_SYMBOL_STAR " Fav", NODE_FAV, e.fav && admin);
if (admin) actionButton(acts, LV_SYMBOL_SETTINGS " Admin", NODE_ADMIN, false);
if (!contact && e.has_key && !e.is_known) actionButton(acts, LV_SYMBOL_PLUS " Add", NODE_ADD, true);
if (contact) _node_delete_lbl = actionButton(acts, LV_SYMBOL_TRASH, NODE_DELETE, false);
@@ -1233,6 +1237,9 @@ void UITask::nodeAction(uint8_t action) {
navFrameTarget();
}
break;
case NODE_ADMIN:
openAdmin(e.pub_key);
break;
case NODE_DELETE:
if (!_delete_armed_ms) { // destructive: second tap within 3 s confirms
_delete_armed_ms = millis();
@@ -1255,6 +1262,7 @@ void UITask::nodeAction(uint8_t action) {
#include "RadioScreen.h"
#include "WifiScreen.h"
#include "ChannelScreen.h"
#include "AdminScreen.h"
// ── Conversation ──────────────────────────────────────────────────────────────
+17 -1
View File
@@ -119,6 +119,15 @@ public:
void channelEditField(lv_obj_t* ta);
void channelEditKbHide();
void channelEditSave();
// Remote admin (AdminScreen.h)
void openAdmin(const uint8_t* pub_key);
void adminTab(int tab);
void adminRow(int row);
void adminValue(uint8_t act);
void adminChoice(int idx);
void adminLogin(bool ok);
void adminTextDone(bool ok);
void adminCancelWait();
void setGps(bool on);
bool ensureGps(); // true with a fix; else turns GPS on / says it's waiting
@@ -127,7 +136,7 @@ public:
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; }
private:
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT };
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN };
void buildStatusBar();
void refreshStatusBar();
@@ -182,6 +191,13 @@ private:
void buildRadio();
void radioCloseFreq();
void buildChannelEdit();
void buildAdmin();
void refreshAdminStatus();
void adminLeave();
void adminPoll();
void adminValuePopup();
void adminTextPopup(const char* text, bool digits);
void adminReplyPopup(const char* text);
bool radioPopupOpen() const;
void refreshClock();
void showRing(const char* text);
+194 -656
View File
@@ -15,479 +15,150 @@
// back to Nodes if they came in directly (see returnToOrigin()); backing out
// of the picker itself returns to Tools.
//
// After login (session password, self-heals like room logins) a category tab
// carousel of AdminField{label, get_cmd, set_prefix} rows unlocks:
// set_prefix==null -> action: send get_cmd literally (e.g. "reboot")
// get_cmd==null -> set-only: blank keyboard, send "<set_prefix> <value>"
// both set -> get, pre-fill keyboard with the reply, then set
// The trailing "Custom command..." row (both null) is a free-text escape hatch
// with {}-key CLI command-name completion (CLI_COMMANDS).
// After login a category tab carousel of admin::Field rows unlocks. The login,
// the CLI round trips, timeouts and the field table are the UI Core's
// AdminSession (ui-core/AdminSession.h, shared with ui-lvgl); this screen
// draws them and edits values. The trailing "Custom command..." row is a
// free-text escape hatch with {}-key CLI command-name completion.
#include "FullscreenMsgView.h"
#include "TabBar.h"
#include "PopupMenu.h" // "Start OTA" confirmation
#include "PopupMenu.h" // "Start OTA" / "Reboot" confirmation
#include "RadioParamsEditor.h" // DigitEditor + stepSF/stepBW/stepCR -- same widgets Settings/Repeater use locally
#include <helpers/ClientACL.h> // PERM_ACL_ADMIN / PERM_ACL_ROLE_MASK
class AdminScreen : public UIScreen {
UITask* _task;
enum Phase : uint8_t { LOGIN, COMMAND, REPLY };
Phase _phase = LOGIN;
ContactInfo _target;
// True when this visit was reached via the "Remote node..." picker
// (UITask::pickAdminTarget() -> NearbyScreen's pick-mode); false when reached
// directly from a node's own Hold-Enter "Admin" action while browsing Nodes
// normally. Determines where Cancel/failure paths send the user back to --
// see returnToOrigin(). Set fresh by startFor() on every entry.
// True when this visit came through the "Remote node..." picker; false when
// opened from a node's own "Admin" action in Nodes. Decides where Cancel /
// failure goes back to (returnToOrigin()).
bool _from_picker = true;
// Cancel/failure exit: back to the target picker if that's how we got here,
// otherwise straight back to Nodes (we were opened directly from its
// "Admin" context-menu action, so a bare pick-list would be a detour).
void returnToOrigin() { if (_from_picker) _task->pickAdminTarget(); else _task->gotoNearbyScreen(); }
AdminSession& S() { return _task->core().admin; }
const AdminSession& S() const { return _task->core().admin; }
// LOGIN
char _login_pw[16] = "";
// True while waiting for a login result with no keyboard on screen -- either a
// silent retry with a remembered password, or right after the typed password
// was submitted. Distinct from the keyboard being open (kb().render()/
// handleInput() only run while this is false).
bool _login_waiting = false;
// Deadline for _login_waiting, mirroring _cmd_deadline_ms/_waiting in the
// COMMAND phase below -- without it, a login reply that never arrives (e.g.
// the remote's password changed since it was saved, and it silently drops
// instead of nacking) leaves the screen stuck on "Logging in..." forever
// with only a manual Cancel to escape. See poll().
uint32_t _login_deadline_ms = 0;
// One-slot memo: the last contact successfully admin-logged-into this visit,
// so re-entering COMMAND for the same target right after doesn't require
// retyping the password. Cleared in onShow().
uint8_t _admin_ok_prefix[4] = { 0, 0, 0, 0 };
bool _admin_ok = false;
// ── COMMAND: category tabs / rows ───────────────────────────────────────────
enum AdminTab : uint8_t { ATAB_SYSTEM, ATAB_RADIO, ATAB_ROUTING, ATAB_ACTIONS, ATAB_COUNT };
static const char* TAB_LABELS[ATAB_COUNT];
// Most fields are still free text (get reply pre-fills the keyboard, user
// retypes, "set <prefix> <text>" on submit) -- fine for names/passwords, but
// painful for values that are really a number or one of a handful of options.
// `kind` (default FK_TEXT, so every existing 3-field {label,get,set} literal
// below still compiles unchanged) opts a field into a typed, in-place editor
// instead: FK_ONOFF toggles on/off; FK_NUMBER steps an int within
// [min_val,max_val] by `step`; FK_RADIO_* are four views onto the same
// "get radio"/"set radio f,bw,sf,cr" tuple (freq via the same digit-cursor
// DigitEditor Settings/Repeater use, bw/sf/cr via RadioParamsEditor's
// discrete-set steppers) -- editing any one re-sends all four, the other
// three carried over unchanged from the fetch. See activateField()/
// beginValueEdit()/the `_value_editing` block in handleInput().
enum FieldKind : uint8_t { FK_TEXT, FK_ONOFF, FK_NUMBER, FK_RADIO_FREQ, FK_RADIO_BW, FK_RADIO_SF, FK_RADIO_CR };
struct AdminField {
const char* label; const char* get_cmd; const char* set_prefix;
FieldKind kind;
float min_val, max_val, step; // FK_NUMBER only
// Explicit ctor (not default member initializers) so every existing
// 3-field {label,get,set} literal below still compiles as a converting
// constructor call -- this toolchain's actual C++ standard (the Adafruit
// nRF52 core's default; only `env:native` sets -std=c++17) predates
// C++14's aggregate-with-default-member-initializer rule, so plain
// aggregate init here fails to convert.
AdminField(const char* l, const char* g, const char* s, FieldKind k = FK_TEXT,
float mn = 0, float mx = 0, float st = 1)
: label(l), get_cmd(g), set_prefix(s), kind(k), min_val(mn), max_val(mx), step(st) {}
};
static const AdminField SYSTEM_FIELDS[];
static const AdminField RADIO_FIELDS[];
static const AdminField ROUTING_FIELDS[];
static const AdminField ACTION_FIELDS[];
static const int ROWS_PER_TAB[ATAB_COUNT];
static const AdminField& fieldAt(int tab, int row) {
switch (tab) {
case ATAB_SYSTEM: return SYSTEM_FIELDS[row];
case ATAB_RADIO: return RADIO_FIELDS[row];
case ATAB_ROUTING: return ROUTING_FIELDS[row];
default: return ACTION_FIELDS[row];
}
void returnToOrigin() {
S().end();
if (_from_picker) _task->pickAdminTarget(); else _task->gotoNearbyScreen();
}
uint8_t _tab = 0;
int _row_sel = 0, _row_scroll = 0;
bool _kb_active = false;
char _cmd_text[161] = ""; // the command being built/sent
bool _waiting = false; // awaiting sendAdminCommand()'s reply
uint32_t _cmd_deadline_ms = 0;
bool _fetch_for_edit = false; // true while waiting on a "get" whose reply opens an editor
const char* _pending_set_prefix = nullptr; // non-null => edited keyboard text gets this prefix on submit
bool _login_kb = false; // password keyboard up (session NEED_PASSWORD)
bool _kb_active = false; // value / custom command keyboard up
bool _value_editing = false; // typed editor live on the selected row
bool _showing_reply = false;
DigitEditor _freq_ed; // K_RADIO_FREQ's digit-cursor editor
// ── Typed value editors (FK_ONOFF/FK_NUMBER/FK_RADIO_*) ────────────────────
bool _fetch_for_value = false; // true while waiting on a "get" whose reply feeds a typed editor
bool _value_editing = false; // true while the selected row's typed editor is live (Enter to send, Cancel to drop)
const AdminField* _edit_field = nullptr; // field currently open in _value_editing
float _edit_val = 0; // FK_ONOFF (0/1) / FK_NUMBER current value
float _radio_freq = 0, _radio_bw = 0; // last-fetched radio tuple -- kept for whichever of the
uint8_t _radio_sf = 0, _radio_cr = 0; // 4 radio sub-fields isn't the one actually being edited
DigitEditor _freq_ed; // FK_RADIO_FREQ's digit-cursor editor (same widget as Settings/Repeater)
// REPLY
FullscreenMsgView _reply_view;
char _reply_text[200] = "";
// "Start OTA" and "Reboot" confirmation -- both take the remote out of
// action for a while (OTA into BLE DFU mode, reboot just offline for a
// few seconds) with no way to intervene if something goes wrong on an
// unattended node, unlike the other one-shot actions on this tab. Only
// these two need a confirm today, so they're special-cased in
// activateField() by command string rather than adding a generic
// needs_confirm flag to every AdminField literal below.
// Reboot / Start OTA confirmation (admin::confirmPrompt()).
PopupMenu _confirm;
const AdminField* _pending_confirm_field = nullptr;
const admin::Field* _pending_confirm_field = nullptr;
KeyboardWidget& kb() { return _task->keyboard(); }
bool isAdminOk(const uint8_t* pub_key) const {
return _admin_ok && memcmp(_admin_ok_prefix, pub_key, 4) == 0;
}
void startLogin() {
_login_pw[0] = '\0';
_login_waiting = false;
void openLoginKb() {
kb().begin("", 15); // admin password: same max length as room/repeater login
kb().clearPlaceholders(); // {loc}/{time} are for messages, not a password
_phase = LOGIN;
_login_kb = true;
}
// Silent retry with a remembered password (see startFor()) -- same idea as
// MessagesScreen's room-login flow: no keyboard shown, just a "Logging
// in..." wait for the async result.
void startLoginWithSaved(const char* password) {
strncpy(_login_pw, password, sizeof(_login_pw) - 1);
_login_pw[sizeof(_login_pw) - 1] = '\0';
uint32_t est_timeout = 0;
bool sent = the_mesh.sendRoomLogin(_target, _login_pw, est_timeout);
_task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500);
if (sent) { _login_waiting = true; _login_deadline_ms = millis() + est_timeout + 4000; _phase = LOGIN; }
}
void sendCommand() {
uint32_t est_timeout = 0;
if (!the_mesh.sendAdminCommand(_target, _cmd_text, est_timeout)) {
_task->showAlert("Send failed", 1200);
_fetch_for_edit = false;
_pending_set_prefix = nullptr;
return;
}
_waiting = true;
// Generous margin over the base estimate, same reasoning as the DM ACK
// deadline in MessagesScreen::sendText() -- a slow multi-hop reply isn't
// prematurely shown as a timeout.
_cmd_deadline_ms = millis() + est_timeout + 4000;
_task->showAlert(_fetch_for_edit ? "Fetching..." : "Sent, waiting...", 800);
}
// Open the free-text keyboard, pre-filled with `initial`. Shared by the
// Custom-command row and remote set-only/get-then-edit fields. `cli_autocomplete`
// wires the {} picker to CLI command-name completion (Custom-command row only).
void openValueKb(const char* initial, bool cli_autocomplete = false) {
kb().begin(initial, (int)sizeof(_cmd_text) - 1); // begin() resets any previous placeholder hook
// Free-text keyboard pre-filled with `initial`; `cli_autocomplete` wires the
// {} picker to CLI command-name completion (Custom-command row only).
void openValueKb(const char* initial, bool cli_autocomplete) {
kb().begin(initial, 160);
kb().clearPlaceholders(); // a CLI command/value is literal, not a message
if (cli_autocomplete) kb().setPlaceholderRefresh(refreshCmdPlaceholders, nullptr, "Commands:");
_kb_active = true;
}
// Candidate completions for the Custom-command row's {} picker: every remotely-
// usable command from docs/cli_commands.md (Serial-Only ones and the region
// sub-grammar's many variants excluded). Entries needing a value end in a
// trailing space, ready to type it.
static const char* const CLI_COMMANDS[];
static const int CLI_COMMAND_COUNT;
// KeyboardWidget::PlaceholderRefreshFn -- repopulates the {} list with only the
// CLI_COMMANDS entries matching the word currently being typed (text since the
// last space), so it narrows as you type. An empty word matches everything.
// Repopulates the {} list with the admin::CLI_COMMANDS matching the word being
// typed (text since the last space); an empty word matches everything.
static void refreshCmdPlaceholders(KeyboardWidget& kbw, void* /*ctx*/) {
kbw.clearPlaceholders();
int word_start = kbw.len;
while (word_start > 0 && kbw.buf[word_start - 1] != ' ') word_start--;
const char* word = kbw.buf + word_start;
int word_len = kbw.len - word_start;
for (int i = 0; i < CLI_COMMAND_COUNT; i++)
if (word_len == 0 || strncmp(CLI_COMMANDS[i], word, word_len) == 0)
kbw.addPlaceholder(CLI_COMMANDS[i]);
for (int i = 0; i < admin::CLI_COMMAND_COUNT; i++)
if (word_len == 0 || strncmp(admin::CLI_COMMANDS[i], word, word_len) == 0)
kbw.addPlaceholder(admin::CLI_COMMANDS[i]);
}
// Dispatch Enter on a COMMAND row per the three field shapes (see file header).
// The trailing "Custom command..." row (both null) is handled first.
void activateField(const AdminField& f) {
_fetch_for_edit = false;
_pending_set_prefix = nullptr;
if (f.kind != FK_TEXT) { // typed editor: on/off, number, or a radio sub-field
_edit_field = &f;
_fetch_for_value = true;
strncpy(_cmd_text, f.get_cmd, sizeof(_cmd_text) - 1);
_cmd_text[sizeof(_cmd_text) - 1] = '\0';
sendCommand();
void activateField(const admin::Field& f) {
const char* prompt = admin::confirmPrompt(f);
if (prompt) {
_pending_confirm_field = &f;
_confirm.beginConfirm(prompt, f.label);
return;
}
if (f.get_cmd == nullptr && f.set_prefix == nullptr) { // Custom command...
openValueKb(_cmd_text, true);
} else if (f.set_prefix == nullptr) { // Action
if (!strcmp(f.get_cmd, "start ota")) { // see _confirm's comment
_pending_confirm_field = &f;
_confirm.beginConfirm("Start OTA update?", "Start");
return;
}
if (!strcmp(f.get_cmd, "reboot")) { // see _confirm's comment
_pending_confirm_field = &f;
_confirm.beginConfirm("Reboot node?", "Reboot");
return;
}
strncpy(_cmd_text, f.get_cmd, sizeof(_cmd_text) - 1);
_cmd_text[sizeof(_cmd_text) - 1] = '\0';
sendCommand();
} else if (f.get_cmd == nullptr) { // Set-only
_pending_set_prefix = f.set_prefix;
openValueKb("");
} else { // Get-then-edit-then-set
_pending_set_prefix = f.set_prefix;
_fetch_for_edit = true;
strncpy(_cmd_text, f.get_cmd, sizeof(_cmd_text) - 1);
_cmd_text[sizeof(_cmd_text) - 1] = '\0';
sendCommand();
if (S().run(f) && S().state() == AdminSession::WAITING)
_task->showAlert(S().fetching() ? "Fetching..." : "Sent, waiting...", 800);
}
// Right-column text for the row in _value_editing (freq draws its own editor).
void formatEditValue(const admin::Field& f, char* buf, size_t n) const {
switch (f.kind) {
case admin::K_ONOFF: snprintf(buf, n, "%s", S().value != 0 ? "ON" : "OFF"); break;
case admin::K_RADIO_BW: snprintf(buf, n, "%.1f", S().radio_bw); break;
case admin::K_RADIO_SF: snprintf(buf, n, "%d", (int)S().radio_sf); break;
case admin::K_RADIO_CR: snprintf(buf, n, "%d", (int)S().radio_cr); break;
default: snprintf(buf, n, "%d", (int)S().value); break; // K_NUMBER
}
}
// A fetch-for-edit didn't get a reply (timeout or the user cancelled the
// wait) -- fall back to a blank editor rather than dead-ending, so the field
// can still be set blind.
void fallBackToBlankEdit() {
_fetch_for_edit = false;
openValueKb("");
}
// Splits a "get radio" reply's value ("freq,bw,sf,cr") into its 4 parts.
// Self-contained (no mesh::Utils dependency) since it only ever needs to
// parse this one, fixed 4-field shape.
static bool parseRadioReply(const char* val, float& freq, float& bw, uint8_t& sf, uint8_t& cr) {
char tmp[48];
strncpy(tmp, val, sizeof(tmp) - 1);
tmp[sizeof(tmp) - 1] = '\0';
char* p = tmp;
char* parts[4];
for (int i = 0; i < 4; i++) {
parts[i] = p;
if (i < 3) {
char* c = strchr(p, ',');
if (!c) return false;
*c = '\0';
p = c + 1;
}
}
freq = strtof(parts[0], nullptr);
bw = strtof(parts[1], nullptr);
sf = (uint8_t)atoi(parts[2]);
cr = (uint8_t)atoi(parts[3]);
return true;
}
// Seeds the typed editor from a (already "> "-stripped) get-reply, per
// _edit_field->kind. Returns false on a malformed reply (caller falls back
// to an alert instead of entering an editor with garbage in it).
bool beginValueEdit(const char* val) {
switch (_edit_field->kind) {
case FK_ONOFF:
_edit_val = (memcmp(val, "on", 2) == 0) ? 1.0f : 0.0f;
return true;
case FK_NUMBER:
// Clamp in case the node's real value sits outside this row's declared
// range (e.g. set via a different firmware/build or the Custom command) --
// otherwise every LEFT/RIGHT step lands out of range and gets silently
// rejected in both directions, stranding the field on an unreachable value.
_edit_val = constrain((float)atoi(val), _edit_field->min_val, _edit_field->max_val);
return true;
case FK_RADIO_FREQ:
case FK_RADIO_BW:
case FK_RADIO_SF:
case FK_RADIO_CR:
if (!parseRadioReply(val, _radio_freq, _radio_bw, _radio_sf, _radio_cr)) return false;
if (_edit_field->kind == FK_RADIO_FREQ)
_freq_ed.begin(_radio_freq, _edit_field->min_val, _edit_field->max_val, 4, 3);
return true;
default:
return false;
}
}
// Right-column display text for the row currently in _value_editing (freq
// has its own DigitEditor::render call instead -- see render()). Matches
// RepeaterScreen's itemValue() formatting for the same quantities (plain
// %d for SF/CR, %.1f for BW) so a value reads the same wherever it's shown.
void formatEditValue(char* buf, size_t n) const {
switch (_edit_field->kind) {
case FK_ONOFF: snprintf(buf, n, "%s", _edit_val != 0 ? "ON" : "OFF"); break;
case FK_RADIO_BW: snprintf(buf, n, "%.1f", _radio_bw); break;
case FK_RADIO_SF: snprintf(buf, n, "%d", (int)_radio_sf); break;
case FK_RADIO_CR: snprintf(buf, n, "%d", (int)_radio_cr); break;
default: snprintf(buf, n, "%d", (int)_edit_val); break; // FK_NUMBER
}
}
void sent(bool ok) { if (ok) _task->showAlert("Sent, waiting...", 800); }
public:
explicit AdminScreen(UITask* task) : _task(task) {}
// Per-visit reset (see UIScreen::onShow). startFor() runs immediately after
// and sets the real phase/target; these are just safe defaults.
// Per-visit reset (see UIScreen::onShow). startFor() runs right after.
void onShow() override {
_phase = LOGIN;
_tab = 0;
_row_sel = _row_scroll = 0;
_kb_active = false;
_waiting = false;
_fetch_for_edit = false;
_pending_set_prefix = nullptr;
_fetch_for_value = false;
_value_editing = false;
_login_waiting = false;
_admin_ok = false;
_login_kb = _kb_active = _value_editing = _showing_reply = false;
_confirm.active = false;
_pending_confirm_field = nullptr;
}
// Canonical entry for a specific target -- called by UITask::openAdminFor(),
// itself reached from NearbyScreen's "Admin" context-menu action or its
// Admin-target pick-mode. Skips straight past login if this contact is already
// admin-ok this visit.
// Canonical entry for a target (UITask::openAdminFor()). The session skips
// the login for the node last logged into, and tries a saved password first.
void startFor(const ContactInfo& ci, bool from_picker) {
_target = ci;
_from_picker = from_picker;
if (isAdminOk(ci.id.pub_key)) {
_tab = ATAB_SYSTEM; _row_sel = _row_scroll = 0;
_phase = COMMAND;
} else {
char saved_pw[sizeof(_login_pw)];
if (the_mesh.getRoomPassword(ci.id.pub_key, saved_pw, sizeof(saved_pw)))
startLoginWithSaved(saved_pw);
else
startLogin();
}
}
// Async result of the on-device login, routed here from UITask::onRoomLoginResult()
// only while this screen is current (see that routing for why).
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {
// Being in LOGIN phase isn't enough on its own -- this only confirms *some*
// login is in flight for *this screen*, not that this particular reply is
// for _target. UITask::onRoomLoginResult() dispatches by whichever screen
// is current, not by who sent the request, so e.g. a slow MessagesScreen
// login reply arriving while the user has since opened Admin on a
// *different*, password-less node (still sat at the blank LOGIN keyboard,
// so _phase == LOGIN here too) would otherwise be accepted as if it were
// this node's own login -- flipping _admin_ok/_phase to COMMAND for
// _target without ever actually authenticating with it.
if (_phase != LOGIN || memcmp(_target.id.pub_key, pub_key, 4) != 0) return; // stale/unrelated result
_login_waiting = false;
if (success && (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN) {
memcpy(_admin_ok_prefix, pub_key, 4);
_admin_ok = true;
the_mesh.saveRoomPassword(pub_key, _login_pw); // remember it -- same logic as room login
_tab = ATAB_SYSTEM;
_row_sel = _row_scroll = 0;
_phase = COMMAND;
_task->showAlert("Logged in (admin)", 1000);
} else if (success) {
// Correct password, just insufficient permission -- leave any saved
// password alone, retyping the same one won't change the outcome.
_task->showAlert("Not admin on this node", 1600);
returnToOrigin();
} else {
// Wrong/stale password -- forget it so the next attempt prompts fresh,
// same self-healing behaviour as a room login.
the_mesh.forgetRoomPassword(pub_key);
_task->showAlert("Login failed", 1400);
returnToOrigin();
}
}
// Async CLI reply, routed here from UITask::onAdminReply().
void onAdminReply(const uint8_t* pub_key, const char* text) {
if (!_waiting || memcmp(_target.id.pub_key, pub_key, 4) != 0) return;
_waiting = false;
// Every "get ..." reply comes back as "> value" (see CommonCLI::handleGetCmd) --
// strip that CLI-decoration prefix before parsing/pre-filling from it. The
// final free-form REPLY view still shows `text` raw: action confirmations
// like "OK" never have the prefix, and a user-typed Custom "get ..." is
// meant to echo the raw wire reply verbatim, prefix included.
const char* val = (text[0] == '>' && text[1] == ' ') ? text + 2 : text;
if (_fetch_for_value) {
_fetch_for_value = false;
if (beginValueEdit(val)) {
_value_editing = true;
} else {
_task->showAlert("Fetch failed", 1400);
}
return;
}
if (_fetch_for_edit) {
_fetch_for_edit = false;
char trimmed[161];
strncpy(trimmed, val, sizeof(trimmed) - 1);
trimmed[sizeof(trimmed) - 1] = '\0';
size_t n = strlen(trimmed);
while (n > 0 && (trimmed[n-1] == '\n' || trimmed[n-1] == '\r' || trimmed[n-1] == ' ')) trimmed[--n] = '\0';
openValueKb(trimmed); // _pending_set_prefix is already set from activateField()
return;
}
// The "Admin password" row (SYSTEM_FIELDS, set-only) just changed the
// remote's own admin credential -- CommonCLI::handleCommand() always
// echoes it back as "password now: <value>" (see src/helpers/CommonCLI.cpp),
// truncation and all, so parsing that confirms the exact value now
// required to log back in. Without this, our saved password goes stale
// the instant this command succeeds, guaranteeing the next login attempt
// fails (see the LOGIN-phase timeout fix above for what that used to look
// like: a permanent "Logging in..." hang with a stale saved password).
static const char PW_ECHO_PREFIX[] = "password now: ";
if (!strncmp(text, PW_ECHO_PREFIX, sizeof(PW_ECHO_PREFIX) - 1)) {
char newpw[32];
strncpy(newpw, text + sizeof(PW_ECHO_PREFIX) - 1, sizeof(newpw) - 1);
newpw[sizeof(newpw) - 1] = '\0';
size_t n = strlen(newpw);
while (n > 0 && (newpw[n-1] == '\n' || newpw[n-1] == '\r' || newpw[n-1] == ' ')) newpw[--n] = '\0';
the_mesh.saveRoomPassword(_target.id.pub_key, newpw);
}
strncpy(_reply_text, text, sizeof(_reply_text) - 1);
_reply_text[sizeof(_reply_text) - 1] = '\0';
_reply_view.begin();
_phase = REPLY;
S().start(ci);
if (S().state() == AdminSession::NEED_PASSWORD) openLoginKb();
else if (S().state() == AdminSession::LOGGING_IN) _task->showAlert("Logging in...", 1000);
}
void poll() override {
if (_phase == LOGIN && _login_waiting && (int32_t)(millis() - _login_deadline_ms) >= 0) {
_login_waiting = false;
// Stop tracking this request on the MyMesh side too -- otherwise a reply
// that still arrives after we've given up gets misrouted to whatever
// screen the user is on by then (see cancelUiPendingLogin()'s comment),
// corrupting that screen's unrelated login state with our answer.
the_mesh.cancelUiPendingLogin(_target.id.pub_key);
// No response at all is ambiguous (could be a wrong/stale password, could
// just be out of range) -- but a saved password that's gone stale (the
// remote's password changed) is exactly this: silence, not a nack. Treat
// it the same as onRoomLoginResult()'s explicit-failure branch: forget it
// so the next attempt prompts fresh instead of retrying the same dead
// password forever.
the_mesh.forgetRoomPassword(_target.id.pub_key);
_task->showAlert("Login failed (timeout)", 1400);
returnToOrigin();
}
if (_phase == COMMAND && _waiting && (int32_t)(millis() - _cmd_deadline_ms) >= 0) {
_waiting = false;
if (_fetch_for_edit) { fallBackToBlankEdit(); _task->showAlert("Fetch failed - enter value", 1400); }
else if (_fetch_for_value) { _fetch_for_value = false; _task->showAlert("Fetch failed - try again", 1400); }
else { _task->showAlert("No response (timeout)", 1600); }
const admin::Field* f = S().currentField();
switch (S().take()) {
case AdminSession::LOGGED_IN:
_tab = admin::TAB_SYSTEM;
_row_sel = _row_scroll = 0;
_task->showAlert("Logged in (admin)", 1000);
break;
case AdminSession::NOT_ADMIN: _task->showAlert("Not admin on this node", 1600); returnToOrigin(); break;
case AdminSession::LOGIN_FAILED: _task->showAlert("Login failed", 1400); returnToOrigin(); break;
case AdminSession::LOGIN_TIMEOUT: _task->showAlert("Login failed (timeout)", 1400); returnToOrigin(); break;
case AdminSession::SEND_FAILED: _task->showAlert("Send failed", 1200); break;
case AdminSession::FETCH_FAILED: _task->showAlert("Fetch failed - try again", 1400); break;
case AdminSession::TIMEOUT: _task->showAlert("No response (timeout)", 1600); break;
case AdminSession::REPLY:
_reply_view.begin();
_showing_reply = true;
break;
case AdminSession::VALUE_READY:
if (f && f->kind == admin::K_RADIO_FREQ) _freq_ed.begin(S().radio_freq, f->min_val, f->max_val, 4, 3);
_value_editing = true;
break;
case AdminSession::TEXT_READY:
if (f && f->get_cmd && !S().text()[0]) _task->showAlert("Fetch failed - enter value", 1400);
openValueKb(S().text(), f && f->isCustom());
break;
default: break;
}
}
@@ -495,266 +166,133 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
if (_phase == LOGIN) {
if (_login_waiting) {
display.drawCenteredHeader("ADMIN LOGIN");
display.setCursor(2, display.listStart());
display.print("Logging in...");
return 500;
}
return kb().render(display);
if (_login_kb) return kb().render(display);
AdminSession::State st = S().state();
if (st == AdminSession::LOGGING_IN || st == AdminSession::IDLE || st == AdminSession::NEED_PASSWORD) {
display.drawCenteredHeader("ADMIN LOGIN");
display.setCursor(2, display.listStart());
if (st == AdminSession::LOGGING_IN) display.print("Logging in...");
return 500;
}
if (_showing_reply) return _reply_view.render(display, S().target().name, S().reply(), false, false);
if (_kb_active) return kb().render(display);
if (st == AdminSession::WAITING) {
char title[24];
snprintf(title, sizeof(title), "%.23s", S().target().name);
display.drawCenteredHeader(title);
display.setCursor(2, display.listStart());
display.print(S().fetching() ? "Fetching..." : "Waiting for reply...");
return 500;
}
if (_phase == COMMAND) {
if (_kb_active) return kb().render(display);
if (_waiting) {
char title[24];
snprintf(title, sizeof(title), "%.23s", _target.name);
display.drawCenteredHeader(title);
display.setCursor(2, display.listStart());
display.print((_fetch_for_edit || _fetch_for_value) ? "Fetching..." : "Waiting for reply...");
return 500;
}
tabbar::draw(display, TAB_LABELS, ATAB_COUNT, _tab);
int n = ROWS_PER_TAB[_tab];
int mq_delay = 0;
drawList(display, n, _row_sel, _row_scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
const AdminField& f = fieldAt(_tab, i);
bool show_val = _value_editing && sel; // the row whose typed editor is currently open
// Leave room for the value column on the row currently showing one, so a
// long label (e.g. "Flood advert interval (h)") can't run under/through
// the value being edited -- only rows without an inline value get the
// full row width.
int label_max = show_val ? display.valCol() - 4 : display.width() - 4 - reserve;
int r = display.drawTextEllipsized(2, y, label_max, f.label, sel);
if (sel && r > 0) mq_delay = r;
if (show_val) {
if (f.kind == FK_RADIO_FREQ) {
// valCol() reserves exactly the 8-char width this editor draws (4
// int + '.' + 3 dec), so with no margin to spare, a visible
// scrollbar's reserve column would otherwise swallow the last
// (thousandths) digit -- shift left to stay clear of it, same as
// the plain-value branch below.
_freq_ed.render(display, display.valCol() - reserve, y);
} else {
char val[16];
formatEditValue(val, sizeof(val));
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
}
tabbar::draw(display, admin::TAB_LABELS, admin::TAB_COUNT, _tab);
int n = admin::rowCount(_tab);
int mq_delay = 0;
drawList(display, n, _row_sel, _row_scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
const admin::Field& f = admin::field(_tab, i);
bool show_val = _value_editing && sel; // the row whose typed editor is open
// Room for the value column only on the row showing one, so a long label
// can't run under the value being edited.
int label_max = show_val ? display.valCol() - 4 : display.width() - 4 - reserve;
int r = display.drawTextEllipsized(2, y, label_max, f.label, sel);
if (sel && r > 0) mq_delay = r;
if (show_val) {
if (f.kind == admin::K_RADIO_FREQ) {
// valCol() reserves exactly this editor's 8 chars; keep clear of a
// visible scrollbar's reserve column (same as the plain value below).
_freq_ed.render(display, display.valCol() - reserve, y);
} else {
char val[16];
formatEditValue(f, val, sizeof(val));
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
}
});
if (_confirm.active) { _confirm.render(display); return 50; }
if (_value_editing) return 50;
return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000;
}
// REPLY
return _reply_view.render(display, _target.name, _reply_text, false, false);
}
});
if (_confirm.active) { _confirm.render(display); return 50; }
if (_value_editing) return 50;
return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000;
}
bool handleInput(char c) override {
if (_phase == LOGIN) {
if (_login_waiting) {
if (c == KEY_CANCEL) {
_login_waiting = false;
the_mesh.cancelUiPendingLogin(_target.id.pub_key); // see cancelUiPendingLogin()'s comment
returnToOrigin();
}
return true;
}
if (_login_kb) {
auto r = kb().handleInput(c);
if (r == KeyboardWidget::CANCELLED) {
_login_kb = false;
returnToOrigin();
} else if (r == KeyboardWidget::DONE) {
strncpy(_login_pw, kb().buf, sizeof(_login_pw) - 1);
_login_pw[sizeof(_login_pw) - 1] = '\0';
uint32_t est_timeout = 0;
bool sent = the_mesh.sendRoomLogin(_target, _login_pw, est_timeout);
_task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500);
if (sent) { _login_waiting = true; _login_deadline_ms = millis() + est_timeout + 4000; } else returnToOrigin();
// else: stay in LOGIN until onRoomLoginResult() fires above.
_login_kb = false;
if (S().login(kb().buf)) _task->showAlert("Logging in...", 1000);
// else: LOGIN_FAILED arrives through poll()
}
return true;
}
if (_phase == COMMAND) {
if (_confirm.active) {
auto res = _confirm.handleInput(c);
if (res == PopupMenu::SELECTED) {
if (_confirm.selectedIndex() == 0 && _pending_confirm_field) {
strncpy(_cmd_text, _pending_confirm_field->get_cmd, sizeof(_cmd_text) - 1);
_cmd_text[sizeof(_cmd_text) - 1] = '\0';
sendCommand();
}
_pending_confirm_field = nullptr;
} else if (res == PopupMenu::CANCELLED) {
_pending_confirm_field = nullptr;
}
return true;
}
if (_kb_active) {
auto r = kb().handleInput(c);
if (r == KeyboardWidget::DONE) {
_kb_active = false;
char edited[sizeof(_cmd_text)];
strncpy(edited, kb().buf, sizeof(edited) - 1);
edited[sizeof(edited) - 1] = '\0';
if (_pending_set_prefix) {
snprintf(_cmd_text, sizeof(_cmd_text), "%s %s", _pending_set_prefix, edited);
_pending_set_prefix = nullptr;
sendCommand();
} else {
strncpy(_cmd_text, edited, sizeof(_cmd_text) - 1);
_cmd_text[sizeof(_cmd_text) - 1] = '\0';
if (_cmd_text[0]) sendCommand();
}
} else if (r == KeyboardWidget::CANCELLED) {
_kb_active = false;
_pending_set_prefix = nullptr;
}
return true;
}
if (_value_editing) {
bool commit = false;
if (_edit_field->kind == FK_RADIO_FREQ) {
auto r = _freq_ed.handleInput(c);
if (r == DigitEditor::DONE) { _radio_freq = _freq_ed.value; commit = true; }
else if (r == DigitEditor::CANCELLED) { _value_editing = false; }
} else if (keyIsPrev(c) || keyIsNext(c)) {
int dir = keyIsNext(c) ? 1 : -1;
switch (_edit_field->kind) {
case FK_ONOFF: _edit_val = (_edit_val != 0) ? 0.0f : 1.0f; break;
case FK_NUMBER: {
float nv = _edit_val + dir * _edit_field->step;
if (nv >= _edit_field->min_val && nv <= _edit_field->max_val) _edit_val = nv;
break;
}
case FK_RADIO_BW: RadioParamsEditor::stepBW(_radio_bw, dir); break;
case FK_RADIO_SF: RadioParamsEditor::stepSF(_radio_sf, dir); break;
case FK_RADIO_CR: RadioParamsEditor::stepCR(_radio_cr, dir); break;
default: break;
}
} else if (c == KEY_ENTER) {
commit = true;
} else if (c == KEY_CANCEL) {
_value_editing = false;
}
if (commit) {
_value_editing = false;
switch (_edit_field->kind) {
case FK_RADIO_FREQ: case FK_RADIO_BW: case FK_RADIO_SF: case FK_RADIO_CR:
snprintf(_cmd_text, sizeof(_cmd_text), "%s %.3f,%.3f,%d,%d",
_edit_field->set_prefix, _radio_freq, _radio_bw, (int)_radio_sf, (int)_radio_cr);
break;
case FK_ONOFF:
snprintf(_cmd_text, sizeof(_cmd_text), "%s %s", _edit_field->set_prefix, _edit_val != 0 ? "on" : "off");
break;
default: // FK_NUMBER
snprintf(_cmd_text, sizeof(_cmd_text), "%s %d", _edit_field->set_prefix, (int)_edit_val);
break;
}
sendCommand();
}
return true;
}
if (_waiting) {
if (c == KEY_CANCEL) {
_waiting = false;
if (_fetch_for_edit) fallBackToBlankEdit();
else if (_fetch_for_value) _fetch_for_value = false;
}
return true;
}
if (c == KEY_CANCEL) { returnToOrigin(); return true; }
if (keyIsPrev(c)) { _tab = (_tab + ATAB_COUNT - 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; }
if (keyIsNext(c)) { _tab = (_tab + 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; }
int n = ROWS_PER_TAB[_tab];
if (c == KEY_UP) { _row_sel = (_row_sel > 0) ? _row_sel - 1 : n - 1; return true; }
if (c == KEY_DOWN) { _row_sel = (_row_sel < n - 1) ? _row_sel + 1 : 0; return true; }
if (c == KEY_ENTER) { activateField(fieldAt(_tab, _row_sel)); return true; }
AdminSession::State st = S().state();
if (st == AdminSession::LOGGING_IN) {
if (c == KEY_CANCEL) returnToOrigin();
return true;
}
if (st == AdminSession::IDLE || st == AdminSession::NEED_PASSWORD) return true; // poll() leaves shortly
// REPLY
auto res = _reply_view.handleInput(c);
if (res != FullscreenMsgView::NONE) _phase = COMMAND; // CLOSE / PREV / NEXT / REPLY all just return here
if (_showing_reply) {
if (_reply_view.handleInput(c) != FullscreenMsgView::NONE) _showing_reply = false; // any close returns here
return true;
}
if (_confirm.active) {
auto res = _confirm.handleInput(c);
if (res == PopupMenu::SELECTED && _confirm.selectedIndex() == 0 && _pending_confirm_field)
sent(S().run(*_pending_confirm_field));
if (res == PopupMenu::SELECTED || res == PopupMenu::CANCELLED) _pending_confirm_field = nullptr;
return true;
}
if (_kb_active) {
auto r = kb().handleInput(c);
if (r == KeyboardWidget::DONE) {
_kb_active = false;
sent(S().sendSet(kb().buf));
} else if (r == KeyboardWidget::CANCELLED) {
_kb_active = false;
}
return true;
}
if (_value_editing) {
const admin::Field* f = S().currentField();
if (!f) { _value_editing = false; return true; }
bool commit = false;
if (f->kind == admin::K_RADIO_FREQ) {
auto r = _freq_ed.handleInput(c);
if (r == DigitEditor::DONE) { S().radio_freq = _freq_ed.value; commit = true; }
else if (r == DigitEditor::CANCELLED) { _value_editing = false; }
} else if (keyIsPrev(c) || keyIsNext(c)) {
int dir = keyIsNext(c) ? 1 : -1;
switch (f->kind) {
case admin::K_RADIO_BW: RadioParamsEditor::stepBW(S().radio_bw, dir); break;
case admin::K_RADIO_SF: RadioParamsEditor::stepSF(S().radio_sf, dir); break;
case admin::K_RADIO_CR: RadioParamsEditor::stepCR(S().radio_cr, dir); break;
default: S().stepValue(dir); break;
}
} else if (c == KEY_ENTER) {
commit = true;
} else if (c == KEY_CANCEL) {
_value_editing = false;
}
if (commit) {
_value_editing = false;
sent(S().sendValue());
}
return true;
}
if (st == AdminSession::WAITING) {
if (c == KEY_CANCEL) S().cancelWait(); // a text fetch still opens a blank editor (poll())
return true;
}
if (c == KEY_CANCEL) { returnToOrigin(); return true; }
if (keyIsPrev(c)) { _tab = (_tab + admin::TAB_COUNT - 1) % admin::TAB_COUNT; _row_sel = _row_scroll = 0; return true; }
if (keyIsNext(c)) { _tab = (_tab + 1) % admin::TAB_COUNT; _row_sel = _row_scroll = 0; return true; }
int n = admin::rowCount(_tab);
if (c == KEY_UP) { _row_sel = (_row_sel > 0) ? _row_sel - 1 : n - 1; return true; }
if (c == KEY_DOWN) { _row_sel = (_row_sel < n - 1) ? _row_sel + 1 : 0; return true; }
if (c == KEY_ENTER) { activateField(admin::field(_tab, _row_sel)); return true; }
return true;
}
};
const char* AdminScreen::TAB_LABELS[AdminScreen::ATAB_COUNT] = { "System", "Radio", "Routing", "Actions" };
const AdminScreen::AdminField AdminScreen::SYSTEM_FIELDS[] = {
{ "Name", "get name", "set name" },
{ "Owner info", "get owner.info", "set owner.info" },
{ "Admin password", nullptr, "password" },
};
// The 4 radio rows all share get_cmd/set_prefix ("get radio" / "set radio") --
// each fetches and re-sends the whole f,bw,sf,cr tuple, only its own value
// actually editable (see beginValueEdit()/the commit switch in handleInput()).
const AdminScreen::AdminField AdminScreen::RADIO_FIELDS[] = {
{ "Frequency (MHz)", "get radio", "set radio", AdminScreen::FK_RADIO_FREQ, 150.0f, 2500.0f },
{ "Bandwidth (kHz)", "get radio", "set radio", AdminScreen::FK_RADIO_BW },
{ "Spreading factor", "get radio", "set radio", AdminScreen::FK_RADIO_SF },
{ "Coding rate", "get radio", "set radio", AdminScreen::FK_RADIO_CR },
{ "TX power (dBm)", "get tx", "set tx", AdminScreen::FK_NUMBER, -9, 30, 1 },
};
const AdminScreen::AdminField AdminScreen::ROUTING_FIELDS[] = {
{ "Repeat", "get repeat", "set repeat", AdminScreen::FK_ONOFF },
{ "Advert interval (min)", "get advert.interval", "set advert.interval", AdminScreen::FK_NUMBER, 0, 240, 2 },
{ "Flood advert interval (h)", "get flood.advert.interval", "set flood.advert.interval", AdminScreen::FK_NUMBER, 0, 168, 1 },
{ "Max hops", "get flood.max", "set flood.max", AdminScreen::FK_NUMBER, 0, 64, 1 },
};
const AdminScreen::AdminField AdminScreen::ACTION_FIELDS[] = {
{ "Send advert", "advert", nullptr },
{ "Send zero-hop advert", "advert.zerohop", nullptr },
{ "Sync clock", "clock sync", nullptr },
{ "Reboot", "reboot", nullptr }, // confirmed first -- see _confirm's comment
{ "Start OTA", "start ota", nullptr }, // confirmed first -- see _confirm's comment
{ "Custom command...", nullptr, nullptr },
};
const int AdminScreen::ROWS_PER_TAB[AdminScreen::ATAB_COUNT] = {
sizeof(AdminScreen::SYSTEM_FIELDS) / sizeof(AdminScreen::AdminField),
sizeof(AdminScreen::RADIO_FIELDS) / sizeof(AdminScreen::AdminField),
sizeof(AdminScreen::ROUTING_FIELDS) / sizeof(AdminScreen::AdminField),
sizeof(AdminScreen::ACTION_FIELDS) / sizeof(AdminScreen::AdminField),
};
const char* const AdminScreen::CLI_COMMANDS[] = {
"reboot", "poweroff", "shutdown", "clkreboot", "clock", "clock sync",
"advert", "advert.zerohop", "start ota", "erase",
"neighbors", "neighbor.remove ", "discover.neighbors",
"ver", "board",
"get radio", "set radio ", "get tx", "set tx ", "tempradio ",
"get freq", "set freq ", "get radio.rxgain", "set radio.rxgain ",
"get name", "set name ", "get lat", "set lat ", "get lon", "set lon ",
"get owner.info", "set owner.info ", "get adc.multiplier", "set adc.multiplier ",
"get public.key", "get role",
"powersaving", "powersaving on", "powersaving off",
"get repeat", "set repeat ",
"get path.hash.mode", "set path.hash.mode ",
"get loop.detect", "set loop.detect ",
"get txdelay", "set txdelay ", "get direct.txdelay", "set direct.txdelay ",
"get rxdelay", "set rxdelay ", "get dutycycle", "set dutycycle ",
"get af", "set af ", "get int.thresh", "set int.thresh ",
"get agc.reset.interval", "set agc.reset.interval ",
"get multi.acks", "set multi.acks ",
"get flood.advert.interval", "set flood.advert.interval ",
"get advert.interval", "set advert.interval ",
"get flood.max", "set flood.max ",
"get flood.max.unscoped", "set flood.max.unscoped ",
"get flood.max.advert", "set flood.max.advert ",
"setperm ", "get acl", "get allow.read.only", "set allow.read.only ",
"region", "region load", "region save", "region list ",
"gps", "gps sync", "gps setloc", "gps advert ",
"sensor list", "sensor get ", "sensor set ",
"get bridge.type", "get bridge.enabled", "set bridge.enabled ",
"get bridge.delay", "set bridge.delay ", "get bridge.source", "set bridge.source ",
"get bridge.baud", "set bridge.baud ", "get bridge.channel", "set bridge.channel ",
"get bridge.secret", "set bridge.secret ",
"get pwrmgt.support", "get pwrmgt.source", "get pwrmgt.bootreason", "get pwrmgt.bootmv",
"password ", "get guest.password", "set guest.password ",
};
const int AdminScreen::CLI_COMMAND_COUNT = sizeof(AdminScreen::CLI_COMMANDS) / sizeof(AdminScreen::CLI_COMMANDS[0]);
+4 -7
View File
@@ -1898,11 +1898,9 @@ bool UITask::getAnyUnreadOverflow() const {
}
void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {
// Only one on-device login can be in flight at a time (MyMesh::ui_pending_login
// is a single slot) -- route the result to whichever of the two screens that
// can trigger a login is currently active, rather than always MessagesScreen.
if (curr == admin_screen) ((AdminScreen*)admin_screen)->onRoomLoginResult(pub_key, success, permissions);
else ((MessagesScreen*)messages_screen)->onRoomLoginResult(pub_key, success, permissions);
// Admin logins are answered to the Core's AdminSession; what reaches here is
// MessagesScreen's room login.
((MessagesScreen*)messages_screen)->onRoomLoginResult(pub_key, success, permissions);
// Unlike the keypress-driven showAlert() calls elsewhere, this fires from a
// background mesh response with no keypress to schedule a redraw — without
// forcing one, the alert's short expiry can lapse before the next scheduled
@@ -1910,8 +1908,7 @@ void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t per
_next_refresh = 0;
}
void UITask::onAdminReply(const uint8_t* pub_key, const char* text) {
((AdminScreen*)admin_screen)->onAdminReply(pub_key, text);
void UITask::onAdminStateChanged() {
_next_refresh = 0; // same reasoning as onRoomLoginResult above
}
+1 -1
View File
@@ -357,7 +357,7 @@ public:
bool isViewingDM(const uint8_t* pub_key) override;
void onViewedHistoryGrew(bool channel) override;
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override;
void onAdminReply(const uint8_t* pub_key, const char* text) override;
void onAdminStateChanged() override;
int getDMUnreadTotal() const;
int getMsgCount() const;
int getChannelUnreadCount() const;