Files
MeshCore-Solo/examples/companion_radio/ui-new/AdminScreen.h
Jakub ce1fde4fdb fix(admin): field label/value overlap and scrollbar-clipped freq digit
Long field labels ("Flood advert interval (h)", "Frequency (MHz)") were
ellipsized to nearly the full row width with no reserve for the value
column, so a label could run under/through the value or digit editor on
the row currently being edited. Only rows showing an inline value now
reserve room before valCol(); other rows keep the full width as before.

Separately, the Frequency digit editor (4 int + 3 dec digits, needed for
Admin's wider 150-2500 MHz range vs Repeater's 3-digit range) drew all 8
digits flush to the screen edge, landing the thousandths digit exactly
under a visible scrollbar's reserve column. Shifted left by that reserve,
matching the plain-value branch's existing scrollbar-aware positioning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 20:27:55 +02:00

664 lines
30 KiB
C++

#pragma once
// 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). This device's own settings live in Settings /
// Tools, not here -- Admin is purely for remote nodes.
//
// Entry is a target-then-act flow, reached two ways: Tools > Admin opens
// Tools > Nodes in a pick-mode (NearbyScreen::startPickAdminTarget(), the same
// borrow-another-screen's-list idiom the channel/bot pickers use) and picking
// a node from it enters Admin; or a repeater/room's own Hold-Enter "Admin"
// action in Nodes enters Admin directly while browsing normally. Both call the
// single canonical UITask::openAdminFor(ContactInfo, from_picker) -> startFor(),
// which remembers which door was used: Cancel/login-failure from a command
// screen returns to the picker if that's how the user arrived, or straight
// back to Nodes if they came in directly (see returnToOrigin()); backing out
// of the picker itself returns to Tools.
//
// After login (session password, self-heals like room logins) a category tab
// carousel of AdminField{label, get_cmd, set_prefix} rows unlocks:
// set_prefix==null -> action: send get_cmd literally (e.g. "reboot")
// get_cmd==null -> set-only: blank keyboard, send "<set_prefix> <value>"
// both set -> get, pre-fill keyboard with the reply, then set
// The trailing "Custom command..." row (both null) is a free-text escape hatch
// with {}-key CLI command-name completion (CLI_COMMANDS).
#include "FullscreenMsgView.h"
#include "TabBar.h"
#include "RadioParamsEditor.h" // DigitEditor + stepSF/stepBW/stepCR -- same widgets Settings/Repeater use locally
#include <helpers/ClientACL.h> // PERM_ACL_ADMIN / PERM_ACL_ROLE_MASK
class AdminScreen : public UIScreen {
UITask* _task;
enum Phase : uint8_t { LOGIN, COMMAND, REPLY };
Phase _phase = LOGIN;
ContactInfo _target;
// True when this visit was reached via the "Remote node..." picker
// (UITask::pickAdminTarget() -> NearbyScreen's pick-mode); false when reached
// directly from a node's own Hold-Enter "Admin" action while browsing Nodes
// normally. Determines where Cancel/failure paths send the user back to --
// see returnToOrigin(). Set fresh by startFor() on every entry.
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(); }
// 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;
// One-slot memo: the last contact successfully admin-logged-into this visit,
// so re-entering COMMAND for the same target right after doesn't require
// retyping the password. Cleared in onShow().
uint8_t _admin_ok_prefix[4] = { 0, 0, 0, 0 };
bool _admin_ok = false;
// ── COMMAND: category tabs / rows ───────────────────────────────────────────
enum AdminTab : uint8_t { ATAB_SYSTEM, ATAB_RADIO, ATAB_ROUTING, ATAB_ACTIONS, ATAB_COUNT };
static const char* TAB_LABELS[ATAB_COUNT];
// Most fields are still free text (get reply pre-fills the keyboard, user
// retypes, "set <prefix> <text>" on submit) -- fine for names/passwords, but
// painful for values that are really a number or one of a handful of options.
// `kind` (default FK_TEXT, so every existing 3-field {label,get,set} literal
// below still compiles unchanged) opts a field into a typed, in-place editor
// instead: FK_ONOFF toggles on/off; FK_NUMBER steps an int within
// [min_val,max_val] by `step`; FK_RADIO_* are four views onto the same
// "get radio"/"set radio f,bw,sf,cr" tuple (freq via the same digit-cursor
// DigitEditor Settings/Repeater use, bw/sf/cr via RadioParamsEditor's
// discrete-set steppers) -- editing any one re-sends all four, the other
// three carried over unchanged from the fetch. See activateField()/
// beginValueEdit()/the `_value_editing` block in handleInput().
enum FieldKind : uint8_t { FK_TEXT, FK_ONOFF, FK_NUMBER, FK_RADIO_FREQ, FK_RADIO_BW, FK_RADIO_SF, FK_RADIO_CR };
struct AdminField {
const char* label; const char* get_cmd; const char* set_prefix;
FieldKind kind;
float min_val, max_val, step; // FK_NUMBER only
// Explicit ctor (not default member initializers) so every existing
// 3-field {label,get,set} literal below still compiles as a converting
// constructor call -- this toolchain's actual C++ standard (the Adafruit
// nRF52 core's default; only `env:native` sets -std=c++17) predates
// C++14's aggregate-with-default-member-initializer rule, so plain
// aggregate init here fails to convert.
AdminField(const char* l, const char* g, const char* s, FieldKind k = FK_TEXT,
float mn = 0, float mx = 0, float st = 1)
: label(l), get_cmd(g), set_prefix(s), kind(k), min_val(mn), max_val(mx), step(st) {}
};
static const AdminField SYSTEM_FIELDS[];
static const AdminField RADIO_FIELDS[];
static const AdminField ROUTING_FIELDS[];
static const AdminField ACTION_FIELDS[];
static const int ROWS_PER_TAB[ATAB_COUNT];
static const AdminField& fieldAt(int tab, int row) {
switch (tab) {
case ATAB_SYSTEM: return SYSTEM_FIELDS[row];
case ATAB_RADIO: return RADIO_FIELDS[row];
case ATAB_ROUTING: return ROUTING_FIELDS[row];
default: return ACTION_FIELDS[row];
}
}
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
// ── 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] = "";
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;
kb().begin("", 15); // admin password: same max length as room/repeater login
kb().clearPlaceholders(); // {loc}/{time} are for messages, not a password
_phase = LOGIN;
}
// 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';
bool sent = the_mesh.sendRoomLogin(_target, _login_pw);
_task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500);
if (sent) { _login_waiting = true; _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
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.
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 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();
return;
}
if (f.get_cmd == nullptr && f.set_prefix == nullptr) { // Custom command...
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';
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();
}
}
// 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
}
}
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.
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;
}
// 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.
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) {
if (_phase != LOGIN) 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;
}
strncpy(_reply_text, text, sizeof(_reply_text) - 1);
_reply_text[sizeof(_reply_text) - 1] = '\0';
_reply_view.begin();
_phase = REPLY;
}
void poll() override {
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); }
}
}
int render(DisplayDriver& display) override {
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 (_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];
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;
display.drawTextEllipsized(2, y, label_max, f.label);
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);
}
}
});
return _value_editing ? 50 : 2000;
}
// REPLY
return _reply_view.render(display, _target.name, _reply_text, false, false);
}
bool handleInput(char c) override {
if (_phase == LOGIN) {
if (_login_waiting) {
if (c == KEY_CANCEL) { _login_waiting = false; returnToOrigin(); }
return true;
}
auto r = kb().handleInput(c);
if (r == KeyboardWidget::CANCELLED) {
returnToOrigin();
} 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 returnToOrigin();
// else: stay in LOGIN until onRoomLoginResult() fires above.
}
return true;
}
if (_phase == COMMAND) {
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; }
return true;
}
// REPLY
auto res = _reply_view.handleInput(c);
if (res != FullscreenMsgView::NONE) _phase = COMMAND; // CLOSE / PREV / NEXT / REPLY all just return here
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 }, // disruptive + no confirm: keep off the default row
{ "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]);