diff --git a/.github/workflows/build-solo-firmwares.yml b/.github/workflows/build-solo-firmwares.yml index 5194facc..e6329011 100644 --- a/.github/workflows/build-solo-firmwares.yml +++ b/.github/workflows/build-solo-firmwares.yml @@ -23,12 +23,13 @@ jobs: - name: Clone Repo uses: actions/checkout@v4 - - name: List solo_dual envs + - name: List solo envs id: list-envs run: | + # *_solo_dual: the OLED / e-ink boards; *_solo_lvgl: the LVGL touch UI (Wio Tracker L2). envs=$(grep -rh '^\[env:' solo/ \ | sed 's/\[env://;s/\]//' \ - | grep '_solo_dual$' \ + | grep -E '_solo_dual$|_solo_lvgl$' \ | jq -R -s -c 'split("\n") | map(select(length > 0))') echo "envs=$envs" >> $GITHUB_OUTPUT @@ -61,7 +62,7 @@ jobs: - name: Rename firmware run: | - DEVICE_NAME=$(echo "${{ matrix.env }}" | sed 's/_solo_dual$//;s/_companion$//' | tr '_' '-') + DEVICE_NAME=$(echo "${{ matrix.env }}" | sed 's/_solo_dual$//;s/_solo_lvgl$//;s/_companion$//' | tr '_' '-') mv out/*.uf2 "out/solo-${{ env.BUILD_VERSION }}-${DEVICE_NAME}.uf2" 2>/dev/null || true # nRF52 DFU package (adafruit-nrfutil) — used for OTA / BLE-DFU updates. mv out/*.zip "out/solo-${{ env.BUILD_VERSION }}-${DEVICE_NAME}-ota.zip" 2>/dev/null || true @@ -71,6 +72,15 @@ jobs: # already on the chip, so shipping it next to the merged image would only # invite bricked-looking flashes. Anyone who wants it can build it locally. mv out/${{ matrix.env }}-*-merged.bin "out/solo-${{ env.BUILD_VERSION }}-${DEVICE_NAME}-merged.bin" 2>/dev/null || true + # Exception: boards that update themselves over WiFi (Settings > Firmware + # update, ui-lvgl/OtaScreen.h) fetch the app-only image, named -ota.bin + # so it isn't mistaken for a fresh-install image. + case "${{ matrix.env }}" in + *_solo_lvgl) + for f in out/${{ matrix.env }}-*.bin; do + if [ -e "$f" ]; then mv "$f" "out/solo-${{ env.BUILD_VERSION }}-${DEVICE_NAME}-ota.bin"; fi + done ;; + esac rm -f out/${{ matrix.env }}-*.bin - name: Upload Firmware @@ -105,3 +115,4 @@ jobs: out/*.uf2 out/*.zip out/*-merged.bin + out/*-ota.bin diff --git a/docs/development/l2-hardware-checklist.md b/docs/development/l2-hardware-checklist.md index 6ea6559b..9e98d0b1 100644 --- a/docs/development/l2-hardware-checklist.md +++ b/docs/development/l2-hardware-checklist.md @@ -251,3 +251,29 @@ The speaker plays through the ES8311 codec (I2S MCLK 10 / BCK 11 / WS 12 / DOUT - [ ] Nearby header: back, title, advert (tower), map, sort, scan (refresh icon) -- nothing overlaps; advert popup sends Nearby only / Everyone and sets Auto-advert - [ ] Status bar, right to left next to the battery: Bluetooth (grey on, white with the app), GPS (grey searching, green fix), bell (alarm set), mute, then amber: auto-advert (tower), trail (route), live share (pin), repeater (loop), arrival alert (flag, with a target); each appears / disappears within a second of switching it - [ ] All icons at once still leave the clock readable on the left + +## Roadmap stage 5: screen PIN (2026-09-26) +- [ ] Settings > Display & power > SECURITY > Screen PIN "Off"; tap → keypad; 1234 ✓, 1234 ✓ → toast "PIN set", row "On", list stays at the bottom +- [ ] A different second entry → "Didn't match - new PIN again"; fewer than 4 digits + ✓ → "At least 4 digits" +- [ ] Screen off (WAKE or timeout) and on → PIN card (clock, unread count, keypad) instead of the slider, even with "Lock screen" off +- [ ] Right PIN unlocks as soon as the last digit is in; wrong one → "Wrong PIN - n tries left"; 5 misses → "Too many tries - wait 30 s" counting down, keys ignored meanwhile +- [ ] USER button does nothing on the PIN card; a ringing alarm is still dismissed by the buttons +- [ ] Reboot → PIN card straight after boot +- [ ] Change PIN (row tap with a PIN set) works; "Remove" in the popup's header → "PIN removed", slider lock / no lock as before +- [ ] PIN survives a reboot and a firmware update (NVS "mc_lock"), not stored in NodePrefs + +## Roadmap stage 5: live map tiles (2026-09-26) +- [ ] Map > ☰ > MAP > "Live tiles" on by default; with WiFi on and a network saved, zoom into an area without tiles → pill "WiFi Connecting..." then "live n", tiles appear one by one, the "(map zN)" magnification goes away +- [ ] The fetched tiles are on the card (/maps/z/x/y.png): reopen the map offline → still there +- [ ] Leave the map: WiFi drops ~30 s later; back within 30 s → no reconnect +- [ ] Live tiles off → nothing fetched, toast "Live tiles off"; on without a saved network → "Pick a WiFi network first" +- [ ] WiFi off in Settings → no live fetching; an area download still works as before (and takes over from live tiles) +- [ ] No SD card map folder at all + live tiles → the folder is created and the map fills in + +## Roadmap stage 5: firmware update (2026-09-26, Arduino 3.x build only) +- [ ] Settings > SYSTEM > Firmware update shows the installed version; 2.0.17 build: "Not available in this build" +- [ ] Check for update: connects WiFi, asks GitHub; before a release with an L2 image: "vX has no image for this device" (proves TLS + the API work) +- [ ] With a release carrying solo-vX-Wio-Tracker-L2-ota.bin: "vX is available" + Install (size in MB); install shows progress, screen stays on, back is refused ("Updating - wait") +- [ ] After "restarting..." the device boots the new version (Settings shows it); contacts, messages, settings, PIN and WiFi kept +- [ ] Up to date → "Up to date - the latest release is vX"; WiFi off / no network / map download running → toasts instead +- [ ] Wrong network in the middle (unplug router) → "Download stalled", old firmware still boots diff --git a/docs/development/l2-roadmap.md b/docs/development/l2-roadmap.md index 2f5ed5f3..6a8f2c2e 100644 --- a/docs/development/l2-roadmap.md +++ b/docs/development/l2-roadmap.md @@ -64,10 +64,21 @@ L1 splits its tools into many small screens because of the joystick and the ## 5. Security and internet -- [ ] Screen-lock PIN. -- [ ] Map tiles fetched live while online. -- [ ] One-button OTA from GitHub Releases (release asset + partition layout - check). +- [x] Screen-lock PIN: 4-8 digits in NVS (`lvport::loadPin`), asked on + every wake and after a reboot, 5 misses pause entry for 30 s; + Settings > Display & power > SECURITY. +- [x] Map tiles fetched live while online: the map queues tiles it is + missing, `TileDownloader` fetches them one at a time over WiFi (connected + on the first miss, dropped 30 s after leaving the map) and saves them to + the card; Map > ☰ > Live tiles (NVS, on by default). +- [x] One-button OTA from GitHub Releases: Settings > System > Firmware + update (`OtaScreen.h`). The latest release's `-Wio-Tracker-L2-ota.bin` + (app image; `build-solo-firmwares.yml` now publishes `*_solo_lvgl`) is + streamed over TLS verified with the framework's CA bundle into the idle + slot of `default_16MB.csv` (two 6.25 MB app slots, no layout change), + chip id checked, then restart. Arduino 3.x builds only. +- [ ] Before the first release with L2: make the 3.x env the + `*_solo_lvgl` one (stage 2's last item), then test an update end to end. ## 6. Theme @@ -79,3 +90,13 @@ L1 splits its tools into many small screens because of the joystick and the - [ ] Find out whether LVGL on every display variant gives consistency and a nicer look more easily. Adopt only if the result is better and every feature is kept (L1: 128×64 OLED, nRF52, RAM already 68% used). + +## Backlog (found along the way) + +- [ ] USB power detection: `WioTrackerL2Board::isExternalPowered()` reads + STATUS0 (0x40) bit 7 of the chip at I2C 0x22 and always gets 0 with + USB plugged in (0x40 reads 0x01). Needed for a charging indicator and + for skipping the low-battery shutdown on the cable. Find the right + chip / register (devices on the bus: 0x14, 0x18, 0x21, 0x22, 0x2c, + 0x34, 0x48, 0x5d). Found 2026-09-26. + diff --git a/examples/companion_radio/ui-lvgl/DeviceScreen.h b/examples/companion_radio/ui-lvgl/DeviceScreen.h index f3c141e6..0efa329a 100644 --- a/examples/companion_radio/ui-lvgl/DeviceScreen.h +++ b/examples/companion_radio/ui-lvgl/DeviceScreen.h @@ -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() { diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index 9e206909..284ede19 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -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() {} diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index 9c6ddc3a..b5e69378 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -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 diff --git a/examples/companion_radio/ui-lvgl/NavMap.h b/examples/companion_radio/ui-lvgl/NavMap.h index 6207cc5c..4e12c8e5 100644 --- a/examples/companion_radio/ui-lvgl/NavMap.h +++ b/examples/companion_radio/ui-lvgl/NavMap.h @@ -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); diff --git a/examples/companion_radio/ui-lvgl/OtaScreen.h b/examples/companion_radio/ui-lvgl/OtaScreen.h new file mode 100644 index 00000000..b0d31196 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/OtaScreen.h @@ -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 + #include + #include + 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(); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index f27bcaed..8b6cf676 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -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" diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index a86e9640..4317a09e 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.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; diff --git a/examples/companion_radio/ui-lvgl/map/TileCache.h b/examples/companion_radio/ui-lvgl/map/TileCache.h index 55c5a67d..a2f75642 100644 --- a/examples/companion_radio/ui-lvgl/map/TileCache.h +++ b/examples/companion_radio/ui-lvgl/map/TileCache.h @@ -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(); } diff --git a/examples/companion_radio/ui-lvgl/map/TileDownloader.h b/examples/companion_radio/ui-lvgl/map/TileDownloader.h index 40bbc287..d68e8616 100644 --- a/examples/companion_radio/ui-lvgl/map/TileDownloader.h +++ b/examples/companion_radio/ui-lvgl/map/TileDownloader.h @@ -16,6 +16,11 @@ // {y}, line 2: attribution); without it, OpenTopoMap. Whatever the source, // its attribution is written to /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 @@ -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 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"); diff --git a/solo/wio-tracker-l2/platformio.ini b/solo/wio-tracker-l2/platformio.ini index 962d8658..bcd8c11c 100644 --- a/solo/wio-tracker-l2/platformio.ini +++ b/solo/wio-tracker-l2/platformio.ini @@ -36,8 +36,11 @@ lib_deps = ${Wio_Tracker_L2.lib_deps} densaugeo/base64 @ ~1.4.0 -; ui-lvgl skeleton: the real L2 interface -- LVGL 9 frontend over the shared UI -; Core (docs/development/ui-core.md, step 5). Not *_solo_dual yet (no release). +; ui-lvgl: the real L2 interface -- LVGL 9 frontend over the shared UI Core +; (docs/development/ui-core.md, step 5). *_solo_lvgl envs are published by +; build-solo-firmwares.yml (merged image + the -ota.bin that Settings > Firmware +; update installs). Make the Arduino 3.x env below this one before tagging a +; release: only 3.x builds can update themselves (CA bundle for the TLS check). [env:Wio_Tracker_L2_companion_solo_lvgl] extends = Wio_Tracker_L2 build_flags = @@ -57,6 +60,7 @@ build_flags = -D MESH_IN_PSRAM=1 ; frees ~120 KB internal RAM for WiFi/TLS map downloads -D PIN_BUZZER=-1 ; no piezo pin: the speaker plays through the codec -D BUZZER_I2S + -D OTA_ASSET_SUFFIX='"-Wio-Tracker-L2-ota.bin"' ; Settings > Firmware update (Arduino 3.x builds) build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + @@ -92,3 +96,4 @@ lib_deps = HTTPClient NetworkClientSecure ; 3.x name of WiFiClientSecure (the header is still there) Preferences + Update ; Settings > Firmware update