perf(ui-lvgl): save settings on leaving the screen, not on every toggle

Every toggle / stepper wrote the prefs file straight away (0.1-0.6 s on the
ESP32's SPIFFS), which stuttered the UI and wore the flash. prefsSave() now
only marks them dirty; they're written once when the screen is left, when the
display goes off, or after 60 s at the latest -- the L1's approach. Shutdown
and the pre-restart OTA save still write immediately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-28 15:47:57 +02:00
co-authored by Claude Opus 5.5
parent 350393436a
commit 063f81e1df
13 changed files with 60 additions and 37 deletions
+4 -4
View File
@@ -142,7 +142,7 @@ void UITask::botToggle(int row, bool on) {
uint8_t* fl = botcfg::flag(_prefs, botcfg::row(botview::s_tab, row).kind);
if (!fl) return;
*fl = on ? 1 : 0;
the_mesh.savePrefs();
prefsSave();
}
// Tap on a value row: its editor.
@@ -247,7 +247,7 @@ void UITask::botTextDone(bool ok) {
char* t = botcfg::text(_prefs, botcfg::row(s_tab, s_row).kind, cap);
if (t) {
snprintf(t, cap, "%s", lv_textarea_get_text(_nav_ta));
the_mesh.savePrefs();
prefsSave();
}
}
navClosePopup();
@@ -260,7 +260,7 @@ void UITask::botPick(int idx) {
botcfg::Kind k = botcfg::row(s_tab, s_row).kind;
if (k == botcfg::CHANNEL) _prefs->bot_channel_idx = (uint8_t)idx;
else if (k == botcfg::ROOM && idx >= 0 && idx < (int)(sizeof(s_rooms) / sizeof(s_rooms[0]))) botcfg::setRoom(_prefs, s_rooms[idx]);
the_mesh.savePrefs();
prefsSave();
navClosePopup();
buildBot();
}
@@ -271,7 +271,7 @@ void UITask::botHour(uint8_t act) {
botcfg::Kind k = botcfg::row(s_tab, s_row).kind;
uint8_t& h = botcfg::hour(_prefs, k);
if (act == B_DONE) {
the_mesh.savePrefs();
prefsSave();
navClosePopup();
buildBot();
return;
@@ -128,7 +128,7 @@ void UITask::channelSet(uint8_t which, int v) {
if (which == C_NOTIF) chanctl::setNotif(_prefs, s_idx, (uint8_t)v);
else if (which == C_SCOPE) the_mesh.setChannelScope(s_idx, (uint8_t)v);
else if (which == C_MELODY) { chanctl::setMelody(_prefs, s_idx, (uint8_t)v); hearMelody(v); }
the_mesh.savePrefs();
prefsSave();
}
void UITask::channelAction(uint8_t act) {
@@ -139,7 +139,7 @@ void UITask::channelAction(uint8_t act) {
case A_FAV: {
bool on = !chanctl::favourite(_prefs, idx);
chanctl::setFavourite(_prefs, idx, on);
the_mesh.savePrefs();
prefsSave();
if (s_fav_btn) lv_obj_set_style_bg_color(s_fav_btn, lv_color_hex(on ? theme::ACCENT_DIM : theme::SURFACE), 0);
showToast(on ? "Added to favourites" : "Removed from favourites", 1200);
if (_screen == SCR_CHATS) { buildChats(); channelMenu(idx); } // list order / star
@@ -267,7 +267,7 @@ void UITask::setAlarm(int which, int v) {
case 3: _prefs->alarm_repeat_mask = NodePrefs::alarmRepeatMaskForIdx((uint8_t)v); break;
}
_core->clock.onAlarmChanged();
the_mesh.savePrefs();
prefsSave();
if (which < 2 && clockview::s_alarm_sw) lv_obj_add_state(clockview::s_alarm_sw, LV_STATE_CHECKED);
}
@@ -185,7 +185,7 @@ void UITask::conversationMenu(const uint8_t* pub_key) {
void UITask::conversationNotif(int v) {
if (!_prefs || v < 0 || v > 2) return;
contactctl::setNotif(_prefs, convview::s_key, (uint8_t)v);
the_mesh.savePrefs();
prefsSave();
}
void UITask::conversationAction(uint8_t act) {
@@ -405,6 +405,6 @@ void UITask::toggleChatFilter(uint8_t which) {
case CF_ROOMS: _prefs->room_fav_only ^= 1; break;
case CF_CONTACTS: _prefs->dm_show_all ^= 1; break;
}
the_mesh.savePrefs();
prefsSave();
if (_screen == SCR_CONTACTS) buildContacts(); else buildChats();
}
@@ -139,7 +139,7 @@ void UITask::nodeNameDone(bool ok) {
const char* t = lv_textarea_get_text(_nav_ta);
if (!t[0]) { showToast("Name can't be empty"); return; }
snprintf(_prefs->node_name, sizeof(_prefs->node_name), "%s", t);
the_mesh.savePrefs(); // getNodeName() and the self-advert read node_name live
prefsSave(); // getNodeName() and the self-advert read node_name live
showToast("Name saved - others see it with your next advert", 3000);
}
navClosePopup();
@@ -512,7 +512,7 @@ void UITask::favAction(uint8_t act) {
int slot = devview::s_fav_slot;
if (act == devview::F_CHANGE) { favPickPopup(slot); return; }
favslots::clear(_prefs, slot);
the_mesh.savePrefs();
prefsSave();
navClosePopup();
favRefresh();
}
@@ -559,7 +559,7 @@ void UITask::favPick(int code) {
using namespace devview;
if (code >= PICK_CONTACT) favslots::pinContact(_prefs, s_fav_slot, s_pick_keys[code - PICK_CONTACT]);
else favslots::pinChannel(_prefs, s_fav_slot, (uint8_t)code);
the_mesh.savePrefs();
prefsSave();
navClosePopup();
favRefresh();
}
@@ -610,7 +610,7 @@ void UITask::pinTo(int slot) {
else favslots::pinContact(_prefs, slot, s_pin_key);
snprintf(msg, sizeof(msg), "Pinned to slot %d", slot + 1);
}
the_mesh.savePrefs();
prefsSave();
navClosePopup();
showToast(msg, 1500);
}
@@ -294,7 +294,7 @@ void UITask::buildHomeClock(lv_obj_t* box) {
void UITask::homeFieldSet(int slot, int f) {
if (!_prefs || slot < 0 || slot >= home::FIELDS || f < 0 || f >= home::F_COUNT) return;
_prefs->dashboard_fields[slot] = (uint8_t)f;
the_mesh.savePrefs();
prefsSave();
setHomePage(home::CLOCK);
}
+5 -5
View File
@@ -419,7 +419,7 @@ void UITask::navFrameTrail() {
void UITask::navSetTarget(uint8_t kind, const uint8_t* key, int32_t lat, int32_t lon, const char* name) {
navmap::s_tb.stop(); // a chosen target replaces walking the trail back
_core->locator.setTarget(kind, key, lat, lon, name);
the_mesh.savePrefs();
prefsSave();
navmap::s_eta.reset();
char t[40];
snprintf(t, sizeof(t), "Navigating to %s", name);
@@ -433,7 +433,7 @@ void UITask::navPick(int code) {
case navmap::T_CLEAR:
if (navmap::s_tb.active()) { navmap::s_tb.stop(); showToast("Track back stopped"); break; }
_core->locator.clearTarget();
the_mesh.savePrefs();
prefsSave();
showToast("Target cleared");
break;
case navmap::T_WAYPOINT: {
@@ -682,7 +682,7 @@ void UITask::navRenameDone(bool ok) {
_core->waypoints.rename(_nav_wp, lv_textarea_get_text(_nav_ta));
if (was_target) { // the bar shows the target's saved label
snprintf(_prefs->locator_label, sizeof(_prefs->locator_label), "%s", _core->waypoints.at(_nav_wp).label);
the_mesh.savePrefs();
prefsSave();
}
rebuildMapMarkers();
layoutMap();
@@ -1008,7 +1008,7 @@ void UITask::navSetShareTarget(int sel) {
_prefs->loc_share_target_type = navmap::s_share_kind[sel];
if (navmap::s_share_kind[sel] == 0) _prefs->loc_share_channel_idx = navmap::s_share_ch[sel];
else memcpy(_prefs->loc_share_dm_prefix, navmap::s_share_key[sel], NodePrefs::FAVOURITE_PREFIX_LEN);
the_mesh.savePrefs();
prefsSave();
}
// A new file in /sdcard/trails named by the local time, "trail-YYYYMMDD-HHMM"
@@ -1243,7 +1243,7 @@ void UITask::navToolAction(uint8_t act) {
if (_core->course.currentLocation(lat, lon)) showToast("Sharing your position");
else ensureGps();
}
the_mesh.savePrefs();
prefsSave();
break;
case navmap::TL_SHARE_ONCE: {
int32_t lat, lon;
@@ -166,7 +166,7 @@ void UITask::quickEditDone(bool ok) {
using namespace qview;
if (ok && _nav_ta && _prefs && s_edit >= 0) {
msgtext::setQuick(_prefs, s_edit, lv_textarea_get_text(_nav_ta));
the_mesh.savePrefs();
prefsSave();
showToast(msgtext::quick(_prefs, s_edit)[0] ? "Quick message saved" : "Quick message cleared", 1200);
}
navClosePopup();
@@ -100,7 +100,7 @@ void UITask::presetAction(uint8_t 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();
prefsSave();
navClosePopup();
rebuildRadio();
showToast("Preset in use");
@@ -115,7 +115,7 @@ void UITask::presetAction(uint8_t act) {
}
case P_DELETE_GO:
radioctl::deleteUserPreset(p, s_slot);
the_mesh.savePrefs();
prefsSave();
navClosePopup();
rebuildRadio();
showToast("Preset deleted");
@@ -157,7 +157,7 @@ void UITask::radioNameDone(bool ok) {
if (s_naming == N_PRESET) {
NodePrefs* p = _prefs;
radioctl::saveUserPreset(p, name, p->freq, p->bw, p->sf, p->cr);
the_mesh.savePrefs();
prefsSave();
toast = "Preset saved";
} else if (s_naming == N_SCOPE_ADD) {
toast = the_mesh.addScope(name) ? "Scope added" : "The list is full";
@@ -126,7 +126,7 @@ void UITask::radioSet(int which, int v) {
case R_TX: p->tx_power_dbm = (int8_t)(TX_MIN + v); radioctl::applyTxPower(p); break;
case R_APC: p->tx_apc = (uint8_t)v; radioctl::applyApc(); break;
}
the_mesh.savePrefs();
prefsSave();
rebuildRadio(); // preset name / hints follow
}
@@ -173,7 +173,7 @@ void UITask::radioFreqDone(bool ok) {
if (f < lo || f > hi) { showToast("Out of the radio's range"); return; }
if (s_freq_rpt) { _prefs->repeater_freq = f; rptctl::applyProfile(); }
else { _prefs->freq = f; radioctl::applyParams(); }
the_mesh.savePrefs();
prefsSave();
}
radioCloseFreq();
if (ok) { if (s_freq_rpt) rebuildRepeater(); else rebuildRadio(); }
@@ -123,7 +123,7 @@ void UITask::repeaterSet(int which, int v) {
case RP_YIELD: p->repeat_delay_boost = (uint8_t)v; break;
case RP_SNR: p->repeat_min_snr = rptctl::snrFromChoice(v); break;
}
the_mesh.savePrefs();
prefsSave();
if (which <= RP_CR) rebuildRepeater(); // hints, the profile rows and the preset name follow
refreshStatusBar();
}
@@ -148,7 +148,7 @@ void UITask::repeaterScopesPopup() {
void UITask::repeaterScopeSet(uint8_t i, bool on) {
rptctl::setExtraScope(_prefs, i, on);
the_mesh.savePrefs();
prefsSave();
char sub[48];
extraScopesSummary(_prefs, sub, sizeof(sub));
if (rptview::s_scopes_sub) lv_label_set_text(rptview::s_scopes_sub, sub);
@@ -250,7 +250,7 @@ void UITask::setSoundMode(int mode) {
soundctl::setMode(_prefs, _buzzer, (uint8_t)mode, isClientConnected());
if (mode == soundctl::MODE_ON) notify(UIEventType::ack); // hear that it's on
#endif
the_mesh.savePrefs();
prefsSave();
refreshStatusBar();
if (mode == soundctl::MODE_AUTO) showToast(isClientConnected() ? "Silent while the app is connected" : "Sound on until the app connects");
}
@@ -260,7 +260,7 @@ void UITask::setSoundVolume(int level) {
#ifdef PIN_BUZZER
soundctl::setVolume(_prefs, _buzzer, (uint8_t)level);
#endif
the_mesh.savePrefs();
prefsSave();
}
// The Sound page's own rows: the mode at the top, the melodies at the bottom.
@@ -357,7 +357,7 @@ void UITask::melodySave() {
using namespace sndview;
if (!s_dirty || !_prefs) return;
soundctl::store(_prefs, s_slot, s_mel);
the_mesh.savePrefs();
prefsSave();
s_dirty = false;
showToast(s_slot ? "Melody 2 saved" : "Melody 1 saved", 1200);
}
@@ -446,7 +446,7 @@ void UITask::melodyAction(uint8_t act) {
void UITask::conversationMelody(int v) {
if (!_prefs || v < 0 || v >= soundctl::OVERRIDE_COUNT) return;
contactctl::setMelody(_prefs, convview::s_key, (uint8_t)v);
the_mesh.savePrefs();
prefsSave();
hearMelody(v);
}
+29 -6
View File
@@ -712,6 +712,25 @@ static void fmtDate(char* b, size_t n, const struct tm& ti) {
snprintf(b, n, "%s %d %s %d", DOW[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon], ti.tm_year + 1900);
}
// Settings changed here reach flash once their screen is left (as on the L1:
// a run of switches is one write, and the flash wears less), just after the
// next screen has come in -- a save stalls the UI (0.1-0.6 s on the ESP32's
// SPIFFS, far less on LittleFS). Else when the screen goes off, or a minute on. NodePrefs
// itself changes at once; shutdown() saves whatever is pending.
static uint32_t s_prefs_dirty_ms = 0; // when the first unsaved change came (0: none)
static uint32_t s_prefs_save_at = 0; // the screen was left: save from then (0: not yet)
static void prefsSave() { if (!s_prefs_dirty_ms) s_prefs_dirty_ms = millis() | 1; }
static void prefsSaveSoon(uint32_t delay_ms) {
if (s_prefs_dirty_ms && !s_prefs_save_at) s_prefs_save_at = (millis() + delay_ms) | 1;
}
static void prefsFlush() {
if (!s_prefs_dirty_ms) return;
bool due = s_prefs_save_at ? (int32_t)(millis() - s_prefs_save_at) >= 0 : millis() - s_prefs_dirty_ms >= 60000;
if (!due) return;
s_prefs_dirty_ms = s_prefs_save_at = 0;
the_mesh.savePrefs();
}
// A big clock (Home, the lock screen): the digits and, on a 12-hour clock,
// AM / PM beside them in `small`, on the digits' baseline. clockFaceSet()
// once a second; "--:--" until the time is known.
@@ -821,7 +840,7 @@ void UITask::toggleMute() {
if (!_prefs) return;
bool on = soundctl::mode(_prefs) != soundctl::MODE_ON;
soundctl::setMode(_prefs, _buzzer, on ? soundctl::MODE_ON : soundctl::MODE_OFF, isClientConnected());
the_mesh.savePrefs();
prefsSave();
if (on) _buzzer.playForced(soundctl::MEL_VOLUME);
if (!_asleep) { showToast(on ? "Sound on" : "Sound off", 1200); refreshStatusBar(); }
#endif
@@ -871,6 +890,7 @@ void UITask::loop() {
_core->loop();
drainCoreEvents();
prefsFlush();
#if defined(UI_HEAP_REPORT) && defined(ESP32)
// -D UI_HEAP_REPORT: internal / PSRAM heap once, 20 s after boot (the
// framework comparison in docs/development/l2-roadmap.md).
@@ -1005,6 +1025,7 @@ uint32_t UITask::idleMillis(uint32_t lv_next) {
}
void UITask::shutdown(bool restart) {
s_prefs_dirty_ms = s_prefs_save_at = 0;
the_mesh.savePrefs();
the_mesh.saveRTCTime();
the_mesh.flushDirtyContacts();
@@ -1053,6 +1074,7 @@ void UITask::sleep() {
if (_asleep) return;
_asleep = true;
if (_display) _display->turnOff();
prefsSaveSoon(0); // nothing to stall now
lvport::powerSave(true, _tap_wake);
if ((_prefs && _prefs->auto_lock) || _pin[0]) lockScreen(); // Lock screen, or a screen PIN
}
@@ -1349,6 +1371,7 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
bool same = _screen == _shown_screen && strncmp(title ? title : "", _shown_title, sizeof(_shown_title) - 1) == 0;
bool backward = _nav_back || screenDepth(_screen) < screenDepth(_shown_screen);
_nav_back = false;
if (!same) prefsSaveSoon(anim::FADE_MS + 60); // left the screen they were changed on
_shown_screen = _screen;
snprintf(_shown_title, sizeof(_shown_title), "%s", title ? title : "");
_scr = scr;
@@ -2174,7 +2197,7 @@ void UITask::nodeAction(uint8_t action) {
if (e.has_prefix) navToNode(e.pub_key, e.lat_e6, e.lon_e6, e.name[0] ? e.name : "Node");
else {
_core->locator.setTarget(0, nullptr, e.lat_e6, e.lon_e6, e.name[0] ? e.name : "Node");
the_mesh.savePrefs();
prefsSave();
openMap(true);
navFrameTarget();
}
@@ -2763,7 +2786,7 @@ static void onKeyboardAlphabet(lv_event_t* e) {
static void onPrefSwitch(lv_event_t* e) {
uint8_t* pref = (uint8_t*)lv_event_get_user_data(e);
*pref = lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0;
the_mesh.savePrefs();
prefsSave();
}
static void onGpsSwitch(lv_event_t* e) {
@@ -2848,7 +2871,7 @@ void UITask::setBrightnessPct(uint8_t pct, bool save) {
_prefs->display_brightness_pct = pct;
_prefs->display_brightness = (uint8_t)((pct + 12) / 25 > 4 ? 4 : (pct + 12) / 25); // nearest level, for anything reading it
applyDisplayPrefs();
if (save) the_mesh.savePrefs();
if (save) prefsSave();
}
static void onTapWake(lv_event_t* e) {
@@ -3008,7 +3031,7 @@ void UITask::setSchemaValue(int idx, int v) {
const settings::Setting& st = settings::ALL[idx];
settings::set(*_prefs, st, (uint8_t)v);
if (st.changed) st.changed(*_core);
the_mesh.savePrefs();
prefsSave();
if (st.offset == offsetof(NodePrefs, units_imperial) && _screen == SCR_SETTINGS_NAV) { // other labels depend on it
lv_obj_t* body = _body;
int32_t y = body ? lv_obj_get_scroll_y(body) : 0;
@@ -3102,7 +3125,7 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) {
if (!_prefs) return;
_prefs->keyboard_main_alphabet = (uint8_t)main_idx;
_prefs->keyboard_alt_alphabet = (uint8_t)(alt_sel == 0 ? main_idx : alt_sel - 1);
the_mesh.savePrefs();
prefsSave();
}
#include "ConversationScreen.h"