Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/AdminScreen.h
T
JakubandClaude Opus 5.5 4f5357031f feat(l2): Arduino 3.x as the published env, limits sized for the PSRAM, stage 7 findings
- Wio_Tracker_L2_companion_solo_lvgl now builds on Arduino-ESP32 3.3.12
  (pioarduino); the 2.0.17 build stays as _solo_lvgl_arduino2 (not published)
- Per-board limits (defaults unchanged, L1 byte-for-byte the same):
  TRAIL_CAPACITY, WAYPOINT_CAPACITY, HIST_CH_MAX / HIST_DM_MAX, NEARBY_MAX;
  the L2 sets 4096 / 64 / 256 / 128 / 64 (in PSRAM with UiCore; internal
  heap 154 KB free after). ui-lvgl: 32 trail segments, contact list 256,
  rooms 32, 50 bubbles per conversation, pickers 128, WiFi scan 20
- Map trail drawing drops points under 2 px apart
- "Nearby" is "Nodes"; advert options "Zero hop" / "Flood"
- Roadmap stage 7: LVGL on other displays -- e-ink stays ui-new, an OLED
  spike after this roadmap, Cardputer as a colour candidate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:51:08 +02:00

433 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);
}
// 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++) {
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;
bool fav = contactctl::favourite(c);
if (fav != (pass == 0)) continue;
if (!rows) sectionTitle(body, "LOG IN TO");
char name[48];
snprintf(name, sizeof(name), "%s%s", fav ? UI_SYMBOL_STAR " " : "", c.name);
memcpy(adminview::s_pick[rows], c.id.pub_key, PUB_KEY_SIZE);
listRow(body, name, c.type == ADV_TYPE_ROOM ? "Room server" : "Repeater", onAdminPickRow, (void*)(uintptr_t)rows);
rows++;
}
}
if (!rows) {
lv_obj_t* t = label(body, "No repeaters or room servers yet. They show up here once their advert is heard.",
THEME_FONT_BODY, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
}
}
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 = lv_obj_create(body);
styleSurface(s_list, theme::BG);
lv_obj_set_width(s_list, LV_PCT(100));
lv_obj_set_flex_grow(s_list, 1);
lv_obj_set_flex_flow(s_list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(s_list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(s_list, LV_SCROLLBAR_MODE_ACTIVE);
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;
lv_obj_t* panel = navPopupPanel(prompt, false);
lv_obj_t* t = label(panel, "It's offline until it comes back.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
lv_obj_t* b = lv_button_create(panel);
lv_obj_set_size(b, LV_PCT(100), 40);
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(theme::FAIL), 0);
lv_obj_add_event_cb(b, onAdminValue, LV_EVENT_CLICKED, (void*)(uintptr_t)V_CONFIRM);
lv_obj_center(label(b, f.label, THEME_FONT_BODY, theme::TEXT));
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()) {
lv_obj_t* note = label(panel, "A mismatch cuts the node off.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP);
lv_obj_set_width(note, LV_PCT(100));
}
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);
}
lv_obj_t* l = label(box, text[0] ? text : "(empty)", THEME_FONT_BODY, theme::TEXT);
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
lv_obj_set_width(l, LV_PCT(100));
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();
}