feat(admin): local-device mode + reuse Nodes screen for target picking

Tools > Admin now opens on a This-device / Remote-node chooser. "This
device" is a login-free 2-tab carousel (System: Name/Radio/TX power/
Lat/Lon, Actions: Send advert/Reboot) mapped onto NodePrefs/sensors and
reusing Settings' own apply chains -- no CommonCLI port. "Remote node..."
opens Tools > Nodes in a pick-mode (borrow-another-screen's-list idiom),
and Nodes gains an "Admin" Hold-Enter action; both converge on the single
canonical UITask::openAdminFor() -> AdminScreen::startFor().

Also:
- KeyboardWidget: opt-in PlaceholderRefreshFn hook for contextual {}-key
  CLI command-name completion (word-replacing); default behaviour and all
  other keyboard uses unchanged.
- TabBar: ellipsize() re-measures the real "text..." candidate per step
  (+ width clamp) so dots can't spill into a neighbour/reserved icon.
- ChannelsView: form rows truncate instead of wrapping.
- Reboot moved off the default-selected Actions row (fires with no confirm).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-07-12 20:32:04 +02:00
parent f399298fa6
commit 9bfccf4cf4
7 changed files with 472 additions and 148 deletions

View File

@@ -41,6 +41,7 @@ Select a node to see its coordinates, distance, bearing with cardinal direction,
| Ping | a public key is known for the node |
| Save waypoint | selected node has GPS |
| Set as target | selected node has GPS **and** a public key — pins it as the active **Locator/Nav target** right away (see **Locator**) |
| Admin | selected node is a saved **repeater or room server** contact — opens **Tools Admin** for it directly (see **Admin**) |
| Sort: Dist/Recent | browsing stored nodes — **LEFT/RIGHT** on the row flips distance ↔ last-heard in place |
| Discover scan / Rescan | always (live `NODE_DISCOVER_REQ` scan) |
@@ -516,27 +517,42 @@ Live forwarding stats — **Forwarded**, **Pool free**, **Queue** — are shown
<!-- screenshot pending: Admin — target picker, command entry, reply view -->
Send CLI commands to a **repeater or room server you have admin permission on** and see the text reply — the on-device equivalent of the companion app's repeater-admin feature. See [CLI Commands](../../cli_commands.md) for the full command grammar.
Send commands to **this device**, or to a **repeater/room server you have admin permission on** — the on-device equivalent of the companion app's repeater-admin feature for remote nodes, plus a local shim over settings this device already has. See [CLI Commands](../../cli_commands.md) for the full remote command grammar.
1. **Select a node** — the list shows every repeater/room-server contact. Press **Enter** to pick one.
2. **Log in** — type the node's **admin password** (this is the same login handshake Messages uses for room servers; a repeater's admin password is set with the `password` CLI command). If a password was already saved for this node from an earlier successful login, it retries silently instead of prompting. Only a login that comes back with **admin**-level permission unlocks the next step — anything less shows "Not admin on this node" and returns to the list.
Opening **Tools Admin** shows a two-row chooser: **This device** or **Remote node...**.
### Remote node
1. **Select a node** — "Remote node..." opens **Tools Nodes** (the same screen, filters, sort and live scan as browsing it normally) so picking a node for Admin looks exactly like using Nodes for anything else; **Enter** on a repeater/room row hands it to Admin. Admin is also reachable directly from a node's own **Hold Enter** menu in Nodes, without going through the chooser at all.
2. **Log in** — type the node's **admin password** (the same login handshake Messages uses for room servers; a repeater's admin password is set with the `password` CLI command). If a password was already saved for this node from an earlier successful login, it retries silently instead of prompting. Only a login that comes back with **admin**-level permission unlocks the next step — anything less shows "Not admin on this node".
3. **Pick a category and a field** — a tab carousel (**LEFT/RIGHT** to switch category, **UP/DOWN** to move within it, same as Auto-Reply Bot's tabs), so common settings don't need the CLI grammar memorised:
| Tab | Rows |
| --- | ---- |
| **System** | Name, Owner info, Admin password |
| **Radio** | Radio (freq, bandwidth, spreading factor, coding rate), TX power |
| **Routing** | Repeat, Advert interval, Flood advert interval, Max hops |
| **Actions** | Reboot, Send advert, Send zero-hop advert, Sync clock, **Custom command...** |
| Tab | Rows |
| --- | ---- |
| **System** | Name, Owner info, Admin password |
| **Radio** | Radio (freq, bandwidth, spreading factor, coding rate), TX power |
| **Routing** | Repeat, Advert interval, Flood advert interval, Max hops |
| **Actions** | Send advert, Send zero-hop advert, Sync clock, Reboot, **Custom command...** |
**Enter** on a row does one of three things, depending on the field:
**Enter** on a row does one of three things, depending on the field:
- Most **System/Radio/Routing** rows first **fetch** the node's current value, then open the keyboard **pre-filled** with it to edit — submitting sends the change. If the fetch fails or times out, the keyboard still opens (blank), so the value can be set blind.
- **Admin password** has no fetch (there's no way to read a password back) — it opens straight to a blank keyboard.
- **Actions** (Reboot, Send advert, …) send immediately, no editing step.
- **Custom command...** (last row of Actions) opens the same free-text entry for anything not covered above — up to 160 characters, see the linked reference for the full grammar.
- **Custom command...** (last row of Actions) opens the same free-text entry for anything not covered above — up to 160 characters, see the linked reference for the full grammar. The keyboard's **{}** key doubles as command completion here: it lists commands matching whatever's typed since the last space (narrowing as you type), and picking one completes that word instead of just inserting after it.
4. **Read the reply** — the text reply opens in a scrollable view (**UP/DOWN** to scroll, **Cancel/Enter** to go back to the category tabs).
> [!WARNING]
> This screen can run **destructive** commands on the *remote* node — `reboot`, `erase`, a new admin password, and others. That's the same capability the phone app's repeater-admin feature already exposes, not a new risk, but double-check the value and the target before sending.
**Passwords are remembered across reboots**, the same self-healing behaviour as room logins in Messages: after a successful admin login the password is saved on the device, so picking that node again — even after a power cycle — logs back in silently. If a saved password stops working (e.g. it was changed on the node), the failed login forgets it, so the next pick prompts for a new one. A correct password that just lacks admin permission is left alone — retyping the same one wouldn't change the outcome. Some commands are marked **Serial Only** in the CLI reference — those reject a remote CLI request and only work over that node's own USB serial connection.
### This device
A lighter, login-free version of the same tab carousel, for settings this device already has:
| Tab | Rows |
| --- | ---- |
| **System** | Name, Radio (freq, bandwidth, spreading factor, coding rate), TX power, Lat, Lon |
| **Actions** | Send advert, Reboot |
There's no fetch step here — the current value is already known, so **Enter** opens the keyboard pre-filled with it immediately, and submitting applies the change at once (the same apply logic Settings' own Radio/TX power rows use) and saves it. Reboot and Send advert fire immediately, same as their remote counterparts. This is a fast, lightly-validated shortcut alongside Settings' own fuller editors (radio presets, chip-bounds frequency entry, etc.) — for careful bounds-checked editing, prefer Settings.

View File

@@ -1,33 +1,28 @@
#pragma once
// Tools > Admin: send CLI commands to a repeater/room server you have admin
// permission on, and see the text reply -- the on-device equivalent of the
// companion app's "repeater admin" feature (see docs/cli_commands.md for the
// command grammar). The wire protocol already supports this in full
// (MyMesh::sendRoomLogin() / sendAdminCommand() / onCommandDataRecv()); this
// screen is just the missing UI layer.
// Tools > Admin: administer a remote repeater/room you have admin rights on
// (over the mesh -- the on-device equivalent of the app's "repeater admin",
// see docs/cli_commands.md) or this device itself. Also reachable from Tools >
// Nodes' Hold-Enter menu (NearbyScreen::ACT_ADMIN).
//
// Credentials are session-only by design: the admin password is typed fresh
// each time this screen is entered for a given contact (onShow() clears the
// one-slot "logged in" memo below) rather than persisted like room chat
// passwords are -- admin actions are infrequent, and keeping them out of the
// same store as room chat credentials keeps the two concerns separate.
// CHOOSER -> "This device" (LOCAL mode) or "Remote node..." (opens Tools > Nodes
// in pick-mode -- NearbyScreen::startPickAdminTarget(), the same borrow-another-
// screen's-list idiom the channel/bot pickers use, so it looks exactly like
// normal Nodes browsing). Both the picker and the Nodes context-menu converge
// on the single canonical UITask::openAdminFor(ContactInfo) -> startFor().
//
// Once logged in, commands are organised into a category tab carousel (same
// "true carousel" idiom BotScreen uses: LEFT/RIGHT exclusively switches tabs,
// UP/DOWN moves within the active tab's rows, Enter drives everything else)
// instead of a single free-text box, so common settings don't need the CLI
// grammar memorised. Each row is one of three shapes, driven by a single
// AdminField{label, get_cmd, set_prefix} table per tab -- see fieldAt():
// - set_prefix == nullptr -> action: get_cmd is the literal command,
// sent immediately (e.g. "reboot").
// - get_cmd == nullptr -> set-only: opens a blank keyboard for the
// new value (there's no getter for it).
// - both set -> get-then-edit-then-set: fetches get_cmd,
// opens the keyboard pre-filled with the
// raw reply, sends "<set_prefix> <edited>".
// The trailing "Custom command..." row (both null) is the escape hatch: it
// opens the same free-text entry this screen originally shipped with, for
// anything not covered by a field row.
// REMOTE mode: login (session password, self-heals like room logins) unlocks a
// category tab carousel of AdminField{label, get_cmd, set_prefix} rows:
// set_prefix==null -> action: send get_cmd literally (e.g. "reboot")
// get_cmd==null -> set-only: blank keyboard, send "<set_prefix> <value>"
// both set -> get, pre-fill keyboard with the reply, then set
// The trailing "Custom command..." row (both null) is a free-text escape hatch
// with {}-key CLI command-name completion (CLI_COMMANDS).
//
// LOCAL mode: no login, no network -- a 2-tab carousel of LocalField{label, kind}
// rows mapped straight onto NodePrefs/sensors, reusing Settings' own apply chains
// (UITask::applyRadioParams()/applyTxPower(), MyMesh::savePrefs()) rather than a
// CLI-grammar port (this firmware has no CommonCLI -- see CODE_REVIEW.md). Fields
// pre-fill with the current value read synchronously and write+apply on submit.
#include "FullscreenMsgView.h"
#include "TabBar.h"
@@ -36,17 +31,16 @@
class AdminScreen : public UIScreen {
UITask* _task;
enum Phase : uint8_t { PICKER, LOGIN, COMMAND, REPLY };
Phase _phase = PICKER;
enum Phase : uint8_t { CHOOSER, LOGIN, COMMAND, REPLY };
Phase _phase = CHOOSER;
// PICKER -- contacts of type ADV_TYPE_REPEATER or ADV_TYPE_ROOM only; a
// plain chat contact can never have an admin ACL entry to log into.
int _sel = 0, _scroll = 0;
int _num = 0;
uint16_t _indices[MAX_CONTACTS];
ContactInfo _target;
// CHOOSER -- "This device" (row 0) / "Remote node..." (row 1). Reuses _sel.
int _sel = 0;
// LOGIN
bool _local_mode = false; // COMMAND is administering this device, not _target
ContactInfo _target; // valid only when !_local_mode
// LOGIN (remote only)
char _login_pw[16] = "";
// True while waiting for the result of a login attempt with no keyboard on
// screen -- either a silent retry with a remembered password, or right after
@@ -57,11 +51,11 @@ class AdminScreen : public UIScreen {
// One-slot memo: the last contact this screen 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() -- see the
// session-only design note above.
// session-only design note in the file header.
uint8_t _admin_ok_prefix[4] = { 0, 0, 0, 0 };
bool _admin_ok = false;
// COMMAND -- category tabs + rows (see file header for the row shapes).
// ── COMMAND: remote (CLI-grammar) tabs/rows ─────────────────────────────────
enum AdminTab : uint8_t { ATAB_SYSTEM, ATAB_RADIO, ATAB_ROUTING, ATAB_ACTIONS, ATAB_COUNT };
static const char* TAB_LABELS[ATAB_COUNT];
@@ -81,33 +75,43 @@ class AdminScreen : public UIScreen {
}
}
uint8_t _tab = ATAB_SYSTEM;
// ── COMMAND: local (this-device) tabs/rows ──────────────────────────────────
static const int LOCAL_TAB_COUNT = 2;
static const char* LOCAL_TAB_LABELS[LOCAL_TAB_COUNT];
enum LocalKind : uint8_t { LK_NONE, LK_NAME, LK_RADIO, LK_TXPOWER, LK_LAT, LK_LON, LK_REBOOT, LK_ADVERT };
struct LocalField { const char* label; LocalKind kind; };
static const LocalField LOCAL_SYSTEM_FIELDS[];
static const LocalField LOCAL_ACTION_FIELDS[];
static const int LOCAL_ROWS_PER_TAB[LOCAL_TAB_COUNT];
static const LocalField& localFieldAt(int tab, int row) {
return (tab == 0) ? LOCAL_SYSTEM_FIELDS[row] : LOCAL_ACTION_FIELDS[row];
}
int tabCount() const { return _local_mode ? LOCAL_TAB_COUNT : (int)ATAB_COUNT; }
int rowsInTab(int tab) const { return _local_mode ? LOCAL_ROWS_PER_TAB[tab] : ROWS_PER_TAB[tab]; }
const char* rowLabel(int tab, int row) const {
return _local_mode ? localFieldAt(tab, row).label : fieldAt(tab, row).label;
}
uint8_t _tab = 0;
int _row_sel = 0, _row_scroll = 0;
bool _kb_active = false;
char _cmd_text[161] = "";
bool _waiting = false;
char _cmd_text[161] = ""; // remote: the command being built/sent
bool _waiting = false; // remote: 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 => the edited keyboard text gets this prefix on submit
bool _fetch_for_edit = false; // remote: true while waiting on a "get" whose reply opens an editor
const char* _pending_set_prefix = nullptr; // remote: non-null => edited keyboard text gets this prefix on submit
LocalKind _pending_local_kind = LK_NONE; // local: which field the open keyboard is editing
// REPLY
// REPLY (remote only)
FullscreenMsgView _reply_view;
char _reply_text[200] = "";
KeyboardWidget& kb() { return _task->keyboard(); }
void buildList() {
ContactInfo c;
int total = the_mesh.getNumContacts();
_num = 0;
for (int i = 0; i < total; i++) {
if (!the_mesh.getContactByIdx(i, c)) continue;
if (c.type != ADV_TYPE_REPEATER && c.type != ADV_TYPE_ROOM) continue;
_indices[_num++] = (uint16_t)i;
}
}
bool isAdminOk(const uint8_t* pub_key) const {
return _admin_ok && memcmp(_admin_ok_prefix, pub_key, 4) == 0;
}
@@ -120,9 +124,9 @@ class AdminScreen : public UIScreen {
_phase = LOGIN;
}
// Silent retry with a remembered password (see PICKER's Enter handler) --
// same idea as MessagesScreen's room-login flow: no keyboard shown, just a
// "Logging in..." wait for the async result.
// 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';
@@ -147,22 +151,47 @@ class AdminScreen : public UIScreen {
_task->showAlert(_fetch_for_edit ? "Fetching..." : "Sent, waiting...", 800);
}
// Open the free-text command keyboard, pre-filled with `initial`. Shared by
// the Custom-command row, set-only fields, and the get-then-edit reply path.
void openValueKb(const char* initial) {
kb().begin(initial, (int)sizeof(_cmd_text) - 1);
// Open the free-text keyboard, pre-filled with `initial`. Shared by the
// Custom-command row, remote set-only/get-then-edit fields, and every local
// field. `cli_autocomplete` wires up the {} picker to complete CLI command
// names contextually -- only meaningful for the remote Custom-command row.
void openValueKb(const char* initial, bool cli_autocomplete = false) {
kb().begin(initial, (int)sizeof(_cmd_text) - 1); // begin() resets any previous placeholder hook
kb().clearPlaceholders(); // a CLI command/value is literal, not a message
if (cli_autocomplete) kb().setPlaceholderRefresh(refreshCmdPlaceholders, nullptr, "Commands:");
_kb_active = true;
}
// Dispatch Enter on a COMMAND-phase row per the three field shapes (see
// file header). idx == ROWS_PER_TAB[_tab]-1 on the Actions tab is the
// "Custom command..." sentinel (both null), handled first.
// Candidate completions for the remote 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 -- typing those out stays
// manual). 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 (the
// text since the last space), so the list narrows as you type. An empty
// word (nothing typed yet, or just after a space) 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]);
}
// Dispatch Enter on a remote 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.get_cmd == nullptr && f.set_prefix == nullptr) { // Custom command...
openValueKb(_cmd_text);
openValueKb(_cmd_text, true);
} else if (f.set_prefix == nullptr) { // Action
strncpy(_cmd_text, f.get_cmd, sizeof(_cmd_text) - 1);
_cmd_text[sizeof(_cmd_text) - 1] = '\0';
@@ -187,26 +216,153 @@ class AdminScreen : public UIScreen {
openValueKb("");
}
// Dispatch Enter on a local COMMAND row: actions fire immediately; value
// fields open the keyboard pre-filled with the current reading.
void activateLocalField(const LocalField& f) {
NodePrefs* p = _task->getNodePrefs();
char buf[32];
switch (f.kind) {
case LK_NAME:
_pending_local_kind = LK_NAME;
openValueKb(the_mesh.getNodeName());
break;
case LK_RADIO:
if (p) snprintf(buf, sizeof(buf), "%.3f,%.0f,%d,%d", p->freq, p->bw, (int)p->sf, (int)p->cr);
else buf[0] = '\0';
_pending_local_kind = LK_RADIO;
openValueKb(buf);
break;
case LK_TXPOWER:
snprintf(buf, sizeof(buf), "%d", p ? (int)p->tx_power_dbm : 0);
_pending_local_kind = LK_TXPOWER;
openValueKb(buf);
break;
case LK_LAT:
snprintf(buf, sizeof(buf), "%.6f", sensors.node_lat);
_pending_local_kind = LK_LAT;
openValueKb(buf);
break;
case LK_LON:
snprintf(buf, sizeof(buf), "%.6f", sensors.node_lon);
_pending_local_kind = LK_LON;
openValueKb(buf);
break;
case LK_REBOOT:
_task->showAlert("Rebooting...", 800);
board.reboot();
break;
case LK_ADVERT:
_task->showAlert(the_mesh.advert() ? "Advert sent!" : "Advert failed", 1200);
break;
default: break;
}
}
// Parse+apply a submitted local field value. Deliberately light validation
// (parse-success only, no chip/physical-range checks like Settings' radio
// editor does) -- this panel is a fast power-user shim over the same
// settings, not a replacement for Settings' fuller, bounds-checked editors.
void commitLocalField(LocalKind kind, const char* text) {
NodePrefs* p = _task->getNodePrefs();
if (!p) return;
switch (kind) {
case LK_NAME: {
strncpy(p->node_name, text, sizeof(p->node_name) - 1);
p->node_name[sizeof(p->node_name) - 1] = '\0';
the_mesh.savePrefs();
_task->showAlert("Saved", 800);
break;
}
case LK_RADIO: {
float freq, bw; int sf, cr;
if (sscanf(text, "%f,%f,%d,%d", &freq, &bw, &sf, &cr) == 4) {
p->freq = freq; p->bw = bw; p->sf = (uint8_t)sf; p->cr = (uint8_t)cr;
_task->applyRadioParams();
the_mesh.savePrefs();
_task->showAlert("Saved", 800);
} else {
_task->showAlert("Expected freq,bw,sf,cr", 1600);
}
break;
}
case LK_TXPOWER: {
int dbm;
if (sscanf(text, "%d", &dbm) == 1) {
p->tx_power_dbm = (int8_t)dbm;
_task->applyTxPower();
the_mesh.savePrefs();
_task->showAlert("Saved", 800);
} else {
_task->showAlert("Invalid number", 1400);
}
break;
}
case LK_LAT: {
double v;
if (sscanf(text, "%lf", &v) == 1 && v >= -90.0 && v <= 90.0) {
sensors.node_lat = v;
the_mesh.savePrefs();
_task->showAlert("Saved", 800);
} else {
_task->showAlert("Lat must be -90..90", 1600);
}
break;
}
case LK_LON: {
double v;
if (sscanf(text, "%lf", &v) == 1 && v >= -180.0 && v <= 180.0) {
sensors.node_lon = v;
the_mesh.savePrefs();
_task->showAlert("Saved", 800);
} else {
_task->showAlert("Lon must be -180..180", 1600);
}
break;
}
default: break;
}
}
public:
explicit AdminScreen(UITask* task) : _task(task) {}
// Central per-visit reset (see UIScreen::onShow) -- always starts back at
// the target picker and forgets the admin-login memo, so credentials really
// are re-typed each time the tool is (re-)entered.
// the This-device/Remote-node chooser and forgets the admin-login memo, so
// remote credentials really are re-typed each time the tool is (re-)entered.
void onShow() override {
_phase = PICKER;
_sel = _scroll = 0;
buildList();
_tab = ATAB_SYSTEM;
_phase = CHOOSER;
_sel = 0;
_local_mode = false;
_tab = 0;
_row_sel = _row_scroll = 0;
_kb_active = false;
_waiting = false;
_fetch_for_edit = false;
_pending_set_prefix = nullptr;
_pending_local_kind = LK_NONE;
_login_waiting = false;
_admin_ok = false;
}
// Canonical entry for a specific remote 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.
void startFor(const ContactInfo& ci) {
_target = ci;
_local_mode = false;
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) {
@@ -223,13 +379,13 @@ public:
} else if (success) {
// Correct password, just insufficient permission -- leave any saved
// password alone, retyping the same one won't change the outcome.
_phase = PICKER;
_phase = CHOOSER; _sel = 0;
_task->showAlert("Not admin on this node", 1600);
} 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);
_phase = PICKER;
_phase = CHOOSER; _sel = 0;
_task->showAlert("Login failed", 1400);
}
}
@@ -266,20 +422,13 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
if (_phase == PICKER) {
display.drawCenteredHeader("ADMIN: SELECT NODE");
if (_num == 0) {
display.drawTextCentered(display.width() / 2, display.height() / 2, "No repeaters/rooms");
return 5000;
}
drawList(display, _num, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
if (_phase == CHOOSER) {
display.drawCenteredHeader("ADMIN");
static const char* const ROWS[2] = { "This device", "Remote node..." };
int scroll = 0;
drawList(display, 2, _sel, scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
ContactInfo c;
if (the_mesh.getContactByIdx(_indices[idx], c)) {
char filtered[sizeof(c.name)];
display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered));
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, filtered);
}
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, ROWS[i]);
});
return 2000;
}
@@ -304,11 +453,11 @@ public:
display.print(_fetch_for_edit ? "Fetching..." : "Waiting for reply...");
return 500;
}
tabbar::draw(display, TAB_LABELS, ATAB_COUNT, _tab);
int n = ROWS_PER_TAB[_tab];
tabbar::draw(display, _local_mode ? LOCAL_TAB_LABELS : TAB_LABELS, tabCount(), _tab);
int n = rowsInTab(_tab);
drawList(display, n, _row_sel, _row_scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, fieldAt(_tab, i).label);
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, rowLabel(_tab, i));
});
return 2000;
}
@@ -318,25 +467,17 @@ public:
}
bool handleInput(char c) override {
if (_phase == PICKER) {
if (_phase == CHOOSER) {
if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; }
if (c == KEY_UP && _num > 0) { _sel = (_sel > 0) ? _sel - 1 : _num - 1; return true; }
if (c == KEY_DOWN && _num > 0) { _sel = (_sel < _num - 1) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER && _num > 0) {
if (the_mesh.getContactByIdx(_indices[_sel], _target)) {
if (isAdminOk(_target.id.pub_key)) {
_tab = ATAB_SYSTEM; _row_sel = _row_scroll = 0;
_phase = COMMAND;
} else {
// Known password from an earlier successful admin login (this boot
// or a previous one) -- retry silently instead of re-prompting,
// same as MessagesScreen's room-login flow.
char saved_pw[sizeof(_login_pw)];
if (the_mesh.getRoomPassword(_target.id.pub_key, saved_pw, sizeof(saved_pw)))
startLoginWithSaved(saved_pw);
else
startLogin();
}
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 1; return true; }
if (c == KEY_DOWN) { _sel = (_sel < 1) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER) {
if (_sel == 0) {
_local_mode = true;
_tab = 0; _row_sel = _row_scroll = 0;
_phase = COMMAND;
} else {
_task->pickAdminTarget(); // -> Tools > Nodes pick-mode -> startFor()
}
return true;
}
@@ -345,18 +486,18 @@ public:
if (_phase == LOGIN) {
if (_login_waiting) {
if (c == KEY_CANCEL) { _login_waiting = false; _phase = PICKER; }
if (c == KEY_CANCEL) { _login_waiting = false; _phase = CHOOSER; _sel = 0; }
return true;
}
auto r = kb().handleInput(c);
if (r == KeyboardWidget::CANCELLED) {
_phase = PICKER;
_phase = CHOOSER; _sel = 0;
} else if (r == KeyboardWidget::DONE) {
strncpy(_login_pw, kb().buf, sizeof(_login_pw) - 1);
_login_pw[sizeof(_login_pw) - 1] = '\0';
bool sent = the_mesh.sendRoomLogin(_target, _login_pw);
_task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500);
if (sent) _login_waiting = true; else _phase = PICKER;
if (sent) _login_waiting = true; else { _phase = CHOOSER; _sel = 0; }
// else: stay in LOGIN until onRoomLoginResult() fires above.
}
return true;
@@ -370,17 +511,23 @@ public:
char edited[sizeof(_cmd_text)];
strncpy(edited, kb().buf, sizeof(edited) - 1);
edited[sizeof(edited) - 1] = '\0';
if (_pending_set_prefix) {
if (_local_mode) {
LocalKind k = _pending_local_kind;
_pending_local_kind = LK_NONE;
commitLocalField(k, edited);
} else 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();
}
if (_cmd_text[0]) sendCommand();
} else if (r == KeyboardWidget::CANCELLED) {
_kb_active = false;
_pending_set_prefix = nullptr;
_pending_local_kind = LK_NONE;
}
return true;
}
@@ -391,13 +538,18 @@ public:
}
return true;
}
if (c == KEY_CANCEL) { _phase = PICKER; 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_CANCEL) { _phase = CHOOSER; _sel = 0; return true; }
int count = tabCount();
if (keyIsPrev(c)) { _tab = (_tab + count - 1) % count; _row_sel = _row_scroll = 0; return true; }
if (keyIsNext(c)) { _tab = (_tab + 1) % count; _row_sel = _row_scroll = 0; return true; }
int n = rowsInTab(_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; }
if (c == KEY_ENTER) {
if (_local_mode) activateLocalField(localFieldAt(_tab, _row_sel));
else activateField(fieldAt(_tab, _row_sel));
return true;
}
return true;
}
@@ -409,6 +561,7 @@ public:
};
const char* AdminScreen::TAB_LABELS[AdminScreen::ATAB_COUNT] = { "System", "Radio", "Routing", "Actions" };
const char* AdminScreen::LOCAL_TAB_LABELS[AdminScreen::LOCAL_TAB_COUNT] = { "System", "Actions" };
const AdminScreen::AdminField AdminScreen::SYSTEM_FIELDS[] = {
{ "Name", "get name", "set name" },
@@ -426,10 +579,10 @@ const AdminScreen::AdminField AdminScreen::ROUTING_FIELDS[] = {
{ "Max hops", "get flood.max", "set flood.max" },
};
const AdminScreen::AdminField AdminScreen::ACTION_FIELDS[] = {
{ "Reboot", "reboot", nullptr },
{ "Send advert", "advert", nullptr },
{ "Send zero-hop advert", "advert.zerohop", nullptr },
{ "Sync clock", "clock sync", nullptr },
{ "Reboot", "reboot", nullptr }, // disruptive + no confirm: keep off the default row
{ "Custom command...", nullptr, nullptr },
};
@@ -439,3 +592,56 @@ const int AdminScreen::ROWS_PER_TAB[AdminScreen::ATAB_COUNT] = {
sizeof(AdminScreen::ROUTING_FIELDS) / sizeof(AdminScreen::AdminField),
sizeof(AdminScreen::ACTION_FIELDS) / sizeof(AdminScreen::AdminField),
};
const AdminScreen::LocalField AdminScreen::LOCAL_SYSTEM_FIELDS[] = {
{ "Name", AdminScreen::LK_NAME },
{ "Radio (f,bw,sf,cr)", AdminScreen::LK_RADIO },
{ "TX power", AdminScreen::LK_TXPOWER },
{ "Lat", AdminScreen::LK_LAT },
{ "Lon", AdminScreen::LK_LON },
};
const AdminScreen::LocalField AdminScreen::LOCAL_ACTION_FIELDS[] = {
{ "Send advert", AdminScreen::LK_ADVERT },
{ "Reboot", AdminScreen::LK_REBOOT }, // disruptive + no confirm: keep off the default row
};
const int AdminScreen::LOCAL_ROWS_PER_TAB[AdminScreen::LOCAL_TAB_COUNT] = {
sizeof(AdminScreen::LOCAL_SYSTEM_FIELDS) / sizeof(AdminScreen::LocalField),
sizeof(AdminScreen::LOCAL_ACTION_FIELDS) / sizeof(AdminScreen::LocalField),
};
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]);

View File

@@ -297,10 +297,21 @@ static int kbUtf8LastCharBytes(const char* buf, int len) {
return n;
}
static const int KB_PH_MAX = 12; // max placeholders in list
static const int KB_PH_LEN = 9; // max placeholder string length incl. null
static const int KB_PH_MAX = 20; // max placeholders in list (PopupMenu::PM_MAX_ITEMS=24 is the hard ceiling)
static const int KB_PH_LEN = 30; // max placeholder string length incl. null -- sized for the longest
// CLI-command candidate (AdminScreen), not just the short {x} tokens
static const int KB_PH_VISIBLE = 3; // items shown at once in overlay
struct KeyboardWidget;
// Optional hook: if set, called right before the placeholder picker opens so
// the caller can repopulate the list contextually (e.g. AdminScreen's CLI
// command autocomplete, filtered by what's already typed). When set, picking
// an entry also replaces the in-progress word (the text since the last
// space) instead of appending it -- true completion, not insertion. Fields
// that don't set this (the common case -- {loc}/{time} etc.) keep the
// original static-list, append-only behaviour untouched.
typedef void (*PlaceholderRefreshFn)(KeyboardWidget& kb, void* ctx);
struct KeyboardWidget {
char buf[KB_MAX_LEN + 1];
int len;
@@ -311,6 +322,12 @@ struct KeyboardWidget {
char _ph_buf[KB_PH_MAX][KB_PH_LEN];
int _ph_count;
PopupMenu _ph_menu;
PlaceholderRefreshFn _ph_refresh = nullptr;
void* _ph_refresh_ctx = nullptr;
const char* _ph_title = "Placeholder:"; // popup title -- overridable so e.g. AdminScreen can say "Commands:"
void setPlaceholderRefresh(PlaceholderRefreshFn fn, void* ctx, const char* title = "Placeholder:") {
_ph_refresh = fn; _ph_refresh_ctx = ctx; _ph_title = title;
}
// Live setting lookup — set once by UITask::begin(). NULL only in tests/tools
// that construct a KeyboardWidget standalone, in which case isT9() defaults
@@ -419,6 +436,9 @@ struct KeyboardWidget {
t9_cell = -1;
t9_cycle = 0;
_ph_menu.active = false;
_ph_refresh = nullptr; // opt-in per session -- the owning screen re-sets it if it wants
_ph_refresh_ctx = nullptr; // contextual autocomplete right after this begin()
_ph_title = "Placeholder:";
// default placeholders — always available
_ph_count = 0;
addPlaceholder("{loc}");
@@ -601,9 +621,16 @@ struct KeyboardWidget {
int idx = _ph_menu.selectedIndex();
const char* ph = _ph_buf[idx];
int ph_len = strlen(ph);
if (len + ph_len <= max_len) {
memcpy(buf + len, ph, ph_len);
len += ph_len;
// Contextual (refresh-hook) fields complete the in-progress word --
// the text since the last space -- instead of appending after it, so
// picking a match doesn't duplicate what's already been typed.
int base_len = len;
if (_ph_refresh) {
while (base_len > 0 && buf[base_len - 1] != ' ') base_len--;
}
if (base_len + ph_len <= max_len) {
memcpy(buf + base_len, ph, ph_len);
len = base_len + ph_len;
buf[len] = '\0';
}
}
@@ -708,7 +735,8 @@ struct KeyboardWidget {
if (len > 0) { len -= kbUtf8LastCharBytes(buf, len); buf[len] = '\0'; }
break;
case 3:
_ph_menu.begin("Placeholder:", KB_PH_VISIBLE);
if (_ph_refresh) _ph_refresh(*this, _ph_refresh_ctx); // contextual repopulate, if wired up
_ph_menu.begin(_ph_title, KB_PH_VISIBLE);
for (int i = 0; i < _ph_count; i++) _ph_menu.addItem(_ph_buf[i]);
break;
case 4:

View File

@@ -25,7 +25,16 @@ class NearbyScreen : public UIScreen {
// ── action-menu actions (matched by id, not by row index) ────────────────────
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR,
ACT_ADD, ACT_DELETE, ACT_FAV, ACT_SORT, ACT_SCAN };
ACT_ADD, ACT_DELETE, ACT_FAV, ACT_ADMIN, ACT_SORT, ACT_SCAN };
// Set by UITask::pickAdminTarget() (Tools > Admin's "Remote node..." choice):
// while true, ENTER on an eligible row (a stored repeater/room contact) hands
// the node straight to Admin instead of opening the detail view -- everything
// else (filters, scan, ping, sort, the Hold-Enter menu) behaves identically to
// normal Nodes browsing, so picking a node for Admin looks exactly like using
// this screen for anything else. Mirrors MessagesScreen's
// startPickBotChannel()/startPickBotRoom() pick-mode idiom.
bool _pick_admin_target = false;
// ── unified list entry ───────────────────────────────────────────────────────
struct Entry {
@@ -541,6 +550,10 @@ class NearbyScreen : public UIScreen {
bool is_contact = entryIsContact(e);
bool can_add = e && has_key && !is_contact; // a new node we can save
bool is_fav = e && has_key && _task->findFavouriteSlot(e->pub_key) >= 0;
// Admin needs a real saved contact (repeater/room), not a scan result or a
// name-only live-share row -- same gating as startPickAdminTarget()'s ENTER.
bool is_admin_target = e && stored && e->contact_idx >= 0
&& (e->type == ADV_TYPE_REPEATER || e->type == ADV_TYPE_ROOM);
buildSortLabel();
_menu_action_count = 0;
@@ -558,6 +571,7 @@ class NearbyScreen : public UIScreen {
if (has_gps && has_key) add("Set as target", ACT_LOCATOR);
if (can_add) add("Add contact", ACT_ADD);
if (is_contact && has_key) add(is_fav ? "Unfavourite" : "Favourite", ACT_FAV);
if (is_admin_target) add("Admin", ACT_ADMIN);
if (is_contact && has_key) add("Delete contact", ACT_DELETE);
if (stored) add(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows
add(stored ? "Discover scan" : "Rescan", ACT_SCAN);
@@ -603,6 +617,13 @@ class NearbyScreen : public UIScreen {
break;
}
case ACT_DELETE: startDeleteConfirm(); break;
case ACT_ADMIN: {
const Entry* e = selected();
ContactInfo ci;
if (e && e->contact_idx >= 0 && the_mesh.getContactByIdx(e->contact_idx, ci))
_task->openAdminFor(ci);
break;
}
case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER
case ACT_SCAN: enterScan(); break;
}
@@ -724,11 +745,16 @@ public:
_ping_menu.active = false;
_confirm.active = false;
_pinging = false;
_pick_admin_target = false; // stale pick-mode from a previous visit shouldn't linger
resetPingLines();
_task->clearPing();
refreshStored();
}
// Entered via UITask::pickAdminTarget() right after setCurrScreen(this) has
// already run onShow()'s reset above -- just arms the pick-mode flag.
void startPickAdminTarget() { _pick_admin_target = true; }
int render(DisplayDriver& display) override {
display.setTextSize(1);
@@ -908,6 +934,7 @@ public:
// ── list view ───────────────────────────────────────────────────────────
if (c == KEY_CANCEL) {
if (_pick_admin_target) { _pick_admin_target = false; _task->gotoAdminScreen(); return true; }
if (_source == SRC_SCAN) leaveScan();
else _task->gotoToolsScreen();
return true;
@@ -917,6 +944,17 @@ public:
if (c == KEY_UP && _count > 0) { _sel = (_sel > 0) ? _sel - 1 : _count - 1; return true; }
if (c == KEY_DOWN && _count > 0) { _sel = (_sel < _count - 1) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER) {
if (_pick_admin_target) {
const Entry* e = selected();
ContactInfo ci;
if (e && e->contact_idx >= 0 && (e->type == ADV_TYPE_REPEATER || e->type == ADV_TYPE_ROOM)
&& the_mesh.getContactByIdx(e->contact_idx, ci)) {
_pick_admin_target = false;
_task->openAdminFor(ci);
}
// else: row isn't an eligible admin target -- ignore, stay on the picker.
return true;
}
if (_count == 0) { if (_source == SRC_STORED) enterScan(); return true; }
_detail = true;
_detail_refresh_ms = millis();

View File

@@ -27,24 +27,41 @@ inline void drawPill(DisplayDriver& display, int x, int w, const char* label, bo
display.setColor(DisplayDriver::LIGHT);
}
// Truncate `label` to fit within `max_w` pixels of text (no padding), the same
// shrink-and-append-"..." approach as DisplayDriver::drawTextEllipsized (which
// can't be reused directly here: it draws immediately at a fixed x, but a tab
// pill needs the truncated width known *before* drawing, to size/centre the
// pill). Returns the resulting text's pixel width; writes the (possibly
// truncated) text into `out`.
// Truncate `label` to fit within `max_w` pixels of text (no padding). Similar
// intent to DisplayDriver::drawTextEllipsized (which can't be reused directly
// here: it draws immediately at a fixed x, but a tab pill needs the truncated
// width known *before* drawing, to size/centre the pill), but verifies the
// fit by re-measuring the actual "text..." candidate at each step, rather
// than estimating via a separately-measured ellipsis width -- font kerning
// between the last real glyph and the first dot can make a single-pass
// estimate overshoot by a px or two, which was enough to visibly spill the
// dots into whatever sits just past this tab (a neighbour, a reserved icon
// like the context-menu hint). Returns the resulting text's pixel width
// (always <= max_w, unless even a bare "..." doesn't fit); writes the
// (possibly truncated) text into `out`.
inline int ellipsize(DisplayDriver& display, const char* label, int max_w, char* out, size_t out_sz) {
strncpy(out, label, out_sz - 1);
out[out_sz - 1] = '\0';
int w = display.getTextWidth(out);
if (w <= max_w) return w;
const int ellipsis_w = display.getTextWidth("...");
int len = (int)strlen(out);
while (len > 0 && display.getTextWidth(out) > max_w - ellipsis_w) out[--len] = '\0';
// Strip an orphaned UTF-8 lead byte left by the byte-at-a-time trim above.
while (len > 0 && ((uint8_t)out[len - 1] & 0xC0) == 0xC0) out[--len] = '\0';
strcat(out, "...");
char trial[40];
while (len > 0) {
// Strip an orphaned UTF-8 lead byte before trying this length.
while (len > 0 && ((uint8_t)out[len - 1] & 0xC0) == 0xC0) len--;
if (len == 0) break;
memcpy(trial, out, len);
strcpy(trial + len, "...");
if (display.getTextWidth(trial) <= max_w) {
strncpy(out, trial, out_sz - 1);
out[out_sz - 1] = '\0';
return display.getTextWidth(out);
}
len--;
}
strncpy(out, "...", out_sz - 1);
out[out_sz - 1] = '\0';
return display.getTextWidth(out);
}
@@ -78,6 +95,12 @@ inline void draw(DisplayDriver& display, const char* const* labels, int count, i
if (avail >= min_w) {
int tw = ellipsize(display, labels[li], avail - pad * 2, buf, sizeof(buf));
int w = tw + pad * 2;
// ellipsize() targets max_w but can overshoot it by a px or two (the
// ellipsis is re-measured as a whole, so kerning against the last real
// character isn't accounted for) -- clamp so the pill can never spill
// past its allotted space (into the screen edge, or a reserved icon
// on the other side).
if (w > avail) w = avail;
drawPill(display, lx - w, w, buf, false);
lx = lx - w - gap;
} else lfit = false;
@@ -89,6 +112,7 @@ inline void draw(DisplayDriver& display, const char* const* labels, int count, i
if (avail >= min_w) {
int tw = ellipsize(display, labels[ri], avail - pad * 2, buf, sizeof(buf));
int w = tw + pad * 2;
if (w > avail) w = avail; // never spill past rx_limit (e.g. into a context-menu icon)
drawPill(display, rx, w, buf, false);
rx += w + gap;
} else rfit = false;

View File

@@ -1471,6 +1471,16 @@ void UITask::gotoToolsScreen() { setCurrScreen(tools_screen); }
void UITask::gotoBotScreen() { setCurrScreen(bot_screen); }
void UITask::gotoAdminScreen() { setCurrScreen(admin_screen); } // AdminScreen::onShow() resets it
void UITask::gotoNearbyScreen() { setCurrScreen(nearby_screen); }
void UITask::pickAdminTarget() {
setCurrScreen(nearby_screen); // runs NearbyScreen::onShow()'s reset first
((NearbyScreen*)nearby_screen)->startPickAdminTarget();
}
void UITask::openAdminFor(const ContactInfo& ci) {
setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first
((AdminScreen*)admin_screen)->startFor(ci);
}
void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); }
void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); }

View File

@@ -231,6 +231,8 @@ public:
void gotoRingtoneEditor(int slot = 0);
void gotoBotScreen();
void gotoAdminScreen();
void pickAdminTarget(); // open Nodes to choose a remote repeater/room for Admin
void openAdminFor(const ContactInfo& ci); // canonical Admin entry for a specific target (Nodes' Hold-Enter menu or the picker above)
void gotoNearbyScreen();
void gotoDashboardConfig();
void gotoAutoAdvertScreen();