mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-09 11:16:39 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user