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
remove it?
- [ ] 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
/sdcard/maps-live, apart from downloaded maps; an index keeps their
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_flag(s_status, LV_OBJ_FLAG_HIDDEN);
s_list = lv_obj_create(body);
styleSurface(s_list, theme::BG);
lv_obj_set_width(s_list, LV_PCT(100));
lv_obj_set_flex_grow(s_list, 1);
lv_obj_set_flex_flow(s_list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(s_list, theme::GAP, 0);
lv_obj_set_scrollbar_mode(s_list, LV_SCROLLBAR_MODE_ACTIVE);
s_list = scrollList(body);
for (int i = 0; i < admin::rowCount(s_tab); i++) {
const admin::Field& f = admin::field(s_tab, i);
const char* hint = f.isCustom() ? "Any CLI command" : f.isAction() ? nullptr
+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_add_event_cb(tabs, onBotTab, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_t* list = lv_obj_create(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);
lv_obj_t* list = scrollList(body);
for (int i = 0; i < botcfg::rowCount(s_tab); i++) {
const botcfg::Row& r = botcfg::row(s_tab, i);
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) {
static const char* SCOPE[] = { "All", "Fav", "" };
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).
static const char* NOTIF[] = { "Default", "Muted", "Always", "" };
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);
row = radioview::settingRow(panel, "Sound", NULL);
row = settingRow(panel, "Sound", NULL);
radioview::rowSegmented(row, MELODY, chanctl::melody(_prefs, idx), 200, onChanSegment, C_MELODY);
const ScopeList& sl = the_mesh.scopeList();
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;
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));
@@ -217,7 +217,7 @@ void UITask::buildChannelEdit() {
s_secret = chanField(body, "Passphrase");
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);
lv_obj_set_size(s_hex_sw, 46, 24);
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)
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);
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);
} else {
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);
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';
if (_screen == SCR_HOME) refreshHome();
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);
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 (unread > 0) lv_label_set_text_fmt(s_lock_unread, LV_SYMBOL_ENVELOPE " %d", 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) ─────────────────────────────
void UITask::showFavourites() {
home::s_page = home::FAVS;
showHome();
}
// The six slots, three to a row, into `grid` (a wrapping row).
void UITask::favGrid(lv_obj_t* grid) {
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());
if (n != s_rows) { buildDiag(); return; }
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() {
@@ -245,8 +245,7 @@ private:
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
FILE* o = fopen(tmp, "wb");
if (o) {
Hdr nh = { MAGIC, VERSION, (uint16_t)sizeof(E), cap, 0, got, h.next_id };
nh.next_id = h.next_id; // ids of the kept records stay as they were
Hdr nh = { MAGIC, VERSION, (uint16_t)sizeof(E), cap, 0, got, h.next_id }; // the kept records keep their ids
bool ok = fwrite(&nh, sizeof(nh), 1, o) == 1 && (got == 0 || fwrite(buf, sizeof(E), got, o) == got);
fclose(o);
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; }
// ── 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.
static void setBacklightPct(uint8_t pct) {
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
// P14) is switched on in WioTrackerL2Board::begin(). Retried on every call
// until it works, so a card inserted later is picked up the next time.
// P14) is switched on in WioTrackerL2Board::begin(). Retried until it works --
// at most every 5 s, a failed mount takes a while -- so a card inserted later
// is picked up.
static bool mountStorage() {
static bool mounted = false;
static uint32_t last_try = 0;
if (mounted) return true;
if (last_try && millis() - last_try < 5000) return false;
last_try = millis() | 1;
SD_MMC.setPins(2, 3, 1);
mounted = SD_MMC.begin("/sdcard", true /* 1-bit */);
return mounted;
}
// ── WiFi (station, only while a map download runs) ───────────────────────────
// Credentials in NVS, not in NodePrefs (whose on-flash layout stays fixed) and
// not on the removable card.
static bool loadWifi(char* ssid, size_t ssid_n, char* pass, size_t pass_n) {
// ── Settings kept in NVS ─────────────────────────────────────────────────────
// Not in NodePrefs (whose on-flash layout stays fixed) and not on the
// removable card. One value per call: the namespace is opened and closed
// around it; a namespace that won't open reads as the default.
namespace nvs {
struct Ns {
Preferences p;
if (!p.begin("mc_wifi", true)) { ssid[0] = pass[0] = '\0'; return false; }
p.getString("ssid", ssid, ssid_n);
p.getString("pass", pass, pass_n);
p.end();
bool ok;
Ns(const char* ns, bool ro) { ok = p.begin(ns, ro); }
~Ns() { if (ok) 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';
}
static void saveWifi(const char* ssid, const char* pass) {
Preferences p;
if (!p.begin("mc_wifi", false)) return;
p.putString("ssid", ssid);
p.putString("pass", pass);
p.end();
nvs::putStr("mc_wifi", "ssid", ssid);
nvs::putStr("mc_wifi", "pass", pass);
}
// Settings > WiFi's switch: off keeps the radio off for everything (scan, map
// download). Kept with the credentials.
static int8_t s_wifi_allowed = -1; // read once (the status bar asks every second)
static bool wifiAllowed() {
if (s_wifi_allowed < 0) {
Preferences p;
bool on = true;
if (p.begin("mc_wifi", true)) { on = p.getBool("on", true); p.end(); }
s_wifi_allowed = on;
}
if (s_wifi_allowed < 0) s_wifi_allowed = nvs::getBool("mc_wifi", "on", true);
return s_wifi_allowed;
}
static void setWifiAllowed(bool on) {
s_wifi_allowed = on;
Preferences p;
if (!p.begin("mc_wifi", false)) return;
p.putBool("on", on);
p.end();
}
static void setWifiAllowed(bool on) { s_wifi_allowed = on; nvs::putBool("mc_wifi", "on", on); }
// Map tools > Live tiles: missing tiles fetched over WiFi while the map is open.
static bool liveTiles() {
Preferences p;
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();
}
static bool liveTiles() { return nvs::getBool("mc_wifi", "live", true); }
static void setLiveTiles(bool on) { nvs::putBool("mc_wifi", "live", on); }
// 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) {
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();
}
static void loadPin(char* out, size_t n) { nvs::getStr("mc_lock", "pin", out, n); }
static void savePin(const char* pin) { nvs::putStr("mc_lock", "pin", pin); }
// Accent colour (Settings > Display & power): an index into theme::ACCENTS.
static int loadAccent() {
Preferences p;
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();
}
static int loadAccent() { return nvs::getU8("mc_ui", "accent", 0); }
static void saveAccent(int idx) { nvs::putU8("mc_ui", "accent", idx); }
// Settings > Storage > Kept per conversation (an index into histstore::KEEP).
static int loadHistKeep() {
Preferences p;
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();
}
static int loadHistKeep() { return nvs::getI8("mc_ui", "hkeep", -1); }
static void saveHistKeep(int idx) { nvs::putI8("mc_ui", "hkeep", idx); }
// Settings > Storage > Live map tiles: index into mapview::LIVE_CAP_MB, -1 = default.
static int loadLiveCap() {
Preferences p;
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();
}
static int loadLiveCap() { return nvs::getI8("mc_ui", "ltcap", -1); }
static void saveLiveCap(int idx) { nvs::putI8("mc_ui", "ltcap", idx); }
// Settings > Display & power > Tap to wake: a touch turns the dark screen on
// (off: only the side button does).
static bool loadTapWake() {
Preferences p;
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();
}
// (off: only the top button does).
static bool loadTapWake() { return nvs::getBool("mc_ui", "tapwake", true); }
static void saveTapWake(bool on) { nvs::putBool("mc_ui", "tapwake", on); }
// 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
// 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() {
lv_display_t* disp = lv_display_create(SimLcdDisplay::W, SimLcdDisplay::H);
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_indev_t* indev = lv_indev_create();
@@ -506,6 +464,8 @@ static bool begin() {
static bool touched() { return SimLcdDisplay::touchState().down; }
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
// 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 > src_max) _dl_zmax = src_max; // the server has nothing finer
_dl_overlay = lv_obj_create(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);
_dl_overlay = dimOverlay(screen());
lv_obj_t* panel = lv_obj_create(_dl_overlay);
anim::popup(_dl_overlay);
@@ -828,8 +822,9 @@ void UITask::mapDownloadTick() {
bool got = false;
while (dl.liveTake(z, x, y)) { mapview::s_cache.forgetMissing(z, x, y); got = true; }
if (got && _screen == SCR_MAP) layoutMap();
// The WiFi goes 30 s after the map was left (a quick look elsewhere keeps it).
if (_screen != SCR_MAP) {
// The WiFi goes 30 s after the map was left or the screen went off (a
// quick look elsewhere keeps it).
if (_screen != SCR_MAP || _asleep) {
if (!_map_left_ms) _map_left_ms = millis() | 1;
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) {
navClosePopup();
_nav_overlay = lv_obj_create(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);
_nav_overlay = dimOverlay(screen());
lv_obj_t* panel = lv_obj_create(_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() {
lv_obj_t* panel = navPopupPanel("Navigate to", true);
lv_obj_t* list = lv_obj_create(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);
lv_obj_t* list = scrollList(panel);
int32_t lat = 0, lon = 0;
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() {
lv_obj_t* panel = navPopupPanel("Map tools", true);
lv_obj_t* list = lv_obj_create(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);
lv_obj_t* list = scrollList(panel);
sectionTitle(list, "TRAIL");
_nav_trail_lbl = label(list, "", THEME_FONT_SMALL, theme::TEXT);
@@ -1035,20 +1017,31 @@ void UITask::navSetShareTarget(int sel) {
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/.
bool UITask::exportTrailGpx(char* name_out, size_t n) {
if (!lvport::mountStorage()) return false;
char path[64];
uint32_t now = rtc_clock.getCurrentTime();
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);
trailFilePath(path, sizeof(path), ".gpx", _prefs);
FILE* f = fopen(path, "w");
if (!f) return false;
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).
bool UITask::saveTrailToCard(char* name_out, size_t n) {
char path[64];
uint32_t now = rtc_clock.getCurrentTime();
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);
trailFilePath(path, sizeof(path), ".trl", _prefs);
FILE* f = fopen(path, "wb");
if (!f) return false;
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).
void UITask::savedTrailsPopup() {
lv_obj_t* panel = navPopupPanel("Saved trails", true);
lv_obj_t* list = lv_obj_create(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);
lv_obj_t* list = scrollList(panel);
navmap::scanTrails();
for (int i = 0; i < navmap::s_st_n; i++) {
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, LV_SYMBOL_KEYBOARD " Coordinates", navmap::TL_WP_COORDS, false);
lv_obj_t* list = lv_obj_create(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);
lv_obj_t* list = scrollList(panel);
int32_t lat = 0, lon = 0;
bool gps = _core->course.currentLocation(lat, lon);
for (int i = 0; i < wp.count(); i++) {
@@ -174,6 +174,9 @@ void UITask::showOta() {
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; }
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)
// 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) {
lv_obj_t* dd = lv_dropdown_create(row);
@@ -170,12 +155,7 @@ void UITask::radioFreqPopup(bool repeater) {
using namespace radioview;
if (s_overlay) return;
s_freq_rpt = repeater;
s_overlay = lv_obj_create(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);
s_overlay = dimOverlay(screen());
lv_obj_t* panel = lv_obj_create(s_overlay);
anim::popup(s_overlay);
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() {
using namespace rptview;
using radioview::settingRow;
using radioview::rowDropdown;
lv_obj_t* body = newScreen("Repeater", true);
NodePrefs* p = _prefs;
@@ -268,7 +268,7 @@ void UITask::buildSoundRows(lv_obj_t* body, bool top) {
using namespace sndview;
if (top) {
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);
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
// 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.
// 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);
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"
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);
}
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;
}
// 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
// buttons, `sel` checked (-1: none). `item` is the unchecked button colour —
// 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);
}
// Unread badge: amber pill with a count, on the right edge of `parent`.
// "@[nick]" -> "@nick" in place, for one-line text (previews, quotes) where
// the bubble's highlight doesn't reach.
static void plainMentions(char* t) {
@@ -189,6 +245,7 @@ static void plainMentions(char* t) {
*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) {
if (n <= 0) return;
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
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 {
uint32_t aoff = autoOffMillis();
if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging() && !otaBusy()) sleep();
@@ -438,6 +499,7 @@ void UITask::loop() {
navPollTrackBack();
}
uint32_t lv_next = 0;
if (!_asleep) {
if ((int32_t)(millis() - _next_status_ms) >= 0) {
_next_status_ms = millis() + 1000;
@@ -479,8 +541,22 @@ void UITask::loop() {
uint32_t sig = threadSignature();
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) {
@@ -532,6 +608,7 @@ void UITask::sleep() {
if (_asleep) return;
_asleep = true;
if (_display) _display->turnOff();
lvport::powerSave(true, _tap_wake);
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);
if (!_asleep) return;
_asleep = false;
lvport::powerSave(false, _tap_wake);
if (_display) _display->turnOn();
refreshStatusBar();
if (_screen == SCR_HOME) refreshHome();
@@ -664,8 +742,9 @@ void UITask::refreshStatusBar() {
lv_label_set_text(_status_time, "--:--");
}
// Battery: the icon, then % or volts per Settings > Battery display.
uint16_t mv = getBattMilliVolts();
// Battery: the icon, then % or volts per Settings > Battery display. The
// 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);
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;
@@ -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_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;
if (_wifi_scanning && !wifiInUse()) lvport::netEnd(); // left mid-scan: the radio goes off
_wifi_scanning = false;
_ota_status = _ota_bar = _ota_btn = _ota_btn_lbl = 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);
lv_obj_set_flex_flow(home::s_row, favs ? LV_FLEX_FLOW_ROW_WRAP : LV_FLEX_FLOW_ROW);
if (favs) {
home::s_fav_sig = homeUnreadTotal();
home::s_fav_sig = unreadTotal();
favGrid(home::s_row); // DeviceScreen.h
}
int first = (page - home::MAIN) * home::PER_PAGE;
@@ -1092,7 +1172,7 @@ void UITask::setHomePage(int page) {
refreshHome();
}
int UITask::homeUnreadTotal() {
int UITask::unreadTotal() {
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_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
return;
}
if (!_home_unread) return;
int unread = homeUnreadTotal();
int unread = unreadTotal();
if (unread > 0) lv_label_set_text_fmt(_home_unread, "%d", 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() {
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);
// 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) {
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);
plainMentions(sub);
} else {
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_list = lv_obj_create(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_list = scrollList(body);
_nearby_sig = 0;
refreshNearbyList();
@@ -1454,13 +1529,7 @@ void UITask::startNearbyScan() {
// Dimmed full-screen overlay (swallows taps) holding a panel with the results.
// A child of the current screen, so it goes away with it.
void UITask::showScanPopup() {
_scan_overlay = lv_obj_create(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);
_scan_overlay = dimOverlay(screen());
lv_obj_t* panel = lv_obj_create(_scan_overlay);
anim::popup(_scan_overlay);
@@ -1485,13 +1554,7 @@ void UITask::showScanPopup() {
headerButton(hdr, LV_SYMBOL_REFRESH " Again", onNearbyScan, 44, NULL);
_scan_status = label(panel, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
_scan_list = lv_obj_create(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_list = scrollList(panel);
_scan_sig = 0;
refreshScanPopup();
@@ -1805,7 +1868,6 @@ void UITask::nodeAction(uint8_t action) {
char t[48];
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);
the_mesh.savePrefs();
showToast(t);
}
break;
@@ -1931,14 +1993,8 @@ void UITask::buildThread() {
headerButton(_header, LV_SYMBOL_SETTINGS, _thread_is_channel ? onChanThreadMenu : onConvThreadMenu, 4, NULL);
}
_thread_list = lv_obj_create(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);
_thread_list = scrollList(body);
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;
_keyboard = nullptr;
@@ -1990,7 +2046,7 @@ uint32_t UITask::threadSignature() const {
sig = n;
for (int j = 0; j < n && j < 8; 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 {
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.
bool meta_in_header = from && from[0] && !status;
if (from && from[0]) {
lv_obj_t* hdr = lv_obj_create(b);
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_t* hdr = flexBox(b, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_column(hdr, 8, 0);
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);
@@ -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
lv_obj_t* acts = lv_obj_create(b);
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_t* acts = flexBox(b, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_column(acts, 6, 0);
lv_obj_set_style_pad_top(acts, 2, 0);
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)
lv_obj_t* line = lv_obj_create(b);
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_t* line = flexBox(b, 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_style_pad_column(line, 6, 0);
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.
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);
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* row = settingRow(parent, text, NULL);
lv_obj_t* dd = lv_dropdown_create(row);
lv_dropdown_set_options_static(dd, options);
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.
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);
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* row = settingRow(parent, text, sub, 236); // the hint clear of the switch
lv_obj_t* sw = lv_switch_create(row);
lv_obj_set_size(sw, 46, 24);
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);
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_slider_set_range(sl, 0, 4);
lv_slider_set_value(sl, v, LV_ANIM_OFF);
@@ -2585,12 +2607,7 @@ void UITask::schemaRows(lv_obj_t* body, uint8_t page) {
continue;
}
if (st.offset == offsetof(NodePrefs, display_brightness)) { // a slider here instead of five steps
lv_obj_t* row = lv_obj_create(body);
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* row = settingRow(body, st.label, NULL);
lv_obj_t* sl = lv_slider_create(row);
lv_slider_set_range(sl, 5, 100);
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);
}
opts[o] = '\0';
lv_obj_t* row = lv_obj_create(body);
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* row = settingRow(body, st.label, st.hint, 176);
lv_obj_t* dd = lv_dropdown_create(row);
lv_dropdown_set_options(dd, opts);
lv_dropdown_set_selected(dd, v);
+6 -4
View File
@@ -235,7 +235,6 @@ public:
void pinRowRefresh();
void accentRow(lv_obj_t* body); // Settings > Display & power > Accent colour
void setAccent(int idx);
void showFavourites();
void showDiag();
void diagTab(int tab);
void diagResetPopup();
@@ -252,7 +251,7 @@ public:
// Kept for main.cpp's sim hooks (ui-new API).
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:
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 otaLeave();
bool otaBusy() const;
bool wifiInUse() const; // a map download or an update holds the WiFi
void buildNearby();
void refreshNearbyList();
uint32_t nearbySignature() const;
@@ -344,7 +344,7 @@ private:
void lockPoll();
void favGrid(lv_obj_t* grid);
void favRefresh();
int homeUnreadTotal();
int unreadTotal(); // DMs, channels and rooms
void buildDiag();
void refreshDiag();
void buildCompass();
@@ -363,8 +363,10 @@ private:
void sleep();
void toggleMute();
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;
uint32_t autoOffMillis() const;
uint32_t idleMillis(uint32_t lv_next);
void checkLowBattery();
#ifdef PIN_BUZZER
@@ -402,7 +404,7 @@ private:
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)
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;
lv_obj_t* _prune_lbl = nullptr;
// 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);
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) {
+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;
// +/-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 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) {
Wire.beginTransmission(TCA9535_ADDR);
@@ -64,7 +69,7 @@ bool WioTrackerL2Board::initExpander() {
delay(10);
expSetOutput(EXP_PIN_TF_EN, HIGH);
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);
expSetOutput(EXP_PIN_GNSS_EN, HIGH);
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)
// 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 divider is powered only around the reading; one taken in the last 2 s
// is reused.
uint16_t WioTrackerL2Board::getBattMilliVolts() {
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++) {
int16_t raw = adsReadRaw();
if (raw > 0) {
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;
}
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
// 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_SD_DETECT 2 // input - microSD card detect
#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
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
// the power-up reset so the module comes back cleanly
void setGnssPower(bool on) {
@@ -73,6 +71,10 @@ public:
// Grove expansion port rail (nothing on-board depends on it)
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() {
expWritePin(EXP_PIN_GNSS_RST, HIGH);
delay(10);
@@ -82,6 +84,11 @@ public:
// WAKE button on expander P00: pressed = level differs from boot baseline
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)
bool isExternalPowered() override;
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
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)
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
+18 -7
View File
@@ -33,6 +33,7 @@ class WioTrackerL2Backlight : public lgfx::v1::ILight {
static constexpr uint8_t REG_LED0_PWM = 0x18;
uint8_t _brightness = 153; // 60%
bool _standby = false; // chip disabled (the screen is off)
void writeReg(uint8_t reg, uint8_t value) {
Wire.beginTransmission(LP5814_I2C_ADDR);
@@ -41,13 +42,7 @@ class WioTrackerL2Backlight : public lgfx::v1::ILight {
Wire.endTransmission();
}
public:
bool init(uint8_t brightness) override {
Wire.beginTransmission(LP5814_I2C_ADDR);
if (Wire.endTransmission() != 0) {
return false; // LP5814 not found
}
void configure() {
writeReg(REG_DEVICE_CONFIG0, 0x01); // chip enable
writeReg(REG_MAX_CURRENT, 0x01); // 51 mA max current
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_UPDATE, 0x55); // latch (LP5814 requires 0x55)
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);
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 {
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++) {
writeReg(REG_LED0_PWM + i, brightness);
}
+23 -1
View File
@@ -10,7 +10,7 @@ WRAPPER_CLASS radio_driver(radio, board);
ESP32RTCClock fallback_clock;
AutoDiscoverRTCClock rtc_clock(fallback_clock);
MicroNMEALocationProvider gps(Serial1, &rtc_clock);
L2GpsProvider gps(Serial1, &rtc_clock);
EnvironmentSensorManager sensors(gps);
#ifdef DISPLAY_CLASS
@@ -22,6 +22,28 @@ EnvironmentSensorManager sensors(gps);
void buzzerAmpPower(bool on) { board.setSpeakerAmp(on); }
#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() {
MESH_DEBUG_PRINTLN("radio_init: rtc + sx1262 init");
fallback_clock.begin();
+14 -1
View File
@@ -14,11 +14,24 @@
#include <helpers/ui/MomentaryButton.h>
#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 WRAPPER_CLASS radio_driver;
extern AutoDiscoverRTCClock rtc_clock;
extern EnvironmentSensorManager sensors;
extern MicroNMEALocationProvider gps;
extern L2GpsProvider gps;
#ifdef DISPLAY_CLASS
extern DISPLAY_CLASS display;