mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
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:
@@ -41,6 +41,7 @@ Select a node to see its coordinates, distance, bearing with cardinal direction,
|
||||
| Ping | a public key is known for the node |
|
||||
| Save waypoint | selected node has GPS |
|
||||
| Set as target | selected node has GPS **and** a public key — pins it as the active **Locator/Nav target** right away (see **Locator**) |
|
||||
| Admin | selected node is a saved **repeater or room server** contact — opens **Tools › Admin** for it directly (see **Admin**) |
|
||||
| Sort: Dist/Recent | browsing stored nodes — **LEFT/RIGHT** on the row flips distance ↔ last-heard in place |
|
||||
| Discover scan / Rescan | always (live `NODE_DISCOVER_REQ` scan) |
|
||||
|
||||
@@ -516,10 +517,14 @@ Live forwarding stats — **Forwarded**, **Pool free**, **Queue** — are shown
|
||||
|
||||
<!-- screenshot pending: Admin — target picker, command entry, reply view -->
|
||||
|
||||
Send CLI commands to a **repeater or room server you have admin permission on** and see the text reply — the on-device equivalent of the companion app's repeater-admin feature. See [CLI Commands](../../cli_commands.md) for the full command grammar.
|
||||
Send commands to **this device**, or to a **repeater/room server you have admin permission on** — the on-device equivalent of the companion app's repeater-admin feature for remote nodes, plus a local shim over settings this device already has. See [CLI Commands](../../cli_commands.md) for the full remote command grammar.
|
||||
|
||||
1. **Select a node** — the list shows every repeater/room-server contact. Press **Enter** to pick one.
|
||||
2. **Log in** — type the node's **admin password** (this is the same login handshake Messages uses for room servers; a repeater's admin password is set with the `password` CLI command). If a password was already saved for this node from an earlier successful login, it retries silently instead of prompting. Only a login that comes back with **admin**-level permission unlocks the next step — anything less shows "Not admin on this node" and returns to the list.
|
||||
Opening **Tools › Admin** shows a two-row chooser: **This device** or **Remote node...**.
|
||||
|
||||
### Remote node
|
||||
|
||||
1. **Select a node** — "Remote node..." opens **Tools › Nodes** (the same screen, filters, sort and live scan as browsing it normally) so picking a node for Admin looks exactly like using Nodes for anything else; **Enter** on a repeater/room row hands it to Admin. Admin is also reachable directly from a node's own **Hold Enter** menu in Nodes, without going through the chooser at all.
|
||||
2. **Log in** — type the node's **admin password** (the same login handshake Messages uses for room servers; a repeater's admin password is set with the `password` CLI command). If a password was already saved for this node from an earlier successful login, it retries silently instead of prompting. Only a login that comes back with **admin**-level permission unlocks the next step — anything less shows "Not admin on this node".
|
||||
3. **Pick a category and a field** — a tab carousel (**LEFT/RIGHT** to switch category, **UP/DOWN** to move within it, same as Auto-Reply Bot's tabs), so common settings don't need the CLI grammar memorised:
|
||||
|
||||
| Tab | Rows |
|
||||
@@ -527,16 +532,27 @@ Send CLI commands to a **repeater or room server you have admin permission on**
|
||||
| **System** | Name, Owner info, Admin password |
|
||||
| **Radio** | Radio (freq, bandwidth, spreading factor, coding rate), TX power |
|
||||
| **Routing** | Repeat, Advert interval, Flood advert interval, Max hops |
|
||||
| **Actions** | Reboot, Send advert, Send zero-hop advert, Sync clock, **Custom command...** |
|
||||
| **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:
|
||||
- Most **System/Radio/Routing** rows first **fetch** the node's current value, then open the keyboard **pre-filled** with it to edit — submitting sends the change. If the fetch fails or times out, the keyboard still opens (blank), so the value can be set blind.
|
||||
- **Admin password** has no fetch (there's no way to read a password back) — it opens straight to a blank keyboard.
|
||||
- **Actions** (Reboot, Send advert, …) send immediately, no editing step.
|
||||
- **Custom command...** (last row of Actions) opens the same free-text entry for anything not covered above — up to 160 characters, see the linked reference for the full grammar.
|
||||
- **Custom command...** (last row of Actions) opens the same free-text entry for anything not covered above — up to 160 characters, see the linked reference for the full grammar. The keyboard's **{}** key doubles as command completion here: it lists commands matching whatever's typed since the last space (narrowing as you type), and picking one completes that word instead of just inserting after it.
|
||||
4. **Read the reply** — the text reply opens in a scrollable view (**UP/DOWN** to scroll, **Cancel/Enter** to go back to the category tabs).
|
||||
|
||||
> [!WARNING]
|
||||
> This screen can run **destructive** commands on the *remote* node — `reboot`, `erase`, a new admin password, and others. That's the same capability the phone app's repeater-admin feature already exposes, not a new risk, but double-check the value and the target before sending.
|
||||
|
||||
**Passwords are remembered across reboots**, the same self-healing behaviour as room logins in Messages: after a successful admin login the password is saved on the device, so picking that node again — even after a power cycle — logs back in silently. If a saved password stops working (e.g. it was changed on the node), the failed login forgets it, so the next pick prompts for a new one. A correct password that just lacks admin permission is left alone — retyping the same one wouldn't change the outcome. Some commands are marked **Serial Only** in the CLI reference — those reject a remote CLI request and only work over that node's own USB serial connection.
|
||||
|
||||
### This device
|
||||
|
||||
A lighter, login-free version of the same tab carousel, for settings this device already has:
|
||||
|
||||
| Tab | Rows |
|
||||
| --- | ---- |
|
||||
| **System** | Name, Radio (freq, bandwidth, spreading factor, coding rate), TX power, Lat, Lon |
|
||||
| **Actions** | Send advert, Reboot |
|
||||
|
||||
There's no fetch step here — the current value is already known, so **Enter** opens the keyboard pre-filled with it immediately, and submitting applies the change at once (the same apply logic Settings' own Radio/TX power rows use) and saves it. Reboot and Send advert fire immediately, same as their remote counterparts. This is a fast, lightly-validated shortcut alongside Settings' own fuller editors (radio presets, chip-bounds frequency entry, etc.) — for careful bounds-checked editing, prefer Settings.
|
||||
|
||||
@@ -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;
|
||||
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 {
|
||||
// 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();
|
||||
}
|
||||
_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();
|
||||
}
|
||||
} 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]);
|
||||
|
||||
@@ -297,10 +297,21 @@ static int kbUtf8LastCharBytes(const char* buf, int len) {
|
||||
return n;
|
||||
}
|
||||
|
||||
static const int KB_PH_MAX = 12; // max placeholders in list
|
||||
static const int KB_PH_LEN = 9; // max placeholder string length incl. null
|
||||
static const int KB_PH_MAX = 20; // max placeholders in list (PopupMenu::PM_MAX_ITEMS=24 is the hard ceiling)
|
||||
static const int KB_PH_LEN = 30; // max placeholder string length incl. null -- sized for the longest
|
||||
// CLI-command candidate (AdminScreen), not just the short {x} tokens
|
||||
static const int KB_PH_VISIBLE = 3; // items shown at once in overlay
|
||||
|
||||
struct KeyboardWidget;
|
||||
// Optional hook: if set, called right before the placeholder picker opens so
|
||||
// the caller can repopulate the list contextually (e.g. AdminScreen's CLI
|
||||
// command autocomplete, filtered by what's already typed). When set, picking
|
||||
// an entry also replaces the in-progress word (the text since the last
|
||||
// space) instead of appending it -- true completion, not insertion. Fields
|
||||
// that don't set this (the common case -- {loc}/{time} etc.) keep the
|
||||
// original static-list, append-only behaviour untouched.
|
||||
typedef void (*PlaceholderRefreshFn)(KeyboardWidget& kb, void* ctx);
|
||||
|
||||
struct KeyboardWidget {
|
||||
char buf[KB_MAX_LEN + 1];
|
||||
int len;
|
||||
@@ -311,6 +322,12 @@ struct KeyboardWidget {
|
||||
char _ph_buf[KB_PH_MAX][KB_PH_LEN];
|
||||
int _ph_count;
|
||||
PopupMenu _ph_menu;
|
||||
PlaceholderRefreshFn _ph_refresh = nullptr;
|
||||
void* _ph_refresh_ctx = nullptr;
|
||||
const char* _ph_title = "Placeholder:"; // popup title -- overridable so e.g. AdminScreen can say "Commands:"
|
||||
void setPlaceholderRefresh(PlaceholderRefreshFn fn, void* ctx, const char* title = "Placeholder:") {
|
||||
_ph_refresh = fn; _ph_refresh_ctx = ctx; _ph_title = title;
|
||||
}
|
||||
|
||||
// Live setting lookup — set once by UITask::begin(). NULL only in tests/tools
|
||||
// that construct a KeyboardWidget standalone, in which case isT9() defaults
|
||||
@@ -419,6 +436,9 @@ struct KeyboardWidget {
|
||||
t9_cell = -1;
|
||||
t9_cycle = 0;
|
||||
_ph_menu.active = false;
|
||||
_ph_refresh = nullptr; // opt-in per session -- the owning screen re-sets it if it wants
|
||||
_ph_refresh_ctx = nullptr; // contextual autocomplete right after this begin()
|
||||
_ph_title = "Placeholder:";
|
||||
// default placeholders — always available
|
||||
_ph_count = 0;
|
||||
addPlaceholder("{loc}");
|
||||
@@ -601,9 +621,16 @@ struct KeyboardWidget {
|
||||
int idx = _ph_menu.selectedIndex();
|
||||
const char* ph = _ph_buf[idx];
|
||||
int ph_len = strlen(ph);
|
||||
if (len + ph_len <= max_len) {
|
||||
memcpy(buf + len, ph, ph_len);
|
||||
len += ph_len;
|
||||
// Contextual (refresh-hook) fields complete the in-progress word --
|
||||
// the text since the last space -- instead of appending after it, so
|
||||
// picking a match doesn't duplicate what's already been typed.
|
||||
int base_len = len;
|
||||
if (_ph_refresh) {
|
||||
while (base_len > 0 && buf[base_len - 1] != ' ') base_len--;
|
||||
}
|
||||
if (base_len + ph_len <= max_len) {
|
||||
memcpy(buf + base_len, ph, ph_len);
|
||||
len = base_len + ph_len;
|
||||
buf[len] = '\0';
|
||||
}
|
||||
}
|
||||
@@ -708,7 +735,8 @@ struct KeyboardWidget {
|
||||
if (len > 0) { len -= kbUtf8LastCharBytes(buf, len); buf[len] = '\0'; }
|
||||
break;
|
||||
case 3:
|
||||
_ph_menu.begin("Placeholder:", KB_PH_VISIBLE);
|
||||
if (_ph_refresh) _ph_refresh(*this, _ph_refresh_ctx); // contextual repopulate, if wired up
|
||||
_ph_menu.begin(_ph_title, KB_PH_VISIBLE);
|
||||
for (int i = 0; i < _ph_count; i++) _ph_menu.addItem(_ph_buf[i]);
|
||||
break;
|
||||
case 4:
|
||||
|
||||
@@ -25,7 +25,16 @@ class NearbyScreen : public UIScreen {
|
||||
|
||||
// ── action-menu actions (matched by id, not by row index) ────────────────────
|
||||
enum Action : uint8_t { ACT_NAV, ACT_PING, ACT_WAYPOINT, ACT_LOCATOR,
|
||||
ACT_ADD, ACT_DELETE, ACT_FAV, ACT_SORT, ACT_SCAN };
|
||||
ACT_ADD, ACT_DELETE, ACT_FAV, ACT_ADMIN, ACT_SORT, ACT_SCAN };
|
||||
|
||||
// Set by UITask::pickAdminTarget() (Tools > Admin's "Remote node..." choice):
|
||||
// while true, ENTER on an eligible row (a stored repeater/room contact) hands
|
||||
// the node straight to Admin instead of opening the detail view -- everything
|
||||
// else (filters, scan, ping, sort, the Hold-Enter menu) behaves identically to
|
||||
// normal Nodes browsing, so picking a node for Admin looks exactly like using
|
||||
// this screen for anything else. Mirrors MessagesScreen's
|
||||
// startPickBotChannel()/startPickBotRoom() pick-mode idiom.
|
||||
bool _pick_admin_target = false;
|
||||
|
||||
// ── unified list entry ───────────────────────────────────────────────────────
|
||||
struct Entry {
|
||||
@@ -541,6 +550,10 @@ class NearbyScreen : public UIScreen {
|
||||
bool is_contact = entryIsContact(e);
|
||||
bool can_add = e && has_key && !is_contact; // a new node we can save
|
||||
bool is_fav = e && has_key && _task->findFavouriteSlot(e->pub_key) >= 0;
|
||||
// Admin needs a real saved contact (repeater/room), not a scan result or a
|
||||
// name-only live-share row -- same gating as startPickAdminTarget()'s ENTER.
|
||||
bool is_admin_target = e && stored && e->contact_idx >= 0
|
||||
&& (e->type == ADV_TYPE_REPEATER || e->type == ADV_TYPE_ROOM);
|
||||
|
||||
buildSortLabel();
|
||||
_menu_action_count = 0;
|
||||
@@ -558,6 +571,7 @@ class NearbyScreen : public UIScreen {
|
||||
if (has_gps && has_key) add("Set as target", ACT_LOCATOR);
|
||||
if (can_add) add("Add contact", ACT_ADD);
|
||||
if (is_contact && has_key) add(is_fav ? "Unfavourite" : "Favourite", ACT_FAV);
|
||||
if (is_admin_target) add("Admin", ACT_ADMIN);
|
||||
if (is_contact && has_key) add("Delete contact", ACT_DELETE);
|
||||
if (stored) add(_sort_label, ACT_SORT); // sort is meaningless for live-scan rows
|
||||
add(stored ? "Discover scan" : "Rescan", ACT_SCAN);
|
||||
@@ -603,6 +617,13 @@ class NearbyScreen : public UIScreen {
|
||||
break;
|
||||
}
|
||||
case ACT_DELETE: startDeleteConfirm(); break;
|
||||
case ACT_ADMIN: {
|
||||
const Entry* e = selected();
|
||||
ContactInfo ci;
|
||||
if (e && e->contact_idx >= 0 && the_mesh.getContactByIdx(e->contact_idx, ci))
|
||||
_task->openAdminFor(ci);
|
||||
break;
|
||||
}
|
||||
case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER
|
||||
case ACT_SCAN: enterScan(); break;
|
||||
}
|
||||
@@ -724,11 +745,16 @@ public:
|
||||
_ping_menu.active = false;
|
||||
_confirm.active = false;
|
||||
_pinging = false;
|
||||
_pick_admin_target = false; // stale pick-mode from a previous visit shouldn't linger
|
||||
resetPingLines();
|
||||
_task->clearPing();
|
||||
refreshStored();
|
||||
}
|
||||
|
||||
// Entered via UITask::pickAdminTarget() right after setCurrScreen(this) has
|
||||
// already run onShow()'s reset above -- just arms the pick-mode flag.
|
||||
void startPickAdminTarget() { _pick_admin_target = true; }
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
|
||||
@@ -908,6 +934,7 @@ public:
|
||||
|
||||
// ── list view ───────────────────────────────────────────────────────────
|
||||
if (c == KEY_CANCEL) {
|
||||
if (_pick_admin_target) { _pick_admin_target = false; _task->gotoAdminScreen(); return true; }
|
||||
if (_source == SRC_SCAN) leaveScan();
|
||||
else _task->gotoToolsScreen();
|
||||
return true;
|
||||
@@ -917,6 +944,17 @@ public:
|
||||
if (c == KEY_UP && _count > 0) { _sel = (_sel > 0) ? _sel - 1 : _count - 1; return true; }
|
||||
if (c == KEY_DOWN && _count > 0) { _sel = (_sel < _count - 1) ? _sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER) {
|
||||
if (_pick_admin_target) {
|
||||
const Entry* e = selected();
|
||||
ContactInfo ci;
|
||||
if (e && e->contact_idx >= 0 && (e->type == ADV_TYPE_REPEATER || e->type == ADV_TYPE_ROOM)
|
||||
&& the_mesh.getContactByIdx(e->contact_idx, ci)) {
|
||||
_pick_admin_target = false;
|
||||
_task->openAdminFor(ci);
|
||||
}
|
||||
// else: row isn't an eligible admin target -- ignore, stay on the picker.
|
||||
return true;
|
||||
}
|
||||
if (_count == 0) { if (_source == SRC_STORED) enterScan(); return true; }
|
||||
_detail = true;
|
||||
_detail_refresh_ms = millis();
|
||||
|
||||
@@ -27,24 +27,41 @@ inline void drawPill(DisplayDriver& display, int x, int w, const char* label, bo
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
// Truncate `label` to fit within `max_w` pixels of text (no padding), the same
|
||||
// shrink-and-append-"..." approach as DisplayDriver::drawTextEllipsized (which
|
||||
// can't be reused directly here: it draws immediately at a fixed x, but a tab
|
||||
// pill needs the truncated width known *before* drawing, to size/centre the
|
||||
// pill). Returns the resulting text's pixel width; writes the (possibly
|
||||
// truncated) text into `out`.
|
||||
// Truncate `label` to fit within `max_w` pixels of text (no padding). Similar
|
||||
// intent to DisplayDriver::drawTextEllipsized (which can't be reused directly
|
||||
// here: it draws immediately at a fixed x, but a tab pill needs the truncated
|
||||
// width known *before* drawing, to size/centre the pill), but verifies the
|
||||
// fit by re-measuring the actual "text..." candidate at each step, rather
|
||||
// than estimating via a separately-measured ellipsis width -- font kerning
|
||||
// between the last real glyph and the first dot can make a single-pass
|
||||
// estimate overshoot by a px or two, which was enough to visibly spill the
|
||||
// dots into whatever sits just past this tab (a neighbour, a reserved icon
|
||||
// like the context-menu hint). Returns the resulting text's pixel width
|
||||
// (always <= max_w, unless even a bare "..." doesn't fit); writes the
|
||||
// (possibly truncated) text into `out`.
|
||||
inline int ellipsize(DisplayDriver& display, const char* label, int max_w, char* out, size_t out_sz) {
|
||||
strncpy(out, label, out_sz - 1);
|
||||
out[out_sz - 1] = '\0';
|
||||
int w = display.getTextWidth(out);
|
||||
if (w <= max_w) return w;
|
||||
|
||||
const int ellipsis_w = display.getTextWidth("...");
|
||||
int len = (int)strlen(out);
|
||||
while (len > 0 && display.getTextWidth(out) > max_w - ellipsis_w) out[--len] = '\0';
|
||||
// Strip an orphaned UTF-8 lead byte left by the byte-at-a-time trim above.
|
||||
while (len > 0 && ((uint8_t)out[len - 1] & 0xC0) == 0xC0) out[--len] = '\0';
|
||||
strcat(out, "...");
|
||||
char trial[40];
|
||||
while (len > 0) {
|
||||
// Strip an orphaned UTF-8 lead byte before trying this length.
|
||||
while (len > 0 && ((uint8_t)out[len - 1] & 0xC0) == 0xC0) len--;
|
||||
if (len == 0) break;
|
||||
memcpy(trial, out, len);
|
||||
strcpy(trial + len, "...");
|
||||
if (display.getTextWidth(trial) <= max_w) {
|
||||
strncpy(out, trial, out_sz - 1);
|
||||
out[out_sz - 1] = '\0';
|
||||
return display.getTextWidth(out);
|
||||
}
|
||||
len--;
|
||||
}
|
||||
strncpy(out, "...", out_sz - 1);
|
||||
out[out_sz - 1] = '\0';
|
||||
return display.getTextWidth(out);
|
||||
}
|
||||
|
||||
@@ -78,6 +95,12 @@ inline void draw(DisplayDriver& display, const char* const* labels, int count, i
|
||||
if (avail >= min_w) {
|
||||
int tw = ellipsize(display, labels[li], avail - pad * 2, buf, sizeof(buf));
|
||||
int w = tw + pad * 2;
|
||||
// ellipsize() targets max_w but can overshoot it by a px or two (the
|
||||
// ellipsis is re-measured as a whole, so kerning against the last real
|
||||
// character isn't accounted for) -- clamp so the pill can never spill
|
||||
// past its allotted space (into the screen edge, or a reserved icon
|
||||
// on the other side).
|
||||
if (w > avail) w = avail;
|
||||
drawPill(display, lx - w, w, buf, false);
|
||||
lx = lx - w - gap;
|
||||
} else lfit = false;
|
||||
@@ -89,6 +112,7 @@ inline void draw(DisplayDriver& display, const char* const* labels, int count, i
|
||||
if (avail >= min_w) {
|
||||
int tw = ellipsize(display, labels[ri], avail - pad * 2, buf, sizeof(buf));
|
||||
int w = tw + pad * 2;
|
||||
if (w > avail) w = avail; // never spill past rx_limit (e.g. into a context-menu icon)
|
||||
drawPill(display, rx, w, buf, false);
|
||||
rx += w + gap;
|
||||
} else rfit = false;
|
||||
|
||||
@@ -1471,6 +1471,16 @@ void UITask::gotoToolsScreen() { setCurrScreen(tools_screen); }
|
||||
void UITask::gotoBotScreen() { setCurrScreen(bot_screen); }
|
||||
void UITask::gotoAdminScreen() { setCurrScreen(admin_screen); } // AdminScreen::onShow() resets it
|
||||
void UITask::gotoNearbyScreen() { setCurrScreen(nearby_screen); }
|
||||
|
||||
void UITask::pickAdminTarget() {
|
||||
setCurrScreen(nearby_screen); // runs NearbyScreen::onShow()'s reset first
|
||||
((NearbyScreen*)nearby_screen)->startPickAdminTarget();
|
||||
}
|
||||
|
||||
void UITask::openAdminFor(const ContactInfo& ci) {
|
||||
setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first
|
||||
((AdminScreen*)admin_screen)->startFor(ci);
|
||||
}
|
||||
void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); }
|
||||
void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
|
||||
void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); }
|
||||
|
||||
@@ -231,6 +231,8 @@ public:
|
||||
void gotoRingtoneEditor(int slot = 0);
|
||||
void gotoBotScreen();
|
||||
void gotoAdminScreen();
|
||||
void pickAdminTarget(); // open Nodes to choose a remote repeater/room for Admin
|
||||
void openAdminFor(const ContactInfo& ci); // canonical Admin entry for a specific target (Nodes' Hold-Enter menu or the picker above)
|
||||
void gotoNearbyScreen();
|
||||
void gotoDashboardConfig();
|
||||
void gotoAutoAdvertScreen();
|
||||
|
||||
Reference in New Issue
Block a user