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
co-authored by Claude Opus 4.8
parent f399298fa6
commit 9bfccf4cf4
7 changed files with 472 additions and 148 deletions
+325 -119
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]);