feat(admin): typed Radio/Routing field editors; pre-release audit fixes

Tools > Admin: split the Radio tab's single "f,bw,sf,cr" comma-string
field (free-text keyboard) into 4 independent, type-appropriate rows --
Frequency (same digit-cursor DigitEditor Settings/Repeater use locally),
Bandwidth/Spreading factor/Coding rate (discrete-set LEFT/RIGHT stepping),
plus TX power and the 3 Routing numeric fields as number steppers and
Repeat as an ON/OFF toggle. Edits happen locally (zero mesh traffic per
keystroke); only Enter sends one combined `set`, Cancel sends nothing.

Full pre-release audit of all 16 commits since v1.22 (5 parallel focus
areas: display/font, keyboard/messages, Nodes/Admin, Bot/channels,
UITask+NodePrefs schema) turned up and fixed:

- Admin: fetched FK_NUMBER values weren't clamped to the field's range,
  so a value already out-of-range could get stuck unreachable; fixed
  commit-time float format noise (%.6f -> %.3f); Cancel/failed-login
  always returned to the Nodes picker even when Admin was opened
  directly from a node's Hold-Enter action -- now returns to wherever
  it was actually opened from (AdminScreen::_from_picker).
- Keyboard: one-shot Shift was consumed after the *first* T9 multi-tap,
  so cycling to the 2nd/3rd candidate always came out lowercase --
  fixed by caching the cycle's caps state (t9_caps).
- Messages: history is numbered newest-first, so a message arriving
  while scrolled up to an older one silently relabeled the view onto a
  different message -- selection now shifts with the insert.
- Channels: onChannelRemoved() didn't clear ch_notif_override/
  ch_notif_muted/ch_fav_bitmask despite its own contract comment
  requiring it (now far more reachable via the on-device Delete);
  an all-zero hex secret silently self-deleted the channel it was
  just saved into (collides with the empty-slot sentinel) -- rejected.
- Room login: isRoomLoggedIn() indexed the login-tracking ring
  directly instead of via its head offset, silently wrong once the
  ring wraps (8+ rooms/session) -- the new on-device Logout depends
  on this being right.
- Font/display: removed the now fully-inert Settings > Display > Font
  toggle and the dead LemonFont.h (retired by the earlier misc-fixed
  font unification, zero remaining includes); fixed a copy-pasted
  "5x7" comment (font is 6x9), two meaningless dead ternaries, and an
  OLED/e-ink inconsistency in the undefined-glyph fallback box offset.
- AdminField's `kind`/bounds fields no longer rely on default member
  initializers inside aggregate-init: this toolchain's actual nRF52
  build (unlike env:native) has no explicit -std= override, so it
  predates C++14's aggregate-with-default-member-initializer rule.
  Given an explicit constructor instead -- portable regardless of
  standard, all existing field-table literals unchanged.

Docs updated to match: tools_screen.md (Diagnostics as a 3-tab
carousel, was documented as one flat screen), message_screen.md
(chat bubbles, newest-at-bottom, cursor mode, secret validation),
settings_screen.md (dropped the dead Font row), solo_ui_framework.md
(header menu_hint signatures, icon priority-drop, KeyboardWidget's
T9/alphabets/cursor-mode). release-notes.md gains the v1.23 section
covering all of the above plus the other 15 commits since v1.22.

Build-verified on WioTrackerL1_companion_solo_dual.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-07-17 19:38:51 +02:00
parent e439dd0fbe
commit 453dc5e570
20 changed files with 396 additions and 1605 deletions

View File

@@ -306,7 +306,7 @@ public:
// To check if there is pending work
bool hasPendingWork() const;
// Number of auto-replies sent since boot (DM + channel). Shown on BotScreen.
// Number of auto-replies sent since boot (DM + channel + room). Shown on BotScreen.
uint16_t botReplyCount() const { return _bot_reply_count; }
private:

View File

@@ -77,8 +77,8 @@ struct NodePrefs { // persisted to file
uint16_t low_batt_mv; // auto-shutdown threshold: 0=disabled, 3000-3500 mV
uint8_t batt_display_mode; // 0=icon, 1=percent, 2=voltage
char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time})
uint64_t ch_notif_override; // bitmask: bit i = channel i has explicit notification setting
uint64_t ch_notif_muted; // bitmask: bit i = channel i muted (only if override bit set)
uint64_t ch_notif_override; // bitmask: bit i = channel i has explicit notification setting [del→onChannelRemoved]
uint64_t ch_notif_muted; // bitmask: bit i = channel i muted (only if override bit set) [del→onChannelRemoved]
uint8_t dm_show_all; // 0=favourites only (default), 1=all chat contacts
uint8_t room_fav_only; // 0=all room servers (default), 1=favourites only
uint8_t ringtone_bpm_idx; // index into {60,90,120,150,180}
@@ -148,7 +148,7 @@ struct NodePrefs { // persisted to file
uint8_t trail_min_delta_idx; // min-distance gate level (0=finest..3); metres or feet per units_imperial
uint8_t trail_units_idx; // legacy: old combined speed/pace+unit index (km/h, mph, min/km, min/mi)
uint64_t ch_fav_bitmask; // bit i = channel i is marked as favourite
uint64_t ch_fav_bitmask; // bit i = channel i is marked as favourite [del→onChannelRemoved]
uint8_t ch_fav_only; // 0=show all channels (default), 1=show favourites only
// Global measurement system for every distance/speed shown in the UI
@@ -329,8 +329,8 @@ struct NodePrefs { // persisted to file
// key (Latin → alphabet → symbols → Latin), so composing in it needs no new
// key, just Settings Keyboard Alphabet to pick which one is available.
// See KeyboardWidget.h for the per-alphabet grids (KB_CYRILLIC_CHARS etc.)
// and Lemon font (src/helpers/ui/LemonFont.h) for on-screen rendering —
// every alphabet here must be in Lemon's U+0020-04FF range.
// and the misc-fixed font (src/helpers/ui/MiscFixedFont.h) for on-screen
// rendering — every alphabet here must be in its U+0020-04FF range.
//
// The Latin-diacritic entries (Polish..Nordic) replace what used to be a
// single combined "Ext.Latin" curated subset — each is now its own full,

View File

@@ -4,13 +4,16 @@
// see docs/cli_commands.md). This device's own settings live in Settings /
// Tools, not here -- Admin is purely for remote nodes.
//
// Entry is always a target-then-act flow: Tools > Admin (or a repeater/room's
// Hold-Enter "Admin" action in Nodes) opens Tools > Nodes in a pick-mode
// (NearbyScreen::startPickAdminTarget(), the same borrow-another-screen's-list
// idiom the channel/bot pickers use), and picking a node calls the single
// canonical UITask::openAdminFor(ContactInfo) -> startFor(). Backing out of a
// command screen returns to that picker; backing out of the picker returns to
// Tools.
// Entry is a target-then-act flow, reached two ways: Tools > Admin opens
// Tools > Nodes in a pick-mode (NearbyScreen::startPickAdminTarget(), the same
// borrow-another-screen's-list idiom the channel/bot pickers use) and picking
// a node from it enters Admin; or a repeater/room's own Hold-Enter "Admin"
// action in Nodes enters Admin directly while browsing normally. Both call the
// single canonical UITask::openAdminFor(ContactInfo, from_picker) -> startFor(),
// which remembers which door was used: Cancel/login-failure from a command
// screen returns to the picker if that's how the user arrived, or straight
// back to Nodes if they came in directly (see returnToOrigin()); backing out
// of the picker itself returns to Tools.
//
// After login (session password, self-heals like room logins) a category tab
// carousel of AdminField{label, get_cmd, set_prefix} rows unlocks:
@@ -22,6 +25,7 @@
#include "FullscreenMsgView.h"
#include "TabBar.h"
#include "RadioParamsEditor.h" // DigitEditor + stepSF/stepBW/stepCR -- same widgets Settings/Repeater use locally
#include <helpers/ClientACL.h> // PERM_ACL_ADMIN / PERM_ACL_ROLE_MASK
class AdminScreen : public UIScreen {
@@ -32,6 +36,18 @@ class AdminScreen : public UIScreen {
ContactInfo _target;
// True when this visit was reached via the "Remote node..." picker
// (UITask::pickAdminTarget() -> NearbyScreen's pick-mode); false when reached
// directly from a node's own Hold-Enter "Admin" action while browsing Nodes
// normally. Determines where Cancel/failure paths send the user back to --
// see returnToOrigin(). Set fresh by startFor() on every entry.
bool _from_picker = true;
// Cancel/failure exit: back to the target picker if that's how we got here,
// otherwise straight back to Nodes (we were opened directly from its
// "Admin" context-menu action, so a bare pick-list would be a detour).
void returnToOrigin() { if (_from_picker) _task->pickAdminTarget(); else _task->gotoNearbyScreen(); }
// LOGIN
char _login_pw[16] = "";
// True while waiting for a login result with no keyboard on screen -- either a
@@ -50,7 +66,33 @@ class AdminScreen : public UIScreen {
enum AdminTab : uint8_t { ATAB_SYSTEM, ATAB_RADIO, ATAB_ROUTING, ATAB_ACTIONS, ATAB_COUNT };
static const char* TAB_LABELS[ATAB_COUNT];
struct AdminField { const char* label; const char* get_cmd; const char* set_prefix; };
// Most fields are still free text (get reply pre-fills the keyboard, user
// retypes, "set <prefix> <text>" on submit) -- fine for names/passwords, but
// painful for values that are really a number or one of a handful of options.
// `kind` (default FK_TEXT, so every existing 3-field {label,get,set} literal
// below still compiles unchanged) opts a field into a typed, in-place editor
// instead: FK_ONOFF toggles on/off; FK_NUMBER steps an int within
// [min_val,max_val] by `step`; FK_RADIO_* are four views onto the same
// "get radio"/"set radio f,bw,sf,cr" tuple (freq via the same digit-cursor
// DigitEditor Settings/Repeater use, bw/sf/cr via RadioParamsEditor's
// discrete-set steppers) -- editing any one re-sends all four, the other
// three carried over unchanged from the fetch. See activateField()/
// beginValueEdit()/the `_value_editing` block in handleInput().
enum FieldKind : uint8_t { FK_TEXT, FK_ONOFF, FK_NUMBER, FK_RADIO_FREQ, FK_RADIO_BW, FK_RADIO_SF, FK_RADIO_CR };
struct AdminField {
const char* label; const char* get_cmd; const char* set_prefix;
FieldKind kind;
float min_val, max_val, step; // FK_NUMBER only
// Explicit ctor (not default member initializers) so every existing
// 3-field {label,get,set} literal below still compiles as a converting
// constructor call -- this toolchain's actual C++ standard (the Adafruit
// nRF52 core's default; only `env:native` sets -std=c++17) predates
// C++14's aggregate-with-default-member-initializer rule, so plain
// aggregate init here fails to convert.
AdminField(const char* l, const char* g, const char* s, FieldKind k = FK_TEXT,
float mn = 0, float mx = 0, float st = 1)
: label(l), get_cmd(g), set_prefix(s), kind(k), min_val(mn), max_val(mx), step(st) {}
};
static const AdminField SYSTEM_FIELDS[];
static const AdminField RADIO_FIELDS[];
static const AdminField ROUTING_FIELDS[];
@@ -76,6 +118,15 @@ class AdminScreen : public UIScreen {
bool _fetch_for_edit = false; // true while waiting on a "get" whose reply opens an editor
const char* _pending_set_prefix = nullptr; // non-null => edited keyboard text gets this prefix on submit
// ── Typed value editors (FK_ONOFF/FK_NUMBER/FK_RADIO_*) ────────────────────
bool _fetch_for_value = false; // true while waiting on a "get" whose reply feeds a typed editor
bool _value_editing = false; // true while the selected row's typed editor is live (Enter to send, Cancel to drop)
const AdminField* _edit_field = nullptr; // field currently open in _value_editing
float _edit_val = 0; // FK_ONOFF (0/1) / FK_NUMBER current value
float _radio_freq = 0, _radio_bw = 0; // last-fetched radio tuple -- kept for whichever of the
uint8_t _radio_sf = 0, _radio_cr = 0; // 4 radio sub-fields isn't the one actually being edited
DigitEditor _freq_ed; // FK_RADIO_FREQ's digit-cursor editor (same widget as Settings/Repeater)
// REPLY
FullscreenMsgView _reply_view;
char _reply_text[200] = "";
@@ -157,6 +208,14 @@ class AdminScreen : public UIScreen {
void activateField(const AdminField& f) {
_fetch_for_edit = false;
_pending_set_prefix = nullptr;
if (f.kind != FK_TEXT) { // typed editor: on/off, number, or a radio sub-field
_edit_field = &f;
_fetch_for_value = true;
strncpy(_cmd_text, f.get_cmd, sizeof(_cmd_text) - 1);
_cmd_text[sizeof(_cmd_text) - 1] = '\0';
sendCommand();
return;
}
if (f.get_cmd == nullptr && f.set_prefix == nullptr) { // Custom command...
openValueKb(_cmd_text, true);
} else if (f.set_prefix == nullptr) { // Action
@@ -183,6 +242,73 @@ class AdminScreen : public UIScreen {
openValueKb("");
}
// Splits a "get radio" reply's value ("freq,bw,sf,cr") into its 4 parts.
// Self-contained (no mesh::Utils dependency) since it only ever needs to
// parse this one, fixed 4-field shape.
static bool parseRadioReply(const char* val, float& freq, float& bw, uint8_t& sf, uint8_t& cr) {
char tmp[48];
strncpy(tmp, val, sizeof(tmp) - 1);
tmp[sizeof(tmp) - 1] = '\0';
char* p = tmp;
char* parts[4];
for (int i = 0; i < 4; i++) {
parts[i] = p;
if (i < 3) {
char* c = strchr(p, ',');
if (!c) return false;
*c = '\0';
p = c + 1;
}
}
freq = strtof(parts[0], nullptr);
bw = strtof(parts[1], nullptr);
sf = (uint8_t)atoi(parts[2]);
cr = (uint8_t)atoi(parts[3]);
return true;
}
// Seeds the typed editor from a (already "> "-stripped) get-reply, per
// _edit_field->kind. Returns false on a malformed reply (caller falls back
// to an alert instead of entering an editor with garbage in it).
bool beginValueEdit(const char* val) {
switch (_edit_field->kind) {
case FK_ONOFF:
_edit_val = (memcmp(val, "on", 2) == 0) ? 1.0f : 0.0f;
return true;
case FK_NUMBER:
// Clamp in case the node's real value sits outside this row's declared
// range (e.g. set via a different firmware/build or the Custom command) --
// otherwise every LEFT/RIGHT step lands out of range and gets silently
// rejected in both directions, stranding the field on an unreachable value.
_edit_val = constrain((float)atoi(val), _edit_field->min_val, _edit_field->max_val);
return true;
case FK_RADIO_FREQ:
case FK_RADIO_BW:
case FK_RADIO_SF:
case FK_RADIO_CR:
if (!parseRadioReply(val, _radio_freq, _radio_bw, _radio_sf, _radio_cr)) return false;
if (_edit_field->kind == FK_RADIO_FREQ)
_freq_ed.begin(_radio_freq, _edit_field->min_val, _edit_field->max_val, 4, 3);
return true;
default:
return false;
}
}
// Right-column display text for the row currently in _value_editing (freq
// has its own DigitEditor::render call instead -- see render()). Matches
// RepeaterScreen's itemValue() formatting for the same quantities (plain
// %d for SF/CR, %.1f for BW) so a value reads the same wherever it's shown.
void formatEditValue(char* buf, size_t n) const {
switch (_edit_field->kind) {
case FK_ONOFF: snprintf(buf, n, "%s", _edit_val != 0 ? "ON" : "OFF"); break;
case FK_RADIO_BW: snprintf(buf, n, "%.1f", _radio_bw); break;
case FK_RADIO_SF: snprintf(buf, n, "%d", (int)_radio_sf); break;
case FK_RADIO_CR: snprintf(buf, n, "%d", (int)_radio_cr); break;
default: snprintf(buf, n, "%d", (int)_edit_val); break; // FK_NUMBER
}
}
public:
explicit AdminScreen(UITask* task) : _task(task) {}
@@ -196,6 +322,8 @@ public:
_waiting = false;
_fetch_for_edit = false;
_pending_set_prefix = nullptr;
_fetch_for_value = false;
_value_editing = false;
_login_waiting = false;
_admin_ok = false;
}
@@ -204,8 +332,9 @@ public:
// 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) {
void startFor(const ContactInfo& ci, bool from_picker) {
_target = ci;
_from_picker = from_picker;
if (isAdminOk(ci.id.pub_key)) {
_tab = ATAB_SYSTEM; _row_sel = _row_scroll = 0;
_phase = COMMAND;
@@ -235,13 +364,13 @@ public:
// Correct password, just insufficient permission -- leave any saved
// password alone, retyping the same one won't change the outcome.
_task->showAlert("Not admin on this node", 1600);
_task->pickAdminTarget();
returnToOrigin();
} else {
// Wrong/stale password -- forget it so the next attempt prompts fresh,
// same self-healing behaviour as a room login.
the_mesh.forgetRoomPassword(pub_key);
_task->showAlert("Login failed", 1400);
_task->pickAdminTarget();
returnToOrigin();
}
}
@@ -249,10 +378,25 @@ public:
void onAdminReply(const uint8_t* pub_key, const char* text) {
if (!_waiting || memcmp(_target.id.pub_key, pub_key, 4) != 0) return;
_waiting = false;
// Every "get ..." reply comes back as "> value" (see CommonCLI::handleGetCmd) --
// strip that CLI-decoration prefix before parsing/pre-filling from it. The
// final free-form REPLY view still shows `text` raw: action confirmations
// like "OK" never have the prefix, and a user-typed Custom "get ..." is
// meant to echo the raw wire reply verbatim, prefix included.
const char* val = (text[0] == '>' && text[1] == ' ') ? text + 2 : text;
if (_fetch_for_value) {
_fetch_for_value = false;
if (beginValueEdit(val)) {
_value_editing = true;
} else {
_task->showAlert("Fetch failed", 1400);
}
return;
}
if (_fetch_for_edit) {
_fetch_for_edit = false;
char trimmed[161];
strncpy(trimmed, text, sizeof(trimmed) - 1);
strncpy(trimmed, val, sizeof(trimmed) - 1);
trimmed[sizeof(trimmed) - 1] = '\0';
size_t n = strlen(trimmed);
while (n > 0 && (trimmed[n-1] == '\n' || trimmed[n-1] == '\r' || trimmed[n-1] == ' ')) trimmed[--n] = '\0';
@@ -268,8 +412,9 @@ public:
void poll() override {
if (_phase == COMMAND && _waiting && (int32_t)(millis() - _cmd_deadline_ms) >= 0) {
_waiting = false;
if (_fetch_for_edit) { fallBackToBlankEdit(); _task->showAlert("Fetch failed - enter value", 1400); }
else { _task->showAlert("No response (timeout)", 1600); }
if (_fetch_for_edit) { fallBackToBlankEdit(); _task->showAlert("Fetch failed - enter value", 1400); }
else if (_fetch_for_value) { _fetch_for_value = false; _task->showAlert("Fetch failed - try again", 1400); }
else { _task->showAlert("No response (timeout)", 1600); }
}
}
@@ -294,16 +439,26 @@ public:
snprintf(title, sizeof(title), "%.23s", _target.name);
display.drawCenteredHeader(title);
display.setCursor(2, display.listStart());
display.print(_fetch_for_edit ? "Fetching..." : "Waiting for reply...");
display.print((_fetch_for_edit || _fetch_for_value) ? "Fetching..." : "Waiting for reply...");
return 500;
}
tabbar::draw(display, TAB_LABELS, ATAB_COUNT, _tab);
int n = ROWS_PER_TAB[_tab];
drawList(display, n, _row_sel, _row_scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, fieldAt(_tab, i).label);
const AdminField& f = fieldAt(_tab, i);
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, f.label);
if (_value_editing && sel) { // the row whose typed editor is currently open
if (f.kind == FK_RADIO_FREQ) {
_freq_ed.render(display, display.valCol(), y);
} else {
char val[16];
formatEditValue(val, sizeof(val));
display.drawTextRightAlign(display.width() - reserve - 2, y, val);
}
}
});
return 2000;
return _value_editing ? 50 : 2000;
}
// REPLY
@@ -313,18 +468,18 @@ public:
bool handleInput(char c) override {
if (_phase == LOGIN) {
if (_login_waiting) {
if (c == KEY_CANCEL) { _login_waiting = false; _task->pickAdminTarget(); }
if (c == KEY_CANCEL) { _login_waiting = false; returnToOrigin(); }
return true;
}
auto r = kb().handleInput(c);
if (r == KeyboardWidget::CANCELLED) {
_task->pickAdminTarget();
returnToOrigin();
} else if (r == KeyboardWidget::DONE) {
strncpy(_login_pw, kb().buf, sizeof(_login_pw) - 1);
_login_pw[sizeof(_login_pw) - 1] = '\0';
bool sent = the_mesh.sendRoomLogin(_target, _login_pw);
_task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500);
if (sent) _login_waiting = true; else _task->pickAdminTarget();
if (sent) _login_waiting = true; else returnToOrigin();
// else: stay in LOGIN until onRoomLoginResult() fires above.
}
return true;
@@ -353,14 +508,58 @@ public:
}
return true;
}
if (_waiting) {
if (c == KEY_CANCEL) {
_waiting = false;
if (_fetch_for_edit) fallBackToBlankEdit();
if (_value_editing) {
bool commit = false;
if (_edit_field->kind == FK_RADIO_FREQ) {
auto r = _freq_ed.handleInput(c);
if (r == DigitEditor::DONE) { _radio_freq = _freq_ed.value; commit = true; }
else if (r == DigitEditor::CANCELLED) { _value_editing = false; }
} else if (keyIsPrev(c) || keyIsNext(c)) {
int dir = keyIsNext(c) ? 1 : -1;
switch (_edit_field->kind) {
case FK_ONOFF: _edit_val = (_edit_val != 0) ? 0.0f : 1.0f; break;
case FK_NUMBER: {
float nv = _edit_val + dir * _edit_field->step;
if (nv >= _edit_field->min_val && nv <= _edit_field->max_val) _edit_val = nv;
break;
}
case FK_RADIO_BW: RadioParamsEditor::stepBW(_radio_bw, dir); break;
case FK_RADIO_SF: RadioParamsEditor::stepSF(_radio_sf, dir); break;
case FK_RADIO_CR: RadioParamsEditor::stepCR(_radio_cr, dir); break;
default: break;
}
} else if (c == KEY_ENTER) {
commit = true;
} else if (c == KEY_CANCEL) {
_value_editing = false;
}
if (commit) {
_value_editing = false;
switch (_edit_field->kind) {
case FK_RADIO_FREQ: case FK_RADIO_BW: case FK_RADIO_SF: case FK_RADIO_CR:
snprintf(_cmd_text, sizeof(_cmd_text), "%s %.3f,%.3f,%d,%d",
_edit_field->set_prefix, _radio_freq, _radio_bw, (int)_radio_sf, (int)_radio_cr);
break;
case FK_ONOFF:
snprintf(_cmd_text, sizeof(_cmd_text), "%s %s", _edit_field->set_prefix, _edit_val != 0 ? "on" : "off");
break;
default: // FK_NUMBER
snprintf(_cmd_text, sizeof(_cmd_text), "%s %d", _edit_field->set_prefix, (int)_edit_val);
break;
}
sendCommand();
}
return true;
}
if (c == KEY_CANCEL) { _task->pickAdminTarget(); return true; }
if (_waiting) {
if (c == KEY_CANCEL) {
_waiting = false;
if (_fetch_for_edit) fallBackToBlankEdit();
else if (_fetch_for_value) _fetch_for_value = false;
}
return true;
}
if (c == KEY_CANCEL) { returnToOrigin(); return true; }
if (keyIsPrev(c)) { _tab = (_tab + ATAB_COUNT - 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; }
if (keyIsNext(c)) { _tab = (_tab + 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; }
int n = ROWS_PER_TAB[_tab];
@@ -384,15 +583,21 @@ const AdminScreen::AdminField AdminScreen::SYSTEM_FIELDS[] = {
{ "Owner info", "get owner.info", "set owner.info" },
{ "Admin password", nullptr, "password" },
};
// The 4 radio rows all share get_cmd/set_prefix ("get radio" / "set radio") --
// each fetches and re-sends the whole f,bw,sf,cr tuple, only its own value
// actually editable (see beginValueEdit()/the commit switch in handleInput()).
const AdminScreen::AdminField AdminScreen::RADIO_FIELDS[] = {
{ "Radio (f,bw,sf,cr)", "get radio", "set radio" },
{ "TX power", "get tx", "set tx" },
{ "Frequency (MHz)", "get radio", "set radio", AdminScreen::FK_RADIO_FREQ, 150.0f, 2500.0f },
{ "Bandwidth (kHz)", "get radio", "set radio", AdminScreen::FK_RADIO_BW },
{ "Spreading factor", "get radio", "set radio", AdminScreen::FK_RADIO_SF },
{ "Coding rate", "get radio", "set radio", AdminScreen::FK_RADIO_CR },
{ "TX power (dBm)", "get tx", "set tx", AdminScreen::FK_NUMBER, -9, 30, 1 },
};
const AdminScreen::AdminField AdminScreen::ROUTING_FIELDS[] = {
{ "Repeat", "get repeat", "set repeat" },
{ "Advert interval (min)", "get advert.interval", "set advert.interval" },
{ "Flood advert interval (h)", "get flood.advert.interval", "set flood.advert.interval" },
{ "Max hops", "get flood.max", "set flood.max" },
{ "Repeat", "get repeat", "set repeat", AdminScreen::FK_ONOFF },
{ "Advert interval (min)", "get advert.interval", "set advert.interval", AdminScreen::FK_NUMBER, 0, 240, 2 },
{ "Flood advert interval (h)", "get flood.advert.interval", "set flood.advert.interval", AdminScreen::FK_NUMBER, 0, 168, 1 },
{ "Max hops", "get flood.max", "set flood.max", AdminScreen::FK_NUMBER, 0, 64, 1 },
};
const AdminScreen::AdminField AdminScreen::ACTION_FIELDS[] = {
{ "Send advert", "advert", nullptr },

View File

@@ -52,6 +52,14 @@ class ChannelsView {
if (*end != 0) return false;
tmp[i] = (uint8_t)v;
}
// An all-zero 16-byte secret is the sentinel setChannelLocal() reads as
// "slot deleted" (see isAllZero() in MyMesh.cpp) -- saving one here would
// pass, then immediately trigger onChannelRemoved() on this very slot,
// silently discarding the channel and its bot/notif/favourite state.
// Reject it up front rather than let that self-delete happen invisibly.
bool all_zero = true;
for (int i = 0; i < 16 && all_zero; i++) if (tmp[i]) all_zero = false;
if (all_zero) return false;
memset(out, 0, 32);
memcpy(out, tmp, 16);
return true;
@@ -68,7 +76,7 @@ class ChannelsView {
if (_name[0] == '\0') { _task->showAlert("Name required", 1200); return; }
uint8_t secret[32];
if (!deriveSecret(secret)) {
_task->showAlert(_hex_mode ? "Need 32 hex chars" : "Secret required", 1400);
_task->showAlert(_hex_mode ? "Invalid secret" : "Secret required", 1400);
return;
}
ChannelDetails ch;

View File

@@ -45,8 +45,9 @@ static const char* const KB_T9_GROUPS[KB_PAGES][9] = {
// Additional (non-Latin) keyboard alphabets — NodePrefs::keyboard_alt_alphabet
// picks which one (if any) joins the Latin/symbols page cycle. Every alphabet
// here must fall inside Lemon's U+0020-04FF range (src/helpers/ui/LemonFont.h)
// since that's what actually draws these glyphs on-screen.
// here must fall inside the misc-fixed font's U+0020-04FF range
// (src/helpers/ui/MiscFixedFont.h) since that's what actually draws these
// glyphs on-screen.
//
// Unlike KB_CHARS (single ASCII byte per cell), these hold UTF-8 strings —
// Cyrillic is 2 bytes/codepoint — so cells are `const char*`, not `char`.
@@ -363,6 +364,12 @@ struct KeyboardWidget {
int t9_cell = -1;
int t9_cycle = 0;
uint32_t t9_last_ms = 0;
// Caps state the *first* tap of the current T9 cycle applied -- reused by every
// later cycling tap on the same cell, since one-shot Shift is consumed (see
// below) right after that first tap, before the user has settled on a letter.
// Without this, cycling to the 2nd/3rd/... candidate would always render
// lowercase regardless of Shift.
bool t9_caps = false;
int gridRows() const { return isT9() ? KB_T9_ROWS : KB_ROWS_CHAR; }
int gridCols() const { return isT9() ? KB_T9_COLS : KB_COLS_CHAR; }
@@ -765,7 +772,7 @@ struct KeyboardWidget {
if (t9_cycle < glen) kbUtf8CharAt(group, t9_cycle, one);
else { one[0] = (char)('1' + cell); one[1] = '\0'; }
char shown[5];
kbApplyCapsUtf8(one, caps, shown, sizeof(shown));
kbApplyCapsUtf8(one, t9_caps, shown, sizeof(shown));
// Replace the codepoint just before the cursor (what the previous
// tap inserted), preserving anything after the cursor too.
int old_n = kbUtf8LastCharBytes(buf, cursor_pos);
@@ -793,6 +800,7 @@ struct KeyboardWidget {
buf[len] = '\0';
t9_cell = cell;
t9_cycle = 0;
t9_caps = caps; // remember it for every later cycling tap on this cell
if (caps && !caps_lock) caps = false; // one-shot: only this first tap gets capitalised
}
}

View File

@@ -629,12 +629,22 @@ public:
// the outgoing path can attach a relay seq to that exact entry.
int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0) {
bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx);
return _history.addChannelMsg(ch_idx, text, viewing, timestamp);
int pos = _history.addChannelMsg(ch_idx, text, viewing, timestamp);
// Ring entries are numbered newest-first (0 == newest), so a new insert
// shifts every older message's index up by one. If the user has scrolled
// up to an older message (_hist_sel > 0), re-point the selection at that
// same message instead of silently relabeling a different one in under
// them. At _hist_sel <= 0 (already at newest, or -1 == compose button
// focused) there's nothing to preserve.
if (viewing && _hist_sel > 0) { _hist_sel++; _hist_scroll++; }
return pos;
}
void markChannelRelayed(uint32_t seq) { _history.markChannelRelayed(seq); }
void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text,
uint32_t sender_timestamp = 0) {
bool viewing = (_phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0);
_history.addDMMsg(pub_key, outgoing, text, sender_timestamp);
if (viewing && _dm_hist_sel > 0) { _dm_hist_sel++; _dm_hist_scroll++; } // see addChannelMsg
}
void markDmDelivered(uint32_t ack_crc) { _history.markDmDelivered(ack_crc); }
@@ -648,8 +658,14 @@ public:
int _room_login_head = 0, _room_login_count = 0;
bool isRoomLoggedIn(const uint8_t* pub_key) const {
for (int i = 0; i < _room_login_count; i++)
if (memcmp(_room_login_prefix[i], pub_key, 4) == 0) return true;
// Indices are relative to _room_login_head, same as forgetRoomLoggedIn() --
// direct 0.._room_login_count indexing only happens to work before the
// ring has wrapped once (head==0); after that it silently checks the wrong
// slots.
for (int i = 0; i < _room_login_count; i++) {
int pos = (_room_login_head + i) % ROOM_LOGIN_TABLE_SIZE;
if (memcmp(_room_login_prefix[pos], pub_key, 4) == 0) return true;
}
return false;
}

View File

@@ -621,7 +621,7 @@ class NearbyScreen : public UIScreen {
const Entry* e = selected();
ContactInfo ci;
if (e && e->contact_idx >= 0 && the_mesh.getContactByIdx(e->contact_idx, ci))
_task->openAdminFor(ci);
_task->openAdminFor(ci, false); // direct from Nodes -- Cancel should return here, not to a pick-list
break;
}
case ACT_SORT: break; // adjusted in-place via LEFT/RIGHT, not ENTER
@@ -950,7 +950,7 @@ public:
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);
_task->openAdminFor(ci, true); // via the picker -- Cancel should return here
}
// else: row isn't an eligible admin target -- ignore, stay on the picker.
return true;

View File

@@ -26,7 +26,6 @@ class SettingsScreen : public UIScreen {
CLOCK_SECONDS,
#endif
CLOCK_FORMAT,
FONT,
#if FEAT_DISPLAY_ROTATION_SETTING
ROTATION,
#endif
@@ -585,10 +584,6 @@ class SettingsScreen : public UIScreen {
display.print("Format");
display.setCursor(valCol(display), y);
display.print((p && p->clock_12h) ? "12h" : "24h");
} else if (item == FONT) {
display.print("Font");
display.setCursor(valCol(display), y);
display.print((p && p->use_lemon_font) ? "Lemon" : "Default");
#if FEAT_DISPLAY_ROTATION_SETTING
} else if (item == ROTATION) {
display.print("Rotation");
@@ -961,14 +956,6 @@ public:
_dirty = true;
return true;
}
if (_selected == FONT && p && (left || right || enter)) {
// Normalise (not ^=1): a stale value >1 from an older build with extra
// font modes would otherwise toggle 2<->3 and stay stuck on Lemon.
p->use_lemon_font = p->use_lemon_font ? 0 : 1;
_task->applyFont();
_dirty = true;
return true;
}
#if FEAT_DISPLAY_ROTATION_SETTING
if (_selected == ROTATION && p && (left || right || enter)) {
p->display_rotation = (p->display_rotation + (left ? 3 : 1)) & 3;

View File

@@ -1481,9 +1481,9 @@ void UITask::pickAdminTarget() {
((NearbyScreen*)nearby_screen)->startPickAdminTarget();
}
void UITask::openAdminFor(const ContactInfo& ci) {
void UITask::openAdminFor(const ContactInfo& ci, bool from_picker) {
setCurrScreen(admin_screen); // runs AdminScreen::onShow()'s reset first
((AdminScreen*)admin_screen)->startFor(ci);
((AdminScreen*)admin_screen)->startFor(ci, from_picker);
}
void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); }
void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
@@ -2624,7 +2624,8 @@ void UITask::onContactRemoved(const uint8_t* pub_key) {
// CONTRACT: every NodePrefs field that keys on a channel index is cleared here,
// so a channel re-added at a freed slot can't inherit the old one's settings.
// If you add such a field, add its cleanup below (and mark it in NodePrefs.h).
// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*.
// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*,
// ch_notif_override/ch_notif_muted, ch_fav_bitmask.
void UITask::onChannelRemoved(uint8_t channel_idx) {
if (!_node_prefs) return;
bool changed = false;
@@ -2644,6 +2645,15 @@ void UITask::onChannelRemoved(uint8_t channel_idx) {
_node_prefs->ch_notif_melody_2 &= ~mask;
changed = true;
}
if (_node_prefs->ch_notif_override & mask) {
_node_prefs->ch_notif_override &= ~mask;
_node_prefs->ch_notif_muted &= ~mask;
changed = true;
}
if (_node_prefs->ch_fav_bitmask & mask) {
_node_prefs->ch_fav_bitmask &= ~mask;
changed = true;
}
if (changed) the_mesh.savePrefs();
}

View File

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