From e2399b5e0d7d198166fae4679699a589b47b29a6 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:59:00 +0200 Subject: [PATCH] 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 --- docs/development/l2-hardware-checklist.md | 14 + docs/development/ui-core.md | 2 +- .../companion_radio/ui-core/AdminSession.h | 409 +++++++++ examples/companion_radio/ui-core/UiCore.h | 14 +- examples/companion_radio/ui-core/UiCoreHost.h | 9 +- .../companion_radio/ui-lvgl/AdminScreen.h | 423 +++++++++ examples/companion_radio/ui-lvgl/UITask.cpp | 12 +- examples/companion_radio/ui-lvgl/UITask.h | 18 +- examples/companion_radio/ui-new/AdminScreen.h | 850 ++++-------------- examples/companion_radio/ui-new/UITask.cpp | 11 +- examples/companion_radio/ui-new/UITask.h | 2 +- 11 files changed, 1091 insertions(+), 673 deletions(-) create mode 100644 examples/companion_radio/ui-core/AdminSession.h create mode 100644 examples/companion_radio/ui-lvgl/AdminScreen.h diff --git a/docs/development/l2-hardware-checklist.md b/docs/development/l2-hardware-checklist.md index e02aaf58..8ab5157c 100644 --- a/docs/development/l2-hardware-checklist.md +++ b/docs/development/l2-hardware-checklist.md @@ -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 diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index fadc2531..8a38c7f0 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -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`: diff --git a/examples/companion_radio/ui-core/AdminSession.h b/examples/companion_radio/ui-core/AdminSession.h new file mode 100644 index 00000000..1959c645 --- /dev/null +++ b/examples/companion_radio/ui-core/AdminSession.h @@ -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 // 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: " ", 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 " echoes "password now: " (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; +}; diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index c13b2741..bd7e4d74 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -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 { diff --git a/examples/companion_radio/ui-core/UiCoreHost.h b/examples/companion_radio/ui-core/UiCoreHost.h index c707a7d8..5d2e8d68 100644 --- a/examples/companion_radio/ui-core/UiCoreHost.h +++ b/examples/companion_radio/ui-core/UiCoreHost.h @@ -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) {} diff --git a/examples/companion_radio/ui-lvgl/AdminScreen.h b/examples/companion_radio/ui-lvgl/AdminScreen.h new file mode 100644 index 00000000..78ac1dfe --- /dev/null +++ b/examples/companion_radio/ui-lvgl/AdminScreen.h @@ -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(); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index f14162ca..554ee049 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -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 ────────────────────────────────────────────────────────────── diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 08f5c6a5..fe5342e3 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -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); diff --git a/examples/companion_radio/ui-new/AdminScreen.h b/examples/companion_radio/ui-new/AdminScreen.h index cba7498e..1b588466 100644 --- a/examples/companion_radio/ui-new/AdminScreen.h +++ b/examples/companion_radio/ui-new/AdminScreen.h @@ -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 " " -// 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 // 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 " 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: " (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]); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index eabdff60..08d8f3be 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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 } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index de81a161..2e193c71 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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;