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 | | Ping | a public key is known for the node |
| Save waypoint | selected node has GPS | | 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**) | | 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 | | 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) | | Discover scan / Rescan | always (live `NODE_DISCOVER_REQ` scan) |
@@ -516,10 +517,14 @@ Live forwarding stats — **Forwarded**, **Pool free**, **Queue** — are shown
<!-- screenshot pending: Admin — target picker, command entry, reply view --> <!-- 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. Opening **Tools Admin** shows a two-row chooser: **This device** or **Remote node...**.
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.
### 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: 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 | | Tab | Rows |
@@ -527,16 +532,27 @@ Send CLI commands to a **repeater or room server you have admin permission on**
| **System** | Name, Owner info, Admin password | | **System** | Name, Owner info, Admin password |
| **Radio** | Radio (freq, bandwidth, spreading factor, coding rate), TX power | | **Radio** | Radio (freq, bandwidth, spreading factor, coding rate), TX power |
| **Routing** | Repeat, Advert interval, Flood advert interval, Max hops | | **Routing** | Repeat, Advert interval, Flood advert interval, Max hops |
| **Actions** | Reboot, Send advert, Send zero-hop advert, Sync clock, **Custom command...** | | **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. - 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. - **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. - **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). 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] > [!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. > 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. **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 #pragma once
// Tools > Admin: send CLI commands to a repeater/room server you have admin // Tools > Admin: administer a remote repeater/room you have admin rights on
// permission on, and see the text reply -- the on-device equivalent of the // (over the mesh -- the on-device equivalent of the app's "repeater admin",
// companion app's "repeater admin" feature (see docs/cli_commands.md for the // see docs/cli_commands.md) or this device itself. Also reachable from Tools >
// command grammar). The wire protocol already supports this in full // Nodes' Hold-Enter menu (NearbyScreen::ACT_ADMIN).
// (MyMesh::sendRoomLogin() / sendAdminCommand() / onCommandDataRecv()); this
// screen is just the missing UI layer.
// //
// Credentials are session-only by design: the admin password is typed fresh // CHOOSER -> "This device" (LOCAL mode) or "Remote node..." (opens Tools > Nodes
// each time this screen is entered for a given contact (onShow() clears the // in pick-mode -- NearbyScreen::startPickAdminTarget(), the same borrow-another-
// one-slot "logged in" memo below) rather than persisted like room chat // screen's-list idiom the channel/bot pickers use, so it looks exactly like
// passwords are -- admin actions are infrequent, and keeping them out of the // normal Nodes browsing). Both the picker and the Nodes context-menu converge
// same store as room chat credentials keeps the two concerns separate. // on the single canonical UITask::openAdminFor(ContactInfo) -> startFor().
// //
// Once logged in, commands are organised into a category tab carousel (same // REMOTE mode: login (session password, self-heals like room logins) unlocks a
// "true carousel" idiom BotScreen uses: LEFT/RIGHT exclusively switches tabs, // category tab carousel of AdminField{label, get_cmd, set_prefix} rows:
// UP/DOWN moves within the active tab's rows, Enter drives everything else) // set_prefix==null -> action: send get_cmd literally (e.g. "reboot")
// instead of a single free-text box, so common settings don't need the CLI // get_cmd==null -> set-only: blank keyboard, send "<set_prefix> <value>"
// grammar memorised. Each row is one of three shapes, driven by a single // both set -> get, pre-fill keyboard with the reply, then set
// AdminField{label, get_cmd, set_prefix} table per tab -- see fieldAt(): // The trailing "Custom command..." row (both null) is a free-text escape hatch
// - set_prefix == nullptr -> action: get_cmd is the literal command, // with {}-key CLI command-name completion (CLI_COMMANDS).
// sent immediately (e.g. "reboot"). //
// - get_cmd == nullptr -> set-only: opens a blank keyboard for the // LOCAL mode: no login, no network -- a 2-tab carousel of LocalField{label, kind}
// new value (there's no getter for it). // rows mapped straight onto NodePrefs/sensors, reusing Settings' own apply chains
// - both set -> get-then-edit-then-set: fetches get_cmd, // (UITask::applyRadioParams()/applyTxPower(), MyMesh::savePrefs()) rather than a
// opens the keyboard pre-filled with the // CLI-grammar port (this firmware has no CommonCLI -- see CODE_REVIEW.md). Fields
// raw reply, sends "<set_prefix> <edited>". // pre-fill with the current value read synchronously and write+apply on submit.
// 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.
#include "FullscreenMsgView.h" #include "FullscreenMsgView.h"
#include "TabBar.h" #include "TabBar.h"
@@ -36,17 +31,16 @@
class AdminScreen : public UIScreen { class AdminScreen : public UIScreen {
UITask* _task; UITask* _task;
enum Phase : uint8_t { PICKER, LOGIN, COMMAND, REPLY }; enum Phase : uint8_t { CHOOSER, LOGIN, COMMAND, REPLY };
Phase _phase = PICKER; Phase _phase = CHOOSER;
// PICKER -- contacts of type ADV_TYPE_REPEATER or ADV_TYPE_ROOM only; a // CHOOSER -- "This device" (row 0) / "Remote node..." (row 1). Reuses _sel.
// plain chat contact can never have an admin ACL entry to log into. int _sel = 0;
int _sel = 0, _scroll = 0;
int _num = 0;
uint16_t _indices[MAX_CONTACTS];
ContactInfo _target;
// 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] = ""; char _login_pw[16] = "";
// True while waiting for the result of a login attempt with no keyboard on // 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 // 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 // One-slot memo: the last contact this screen successfully admin-logged
// into this visit, so re-entering COMMAND for the same target right after // into this visit, so re-entering COMMAND for the same target right after
// doesn't require retyping the password. Cleared in onShow() -- see the // 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 }; uint8_t _admin_ok_prefix[4] = { 0, 0, 0, 0 };
bool _admin_ok = false; 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 }; enum AdminTab : uint8_t { ATAB_SYSTEM, ATAB_RADIO, ATAB_ROUTING, ATAB_ACTIONS, ATAB_COUNT };
static const char* TAB_LABELS[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; int _row_sel = 0, _row_scroll = 0;
bool _kb_active = false; bool _kb_active = false;
char _cmd_text[161] = ""; char _cmd_text[161] = ""; // remote: the command being built/sent
bool _waiting = false; bool _waiting = false; // remote: awaiting sendAdminCommand()'s reply
uint32_t _cmd_deadline_ms = 0; uint32_t _cmd_deadline_ms = 0;
bool _fetch_for_edit = false; // true while waiting on a "get" whose reply opens an editor bool _fetch_for_edit = false; // remote: 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 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; FullscreenMsgView _reply_view;
char _reply_text[200] = ""; char _reply_text[200] = "";
KeyboardWidget& kb() { return _task->keyboard(); } 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 { bool isAdminOk(const uint8_t* pub_key) const {
return _admin_ok && memcmp(_admin_ok_prefix, pub_key, 4) == 0; return _admin_ok && memcmp(_admin_ok_prefix, pub_key, 4) == 0;
} }
@@ -120,9 +124,9 @@ class AdminScreen : public UIScreen {
_phase = LOGIN; _phase = LOGIN;
} }
// Silent retry with a remembered password (see PICKER's Enter handler) -- // Silent retry with a remembered password (see startFor()) -- same idea as
// same idea as MessagesScreen's room-login flow: no keyboard shown, just a // MessagesScreen's room-login flow: no keyboard shown, just a "Logging
// "Logging in..." wait for the async result. // in..." wait for the async result.
void startLoginWithSaved(const char* password) { void startLoginWithSaved(const char* password) {
strncpy(_login_pw, password, sizeof(_login_pw) - 1); strncpy(_login_pw, password, sizeof(_login_pw) - 1);
_login_pw[sizeof(_login_pw) - 1] = '\0'; _login_pw[sizeof(_login_pw) - 1] = '\0';
@@ -147,22 +151,47 @@ class AdminScreen : public UIScreen {
_task->showAlert(_fetch_for_edit ? "Fetching..." : "Sent, waiting...", 800); _task->showAlert(_fetch_for_edit ? "Fetching..." : "Sent, waiting...", 800);
} }
// Open the free-text command keyboard, pre-filled with `initial`. Shared by // Open the free-text keyboard, pre-filled with `initial`. Shared by the
// the Custom-command row, set-only fields, and the get-then-edit reply path. // Custom-command row, remote set-only/get-then-edit fields, and every local
void openValueKb(const char* initial) { // field. `cli_autocomplete` wires up the {} picker to complete CLI command
kb().begin(initial, (int)sizeof(_cmd_text) - 1); // 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 kb().clearPlaceholders(); // a CLI command/value is literal, not a message
if (cli_autocomplete) kb().setPlaceholderRefresh(refreshCmdPlaceholders, nullptr, "Commands:");
_kb_active = true; _kb_active = true;
} }
// Dispatch Enter on a COMMAND-phase row per the three field shapes (see // Candidate completions for the remote Custom-command row's {} picker: every
// file header). idx == ROWS_PER_TAB[_tab]-1 on the Actions tab is the // remotely-usable command from docs/cli_commands.md (Serial-Only ones and
// "Custom command..." sentinel (both null), handled first. // 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) { void activateField(const AdminField& f) {
_fetch_for_edit = false; _fetch_for_edit = false;
_pending_set_prefix = nullptr; _pending_set_prefix = nullptr;
if (f.get_cmd == nullptr && f.set_prefix == nullptr) { // Custom command... 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 } else if (f.set_prefix == nullptr) { // Action
strncpy(_cmd_text, f.get_cmd, sizeof(_cmd_text) - 1); strncpy(_cmd_text, f.get_cmd, sizeof(_cmd_text) - 1);
_cmd_text[sizeof(_cmd_text) - 1] = '\0'; _cmd_text[sizeof(_cmd_text) - 1] = '\0';
@@ -187,26 +216,153 @@ class AdminScreen : public UIScreen {
openValueKb(""); 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: public:
explicit AdminScreen(UITask* task) : _task(task) {} explicit AdminScreen(UITask* task) : _task(task) {}
// Central per-visit reset (see UIScreen::onShow) -- always starts back at // Central per-visit reset (see UIScreen::onShow) -- always starts back at
// the target picker and forgets the admin-login memo, so credentials really // the This-device/Remote-node chooser and forgets the admin-login memo, so
// are re-typed each time the tool is (re-)entered. // remote credentials really are re-typed each time the tool is (re-)entered.
void onShow() override { void onShow() override {
_phase = PICKER; _phase = CHOOSER;
_sel = _scroll = 0; _sel = 0;
buildList(); _local_mode = false;
_tab = ATAB_SYSTEM; _tab = 0;
_row_sel = _row_scroll = 0; _row_sel = _row_scroll = 0;
_kb_active = false; _kb_active = false;
_waiting = false; _waiting = false;
_fetch_for_edit = false; _fetch_for_edit = false;
_pending_set_prefix = nullptr; _pending_set_prefix = nullptr;
_pending_local_kind = LK_NONE;
_login_waiting = false; _login_waiting = false;
_admin_ok = 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() // Async result of the on-device login, routed here from UITask::onRoomLoginResult()
// only while this screen is current (see that routing for why). // only while this screen is current (see that routing for why).
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {
@@ -223,13 +379,13 @@ public:
} else if (success) { } else if (success) {
// Correct password, just insufficient permission -- leave any saved // Correct password, just insufficient permission -- leave any saved
// password alone, retyping the same one won't change the outcome. // 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); _task->showAlert("Not admin on this node", 1600);
} else { } else {
// Wrong/stale password -- forget it so the next attempt prompts fresh, // Wrong/stale password -- forget it so the next attempt prompts fresh,
// same self-healing behaviour as a room login. // same self-healing behaviour as a room login.
the_mesh.forgetRoomPassword(pub_key); the_mesh.forgetRoomPassword(pub_key);
_phase = PICKER; _phase = CHOOSER; _sel = 0;
_task->showAlert("Login failed", 1400); _task->showAlert("Login failed", 1400);
} }
} }
@@ -266,20 +422,13 @@ public:
display.setTextSize(1); display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
if (_phase == PICKER) { if (_phase == CHOOSER) {
display.drawCenteredHeader("ADMIN: SELECT NODE"); display.drawCenteredHeader("ADMIN");
if (_num == 0) { static const char* const ROWS[2] = { "This device", "Remote node..." };
display.drawTextCentered(display.width() / 2, display.height() / 2, "No repeaters/rooms"); int scroll = 0;
return 5000; drawList(display, 2, _sel, scroll, [&](int i, int y, bool sel, int reserve) {
}
drawList(display, _num, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve); drawRowSelection(display, y, sel, reserve);
ContactInfo c; display.drawTextEllipsized(2, y, display.width() - 4 - reserve, ROWS[i]);
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);
}
}); });
return 2000; return 2000;
} }
@@ -304,11 +453,11 @@ public:
display.print(_fetch_for_edit ? "Fetching..." : "Waiting for reply..."); display.print(_fetch_for_edit ? "Fetching..." : "Waiting for reply...");
return 500; return 500;
} }
tabbar::draw(display, TAB_LABELS, ATAB_COUNT, _tab); tabbar::draw(display, _local_mode ? LOCAL_TAB_LABELS : TAB_LABELS, tabCount(), _tab);
int n = ROWS_PER_TAB[_tab]; int n = rowsInTab(_tab);
drawList(display, n, _row_sel, _row_scroll, [&](int i, int y, bool sel, int reserve) { drawList(display, n, _row_sel, _row_scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, 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; return 2000;
} }
@@ -318,25 +467,17 @@ public:
} }
bool handleInput(char c) override { bool handleInput(char c) override {
if (_phase == PICKER) { if (_phase == CHOOSER) {
if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; } 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_UP) { _sel = (_sel > 0) ? _sel - 1 : 1; return true; }
if (c == KEY_DOWN && _num > 0) { _sel = (_sel < _num - 1) ? _sel + 1 : 0; return true; } if (c == KEY_DOWN) { _sel = (_sel < 1) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER && _num > 0) { if (c == KEY_ENTER) {
if (the_mesh.getContactByIdx(_indices[_sel], _target)) { if (_sel == 0) {
if (isAdminOk(_target.id.pub_key)) { _local_mode = true;
_tab = ATAB_SYSTEM; _row_sel = _row_scroll = 0; _tab = 0; _row_sel = _row_scroll = 0;
_phase = COMMAND; _phase = COMMAND;
} else { } else {
// Known password from an earlier successful admin login (this boot _task->pickAdminTarget(); // -> Tools > Nodes pick-mode -> startFor()
// 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();
}
} }
return true; return true;
} }
@@ -345,18 +486,18 @@ public:
if (_phase == LOGIN) { if (_phase == LOGIN) {
if (_login_waiting) { 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; return true;
} }
auto r = kb().handleInput(c); auto r = kb().handleInput(c);
if (r == KeyboardWidget::CANCELLED) { if (r == KeyboardWidget::CANCELLED) {
_phase = PICKER; _phase = CHOOSER; _sel = 0;
} else if (r == KeyboardWidget::DONE) { } else if (r == KeyboardWidget::DONE) {
strncpy(_login_pw, kb().buf, sizeof(_login_pw) - 1); strncpy(_login_pw, kb().buf, sizeof(_login_pw) - 1);
_login_pw[sizeof(_login_pw) - 1] = '\0'; _login_pw[sizeof(_login_pw) - 1] = '\0';
bool sent = the_mesh.sendRoomLogin(_target, _login_pw); bool sent = the_mesh.sendRoomLogin(_target, _login_pw);
_task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500); _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. // else: stay in LOGIN until onRoomLoginResult() fires above.
} }
return true; return true;
@@ -370,17 +511,23 @@ public:
char edited[sizeof(_cmd_text)]; char edited[sizeof(_cmd_text)];
strncpy(edited, kb().buf, sizeof(edited) - 1); strncpy(edited, kb().buf, sizeof(edited) - 1);
edited[sizeof(edited) - 1] = '\0'; 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); snprintf(_cmd_text, sizeof(_cmd_text), "%s %s", _pending_set_prefix, edited);
_pending_set_prefix = nullptr; _pending_set_prefix = nullptr;
sendCommand();
} else { } else {
strncpy(_cmd_text, edited, sizeof(_cmd_text) - 1); strncpy(_cmd_text, edited, sizeof(_cmd_text) - 1);
_cmd_text[sizeof(_cmd_text) - 1] = '\0'; _cmd_text[sizeof(_cmd_text) - 1] = '\0';
}
if (_cmd_text[0]) sendCommand(); if (_cmd_text[0]) sendCommand();
}
} else if (r == KeyboardWidget::CANCELLED) { } else if (r == KeyboardWidget::CANCELLED) {
_kb_active = false; _kb_active = false;
_pending_set_prefix = nullptr; _pending_set_prefix = nullptr;
_pending_local_kind = LK_NONE;
} }
return true; return true;
} }
@@ -391,13 +538,18 @@ public:
} }
return true; return true;
} }
if (c == KEY_CANCEL) { _phase = PICKER; return true; } if (c == KEY_CANCEL) { _phase = CHOOSER; _sel = 0; return true; }
if (keyIsPrev(c)) { _tab = (_tab + ATAB_COUNT - 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; } int count = tabCount();
if (keyIsNext(c)) { _tab = (_tab + 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; } if (keyIsPrev(c)) { _tab = (_tab + count - 1) % count; _row_sel = _row_scroll = 0; return true; }
int n = ROWS_PER_TAB[_tab]; 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_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_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; return true;
} }
@@ -409,6 +561,7 @@ public:
}; };
const char* AdminScreen::TAB_LABELS[AdminScreen::ATAB_COUNT] = { "System", "Radio", "Routing", "Actions" }; 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[] = { const AdminScreen::AdminField AdminScreen::SYSTEM_FIELDS[] = {
{ "Name", "get name", "set name" }, { "Name", "get name", "set name" },
@@ -426,10 +579,10 @@ const AdminScreen::AdminField AdminScreen::ROUTING_FIELDS[] = {
{ "Max hops", "get flood.max", "set flood.max" }, { "Max hops", "get flood.max", "set flood.max" },
}; };
const AdminScreen::AdminField AdminScreen::ACTION_FIELDS[] = { const AdminScreen::AdminField AdminScreen::ACTION_FIELDS[] = {
{ "Reboot", "reboot", nullptr },
{ "Send advert", "advert", nullptr }, { "Send advert", "advert", nullptr },
{ "Send zero-hop advert", "advert.zerohop", nullptr }, { "Send zero-hop advert", "advert.zerohop", nullptr },
{ "Sync clock", "clock sync", nullptr }, { "Sync clock", "clock sync", nullptr },
{ "Reboot", "reboot", nullptr }, // disruptive + no confirm: keep off the default row
{ "Custom command...", nullptr, nullptr }, { "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::ROUTING_FIELDS) / sizeof(AdminScreen::AdminField),
sizeof(AdminScreen::ACTION_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; return n;
} }
static const int KB_PH_MAX = 12; // max placeholders in list 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 = 9; // max placeholder string length incl. null 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 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 { struct KeyboardWidget {
char buf[KB_MAX_LEN + 1]; char buf[KB_MAX_LEN + 1];
int len; int len;
@@ -311,6 +322,12 @@ struct KeyboardWidget {
char _ph_buf[KB_PH_MAX][KB_PH_LEN]; char _ph_buf[KB_PH_MAX][KB_PH_LEN];
int _ph_count; int _ph_count;
PopupMenu _ph_menu; 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 // Live setting lookup — set once by UITask::begin(). NULL only in tests/tools
// that construct a KeyboardWidget standalone, in which case isT9() defaults // that construct a KeyboardWidget standalone, in which case isT9() defaults
@@ -419,6 +436,9 @@ struct KeyboardWidget {
t9_cell = -1; t9_cell = -1;
t9_cycle = 0; t9_cycle = 0;
_ph_menu.active = false; _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 // default placeholders — always available
_ph_count = 0; _ph_count = 0;
addPlaceholder("{loc}"); addPlaceholder("{loc}");
@@ -601,9 +621,16 @@ struct KeyboardWidget {
int idx = _ph_menu.selectedIndex(); int idx = _ph_menu.selectedIndex();
const char* ph = _ph_buf[idx]; const char* ph = _ph_buf[idx];
int ph_len = strlen(ph); int ph_len = strlen(ph);
if (len + ph_len <= max_len) { // Contextual (refresh-hook) fields complete the in-progress word --
memcpy(buf + len, ph, ph_len); // the text since the last space -- instead of appending after it, so
len += ph_len; // 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'; buf[len] = '\0';
} }
} }
@@ -708,7 +735,8 @@ struct KeyboardWidget {
if (len > 0) { len -= kbUtf8LastCharBytes(buf, len); buf[len] = '\0'; } if (len > 0) { len -= kbUtf8LastCharBytes(buf, len); buf[len] = '\0'; }
break; break;
case 3: 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]); for (int i = 0; i < _ph_count; i++) _ph_menu.addItem(_ph_buf[i]);
break; break;
case 4: case 4:

View File

@@ -25,7 +25,16 @@ class NearbyScreen : public UIScreen {
// ── action-menu actions (matched by id, not by row index) ──────────────────── // ── action-menu actions (matched by id, not by row index) ────────────────────
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR, 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 ─────────────────────────────────────────────────────── // ── unified list entry ───────────────────────────────────────────────────────
struct Entry { struct Entry {
@@ -541,6 +550,10 @@ class NearbyScreen : public UIScreen {
bool is_contact = entryIsContact(e); bool is_contact = entryIsContact(e);
bool can_add = e && has_key && !is_contact; // a new node we can save 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; 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(); buildSortLabel();
_menu_action_count = 0; _menu_action_count = 0;
@@ -558,6 +571,7 @@ class NearbyScreen : public UIScreen {
if (has_gps && has_key) add("Set as target", ACT_LOCATOR); if (has_gps && has_key) add("Set as target", ACT_LOCATOR);
if (can_add) add("Add contact", ACT_ADD); if (can_add) add("Add contact", ACT_ADD);
if (is_contact && has_key) add(is_fav ? "Unfavourite" : "Favourite", ACT_FAV); 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 (is_contact && has_key) add("Delete contact", ACT_DELETE);
if (stored) add(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows if (stored) add(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows
add(stored ? "Discover scan" : "Rescan", ACT_SCAN); add(stored ? "Discover scan" : "Rescan", ACT_SCAN);
@@ -603,6 +617,13 @@ class NearbyScreen : public UIScreen {
break; break;
} }
case ACT_DELETE: startDeleteConfirm(); 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_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER
case ACT_SCAN: enterScan(); break; case ACT_SCAN: enterScan(); break;
} }
@@ -724,11 +745,16 @@ public:
_ping_menu.active = false; _ping_menu.active = false;
_confirm.active = false; _confirm.active = false;
_pinging = false; _pinging = false;
_pick_admin_target = false; // stale pick-mode from a previous visit shouldn't linger
resetPingLines(); resetPingLines();
_task->clearPing(); _task->clearPing();
refreshStored(); 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 { int render(DisplayDriver& display) override {
display.setTextSize(1); display.setTextSize(1);
@@ -908,6 +934,7 @@ public:
// ── list view ─────────────────────────────────────────────────────────── // ── list view ───────────────────────────────────────────────────────────
if (c == KEY_CANCEL) { if (c == KEY_CANCEL) {
if (_pick_admin_target) { _pick_admin_target = false; _task->gotoAdminScreen(); return true; }
if (_source == SRC_SCAN) leaveScan(); if (_source == SRC_SCAN) leaveScan();
else _task->gotoToolsScreen(); else _task->gotoToolsScreen();
return true; 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_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_DOWN && _count > 0) { _sel = (_sel < _count - 1) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER) { 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; } if (_count == 0) { if (_source == SRC_STORED) enterScan(); return true; }
_detail = true; _detail = true;
_detail_refresh_ms = millis(); _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); display.setColor(DisplayDriver::LIGHT);
} }
// Truncate `label` to fit within `max_w` pixels of text (no padding), the same // Truncate `label` to fit within `max_w` pixels of text (no padding). Similar
// shrink-and-append-"..." approach as DisplayDriver::drawTextEllipsized (which // intent to DisplayDriver::drawTextEllipsized (which can't be reused directly
// can't be reused directly here: it draws immediately at a fixed x, but a tab // here: it draws immediately at a fixed x, but a tab pill needs the truncated
// pill needs the truncated width known *before* drawing, to size/centre the // width known *before* drawing, to size/centre the pill), but verifies the
// pill). Returns the resulting text's pixel width; writes the (possibly // fit by re-measuring the actual "text..." candidate at each step, rather
// truncated) text into `out`. // 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) { inline int ellipsize(DisplayDriver& display, const char* label, int max_w, char* out, size_t out_sz) {
strncpy(out, label, out_sz - 1); strncpy(out, label, out_sz - 1);
out[out_sz - 1] = '\0'; out[out_sz - 1] = '\0';
int w = display.getTextWidth(out); int w = display.getTextWidth(out);
if (w <= max_w) return w; if (w <= max_w) return w;
const int ellipsis_w = display.getTextWidth("...");
int len = (int)strlen(out); int len = (int)strlen(out);
while (len > 0 && display.getTextWidth(out) > max_w - ellipsis_w) out[--len] = '\0'; char trial[40];
// Strip an orphaned UTF-8 lead byte left by the byte-at-a-time trim above. while (len > 0) {
while (len > 0 && ((uint8_t)out[len - 1] & 0xC0) == 0xC0) out[--len] = '\0'; // Strip an orphaned UTF-8 lead byte before trying this length.
strcat(out, "..."); 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); return display.getTextWidth(out);
} }
@@ -78,6 +95,12 @@ inline void draw(DisplayDriver& display, const char* const* labels, int count, i
if (avail >= min_w) { if (avail >= min_w) {
int tw = ellipsize(display, labels[li], avail - pad * 2, buf, sizeof(buf)); int tw = ellipsize(display, labels[li], avail - pad * 2, buf, sizeof(buf));
int w = tw + pad * 2; 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); drawPill(display, lx - w, w, buf, false);
lx = lx - w - gap; lx = lx - w - gap;
} else lfit = false; } else lfit = false;
@@ -89,6 +112,7 @@ inline void draw(DisplayDriver& display, const char* const* labels, int count, i
if (avail >= min_w) { if (avail >= min_w) {
int tw = ellipsize(display, labels[ri], avail - pad * 2, buf, sizeof(buf)); int tw = ellipsize(display, labels[ri], avail - pad * 2, buf, sizeof(buf));
int w = tw + pad * 2; 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); drawPill(display, rx, w, buf, false);
rx += w + gap; rx += w + gap;
} else rfit = false; } else rfit = false;

View File

@@ -1471,6 +1471,16 @@ void UITask::gotoToolsScreen() { setCurrScreen(tools_screen); }
void UITask::gotoBotScreen() { setCurrScreen(bot_screen); } void UITask::gotoBotScreen() { setCurrScreen(bot_screen); }
void UITask::gotoAdminScreen() { setCurrScreen(admin_screen); } // AdminScreen::onShow() resets it void UITask::gotoAdminScreen() { setCurrScreen(admin_screen); } // AdminScreen::onShow() resets it
void UITask::gotoNearbyScreen() { setCurrScreen(nearby_screen); } 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::gotoDashboardConfig() { setCurrScreen(dashboard_config); }
void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); } void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); } void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); }

View File

@@ -231,6 +231,8 @@ public:
void gotoRingtoneEditor(int slot = 0); void gotoRingtoneEditor(int slot = 0);
void gotoBotScreen(); void gotoBotScreen();
void gotoAdminScreen(); 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 gotoNearbyScreen();
void gotoDashboardConfig(); void gotoDashboardConfig();
void gotoAutoAdvertScreen(); void gotoAutoAdvertScreen();