feat(ui-lvgl): power saving, review fixes, shared helpers

Power: the UI loop sleeps until something is due (never with packets
queued); screen off puts the CPU at 80 MHz, the GT911 to sleep (unless a
tap wakes the screen) and the LP5814 in standby. GPS off now cuts the
L76K's rail and UART on the L2 (L2GpsProvider). The battery divider is
powered only for a reading (measured: settles at once); readings within
2 s are reused. The SD mount is retried at most every 5 s.

Fixes: WiFi left on after leaving a scan, live tiles' WiFi kept with the
screen off on the map, an ADC read every second for the status bar, touch
polled on every pass while dark, a scan cutting an update's WiFi, relay
count not refreshing in a channel thread, raw @[nick] in room previews.
Labels skip unchanged text (no redraw).

Helpers: scrollList, dimOverlay, settingRow, flexBox, nvs::get/put,
trailFilePath (a second save in the same minute no longer overwrites),
unreadTotal. Dead code removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-27 00:12:08 +02:00
co-authored by Claude Opus 5.5
parent f5206f10b4
commit 23caa11f51
23 changed files with 337 additions and 347 deletions
+9 -1
View File
@@ -226,7 +226,15 @@ Ideas to come back to (2026-09-26):
- [ ] Speaker click: would keeping the amplifier on at minimum volume - [ ] Speaker click: would keeping the amplifier on at minimum volume
remove it? remove it?
- [ ] Import routes from the SD card (optional extra). - [ ] Import routes from the SD card (optional extra).
- [ ] Battery life without losing features, above all CPU sleep. - [x] Battery life without losing features (2026-09-27): the UI loop sleeps
until something is due (none while packets are queued; 1-2 ms with a
melody / the app connected; up to 10 ms awake, 20 ms dark); screen off:
CPU at 80 MHz, touch controller asleep unless it wakes the screen,
backlight driver in standby. GPS off really cuts the L76K's rail (and
its UART). Battery divider powered only for a reading (measured: settles
at once). Also fixed: WiFi left on after a scan cut short, live tiles'
WiFi kept with the screen off on the map, a battery ADC read every
second. Not measured yet: the current before / after.
- [x] Live tiles: bounded cache on the card (2026-09-26) -- they go to - [x] Live tiles: bounded cache on the card (2026-09-26) -- they go to
/sdcard/maps-live, apart from downloaded maps; an index keeps their /sdcard/maps-live, apart from downloaded maps; an index keeps their
order and the oldest are deleted past the limit (Settings > Storage > order and the oldest are deleted past the limit (Settings > Storage >
@@ -165,13 +165,7 @@ void UITask::buildAdmin() {
lv_obj_add_event_cb(s_status, onAdminCancelWait, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(s_status, onAdminCancelWait, LV_EVENT_CLICKED, NULL);
lv_obj_add_flag(s_status, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(s_status, LV_OBJ_FLAG_HIDDEN);
s_list = lv_obj_create(body); s_list = scrollList(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++) { for (int i = 0; i < admin::rowCount(s_tab); i++) {
const admin::Field& f = admin::field(s_tab, i); const admin::Field& f = admin::field(s_tab, i);
const char* hint = f.isCustom() ? "Any CLI command" : f.isAction() ? nullptr const char* hint = f.isCustom() ? "Any CLI command" : f.isAction() ? nullptr
+2 -8
View File
@@ -89,18 +89,12 @@ void UITask::buildBot() {
lv_obj_set_style_bg_color(tabs, lv_color_hex(theme::SURFACE), LV_PART_ITEMS); lv_obj_set_style_bg_color(tabs, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
lv_obj_add_event_cb(tabs, onBotTab, LV_EVENT_VALUE_CHANGED, NULL); lv_obj_add_event_cb(tabs, onBotTab, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_t* list = lv_obj_create(body); lv_obj_t* list = scrollList(body);
styleSurface(list, theme::BG);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_ACTIVE);
for (int i = 0; i < botcfg::rowCount(s_tab); i++) { for (int i = 0; i < botcfg::rowCount(s_tab); i++) {
const botcfg::Row& r = botcfg::row(s_tab, i); const botcfg::Row& r = botcfg::row(s_tab, i);
uint8_t* fl = botcfg::flag(p, r.kind); uint8_t* fl = botcfg::flag(p, r.kind);
lv_obj_t* row = radioview::settingRow(list, r.label, fl || botcfg::isHour(r.kind) ? r.hint : nullptr); lv_obj_t* row = settingRow(list, r.label, fl || botcfg::isHour(r.kind) ? r.hint : nullptr);
if (fl && r.kind == botcfg::DM_SCOPE) { if (fl && r.kind == botcfg::DM_SCOPE) {
static const char* SCOPE[] = { "All", "Fav", "" }; static const char* SCOPE[] = { "All", "Fav", "" };
lv_obj_t* seg = segmented(row, SCOPE, *fl ? 1 : 0, 110, 32); lv_obj_t* seg = segmented(row, SCOPE, *fl ? 1 : 0, 110, 32);
@@ -83,13 +83,13 @@ void UITask::channelMenu(int idx) {
// bottom of the screen from a popup this low). // bottom of the screen from a popup this low).
static const char* NOTIF[] = { "Default", "Muted", "Always", "" }; static const char* NOTIF[] = { "Default", "Muted", "Always", "" };
static const char* MELODY[] = { "Default", "Melody 1", "Melody 2", "" }; static const char* MELODY[] = { "Default", "Melody 1", "Melody 2", "" };
lv_obj_t* row = radioview::settingRow(panel, "Alerts", NULL); lv_obj_t* row = settingRow(panel, "Alerts", NULL);
radioview::rowSegmented(row, NOTIF, chanctl::notif(_prefs, idx), 200, onChanSegment, C_NOTIF); radioview::rowSegmented(row, NOTIF, chanctl::notif(_prefs, idx), 200, onChanSegment, C_NOTIF);
row = radioview::settingRow(panel, "Sound", NULL); row = settingRow(panel, "Sound", NULL);
radioview::rowSegmented(row, MELODY, chanctl::melody(_prefs, idx), 200, onChanSegment, C_MELODY); radioview::rowSegmented(row, MELODY, chanctl::melody(_prefs, idx), 200, onChanSegment, C_MELODY);
const ScopeList& sl = the_mesh.scopeList(); const ScopeList& sl = the_mesh.scopeList();
if (sl.count > 0) { // only once regions are set up (Settings in the app) if (sl.count > 0) { // only once regions are set up (Settings in the app)
row = radioview::settingRow(panel, "Scope", "Region it's sent in"); row = settingRow(panel, "Scope", "Region it's sent in");
int o = 0; int o = 0;
for (uint8_t i = 0; i <= sl.count && o < (int)sizeof(s_scope_opts) - 24; i++) for (uint8_t i = 0; i <= sl.count && o < (int)sizeof(s_scope_opts) - 24; i++)
o += snprintf(s_scope_opts + o, sizeof(s_scope_opts) - o, "%s%s", i ? "\n" : "", sl.name(i)); o += snprintf(s_scope_opts + o, sizeof(s_scope_opts) - o, "%s%s", i ? "\n" : "", sl.name(i));
@@ -217,7 +217,7 @@ void UITask::buildChannelEdit() {
s_secret = chanField(body, "Passphrase"); s_secret = chanField(body, "Passphrase");
lv_textarea_set_max_length(s_secret, 32); lv_textarea_set_max_length(s_secret, 32);
lv_obj_t* row = radioview::settingRow(body, "Hex key", "32 characters"); lv_obj_t* row = settingRow(body, "Hex key", "32 characters");
s_hex_sw = lv_switch_create(row); s_hex_sw = lv_switch_create(row);
lv_obj_set_size(s_hex_sw, 46, 24); lv_obj_set_size(s_hex_sw, 46, 24);
lv_obj_align(s_hex_sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); lv_obj_align(s_hex_sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
@@ -163,10 +163,10 @@ void UITask::conversationMenu(const uint8_t* pub_key) {
if (!room) { // alerts: three segments (a dropdown's list runs off screen this low) if (!room) { // alerts: three segments (a dropdown's list runs off screen this low)
static const char* NOTIF[] = { "Default", "Muted", "Always", "" }; static const char* NOTIF[] = { "Default", "Muted", "Always", "" };
lv_obj_t* row = radioview::settingRow(panel, "Alerts", NULL); lv_obj_t* row = settingRow(panel, "Alerts", NULL);
radioview::rowSegmented(row, NOTIF, contactctl::notif(_prefs, ci.id.pub_key), 200, onConvNotif, 0); radioview::rowSegmented(row, NOTIF, contactctl::notif(_prefs, ci.id.pub_key), 200, onConvNotif, 0);
static const char* MELODY[] = { "Default", "Melody 1", "Melody 2", "" }; static const char* MELODY[] = { "Default", "Melody 1", "Melody 2", "" };
row = radioview::settingRow(panel, "Sound", NULL); row = settingRow(panel, "Sound", NULL);
radioview::rowSegmented(row, MELODY, contactctl::melody(_prefs, ci.id.pub_key), 200, onConvMelody, 0); radioview::rowSegmented(row, MELODY, contactctl::melody(_prefs, ci.id.pub_key), 200, onConvMelody, 0);
} else { } else {
bool in = _core->rooms.isLoggedIn(ci.id.pub_key); bool in = _core->rooms.isLoggedIn(ci.id.pub_key);
@@ -248,7 +248,7 @@ void UITask::unlockScreen() {
anim::run(fading, anim::setBgOpa, LV_OPA_COVER, LV_OPA_TRANSP, anim::UNLOCK_MS, anim::coverDone); anim::run(fading, anim::setBgOpa, LV_OPA_COVER, LV_OPA_TRANSP, anim::UNLOCK_MS, anim::coverDone);
if (_body) anim::run(_body, anim::setTy, 12, 0, anim::UNLOCK_MS); if (_body) anim::run(_body, anim::setTy, 12, 0, anim::UNLOCK_MS);
} }
s_lock =s_lock_clock = s_lock_date = s_lock_unread = s_lock_slider = s_pin_dots = s_pin_msg = nullptr; s_lock = s_lock_clock = s_lock_date = s_lock_unread = s_lock_slider = s_pin_dots = s_pin_msg = nullptr;
_pin_entry[0] = '\0'; _pin_entry[0] = '\0';
if (_screen == SCR_HOME) refreshHome(); if (_screen == SCR_HOME) refreshHome();
else if (_screen == SCR_CHATS) buildChats(); // counts moved on while locked else if (_screen == SCR_CHATS) buildChats(); // counts moved on while locked
@@ -302,7 +302,7 @@ void UITask::refreshLock() {
lv_label_set_text(s_lock_clock, clk); lv_label_set_text(s_lock_clock, clk);
if (s_lock_date) lv_label_set_text(s_lock_date, date); if (s_lock_date) lv_label_set_text(s_lock_date, date);
} }
int unread = _core->dmUnreadTotal() + _core->history.getTotalChannelUnread() + _core->roomUnread(); int unread = unreadTotal();
if (!s_lock_date) { // PIN card: the count next to the clock, the keypad's line if (!s_lock_date) { // PIN card: the count next to the clock, the keypad's line
if (unread > 0) lv_label_set_text_fmt(s_lock_unread, LV_SYMBOL_ENVELOPE " %d", unread); if (unread > 0) lv_label_set_text_fmt(s_lock_unread, LV_SYMBOL_ENVELOPE " %d", unread);
else lv_label_set_text(s_lock_unread, ""); else lv_label_set_text(s_lock_unread, "");
@@ -449,11 +449,6 @@ void UITask::setAccent(int idx) {
// ── Favourites card (Home, left of the main page) ───────────────────────────── // ── Favourites card (Home, left of the main page) ─────────────────────────────
void UITask::showFavourites() {
home::s_page = home::FAVS;
showHome();
}
// The six slots, three to a row, into `grid` (a wrapping row). // The six slots, three to a row, into `grid` (a wrapping row).
void UITask::favGrid(lv_obj_t* grid) { void UITask::favGrid(lv_obj_t* grid) {
int w = (lv_display_get_horizontal_resolution(NULL) - 2 * theme::PAD - 2 * theme::GAP) / 3; int w = (lv_display_get_horizontal_resolution(NULL) - 2 * theme::PAD - 2 * theme::GAP) / 3;
@@ -117,7 +117,7 @@ void UITask::refreshDiag() {
int n = allRows(rows, _core->gpsEnabled()); int n = allRows(rows, _core->gpsEnabled());
if (n != s_rows) { buildDiag(); return; } if (n != s_rows) { buildDiag(); return; }
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
if (strcmp(lv_label_get_text(s_vals[i]), rows[i].value) != 0) lv_label_set_text(s_vals[i], rows[i].value); lv_label_set_text(s_vals[i], rows[i].value); // unchanged text isn't redrawn (UITask.cpp)
} }
void UITask::diagResetPopup() { void UITask::diagResetPopup() {
@@ -245,8 +245,7 @@ private:
snprintf(tmp, sizeof(tmp), "%s.tmp", path); snprintf(tmp, sizeof(tmp), "%s.tmp", path);
FILE* o = fopen(tmp, "wb"); FILE* o = fopen(tmp, "wb");
if (o) { if (o) {
Hdr nh = { MAGIC, VERSION, (uint16_t)sizeof(E), cap, 0, got, h.next_id }; Hdr nh = { MAGIC, VERSION, (uint16_t)sizeof(E), cap, 0, got, h.next_id }; // the kept records keep their ids
nh.next_id = h.next_id; // ids of the kept records stay as they were
bool ok = fwrite(&nh, sizeof(nh), 1, o) == 1 && (got == 0 || fwrite(buf, sizeof(E), got, o) == got); bool ok = fwrite(&nh, sizeof(nh), 1, o) == 1 && (got == 0 || fwrite(buf, sizeof(E), got, o) == got);
fclose(o); fclose(o);
if (ok) { remove(path); rename(tmp, path); } // FAT's rename doesn't replace if (ok) { remove(path); rename(tmp, path); } // FAT's rename doesn't replace
+69 -109
View File
@@ -103,6 +103,21 @@ static bool touched() {
static void swallowTouch() { s_swallow = true; } static void swallowTouch() { s_swallow = true; }
// ── Power ────────────────────────────────────────────────────────────────────
// Screen off: the CPU clock down (LoRa, BLE and WiFi all run at 80 MHz) and
// the touch panel asleep unless a tap is to wake the screen. Screen on: back.
static bool s_touch_asleep = false;
static void powerSave(bool on, bool keep_touch) {
static uint32_t full_mhz = 0;
if (!full_mhz) full_mhz = getCpuFrequencyMhz();
setCpuFrequencyMhz(on ? 80 : full_mhz);
if (on && !keep_touch) { board.touchSleep(); s_touch_asleep = true; }
else if (!on && s_touch_asleep) { board.touchWake(); s_touch_asleep = false; }
}
// Nothing due for `ms`: the loop task blocks and the core idles, clock-gated,
// instead of spinning.
static void idle(uint32_t ms) { if (ms) vTaskDelay(pdMS_TO_TICKS(ms)); }
// Backlight 1-100 % (the brightness slider), on the LP5814 PWM. // Backlight 1-100 % (the brightness slider), on the LP5814 PWM.
static void setBacklightPct(uint8_t pct) { static void setBacklightPct(uint8_t pct) {
if (pct > 100) pct = 100; if (pct > 100) pct = 100;
@@ -110,141 +125,84 @@ static void setBacklightPct(uint8_t pct) {
} }
// microSD over SDMMC, 1-bit (CLK 2, CMD 3, D0 1); its power rail (expander // microSD over SDMMC, 1-bit (CLK 2, CMD 3, D0 1); its power rail (expander
// P14) is switched on in WioTrackerL2Board::begin(). Retried on every call // P14) is switched on in WioTrackerL2Board::begin(). Retried until it works --
// until it works, so a card inserted later is picked up the next time. // at most every 5 s, a failed mount takes a while -- so a card inserted later
// is picked up.
static bool mountStorage() { static bool mountStorage() {
static bool mounted = false; static bool mounted = false;
static uint32_t last_try = 0;
if (mounted) return true; if (mounted) return true;
if (last_try && millis() - last_try < 5000) return false;
last_try = millis() | 1;
SD_MMC.setPins(2, 3, 1); SD_MMC.setPins(2, 3, 1);
mounted = SD_MMC.begin("/sdcard", true /* 1-bit */); mounted = SD_MMC.begin("/sdcard", true /* 1-bit */);
return mounted; return mounted;
} }
// ── WiFi (station, only while a map download runs) ─────────────────────────── // ── Settings kept in NVS ─────────────────────────────────────────────────────
// Credentials in NVS, not in NodePrefs (whose on-flash layout stays fixed) and // Not in NodePrefs (whose on-flash layout stays fixed) and not on the
// not on the removable card. // removable card. One value per call: the namespace is opened and closed
static bool loadWifi(char* ssid, size_t ssid_n, char* pass, size_t pass_n) { // around it; a namespace that won't open reads as the default.
namespace nvs {
struct Ns {
Preferences p; Preferences p;
if (!p.begin("mc_wifi", true)) { ssid[0] = pass[0] = '\0'; return false; } bool ok;
p.getString("ssid", ssid, ssid_n); Ns(const char* ns, bool ro) { ok = p.begin(ns, ro); }
p.getString("pass", pass, pass_n); ~Ns() { if (ok) p.end(); }
p.end(); };
static bool getBool(const char* ns, const char* key, bool def) { Ns n(ns, true); return n.ok ? n.p.getBool(key, def) : def; }
static void putBool(const char* ns, const char* key, bool v) { Ns n(ns, false); if (n.ok) n.p.putBool(key, v); }
static int getI8(const char* ns, const char* key, int def) { Ns n(ns, true); return n.ok ? n.p.getChar(key, (int8_t)def) : def; }
static void putI8(const char* ns, const char* key, int v) { Ns n(ns, false); if (n.ok) n.p.putChar(key, (int8_t)v); }
static int getU8(const char* ns, const char* key, int def) { Ns n(ns, true); return n.ok ? n.p.getUChar(key, (uint8_t)def) : def; }
static void putU8(const char* ns, const char* key, int v) { Ns n(ns, false); if (n.ok) n.p.putUChar(key, (uint8_t)v); }
static void getStr(const char* ns, const char* key, char* out, size_t n_out) {
out[0] = '\0';
Ns n(ns, true);
if (n.ok) n.p.getString(key, out, n_out);
}
static void putStr(const char* ns, const char* key, const char* v) { Ns n(ns, false); if (n.ok) n.p.putString(key, v); }
} // namespace nvs
// ── WiFi (station, only while a map download runs) ───────────────────────────
static bool loadWifi(char* ssid, size_t ssid_n, char* pass, size_t pass_n) {
nvs::getStr("mc_wifi", "ssid", ssid, ssid_n);
nvs::getStr("mc_wifi", "pass", pass, pass_n);
return ssid[0] != '\0'; return ssid[0] != '\0';
} }
static void saveWifi(const char* ssid, const char* pass) { static void saveWifi(const char* ssid, const char* pass) {
Preferences p; nvs::putStr("mc_wifi", "ssid", ssid);
if (!p.begin("mc_wifi", false)) return; nvs::putStr("mc_wifi", "pass", pass);
p.putString("ssid", ssid);
p.putString("pass", pass);
p.end();
} }
// Settings > WiFi's switch: off keeps the radio off for everything (scan, map // Settings > WiFi's switch: off keeps the radio off for everything (scan, map
// download). Kept with the credentials. // download). Kept with the credentials.
static int8_t s_wifi_allowed = -1; // read once (the status bar asks every second) static int8_t s_wifi_allowed = -1; // read once (the status bar asks every second)
static bool wifiAllowed() { static bool wifiAllowed() {
if (s_wifi_allowed < 0) { if (s_wifi_allowed < 0) s_wifi_allowed = nvs::getBool("mc_wifi", "on", true);
Preferences p;
bool on = true;
if (p.begin("mc_wifi", true)) { on = p.getBool("on", true); p.end(); }
s_wifi_allowed = on;
}
return s_wifi_allowed; return s_wifi_allowed;
} }
static void setWifiAllowed(bool on) { static void setWifiAllowed(bool on) { s_wifi_allowed = on; nvs::putBool("mc_wifi", "on", on); }
s_wifi_allowed = on;
Preferences p;
if (!p.begin("mc_wifi", false)) return;
p.putBool("on", on);
p.end();
}
// Map tools > Live tiles: missing tiles fetched over WiFi while the map is open. // Map tools > Live tiles: missing tiles fetched over WiFi while the map is open.
static bool liveTiles() { static bool liveTiles() { return nvs::getBool("mc_wifi", "live", true); }
Preferences p; static void setLiveTiles(bool on) { nvs::putBool("mc_wifi", "live", on); }
if (!p.begin("mc_wifi", true)) return true;
bool on = p.getBool("live", true);
p.end();
return on;
}
static void setLiveTiles(bool on) {
Preferences p;
if (!p.begin("mc_wifi", false)) return;
p.putBool("live", on);
p.end();
}
// Screen-lock PIN (Settings > Display & power > Screen PIN): digits, "" = none. // Screen-lock PIN (Settings > Display & power > Screen PIN): digits, "" = none.
// In NVS for the same reason as the WiFi credentials. static void loadPin(char* out, size_t n) { nvs::getStr("mc_lock", "pin", out, n); }
static void loadPin(char* out, size_t n) { static void savePin(const char* pin) { nvs::putStr("mc_lock", "pin", pin); }
Preferences p;
out[0] = '\0';
if (!p.begin("mc_lock", true)) return;
p.getString("pin", out, n);
p.end();
}
static void savePin(const char* pin) {
Preferences p;
if (!p.begin("mc_lock", false)) return;
p.putString("pin", pin);
p.end();
}
// Accent colour (Settings > Display & power): an index into theme::ACCENTS. // Accent colour (Settings > Display & power): an index into theme::ACCENTS.
static int loadAccent() { static int loadAccent() { return nvs::getU8("mc_ui", "accent", 0); }
Preferences p; static void saveAccent(int idx) { nvs::putU8("mc_ui", "accent", idx); }
if (!p.begin("mc_ui", true)) return 0;
int v = p.getUChar("accent", 0);
p.end();
return v;
}
static void saveAccent(int idx) {
Preferences p;
if (!p.begin("mc_ui", false)) return;
p.putUChar("accent", (uint8_t)idx);
p.end();
}
// Settings > Storage > Kept per conversation (an index into histstore::KEEP). // Settings > Storage > Kept per conversation (an index into histstore::KEEP).
static int loadHistKeep() { static int loadHistKeep() { return nvs::getI8("mc_ui", "hkeep", -1); }
Preferences p; static void saveHistKeep(int idx) { nvs::putI8("mc_ui", "hkeep", idx); }
if (!p.begin("mc_ui", true)) return -1;
int v = p.getChar("hkeep", -1);
p.end();
return v;
}
static void saveHistKeep(int idx) {
Preferences p;
if (!p.begin("mc_ui", false)) return;
p.putChar("hkeep", (int8_t)idx);
p.end();
}
// Settings > Storage > Live map tiles: index into mapview::LIVE_CAP_MB, -1 = default. // Settings > Storage > Live map tiles: index into mapview::LIVE_CAP_MB, -1 = default.
static int loadLiveCap() { static int loadLiveCap() { return nvs::getI8("mc_ui", "ltcap", -1); }
Preferences p; static void saveLiveCap(int idx) { nvs::putI8("mc_ui", "ltcap", idx); }
if (!p.begin("mc_ui", true)) return -1;
int v = p.getChar("ltcap", -1);
p.end();
return v;
}
static void saveLiveCap(int idx) {
Preferences p;
if (!p.begin("mc_ui", false)) return;
p.putChar("ltcap", (int8_t)idx);
p.end();
}
// Settings > Display & power > Tap to wake: a touch turns the dark screen on // Settings > Display & power > Tap to wake: a touch turns the dark screen on
// (off: only the side button does). // (off: only the top button does).
static bool loadTapWake() { static bool loadTapWake() { return nvs::getBool("mc_ui", "tapwake", true); }
Preferences p; static void saveTapWake(bool on) { nvs::putBool("mc_ui", "tapwake", on); }
if (!p.begin("mc_ui", true)) return true;
bool on = p.getBool("tapwake", true);
p.end();
return on;
}
static void saveTapWake(bool on) {
Preferences p;
if (!p.begin("mc_ui", false)) return;
p.putBool("tapwake", on);
p.end();
}
// Filesystem size and space in use (Settings > Storage), plus the card's own // Filesystem size and space in use (Settings > Storage), plus the card's own
// size -- a card whose FAT partition is small (e.g. written by a Raspberry Pi // size -- a card whose FAT partition is small (e.g. written by a Raspberry Pi
// imager) shows both. The first free-space count on a big card takes a moment. // imager) shows both. The first free-space count on a big card takes a moment.
@@ -495,7 +453,7 @@ static void touchCb(lv_indev_t* indev, lv_indev_data_t* data) {
static bool begin() { static bool begin() {
lv_display_t* disp = lv_display_create(SimLcdDisplay::W, SimLcdDisplay::H); lv_display_t* disp = lv_display_create(SimLcdDisplay::W, SimLcdDisplay::H);
lv_display_set_flush_cb(disp, flushCb); lv_display_set_flush_cb(disp, flushCb);
static uint8_t buf[SimLcdDisplay::W * 40 * 2]; // same 40-line partial buffer as the board static uint8_t buf[SimLcdDisplay::W * 40 * 2]; // 40 lines (the board: two half screens)
lv_display_set_buffers(disp, buf, nullptr, sizeof(buf), LV_DISPLAY_RENDER_MODE_PARTIAL); lv_display_set_buffers(disp, buf, nullptr, sizeof(buf), LV_DISPLAY_RENDER_MODE_PARTIAL);
lv_indev_t* indev = lv_indev_create(); lv_indev_t* indev = lv_indev_create();
@@ -506,6 +464,8 @@ static bool begin() {
static bool touched() { return SimLcdDisplay::touchState().down; } static bool touched() { return SimLcdDisplay::touchState().down; }
static void swallowTouch() { s_swallow = true; } static void swallowTouch() { s_swallow = true; }
static void powerSave(bool, bool) {} // the browser's page loop runs the sim
static void idle(uint32_t) {}
static void setBacklightPct(uint8_t pct) { (void)pct; } // the browser canvas has no backlight static void setBacklightPct(uint8_t pct) { (void)pct; } // the browser canvas has no backlight
// The host page preloads map tiles into the in-memory FS under /sdcard/maps. // The host page preloads map tiles into the in-memory FS under /sdcard/maps.
+4 -9
View File
@@ -583,13 +583,7 @@ void UITask::mapDownloadPopup() {
if (_dl_zmax < _map_z) _dl_zmax = _map_z + 3; if (_dl_zmax < _map_z) _dl_zmax = _map_z + 3;
if (_dl_zmax > src_max) _dl_zmax = src_max; // the server has nothing finer if (_dl_zmax > src_max) _dl_zmax = src_max; // the server has nothing finer
_dl_overlay = lv_obj_create(screen()); _dl_overlay = dimOverlay(screen());
lv_obj_remove_style_all(_dl_overlay);
lv_obj_set_size(_dl_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(_dl_overlay, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(_dl_overlay, LV_OPA_60, 0);
lv_obj_add_flag(_dl_overlay, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(_dl_overlay, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* panel = lv_obj_create(_dl_overlay); lv_obj_t* panel = lv_obj_create(_dl_overlay);
anim::popup(_dl_overlay); anim::popup(_dl_overlay);
@@ -828,8 +822,9 @@ void UITask::mapDownloadTick() {
bool got = false; bool got = false;
while (dl.liveTake(z, x, y)) { mapview::s_cache.forgetMissing(z, x, y); got = true; } while (dl.liveTake(z, x, y)) { mapview::s_cache.forgetMissing(z, x, y); got = true; }
if (got && _screen == SCR_MAP) layoutMap(); if (got && _screen == SCR_MAP) layoutMap();
// The WiFi goes 30 s after the map was left (a quick look elsewhere keeps it). // The WiFi goes 30 s after the map was left or the screen went off (a
if (_screen != SCR_MAP) { // quick look elsewhere keeps it).
if (_screen != SCR_MAP || _asleep) {
if (!_map_left_ms) _map_left_ms = millis() | 1; if (!_map_left_ms) _map_left_ms = millis() | 1;
else if (millis() - _map_left_ms > 30000) dl.liveEnd(); else if (millis() - _map_left_ms > 30000) dl.liveEnd();
} }
+27 -55
View File
@@ -476,13 +476,7 @@ void UITask::navToNode(const uint8_t* key, int32_t lat, int32_t lon, const char*
lv_obj_t* UITask::navPopupPanel(const char* title, bool full) { lv_obj_t* UITask::navPopupPanel(const char* title, bool full) {
navClosePopup(); navClosePopup();
_nav_overlay = lv_obj_create(screen()); _nav_overlay = dimOverlay(screen());
lv_obj_remove_style_all(_nav_overlay);
lv_obj_set_size(_nav_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(_nav_overlay, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(_nav_overlay, LV_OPA_60, 0);
lv_obj_add_flag(_nav_overlay, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(_nav_overlay, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* panel = lv_obj_create(_nav_overlay); lv_obj_t* panel = lv_obj_create(_nav_overlay);
anim::popup(_nav_overlay); anim::popup(_nav_overlay);
@@ -529,13 +523,7 @@ static void navRowRight(lv_obj_t* row, const char* text, uint32_t col, int right
void UITask::navTargetsPopup() { void UITask::navTargetsPopup() {
lv_obj_t* panel = navPopupPanel("Navigate to", true); lv_obj_t* panel = navPopupPanel("Navigate to", true);
lv_obj_t* list = lv_obj_create(panel); lv_obj_t* list = scrollList(panel);
styleSurface(list, theme::BG);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_ACTIVE);
int32_t lat = 0, lon = 0; int32_t lat = 0, lon = 0;
bool gps = _core->course.currentLocation(lat, lon); bool gps = _core->course.currentLocation(lat, lon);
@@ -914,13 +902,7 @@ static lv_obj_t* toolButton(lv_obj_t* row, const char* text, uint8_t act, bool a
void UITask::navToolsPopup() { void UITask::navToolsPopup() {
lv_obj_t* panel = navPopupPanel("Map tools", true); lv_obj_t* panel = navPopupPanel("Map tools", true);
lv_obj_t* list = lv_obj_create(panel); lv_obj_t* list = scrollList(panel);
styleSurface(list, theme::BG);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_ACTIVE);
sectionTitle(list, "TRAIL"); sectionTitle(list, "TRAIL");
_nav_trail_lbl = label(list, "", THEME_FONT_SMALL, theme::TEXT); _nav_trail_lbl = label(list, "", THEME_FONT_SMALL, theme::TEXT);
@@ -1035,20 +1017,31 @@ void UITask::navSetShareTarget(int sel) {
the_mesh.savePrefs(); the_mesh.savePrefs();
} }
// A new file in /sdcard/trails named by the local time, "trail-YYYYMMDD-HHMM"
// (seconds since boot before the clock is set), with `ext`; a second save in
// the same minute gets "-2", "-3"...
static void trailFilePath(char* path, size_t n, const char* ext, const NodePrefs* p) {
char stem[40];
uint32_t now = rtc_clock.getCurrentTime();
if (now > 1000000000UL) {
time_t t = (time_t)((int64_t)now + (int64_t)(p ? p->tz_offset_hours : 0) * 3600);
struct tm* ti = gmtime(&t);
snprintf(stem, sizeof(stem), "%s/trail-%04d%02d%02d-%02d%02d", navmap::TRAILS_DIR, ti->tm_year + 1900,
ti->tm_mon + 1, ti->tm_mday, ti->tm_hour, ti->tm_min);
} else {
snprintf(stem, sizeof(stem), "%s/trail-%lu", navmap::TRAILS_DIR, (unsigned long)(millis() / 1000));
}
snprintf(path, n, "%s%s", stem, ext);
struct stat st;
for (int k = 2; k < 100 && stat(path, &st) == 0; k++) snprintf(path, n, "%s-%d%s", stem, k, ext);
mapview::makeParents(path);
}
// GPX of the live trail (with the waypoints) to /sdcard/trails/. // GPX of the live trail (with the waypoints) to /sdcard/trails/.
bool UITask::exportTrailGpx(char* name_out, size_t n) { bool UITask::exportTrailGpx(char* name_out, size_t n) {
if (!lvport::mountStorage()) return false; if (!lvport::mountStorage()) return false;
char path[64]; char path[64];
uint32_t now = rtc_clock.getCurrentTime(); trailFilePath(path, sizeof(path), ".gpx", _prefs);
if (now > 1000000000UL) {
time_t t = (time_t)((int64_t)now + (int64_t)_prefs->tz_offset_hours * 3600);
struct tm* ti = gmtime(&t);
snprintf(path, sizeof(path), "/sdcard/trails/trail-%04d%02d%02d-%02d%02d.gpx", ti->tm_year + 1900,
ti->tm_mon + 1, ti->tm_mday, ti->tm_hour, ti->tm_min);
} else {
snprintf(path, sizeof(path), "/sdcard/trails/trail-%lu.gpx", (unsigned long)(millis() / 1000));
}
mapview::makeParents(path);
FILE* f = fopen(path, "w"); FILE* f = fopen(path, "w");
if (!f) return false; if (!f) return false;
navmap::FilePrint out(f); navmap::FilePrint out(f);
@@ -1062,16 +1055,7 @@ bool UITask::exportTrailGpx(char* name_out, size_t n) {
// The live trail to a new file in /sdcard/trails (named by the local time). // The live trail to a new file in /sdcard/trails (named by the local time).
bool UITask::saveTrailToCard(char* name_out, size_t n) { bool UITask::saveTrailToCard(char* name_out, size_t n) {
char path[64]; char path[64];
uint32_t now = rtc_clock.getCurrentTime(); trailFilePath(path, sizeof(path), ".trl", _prefs);
if (now > 1000000000UL) {
time_t t = (time_t)((int64_t)now + (int64_t)_prefs->tz_offset_hours * 3600);
struct tm* ti = gmtime(&t);
snprintf(path, sizeof(path), "%s/trail-%04d%02d%02d-%02d%02d.trl", navmap::TRAILS_DIR, ti->tm_year + 1900,
ti->tm_mon + 1, ti->tm_mday, ti->tm_hour, ti->tm_min);
} else {
snprintf(path, sizeof(path), "%s/trail-%lu.trl", navmap::TRAILS_DIR, (unsigned long)(millis() / 1000));
}
mapview::makeParents(path);
FILE* f = fopen(path, "wb"); FILE* f = fopen(path, "wb");
if (!f) return false; if (!f) return false;
navmap::FileRW io{ f }; navmap::FileRW io{ f };
@@ -1086,13 +1070,7 @@ bool UITask::saveTrailToCard(char* name_out, size_t n) {
// device's own slot (manual saves without a card, the low-battery auto-save). // device's own slot (manual saves without a card, the low-battery auto-save).
void UITask::savedTrailsPopup() { void UITask::savedTrailsPopup() {
lv_obj_t* panel = navPopupPanel("Saved trails", true); lv_obj_t* panel = navPopupPanel("Saved trails", true);
lv_obj_t* list = lv_obj_create(panel); lv_obj_t* list = scrollList(panel);
styleSurface(list, theme::BG);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_ACTIVE);
navmap::scanTrails(); navmap::scanTrails();
for (int i = 0; i < navmap::s_st_n; i++) { for (int i = 0; i < navmap::s_st_n; i++) {
char title[32], sub[48], path[64]; char title[32], sub[48], path[64];
@@ -1328,13 +1306,7 @@ void UITask::navWaypointsPopup() {
toolButton(r, UI_SYMBOL_PIN " Here (GPS)", navmap::TL_WP_HERE, true); toolButton(r, UI_SYMBOL_PIN " Here (GPS)", navmap::TL_WP_HERE, true);
toolButton(r, LV_SYMBOL_KEYBOARD " Coordinates", navmap::TL_WP_COORDS, false); toolButton(r, LV_SYMBOL_KEYBOARD " Coordinates", navmap::TL_WP_COORDS, false);
lv_obj_t* list = lv_obj_create(panel); lv_obj_t* list = scrollList(panel);
styleSurface(list, theme::BG);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_ACTIVE);
int32_t lat = 0, lon = 0; int32_t lat = 0, lon = 0;
bool gps = _core->course.currentLocation(lat, lon); bool gps = _core->course.currentLocation(lat, lon);
for (int i = 0; i < wp.count(); i++) { for (int i = 0; i < wp.count(); i++) {
@@ -174,6 +174,9 @@ void UITask::showOta() {
buildOta(); buildOta();
} }
// Something else holds the WiFi: a scan leaves it up.
bool UITask::wifiInUse() const { return mapview::s_dl.active() || otaBusy() || ota::s_state == ota::CONNECTING; }
bool UITask::otaBusy() const { return ota::s_state == ota::CHECKING || ota::s_state == ota::INSTALLING || ota::s_state == ota::DONE; } bool UITask::otaBusy() const { return ota::s_state == ota::CHECKING || ota::s_state == ota::INSTALLING || ota::s_state == ota::DONE; }
void UITask::buildOta() { void UITask::buildOta() {
+1 -21
View File
@@ -24,21 +24,6 @@ static const int OPTS_LEN = 1024;
static char* s_opts = psramBuf<char>(OPTS_LEN); // dropdown options (LVGL copies them) static char* s_opts = psramBuf<char>(OPTS_LEN); // dropdown options (LVGL copies them)
// Label + optional hint on the left of a settings row; the control goes on the right. // Label + optional hint on the left of a settings row; the control goes on the right.
static lv_obj_t* settingRow(lv_obj_t* parent, const char* text, const char* hint) {
lv_obj_t* row = lv_obj_create(parent);
styleSurface(row, theme::SURFACE);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(row, LV_PCT(100), theme::ROW_H);
lv_obj_set_style_radius(row, theme::RADIUS, 0);
lv_obj_align(label(row, text, THEME_FONT_BODY, theme::TEXT), LV_ALIGN_TOP_LEFT, theme::PAD, hint ? 5 : 13);
if (hint) {
lv_obj_t* h = label(row, hint, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(h, LV_LABEL_LONG_DOT);
lv_obj_set_size(h, 150, 15);
lv_obj_align(h, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5);
}
return row;
}
static lv_obj_t* rowDropdown(lv_obj_t* row, const char* opts, int sel, int width, lv_event_cb_t cb, uintptr_t which) { static lv_obj_t* rowDropdown(lv_obj_t* row, const char* opts, int sel, int width, lv_event_cb_t cb, uintptr_t which) {
lv_obj_t* dd = lv_dropdown_create(row); lv_obj_t* dd = lv_dropdown_create(row);
@@ -170,12 +155,7 @@ void UITask::radioFreqPopup(bool repeater) {
using namespace radioview; using namespace radioview;
if (s_overlay) return; if (s_overlay) return;
s_freq_rpt = repeater; s_freq_rpt = repeater;
s_overlay = lv_obj_create(screen()); s_overlay = dimOverlay(screen());
lv_obj_remove_style_all(s_overlay);
lv_obj_set_size(s_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(s_overlay, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_overlay, LV_OPA_60, 0);
lv_obj_add_flag(s_overlay, LV_OBJ_FLAG_CLICKABLE);
lv_obj_t* panel = lv_obj_create(s_overlay); lv_obj_t* panel = lv_obj_create(s_overlay);
anim::popup(s_overlay); anim::popup(s_overlay);
lv_obj_set_size(panel, lv_display_get_horizontal_resolution(NULL) - 16, LV_SIZE_CONTENT); lv_obj_set_size(panel, lv_display_get_horizontal_resolution(NULL) - 16, LV_SIZE_CONTENT);
@@ -48,7 +48,6 @@ void UITask::showRepeater() {
void UITask::buildRepeater() { void UITask::buildRepeater() {
using namespace rptview; using namespace rptview;
using radioview::settingRow;
using radioview::rowDropdown; using radioview::rowDropdown;
lv_obj_t* body = newScreen("Repeater", true); lv_obj_t* body = newScreen("Repeater", true);
NodePrefs* p = _prefs; NodePrefs* p = _prefs;
@@ -268,7 +268,7 @@ void UITask::buildSoundRows(lv_obj_t* body, bool top) {
using namespace sndview; using namespace sndview;
if (top) { if (top) {
static const char* MODES[] = { "On", "Off", "Auto", "" }; static const char* MODES[] = { "On", "Off", "Auto", "" };
lv_obj_t* row = radioview::settingRow(body, "Sound", "Auto: off with the app"); lv_obj_t* row = settingRow(body, "Sound", "Auto: off with the app");
radioview::rowSegmented(row, MODES, soundctl::mode(_prefs), 150, onSoundMode, 0); radioview::rowSegmented(row, MODES, soundctl::mode(_prefs), 150, onSoundMode, 0);
return; return;
} }
+105 -100
View File
@@ -13,10 +13,14 @@
// this UI (all of it is this one file and the headers it includes) goes through // this UI (all of it is this one file and the headers it includes) goes through
// these, which swap each pair for the codepoint fonts/ui_emoji.c keeps the // these, which swap each pair for the codepoint fonts/ui_emoji.c keeps the
// flag's image under. Display only: what's stored and sent stays as it was. // flag's image under. Display only: what's stored and sent stays as it was.
// A label given the text it already shows is left alone: the status bar, the
// clocks and the live values are set every refresh, and each set is a redraw.
extern "C" char* ui_emoji_flags(const char* s); extern "C" char* ui_emoji_flags(const char* s);
static void ui_label_set_text(lv_obj_t* o, const char* t) { static void ui_label_set_text(lv_obj_t* o, const char* t) {
char* f = t ? ui_emoji_flags(t) : nullptr; // t == NULL: LVGL's "redraw the current text" char* f = t ? ui_emoji_flags(t) : nullptr; // t == NULL: LVGL's "redraw the current text"
lv_label_set_text(o, f ? f : t); const char* s = f ? f : t;
const char* cur = lv_label_get_text(o);
if (!s || !cur || strcmp(cur, s) != 0) lv_label_set_text(o, s);
lv_free(f); lv_free(f);
} }
static void ui_label_set_text_fmt(lv_obj_t* o, const char* fmt, ...) __attribute__((format(printf, 2, 3))); static void ui_label_set_text_fmt(lv_obj_t* o, const char* fmt, ...) __attribute__((format(printf, 2, 3)));
@@ -121,6 +125,59 @@ static lv_obj_t* label(lv_obj_t* parent, const char* text, const lv_font_t* font
return l; return l;
} }
// A list filling the rest of a flex column, scrolling on its own (the
// scrollbar only while it moves).
static lv_obj_t* scrollList(lv_obj_t* parent) {
lv_obj_t* l = lv_obj_create(parent);
styleSurface(l, theme::BG);
lv_obj_set_width(l, LV_PCT(100));
lv_obj_set_flex_grow(l, 1);
lv_obj_set_flex_flow(l, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(l, theme::GAP, 0);
lv_obj_set_scrollbar_mode(l, LV_SCROLLBAR_MODE_ACTIVE);
return l;
}
// The dimmed full-screen layer under a popup; it swallows taps on what's below.
static lv_obj_t* dimOverlay(lv_obj_t* parent) {
lv_obj_t* o = lv_obj_create(parent);
lv_obj_remove_style_all(o);
lv_obj_set_size(o, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(o, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(o, LV_OPA_60, 0);
lv_obj_add_flag(o, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE);
return o;
}
// A settings row: the label left (a muted hint under it, cut at `hint_w`),
// room on the right for a switch / dropdown / slider.
static lv_obj_t* settingRow(lv_obj_t* parent, const char* text, const char* hint, int hint_w = 150) {
lv_obj_t* row = lv_obj_create(parent);
styleSurface(row, theme::SURFACE);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(row, LV_PCT(100), theme::ROW_H);
lv_obj_set_style_radius(row, theme::RADIUS, 0);
lv_obj_align(label(row, text, THEME_FONT_BODY, theme::TEXT), LV_ALIGN_TOP_LEFT, theme::PAD, hint ? 5 : 13);
if (hint) {
lv_obj_t* h = label(row, hint, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(h, LV_LABEL_LONG_DOT);
lv_obj_set_size(h, hint_w, 15); // fixed height: cut, don't wrap onto the label
lv_obj_align(h, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5);
}
return row;
}
// A bare flex row / column sized to its content, taps passing through.
static lv_obj_t* flexBox(lv_obj_t* parent, lv_flex_flow_t flow) {
lv_obj_t* b = lv_obj_create(parent);
lv_obj_remove_style_all(b);
lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(b, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(b, flow);
return b;
}
// A one-of-N choice: a row (or grid, with "\n" in the map) of checkable // A one-of-N choice: a row (or grid, with "\n" in the map) of checkable
// buttons, `sel` checked (-1: none). `item` is the unchecked button colour — // buttons, `sel` checked (-1: none). `item` is the unchecked button colour —
// SURFACE on a SURFACE_2 panel, SURFACE_2 on the page. // SURFACE on a SURFACE_2 panel, SURFACE_2 on the page.
@@ -171,7 +228,6 @@ static void stylePrimary(lv_obj_t* b) {
lv_obj_set_style_text_color(lv_obj_get_child(b, i), lv_color_hex(theme::BG), 0); lv_obj_set_style_text_color(lv_obj_get_child(b, i), lv_color_hex(theme::BG), 0);
} }
// Unread badge: amber pill with a count, on the right edge of `parent`.
// "@[nick]" -> "@nick" in place, for one-line text (previews, quotes) where // "@[nick]" -> "@nick" in place, for one-line text (previews, quotes) where
// the bubble's highlight doesn't reach. // the bubble's highlight doesn't reach.
static void plainMentions(char* t) { static void plainMentions(char* t) {
@@ -189,6 +245,7 @@ static void plainMentions(char* t) {
*w = '\0'; *w = '\0';
} }
// Unread badge: amber pill with a count, on the right edge of `parent`.
static void badge(lv_obj_t* parent, int n, bool overflow) { static void badge(lv_obj_t* parent, int n, bool overflow) {
if (n <= 0) return; if (n <= 0) return;
lv_obj_t* b = lv_obj_create(parent); lv_obj_t* b = lv_obj_create(parent);
@@ -357,7 +414,11 @@ void UITask::loop() {
else if (btn_click) { if (!_asleep && !locked()) back(); } // the side button doesn't wake: pockets else if (btn_click) { if (!_asleep && !locked()) back(); } // the side button doesn't wake: pockets
if (_asleep) { if (_asleep) {
if (lvport::touched()) { lvport::swallowTouch(); if (_tap_wake) wake(); } // off: the top (WAKE) button only // Tap to wake (off: the top button only). An I2C read on the touch panel.
if (_tap_wake && (int32_t)(millis() - _next_touch_poll_ms) >= 0) {
_next_touch_poll_ms = millis() + 50;
if (lvport::touched()) { lvport::swallowTouch(); wake(); }
}
} else { } else {
uint32_t aoff = autoOffMillis(); uint32_t aoff = autoOffMillis();
if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging() && !otaBusy()) sleep(); if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging() && !otaBusy()) sleep();
@@ -438,6 +499,7 @@ void UITask::loop() {
navPollTrackBack(); navPollTrackBack();
} }
uint32_t lv_next = 0;
if (!_asleep) { if (!_asleep) {
if ((int32_t)(millis() - _next_status_ms) >= 0) { if ((int32_t)(millis() - _next_status_ms) >= 0) {
_next_status_ms = millis() + 1000; _next_status_ms = millis() + 1000;
@@ -479,8 +541,22 @@ void UITask::loop() {
uint32_t sig = threadSignature(); uint32_t sig = threadSignature();
if (_thread_dirty || sig != _thread_sig) refreshThread(); if (_thread_dirty || sig != _thread_sig) refreshThread();
} }
lv_timer_handler(); lv_next = lv_timer_handler();
} }
lvport::idle(idleMillis(lv_next));
}
// How long loop() may sleep: nothing is due before then. Not at all while the
// mesh has packets queued; briefly while a melody plays (its notes are timed
// here) or the app is connected (its frames are read one a pass).
uint32_t UITask::idleMillis(uint32_t lv_next) {
if (the_mesh.hasPendingWork()) return 0;
#ifdef PIN_BUZZER
if (_buzzer.isPlaying()) return 1;
#endif
if (isClientConnected()) return 2;
if (_asleep) return 20; // the buttons and tap to wake are polled every 50 ms
return lv_next < 10 ? lv_next : 10; // LVGL's next timer: a refresh, an animation, input
} }
void UITask::shutdown(bool restart) { void UITask::shutdown(bool restart) {
@@ -532,6 +608,7 @@ void UITask::sleep() {
if (_asleep) return; if (_asleep) return;
_asleep = true; _asleep = true;
if (_display) _display->turnOff(); if (_display) _display->turnOff();
lvport::powerSave(true, _tap_wake);
if ((_prefs && _prefs->auto_lock) || _pin[0]) lockScreen(); // Lock screen, or a screen PIN if ((_prefs && _prefs->auto_lock) || _pin[0]) lockScreen(); // Lock screen, or a screen PIN
} }
@@ -539,6 +616,7 @@ void UITask::wake() {
lv_display_trigger_activity(NULL); lv_display_trigger_activity(NULL);
if (!_asleep) return; if (!_asleep) return;
_asleep = false; _asleep = false;
lvport::powerSave(false, _tap_wake);
if (_display) _display->turnOn(); if (_display) _display->turnOn();
refreshStatusBar(); refreshStatusBar();
if (_screen == SCR_HOME) refreshHome(); if (_screen == SCR_HOME) refreshHome();
@@ -664,8 +742,9 @@ void UITask::refreshStatusBar() {
lv_label_set_text(_status_time, "--:--"); lv_label_set_text(_status_time, "--:--");
} }
// Battery: the icon, then % or volts per Settings > Battery display. // Battery: the icon, then % or volts per Settings > Battery display. The
uint16_t mv = getBattMilliVolts(); // smoothed reading (checkLowBattery): an ADC read blocks the loop for 10 ms.
uint16_t mv = _batt_mv ? _batt_mv : getBattMilliVolts();
int pct = battery::percent(mv, _prefs ? _prefs->low_batt_mv : 0); 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 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; : pct > 30 ? LV_SYMBOL_BATTERY_2 : pct > 10 ? LV_SYMBOL_BATTERY_1 : LV_SYMBOL_BATTERY_EMPTY;
@@ -769,6 +848,7 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
_nav_overlay = _nav_ta = _nav_kb = _nav_del_lbl = _nav_rec = _nav_avg_pill = nullptr; _nav_overlay = _nav_ta = _nav_kb = _nav_del_lbl = _nav_rec = _nav_avg_pill = nullptr;
_nav_trail_lbl = _nav_trail_btn = _nav_reset_lbl = _nav_share_lbl = _nav_share_btn = _nav_tb_btn = nullptr; _nav_trail_lbl = _nav_trail_btn = _nav_reset_lbl = _nav_share_lbl = _nav_share_btn = _nav_tb_btn = nullptr;
_wifi_ssid = _wifi_pass = _wifi_kb = _wifi_list = _wifi_status = nullptr; _wifi_ssid = _wifi_pass = _wifi_kb = _wifi_list = _wifi_status = nullptr;
if (_wifi_scanning && !wifiInUse()) lvport::netEnd(); // left mid-scan: the radio goes off
_wifi_scanning = false; _wifi_scanning = false;
_ota_status = _ota_bar = _ota_btn = _ota_btn_lbl = nullptr; _ota_status = _ota_bar = _ota_btn = _ota_btn_lbl = nullptr;
for (lv_obj_t*& t : _map_tiles) t = nullptr; for (lv_obj_t*& t : _map_tiles) t = nullptr;
@@ -1071,7 +1151,7 @@ void UITask::setHomePage(int page) {
else lv_obj_remove_flag(home::s_top, LV_OBJ_FLAG_HIDDEN); else lv_obj_remove_flag(home::s_top, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_flex_flow(home::s_row, favs ? LV_FLEX_FLOW_ROW_WRAP : LV_FLEX_FLOW_ROW); lv_obj_set_flex_flow(home::s_row, favs ? LV_FLEX_FLOW_ROW_WRAP : LV_FLEX_FLOW_ROW);
if (favs) { if (favs) {
home::s_fav_sig = homeUnreadTotal(); home::s_fav_sig = unreadTotal();
favGrid(home::s_row); // DeviceScreen.h favGrid(home::s_row); // DeviceScreen.h
} }
int first = (page - home::MAIN) * home::PER_PAGE; int first = (page - home::MAIN) * home::PER_PAGE;
@@ -1092,7 +1172,7 @@ void UITask::setHomePage(int page) {
refreshHome(); refreshHome();
} }
int UITask::homeUnreadTotal() { int UITask::unreadTotal() {
return _core->dmUnreadTotal() + _core->history.getTotalChannelUnread() + _core->roomUnread(); return _core->dmUnreadTotal() + _core->history.getTotalChannelUnread() + _core->roomUnread();
} }
@@ -1109,12 +1189,12 @@ void UITask::refreshHome() {
lv_label_set_text(_home_clock, "--:--"); lv_label_set_text(_home_clock, "--:--");
lv_label_set_text(_home_date, "time not synced"); lv_label_set_text(_home_date, "time not synced");
} }
if (home::s_page == home::FAVS && home::s_row && homeUnreadTotal() != home::s_fav_sig) { if (home::s_page == home::FAVS && home::s_row && unreadTotal() != home::s_fav_sig) {
setHomePage(home::FAVS); // new messages: the favourites' badges redrawn setHomePage(home::FAVS); // new messages: the favourites' badges redrawn
return; return;
} }
if (!_home_unread) return; if (!_home_unread) return;
int unread = homeUnreadTotal(); int unread = unreadTotal();
if (unread > 0) lv_label_set_text_fmt(_home_unread, "%d", unread); if (unread > 0) lv_label_set_text_fmt(_home_unread, "%d", unread);
else lv_label_set_text(_home_unread, ""); else lv_label_set_text(_home_unread, "");
} }
@@ -1210,7 +1290,7 @@ static lv_obj_t* headerButton(lv_obj_t* hdr, const char* text, lv_event_cb_t cb,
void UITask::buildChats() { void UITask::buildChats() {
lv_obj_t* body = newScreen("Messages", true); lv_obj_t* body = newScreen("Messages", true);
if (_header && _core->dmUnreadTotal() + _core->history.getTotalChannelUnread() + _core->roomUnread() > 0) if (_header && unreadTotal() > 0)
headerButton(_header, LV_SYMBOL_OK " Read all", onMarkAllRead, 4, NULL); headerButton(_header, LV_SYMBOL_OK " Read all", onMarkAllRead, 4, NULL);
// Channels: favourites first (unless turned off), hold a row for its options // Channels: favourites first (unless turned off), hold a row for its options
@@ -1299,6 +1379,7 @@ void UITask::buildChats() {
if (_core->history.dmHistCountForContact(c.id.pub_key) > 0) { if (_core->history.dmHistCountForContact(c.id.pub_key) > 0) {
const DmHistEntry& e = _core->history.dmAtPos(_core->history.dmHistEntryForContact(c.id.pub_key, 0)); const DmHistEntry& e = _core->history.dmAtPos(_core->history.dmHistEntryForContact(c.id.pub_key, 0));
snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text); snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text);
plainMentions(sub);
} else { } else {
snprintf(sub, sizeof(sub), "%s", _core->rooms.isLoggedIn(c.id.pub_key) ? "Logged in" : "Tap to log in"); snprintf(sub, sizeof(sub), "%s", _core->rooms.isLoggedIn(c.id.pub_key) ? "Logged in" : "Tap to log in");
} }
@@ -1418,13 +1499,7 @@ void UITask::buildNearby() {
_nearby_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); _nearby_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
_nearby_list = lv_obj_create(body); _nearby_list = scrollList(body);
styleSurface(_nearby_list, theme::BG);
lv_obj_set_width(_nearby_list, LV_PCT(100));
lv_obj_set_flex_grow(_nearby_list, 1);
lv_obj_set_flex_flow(_nearby_list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(_nearby_list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(_nearby_list, LV_SCROLLBAR_MODE_ACTIVE);
_nearby_sig = 0; _nearby_sig = 0;
refreshNearbyList(); refreshNearbyList();
@@ -1454,13 +1529,7 @@ void UITask::startNearbyScan() {
// Dimmed full-screen overlay (swallows taps) holding a panel with the results. // Dimmed full-screen overlay (swallows taps) holding a panel with the results.
// A child of the current screen, so it goes away with it. // A child of the current screen, so it goes away with it.
void UITask::showScanPopup() { void UITask::showScanPopup() {
_scan_overlay = lv_obj_create(screen()); _scan_overlay = dimOverlay(screen());
lv_obj_remove_style_all(_scan_overlay);
lv_obj_set_size(_scan_overlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_color(_scan_overlay, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(_scan_overlay, LV_OPA_60, 0);
lv_obj_add_flag(_scan_overlay, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(_scan_overlay, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* panel = lv_obj_create(_scan_overlay); lv_obj_t* panel = lv_obj_create(_scan_overlay);
anim::popup(_scan_overlay); anim::popup(_scan_overlay);
@@ -1485,13 +1554,7 @@ void UITask::showScanPopup() {
headerButton(hdr, LV_SYMBOL_REFRESH " Again", onNearbyScan, 44, NULL); headerButton(hdr, LV_SYMBOL_REFRESH " Again", onNearbyScan, 44, NULL);
_scan_status = label(panel, "", THEME_FONT_SMALL, theme::TEXT_MUTED); _scan_status = label(panel, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
_scan_list = lv_obj_create(panel); _scan_list = scrollList(panel);
styleSurface(_scan_list, theme::BG);
lv_obj_set_width(_scan_list, LV_PCT(100));
lv_obj_set_flex_grow(_scan_list, 1);
lv_obj_set_flex_flow(_scan_list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(_scan_list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(_scan_list, LV_SCROLLBAR_MODE_ACTIVE);
_scan_sig = 0; _scan_sig = 0;
refreshScanPopup(); refreshScanPopup();
@@ -1805,7 +1868,6 @@ void UITask::nodeAction(uint8_t action) {
char t[48]; char t[48];
if (_core->waypoints.add(e.lat_e6, e.lon_e6, rtc_clock.getCurrentTime(), e.name[0] ? e.name : "Node")) { if (_core->waypoints.add(e.lat_e6, e.lon_e6, rtc_clock.getCurrentTime(), e.name[0] ? e.name : "Node")) {
snprintf(t, sizeof(t), "Saved %s", _core->waypoints.at(_core->waypoints.count() - 1).label); snprintf(t, sizeof(t), "Saved %s", _core->waypoints.at(_core->waypoints.count() - 1).label);
the_mesh.savePrefs();
showToast(t); showToast(t);
} }
break; break;
@@ -1931,14 +1993,8 @@ void UITask::buildThread() {
headerButton(_header, LV_SYMBOL_SETTINGS, _thread_is_channel ? onChanThreadMenu : onConvThreadMenu, 4, NULL); headerButton(_header, LV_SYMBOL_SETTINGS, _thread_is_channel ? onChanThreadMenu : onConvThreadMenu, 4, NULL);
} }
_thread_list = lv_obj_create(body); _thread_list = scrollList(body);
styleSurface(_thread_list, theme::BG);
lv_obj_set_width(_thread_list, LV_PCT(100));
lv_obj_set_flex_grow(_thread_list, 1);
lv_obj_set_flex_flow(_thread_list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(_thread_list, theme::PAD, 0); lv_obj_set_style_pad_all(_thread_list, theme::PAD, 0);
lv_obj_set_style_pad_row(_thread_list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(_thread_list, LV_SCROLLBAR_MODE_ACTIVE); // only while scrolling
_compose_ta = nullptr; _compose_ta = nullptr;
_keyboard = nullptr; _keyboard = nullptr;
@@ -1990,7 +2046,7 @@ uint32_t UITask::threadSignature() const {
sig = n; sig = n;
for (int j = 0; j < n && j < 8; j++) { for (int j = 0; j < n && j < 8; j++) {
const ChHistEntry& e = h.chAtPos(h.histEntryForChannel(_thread_channel, j)); const ChHistEntry& e = h.chAtPos(h.histEntryForChannel(_thread_channel, j));
sig = sig * 31 + e.timestamp + e.relay_status; sig = sig * 31 + e.timestamp + e.relay_status + e.path_len; // path_len: repeaters heard
} }
} else { } else {
int n = h.dmHistCountForContact(_thread_key); int n = h.dmHistCountForContact(_thread_key);
@@ -2140,13 +2196,7 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own,
// the bubble two lines tall -- matters with the keyboard up. // the bubble two lines tall -- matters with the keyboard up.
bool meta_in_header = from && from[0] && !status; bool meta_in_header = from && from[0] && !status;
if (from && from[0]) { if (from && from[0]) {
lv_obj_t* hdr = lv_obj_create(b); lv_obj_t* hdr = flexBox(b, LV_FLEX_FLOW_ROW);
styleSurface(hdr, theme::BG);
lv_obj_set_style_bg_opa(hdr, LV_OPA_TRANSP, 0);
lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(hdr, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(hdr, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(hdr, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_column(hdr, 8, 0); lv_obj_set_style_pad_column(hdr, 8, 0);
label(hdr, from, THEME_FONT_SMALL, theme::ACCENT); // names are <= 31 chars: fits the bubble label(hdr, from, THEME_FONT_SMALL, theme::ACCENT); // names are <= 31 chars: fits the bubble
if (meta_in_header) label(hdr, meta, THEME_FONT_SMALL, theme::TEXT_MUTED); if (meta_in_header) label(hdr, meta, THEME_FONT_SMALL, theme::TEXT_MUTED);
@@ -2159,13 +2209,7 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own,
} }
if (loc_idx >= 0) { // position: navigate there / keep it as a waypoint if (loc_idx >= 0) { // position: navigate there / keep it as a waypoint
lv_obj_t* acts = lv_obj_create(b); lv_obj_t* acts = flexBox(b, LV_FLEX_FLOW_ROW);
styleSurface(acts, theme::BG);
lv_obj_set_style_bg_opa(acts, LV_OPA_TRANSP, 0);
lv_obj_remove_flag(acts, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(acts, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(acts, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(acts, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_column(acts, 6, 0); lv_obj_set_style_pad_column(acts, 6, 0);
lv_obj_set_style_pad_top(acts, 2, 0); lv_obj_set_style_pad_top(acts, 2, 0);
msgLocButton(acts, UI_SYMBOL_COMPASS " Go", loc_idx, false, true); msgLocButton(acts, UI_SYMBOL_COMPASS " Go", loc_idx, false, true);
@@ -2173,11 +2217,7 @@ static void bubble(lv_obj_t* list, const char* from, const char* text, bool own,
} }
if (!meta_in_header) { // the age, then the delivery mark in its colour (as L1) if (!meta_in_header) { // the age, then the delivery mark in its colour (as L1)
lv_obj_t* line = lv_obj_create(b); lv_obj_t* line = flexBox(b, LV_FLEX_FLOW_ROW);
lv_obj_remove_style_all(line);
lv_obj_remove_flag(line, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(line, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(line, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(line, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_flex_align(line, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(line, 6, 0); lv_obj_set_style_pad_column(line, 6, 0);
label(line, meta, THEME_FONT_SMALL, theme::TEXT_MUTED); label(line, meta, THEME_FONT_SMALL, theme::TEXT_MUTED);
@@ -2399,13 +2439,7 @@ static void onKeyboardAlphabet(lv_event_t* e) {
// One settings row: label left, dropdown right. // One settings row: label left, dropdown right.
static lv_obj_t* dropdownRow(lv_obj_t* parent, const char* text, const char* options, int sel) { static lv_obj_t* dropdownRow(lv_obj_t* parent, const char* text, const char* options, int sel) {
lv_obj_t* row = lv_obj_create(parent); lv_obj_t* row = settingRow(parent, text, NULL);
styleSurface(row, theme::SURFACE);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(row, LV_PCT(100), theme::ROW_H);
lv_obj_set_style_radius(row, theme::RADIUS, 0);
lv_obj_t* l = label(row, text, THEME_FONT_BODY, theme::TEXT);
lv_obj_align(l, LV_ALIGN_LEFT_MID, theme::PAD, 0);
lv_obj_t* dd = lv_dropdown_create(row); lv_obj_t* dd = lv_dropdown_create(row);
lv_dropdown_set_options_static(dd, options); lv_dropdown_set_options_static(dd, options);
lv_dropdown_set_selected(dd, sel); lv_dropdown_set_selected(dd, sel);
@@ -2428,19 +2462,7 @@ static void onGpsSwitch(lv_event_t* e) {
// pref == nullptr: the caller wires the switch itself. // pref == nullptr: the caller wires the switch itself.
static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref) { static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref) {
lv_obj_t* row = lv_obj_create(parent); lv_obj_t* row = settingRow(parent, text, sub, 236); // the hint clear of the switch
styleSurface(row, theme::SURFACE);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(row, LV_PCT(100), theme::ROW_H);
lv_obj_set_style_radius(row, theme::RADIUS, 0);
lv_obj_t* l = label(row, text, THEME_FONT_BODY, theme::TEXT);
lv_obj_align(l, LV_ALIGN_TOP_LEFT, theme::PAD, sub ? 5 : 13);
if (sub) {
lv_obj_t* h = label(row, sub, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(h, LV_LABEL_LONG_DOT);
lv_obj_set_size(h, 236, 15); // clear of the switch
lv_obj_align(h, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5);
}
lv_obj_t* sw = lv_switch_create(row); lv_obj_t* sw = lv_switch_create(row);
lv_obj_set_size(sw, 46, 24); lv_obj_set_size(sw, 46, 24);
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0); lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
@@ -2574,7 +2596,7 @@ void UITask::schemaRows(lv_obj_t* body, uint8_t page) {
} }
uint8_t v = settings::get(*_prefs, st); uint8_t v = settings::get(*_prefs, st);
if (st.offset == offsetof(NodePrefs, buzzer_volume)) { // a five-step slider, heard on release if (st.offset == offsetof(NodePrefs, buzzer_volume)) { // a five-step slider, heard on release
lv_obj_t* row = radioview::settingRow(body, st.label, NULL); lv_obj_t* row = settingRow(body, st.label, NULL);
lv_obj_t* sl = lv_slider_create(row); lv_obj_t* sl = lv_slider_create(row);
lv_slider_set_range(sl, 0, 4); lv_slider_set_range(sl, 0, 4);
lv_slider_set_value(sl, v, LV_ANIM_OFF); lv_slider_set_value(sl, v, LV_ANIM_OFF);
@@ -2585,12 +2607,7 @@ void UITask::schemaRows(lv_obj_t* body, uint8_t page) {
continue; continue;
} }
if (st.offset == offsetof(NodePrefs, display_brightness)) { // a slider here instead of five steps if (st.offset == offsetof(NodePrefs, display_brightness)) { // a slider here instead of five steps
lv_obj_t* row = lv_obj_create(body); lv_obj_t* row = settingRow(body, st.label, NULL);
styleSurface(row, theme::SURFACE);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(row, LV_PCT(100), theme::ROW_H);
lv_obj_set_style_radius(row, theme::RADIUS, 0);
lv_obj_align(label(row, st.label, THEME_FONT_BODY, theme::TEXT), LV_ALIGN_LEFT_MID, theme::PAD, 0);
lv_obj_t* sl = lv_slider_create(row); lv_obj_t* sl = lv_slider_create(row);
lv_slider_set_range(sl, 5, 100); lv_slider_set_range(sl, 5, 100);
uint8_t pct = _prefs->display_brightness_pct ? _prefs->display_brightness_pct uint8_t pct = _prefs->display_brightness_pct ? _prefs->display_brightness_pct
@@ -2617,19 +2634,7 @@ void UITask::schemaRows(lv_obj_t* body, uint8_t page) {
o += strlen(opts + o); o += strlen(opts + o);
} }
opts[o] = '\0'; opts[o] = '\0';
lv_obj_t* row = lv_obj_create(body); lv_obj_t* row = settingRow(body, st.label, st.hint, 176);
styleSurface(row, theme::SURFACE);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(row, LV_PCT(100), theme::ROW_H);
lv_obj_set_style_radius(row, theme::RADIUS, 0);
lv_obj_t* l = label(row, st.label, THEME_FONT_BODY, theme::TEXT);
lv_obj_align(l, LV_ALIGN_TOP_LEFT, theme::PAD, st.hint ? 5 : 13);
if (st.hint) {
lv_obj_t* h = label(row, st.hint, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(h, LV_LABEL_LONG_DOT);
lv_obj_set_size(h, 176, 15); // fixed height: cut, don't wrap onto the label
lv_obj_align(h, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5);
}
lv_obj_t* dd = lv_dropdown_create(row); lv_obj_t* dd = lv_dropdown_create(row);
lv_dropdown_set_options(dd, opts); lv_dropdown_set_options(dd, opts);
lv_dropdown_set_selected(dd, v); lv_dropdown_set_selected(dd, v);
+6 -4
View File
@@ -235,7 +235,6 @@ public:
void pinRowRefresh(); void pinRowRefresh();
void accentRow(lv_obj_t* body); // Settings > Display & power > Accent colour void accentRow(lv_obj_t* body); // Settings > Display & power > Accent colour
void setAccent(int idx); void setAccent(int idx);
void showFavourites();
void showDiag(); void showDiag();
void diagTab(int tab); void diagTab(int tab);
void diagResetPopup(); void diagResetPopup();
@@ -252,7 +251,7 @@ public:
// Kept for main.cpp's sim hooks (ui-new API). // Kept for main.cpp's sim hooks (ui-new API).
void openContactDM(const ContactInfo& ci) { openDM(ci.id.pub_key); } void openContactDM(const ContactInfo& ci) { openDM(ci.id.pub_key); }
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; } void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)from_picker; openAdmin(ci.id.pub_key); }
private: 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_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS }; 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_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS };
@@ -278,6 +277,7 @@ private:
void otaTick(); void otaTick();
void otaLeave(); void otaLeave();
bool otaBusy() const; bool otaBusy() const;
bool wifiInUse() const; // a map download or an update holds the WiFi
void buildNearby(); void buildNearby();
void refreshNearbyList(); void refreshNearbyList();
uint32_t nearbySignature() const; uint32_t nearbySignature() const;
@@ -344,7 +344,7 @@ private:
void lockPoll(); void lockPoll();
void favGrid(lv_obj_t* grid); void favGrid(lv_obj_t* grid);
void favRefresh(); void favRefresh();
int homeUnreadTotal(); int unreadTotal(); // DMs, channels and rooms
void buildDiag(); void buildDiag();
void refreshDiag(); void refreshDiag();
void buildCompass(); void buildCompass();
@@ -363,8 +363,10 @@ private:
void sleep(); void sleep();
void toggleMute(); void toggleMute();
uint32_t _next_wake_poll_ms = 0; uint32_t _next_wake_poll_ms = 0;
uint32_t _next_touch_poll_ms = 0; // tap to wake, while the screen is off
bool _wake_down = false; bool _wake_down = false;
uint32_t autoOffMillis() const; uint32_t autoOffMillis() const;
uint32_t idleMillis(uint32_t lv_next);
void checkLowBattery(); void checkLowBattery();
#ifdef PIN_BUZZER #ifdef PIN_BUZZER
@@ -402,7 +404,7 @@ private:
uint32_t _map_left_ms = 0; // when the map was left, for dropping live tiles' WiFi uint32_t _map_left_ms = 0; // when the map was left, for dropping live tiles' WiFi
bool _admin_from_pick = false; // Admin opened from its Home tile (back returns there) bool _admin_from_pick = false; // Admin opened from its Home tile (back returns there)
uint32_t _prune_armed_ms = 0; uint32_t _prune_armed_ms = 0;
uint16_t _batt_mv = 0; // smoothed, for the low-battery shutdown uint16_t _batt_mv = 0; // smoothed, read every 8 s: the status bar and the low-battery shutdown
uint32_t _next_batt_ms = 0; uint32_t _next_batt_ms = 0;
lv_obj_t* _prune_lbl = nullptr; lv_obj_t* _prune_lbl = nullptr;
// Screen PIN (DeviceScreen.h); stored in NVS (lvport::loadPin) // Screen PIN (DeviceScreen.h); stored in NVS (lvport::loadPin)
@@ -175,7 +175,7 @@ void UITask::pollWifiScan() {
} }
if (n > 0) lv_obj_remove_flag(_wifi_list, LV_OBJ_FLAG_HIDDEN); if (n > 0) lv_obj_remove_flag(_wifi_list, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(_wifi_status, n > 0 ? "Tap a network, then enter its password." : "No networks found."); lv_label_set_text(_wifi_status, n > 0 ? "Tap a network, then enter its password." : "No networks found.");
if (!mapview::s_dl.active()) lvport::netEnd(); // the scan switched the radio on if (!wifiInUse()) lvport::netEnd(); // the scan switched the radio on
} }
void UITask::wifiPick(int idx) { void UITask::wifiPick(int idx) {
+32 -2
View File
@@ -14,6 +14,11 @@ static const uint8_t ADS_REG_CONFIG = 0x01;
static const uint16_t ADS_CFG_BATT = 0x8000 | 0x4000 | 0x0200 | 0x0100 | 0x0080 | 0x0003; static const uint16_t ADS_CFG_BATT = 0x8000 | 0x4000 | 0x0200 | 0x0100 | 0x0080 | 0x0003;
// +/-4.096V FSR -> 125uV/LSB; battery is behind a x2 divider -> 0.25 mV/LSB // +/-4.096V FSR -> 125uV/LSB; battery is behind a x2 divider -> 0.25 mV/LSB
static const float ADS_MV_PER_LSB = 0.125f * 2.0f; static const float ADS_MV_PER_LSB = 0.125f * 2.0f;
static const uint32_t BATT_SETTLE_MS = 1; // divider on -> input settled (measured: at once)
static const uint32_t BATT_REUSE_MS = 2000; // the UI, diagnostics and the app all ask
// GT911: its command register; 0x05 = sleep
static const uint8_t GT911_ADDR = 0x5D;
bool WioTrackerL2Board::expWriteReg(uint8_t reg, uint8_t val) { bool WioTrackerL2Board::expWriteReg(uint8_t reg, uint8_t val) {
Wire.beginTransmission(TCA9535_ADDR); Wire.beginTransmission(TCA9535_ADDR);
@@ -64,7 +69,7 @@ bool WioTrackerL2Board::initExpander() {
delay(10); delay(10);
expSetOutput(EXP_PIN_TF_EN, HIGH); expSetOutput(EXP_PIN_TF_EN, HIGH);
delay(10); delay(10);
expSetOutput(EXP_PIN_BAT_ADC_EN, HIGH); expSetOutput(EXP_PIN_BAT_ADC_EN, LOW); // the divider only draws while a reading is taken
delay(10); delay(10);
expSetOutput(EXP_PIN_GNSS_EN, HIGH); expSetOutput(EXP_PIN_GNSS_EN, HIGH);
delay(10); delay(10);
@@ -216,15 +221,40 @@ int16_t WioTrackerL2Board::adsReadRaw() {
// A failed or zero ADS1115 read (the I2C bus busy or the ADC not answering) // A failed or zero ADS1115 read (the I2C bus busy or the ADC not answering)
// keeps the last good value instead of reporting 0 mV -- that read as 0 % in // keeps the last good value instead of reporting 0 mV -- that read as 0 % in
// the status bar and could trip the low-battery shutdown. Retried once first. // the status bar and could trip the low-battery shutdown. Retried once first.
// The divider is powered only around the reading; one taken in the last 2 s
// is reused.
uint16_t WioTrackerL2Board::getBattMilliVolts() { uint16_t WioTrackerL2Board::getBattMilliVolts() {
if (!expander_ok) return 0; // BAT_ADC_EN rail never came up if (!expander_ok) return 0; // BAT_ADC_EN rail never came up
if (batt_mv_last && millis() - batt_read_ms < BATT_REUSE_MS) return batt_mv_last;
expWritePin(EXP_PIN_BAT_ADC_EN, HIGH);
delay(BATT_SETTLE_MS);
for (int attempt = 0; attempt < 2; attempt++) { for (int attempt = 0; attempt < 2; attempt++) {
int16_t raw = adsReadRaw(); int16_t raw = adsReadRaw();
if (raw > 0) { if (raw > 0) {
batt_mv_last = (uint16_t)(raw * ADS_MV_PER_LSB); batt_mv_last = (uint16_t)(raw * ADS_MV_PER_LSB);
return batt_mv_last; break;
} }
} }
expWritePin(EXP_PIN_BAT_ADC_EN, LOW);
batt_read_ms = millis();
return batt_mv_last; return batt_mv_last;
} }
void WioTrackerL2Board::touchSleep() {
if (!expander_ok) return;
expWritePin(EXP_PIN_TP_INT, LOW); // held low while it sleeps (it is, from the reset sequence on)
Wire.beginTransmission(GT911_ADDR);
Wire.write((uint8_t)0x80);
Wire.write((uint8_t)0x40);
Wire.write((uint8_t)0x05);
Wire.endTransmission();
}
void WioTrackerL2Board::touchWake() {
if (!expander_ok) return;
expWritePin(EXP_PIN_TP_INT, HIGH); // 2-5 ms high wakes it
delay(5);
expWritePin(EXP_PIN_TP_INT, LOW);
}
+11 -3
View File
@@ -20,7 +20,7 @@
#define ADS1115_ADDR 0x48 #define ADS1115_ADDR 0x48
// TCA9535 pin numbering: 0..7 = port 0 (P00..P07), 8..15 = port 1 (P10..P17) // TCA9535 pin numbering: 0..7 = port 0 (P00..P07), 8..15 = port 1 (P10..P17)
#define EXP_PIN_WAKE_BTN 0 // input - side WAKE button #define EXP_PIN_WAKE_BTN 0 // input - WAKE button (top edge)
#define EXP_PIN_I2C_IRQ 1 // input - shared I2C IRQ #define EXP_PIN_I2C_IRQ 1 // input - shared I2C IRQ
#define EXP_PIN_SD_DETECT 2 // input - microSD card detect #define EXP_PIN_SD_DETECT 2 // input - microSD card detect
#define EXP_PIN_TP_INT 3 // output - touch panel interrupt (driven for reset seq) #define EXP_PIN_TP_INT 3 // output - touch panel interrupt (driven for reset seq)
@@ -57,8 +57,6 @@ public:
// antenna away from class-D switching noise and to save battery // antenna away from class-D switching noise and to save battery
void setSpeakerAmp(bool on) { expWritePin(EXP_PIN_PA_EN, on); } void setSpeakerAmp(bool on) { expWritePin(EXP_PIN_PA_EN, on); }
// hardware reset pulse to the L76K GNSS (active HIGH on this board); the
// module keeps almanac/ephemeris across it, so this is a warm restart
// GNSS rail: off saves the whole receiver when GPS is disabled; on re-runs // GNSS rail: off saves the whole receiver when GPS is disabled; on re-runs
// the power-up reset so the module comes back cleanly // the power-up reset so the module comes back cleanly
void setGnssPower(bool on) { void setGnssPower(bool on) {
@@ -73,6 +71,10 @@ public:
// Grove expansion port rail (nothing on-board depends on it) // Grove expansion port rail (nothing on-board depends on it)
void setGrovePower(bool on) { expWritePin(EXP_PIN_GROVE_EN, on); } void setGrovePower(bool on) { expWritePin(EXP_PIN_GROVE_EN, on); }
bool gnssPowered() const { return (out_shadow[EXP_PIN_GNSS_EN >> 3] >> (EXP_PIN_GNSS_EN & 7)) & 1; }
// hardware reset pulse to the L76K GNSS (active HIGH on this board); the
// module keeps almanac/ephemeris across it, so this is a warm restart
void gnssReset() { void gnssReset() {
expWritePin(EXP_PIN_GNSS_RST, HIGH); expWritePin(EXP_PIN_GNSS_RST, HIGH);
delay(10); delay(10);
@@ -82,6 +84,11 @@ public:
// WAKE button on expander P00: pressed = level differs from boot baseline // WAKE button on expander P00: pressed = level differs from boot baseline
bool readWakeButton(); bool readWakeButton();
// GT911 touch controller: sleep (a few mA -> tens of uA) while the screen is
// off and a touch shouldn't wake it; wake with a pulse on its INT line.
void touchSleep();
void touchWake();
// VBUS presence via the AW35615 USB-C controller (I2C 0x22) // VBUS presence via the AW35615 USB-C controller (I2C 0x22)
bool isExternalPowered() override; bool isExternalPowered() override;
int awRead(uint8_t reg); // AW35615 register, -1 on error int awRead(uint8_t reg); // AW35615 register, -1 on error
@@ -95,6 +102,7 @@ private:
bool aw_ok = false; // AW35615 USB-C controller responded at probe bool aw_ok = false; // AW35615 USB-C controller responded at probe
uint8_t wake_btn_baseline = 0; // idle level of P00, captured at init uint8_t wake_btn_baseline = 0; // idle level of P00, captured at init
uint16_t batt_mv_last = 0; // last good battery reading (a failed read keeps it) uint16_t batt_mv_last = 0; // last good battery reading (a failed read keeps it)
uint32_t batt_read_ms = 0; // when it was taken (reads closer together reuse it)
int expReadInputs(); // 16-bit input register pair, -1 on error int expReadInputs(); // 16-bit input register pair, -1 on error
+18 -7
View File
@@ -33,6 +33,7 @@ class WioTrackerL2Backlight : public lgfx::v1::ILight {
static constexpr uint8_t REG_LED0_PWM = 0x18; static constexpr uint8_t REG_LED0_PWM = 0x18;
uint8_t _brightness = 153; // 60% uint8_t _brightness = 153; // 60%
bool _standby = false; // chip disabled (the screen is off)
void writeReg(uint8_t reg, uint8_t value) { void writeReg(uint8_t reg, uint8_t value) {
Wire.beginTransmission(LP5814_I2C_ADDR); Wire.beginTransmission(LP5814_I2C_ADDR);
@@ -41,13 +42,7 @@ class WioTrackerL2Backlight : public lgfx::v1::ILight {
Wire.endTransmission(); Wire.endTransmission();
} }
public: void configure() {
bool init(uint8_t brightness) override {
Wire.beginTransmission(LP5814_I2C_ADDR);
if (Wire.endTransmission() != 0) {
return false; // LP5814 not found
}
writeReg(REG_DEVICE_CONFIG0, 0x01); // chip enable writeReg(REG_DEVICE_CONFIG0, 0x01); // chip enable
writeReg(REG_MAX_CURRENT, 0x01); // 51 mA max current writeReg(REG_MAX_CURRENT, 0x01); // 51 mA max current
writeReg(REG_ENABLE_CONTROL, 0x00); // outputs off while configuring writeReg(REG_ENABLE_CONTROL, 0x00); // outputs off while configuring
@@ -59,12 +54,28 @@ public:
writeReg(REG_ENABLE_CONTROL, 0x0F); // enable all 4 channels writeReg(REG_ENABLE_CONTROL, 0x0F); // enable all 4 channels
writeReg(REG_UPDATE, 0x55); // latch (LP5814 requires 0x55) writeReg(REG_UPDATE, 0x55); // latch (LP5814 requires 0x55)
delay(5); delay(5);
}
public:
bool init(uint8_t brightness) override {
Wire.beginTransmission(LP5814_I2C_ADDR);
if (Wire.endTransmission() != 0) {
return false; // LP5814 not found
}
configure();
setBrightness(brightness); setBrightness(brightness);
return true; return true;
} }
// 0 (LovyanGFX's sleep()) puts the chip in standby; the next level brings it
// back, set up again.
void setBrightness(uint8_t brightness) override { void setBrightness(uint8_t brightness) override {
if (brightness == 0) {
writeReg(REG_DEVICE_CONFIG0, 0x00); // chip disable: standby
_standby = true;
return;
}
if (_standby) { configure(); _standby = false; }
for (uint8_t i = 0; i < 4; i++) { for (uint8_t i = 0; i < 4; i++) {
writeReg(REG_LED0_PWM + i, brightness); writeReg(REG_LED0_PWM + i, brightness);
} }
+23 -1
View File
@@ -10,7 +10,7 @@ WRAPPER_CLASS radio_driver(radio, board);
ESP32RTCClock fallback_clock; ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock); AutoDiscoverRTCClock rtc_clock(fallback_clock);
MicroNMEALocationProvider gps(Serial1, &rtc_clock); L2GpsProvider gps(Serial1, &rtc_clock);
EnvironmentSensorManager sensors(gps); EnvironmentSensorManager sensors(gps);
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
@@ -22,6 +22,28 @@ EnvironmentSensorManager sensors(gps);
void buzzerAmpPower(bool on) { board.setSpeakerAmp(on); } void buzzerAmpPower(bool on) { board.setSpeakerAmp(on); }
#endif #endif
void L2GpsProvider::begin() {
board.setGnssPower(true); // with the power-up reset
if (_uart_off) { // back from stop(): the UART again
Serial1.setRxBufferSize(1024);
Serial1.begin(GPS_BAUD_RATE, SERIAL_8N1, PIN_GPS_TX, PIN_GPS_RX);
_uart_off = false;
}
MicroNMEALocationProvider::begin();
}
void L2GpsProvider::stop() {
MicroNMEALocationProvider::stop();
// The UART's TX idling high would feed the unpowered module through its pin.
Serial1.end();
pinMode(PIN_GPS_RX, INPUT); // PIN_GPS_RX: the module's RX, our TX
_uart_off = true;
board.setGnssPower(false);
}
void L2GpsProvider::reset() { board.gnssReset(); }
bool L2GpsProvider::isEnabled() { return board.gnssPowered(); }
bool radio_init() { bool radio_init() {
MESH_DEBUG_PRINTLN("radio_init: rtc + sx1262 init"); MESH_DEBUG_PRINTLN("radio_init: rtc + sx1262 init");
fallback_clock.begin(); fallback_clock.begin();
+14 -1
View File
@@ -14,11 +14,24 @@
#include <helpers/ui/MomentaryButton.h> #include <helpers/ui/MomentaryButton.h>
#endif #endif
// The L76K's power and reset lines are on the IO expander, out of the NMEA
// provider's reach: begin() / stop() / reset() drive them here, so GPS off
// (Settings, duty cycling) really powers the receiver down.
class L2GpsProvider : public MicroNMEALocationProvider {
bool _uart_off = false;
public:
using MicroNMEALocationProvider::MicroNMEALocationProvider;
void begin() override;
void stop() override;
void reset() override;
bool isEnabled() override;
};
extern WioTrackerL2Board board; extern WioTrackerL2Board board;
extern WRAPPER_CLASS radio_driver; extern WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock; extern AutoDiscoverRTCClock rtc_clock;
extern EnvironmentSensorManager sensors; extern EnvironmentSensorManager sensors;
extern MicroNMEALocationProvider gps; extern L2GpsProvider gps;
#ifdef DISPLAY_CLASS #ifdef DISPLAY_CLASS
extern DISPLAY_CLASS display; extern DISPLAY_CLASS display;