Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/AdminScreen.h
T
JakubandClaude Opus 5.5 039485bf34 perf(l2): heavy screens build in pieces over several loop passes
The list fill becomes a small job queue (fill/then/fillStart, fillTick with a
25 ms budget per pass). Settings pages build section by section in
schemaRows, with the trailing extras (melodies, prune, quick messages) queued
after them so the order stays; Radio and Repeater build by group, Admin's
repeater pick like the contact list, Diag's Live tab by section. Rebuilds
that restore the scroll flush the queue first. Screens went from 220-360 ms
to 80-175 ms (build + first frame).

Diag's crash line read the core dump in flash every second (~44 ms each);
it is read once now. The perf run times every page and dismisses the USB
popup, which had covered all earlier measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:53:15 +02:00

420 lines
17 KiB
C++

#pragma once
// Remote admin of a repeater / room server (node detail > Admin, or the Home
// tile's list) -- ui-new's Tools > Admin. The session (login with the saved
// password, CLI round trips, timeouts, field table) is the UI Core's AdminSession; this draws it:
// password entry, tabs of fields, and a popup per field -- a switch, a
// -/+ stepper, a row of choices (SF / bandwidth / coding rate), or a text
// field with the keyboard (text, frequency, custom command); replies in a popup.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after ChannelScreen.h.
namespace adminview {
static uint8_t s_tab = 0;
static lv_obj_t* s_status = nullptr; // "Logging in..." / "Waiting for reply..."
static lv_obj_t* s_list = nullptr; // the tab's rows
static lv_obj_t* s_tabs = nullptr;
static lv_obj_t* s_pw = nullptr; // password field (NEED_PASSWORD)
static lv_obj_t* s_kb = nullptr;
static lv_obj_t* s_val_lbl = nullptr; // stepper value in the popup
static lv_obj_t* s_choice = nullptr; // SF / BW / CR choices in the popup
static const admin::Field* s_confirm = nullptr; // Reboot / OTA waiting for "yes"
static char s_btn_map[24][8];
static const char* s_map[28];
enum : uint8_t { V_MINUS, V_PLUS, V_SAVE, V_TOGGLE, V_CONFIRM, V_CLOSE };
static void clear() { s_status = s_list = s_tabs = s_pw = s_kb = s_val_lbl = s_choice = nullptr; }
// Choice buttons for a radio field: labels into s_btn_map, map into s_map
// (a "\n" after every `per_row`), selected index out.
static int radioChoices(const admin::Field& f, const AdminSession& S, int& per_row) {
int n = 0, sel = 0, m = 0;
if (f.kind == admin::K_RADIO_SF) {
per_row = 8;
for (int v = 5; v <= 12; v++, n++) { snprintf(s_btn_map[n], 8, "%d", v); if (v == S.radio_sf) sel = n; }
} else if (f.kind == admin::K_RADIO_CR) {
per_row = 4;
for (int v = 5; v <= 8; v++, n++) { snprintf(s_btn_map[n], 8, "4/%d", v); if (v == S.radio_cr) sel = n; }
} else {
per_row = 5;
sel = nearestBwIndex(S.radio_bw);
for (; n < LORA_BW_OPT_COUNT; n++) snprintf(s_btn_map[n], 8, "%g", (double)LORA_BW_OPTS[n]);
}
for (int i = 0; i < n; i++) {
if (i && i % per_row == 0) s_map[m++] = "\n";
s_map[m++] = s_btn_map[i];
}
s_map[m] = "";
return sel;
}
} // namespace adminview
static void onAdminTab(lv_event_t* e) {
s_ui->adminTab((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onAdminRow(lv_event_t* e) { s_ui->adminRow((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onAdminValue(lv_event_t* e) { s_ui->adminValue((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onAdminChoice(lv_event_t* e) {
s_ui->adminChoice((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onAdminPwKb(lv_event_t* e) { s_ui->adminLogin(lv_event_get_code(e) == LV_EVENT_READY); }
static void onAdminTextKb(lv_event_t* e) { s_ui->adminTextDone(lv_event_get_code(e) == LV_EVENT_READY); }
static void onAdminCancelWait(lv_event_t* e) { (void)e; s_ui->adminCancelWait(); }
static void onOpenAdminPick(lv_event_t* e) { (void)e; s_ui->showAdminPick(); }
static void onAdminPickRow(lv_event_t* e) { s_ui->adminPick((int)(uintptr_t)lv_event_get_user_data(e)); }
namespace adminview {
static const int PICK_MAX = 128;
static uint8_t (*s_pick)[PUB_KEY_SIZE] = psramBuf<uint8_t[PUB_KEY_SIZE]>(PICK_MAX);
static uint16_t* s_pick_raw = psramBuf<uint16_t>(PICK_MAX); // their raw table index, for the row
}
// Home > Admin: every repeater and room server, favourites first (ui-new's
// Tools > Admin picker); the session is the same as from node detail.
void UITask::showAdminPick() {
_screen = SCR_ADMIN_PICK;
buildAdminPick();
}
void UITask::buildAdminPick() {
lv_obj_t* body = newScreen("Admin", true);
int rows = 0;
for (int pass = 0; pass < 2; pass++) { // favourites first
for (int i = 0; i < the_mesh.getNumContacts() && rows < adminview::PICK_MAX; i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue;
if (c.type != ADV_TYPE_REPEATER && c.type != ADV_TYPE_ROOM) continue;
if (contactctl::favourite(c) != (pass == 0)) continue;
memcpy(adminview::s_pick[rows], c.id.pub_key, PUB_KEY_SIZE);
adminview::s_pick_raw[rows] = (uint16_t)(MAX_ANON_CONTACTS + i);
rows++;
}
}
if (rows) sectionTitle(body, "LOG IN TO");
fillStart(rows, 8, &UITask::adminPickRow); // the rest as the loop goes (fill)
if (!rows) {
noteLabel(body, "No repeaters or room servers yet. They show up here once their advert is heard.",
THEME_FONT_BODY, theme::TEXT_MUTED);
}
}
void UITask::adminPickRow(int i) {
ContactInfo c;
if (!the_mesh.getContactByIdx(adminview::s_pick_raw[i], c) || memcmp(c.id.pub_key, adminview::s_pick[i], PUB_KEY_SIZE) != 0) {
listRow(_body, "?", NULL, onAdminPickRow, (void*)(uintptr_t)i); // deleted meanwhile
return;
}
char name[48];
snprintf(name, sizeof(name), "%s%s", contactctl::favourite(c) ? UI_SYMBOL_STAR " " : "", c.name);
listRow(_body, name, c.type == ADV_TYPE_ROOM ? "Room server" : "Repeater", onAdminPickRow, (void*)(uintptr_t)i);
}
void UITask::adminPick(int row) {
if (row < 0 || row >= adminview::PICK_MAX) return;
openAdmin(adminview::s_pick[row]);
}
// Node detail > Admin.
void UITask::openAdmin(const uint8_t* pub_key) {
ContactInfo ci;
if (!MessageHistory::contactByPrefix(pub_key, ci)) return;
_core->admin.start(ci);
_admin_from_pick = _screen == SCR_ADMIN_PICK;
adminview::s_tab = admin::TAB_SYSTEM;
_screen = SCR_ADMIN;
buildAdmin();
}
void UITask::adminLeave() {
_core->admin.end();
navClosePopup();
if (_admin_from_pick) { showAdminPick(); return; }
_screen = SCR_NODE;
buildNode();
}
void UITask::buildAdmin() {
using namespace adminview;
AdminSession& S = _core->admin;
lv_obj_t* body = newScreen(S.target().name, true);
lv_obj_set_style_pad_row(body, 4, 0);
clear();
AdminSession::State st = S.state();
if (st == AdminSession::NEED_PASSWORD) {
label(body, "Admin password for this node", THEME_FONT_SMALL, theme::TEXT_MUTED);
s_pw = wifiField(body, "Password", true);
lv_obj_remove_event_cb(s_pw, onWifiField);
lv_textarea_set_max_length(s_pw, 15);
lv_obj_add_state(s_pw, LV_STATE_FOCUSED);
s_kb = kb::create(screen(), _prefs);
lv_obj_set_size(s_kb, LV_PCT(100), 124);
lv_obj_align(s_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_keyboard_set_textarea(s_kb, s_pw);
lv_obj_add_event_cb(s_kb, onAdminPwKb, LV_EVENT_READY, NULL);
lv_obj_add_event_cb(s_kb, onAdminPwKb, LV_EVENT_CANCEL, NULL);
return;
}
if (st == AdminSession::LOGGING_IN || st == AdminSession::IDLE) {
s_status = label(body, st == AdminSession::LOGGING_IN ? LV_SYMBOL_REFRESH " Logging in..." : "", THEME_FONT_BODY, theme::TEXT);
return;
}
static const char* TABS[admin::TAB_COUNT + 1];
for (int i = 0; i < admin::TAB_COUNT; i++) TABS[i] = admin::TAB_LABELS[i];
TABS[admin::TAB_COUNT] = "";
s_tabs = segmented(body, TABS, s_tab, LV_PCT(100), 34, theme::SURFACE);
lv_obj_add_event_cb(s_tabs, onAdminTab, LV_EVENT_VALUE_CHANGED, NULL);
s_status = label(body, "", THEME_FONT_SMALL, theme::ACCENT);
lv_obj_add_flag(s_status, LV_OBJ_FLAG_CLICKABLE); // tap: stop waiting
lv_obj_add_event_cb(s_status, onAdminCancelWait, LV_EVENT_CLICKED, NULL);
lv_obj_add_flag(s_status, LV_OBJ_FLAG_HIDDEN);
s_list = scrollList(body);
for (int i = 0; i < admin::rowCount(s_tab); i++) {
const admin::Field& f = admin::field(s_tab, i);
const char* hint = f.isCustom() ? "Any CLI command" : f.isAction() ? nullptr
: f.isSetOnly() ? "Set a new one" : "Tap to read and change";
lv_obj_t* row = listRow(s_list, f.label, hint, onAdminRow, (void*)(uintptr_t)i);
if (f.isAction() && admin::confirmPrompt(f))
lv_obj_align(label(row, LV_SYMBOL_WARNING, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
}
refreshAdminStatus();
}
void UITask::refreshAdminStatus() {
using namespace adminview;
if (!s_status || !s_list) return;
AdminSession& S = _core->admin;
bool waiting = S.state() == AdminSession::WAITING;
if (waiting) {
lv_label_set_text(s_status, S.fetching() ? LV_SYMBOL_REFRESH " Reading... (tap to stop)"
: LV_SYMBOL_REFRESH " Waiting for reply... (tap to stop)");
lv_obj_remove_flag(s_status, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(s_status, LV_OBJ_FLAG_HIDDEN);
}
if (waiting) lv_obj_add_state(s_list, LV_STATE_DISABLED); else lv_obj_remove_state(s_list, LV_STATE_DISABLED);
}
void UITask::adminTab(int tab) {
if (tab < 0 || tab >= admin::TAB_COUNT) return;
adminview::s_tab = (uint8_t)tab;
buildAdmin();
}
void UITask::adminLogin(bool ok) {
using namespace adminview;
if (!ok) { adminLeave(); return; }
if (!s_pw) return;
_core->admin.login(lv_textarea_get_text(s_pw)); // a send failure comes back as LOGIN_FAILED
buildAdmin();
}
void UITask::adminCancelWait() {
_core->admin.cancelWait();
refreshAdminStatus();
}
void UITask::adminRow(int row) {
using namespace adminview;
AdminSession& S = _core->admin;
if (S.state() != AdminSession::READY || row < 0 || row >= admin::rowCount(s_tab)) return;
const admin::Field& f = admin::field(s_tab, row);
const char* prompt = admin::confirmPrompt(f);
if (prompt) { // takes the node out of service for a while: ask first
s_confirm = &f;
confirmBody(navPopupPanel(prompt, false), "It's offline until it comes back.", f.label, onAdminValue, V_CONFIRM);
return;
}
S.run(f);
refreshAdminStatus();
}
// Popup buttons: stepper, save, confirm.
void UITask::adminValue(uint8_t act) {
using namespace adminview;
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
switch (act) {
case V_MINUS: case V_PLUS:
S.stepValue(act == V_PLUS ? 1 : -1);
if (s_val_lbl) lv_label_set_text_fmt(s_val_lbl, "%d", (int)S.value);
break;
case V_TOGGLE:
S.stepValue(1);
break;
case V_SAVE:
navClosePopup();
if (f) S.sendValue();
refreshAdminStatus();
break;
case V_CONFIRM:
navClosePopup();
if (s_confirm) S.run(*s_confirm);
s_confirm = nullptr;
refreshAdminStatus();
break;
default:
navClosePopup();
break;
}
}
void UITask::adminChoice(int idx) {
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
if (!f || idx < 0) return;
if (f->kind == admin::K_RADIO_SF) S.radio_sf = (uint8_t)(5 + idx);
else if (f->kind == admin::K_RADIO_CR) S.radio_cr = (uint8_t)(5 + idx);
else if (f->kind == admin::K_RADIO_BW && idx < LORA_BW_OPT_COUNT) S.radio_bw = LORA_BW_OPTS[idx];
}
static lv_obj_t* adminPopupButton(lv_obj_t* parent, const char* text, uint8_t act, bool accent) {
lv_obj_t* b = lv_button_create(parent);
lv_obj_set_height(b, 40);
lv_obj_set_flex_grow(b, 1);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(accent ? theme::ACCENT_DIM : theme::SURFACE), 0);
lv_obj_add_event_cb(b, onAdminValue, LV_EVENT_CLICKED, (void*)(uintptr_t)act);
lv_obj_center(label(b, text, act == adminview::V_MINUS || act == adminview::V_PLUS ? THEME_FONT_LARGE : THEME_FONT_BODY, theme::TEXT));
return b;
}
static lv_obj_t* adminRowBox(lv_obj_t* parent) {
lv_obj_t* r = lv_obj_create(parent);
styleSurface(r, theme::BG);
lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(r, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(r, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(r, theme::GAP, 0);
return r;
}
// A typed value arrived: its editor.
void UITask::adminValuePopup() {
using namespace adminview;
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
if (!f) return;
if (f->kind == admin::K_RADIO_FREQ) { // typed, like text
char fs[16];
snprintf(fs, sizeof(fs), "%.3f", S.radio_freq);
adminTextPopup(fs, true);
return;
}
lv_obj_t* panel = navPopupPanel(f->label, false);
s_val_lbl = s_choice = nullptr;
if (f->kind == admin::K_ONOFF) {
lv_obj_t* r = adminRowBox(panel);
label(r, "Off", THEME_FONT_BODY, theme::TEXT_MUTED);
lv_obj_t* sw = lv_switch_create(r);
lv_obj_set_size(sw, 56, 28);
if (S.value != 0) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onAdminValue, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)V_TOGGLE);
label(r, "On", THEME_FONT_BODY, theme::TEXT_MUTED);
} else if (f->kind == admin::K_NUMBER) {
lv_obj_t* r = adminRowBox(panel);
adminPopupButton(r, LV_SYMBOL_MINUS, V_MINUS, false);
s_val_lbl = label(r, "", THEME_FONT_LARGE, theme::TEXT);
lv_obj_set_width(s_val_lbl, 80);
lv_obj_set_style_text_align(s_val_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text_fmt(s_val_lbl, "%d", (int)S.value);
adminPopupButton(r, LV_SYMBOL_PLUS, V_PLUS, false);
} else { // SF / bandwidth / coding rate
int per_row = 1;
int sel = radioChoices(*f, S, per_row);
int rows = f->kind == admin::K_RADIO_BW ? 2 : 1;
s_choice = segmented(panel, s_map, sel, LV_PCT(100), rows * 34 + (rows - 1) * 4, theme::SURFACE);
lv_obj_add_event_cb(s_choice, onAdminChoice, LV_EVENT_VALUE_CHANGED, NULL);
}
if (f->isRadio()) {
noteLabel(panel, "A mismatch cuts the node off.");
}
lv_obj_t* r = adminRowBox(panel);
adminPopupButton(r, LV_SYMBOL_OK " Save", V_SAVE, true);
}
// Text to edit (a fetched value, a new password, a custom command, the frequency).
void UITask::adminTextPopup(const char* text, bool digits) {
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
lv_obj_t* panel = navPopupPanel(f ? f->label : "Command", false);
lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard
_nav_ta = textField(panel);
lv_textarea_set_max_length(_nav_ta, 150);
if (digits) lv_textarea_set_accepted_chars(_nav_ta, "0123456789.");
if (f && f->isCustom()) lv_textarea_set_placeholder_text(_nav_ta, "e.g. get advert.interval");
lv_textarea_set_text(_nav_ta, text);
lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED);
_nav_kb = kb::create(_nav_overlay, _prefs);
lv_obj_set_size(_nav_kb, LV_PCT(100), 124);
lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_keyboard_set_textarea(_nav_kb, _nav_ta);
if (digits) kb::apply(_nav_kb, kb::L_SYM);
lv_obj_add_event_cb(_nav_kb, onAdminTextKb, LV_EVENT_READY, NULL);
lv_obj_add_event_cb(_nav_kb, onAdminTextKb, LV_EVENT_CANCEL, NULL);
}
void UITask::adminTextDone(bool ok) {
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
if (ok && _nav_ta && f) {
const char* t = lv_textarea_get_text(_nav_ta);
if (f->kind == admin::K_RADIO_FREQ) {
float v = strtof(t, nullptr);
if (v < f->min_val || v > f->max_val) { showToast("Frequency out of range"); return; }
S.radio_freq = v;
S.sendValue();
} else {
S.sendSet(t);
}
}
navClosePopup();
refreshAdminStatus();
}
void UITask::adminReplyPopup(const char* text) {
bool long_text = strlen(text) > 90; // e.g. "neighbors": full height, scrolls
lv_obj_t* panel = navPopupPanel("Reply", long_text);
lv_obj_t* box = panel;
if (long_text) {
box = lv_obj_create(panel);
styleSurface(box, theme::BG);
lv_obj_set_width(box, LV_PCT(100));
lv_obj_set_flex_grow(box, 1);
lv_obj_set_scrollbar_mode(box, LV_SCROLLBAR_MODE_ACTIVE);
}
noteLabel(box, text[0] ? text : "(empty)", THEME_FONT_BODY, theme::TEXT);
lv_obj_t* r = adminRowBox(panel);
adminPopupButton(r, "OK", adminview::V_CLOSE, false);
}
// From loop() on SCR_ADMIN: the session's results.
void UITask::adminPoll() {
AdminSession& S = _core->admin;
const admin::Field* f = S.currentField();
switch (S.take()) {
case AdminSession::NONE: return;
case AdminSession::LOGGED_IN: showToast("Logged in as admin", 1500); buildAdmin(); break;
case AdminSession::NOT_ADMIN: showToast("Not an admin on this node"); adminLeave(); break;
case AdminSession::LOGIN_FAILED: showToast("Login failed - wrong password?", 3000); adminLeave(); break;
case AdminSession::LOGIN_TIMEOUT: showToast("No answer to the login - out of range?"); adminLeave(); break;
case AdminSession::SEND_FAILED: showToast("Send failed"); break;
case AdminSession::FETCH_FAILED: showToast("Couldn't read the value; try again"); break;
case AdminSession::TIMEOUT: showToast("No reply (timeout)"); break;
case AdminSession::REPLY: adminReplyPopup(S.reply()); break;
case AdminSession::VALUE_READY: adminValuePopup(); break;
case AdminSession::TEXT_READY:
if (f && f->get_cmd && !S.text()[0]) showToast("Couldn't read the value; enter a new one");
adminTextPopup(S.text(), false);
break;
}
refreshAdminStatus();
}