From 6f5fc07cd09f78f6110821b92b5679d2388e0644 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:21:38 +0200 Subject: [PATCH] feat(ui-core,ui-lvgl): user presets and battery level in the Core, L2 my presets, scopes, battery display, clock seconds Co-Authored-By: Claude Opus 5.5 --- docs/development/l2-hardware-checklist.md | 9 + docs/development/ui-core.md | 2 +- examples/companion_radio/ui-core/Battery.h | 50 ++++ .../companion_radio/ui-core/RadioControl.h | 36 ++- .../companion_radio/ui-core/SettingsSchema.h | 6 + .../companion_radio/ui-lvgl/DeviceScreen.h | 2 +- .../companion_radio/ui-lvgl/RadioExtras.h | 243 ++++++++++++++++++ .../companion_radio/ui-lvgl/RadioScreen.h | 15 +- examples/companion_radio/ui-lvgl/UITask.cpp | 37 ++- examples/companion_radio/ui-lvgl/UITask.h | 13 +- .../ui-new/RadioPresetPicker.h | 23 +- examples/companion_radio/ui-new/UITask.cpp | 38 +-- 12 files changed, 405 insertions(+), 69 deletions(-) create mode 100644 examples/companion_radio/ui-core/Battery.h create mode 100644 examples/companion_radio/ui-lvgl/RadioExtras.h diff --git a/docs/development/l2-hardware-checklist.md b/docs/development/l2-hardware-checklist.md index 4e26e996..80501e17 100644 --- a/docs/development/l2-hardware-checklist.md +++ b/docs/development/l2-hardware-checklist.md @@ -66,6 +66,15 @@ Confirmed: green dot inside the ring, waypoints much better, download works, set - [ ] Room server login from the phone app / L1 still works while this exists (only admin logins go to the new session) - [ ] L1 (ui-new): Tools > Admin works as before (login, saved password, typed values, confirm reboot); it now also skips the login for the node you just used +## New: My presets, scopes, battery display, clock seconds (2026-09-25) +- [ ] Settings > Radio > MY PRESETS > Save current settings: name popup, Enter → "Preset saved", the row shows freq / SF / BW / CR with a tick while in use, and it is in the Preset dropdown +- [ ] Tap a saved preset → Use (radio switches) / Delete (confirm, red) → "Preset deleted"; a 5th name replaces the oldest (hint says so when all 4 are used); presets survive a reboot +- [ ] L1: Settings › Radio › Preset lists the presets saved on L2 and vice versa (same slots) +- [ ] Settings > Radio > SCOPE > Scopes: "* (no scope)" is the default; + Add → name → listed; tap → Default / Rename / Delete; the Radio screen row shows the default +- [ ] Deleting the default scope makes * the default; a channel set to the deleted scope goes back to none (channel options > Scope); the app shows the same default scope after a sync +- [ ] Settings > Display & power > Battery display: Icon / Percent / Voltage change the status bar at once; percent follows the L1 curve (empties at the Battery shutdown voltage) +- [ ] Settings > Display & power > TIME > Clock seconds off: Home and lock screen clocks show HH:MM; on: HH:MM:SS + ## New: Diagnostics, auto-advert, compass (2026-09-25) - [ ] Settings > SYSTEM > Diagnostics: tabs Live / System / Font; Live counters (uptime, rx/tx, heap, noise floor, RSSI/SNR, queue, errors) tick every second - [ ] Live > Reset → confirm popup → "Counters reset", rx/tx and forwarded go back to 0 diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index 09b45b43..57bc9cb0 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -110,7 +110,7 @@ call the same ones, so e.g. "marking a channel read" has one implementation. ### 5. Declarative settings schema -`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h` (`pinContact` / `pinChannel` move an entry that is already on another slot; the slot's name is `contactctl::favName`). The schema's display page also carries the lock screen (`auto_lock`) and the 12-hour clock, which ui-lvgl implements (a slide-to-unlock card after the screen turns off). Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. Room servers: `ui-core/RoomSessions.h` (`UiCore::rooms`) keeps who is logged in this session, the one login in flight (with a timeout) and the saved passwords (saved on success, forgotten on failure); `open(contact)` says whether to go straight in, wait for a background login or ask for a password, and outcomes come out through `take()`. Per-contact helpers are `ui-core/ContactControl.h` (`contactctl::`: DM notification / melody overrides, favourite flag, the `@[name] ` reply prefix, splitting a room post into author and text, naming a message's path hops). ui-new's Messages and ui-lvgl's Chats / thread use both. Diagnostics rows are `ui-core/Diagnostics.h` (`diag::`: live counters, system card, font card, counter reset), shown by ui-new's Tools > Diagnostics and ui-lvgl's Settings > Diagnostics. Auto-advert is a schema row (`advert_auto_interval_sec`, sent by MyMesh); ui-new keeps its Tools > Auto-Advert screen over the same pref. The compass (ui-lvgl Home > Compass, ui-new Tools > Compass) reads `UiCore::course`. +`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, lock screen, battery display, battery shutdown, GPS power saving, time zone, 12-hour clock, clock seconds) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h` (`pinContact` / `pinChannel` move an entry that is already on another slot; the slot's name is `contactctl::favName`). The schema's display page also carries the lock screen (`auto_lock`) and the 12-hour clock, which ui-lvgl implements (a slide-to-unlock card after the screen turns off). Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. Room servers: `ui-core/RoomSessions.h` (`UiCore::rooms`) keeps who is logged in this session, the one login in flight (with a timeout) and the saved passwords (saved on success, forgotten on failure); `open(contact)` says whether to go straight in, wait for a background login or ask for a password, and outcomes come out through `take()`. Per-contact helpers are `ui-core/ContactControl.h` (`contactctl::`: DM notification / melody overrides, favourite flag, the `@[name] ` reply prefix, splitting a room post into author and text, naming a message's path hops). ui-new's Messages and ui-lvgl's Chats / thread use both. Diagnostics rows are `ui-core/Diagnostics.h` (`diag::`: live counters, system card, font card, counter reset), shown by ui-new's Tools > Diagnostics and ui-lvgl's Settings > Diagnostics. Auto-advert is a schema row (`advert_auto_interval_sec`, sent by MyMesh); ui-new keeps its Tools > Auto-Advert screen over the same pref. The compass (ui-lvgl Home > Compass, ui-new Tools > Compass) reads `UiCore::course`. Saving / deleting the user's radio presets is `radioctl::saveUserPreset` / `deleteUserPreset` (ui-new's preset picker and ui-lvgl's Radio > My presets); the scope list is edited through MyMesh (`addScope` / `renameScope` / `removeScope` / `setDefaultScope`), which ui-lvgl's Radio > Scopes and ui-new's Settings > Radio > Scope call directly. The battery percentage (LiPo curve, empty at the shutdown voltage) and the display mode are `ui-core/Battery.h`, used by both status bars; the schema carries Battery display and Clock seconds. Replaces the per-item code in `SettingsScreen`: diff --git a/examples/companion_radio/ui-core/Battery.h b/examples/companion_radio/ui-core/Battery.h new file mode 100644 index 00000000..35fa17f6 --- /dev/null +++ b/examples/companion_radio/ui-core/Battery.h @@ -0,0 +1,50 @@ +#pragma once +// Battery level shared by the frontends' status bars (and ui-new's dashboard +// Batt% field), so both report the same number for the same voltage, plus the +// Settings > Battery display choice (NodePrefs::batt_display_mode). + +#include + +#ifndef BATT_MIN_MILLIVOLTS + #define BATT_MIN_MILLIVOLTS 3200 +#endif + +namespace battery { + +enum Mode : uint8_t { ICON, PERCENT, VOLTAGE, MODE_COUNT }; // NodePrefs::batt_display_mode + +// LiPo discharge curve: voltage (mV) -> raw capacity (%). low_mv (typically +// NodePrefs.low_batt_mv, the user-configurable auto-shutdown threshold in +// Settings) is rescaled to 0% so the bar empties at the cutoff the user +// actually cares about. +inline int percent(int mv, int low_mv) { + static const struct { uint16_t mv; uint8_t pct; } CURVE[] = { + {3200, 0}, {3300, 3}, {3400, 8}, {3500, 15}, + {3600, 25}, {3650, 33}, {3700, 45}, {3750, 58}, + {3800, 68}, {3900, 77}, {4000, 86}, {4100, 93}, {4170, 100} + }; + static const int CURVE_LEN = sizeof(CURVE) / sizeof(CURVE[0]); + auto curveAt = [&](int v) -> int { + if (v <= (int)CURVE[0].mv) return CURVE[0].pct; + if (v >= (int)CURVE[CURVE_LEN-1].mv) return CURVE[CURVE_LEN-1].pct; + for (int i = 1; i < CURVE_LEN; i++) { + if (v <= (int)CURVE[i].mv) { + int span_mv = CURVE[i].mv - CURVE[i-1].mv; + int span_pct = CURVE[i].pct - CURVE[i-1].pct; + return CURVE[i-1].pct + (v - (int)CURVE[i-1].mv) * span_pct / span_mv; + } + } + return 100; + }; + if (low_mv <= 0) low_mv = BATT_MIN_MILLIVOLTS; + int raw_pct = curveAt(mv); + int low_pct = curveAt(low_mv); + int pct = (low_pct >= 100) ? 0 : (raw_pct - low_pct) * 100 / (100 - low_pct); + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + return pct; +} + +inline Mode mode(uint8_t pref) { return pref < MODE_COUNT ? (Mode)pref : ICON; } + +} // namespace battery diff --git a/examples/companion_radio/ui-core/RadioControl.h b/examples/companion_radio/ui-core/RadioControl.h index 8d1d9e01..4a406e24 100644 --- a/examples/companion_radio/ui-core/RadioControl.h +++ b/examples/companion_radio/ui-core/RadioControl.h @@ -1,8 +1,9 @@ #pragma once // Radio settings actions shared by the frontends: push the companion's // NodePrefs radio fields (freq / bw / sf / cr, TX power, Adaptive Power -// Control) to the radio, and the preset list (../RadioPresets.h built-ins, -// then the user's saved presets in NodePrefs) the Settings > Radio pickers show. +// Control) to the radio, the preset list (../RadioPresets.h built-ins, then +// the user's saved presets in NodePrefs) the Settings > Radio pickers show, +// and saving / deleting those user presets. // ui-new's Settings / Repeater screens and ui-lvgl's Radio screen both go // through here. @@ -67,4 +68,35 @@ static bool choosePreset(NodePrefs* p, int idx) { return true; } +// ── The user's saved presets (NodePrefs::user_radio_presets) ──────────────── + +// Whether saving one more under a new name would replace the oldest slot. +static bool userPresetsFull(const NodePrefs* p) { + for (int i = 0; p && i < NodePrefs::USER_RADIO_PRESET_MAX; i++) if (!p->user_radio_presets[i].name[0]) return false; + return p != nullptr; +} + +// Saves freq / bw / sf / cr under `name`: over a slot with the same name if +// there is one, else the first empty slot, else slot 0 (the oldest). The +// caller saves the prefs. False for an empty name. +static bool saveUserPreset(NodePrefs* p, const char* name, float freq, float bw, uint8_t sf, uint8_t cr) { + if (!p || !name || !name[0]) return false; + int slot = -1; + for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) + if (strcmp(p->user_radio_presets[i].name, name) == 0) { slot = i; break; } + if (slot < 0) + for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) + if (!p->user_radio_presets[i].name[0]) { slot = i; break; } + if (slot < 0) slot = 0; + NodePrefs::UserRadioPreset& u = p->user_radio_presets[slot]; + strncpy(u.name, name, sizeof(u.name) - 1); + u.name[sizeof(u.name) - 1] = '\0'; + u.freq = freq; u.bw = bw; u.sf = sf; u.cr = cr; + return true; +} + +static void deleteUserPreset(NodePrefs* p, int slot) { + if (p && slot >= 0 && slot < NodePrefs::USER_RADIO_PRESET_MAX) p->user_radio_presets[slot].name[0] = '\0'; +} + } // namespace radioctl diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index 8e607a33..0808c3ac 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -119,6 +119,10 @@ static const int32_t INVERTED[] = { 1, 0 }; // switch over a field stored as " static void optResend(uint8_t v, char* b, int n, const NodePrefs&) { if (v == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%u more", (unsigned)v); } +static void optBattDisplay(uint8_t v, char* b, int n, const NodePrefs&) { + static const char* L[] = { "Icon", "Percent", "Voltage" }; + snprintf(b, n, "%s", L[v < 3 ? v : 0]); +} static void optExpiry(uint8_t v, char* b, int n, const NodePrefs&) { snprintf(b, n, "%s", NodePrefs::contactExpiryLabel(v)); } @@ -171,12 +175,14 @@ static const Setting ALL[] = { MAP("Wake on message", "Turn the screen on for new messages", SEC_DISPLAY, msg_wake_screen_off, INVERTED, nullptr, nullptr), SW("Lock screen", "Slide to unlock after the screen turns off", SEC_DISPLAY, auto_lock, nullptr), + IDX("Battery display", "In the status bar", SEC_DISPLAY, batt_display_mode, 3, optBattDisplay, nullptr), MAP("Battery shutdown", "Power off below this voltage", SEC_POWER, low_batt_mv, LOW_BATT, optLowBatt, nullptr), MAP("GPS power saving", "Sleep between fixes", SEC_POWER, gps_interval, GPS_DUTY, optGpsDuty, applyGpsDuty), MAP("Time zone", "For the clock and the alarm", SEC_TIME, tz_offset_hours, TZ, optTz, nullptr), SW("12-hour clock", "AM / PM instead of 24 h", SEC_TIME, clock_12h, nullptr), + MAP("Clock seconds", "On the home and lock screen clock", SEC_TIME, clock_hide_seconds, INVERTED, nullptr, nullptr), IDX("Resend direct messages", "Extra tries without a delivery tick", SEC_MESSAGES, dm_resend_count, 6, optResend, nullptr), diff --git a/examples/companion_radio/ui-lvgl/DeviceScreen.h b/examples/companion_radio/ui-lvgl/DeviceScreen.h index 803a352f..4feca01c 100644 --- a/examples/companion_radio/ui-lvgl/DeviceScreen.h +++ b/examples/companion_radio/ui-lvgl/DeviceScreen.h @@ -203,7 +203,7 @@ void UITask::refreshLock() { struct tm ti; if (localTime(_prefs, ti)) { char clk[12], date[48]; - fmtClock(clk, sizeof(clk), ti, _prefs, false); + fmtClock(clk, sizeof(clk), ti, _prefs, false, true); fmtDate(date, sizeof(date), ti, _prefs); lv_label_set_text(s_lock_clock, clk); lv_label_set_text(s_lock_date, date); diff --git a/examples/companion_radio/ui-lvgl/RadioExtras.h b/examples/companion_radio/ui-lvgl/RadioExtras.h new file mode 100644 index 00000000..75fbbe66 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/RadioExtras.h @@ -0,0 +1,243 @@ +#pragma once +// Settings > Radio, the parts beyond the radio parameters -- ui-new's +// Settings > Radio preset picker ("+ Save current..." / "- Delete preset...") +// and Settings > Radio > Scope list: +// - My presets: the user's saved presets (NodePrefs::user_radio_presets, 4 +// slots; saving is ui-core/RadioControl.h). Tap one to use or delete it; +// "Save current settings" names the current parameters as a new one. They +// also appear in the Preset dropdown above. +// - Scopes: the shared named-scope list (ScopeList.h, edited through +// MyMesh). "*" is no scope; the default one goes on direct messages and +// relaying, channels pick their own in their options. +// +// Single-TU fragment: included at the end of ui-lvgl/UITask.cpp. + +namespace radiox { + +enum : uint8_t { N_PRESET, N_SCOPE_ADD, N_SCOPE_RENAME }; // what the name popup names +enum : uint8_t { P_USE, P_DELETE, P_DELETE_GO }; // preset popup actions +enum : uint8_t { S_DEFAULT, S_RENAME, S_DELETE, S_DELETE_GO }; // scope popup actions + +static uint8_t s_naming = N_PRESET; +static int s_slot = -1; // user preset slot the popup is about +static int s_scope = -1; // scope list index the popup is about + +static lv_obj_t* redButton(lv_obj_t* panel, const char* text, lv_event_cb_t cb, uint8_t act) { + 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, cb, LV_EVENT_CLICKED, (void*)(uintptr_t)act); + lv_obj_center(label(b, text, THEME_FONT_BODY, theme::TEXT)); + return b; +} + +static lv_obj_t* note(lv_obj_t* parent, const char* text) { + lv_obj_t* t = label(parent, text, THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); + lv_obj_set_width(t, LV_PCT(100)); + return t; +} + +} // namespace radiox + +static void onPresetRow(lv_event_t* e) { s_ui->presetMenu((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onPresetAction(lv_event_t* e) { s_ui->presetAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } +static void onPresetSave(lv_event_t* e) { (void)e; s_ui->radioNamePopup(radiox::N_PRESET); } +static void onRadioNameKb(lv_event_t* e) { s_ui->radioNameDone(lv_event_get_code(e) == LV_EVENT_READY); } +static void onOpenScopes(lv_event_t* e) { (void)e; s_ui->showScopes(); } +static void onScopeRow(lv_event_t* e) { s_ui->scopeMenu((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onScopeAdd(lv_event_t* e) { (void)e; s_ui->radioNamePopup(radiox::N_SCOPE_ADD); } +static void onScopeAction(lv_event_t* e) { s_ui->scopeAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } + +// The Radio screen's lower sections, under the parameters. +void UITask::buildRadioExtras(lv_obj_t* body) { + NodePrefs* p = _prefs; + sectionTitle(body, "MY PRESETS"); + for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) { + const NodePrefs::UserRadioPreset& u = p->user_radio_presets[i]; + if (!u.name[0]) continue; + char sub[48]; + snprintf(sub, sizeof(sub), "%.3f MHz SF%u BW%g 4/%u", u.freq, (unsigned)u.sf, (double)u.bw, (unsigned)u.cr); + lv_obj_t* row = listRow(body, u.name, sub, onPresetRow, (void*)(uintptr_t)i); + if (radioParamsMatchPreset(p->freq, p->bw, p->sf, p->cr, u.freq, u.bw, u.sf, u.cr)) + lv_obj_align(label(row, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT), LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + } + listRow(body, LV_SYMBOL_PLUS " Save current settings", + radioctl::userPresetsFull(p) ? "All 4 used - a new name replaces the oldest" : "As a preset of your own", + onPresetSave, NULL); + + sectionTitle(body, "SCOPE"); + const ScopeList& sl = the_mesh.scopeList(); + char sub[48]; + if (sl.count == 0) snprintf(sub, sizeof(sub), "None set up - messages go everywhere"); + else snprintf(sub, sizeof(sub), "Default: %s (%u set up)", sl.default_idx ? sl.name(sl.default_idx) : "no scope", + (unsigned)sl.count); + listRow(body, LV_SYMBOL_LIST " Scopes", sub, onOpenScopes, NULL); +} + +// ── My presets ──────────────────────────────────────────────────────────────── + +void UITask::presetMenu(int slot) { + if (!_prefs || slot < 0 || slot >= NodePrefs::USER_RADIO_PRESET_MAX) return; + const NodePrefs::UserRadioPreset& u = _prefs->user_radio_presets[slot]; + if (!u.name[0]) return; + radiox::s_slot = slot; + lv_obj_t* panel = navPopupPanel(u.name, false); + char info[64]; + snprintf(info, sizeof(info), "%.3f MHz, SF%u, BW %g kHz, CR 4/%u", u.freq, (unsigned)u.sf, (double)u.bw, (unsigned)u.cr); + label(panel, info, THEME_FONT_BODY, theme::TEXT); + lv_obj_t* acts = convview::actionRow(panel); + convview::actionButton(acts, LV_SYMBOL_OK " Use", onPresetAction, radiox::P_USE, true); + convview::actionButton(acts, LV_SYMBOL_TRASH " Delete", onPresetAction, radiox::P_DELETE, false); +} + +void UITask::presetAction(uint8_t act) { + using namespace radiox; + NodePrefs* p = _prefs; + if (!p || s_slot < 0 || !p->user_radio_presets[s_slot].name[0]) { navClosePopup(); return; } + NodePrefs::UserRadioPreset& u = p->user_radio_presets[s_slot]; + switch (act) { + case P_USE: + p->freq = u.freq; p->bw = u.bw; p->sf = u.sf; p->cr = u.cr; + radioctl::applyParams(); + the_mesh.savePrefs(); + navClosePopup(); + rebuildRadio(); + showToast("Preset in use"); + break; + case P_DELETE: { + char t[40]; + snprintf(t, sizeof(t), "Delete %s?", u.name); + lv_obj_t* panel = navPopupPanel(t, false); + note(panel, "Removes the saved preset. The radio settings stay as they are."); + redButton(panel, LV_SYMBOL_TRASH " Delete", onPresetAction, P_DELETE_GO); + break; + } + case P_DELETE_GO: + radioctl::deleteUserPreset(p, s_slot); + the_mesh.savePrefs(); + navClosePopup(); + rebuildRadio(); + showToast("Preset deleted"); + break; + } +} + +// ── Name entry: a new preset, a new scope, a scope's new name ──────────────── + +void UITask::radioNamePopup(uint8_t what) { + using namespace radiox; + if (!_prefs) return; + s_naming = what; + const char* title = what == N_PRESET ? "Preset name" : what == N_SCOPE_ADD ? "New scope" : "Scope name"; + const char* hint = what == N_PRESET ? "e.g. Hiking" : "Region name, e.g. pl-maz"; + size_t cap = what == N_PRESET ? sizeof(_prefs->user_radio_presets[0].name) - 1 : sizeof(ScopeEntry::name) - 1; + lv_obj_t* panel = navPopupPanel(title, false); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard + _nav_ta = wifiField(panel, hint, false); + lv_obj_remove_event_cb(_nav_ta, onWifiField); + if (what == N_SCOPE_RENAME) lv_textarea_set_text(_nav_ta, the_mesh.scopeList().name((uint8_t)s_scope)); + lv_obj_add_event_cb(_nav_ta, devview::onByteCapInsert, LV_EVENT_INSERT, (void*)(uintptr_t)cap); + lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor + _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); + lv_obj_add_event_cb(_nav_kb, onRadioNameKb, LV_EVENT_READY, NULL); + lv_obj_add_event_cb(_nav_kb, onRadioNameKb, LV_EVENT_CANCEL, NULL); +} + +void UITask::radioNameDone(bool ok) { + using namespace radiox; + if (!ok || !_nav_ta || !_prefs) { navClosePopup(); return; } + char name[32]; + snprintf(name, sizeof(name), "%s", lv_textarea_get_text(_nav_ta)); + if (!name[0]) { showToast("Name can't be empty"); return; } + const char* toast = nullptr; + if (s_naming == N_PRESET) { + NodePrefs* p = _prefs; + radioctl::saveUserPreset(p, name, p->freq, p->bw, p->sf, p->cr); + the_mesh.savePrefs(); + toast = "Preset saved"; + } else if (s_naming == N_SCOPE_ADD) { + toast = the_mesh.addScope(name) ? "Scope added" : "The list is full"; + } else { + the_mesh.renameScope((uint8_t)s_scope, name); + toast = "Scope renamed"; + } + navClosePopup(); + if (_screen == SCR_RADIO) rebuildRadio(); + else if (_screen == SCR_SCOPES) buildScopes(); + showToast(toast); +} + +// ── Scopes ──────────────────────────────────────────────────────────────────── + +void UITask::showScopes() { + _screen = SCR_SCOPES; + buildScopes(); +} + +void UITask::buildScopes() { + lv_obj_t* body = newScreen("Scopes", true); + const ScopeList& sl = the_mesh.scopeList(); + if (_header && sl.count < ScopeList::MAX_SCOPE_ENTRIES) headerButton(_header, LV_SYMBOL_PLUS " Add", onScopeAdd, 4, NULL); + radiox::note(body, "A scope keeps messages within a region's repeaters. The default one is used for direct messages " + "and relaying; each channel picks its own in its options."); + for (uint8_t i = 0; i <= sl.count; i++) { + bool def = i == sl.default_idx; + const char* sub = def ? "Default" : i == 0 ? "Reaches every repeater" : nullptr; + lv_obj_t* row = listRow(body, i == 0 ? "* (no scope)" : sl.name(i), sub, onScopeRow, (void*)(uintptr_t)i); + if (def) lv_obj_align(label(row, LV_SYMBOL_OK, THEME_FONT_BODY, theme::ACCENT), LV_ALIGN_RIGHT_MID, -theme::PAD, 0); + } +} + +void UITask::scopeMenu(int idx) { + const ScopeList& sl = the_mesh.scopeList(); + if (idx < 0 || idx > sl.count) return; + radiox::s_scope = idx; + bool def = idx == sl.default_idx; + lv_obj_t* panel = navPopupPanel(idx == 0 ? "* (no scope)" : sl.name((uint8_t)idx), false); + if (def) radiox::note(panel, "This is the default: direct messages and relaying use it."); + if (idx == 0 && def) return; // nothing else to do with "*" + lv_obj_t* acts = convview::actionRow(panel); + if (!def) convview::actionButton(acts, LV_SYMBOL_OK " Default", onScopeAction, radiox::S_DEFAULT, true); + if (idx > 0) { + convview::actionButton(acts, LV_SYMBOL_EDIT " Rename", onScopeAction, radiox::S_RENAME, false); + convview::actionButton(acts, LV_SYMBOL_TRASH " Delete", onScopeAction, radiox::S_DELETE, false); + } +} + +void UITask::scopeAction(uint8_t act) { + using namespace radiox; + const ScopeList& sl = the_mesh.scopeList(); + if (s_scope < 0 || s_scope > sl.count) { navClosePopup(); return; } + switch (act) { + case S_DEFAULT: + the_mesh.setDefaultScope((uint8_t)s_scope); + navClosePopup(); + buildScopes(); + showToast("Default for direct messages and relaying"); + break; + case S_RENAME: + if (s_scope > 0) radioNamePopup(N_SCOPE_RENAME); + break; + case S_DELETE: { + if (s_scope == 0) break; + char t[40]; + snprintf(t, sizeof(t), "Delete %s?", sl.name((uint8_t)s_scope)); + lv_obj_t* panel = navPopupPanel(t, false); + note(panel, "Channels using it go back to no scope. If it is the default, * becomes the default."); + redButton(panel, LV_SYMBOL_TRASH " Delete", onScopeAction, S_DELETE_GO); + break; + } + case S_DELETE_GO: + if (s_scope > 0) the_mesh.removeScope((uint8_t)s_scope); + navClosePopup(); + buildScopes(); + showToast("Scope deleted"); + break; + } +} diff --git a/examples/companion_radio/ui-lvgl/RadioScreen.h b/examples/companion_radio/ui-lvgl/RadioScreen.h index 4c18d137..c166f5bb 100644 --- a/examples/companion_radio/ui-lvgl/RadioScreen.h +++ b/examples/companion_radio/ui-lvgl/RadioScreen.h @@ -1,6 +1,7 @@ #pragma once // Settings > Radio: preset, frequency, SF / bandwidth / coding rate, TX power -// and Adaptive Power Control -- ui-new's Settings > Radio. Every change is +// and Adaptive Power Control -- ui-new's Settings > Radio; the saved presets +// and scopes below them are RadioExtras.h. Every change is // applied to the radio at once and saved (ui-core/RadioControl.h does the // applying for both frontends). // @@ -124,6 +125,8 @@ void UITask::buildRadio() { THEME_FONT_SMALL, theme::TEXT_MUTED); lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP); lv_obj_set_width(note, LV_PCT(100)); + + buildRadioExtras(body); // my presets, scopes (RadioExtras.h) } void UITask::radioSet(int which, int v) { @@ -142,9 +145,13 @@ void UITask::radioSet(int which, int v) { case R_APC: p->tx_apc = (uint8_t)v; radioctl::applyApc(); break; } the_mesh.savePrefs(); - lv_obj_t* body = _body; - int32_t y = body ? lv_obj_get_scroll_y(body) : 0; - buildRadio(); // preset name / hints follow + rebuildRadio(); // preset name / hints follow +} + +// Rebuilds the screen where it was scrolled to. +void UITask::rebuildRadio() { + int32_t y = _body ? lv_obj_get_scroll_y(_body) : 0; + buildRadio(); if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); } } diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 7ca51980..8198d867 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -14,6 +14,7 @@ #include "../ui-core/TrackBack.h" #include "../ui-core/RadioControl.h" #include "../ui-core/Diagnostics.h" +#include "../ui-core/Battery.h" #include "../ui-core/ChannelControl.h" #include "../ui-core/BotConfig.h" #include "Theme.h" @@ -88,13 +89,16 @@ static bool localTime(const NodePrefs* p, struct tm& out) { out = *gmtime(&t); return true; } -// "14:05", or "2:05" (+ " PM" with suffix) with Settings > 12-hour clock. -static void fmtClock(char* b, size_t n, const struct tm& ti, const NodePrefs* p, bool suffix) { +// "14:05", or "2:05" (+ " PM" with suffix) with Settings > 12-hour clock; +// with `seconds`, ":09" follows unless Settings > Clock seconds is off. +static void fmtClock(char* b, size_t n, const struct tm& ti, const NodePrefs* p, bool suffix, bool seconds = false) { + char sec[4] = ""; + if (seconds && p && !p->clock_hide_seconds) snprintf(sec, sizeof(sec), ":%02d", ti.tm_sec); if (p && p->clock_12h) { int h = ti.tm_hour % 12; - snprintf(b, n, "%d:%02d%s", h ? h : 12, ti.tm_min, suffix ? (ti.tm_hour < 12 ? " AM" : " PM") : ""); + snprintf(b, n, "%d:%02d%s%s", h ? h : 12, ti.tm_min, sec, suffix ? (ti.tm_hour < 12 ? " AM" : " PM") : ""); } else { - snprintf(b, n, "%02d:%02d", ti.tm_hour, ti.tm_min); + snprintf(b, n, "%02d:%02d%s", ti.tm_hour, ti.tm_min, sec); } } // "Thu 25 Sep 2026" ("PM Thu 25 Sep 2026" on a 12-hour clock, whose big @@ -375,11 +379,18 @@ void UITask::refreshStatusBar() { lv_label_set_text(_status_time, "--:--"); } + // Battery: the icon, then % or volts per Settings > Battery display. uint16_t mv = getBattMilliVolts(); - int pct = mv <= 3300 ? 0 : mv >= 4200 ? 100 : (int)(mv - 3300) * 100 / 900; + int pct = battery::percent(mv, _prefs ? _prefs->low_batt_mv : 0); const char* batt = pct > 80 ? LV_SYMBOL_BATTERY_FULL : pct > 55 ? LV_SYMBOL_BATTERY_3 : pct > 30 ? LV_SYMBOL_BATTERY_2 : pct > 10 ? LV_SYMBOL_BATTERY_1 : LV_SYMBOL_BATTERY_EMPTY; - lv_label_set_text_fmt(_status_icons, "%s%s %d%%", hasConnection() ? LV_SYMBOL_BLUETOOTH " " : "", batt, pct); + char level[12] = ""; + switch (battery::mode(_prefs ? _prefs->batt_display_mode : 0)) { + case battery::PERCENT: snprintf(level, sizeof(level), " %d%%", pct); break; + case battery::VOLTAGE: snprintf(level, sizeof(level), " %u.%02u V", mv / 1000, (mv % 1000) / 10); break; + default: break; + } + lv_label_set_text_fmt(_status_icons, "%s%s%s", hasConnection() ? LV_SYMBOL_BLUETOOTH " " : "", batt, level); // GPS: green with a fix, muted while on and searching, absent when off. int32_t lat, lon; bool fix = _core->course.currentLocation(lat, lon); @@ -501,7 +512,16 @@ void UITask::back() { case SCR_SETTINGS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_SETTINGS_NAV: showSettings(); break; case SCR_CLOCK: showHome(); break; - case SCR_RADIO: if (radioPopupOpen()) radioCloseFreq(); else showSettings(); break; + case SCR_RADIO: + if (radioPopupOpen()) radioCloseFreq(); + else if (_nav_overlay) navClosePopup(); + else showSettings(); + break; + case SCR_SCOPES: // back to the Radio screen's bottom, where Scopes is + if (_nav_overlay) { navClosePopup(); break; } + showRadio(); + if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); } + break; case SCR_CHATS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_NEARBY: if (_scan_overlay) closeScanPopup(); @@ -683,7 +703,7 @@ void UITask::refreshHome() { struct tm ti; if (localTime(_prefs, ti)) { char clk[12], date[48]; - fmtClock(clk, sizeof(clk), ti, _prefs, false); + fmtClock(clk, sizeof(clk), ti, _prefs, false, true); fmtDate(date, sizeof(date), ti, _prefs); lv_label_set_text(_home_clock, clk); lv_label_set_text(_home_date, date); @@ -2062,3 +2082,4 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { #include "DeviceScreen.h" #include "DiagScreen.h" #include "CompassScreen.h" +#include "RadioExtras.h" diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index ad5c75cf..2e08997d 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -109,6 +109,14 @@ public: void radioSet(int which, int v); void radioFreqPopup(); void radioFreqDone(bool ok); + // Settings > Radio: my presets, scopes (RadioExtras.h) + void presetMenu(int slot); + void presetAction(uint8_t act); + void radioNamePopup(uint8_t what); + void radioNameDone(bool ok); + void showScopes(); + void scopeMenu(int idx); + void scopeAction(uint8_t act); // Channels (ChannelScreen.h) void channelMenu(int idx); void channelSet(uint8_t which, int v); @@ -172,7 +180,7 @@ public: void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; } private: - enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS }; + enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES }; void buildStatusBar(); void refreshStatusBar(); @@ -225,6 +233,9 @@ private: uint32_t threadSignature() const; void buildClock(); void buildRadio(); + void rebuildRadio(); + void buildRadioExtras(lv_obj_t* body); + void buildScopes(); void radioCloseFreq(); void buildChannelEdit(); void buildAdmin(); diff --git a/examples/companion_radio/ui-new/RadioPresetPicker.h b/examples/companion_radio/ui-new/RadioPresetPicker.h index 5d504b64..605d880a 100644 --- a/examples/companion_radio/ui-new/RadioPresetPicker.h +++ b/examples/companion_radio/ui-new/RadioPresetPicker.h @@ -3,6 +3,7 @@ #include "PopupMenu.h" #include "../RadioPresets.h" #include "../NodePrefs.h" +#include "../ui-core/RadioControl.h" // saving / deleting user presets // Shared "radio preset" picker used by both Settings › Radio and Tools › // Repeater. Both edit the same 4 user preset slots in NodePrefs and build the @@ -78,23 +79,11 @@ struct RadioPresetPicker { return -1; } - // Save the target's params as a named user preset: overwrite a slot with the - // same name if one exists, else the first empty slot, else slot 0 (oldest). - // Returns true if anything was written (caller alerts + marks dirty). + // Save the target's params as a named user preset (slot choice in + // radioctl::saveUserPreset). Returns true if anything was written (caller + // alerts + marks dirty). bool save(NodePrefs* p, const char* name, const Target& t) { - if (!p || !name || !name[0]) return false; - int slot = -1; - for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) - if (strcmp(p->user_radio_presets[i].name, name) == 0) { slot = i; break; } - if (slot < 0) - for (int i = 0; i < NodePrefs::USER_RADIO_PRESET_MAX; i++) - if (!p->user_radio_presets[i].name[0]) { slot = i; break; } - if (slot < 0) slot = 0; - NodePrefs::UserRadioPreset& u = p->user_radio_presets[slot]; - strncpy(u.name, name, sizeof(u.name) - 1); - u.name[sizeof(u.name) - 1] = '\0'; - u.freq = *t.freq; u.bw = *t.bw; u.sf = *t.sf; u.cr = *t.cr; - return true; + return radioctl::saveUserPreset(p, name, *t.freq, *t.bw, *t.sf, *t.cr); } void open(NodePrefs* p, const Target& t, const char* title) { @@ -143,7 +132,7 @@ struct RadioPresetPicker { if (confirm_slot >= 0) { Result r = NONE; if (idx == 0) { // "Delete" - p->user_radio_presets[confirm_slot].name[0] = '\0'; + radioctl::deleteUserPreset(p, confirm_slot); r = DELETED; } confirm_slot = -1; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 08d8f3be..562933cb 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -184,42 +184,10 @@ static const int QUICK_MSGS_MAX = 10; #include "ToolsScreen.h" #include "ClockToolsScreen.h" // Alarm / Timer / Stopwatch (Clock page › Enter) -#ifndef BATT_MIN_MILLIVOLTS - #define BATT_MIN_MILLIVOLTS 3200 -#endif +#include "../ui-core/Battery.h" -// LiPo discharge curve: voltage (mV) → raw capacity (%). Shared by the top-bar -// battery indicator and the dashboard Batt% field so both report the same -// number for the same voltage. low_mv (typically NodePrefs.low_batt_mv, the -// user-configurable auto-shutdown threshold in Settings) is rescaled to 0% -// so the bar empties at the cutoff the user actually cares about. -static int battMvToPercent(int mv, int low_mv) { - static const struct { uint16_t mv; uint8_t pct; } CURVE[] = { - {3200, 0}, {3300, 3}, {3400, 8}, {3500, 15}, - {3600, 25}, {3650, 33}, {3700, 45}, {3750, 58}, - {3800, 68}, {3900, 77}, {4000, 86}, {4100, 93}, {4170, 100} - }; - static const int CURVE_LEN = sizeof(CURVE) / sizeof(CURVE[0]); - auto curveAt = [&](int v) -> int { - if (v <= (int)CURVE[0].mv) return CURVE[0].pct; - if (v >= (int)CURVE[CURVE_LEN-1].mv) return CURVE[CURVE_LEN-1].pct; - for (int i = 1; i < CURVE_LEN; i++) { - if (v <= (int)CURVE[i].mv) { - int span_mv = CURVE[i].mv - CURVE[i-1].mv; - int span_pct = CURVE[i].pct - CURVE[i-1].pct; - return CURVE[i-1].pct + (v - (int)CURVE[i-1].mv) * span_pct / span_mv; - } - } - return 100; - }; - if (low_mv <= 0) low_mv = BATT_MIN_MILLIVOLTS; - int raw_pct = curveAt(mv); - int low_pct = curveAt(low_mv); - int pct = (low_pct >= 100) ? 0 : (raw_pct - low_pct) * 100 / (100 - low_pct); - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - return pct; -} +// Voltage -> battery %, from the Core (top bar and dashboard Batt% alike). +static int battMvToPercent(int mv, int low_mv) { return battery::percent(mv, low_mv); } // Render the time starting at top_y; returns the y just below the time block // so the caller can flow the date / dashboard rows beneath it.