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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-25 23:21:38 +02:00
co-authored by Claude Opus 5.5
parent 63db1e2e07
commit 6f5fc07cd0
12 changed files with 405 additions and 69 deletions
@@ -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 <stdint.h>
#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
@@ -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
@@ -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),
@@ -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);
@@ -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;
}
}
+11 -4
View File
@@ -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); }
}
+29 -8
View File
@@ -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"
+12 -1
View File
@@ -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();
@@ -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;
+3 -35
View File
@@ -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.