mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-09 11:16:39 +00:00
feat(ui-lvgl): roadmap stage 5 - screen PIN, live map tiles, OTA from GitHub releases
- Screen PIN (Settings > Display & power > Security): 4-8 digits in NVS, keypad on the lock card after every wake and on boot, 5 misses -> 30 s pause - Live map tiles: missing tiles at the current zoom are fetched over the saved WiFi, written to the card and drawn; WiFi drops 30 s after leaving the map; "Live tiles" switch in Map tools - Firmware update (Settings > System): checks the fork's latest release, streams the -ota.bin into the spare app slot over TLS verified with the Arduino 3.x CA bundle, checks image magic/chip id, restarts - Release workflow publishes *_solo_lvgl envs (merged image + -ota.bin) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
// - node name (Settings > Node), reboot / power off (Settings > System);
|
||||
// - lock screen (Settings > Display & power > Lock screen, NodePrefs::auto_lock):
|
||||
// once the screen turns off, waking shows a clock card and nothing reacts
|
||||
// until "slide to unlock" -- against touches in a pocket;
|
||||
// until "slide to unlock" -- against touches in a pocket; with a screen PIN
|
||||
// (NVS, lvport::loadPin) the card always comes up, also after a reboot, and
|
||||
// asks for the PIN on a keypad instead of the slider;
|
||||
// - favourites dial (Home > Favourites, NodePrefs::favourite_contacts): six
|
||||
// slots holding a contact, room or channel; tap opens it, hold changes /
|
||||
// removes it; "Pin" in a conversation's / channel's options puts it on one.
|
||||
@@ -19,6 +21,13 @@ static lv_obj_t* s_lock_clock = nullptr;
|
||||
static lv_obj_t* s_lock_date = nullptr;
|
||||
static lv_obj_t* s_lock_unread = nullptr;
|
||||
static lv_obj_t* s_lock_slider = nullptr;
|
||||
static lv_obj_t* s_pin_dots = nullptr; // lock card / setup popup: one dot per digit typed
|
||||
static lv_obj_t* s_pin_msg = nullptr; // "Wrong PIN" / "Try again in 30 s" / setup step
|
||||
static const uint8_t PIN_MIN = 4, PIN_MAX = 8;
|
||||
static const uint8_t PIN_TRIES = 5; // misses before a pause
|
||||
static const uint32_t PIN_PAUSE_MS = 30000;
|
||||
static const char* const PIN_MAP[] = { "1", "2", "3", "\n", "4", "5", "6", "\n", "7", "8", "9", "\n",
|
||||
LV_SYMBOL_BACKSPACE, "0", LV_SYMBOL_OK, "" };
|
||||
|
||||
static int s_fav_slot = -1; // slot the hold / pick popup is about
|
||||
static const int PICK_MAX = 64;
|
||||
@@ -55,6 +64,56 @@ static void onFavHold(lv_event_t* e) {
|
||||
static void onFavAction(lv_event_t* e) { s_ui->favAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
|
||||
static void onFavPick(lv_event_t* e) { s_ui->favPick((int)(uintptr_t)lv_event_get_user_data(e)); }
|
||||
static void onPinSlot(lv_event_t* e) { s_ui->pinTo((int)(uintptr_t)lv_event_get_user_data(e)); }
|
||||
static void onPinPad(lv_event_t* e) {
|
||||
lv_obj_t* m = (lv_obj_t*)lv_event_get_target(e);
|
||||
const char* k = lv_buttonmatrix_get_button_text(m, lv_buttonmatrix_get_selected_button(m));
|
||||
if (!k) return;
|
||||
if (lv_event_get_user_data(e)) s_ui->pinSetupKey(k); else s_ui->pinKey(k);
|
||||
}
|
||||
static void onPinSetup(lv_event_t* e) { (void)e; s_ui->pinSetupPopup(); }
|
||||
static void onPinRemove(lv_event_t* e) { (void)e; s_ui->pinRemove(); }
|
||||
|
||||
namespace devview {
|
||||
// The digit keypad (1-9, backspace, 0, OK) under a row of dots.
|
||||
static lv_obj_t* pinPad(lv_obj_t* parent, bool setup, int w, int h) {
|
||||
s_pin_dots = label(parent, "", THEME_FONT_LARGE, theme::TEXT);
|
||||
lv_obj_set_style_text_letter_space(s_pin_dots, 6, 0);
|
||||
lv_obj_set_height(s_pin_dots, 22);
|
||||
s_pin_msg = label(parent, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
||||
lv_obj_t* m = lv_buttonmatrix_create(parent);
|
||||
lv_buttonmatrix_set_map(m, PIN_MAP);
|
||||
lv_obj_set_size(m, w, h);
|
||||
lv_obj_set_style_bg_opa(m, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(m, 0, 0);
|
||||
lv_obj_set_style_pad_all(m, 0, 0);
|
||||
lv_obj_set_style_pad_gap(m, 6, 0);
|
||||
lv_obj_set_style_bg_color(m, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
|
||||
lv_obj_set_style_bg_color(m, lv_color_hex(theme::SURFACE_2), LV_PART_ITEMS | LV_STATE_PRESSED);
|
||||
lv_obj_set_style_text_font(m, THEME_FONT_LARGE, LV_PART_ITEMS);
|
||||
lv_obj_set_style_text_color(m, lv_color_hex(theme::TEXT), LV_PART_ITEMS);
|
||||
lv_obj_set_style_radius(m, theme::RADIUS, LV_PART_ITEMS);
|
||||
lv_obj_set_style_shadow_width(m, 0, LV_PART_ITEMS);
|
||||
lv_obj_add_event_cb(m, onPinPad, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)(setup ? 1 : 0));
|
||||
return m;
|
||||
}
|
||||
|
||||
static void showDots(const char* entry) {
|
||||
if (!s_pin_dots) return;
|
||||
char d[PIN_MAX * 3 + 1] = "";
|
||||
for (size_t i = 0; entry[i] && i < PIN_MAX; i++) strcat(d, "\xE2\x80\xA2"); // U+2022 bullet
|
||||
lv_label_set_text(s_pin_dots, d);
|
||||
}
|
||||
|
||||
// Digit / backspace into `entry`; true when OK was pressed.
|
||||
static bool pinEdit(char* entry, const char* key) {
|
||||
size_t n = strlen(entry);
|
||||
if (!strcmp(key, LV_SYMBOL_BACKSPACE)) { if (n) entry[n - 1] = '\0'; }
|
||||
else if (!strcmp(key, LV_SYMBOL_OK)) return true;
|
||||
else if (key[0] >= '0' && key[0] <= '9' && !key[1] && n < PIN_MAX) { entry[n] = key[0]; entry[n + 1] = '\0'; }
|
||||
showDots(entry);
|
||||
return false;
|
||||
}
|
||||
} // namespace devview
|
||||
|
||||
// ── Node name, reboot, power off ──────────────────────────────────────────────
|
||||
|
||||
@@ -125,6 +184,25 @@ void UITask::lockScreen() {
|
||||
lv_obj_set_flex_align(s_lock, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_top(s_lock, 18, 0);
|
||||
lv_obj_set_style_pad_row(s_lock, 4, 0);
|
||||
if (_pin[0]) { // compact card: the time and unread on one line, the keypad below
|
||||
lv_obj_set_style_pad_top(s_lock, 4, 0);
|
||||
lv_obj_set_style_pad_row(s_lock, 2, 0);
|
||||
lv_obj_t* top = lv_obj_create(s_lock);
|
||||
lv_obj_remove_style_all(top);
|
||||
lv_obj_set_size(top, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_flex_flow(top, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(top, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_column(top, 14, 0);
|
||||
s_lock_clock = label(top, "--:--", THEME_FONT_LARGE, theme::TEXT);
|
||||
s_lock_unread = label(top, "", THEME_FONT_BODY, theme::ACCENT);
|
||||
s_lock_date = nullptr;
|
||||
_pin_entry[0] = '\0';
|
||||
pinPad(s_lock, false, 228, 124);
|
||||
showDots(_pin_entry);
|
||||
if (_toast) lv_obj_move_foreground(_toast);
|
||||
refreshLock();
|
||||
return;
|
||||
}
|
||||
s_lock_clock = label(s_lock, "--:--", THEME_FONT_CLOCK, theme::TEXT);
|
||||
s_lock_date = label(s_lock, "", THEME_FONT_BODY, theme::TEXT_MUTED);
|
||||
s_lock_unread = label(s_lock, "", THEME_FONT_BODY, theme::ACCENT);
|
||||
@@ -160,7 +238,8 @@ void UITask::unlockScreen() {
|
||||
if (!s_lock) return;
|
||||
lv_indev_wait_release(lv_indev_active()); // the slide isn't also a tap underneath
|
||||
lv_obj_delete_async(s_lock);
|
||||
s_lock = s_lock_clock = s_lock_date = s_lock_unread = s_lock_slider = 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
|
||||
}
|
||||
@@ -207,13 +286,108 @@ void UITask::refreshLock() {
|
||||
fmtClock(clk, sizeof(clk), ti, _prefs, false, true);
|
||||
fmtDate(date, sizeof(date), ti, _prefs);
|
||||
lv_label_set_text(s_lock_clock, clk);
|
||||
lv_label_set_text(s_lock_date, date);
|
||||
if (s_lock_date) lv_label_set_text(s_lock_date, date);
|
||||
}
|
||||
int unread = _core->dmUnreadTotal() + _core->history.getTotalChannelUnread() + _core->roomUnread();
|
||||
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, "");
|
||||
if (!s_pin_msg) return;
|
||||
int32_t left = (int32_t)(_pin_block_until - millis());
|
||||
if (_pin_block_until && left > 0) lv_label_set_text_fmt(s_pin_msg, "Too many tries - wait %ld s", (long)((left + 999) / 1000));
|
||||
else if (_pin_block_until) { _pin_block_until = 0; lv_label_set_text(s_pin_msg, "Enter PIN"); }
|
||||
else if (!lv_label_get_text(s_pin_msg)[0]) lv_label_set_text(s_pin_msg, "Enter PIN");
|
||||
return;
|
||||
}
|
||||
if (unread > 0) lv_label_set_text_fmt(s_lock_unread, LV_SYMBOL_ENVELOPE " %d new message%s", unread, unread == 1 ? "" : "s");
|
||||
else lv_label_set_text(s_lock_unread, "");
|
||||
}
|
||||
|
||||
// ── Screen PIN ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Lock card keypad: the PIN unlocks as soon as it's complete (OK checks a
|
||||
// shorter entry); five misses pause entry for 30 s.
|
||||
void UITask::pinKey(const char* key) {
|
||||
using namespace devview;
|
||||
if (!s_lock || !_pin[0]) return;
|
||||
if (_pin_block_until && (int32_t)(_pin_block_until - millis()) > 0) return;
|
||||
bool ok = pinEdit(_pin_entry, key);
|
||||
size_t n = strlen(_pin_entry);
|
||||
if (!ok && n < strlen(_pin)) {
|
||||
if (n && s_pin_msg) lv_label_set_text(s_pin_msg, "Enter PIN");
|
||||
return;
|
||||
}
|
||||
if (!n) return;
|
||||
if (!strcmp(_pin_entry, _pin)) { _pin_fails = 0; unlockScreen(); return; }
|
||||
_pin_entry[0] = '\0';
|
||||
showDots(_pin_entry);
|
||||
if (++_pin_fails >= PIN_TRIES) {
|
||||
_pin_fails = 0;
|
||||
_pin_block_until = (millis() + PIN_PAUSE_MS) | 1;
|
||||
refreshLock();
|
||||
} else if (s_pin_msg) {
|
||||
lv_label_set_text_fmt(s_pin_msg, "Wrong PIN - %d tr%s left", PIN_TRIES - _pin_fails, PIN_TRIES - _pin_fails == 1 ? "y" : "ies");
|
||||
}
|
||||
}
|
||||
|
||||
// Settings > Display & power > Screen PIN: a new PIN typed twice; with one
|
||||
// set, "Remove" in the header too.
|
||||
void UITask::pinSetupPopup() {
|
||||
using namespace devview;
|
||||
_pin_entry[0] = _pin_new[0] = '\0';
|
||||
lv_obj_t* panel = navPopupPanel(_pin[0] ? "Change PIN" : "Set screen PIN", true);
|
||||
lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(panel, 2, 0);
|
||||
if (_pin[0]) { // next to the close button
|
||||
lv_obj_t* l;
|
||||
headerButton(lv_obj_get_child(panel, 0), LV_SYMBOL_TRASH " Remove", onPinRemove, 44, &l);
|
||||
lv_obj_set_style_text_color(l, lv_color_hex(theme::FAIL), 0);
|
||||
}
|
||||
pinPad(panel, true, 228, 116);
|
||||
lv_label_set_text(s_pin_msg, "New PIN, 4 to 8 digits, then " LV_SYMBOL_OK);
|
||||
}
|
||||
|
||||
void UITask::pinSetupKey(const char* key) {
|
||||
using namespace devview;
|
||||
if (!s_pin_msg || !pinEdit(_pin_entry, key)) return;
|
||||
size_t n = strlen(_pin_entry);
|
||||
if (n < PIN_MIN) { lv_label_set_text(s_pin_msg, "At least 4 digits"); return; }
|
||||
if (!_pin_new[0]) { // first entry: ask again
|
||||
strcpy(_pin_new, _pin_entry);
|
||||
_pin_entry[0] = '\0';
|
||||
showDots(_pin_entry);
|
||||
lv_label_set_text(s_pin_msg, "Once more to confirm, then " LV_SYMBOL_OK);
|
||||
return;
|
||||
}
|
||||
if (strcmp(_pin_new, _pin_entry) != 0) {
|
||||
_pin_new[0] = _pin_entry[0] = '\0';
|
||||
showDots(_pin_entry);
|
||||
lv_label_set_text(s_pin_msg, "Didn't match - new PIN again");
|
||||
return;
|
||||
}
|
||||
strcpy(_pin, _pin_new);
|
||||
lvport::savePin(_pin);
|
||||
_pin_new[0] = _pin_entry[0] = '\0';
|
||||
navClosePopup();
|
||||
showToast("PIN set - asked each time the screen wakes", 3000);
|
||||
pinRowRefresh();
|
||||
}
|
||||
|
||||
void UITask::pinRemove() {
|
||||
_pin[0] = '\0';
|
||||
lvport::savePin("");
|
||||
navClosePopup();
|
||||
showToast("PIN removed");
|
||||
pinRowRefresh();
|
||||
}
|
||||
|
||||
// Display & power rebuilt for the Screen PIN row, still at its bottom.
|
||||
void UITask::pinRowRefresh() {
|
||||
if (_screen != SCR_SETTINGS_NAV) return;
|
||||
buildSchemaSettings();
|
||||
if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); }
|
||||
}
|
||||
|
||||
// ── Favourites dial ───────────────────────────────────────────────────────────
|
||||
|
||||
void UITask::showFavourites() {
|
||||
|
||||
@@ -137,6 +137,36 @@ static void setWifiAllowed(bool on) {
|
||||
p.putBool("on", on);
|
||||
p.end();
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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 netBegin(const char* ssid, const char* pass) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
@@ -365,9 +395,15 @@ static void saveWifi(const char* ssid, const char* pass) {
|
||||
snprintf(s_ssid, sizeof(s_ssid), "%s", ssid);
|
||||
snprintf(s_pass, sizeof(s_pass), "%s", pass);
|
||||
}
|
||||
static char s_pin[9] = ""; // the screen PIN, for the session
|
||||
static void loadPin(char* out, size_t n) { snprintf(out, n, "%s", s_pin); }
|
||||
static void savePin(const char* pin) { snprintf(s_pin, sizeof(s_pin), "%s", pin); }
|
||||
static bool s_wifi_on = true;
|
||||
static bool wifiAllowed() { return s_wifi_on; }
|
||||
static void setWifiAllowed(bool on) { s_wifi_on = on; }
|
||||
static bool s_live_tiles = true;
|
||||
static bool liveTiles() { return s_live_tiles; }
|
||||
static void setLiveTiles(bool on) { s_live_tiles = on; }
|
||||
static void netBegin(const char*, const char*) {}
|
||||
static int netState() { return NET_UP; }
|
||||
static void netEnd() {}
|
||||
|
||||
@@ -197,6 +197,7 @@ void UITask::openMap(bool nav) {
|
||||
|
||||
void UITask::showMap() {
|
||||
_screen = SCR_MAP;
|
||||
mapLiveBegin();
|
||||
mapview::s_available = lvport::mountStorage() && mapview::s_provider->available();
|
||||
if (_map_z == 0) { // first open: own position, else the node's own advert position, else Poland
|
||||
int32_t lat, lon;
|
||||
@@ -335,6 +336,7 @@ void UITask::layoutMap() {
|
||||
bool stand_in = false;
|
||||
if (s && !s->present) { // none at this zoom: magnify a coarser one
|
||||
missing++;
|
||||
mapview::s_dl.liveRequest(_map_z, wx, ty); // online: fetch it (no-op when live tiles are off)
|
||||
int wz, ax, ay;
|
||||
show = mapview::ancestorFor(_map_z, wx, ty, k, wz, ax, ay);
|
||||
if (!show && wz >= 0) _map_pending = true;
|
||||
@@ -380,6 +382,7 @@ void UITask::layoutMap() {
|
||||
if (over) lv_label_set_text_fmt(_map_zoom_lbl, "z%d (map z%d)", _map_z, _map_z - over); // magnified
|
||||
else lv_label_set_text_fmt(_map_zoom_lbl, "z%d", _map_z);
|
||||
const char* hint = !have_provider ? "No map on the SD card.\nPut tiles in /maps (tools/maps)."
|
||||
: mapview::s_dl.liveQueued() > 0 ? nullptr // being fetched
|
||||
: (!_map_pending && shown == 0 && missing > 0)
|
||||
? "No map detail here at this zoom.\nZoom out, or download this area." : nullptr;
|
||||
if (hint) { lv_label_set_text(_map_hint, hint); lv_obj_remove_flag(_map_hint, LV_OBJ_FLAG_HIDDEN); }
|
||||
@@ -761,9 +764,45 @@ void UITask::mapDownloadStop() {
|
||||
static mapview::TileArea s_job_tmp;
|
||||
|
||||
// Every UI loop pass, whatever the screen: a download keeps going in the background.
|
||||
// Live tiles on while the map is open: WiFi allowed, a network saved, a card in.
|
||||
void UITask::mapLiveBegin() {
|
||||
_map_left_ms = 0;
|
||||
char ssid[33], pass[65];
|
||||
if (!lvport::liveTiles() || !lvport::wifiAllowed() || !lvport::mountStorage()) { mapview::s_dl.liveEnd(); return; }
|
||||
if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) return;
|
||||
mapview::s_dl.liveBegin(ssid, pass);
|
||||
}
|
||||
|
||||
void UITask::setLiveTiles(bool on) {
|
||||
lvport::setLiveTiles(on);
|
||||
if (on) {
|
||||
char ssid[33], pass[65];
|
||||
if (!lvport::wifiAllowed()) showToast("WiFi is off - Settings > WiFi");
|
||||
else if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) showToast("Pick a WiFi network first - Settings > WiFi", 3000);
|
||||
else showToast("Missing tiles load over WiFi");
|
||||
mapLiveBegin();
|
||||
mapview::s_available = lvport::mountStorage() && mapview::s_provider->available();
|
||||
} else {
|
||||
mapview::s_dl.liveEnd();
|
||||
showToast("Live tiles off");
|
||||
}
|
||||
if (_screen == SCR_MAP) layoutMap();
|
||||
}
|
||||
|
||||
void UITask::mapDownloadTick() {
|
||||
mapview::TileDownloader& dl = mapview::s_dl;
|
||||
dl.loop();
|
||||
if (dl.liveOn()) {
|
||||
int z, x, y;
|
||||
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) {
|
||||
if (!_map_left_ms) _map_left_ms = millis() | 1;
|
||||
else if (millis() - _map_left_ms > 30000) dl.liveEnd();
|
||||
}
|
||||
}
|
||||
uint8_t st = dl.state();
|
||||
bool was_active = _dl_last_state == mapview::TileDownloader::CONNECTING ||
|
||||
_dl_last_state == mapview::TileDownloader::RUNNING;
|
||||
@@ -792,6 +831,12 @@ void UITask::mapDownloadTick() {
|
||||
lv_label_set_text_fmt(_map_dl_pill, LV_SYMBOL_DOWNLOAD " %lu / %lu", (unsigned long)dl.processed(),
|
||||
(unsigned long)dl.total());
|
||||
lv_obj_remove_flag(_map_dl_pill, LV_OBJ_FLAG_HIDDEN);
|
||||
} else if (dl.liveConnecting()) {
|
||||
lv_label_set_text(_map_dl_pill, LV_SYMBOL_WIFI " Connecting...");
|
||||
lv_obj_remove_flag(_map_dl_pill, LV_OBJ_FLAG_HIDDEN);
|
||||
} else if (dl.liveQueued() > 0) {
|
||||
lv_label_set_text_fmt(_map_dl_pill, LV_SYMBOL_DOWNLOAD " live %d", dl.liveQueued());
|
||||
lv_obj_remove_flag(_map_dl_pill, LV_OBJ_FLAG_HIDDEN);
|
||||
} else if (mapview::s_available && dl.savedJob(s_job_tmp)) {
|
||||
lv_label_set_text(_map_dl_pill, LV_SYMBOL_DOWNLOAD " Resume?");
|
||||
lv_obj_remove_flag(_map_dl_pill, LV_OBJ_FLAG_HIDDEN);
|
||||
@@ -803,3 +848,8 @@ void UITask::mapDownloadTick() {
|
||||
// New tiles in view while downloading: let the map pick them up.
|
||||
if (dl.active() && _screen == SCR_MAP && dl.downloaded() > 0) { mapview::s_cache.forgetMissing(); layoutMap(); }
|
||||
}
|
||||
|
||||
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
|
||||
// Sim page / tests: a saved WiFi network without going through Settings > WiFi.
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE void sim_wifi_save(const char* ssid, const char* pass) { lvport::saveWifi(ssid, pass); }
|
||||
#endif
|
||||
|
||||
@@ -777,6 +777,9 @@ struct FilePrint {
|
||||
} // namespace navmap
|
||||
|
||||
static void onNavTools(lv_event_t* e) { (void)e; s_ui->navToolsPopup(); }
|
||||
static void onLiveTiles(lv_event_t* e) {
|
||||
s_ui->setLiveTiles(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
|
||||
}
|
||||
static void onNavTool(lv_event_t* e) { s_ui->navToolAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
|
||||
static void onNavShareTarget(lv_event_t* e) {
|
||||
s_ui->navSetShareTarget((int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e)));
|
||||
@@ -881,6 +884,9 @@ void UITask::navToolsPopup() {
|
||||
label(list, al, THEME_FONT_SMALL, theme::TEXT);
|
||||
|
||||
sectionTitle(list, "MAP");
|
||||
lv_obj_t* lsw = switchRow(list, "Live tiles", "Load missing tiles over WiFi", nullptr);
|
||||
if (lvport::liveTiles()) lv_obj_add_state(lsw, LV_STATE_CHECKED);
|
||||
lv_obj_add_event_cb(lsw, onLiveTiles, LV_EVENT_VALUE_CHANGED, NULL);
|
||||
r = toolRow(list);
|
||||
toolButton(r, LV_SYMBOL_DOWNLOAD " Download this area", navmap::TL_DOWNLOAD, false);
|
||||
r = toolRow(list);
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
#pragma once
|
||||
// Settings > System > Firmware update: one-button OTA from this fork's GitHub
|
||||
// Releases. The latest release's app image for this board (the asset whose
|
||||
// name ends in OTA_ASSET_SUFFIX, published by build-solo-firmwares.yml) is
|
||||
// streamed over verified TLS (the framework's Mozilla CA bundle) straight into
|
||||
// the idle OTA slot; the device then restarts into it. Settings, contacts and
|
||||
// messages live outside the app slots and are kept.
|
||||
//
|
||||
// Needs Arduino-ESP32 3.x for the CA bundle: the 2.0.17 build and the sim say
|
||||
// so instead of offering the update.
|
||||
//
|
||||
// Single-TU fragment: included at the end of ui-lvgl/UITask.cpp.
|
||||
|
||||
#ifndef OTA_REPO
|
||||
#define OTA_REPO "MarekZegare4/MeshCore-Solo"
|
||||
#endif
|
||||
|
||||
#if defined(ESP32) && defined(OTA_ASSET_SUFFIX) && defined(ESP_ARDUINO_VERSION_MAJOR) && ESP_ARDUINO_VERSION_MAJOR >= 3
|
||||
#define OTA_SUPPORTED 1
|
||||
#include <Update.h>
|
||||
#include <NetworkClientSecure.h>
|
||||
#include <HTTPClient.h>
|
||||
extern const uint8_t ota_crt_bundle_start[] asm("_binary_x509_crt_bundle_start");
|
||||
extern const uint8_t ota_crt_bundle_end[] asm("_binary_x509_crt_bundle_end");
|
||||
#else
|
||||
#define OTA_SUPPORTED 0
|
||||
#endif
|
||||
|
||||
namespace ota {
|
||||
|
||||
enum State : uint8_t { IDLE, CONNECTING, CHECKING, CHECKED, INSTALLING, DONE, FAILED };
|
||||
static volatile uint8_t s_state = IDLE;
|
||||
static char s_msg[96] = ""; // why it failed / what was found
|
||||
static char s_latest[24] = ""; // the release tag
|
||||
static char s_url[256] = ""; // the asset's download URL
|
||||
static volatile uint32_t s_size = 0, s_written = 0;
|
||||
static uint32_t s_since = 0; // WiFi connect start / restart countdown
|
||||
static bool s_newer = false; // the release is newer than this build
|
||||
static uint8_t s_job = 0; // what the task does once the WiFi is up: 1 check, 2 install
|
||||
|
||||
// "v1.28" / "1.28.3" -> comparable number; 0 when it isn't a version (a dev build).
|
||||
static uint32_t versionNum(const char* v) {
|
||||
if (*v == 'v' || *v == 'V') v++;
|
||||
unsigned a = 0, b = 0, c = 0;
|
||||
int n = sscanf(v, "%u.%u.%u", &a, &b, &c);
|
||||
return n >= 2 ? (a << 20) | (b << 10) | c : 0;
|
||||
}
|
||||
|
||||
#if OTA_SUPPORTED
|
||||
static TaskHandle_t s_task = nullptr;
|
||||
|
||||
static void fail(const char* m) { snprintf(s_msg, sizeof(s_msg), "%s", m); s_state = FAILED; }
|
||||
|
||||
// "key":"value" at or after `from`.
|
||||
static bool jsonStr(const char* from, const char* key, char* out, size_t n) {
|
||||
char pat[40];
|
||||
snprintf(pat, sizeof(pat), "\"%s\":\"", key);
|
||||
const char* p = strstr(from, pat);
|
||||
if (!p) return false;
|
||||
p += strlen(pat);
|
||||
size_t i = 0;
|
||||
while (*p && *p != '"' && i + 1 < n) out[i++] = *p++;
|
||||
out[i] = '\0';
|
||||
return *p == '"';
|
||||
}
|
||||
|
||||
static void check(HTTPClient& http, NetworkClientSecure& tls) {
|
||||
if (!http.begin(tls, "https://api.github.com/repos/" OTA_REPO "/releases/latest")) { fail("Bad release URL"); return; }
|
||||
http.addHeader("Accept", "application/vnd.github+json");
|
||||
int code = http.GET();
|
||||
if (code != 200) {
|
||||
if (code == 404) fail("No published release yet");
|
||||
else if (code < 0) fail(HTTPClient::errorToString(code).c_str());
|
||||
else snprintf(s_msg, sizeof(s_msg), "GitHub answered %d", code), s_state = FAILED;
|
||||
http.end();
|
||||
return;
|
||||
}
|
||||
String body = http.getString(); // tens of KB: lands in PSRAM (large mallocs do)
|
||||
http.end();
|
||||
const char* b = body.c_str();
|
||||
if (!jsonStr(b, "tag_name", s_latest, sizeof(s_latest))) { fail("Unexpected answer from GitHub"); return; }
|
||||
const char* a = strstr(b, OTA_ASSET_SUFFIX "\""); // the asset's "name" comes before its size / URL
|
||||
if (!a) { snprintf(s_msg, sizeof(s_msg), "%s has no image for this device", s_latest); s_state = FAILED; return; }
|
||||
const char* sz = strstr(a, "\"size\":");
|
||||
s_size = sz ? (uint32_t)strtoul(sz + 7, nullptr, 10) : 0;
|
||||
if (!jsonStr(a, "browser_download_url", s_url, sizeof(s_url)) || !s_size) { fail("Unexpected answer from GitHub"); return; }
|
||||
uint32_t have = versionNum(FIRMWARE_VERSION), got = versionNum(s_latest);
|
||||
s_newer = !have || got > have;
|
||||
s_state = CHECKED;
|
||||
}
|
||||
|
||||
static void install(HTTPClient& http, NetworkClientSecure& tls) {
|
||||
s_written = 0;
|
||||
if (!http.begin(tls, s_url)) { fail("Bad download URL"); return; }
|
||||
int code = http.GET(); // github.com answers with a redirect to its download host
|
||||
if (code != 200) {
|
||||
if (code < 0) fail(HTTPClient::errorToString(code).c_str());
|
||||
else snprintf(s_msg, sizeof(s_msg), "Download answered %d", code), s_state = FAILED;
|
||||
http.end();
|
||||
return;
|
||||
}
|
||||
int len = http.getSize();
|
||||
if (len <= 0 || (uint32_t)len != s_size) { fail("Download size doesn't match the release"); http.end(); return; }
|
||||
if (!Update.begin(len)) { snprintf(s_msg, sizeof(s_msg), "No room for the update: %s", Update.errorString()); s_state = FAILED; http.end(); return; }
|
||||
uint8_t* buf = (uint8_t*)heap_caps_malloc(4096, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
if (!buf) buf = (uint8_t*)malloc(4096);
|
||||
NetworkClient* in = http.getStreamPtr();
|
||||
uint32_t last_data = millis();
|
||||
bool ok = buf != nullptr;
|
||||
if (!ok) snprintf(s_msg, sizeof(s_msg), "Out of memory");
|
||||
while (ok && s_written < (uint32_t)len) {
|
||||
size_t avail = in->available();
|
||||
if (!avail) {
|
||||
if (!in->connected() || millis() - last_data > 20000) { snprintf(s_msg, sizeof(s_msg), "Download stalled"); ok = false; break; }
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
continue;
|
||||
}
|
||||
int n = in->readBytes(buf, avail > 4096 ? 4096 : avail);
|
||||
if (n <= 0) continue;
|
||||
last_data = millis();
|
||||
// The image header: ESP magic, and the chip (offset 12, little endian) must be this one.
|
||||
if (s_written == 0 && (n < 16 || buf[0] != 0xE9 || (buf[12] | (buf[13] << 8)) != CONFIG_IDF_FIRMWARE_CHIP_ID)) {
|
||||
snprintf(s_msg, sizeof(s_msg), "Not a firmware image for this chip");
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (Update.write(buf, n) != (size_t)n) { snprintf(s_msg, sizeof(s_msg), "Flash write failed: %s", Update.errorString()); ok = false; break; }
|
||||
s_written += n;
|
||||
}
|
||||
free(buf);
|
||||
http.end();
|
||||
if (!ok) { Update.abort(); s_state = FAILED; return; }
|
||||
if (!Update.end(true)) { snprintf(s_msg, sizeof(s_msg), "Image rejected: %s", Update.errorString()); s_state = FAILED; return; }
|
||||
s_state = DONE;
|
||||
}
|
||||
|
||||
static void task(void*) {
|
||||
mbedtls_platform_set_calloc_free(lvport::tlsCalloc, heap_caps_free); // TLS buffers in PSRAM
|
||||
{
|
||||
NetworkClientSecure tls;
|
||||
tls.setCACertBundle(ota_crt_bundle_start, ota_crt_bundle_end - ota_crt_bundle_start);
|
||||
tls.setHandshakeTimeout(20);
|
||||
HTTPClient http;
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.setConnectTimeout(10000);
|
||||
http.setTimeout(20000);
|
||||
http.setUserAgent("MeshCore-Solo-OTA");
|
||||
if (s_job == 1) check(http, tls); else install(http, tls);
|
||||
}
|
||||
s_task = nullptr;
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
static bool startTask() {
|
||||
s_state = s_job == 1 ? CHECKING : INSTALLING;
|
||||
if (xTaskCreatePinnedToCore(task, "ota", 12288, nullptr, 1, &s_task, 0) != pdPASS) {
|
||||
s_task = nullptr;
|
||||
fail("Out of memory (update task)");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace ota
|
||||
|
||||
static void onOpenOta(lv_event_t* e) { (void)e; s_ui->showOta(); }
|
||||
static void onOtaButton(lv_event_t* e) { (void)e; s_ui->otaAction(); }
|
||||
|
||||
void UITask::showOta() {
|
||||
_screen = SCR_OTA;
|
||||
buildOta();
|
||||
}
|
||||
|
||||
bool UITask::otaBusy() const { return ota::s_state == ota::CHECKING || ota::s_state == ota::INSTALLING || ota::s_state == ota::DONE; }
|
||||
|
||||
void UITask::buildOta() {
|
||||
lv_obj_t* body = newScreen("Firmware update", true);
|
||||
char t[96];
|
||||
snprintf(t, sizeof(t), "Installed: %s", FIRMWARE_VERSION);
|
||||
label(body, t, THEME_FONT_BODY, theme::TEXT);
|
||||
_ota_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
||||
lv_label_set_long_mode(_ota_status, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(_ota_status, LV_PCT(100));
|
||||
_ota_bar = lv_bar_create(body);
|
||||
lv_obj_set_size(_ota_bar, LV_PCT(100), 10);
|
||||
lv_bar_set_range(_ota_bar, 0, 1000);
|
||||
lv_obj_set_style_bg_color(_ota_bar, lv_color_hex(theme::SURFACE), LV_PART_MAIN);
|
||||
lv_obj_set_style_bg_color(_ota_bar, lv_color_hex(theme::ACCENT), LV_PART_INDICATOR);
|
||||
lv_obj_t* b = lv_button_create(body);
|
||||
lv_obj_set_size(b, LV_PCT(100), 40);
|
||||
lv_obj_set_style_shadow_width(b, 0, 0);
|
||||
lv_obj_set_style_radius(b, theme::RADIUS, 0);
|
||||
lv_obj_set_style_bg_color(b, lv_color_hex(theme::ACCENT_DIM), 0);
|
||||
lv_obj_add_event_cb(b, onOtaButton, LV_EVENT_CLICKED, NULL);
|
||||
_ota_btn = b;
|
||||
_ota_btn_lbl = label(b, "", THEME_FONT_BODY, theme::TEXT);
|
||||
lv_obj_center(_ota_btn_lbl);
|
||||
lv_obj_t* h = label(body, "From github.com/" OTA_REPO " releases, over WiFi. About a minute; keep the device "
|
||||
"charged. Settings, contacts and messages are kept.", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
||||
lv_label_set_long_mode(h, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(h, LV_PCT(100));
|
||||
refreshOta();
|
||||
}
|
||||
|
||||
void UITask::refreshOta() {
|
||||
if (_screen != SCR_OTA || !_ota_status) return;
|
||||
using namespace ota;
|
||||
const char* btn = "Check for update";
|
||||
bool bar = false, btn_on = true;
|
||||
char st[128];
|
||||
st[0] = '\0';
|
||||
switch (s_state) {
|
||||
case IDLE:
|
||||
#if OTA_SUPPORTED
|
||||
snprintf(st, sizeof(st), "Looks for a newer release on GitHub.");
|
||||
#elif defined(SIM_PLATFORM)
|
||||
snprintf(st, sizeof(st), "Not available in the simulator.");
|
||||
btn_on = false;
|
||||
#else
|
||||
snprintf(st, sizeof(st), "Not available in this build (needs the Arduino-ESP32 3.x firmware line).");
|
||||
btn_on = false;
|
||||
#endif
|
||||
break;
|
||||
case CONNECTING: snprintf(st, sizeof(st), "Connecting to WiFi..."); btn_on = false; break;
|
||||
case CHECKING: snprintf(st, sizeof(st), "Asking GitHub for the latest release..."); btn_on = false; break;
|
||||
case CHECKED:
|
||||
if (s_newer) {
|
||||
snprintf(st, sizeof(st), "%s is available.", s_latest);
|
||||
static char b[48];
|
||||
snprintf(b, sizeof(b), LV_SYMBOL_DOWNLOAD " Install %s (%.1f MB)", s_latest, s_size / 1048576.0);
|
||||
btn = b;
|
||||
} else {
|
||||
snprintf(st, sizeof(st), "Up to date - the latest release is %s.", s_latest);
|
||||
btn = "Check again";
|
||||
}
|
||||
break;
|
||||
case INSTALLING:
|
||||
snprintf(st, sizeof(st), "Installing %s: %lu of %lu KB. Don't turn the device off.", s_latest,
|
||||
(unsigned long)(s_written / 1024), (unsigned long)(s_size / 1024));
|
||||
bar = true; btn_on = false;
|
||||
if (s_size) lv_bar_set_value(_ota_bar, (int32_t)((uint64_t)s_written * 1000 / s_size), LV_ANIM_OFF);
|
||||
break;
|
||||
case DONE:
|
||||
snprintf(st, sizeof(st), "Installed %s - restarting...", s_latest);
|
||||
bar = true; btn_on = false;
|
||||
lv_bar_set_value(_ota_bar, 1000, LV_ANIM_OFF);
|
||||
break;
|
||||
case FAILED:
|
||||
snprintf(st, sizeof(st), "%s", s_msg);
|
||||
btn = "Try again";
|
||||
break;
|
||||
}
|
||||
lv_label_set_text(_ota_status, st);
|
||||
lv_label_set_text(_ota_btn_lbl, btn);
|
||||
if (bar) lv_obj_remove_flag(_ota_bar, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(_ota_bar, LV_OBJ_FLAG_HIDDEN);
|
||||
if (btn_on) lv_obj_remove_flag(_ota_btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(_ota_btn, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
// The button: check (after bringing the WiFi up), then install what was found.
|
||||
void UITask::otaAction() {
|
||||
#if OTA_SUPPORTED
|
||||
using namespace ota;
|
||||
if (otaBusy() || s_state == CONNECTING) return;
|
||||
bool install = s_state == CHECKED && s_newer;
|
||||
char ssid[33], pass[65];
|
||||
if (!lvport::wifiAllowed()) { showToast("WiFi is off - Settings > WiFi"); return; }
|
||||
if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) { showToast("Pick a WiFi network first - Settings > WiFi", 3000); return; }
|
||||
if (mapview::s_dl.active()) { showToast("A map download is running - stop it first"); return; }
|
||||
mapview::s_dl.liveEnd(); // the update owns the WiFi from here
|
||||
s_job = install ? 2 : 1;
|
||||
if (lvport::netState() == lvport::NET_UP) startTask();
|
||||
else { lvport::netBegin(ssid, pass); s_state = CONNECTING; s_since = millis(); }
|
||||
refreshOta();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Every loop pass (the install runs on with the screen off).
|
||||
void UITask::otaTick() {
|
||||
using namespace ota;
|
||||
static uint32_t next = 0;
|
||||
if (s_state == IDLE || (int32_t)(millis() - next) < 0) return;
|
||||
next = millis() + 250;
|
||||
#if OTA_SUPPORTED
|
||||
if (s_state == CONNECTING) {
|
||||
int ns = lvport::netState();
|
||||
if (ns == lvport::NET_UP) startTask();
|
||||
else if (ns == lvport::NET_FAILED || millis() - s_since > 20000) { lvport::netEnd(); fail("WiFi: can't connect"); }
|
||||
} else if (s_state == DONE) {
|
||||
if (!s_since || s_job) { s_since = millis(); s_job = 0; }
|
||||
else if (millis() - s_since > 2000) { the_mesh.savePrefs(); ESP.restart(); }
|
||||
} else if (s_state == FAILED && _screen != SCR_OTA) {
|
||||
lvport::netEnd();
|
||||
s_state = IDLE;
|
||||
}
|
||||
#endif
|
||||
refreshOta();
|
||||
}
|
||||
|
||||
// Leaving the screen: the WiFi goes unless the update is running.
|
||||
void UITask::otaLeave() {
|
||||
if (otaBusy()) { showToast("Updating - wait for it to finish"); return; }
|
||||
if (ota::s_state != ota::IDLE) { lvport::netEnd(); ota::s_state = ota::IDLE; }
|
||||
showSettings();
|
||||
}
|
||||
@@ -208,6 +208,8 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs
|
||||
buildStatusBar();
|
||||
applyDisplayPrefs(); // a slider percentage overrides the level main.cpp set
|
||||
showHome();
|
||||
lvport::loadPin(_pin, sizeof(_pin));
|
||||
if (_pin[0]) lockScreen(); // a reboot doesn't get round the PIN
|
||||
}
|
||||
|
||||
MyMesh::Listener* UITask::meshListener() { return _core; }
|
||||
@@ -259,7 +261,7 @@ void UITask::loop() {
|
||||
if (lvport::touched()) { lvport::swallowTouch(); wake(); }
|
||||
} else {
|
||||
uint32_t aoff = autoOffMillis();
|
||||
if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging()) sleep();
|
||||
if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging() && !otaBusy()) sleep();
|
||||
}
|
||||
|
||||
_core->loop();
|
||||
@@ -285,6 +287,7 @@ void UITask::loop() {
|
||||
#endif
|
||||
checkLowBattery();
|
||||
mapDownloadTick(); // a map download keeps running on any screen, and asleep
|
||||
otaTick(); // so does a firmware update
|
||||
if ((int32_t)(millis() - _next_trackback_ms) >= 0) { // walking the trail back, on any screen
|
||||
_next_trackback_ms = millis() + 1000;
|
||||
navPollTrackBack();
|
||||
@@ -382,7 +385,7 @@ void UITask::sleep() {
|
||||
if (_asleep) return;
|
||||
_asleep = true;
|
||||
if (_display) _display->turnOff();
|
||||
if (_prefs && _prefs->auto_lock) lockScreen(); // Settings > Display & power > Lock screen
|
||||
if ((_prefs && _prefs->auto_lock) || _pin[0]) lockScreen(); // Lock screen, or a screen PIN
|
||||
}
|
||||
|
||||
void UITask::wake() {
|
||||
@@ -613,6 +616,7 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
|
||||
_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_scanning = false;
|
||||
_ota_status = _ota_bar = _ota_btn = _ota_btn_lbl = nullptr;
|
||||
for (lv_obj_t*& t : _map_tiles) t = nullptr;
|
||||
lv_obj_t* prev = lv_screen_active();
|
||||
lv_obj_t* scr = lv_obj_create(NULL);
|
||||
@@ -665,6 +669,7 @@ void UITask::back() {
|
||||
case SCR_FAVS: if (_nav_overlay) navClosePopup(); else showHome(); break;
|
||||
case SCR_DIAG: if (_nav_overlay) navClosePopup(); else showHome(); break;
|
||||
case SCR_ADMIN_PICK: showHome(); break;
|
||||
case SCR_OTA: otaLeave(); break;
|
||||
case SCR_COMPASS: showHome(); break;
|
||||
case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break;
|
||||
case SCR_SETTINGS: if (_nav_overlay) navClosePopup(); else showHome(); break;
|
||||
@@ -2092,6 +2097,8 @@ static void onSchemaDropdown(lv_event_t* e) {
|
||||
static void onOpenSchemaPage(lv_event_t* e) { s_ui->showSchemaSettings((int)(uintptr_t)lv_event_get_user_data(e)); }
|
||||
static void onPruneContacts(lv_event_t* e) { (void)e; s_ui->pruneContacts(); }
|
||||
static void onOpenQuickMsgs(lv_event_t* e);
|
||||
static void onPinSetup(lv_event_t* e); // DeviceScreen.h
|
||||
static void onOpenOta(lv_event_t* e); // OtaScreen.h
|
||||
static void bluetoothRow(lv_obj_t* body);
|
||||
static void onVolumeSlider(lv_event_t* e) {
|
||||
s_ui->setSoundVolume((int)lv_slider_get_value((lv_obj_t*)lv_event_get_target(e)));
|
||||
@@ -2158,6 +2165,11 @@ void UITask::buildSchemaSettings() {
|
||||
_prune_lbl = label(b, LV_SYMBOL_TRASH " Remove inactive contacts now", THEME_FONT_BODY, theme::TEXT);
|
||||
lv_obj_center(_prune_lbl);
|
||||
}
|
||||
if (_settings_page == settings::PG_DEVICE) { // after Lock screen's section, in NVS not the schema
|
||||
sectionTitle(body, "SECURITY");
|
||||
listRow(body, LV_SYMBOL_EYE_CLOSE " Screen PIN", _pin[0] ? "On - asked when the screen wakes" : "Off",
|
||||
onPinSetup, NULL);
|
||||
}
|
||||
if (_settings_page == settings::PG_SOUND) buildSoundRows(body, false); // the melodies
|
||||
if (_settings_page == settings::PG_MESSAGES) {
|
||||
sectionTitle(body, "QUICK MESSAGES");
|
||||
@@ -2292,6 +2304,7 @@ void UITask::buildSettings() {
|
||||
wifiRow(body);
|
||||
sectionTitle(body, "SYSTEM");
|
||||
listRow(body, LV_SYMBOL_EDIT " Name", the_mesh.getNodeName(), onNodeName, NULL);
|
||||
listRow(body, LV_SYMBOL_DOWNLOAD " Firmware update", FIRMWARE_VERSION, onOpenOta, NULL);
|
||||
if (_core->gpsAvailable()) {
|
||||
lv_obj_t* sw = switchRow(body, "GPS", "For maps and sharing", nullptr);
|
||||
if (_core->gpsEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED);
|
||||
@@ -2343,3 +2356,4 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) {
|
||||
#include "RepeaterScreen.h"
|
||||
#include "SoundScreen.h"
|
||||
#include "QuickScreen.h"
|
||||
#include "OtaScreen.h"
|
||||
|
||||
@@ -47,6 +47,9 @@ public:
|
||||
void showSchemaSettings(int page);
|
||||
// Home > Admin: the repeaters / room servers to log in to (AdminScreen.h)
|
||||
void showAdminPick();
|
||||
// Settings > System > Firmware update (OtaScreen.h)
|
||||
void showOta();
|
||||
void otaAction();
|
||||
void adminPick(int row);
|
||||
void pruneContacts();
|
||||
void setBrightnessPct(uint8_t pct, bool save);
|
||||
@@ -67,6 +70,7 @@ public:
|
||||
void mapDownloadResume();
|
||||
void mapDownloadDiscard();
|
||||
void mapDownloadZmax(int delta);
|
||||
void setLiveTiles(bool on); // Map tools > Live tiles
|
||||
// Navigation map (NavMap.h)
|
||||
void navTargetsPopup();
|
||||
void navClosePopup();
|
||||
@@ -205,6 +209,11 @@ public:
|
||||
void powerPopup(bool restart);
|
||||
void unlockScreen();
|
||||
bool locked() const;
|
||||
void pinKey(const char* key); // the lock screen's keypad
|
||||
void pinSetupPopup(); // Settings > Display & power > Screen PIN
|
||||
void pinSetupKey(const char* key);
|
||||
void pinRemove();
|
||||
void pinRowRefresh();
|
||||
void showFavourites();
|
||||
void showDiag();
|
||||
void diagTab(int tab);
|
||||
@@ -225,7 +234,7 @@ public:
|
||||
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; }
|
||||
|
||||
private:
|
||||
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK };
|
||||
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA };
|
||||
|
||||
void buildStatusBar();
|
||||
void refreshStatusBar();
|
||||
@@ -239,6 +248,11 @@ private:
|
||||
void buildSchemaSettings();
|
||||
void schemaRows(lv_obj_t* body, uint8_t page);
|
||||
void buildAdminPick();
|
||||
void buildOta();
|
||||
void refreshOta();
|
||||
void otaTick();
|
||||
void otaLeave();
|
||||
bool otaBusy() const;
|
||||
void buildNearby();
|
||||
void refreshNearbyList();
|
||||
uint32_t nearbySignature() const;
|
||||
@@ -270,6 +284,7 @@ private:
|
||||
bool navCurrentTarget(int32_t& lat, int32_t& lon, char* name, int n, bool& person, bool& set);
|
||||
bool exportTrailGpx(char* name_out, size_t n);
|
||||
void mapDownloadTick();
|
||||
void mapLiveBegin();
|
||||
void refreshDownloadPopup();
|
||||
void buildWifi();
|
||||
void pollWifiScan();
|
||||
@@ -346,11 +361,22 @@ private:
|
||||
uint32_t _next_trackback_ms = 0;
|
||||
uint32_t _next_clock_ms = 0;
|
||||
uint8_t _settings_page = 0; // settings::Page shown in SCR_SETTINGS_NAV
|
||||
lv_obj_t* _ota_status = nullptr;
|
||||
lv_obj_t* _ota_bar = nullptr;
|
||||
lv_obj_t* _ota_btn = nullptr;
|
||||
lv_obj_t* _ota_btn_lbl = nullptr;
|
||||
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
|
||||
uint32_t _next_batt_ms = 0;
|
||||
lv_obj_t* _prune_lbl = nullptr;
|
||||
// Screen PIN (DeviceScreen.h); stored in NVS (lvport::loadPin)
|
||||
char _pin[9] = ""; // "" = no PIN
|
||||
char _pin_entry[9] = ""; // digits typed so far
|
||||
char _pin_new[9] = ""; // setup: the first entry, waiting for its confirmation
|
||||
uint8_t _pin_fails = 0;
|
||||
uint32_t _pin_block_until = 0; // millis() until which entry is refused after 5 misses
|
||||
|
||||
// Open conversation (SCR_THREAD)
|
||||
bool _thread_is_channel = false;
|
||||
|
||||
@@ -73,6 +73,10 @@ public:
|
||||
|
||||
// Forget the "no tile here" answers (new tiles may have been written).
|
||||
void forgetMissing() { for (Missing& m : _missing) m.z = -1; }
|
||||
// Forget one "no tile here" answer (that tile was just fetched).
|
||||
void forgetMissing(int z, int x, int y) {
|
||||
for (Missing& m : _missing) if (m.z == z && m.x == x && m.y == y) m.z = -1;
|
||||
}
|
||||
|
||||
// Forget everything (e.g. the card was swapped); keeps the buffers.
|
||||
void invalidate() { for (Slot& s : _slots) { s.z = -1; s.used = 0; } forgetMissing(); }
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
// {y}, line 2: attribution); without it, OpenTopoMap. Whatever the source,
|
||||
// its attribution is written to <root>/attribution.txt for the map to show.
|
||||
//
|
||||
// Live tiles: while the map is open, single tiles it is missing are fetched
|
||||
// the same way (one at a time, between the job's steps when there is no job),
|
||||
// the WiFi connected on the first miss and dropped by the caller (liveEnd)
|
||||
// once the map has been closed for a while.
|
||||
//
|
||||
// Single-TU fragment: included by ui-lvgl/UITask.cpp only (after LvglPort.h).
|
||||
|
||||
#include <math.h>
|
||||
@@ -98,6 +103,8 @@ public:
|
||||
|
||||
bool start(const TileArea& a, const char* ssid, const char* pass) {
|
||||
if (active()) return false;
|
||||
if (_lv_fetching) { lvport::fetchAbandon(); _lv_fetching = false; } // the job takes the fetcher
|
||||
_lv_state = LV_IDLE;
|
||||
_area = a;
|
||||
_total = countTiles(a);
|
||||
_done = _skipped = _failed = _consec_fail = 0;
|
||||
@@ -123,7 +130,45 @@ public:
|
||||
finish(CANCELLED, "Cancelled");
|
||||
}
|
||||
|
||||
// ── Live tiles ──
|
||||
// Enabled with the credentials; nothing connects until a tile is requested.
|
||||
void liveBegin(const char* ssid, const char* pass) {
|
||||
snprintf(_lv_ssid, sizeof(_lv_ssid), "%s", ssid);
|
||||
snprintf(_lv_pass, sizeof(_lv_pass), "%s", pass);
|
||||
if (!_live) { loadSource(); writeAttribution(); } // also creates <root> for the provider
|
||||
_live = true;
|
||||
}
|
||||
void liveEnd() {
|
||||
if (!_live) return;
|
||||
_live = false;
|
||||
if (_lv_fetching) { lvport::fetchAbandon(); _lv_fetching = false; }
|
||||
_lv_n = 0;
|
||||
if (_lv_state != LV_IDLE && !active()) lvport::netEnd();
|
||||
_lv_state = LV_IDLE;
|
||||
}
|
||||
bool liveOn() const { return _live; }
|
||||
bool liveConnecting() const { return _live && _lv_state == LV_CONNECTING; }
|
||||
int liveQueued() const { return _live ? _lv_n + (_lv_fetching ? 1 : 0) : 0; }
|
||||
// A tile the map is missing (duplicates, recent failures and a full queue ignored).
|
||||
void liveRequest(int z, int x, int y) {
|
||||
if (!_live || active()) return;
|
||||
if (_lv_fetching && _lv_cur.z == z && _lv_cur.x == x && _lv_cur.y == y) return;
|
||||
for (int i = 0; i < _lv_n; i++) if (_lv_q[i].z == z && _lv_q[i].x == x && _lv_q[i].y == y) return;
|
||||
for (const LiveTile& f : _lv_failed) if (f.z == z && f.x == x && f.y == y) return;
|
||||
if (_lv_n >= LV_QUEUE) return;
|
||||
LiveTile& t = _lv_q[_lv_n++];
|
||||
t.z = (int16_t)z; t.x = x; t.y = y;
|
||||
}
|
||||
// Next tile written since the last call (the map forgets it was missing).
|
||||
bool liveTake(int& z, int& x, int& y) {
|
||||
if (!_lv_done_n) return false;
|
||||
const LiveTile& t = _lv_done[--_lv_done_n];
|
||||
z = t.z; x = t.x; y = t.y;
|
||||
return true;
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (!active() && _live) { liveLoop(); return; }
|
||||
if (_state == CONNECTING) {
|
||||
int ns = lvport::netState();
|
||||
if (ns == lvport::NET_UP) { _state = RUNNING; return; }
|
||||
@@ -149,7 +194,7 @@ public:
|
||||
const uint8_t* data = lvport::fetchData(len);
|
||||
if (!looksLikeImage(data, len)) onFailure("Server didn't send a PNG tile");
|
||||
else if (isPlaceholder(data, len)) { lvport::fetchRelease(); finish(FAILED, "Server sends a placeholder (blocked / key?)"); return; }
|
||||
else if (!writeTile(data, len)) { lvport::fetchRelease(); finish(FAILED, "Can't write to the SD card"); return; }
|
||||
else if (!writeTile(_z, _x, _y, data, len)) { lvport::fetchRelease(); finish(FAILED, "Can't write to the SD card"); return; }
|
||||
else { _done++; _consec_fail = 0; }
|
||||
} else {
|
||||
const char* why = lvport::fetchError();
|
||||
@@ -196,6 +241,86 @@ private:
|
||||
TileArea _job = {0, 0, 0, 0, 0, 0};
|
||||
bool _has_job = false, _job_checked = false;
|
||||
|
||||
// Live tiles
|
||||
struct LiveTile { int16_t z = -1; int32_t x = 0, y = 0; };
|
||||
enum LiveState : uint8_t { LV_IDLE, LV_CONNECTING, LV_UP, LV_BACKOFF };
|
||||
static const int LV_QUEUE = 8, LV_FAILED = 16, LV_DONE = 8;
|
||||
bool _live = false;
|
||||
LiveState _lv_state = LV_IDLE;
|
||||
char _lv_ssid[33] = "", _lv_pass[65] = "";
|
||||
LiveTile _lv_q[LV_QUEUE], _lv_failed[LV_FAILED], _lv_done[LV_DONE], _lv_cur;
|
||||
int _lv_n = 0, _lv_failed_next = 0, _lv_done_n = 0;
|
||||
bool _lv_fetching = false;
|
||||
uint8_t _lv_consec_fail = 0;
|
||||
uint32_t _lv_since = 0; // connect start / back-off end
|
||||
|
||||
void liveFailed(const LiveTile& t) {
|
||||
_lv_failed[_lv_failed_next++ % LV_FAILED] = t;
|
||||
if (++_lv_consec_fail >= 3) { // the server or the network is down: pause
|
||||
_lv_consec_fail = 0;
|
||||
_lv_state = LV_BACKOFF;
|
||||
_lv_since = millis() + 30000;
|
||||
lvport::netEnd();
|
||||
}
|
||||
}
|
||||
|
||||
void liveLoop() {
|
||||
switch (_lv_state) {
|
||||
case LV_IDLE:
|
||||
case LV_BACKOFF:
|
||||
if (!_lv_n) return;
|
||||
if (_lv_state == LV_BACKOFF && (int32_t)(millis() - _lv_since) < 0) return;
|
||||
if (!_lv_ssid[0]) return;
|
||||
lvport::netBegin(_lv_ssid, _lv_pass);
|
||||
_lv_state = LV_CONNECTING;
|
||||
_lv_since = millis();
|
||||
return;
|
||||
case LV_CONNECTING: {
|
||||
int ns = lvport::netState();
|
||||
if (ns == lvport::NET_UP) { _lv_state = LV_UP; return; }
|
||||
if (ns == lvport::NET_FAILED || millis() - _lv_since > 20000) {
|
||||
lvport::netEnd();
|
||||
_lv_state = LV_BACKOFF;
|
||||
_lv_since = millis() + 60000;
|
||||
_lv_n = 0; // the map asks again for what it still misses
|
||||
}
|
||||
return;
|
||||
}
|
||||
case LV_UP:
|
||||
break;
|
||||
}
|
||||
if (lvport::netState() != lvport::NET_UP && !_lv_fetching) { _lv_state = LV_IDLE; return; } // dropped: reconnect
|
||||
if (_lv_fetching) {
|
||||
int r = lvport::fetchPoll();
|
||||
if (r == 0) {
|
||||
if (millis() - _last_start < 30000) return;
|
||||
lvport::fetchAbandon();
|
||||
_lv_fetching = false;
|
||||
liveFailed(_lv_cur);
|
||||
return;
|
||||
}
|
||||
_lv_fetching = false;
|
||||
size_t len = 0;
|
||||
const uint8_t* data = r > 0 ? lvport::fetchData(len) : nullptr;
|
||||
if (r > 0 && looksLikeImage(data, len) && writeTile(_lv_cur.z, _lv_cur.x, _lv_cur.y, data, len)) {
|
||||
_lv_consec_fail = 0;
|
||||
if (_lv_done_n < LV_DONE) _lv_done[_lv_done_n++] = _lv_cur;
|
||||
} else {
|
||||
liveFailed(_lv_cur);
|
||||
}
|
||||
lvport::fetchRelease();
|
||||
return;
|
||||
}
|
||||
if (!_lv_n || millis() - _last_start < 120) return; // be gentle with the server
|
||||
_lv_cur = _lv_q[0];
|
||||
for (int i = 1; i < _lv_n; i++) _lv_q[i - 1] = _lv_q[i];
|
||||
_lv_n--;
|
||||
char url[200];
|
||||
buildUrl(url, sizeof(url), _lv_cur.z, _lv_cur.x, _lv_cur.y);
|
||||
if (lvport::fetchStart(url) > 0) { _lv_fetching = true; _last_start = millis(); }
|
||||
else _lv_q[_lv_n++] = _lv_cur; // fetcher busy: back in the queue
|
||||
}
|
||||
|
||||
void fail(const char* m) { snprintf(_msg, sizeof(_msg), "%s", m); _state = FAILED; }
|
||||
|
||||
void finish(State s, const char* m) {
|
||||
@@ -312,9 +437,9 @@ private:
|
||||
return false;
|
||||
}
|
||||
|
||||
bool writeTile(const uint8_t* d, size_t n) {
|
||||
bool writeTile(int z, int x, int y, const uint8_t* d, size_t n) {
|
||||
char path[96], tmp[100];
|
||||
tilePath(path, sizeof(path), _z, _x, _y);
|
||||
tilePath(path, sizeof(path), z, x, y);
|
||||
makeParents(path);
|
||||
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
|
||||
FILE* f = fopen(tmp, "wb");
|
||||
|
||||
Reference in New Issue
Block a user