From 0fa7966280838839f55202f4a81e83dfa15037d6 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:26:44 +0200 Subject: [PATCH] feat(ui-lvgl): on-device map download over WiFi The map's download button fetches the visible area (overview levels up to a chosen zoom) into /sdcard/maps from OpenTopoMap or maps/source.txt, skipping tiles already on the card and rejecting placeholder answers. Settings > WiFi stores the network in NVS (with scan); WiFi is on only while a download runs. HTTP runs on a worker task on core 0; the popup shows link, internal heap and the last TLS/HTTP error. Internal RAM was the blocker (~13 KB free with WiFi + Bluedroid): the L2 LVGL env now allocates the_mesh in PSRAM (MESH_IN_PSRAM, static RAM 210 -> 87 KB) and mbedTLS allocates from PSRAM. Confirmed on hardware. Co-Authored-By: Claude Opus 5.5 --- docs/development/ui-core.md | 2 +- examples/companion_radio/MyMesh.h | 4 + examples/companion_radio/main.cpp | 14 + examples/companion_radio/ui-lvgl/LvglPort.h | 286 +++++++++++++++++- examples/companion_radio/ui-lvgl/MapScreen.h | 219 +++++++++++++- examples/companion_radio/ui-lvgl/UITask.cpp | 20 +- examples/companion_radio/ui-lvgl/UITask.h | 33 +- examples/companion_radio/ui-lvgl/WifiScreen.h | 168 ++++++++++ .../companion_radio/ui-lvgl/map/TileCache.h | 3 + .../ui-lvgl/map/TileDownloader.h | 280 +++++++++++++++++ solo/wio-tracker-l2/platformio.ini | 7 +- variants/sim/build_wasm.sh | 3 + 12 files changed, 1030 insertions(+), 9 deletions(-) create mode 100644 examples/companion_radio/ui-lvgl/WifiScreen.h create mode 100644 examples/companion_radio/ui-lvgl/map/TileDownloader.h diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index e8457957..7d4a88b5 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -147,7 +147,7 @@ consumes keys only. - Theme tokens (colours, spacing, typography) in one place — the first chance to realise the "Amber Trace" direction in colour. - Fonts ✅ Noto Sans generated by `ui-lvgl/fonts/generate.sh` (lv_font_conv): European Latin (Latin-1, Extended-A/B), Greek, Cyrillic + supplement, typographic punctuation, currency, LVGL symbols; 12/14/16/20 px, clock 40 px digits only. On-screen keyboard (`ui-lvgl/Keyboard.h`), phone-style: one layout per script (Latin QWERTY / Cyrillic ЙЦУКЕН / Greek) chosen by the same `keyboard_main_alphabet` / `keyboard_alt_alphabet` prefs as `ui-new` (globe key switches), hold a key for its variants; compose limit counted in UTF-8 bytes. Long-press variants and UTF-8 case mapping are shared with `ui-new` in `ui-core/KeyboardData.h`. - Simulator ✅: `SIM_UI=lvgl variants/sim/build_wasm.sh` builds the same `ui-lvgl` code for the browser (`SimLcdDisplay` in `variants/sim/SimDisplayDriver.h` blits LVGL's RGB565 flush to a 320×240 canvas; the `SIM_PLATFORM` branch of `ui-lvgl/LvglPort.h` feeds the mouse in as touch). LVGL comes from the L2 env's PlatformIO libdeps; its objects are cached in `web/build/obj_lvgl/`. `web/lvgl.html` runs it next to a `ui-new` peer on a direct link (buttons: advert, peer DM, peer → Public, the board button) and exposes `window.sim` (`tap`, `hold`, `touch`, `peerDM`, `peerChannel`) for headless checks. -- Map (in progress): offline Web-Mercator raster tiles on the microSD (`/maps/{z}/{x}/{y}.png`, the Meshtastic MUI layout; optional per-column `.pak` from PR #3381's packer), SDMMC 1-bit mount in `LvglPort.h`. `ui-lvgl/map/TileProvider.h` is the swap point: the screen and `map/TileCache.h` (12 decoded RGB565 tiles in PSRAM, LRU) only call `renderTile(z, x, y, rgb565)`, so a vector renderer can replace the PNG provider later. `MapScreen.h`: pan by drag, +/-, re-centre/follow GPS, Nearby nodes with a position as markers (tap = node detail), one tile decoded per loop pass. `maps/attribution.txt` is shown on the map. Planned split (user, 2026-09-25): a Nodes map (every contact with a position) and a separate Navigation map (waypoints + live shares only, for walking to a target). Tiles come from `tools/maps/fetch_tiles.py` (no default server: tile.openstreetmap.org blocks offline use) or, next, an on-device WiFi download of the visible area. +- Map (in progress): offline Web-Mercator raster tiles on the microSD (`/maps/{z}/{x}/{y}.png`, the Meshtastic MUI layout; optional per-column `.pak` from PR #3381's packer), SDMMC 1-bit mount in `LvglPort.h`. `ui-lvgl/map/TileProvider.h` is the swap point: the screen and `map/TileCache.h` (12 decoded RGB565 tiles in PSRAM, LRU) only call `renderTile(z, x, y, rgb565)`, so a vector renderer can replace the PNG provider later. `MapScreen.h`: pan by drag, +/-, re-centre/follow GPS, Nearby nodes with a position as markers (tap = node detail), one tile decoded per loop pass. `maps/attribution.txt` is shown on the map. Planned split (user, 2026-09-25): a Nodes map (every contact with a position) and a separate Navigation map (waypoints + live shares only, for walking to a target). Tiles come from `tools/maps/fetch_tiles.py` (no default server: tile.openstreetmap.org blocks offline use) or on the device: the map's download button fetches the visible area (a few overview levels up to a chosen zoom) over WiFi (`map/TileDownloader.h`; skips tiles on the card, rejects non-PNG and placeholder answers; server from `maps/source.txt`, default OpenTopoMap). The HTTP GET runs on a worker task (`LvglPort.h`, keep-alive TLS); WiFi credentials (Settings > WiFi, with scan) live in NVS, and WiFi is on only during a download. Internal RAM is the constraint (Arduino-ESP32 2.0.17 / IDF 4.4: task stacks and, by default, mbedTLS buffers must be internal): with WiFi + Bluedroid up only ~13 KB was left, so the L2 LVGL env sets `MESH_IN_PSRAM` (`the_mesh`, ~120 KB, placement-new into PSRAM in `main.cpp`) and the fetch task points mbedTLS at PSRAM. The download popup shows IP, RSSI, free/largest internal heap and the last TLS/HTTP error. Arduino-ESP32 3.x (IDF 5: PSRAM stacks, NimBLE) is to be evaluated in the polishing phase. In the simulator the same code downloads through emscripten fetch. - PR #3381's LVGL UI is a reference for platform glue (flush, GT911, PSRAM pool) only; its screens are not adopted. ## Migration plan diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index e68477df..5182df7d 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -768,4 +768,8 @@ private: void* _ping_callback_arg; }; +#if defined(MESH_IN_PSRAM) && defined(ESP32) +extern MyMesh& the_mesh; // allocated in PSRAM, see main.cpp +#else extern MyMesh the_mesh; +#endif diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 2ddbe28b..81482a18 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -135,7 +135,21 @@ static uint32_t _atoi(const char* sp) { StdRNG fast_rng; SimpleMeshTables tables; +#if defined(MESH_IN_PSRAM) && defined(ESP32) +// ~120 KB of contacts/channels/queues: on PSRAM boards keep it out of internal +// RAM, which WiFi + TLS + BLE need (PSRAM is on the heap before global +// constructors run; internal RAM is the fallback). +#include +#include +static MyMesh* newMesh() { + void* p = heap_caps_malloc(sizeof(MyMesh), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!p) p = malloc(sizeof(MyMesh)); + return new (p) MyMesh(radio_driver, fast_rng, rtc_clock, tables, store); +} +MyMesh& the_mesh = *newMesh(); +#else MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store); +#endif /* END GLOBAL OBJECTS */ diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index d552e03f..3c8db97b 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -1,16 +1,29 @@ #pragma once -// LVGL <-> board glue: display flush, pointer input and the storage card (map -// tiles, mounted at /sdcard). One implementation per board; the rest of -// ui-lvgl never touches the hardware directly. +// LVGL <-> board glue: display flush, pointer input, the storage card (map +// tiles, mounted at /sdcard) and the network used to download map tiles (WiFi +// on the board, the browser in the simulator). One implementation per board; +// the rest of ui-lvgl never touches the hardware directly. // // Single-TU fragment: included by ui-lvgl/UITask.cpp only. #if defined(SEEED_WIO_TRACKER_L2) #include + #include + #include + #include + #include + #include + #include +#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) + #include #endif namespace lvport { +// Network for map downloads: state of the link, and one HTTP GET at a time. +enum NetState { NET_OFF, NET_CONNECTING, NET_UP, NET_FAILED }; +static const int WIFI_SCAN_MAX = 12; + #if defined(SEEED_WIO_TRACKER_L2) // LovyanGFX device (NV3031B QSPI panel + GT911 touch) from WioTrackerL2Display. @@ -85,6 +98,197 @@ static bool mountStorage() { return mounted; } +// ── WiFi (station, only while a map download runs) ─────────────────────────── +// Credentials in NVS, not in NodePrefs (whose on-flash layout stays fixed) and +// not on the removable card. +static bool loadWifi(char* ssid, size_t ssid_n, char* pass, size_t pass_n) { + Preferences p; + if (!p.begin("mc_wifi", true)) { ssid[0] = pass[0] = '\0'; return false; } + p.getString("ssid", ssid, ssid_n); + p.getString("pass", pass, pass_n); + p.end(); + return ssid[0] != '\0'; +} +static void saveWifi(const char* ssid, const char* pass) { + Preferences p; + if (!p.begin("mc_wifi", false)) return; + p.putString("ssid", ssid); + p.putString("pass", pass); + p.end(); +} + +static void netBegin(const char* ssid, const char* pass) { + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, pass); +} +static int netState() { + switch (WiFi.status()) { + case WL_CONNECTED: return NET_UP; + case WL_CONNECT_FAILED: + case WL_NO_SSID_AVAIL: return NET_FAILED; + case WL_IDLE_STATUS: + case WL_DISCONNECTED: return NET_CONNECTING; + default: return NET_CONNECTING; + } +} +static void netEnd() { + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); +} +// One line for the download popup: link details and the internal heap (TLS, +// WiFi and BLE all compete for it). +static void netInfo(char* out, size_t n) { + size_t free_int = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + size_t big_int = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL); + if (WiFi.status() == WL_CONNECTED) + snprintf(out, n, "%s %d dBm heap %uk/%uk", WiFi.localIP().toString().c_str(), (int)WiFi.RSSI(), + (unsigned)(free_int / 1024), (unsigned)(big_int / 1024)); + else + snprintf(out, n, "WiFi status %d heap %uk/%uk", (int)WiFi.status(), + (unsigned)(free_int / 1024), (unsigned)(big_int / 1024)); +} + +// Async scan: scanStart(), then scanResults() returns -1 while running, else +// the count, filling `names` (strongest first, as the driver reports them). +static void scanStart() { + WiFi.mode(WIFI_STA); + WiFi.scanNetworks(true /* async */); +} +static int scanResults(char names[][33], int max) { + int n = WiFi.scanComplete(); + if (n == WIFI_SCAN_RUNNING) return -1; + if (n < 0) return 0; + int k = 0; + for (int i = 0; i < n && k < max; i++) { + String s = WiFi.SSID(i); + if (!s.length()) continue; + bool dup = false; + for (int j = 0; j < k; j++) if (strcmp(names[j], s.c_str()) == 0) { dup = true; break; } + if (dup) continue; + snprintf(names[k++], 33, "%s", s.c_str()); + } + WiFi.scanDelete(); + return k; +} + +// ── One HTTP GET at a time, on a worker task (core 0) ──────────────────────── +// TLS handshakes and slow servers would stall the mesh loop, so the request +// runs on its own task; the UI loop polls. The HTTP connection is reused +// between tiles (keep-alive). Tiles are public pictures, so the TLS peer +// isn't verified (no CA bundle in flash for this). +enum { F_IDLE, F_REQUESTED, F_BUSY, F_DONE, F_FAILED }; +static volatile int s_fstate = F_IDLE; +static volatile int s_fcode = 0; +static char s_furl[200]; +static uint8_t* s_fbuf = nullptr; +static size_t s_fcap = 0; +static volatile size_t s_flen = 0; +static volatile bool s_fabandoned = false; +static TaskHandle_t s_ftask = nullptr; + +// Stream sink for HTTPClient::writeToStream() (which also undoes chunking). +class FetchSink : public Stream { +public: + size_t write(uint8_t c) override { return write(&c, 1); } + size_t write(const uint8_t* d, size_t n) override { + if (s_flen + n > s_fcap) { + size_t cap = s_fcap ? s_fcap : 32 * 1024; + while (cap < s_flen + n) cap *= 2; + if (cap > 512 * 1024) return 0; + uint8_t* nb = (uint8_t*)heap_caps_realloc(s_fbuf, cap, MALLOC_CAP_SPIRAM); + if (!nb) return 0; + s_fbuf = nb; s_fcap = cap; + } + memcpy(s_fbuf + s_flen, d, n); + s_flen += n; + return n; + } + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + void flush() override {} +}; + +static char s_ferr[64] = ""; // why the last request failed (HTTP / TLS), for the popup + +// The framework builds mbedTLS with internal-RAM-only buffers (~40 KB per +// session), which Bluedroid + WiFi + LVGL leave too little of: put them in +// PSRAM, falling back to internal RAM. +static void* tlsCalloc(size_t n, size_t size) { + return heap_caps_calloc_prefer(n, size, 2, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, MALLOC_CAP_DEFAULT); +} + +static void fetchTask(void*) { + mbedtls_platform_set_calloc_free(tlsCalloc, heap_caps_free); + WiFiClientSecure tls; + tls.setInsecure(); + tls.setHandshakeTimeout(20); // seconds (default 120) + WiFiClient plain; + HTTPClient http; + http.setReuse(true); + http.setConnectTimeout(10000); + http.setTimeout(15000); + http.setUserAgent("MeshCore-Solo/1.0 (LoRa handheld; offline map download)"); + for (;;) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + s_fstate = F_BUSY; + s_flen = 0; + bool https = strncmp(s_furl, "https:", 6) == 0; + int code = -1; + s_ferr[0] = '\0'; + if (https ? http.begin(tls, s_furl) : http.begin(plain, s_furl)) { + code = http.GET(); + if (code == 200) { + FetchSink sink; + if (http.writeToStream(&sink) < 0) { code = -1; snprintf(s_ferr, sizeof(s_ferr), "read failed / out of memory"); } + } else if (code < 0) { + char tls_err[40] = ""; + if (https && tls.lastError(tls_err, sizeof(tls_err)) != 0 && tls_err[0]) + snprintf(s_ferr, sizeof(s_ferr), "TLS: %s", tls_err); + else + snprintf(s_ferr, sizeof(s_ferr), "%s", HTTPClient::errorToString(code).c_str()); + } else { + snprintf(s_ferr, sizeof(s_ferr), "HTTP %d", code); + } + http.end(); + } else { + snprintf(s_ferr, sizeof(s_ferr), "bad URL"); + } + s_fcode = code; + if (s_fabandoned) { s_fabandoned = false; s_fstate = F_IDLE; } // nobody wants it any more + else s_fstate = code == 200 ? F_DONE : F_FAILED; + } +} + +// 1 = started, 0 = busy (an abandoned request is still finishing), -1 = no +// memory for the worker task. +static int fetchStart(const char* url) { + if (s_fstate != F_IDLE) return 0; + if (!s_ftask && xTaskCreatePinnedToCore(fetchTask, "tilefetch", 10240, nullptr, 1, &s_ftask, 0) != pdPASS) { + s_ftask = nullptr; + return -1; + } + snprintf(s_furl, sizeof(s_furl), "%s", url); + s_fstate = F_REQUESTED; + xTaskNotifyGive(s_ftask); + return 1; +} +// 0 = in flight, 1 = done (fetchData valid until fetchRelease), <0 = failed (-HTTP status) +static int fetchPoll() { + int st = s_fstate; + if (st == F_DONE) return 1; + if (st == F_FAILED) return s_fcode > 0 ? -s_fcode : -1; + return 0; +} +static const uint8_t* fetchData(size_t& len) { len = s_flen; return s_fbuf; } +static void fetchRelease() { if (s_fstate == F_DONE || s_fstate == F_FAILED) s_fstate = F_IDLE; } +// Stop caring about the request in flight; it completes (or fails) on its own. +static void fetchAbandon() { + if (s_fstate == F_REQUESTED || s_fstate == F_BUSY) s_fabandoned = true; + else fetchRelease(); +} +static const char* fetchError() { return s_ferr; } + #elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) // Browser simulator (variants/sim/build_wasm_lvgl.sh): SimLcdDisplay blits to // a , the host page feeds the mouse in as touch. @@ -128,6 +332,82 @@ static void swallowTouch() { s_swallow = true; } // The host page preloads map tiles into the in-memory FS under /sdcard/maps. static bool mountStorage() { return true; } +// The browser is always online; WiFi credentials only live for the session. +static char s_ssid[33] = "", s_pass[65] = ""; +static bool loadWifi(char* ssid, size_t ssid_n, char* pass, size_t pass_n) { + snprintf(ssid, ssid_n, "%s", s_ssid); + snprintf(pass, pass_n, "%s", s_pass); + return ssid[0] != '\0'; +} +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 void netBegin(const char*, const char*) {} +static int netState() { return NET_UP; } +static void netEnd() {} +static void netInfo(char* out, size_t n) { snprintf(out, n, "browser network"); } +static uint32_t s_scan_at = 0; +static void scanStart() { s_scan_at = millis(); } +static int scanResults(char names[][33], int max) { // a pretend scan, for the UI + if (millis() - s_scan_at < 800) return -1; + static const char* const FAKE[] = { "Sim-Home", "Sim-Office", "Cafe Guest" }; + int n = 0; + for (; n < 3 && n < max; n++) snprintf(names[n], 33, "%s", FAKE[n]); + return n; +} + +// fetch() through emscripten's Fetch API (the tile server must allow CORS, +// which OpenTopoMap does). +enum { F_IDLE, F_BUSY, F_DONE, F_FAILED }; +static int s_fstate = F_IDLE; +static int s_fcode = 0; +static uint8_t* s_fbuf = nullptr; +static size_t s_flen = 0; +static bool s_fabandoned = false; + +static void fetchDone(emscripten_fetch_t* f) { + if (!s_fabandoned) { + s_fcode = f->status; + free(s_fbuf); + s_fbuf = nullptr; + s_flen = 0; + if (f->status == 200 && f->numBytes > 0 && (s_fbuf = (uint8_t*)malloc(f->numBytes))) { + memcpy(s_fbuf, f->data, f->numBytes); + s_flen = f->numBytes; + s_fstate = F_DONE; + } else { + s_fstate = F_FAILED; + } + } else { + s_fstate = F_IDLE; + } + s_fabandoned = false; + emscripten_fetch_close(f); +} + +static int fetchStart(const char* url) { // 1 started, 0 busy + if (s_fstate != F_IDLE) return 0; + emscripten_fetch_attr_t a; + emscripten_fetch_attr_init(&a); + strcpy(a.requestMethod, "GET"); + a.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY; + a.onsuccess = fetchDone; + a.onerror = fetchDone; + s_fstate = F_BUSY; + emscripten_fetch(&a, url); + return 1; +} +static int fetchPoll() { + if (s_fstate == F_DONE) return 1; + if (s_fstate == F_FAILED) return s_fcode > 0 ? -s_fcode : -1; + return 0; +} +static const uint8_t* fetchData(size_t& len) { len = s_flen; return s_fbuf; } +static void fetchRelease() { if (s_fstate == F_DONE || s_fstate == F_FAILED) s_fstate = F_IDLE; } +static void fetchAbandon() { if (s_fstate == F_BUSY) s_fabandoned = true; else fetchRelease(); } +static const char* fetchError() { return s_fcode > 0 && s_fcode != 200 ? "HTTP error" : s_fcode == 0 ? "network / CORS" : ""; } + #else #error "ui-lvgl: no LVGL port for this board (see LvglPort.h)" #endif diff --git a/examples/companion_radio/ui-lvgl/MapScreen.h b/examples/companion_radio/ui-lvgl/MapScreen.h index 38731af6..5e917b46 100644 --- a/examples/companion_radio/ui-lvgl/MapScreen.h +++ b/examples/companion_radio/ui-lvgl/MapScreen.h @@ -2,7 +2,8 @@ // Map screen: offline Web-Mercator tiles from the card (map/TileProvider.h, // cached by map/TileCache.h) under own position and the nodes Nearby knows a // position for. Drag to pan, +/- to zoom, the crosshair re-centres and -// follows the GPS again; tap a marker for its node detail. +// follows the GPS again; tap a marker for its node detail. The download +// button fetches the visible area over WiFi (map/TileDownloader.h). // // Tiles decode one per loop pass (a PNG takes tens of ms), so panning stays // responsive and the radio keeps being serviced while a view fills in. @@ -12,12 +13,15 @@ #include #include "map/TileProvider.h" #include "map/TileCache.h" +#include "map/TileDownloader.h" namespace mapview { static RasterTileProvider s_raster("/sdcard/maps"); static TileProvider* s_provider = &s_raster; // the one place to swap in a vector renderer static TileCache s_cache; +static TileDownloader s_dl("/sdcard/maps"); +static const uint32_t AVG_TILE_BYTES = 22 * 1024; // OpenTopoMap-ish, for the size estimate static const int MIN_Z = 3, MAX_Z = 18; static const int GRID_COLS = 3, GRID_ROWS = 2; // covers 320x218 at any offset @@ -53,6 +57,12 @@ static void onMapPress(lv_event_t* e) { static void onMapZoomIn(lv_event_t* e) { (void)e; s_ui->mapZoom(+1); } static void onMapZoomOut(lv_event_t* e) { (void)e; s_ui->mapZoom(-1); } static void onMapCenter(lv_event_t* e) { (void)e; s_ui->mapCenterOnMe(); } +static void onMapDownload(lv_event_t* e) { (void)e; s_ui->mapDownloadPopup(); } +static void onDlClose(lv_event_t* e) { (void)e; s_ui->mapDownloadClose(); } +static void onDlStart(lv_event_t* e) { (void)e; s_ui->mapDownloadStart(); } +static void onDlZoomMinus(lv_event_t* e) { (void)e; s_ui->mapDownloadZmax(-1); } +static void onDlZoomPlus(lv_event_t* e) { (void)e; s_ui->mapDownloadZmax(+1); } +static void onDlWifi(lv_event_t* e) { (void)e; s_ui->showWifi(true); } static void onMapMarker(lv_event_t* e) { if (mapview::s_drag > 8) return; // the press was a pan that ended on a marker s_ui->mapOpenMarker((int)(uintptr_t)lv_event_get_user_data(e)); @@ -141,7 +151,12 @@ void UITask::buildMap() { lv_obj_align(back, LV_ALIGN_TOP_LEFT, 6, 6); lv_obj_align(mapButton(body, LV_SYMBOL_PLUS, onMapZoomIn), LV_ALIGN_TOP_RIGHT, -6, 6); lv_obj_align(mapButton(body, LV_SYMBOL_MINUS, onMapZoomOut), LV_ALIGN_TOP_RIGHT, -6, 52); + lv_obj_align(mapButton(body, LV_SYMBOL_DOWNLOAD, onMapDownload), LV_ALIGN_TOP_RIGHT, -6, 98); lv_obj_align(mapButton(body, LV_SYMBOL_GPS, onMapCenter), LV_ALIGN_BOTTOM_RIGHT, -6, -6); + _map_dl_pill = mapPill(body, ""); + lv_obj_set_style_text_color(_map_dl_pill, lv_color_hex(theme::ACCENT), 0); + lv_obj_align(_map_dl_pill, LV_ALIGN_TOP_MID, 0, 16); + lv_obj_add_flag(_map_dl_pill, LV_OBJ_FLAG_HIDDEN); _map_zoom_lbl = mapPill(body, ""); lv_obj_align(_map_zoom_lbl, LV_ALIGN_TOP_LEFT, 52, 16); lv_obj_t* attr = mapPill(body, mapview::s_provider->attribution()); // required by the data licences @@ -330,3 +345,205 @@ void UITask::mapOpenMarker(int idx) { _node_from_map = true; } + +// ── Area download ───────────────────────────────────────────────────────────── + +// The visible view as a lon/lat box. +static mapview::TileArea visibleArea(double cx, double cy, int z, int w, int h, int zmin, int zmax) { + double n = (double)(1 << z); + auto lon = [n](double tx) { return tx / n * 360.0 - 180.0; }; + auto lat = [n](double ty) { return atan(sinh(M_PI * (1.0 - 2.0 * ty / n))) * 180.0 / M_PI; }; + double hw = w / 2.0 / mapview::TILE_PX, hh = h / 2.0 / mapview::TILE_PX; + mapview::TileArea a; + a.lon0 = lon(cx - hw); a.lon1 = lon(cx + hw); + a.lat1 = lat(cy - hh); a.lat0 = lat(cy + hh); + a.zmin = zmin; a.zmax = zmax; + return a; +} + +static int dlZmin(int z) { return z - 4 < 5 ? (z < 5 ? z : 5) : z - 4; } // a few overview levels (cheap) + +void UITask::mapDownloadPopup() { + if (_dl_overlay) return; + if (_dl_zmax < _map_z) _dl_zmax = _map_z + 3 > 17 ? 17 : _map_z + 3; + + _dl_overlay = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(_dl_overlay); + lv_obj_set_size(_dl_overlay, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(_dl_overlay, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(_dl_overlay, LV_OPA_60, 0); + lv_obj_add_flag(_dl_overlay, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(_dl_overlay, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* panel = lv_obj_create(_dl_overlay); + lv_obj_set_size(panel, lv_display_get_horizontal_resolution(NULL) - 16, LV_SIZE_CONTENT); + lv_obj_align(panel, LV_ALIGN_CENTER, 0, theme::STATUS_H / 2); + lv_obj_set_style_bg_color(panel, lv_color_hex(theme::BG), 0); + lv_obj_set_style_border_color(panel, lv_color_hex(theme::ACCENT), 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_radius(panel, theme::RADIUS, 0); + lv_obj_set_style_pad_all(panel, theme::PAD, 0); + lv_obj_set_style_pad_row(panel, 6, 0); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* hdr = lv_obj_create(panel); + styleSurface(hdr, theme::BG); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(hdr, LV_PCT(100), 28); + lv_obj_align(label(hdr, "Download this area", THEME_FONT_TITLE, theme::TEXT), LV_ALIGN_LEFT_MID, 0, 0); + headerButton(hdr, LV_SYMBOL_CLOSE, onDlClose, 0, NULL); + + // Detail range: from a few overview levels up to the chosen max zoom. + lv_obj_t* zr = lv_obj_create(panel); + styleSurface(zr, theme::BG); + lv_obj_remove_flag(zr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(zr, LV_PCT(100), 30); + lv_obj_align(label(zr, "Up to zoom", THEME_FONT_BODY, theme::TEXT_MUTED), LV_ALIGN_LEFT_MID, 0, 0); + headerButton(zr, LV_SYMBOL_PLUS, onDlZoomPlus, 0, NULL); + _dl_zoom_lbl = label(zr, "", THEME_FONT_TITLE, theme::ACCENT); + lv_obj_align(_dl_zoom_lbl, LV_ALIGN_RIGHT_MID, -56, 0); + headerButton(zr, LV_SYMBOL_MINUS, onDlZoomMinus, 96, NULL); + + _dl_info = label(panel, "", THEME_FONT_SMALL, theme::TEXT); + lv_label_set_long_mode(_dl_info, LV_LABEL_LONG_WRAP); + lv_obj_set_width(_dl_info, LV_PCT(100)); + lv_obj_set_style_text_line_space(_dl_info, 2, 0); + + lv_obj_t* acts = lv_obj_create(panel); + styleSurface(acts, theme::BG); + lv_obj_remove_flag(acts, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(acts, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(acts, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(acts, theme::GAP, 0); + lv_obj_t* wifi = lv_button_create(acts); + lv_obj_set_height(wifi, 38); + lv_obj_set_flex_grow(wifi, 1); + lv_obj_set_style_shadow_width(wifi, 0, 0); + lv_obj_set_style_radius(wifi, theme::RADIUS, 0); + lv_obj_set_style_bg_color(wifi, lv_color_hex(theme::SURFACE), 0); + lv_obj_add_event_cb(wifi, onDlWifi, LV_EVENT_CLICKED, NULL); + lv_obj_center(label(wifi, LV_SYMBOL_WIFI " WiFi", THEME_FONT_SMALL, theme::TEXT)); + lv_obj_t* go = lv_button_create(acts); + lv_obj_set_height(go, 38); + lv_obj_set_flex_grow(go, 2); + lv_obj_set_style_shadow_width(go, 0, 0); + lv_obj_set_style_radius(go, theme::RADIUS, 0); + lv_obj_set_style_bg_color(go, lv_color_hex(theme::ACCENT_DIM), 0); + lv_obj_add_event_cb(go, onDlStart, LV_EVENT_CLICKED, NULL); + _dl_start_lbl = label(go, "", THEME_FONT_SMALL, theme::TEXT); + lv_obj_center(_dl_start_lbl); + + refreshDownloadPopup(); +} + +void UITask::mapDownloadClose() { + if (_dl_overlay) lv_obj_delete_async(_dl_overlay); // may be closing from its own button + _dl_overlay = _dl_info = _dl_zoom_lbl = _dl_start_lbl = nullptr; +} + +void UITask::mapDownloadZmax(int delta) { + if (mapview::s_dl.active()) return; + int z = _dl_zmax + delta; + if (z < _map_z || z > mapview::MAX_Z) return; + _dl_zmax = z; + refreshDownloadPopup(); +} + +void UITask::refreshDownloadPopup() { + if (!_dl_info) return; + mapview::TileDownloader& dl = mapview::s_dl; + lv_label_set_text_fmt(_dl_zoom_lbl, "z%d", _dl_zmax); + if (dl.active()) { + char net[64]; + lvport::netInfo(net, sizeof(net)); + if (dl.state() == mapview::TileDownloader::CONNECTING) + lv_label_set_text_fmt(_dl_info, "Connecting to WiFi...\nFrom %s\n%s", dl.sourceHost(), net); + else if (dl.failed()) + lv_label_set_text_fmt(_dl_info, "Downloading %lu / %lu tiles - %lu failed\nLast error: %s\n%s", + (unsigned long)dl.processed(), (unsigned long)dl.total(), + (unsigned long)dl.failed(), dl.message(), net); + else + lv_label_set_text_fmt(_dl_info, "Downloading %lu / %lu tiles (%lu new)\nFrom %s - you can close this\n%s", + (unsigned long)dl.processed(), (unsigned long)dl.total(), + (unsigned long)dl.downloaded(), dl.sourceHost(), net); + lv_label_set_text(_dl_start_lbl, LV_SYMBOL_STOP " Stop"); + return; + } + int w = _map_area ? lv_obj_get_width(_map_area) : 320, h = _map_area ? lv_obj_get_height(_map_area) : 218; + mapview::TileArea a = visibleArea(_map_cx, _map_cy, _map_z, w, h, dlZmin(_map_z), _dl_zmax); + uint32_t n = mapview::countTiles(a); + char ssid[33], pass[65]; + bool have_wifi = lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass)); + char last[64] = ""; + if (dl.state() == mapview::TileDownloader::DONE || dl.state() == mapview::TileDownloader::FAILED || + dl.state() == mapview::TileDownloader::CANCELLED) + snprintf(last, sizeof(last), "\nLast: %s (%lu new)", dl.message(), (unsigned long)dl.downloaded()); + char size[16]; + uint64_t bytes = (uint64_t)n * mapview::AVG_TILE_BYTES; + if (bytes < 1024 * 1024) snprintf(size, sizeof(size), "%lu KB", (unsigned long)(bytes / 1024)); + else snprintf(size, sizeof(size), "%lu MB", (unsigned long)((bytes + 512 * 1024) / (1024 * 1024))); + lv_label_set_text_fmt(_dl_info, "z%d-%d: %lu tiles, about %s (tiles on the card are skipped)\n" + "From %s\nWiFi: %s%s", + a.zmin, a.zmax, (unsigned long)n, size, + dl.sourceHost(), have_wifi ? ssid : "not set - tap WiFi", last); + lv_label_set_text(_dl_start_lbl, n > mapview::TileDownloader::MAX_TILES ? "Too large - zoom in" + : LV_SYMBOL_DOWNLOAD " Download"); +} + +void UITask::mapDownloadStart() { + if (mapview::s_dl.active()) { mapDownloadStop(); return; } + char ssid[33], pass[65]; + if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) { showWifi(true); return; } + if (!lvport::mountStorage()) { showToast("No SD card"); return; } + int w = _map_area ? lv_obj_get_width(_map_area) : 320, h = _map_area ? lv_obj_get_height(_map_area) : 218; + mapview::TileArea a = visibleArea(_map_cx, _map_cy, _map_z, w, h, dlZmin(_map_z), _dl_zmax); + if (!mapview::s_dl.start(a, ssid, pass)) { showToast(mapview::s_dl.message()); return; } + refreshDownloadPopup(); +} + +void UITask::mapDownloadStop() { + mapview::s_dl.cancel(); + refreshDownloadPopup(); +} + +// Every UI loop pass, whatever the screen: a download keeps going in the background. +void UITask::mapDownloadTick() { + mapview::TileDownloader& dl = mapview::s_dl; + dl.loop(); + uint8_t st = dl.state(); + bool was_active = _dl_last_state == mapview::TileDownloader::CONNECTING || + _dl_last_state == mapview::TileDownloader::RUNNING; + if (was_active && !dl.active()) { // just finished + char t[64]; + snprintf(t, sizeof(t), "Map: %s, %lu new tiles", dl.message(), (unsigned long)dl.downloaded()); + showToast(t, 4000); + mapview::s_cache.forgetMissing(); // tiles that were missing may be there now + mapview::s_available = mapview::s_provider->available(); + if (_screen == SCR_MAP) layoutMap(); + } + _dl_last_state = st; + + static uint32_t next_ui = 0; + if ((int32_t)(millis() - next_ui) < 0) return; + next_ui = millis() + 500; + if (_map_dl_pill) { + if (dl.state() == mapview::TileDownloader::CONNECTING) { + 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.active()) { + if (dl.failed()) + lv_label_set_text_fmt(_map_dl_pill, LV_SYMBOL_DOWNLOAD " %lu / %lu " LV_SYMBOL_WARNING " %lu", + (unsigned long)dl.processed(), (unsigned long)dl.total(), (unsigned long)dl.failed()); + else + 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 { + lv_obj_add_flag(_map_dl_pill, LV_OBJ_FLAG_HIDDEN); + } + } + if (_dl_overlay) refreshDownloadPopup(); + // 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(); } +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index a24c828e..8e10f01b 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -140,6 +140,7 @@ void UITask::loop() { _core->loop(); drainCoreEvents(); + mapDownloadTick(); // a map download keeps running on any screen, and asleep if (!_asleep) { if ((int32_t)(millis() - _next_status_ms) >= 0) { @@ -159,6 +160,7 @@ void UITask::loop() { } } if (_screen == SCR_MAP) mapLoop(); + if (_screen == SCR_WIFI) pollWifiScan(); if (_screen == SCR_THREAD && (int32_t)(millis() - _next_thread_check_ms) >= 0) { _next_thread_check_ms = millis() + 500; uint32_t sig = threadSignature(); @@ -344,7 +346,10 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) { _nearby_list = _nearby_status = _nearby_sort_lbl = _nearby_chips = nullptr; _node_info = _node_ping = _node_delete_lbl = nullptr; _scan_overlay = _scan_list = _scan_status = nullptr; // the popup went with the old screen - _map_area = _map_marks = _map_me = _map_zoom_lbl = _map_hint = nullptr; + _map_area = _map_marks = _map_me = _map_zoom_lbl = _map_hint = _map_dl_pill = nullptr; + _dl_overlay = _dl_info = _dl_zoom_lbl = _dl_start_lbl = nullptr; + _wifi_ssid = _wifi_pass = _wifi_kb = _wifi_list = _wifi_status = nullptr; + _wifi_scanning = false; for (lv_obj_t*& t : _map_tiles) t = nullptr; lv_obj_t* prev = lv_screen_active(); lv_obj_t* scr = lv_obj_create(NULL); @@ -404,7 +409,11 @@ void UITask::back() { buildNearby(); if (_node_from_scan) showScanPopup(); break; - case SCR_MAP: showHome(); break; + case SCR_MAP: + if (_dl_overlay) mapDownloadClose(); + else showHome(); + break; + case SCR_WIFI: if (_wifi_from_map) showMap(); else showSettings(); break; default: break; } } @@ -1054,6 +1063,7 @@ void UITask::nodeAction(uint8_t action) { } #include "MapScreen.h" +#include "WifiScreen.h" // ── Conversation ────────────────────────────────────────────────────────────── @@ -1370,8 +1380,14 @@ void UITask::showSettings() { buildSettings(); } +static void onOpenWifi(lv_event_t* e) { (void)e; s_ui->showWifi(false); } + void UITask::buildSettings() { lv_obj_t* body = newScreen("Settings", true); + sectionTitle(body, "CONNECTIVITY"); + char ssid[33], pass[65]; + bool have = lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass)); + listRow(body, LV_SYMBOL_WIFI " WiFi", have ? ssid : "Not set (for map downloads)", onOpenWifi, NULL); sectionTitle(body, "KEYBOARD"); uint8_t main_a = _prefs ? _prefs->keyboard_main_alphabet : 0; uint8_t alt_a = _prefs ? _prefs->keyboard_alt_alphabet : 0; diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 48d36401..3ecf35fa 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -44,6 +44,17 @@ public: void mapCenterOnMe(); void mapPan(int dx, int dy); void mapOpenMarker(int idx); + void mapDownloadPopup(); + void mapDownloadClose(); + void mapDownloadStart(); + void mapDownloadStop(); + void mapDownloadZmax(int delta); + void showWifi(bool from_map); + void wifiScan(); + void wifiPick(int idx); + void wifiSave(); + void wifiEdit(lv_obj_t* ta); + void wifiKeyboardHide(); void setNearbyFilter(uint8_t f); void toggleNearbySort(); void startNearbyScan(); @@ -64,7 +75,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 }; + enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI }; void buildStatusBar(); void refreshStatusBar(); @@ -83,6 +94,10 @@ private: void layoutMap(); void mapLoop(); void rebuildMapMarkers(); + void mapDownloadTick(); + void refreshDownloadPopup(); + void buildWifi(); + void pollWifiScan(); void buildNode(); void refreshNode(); void buildThread(); @@ -161,4 +176,20 @@ private: lv_obj_t* _map_me = nullptr; lv_obj_t* _map_zoom_lbl = nullptr; lv_obj_t* _map_hint = nullptr; + lv_obj_t* _map_dl_pill = nullptr; // download progress over the map + lv_obj_t* _dl_overlay = nullptr; // download popup + lv_obj_t* _dl_info = nullptr; + lv_obj_t* _dl_zoom_lbl = nullptr; + lv_obj_t* _dl_start_lbl = nullptr; + int _dl_zmax = 0; + uint8_t _dl_last_state = 0; + + // WiFi settings (SCR_WIFI) + bool _wifi_from_map = false; + bool _wifi_scanning = false; + lv_obj_t* _wifi_ssid = nullptr; + lv_obj_t* _wifi_pass = nullptr; + lv_obj_t* _wifi_kb = nullptr; + lv_obj_t* _wifi_list = nullptr; + lv_obj_t* _wifi_status = nullptr; }; diff --git a/examples/companion_radio/ui-lvgl/WifiScreen.h b/examples/companion_radio/ui-lvgl/WifiScreen.h new file mode 100644 index 00000000..bc6bdecb --- /dev/null +++ b/examples/companion_radio/ui-lvgl/WifiScreen.h @@ -0,0 +1,168 @@ +#pragma once +// WiFi settings (Settings > WiFi, or from the map's download popup): the +// network map downloads connect to. Scan lists nearby networks to pick from; +// the password field uses the same keyboard as compose. Saved through +// lvport::saveWifi() (NVS on the board). WiFi itself stays off except while a +// download runs. +// +// Single-TU fragment: included by ui-lvgl/UITask.cpp after MapScreen.h. + +static char s_wifi_names[lvport::WIFI_SCAN_MAX][33]; + +static void onWifiScan(lv_event_t* e) { (void)e; s_ui->wifiScan(); } +static void onWifiSave(lv_event_t* e) { (void)e; s_ui->wifiSave(); } +static void onWifiPick(lv_event_t* e) { s_ui->wifiPick((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onWifiField(lv_event_t* e) { s_ui->wifiEdit((lv_obj_t*)lv_event_get_target(e)); } +static void onWifiKb(lv_event_t* e) { + if (lv_event_get_code(e) == LV_EVENT_READY) s_ui->wifiSave(); + else s_ui->wifiKeyboardHide(); +} + +static lv_obj_t* wifiField(lv_obj_t* parent, const char* placeholder, bool password) { + lv_obj_t* ta = lv_textarea_create(parent); + lv_textarea_set_one_line(ta, true); + lv_textarea_set_placeholder_text(ta, placeholder); + lv_textarea_set_password_mode(ta, password); + lv_obj_set_width(ta, LV_PCT(100)); + lv_obj_set_height(ta, 34); // one 20 px line: 2*1 border + 2*6 pad + 20 (as the compose field) + lv_obj_set_style_border_width(ta, 1, 0); + lv_obj_set_style_pad_ver(ta, 6, 0); + lv_obj_set_style_pad_hor(ta, 10, 0); + lv_obj_set_scrollbar_mode(ta, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_border_color(ta, lv_color_hex(theme::ACCENT), LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_set_style_border_width(ta, 2, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_add_event_cb(ta, onWifiField, LV_EVENT_CLICKED, NULL); + return ta; +} + +void UITask::showWifi(bool from_map) { + _wifi_from_map = from_map; + _screen = SCR_WIFI; + buildWifi(); +} + +void UITask::buildWifi() { + lv_obj_t* body = newScreen("WiFi", true); + lv_obj_set_style_pad_row(body, 4, 0); + label(body, "Used only to download maps; off otherwise.", THEME_FONT_SMALL, theme::TEXT_MUTED); + + lv_obj_t* row = lv_obj_create(body); + styleSurface(row, theme::BG); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(row, theme::GAP, 0); + _wifi_ssid = wifiField(row, "Network name", false); + lv_obj_set_width(_wifi_ssid, 0); + lv_obj_set_flex_grow(_wifi_ssid, 1); + lv_obj_t* scan = lv_button_create(row); + lv_obj_set_size(scan, 64, 34); + lv_obj_set_style_shadow_width(scan, 0, 0); + lv_obj_set_style_radius(scan, theme::RADIUS, 0); + lv_obj_set_style_bg_color(scan, lv_color_hex(theme::SURFACE), 0); + lv_obj_add_event_cb(scan, onWifiScan, LV_EVENT_CLICKED, NULL); + lv_obj_center(label(scan, "Scan", THEME_FONT_SMALL, theme::TEXT)); + + _wifi_list = lv_obj_create(body); // scan results, shown after a scan + styleSurface(_wifi_list, theme::BG); + lv_obj_remove_flag(_wifi_list, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(_wifi_list, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(_wifi_list, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_style_pad_column(_wifi_list, 4, 0); + lv_obj_set_style_pad_row(_wifi_list, 4, 0); + lv_obj_add_flag(_wifi_list, LV_OBJ_FLAG_HIDDEN); + + _wifi_pass = wifiField(body, "Password", true); + + lv_obj_t* save = lv_button_create(body); + lv_obj_set_size(save, LV_PCT(100), 38); + lv_obj_set_style_shadow_width(save, 0, 0); + lv_obj_set_style_radius(save, theme::RADIUS, 0); + lv_obj_set_style_bg_color(save, lv_color_hex(theme::ACCENT_DIM), 0); + lv_obj_add_event_cb(save, onWifiSave, LV_EVENT_CLICKED, NULL); + lv_obj_center(label(save, LV_SYMBOL_OK " Save", THEME_FONT_BODY, theme::TEXT)); + _wifi_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + + char ssid[33], pass[65]; + lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass)); + lv_textarea_set_text(_wifi_ssid, ssid); + lv_textarea_set_text(_wifi_pass, pass); + + // Keyboard over the bottom of the screen; the body shrinks above it while + // it is up, so the field being edited stays visible. + _wifi_kb = kb::create(lv_screen_active(), _prefs); + lv_obj_set_size(_wifi_kb, LV_PCT(100), 124); + lv_obj_align(_wifi_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_event_cb(_wifi_kb, onWifiKb, LV_EVENT_READY, NULL); + lv_obj_add_event_cb(_wifi_kb, onWifiKb, LV_EVENT_CANCEL, NULL); + lv_obj_add_flag(_wifi_kb, LV_OBJ_FLAG_HIDDEN); +} + +void UITask::wifiEdit(lv_obj_t* ta) { + if (!_wifi_kb) return; + lv_obj_remove_state(_wifi_ssid, LV_STATE_FOCUSED); + lv_obj_remove_state(_wifi_pass, LV_STATE_FOCUSED); + lv_obj_add_state(ta, LV_STATE_FOCUSED); // draws the cursor + lv_keyboard_set_textarea(_wifi_kb, ta); + if (lv_obj_has_flag(_wifi_kb, LV_OBJ_FLAG_HIDDEN)) { + lv_obj_remove_flag(_wifi_kb, LV_OBJ_FLAG_HIDDEN); + if (_body) lv_obj_set_height(_body, lv_obj_get_height(_body) - lv_obj_get_height(_wifi_kb)); + } + lv_obj_update_layout(lv_screen_active()); + lv_obj_scroll_to_view(ta, LV_ANIM_OFF); +} + +void UITask::wifiKeyboardHide() { + if (!_wifi_kb || lv_obj_has_flag(_wifi_kb, LV_OBJ_FLAG_HIDDEN)) return; + lv_obj_add_flag(_wifi_kb, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_state(_wifi_ssid, LV_STATE_FOCUSED); + lv_obj_remove_state(_wifi_pass, LV_STATE_FOCUSED); + if (_body) lv_obj_set_height(_body, lv_obj_get_height(_body) + lv_obj_get_height(_wifi_kb)); +} + +void UITask::wifiScan() { + if (_wifi_scanning) return; + lvport::scanStart(); + _wifi_scanning = true; + lv_label_set_text(_wifi_status, LV_SYMBOL_REFRESH " Scanning..."); +} + +void UITask::pollWifiScan() { + if (!_wifi_scanning || !_wifi_list) return; + int n = lvport::scanResults(s_wifi_names, lvport::WIFI_SCAN_MAX); + if (n < 0) return; + _wifi_scanning = false; + lv_obj_clean(_wifi_list); + for (int i = 0; i < n; i++) { + lv_obj_t* b = lv_button_create(_wifi_list); + lv_obj_set_height(b, 30); + lv_obj_set_style_pad_hor(b, 10, 0); + lv_obj_set_style_pad_ver(b, 0, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_radius(b, 15, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0); + lv_obj_add_event_cb(b, onWifiPick, LV_EVENT_CLICKED, (void*)(uintptr_t)i); + lv_obj_center(label(b, s_wifi_names[i], THEME_FONT_SMALL, theme::TEXT)); + } + if (n > 0) lv_obj_remove_flag(_wifi_list, LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(_wifi_status, n > 0 ? "Tap a network, then enter its password." : "No networks found."); + if (!mapview::s_dl.active()) lvport::netEnd(); // the scan switched the radio on +} + +void UITask::wifiPick(int idx) { + if (idx < 0 || idx >= lvport::WIFI_SCAN_MAX) return; + lv_textarea_set_text(_wifi_ssid, s_wifi_names[idx]); + lv_obj_add_flag(_wifi_list, LV_OBJ_FLAG_HIDDEN); + wifiEdit(_wifi_pass); +} + +void UITask::wifiSave() { + const char* ssid = lv_textarea_get_text(_wifi_ssid); + const char* pass = lv_textarea_get_text(_wifi_pass); + if (!ssid[0]) { lv_label_set_text(_wifi_status, "Enter a network name."); return; } + lvport::saveWifi(ssid, pass); + wifiKeyboardHide(); + showToast("WiFi saved"); + if (_wifi_from_map) { showMap(); mapDownloadPopup(); } + else lv_label_set_text(_wifi_status, "Saved."); +} diff --git a/examples/companion_radio/ui-lvgl/map/TileCache.h b/examples/companion_radio/ui-lvgl/map/TileCache.h index 1706a887..d22a1ca4 100644 --- a/examples/companion_radio/ui-lvgl/map/TileCache.h +++ b/examples/companion_radio/ui-lvgl/map/TileCache.h @@ -60,6 +60,9 @@ public: return s; } + // Forget the "no tile here" answers (new tiles may have been written). + void forgetMissing() { for (Slot& s : _slots) if (s.z >= 0 && !s.present) { s.z = -1; s.used = 0; } } + // Forget everything (e.g. the card was swapped); keeps the buffers. void invalidate() { for (Slot& s : _slots) { s.z = -1; s.used = 0; } } diff --git a/examples/companion_radio/ui-lvgl/map/TileDownloader.h b/examples/companion_radio/ui-lvgl/map/TileDownloader.h new file mode 100644 index 00000000..1f2f90f1 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/map/TileDownloader.h @@ -0,0 +1,280 @@ +#pragma once +// On-device map download: fetches every tile of an area over a zoom range +// into /{z}/{x}/{y}.png, the layout RasterTileProvider reads (the PC +// alternative is tools/maps/fetch_tiles.py). Tiles already on the card are +// skipped, so a download can be repeated to fill gaps or extend an area. +// +// Driven by loop() from the UI loop: one small step per call, the HTTP GET +// itself runs asynchronously in the board's fetcher (lvport::fetch*), so the +// mesh keeps being serviced during a long download. +// +// The server comes from /source.txt (line 1: URL template with {z} {x} +// {y}, line 2: attribution); without it, OpenTopoMap. Whatever the source, +// its attribution is written to /attribution.txt for the map to show. +// +// Single-TU fragment: included by ui-lvgl/UITask.cpp only (after LvglPort.h). + +#include +#include +#include + +namespace mapview { + +static const char* const DEFAULT_TILE_URL = "https://a.tile.opentopomap.org/{z}/{x}/{y}.png"; +static const char* const DEFAULT_TILE_ATTR = "\xC2\xA9 OpenStreetMap contributors, SRTM | \xC2\xA9 OpenTopoMap (CC-BY-SA)"; + +struct TileArea { + double lon0, lat0, lon1, lat1; // west, south, east, north + int zmin, zmax; +}; + +static void tileRange(const TileArea& a, int z, int& x0, int& y0, int& x1, int& y1) { + int n = 1 << z; + auto tx = [n](double lon) { int x = (int)floor((lon + 180.0) / 360.0 * n); return x < 0 ? 0 : x >= n ? n - 1 : x; }; + auto ty = [n](double lat) { + double r = lat * M_PI / 180.0; + int y = (int)floor((1.0 - asinh(tan(r)) / M_PI) / 2.0 * n); + return y < 0 ? 0 : y >= n ? n - 1 : y; + }; + x0 = tx(a.lon0); x1 = tx(a.lon1); + y0 = ty(a.lat1); y1 = ty(a.lat0); // tile y grows southwards +} + +static uint32_t countTiles(const TileArea& a) { + uint32_t total = 0; + for (int z = a.zmin; z <= a.zmax; z++) { + int x0, y0, x1, y1; + tileRange(a, z, x0, y0, x1, y1); + total += (uint32_t)(x1 - x0 + 1) * (uint32_t)(y1 - y0 + 1); + } + return total; +} + +// mkdir -p for the directories of `path` (not the file itself). +static void makeParents(const char* path) { + char tmp[96]; + snprintf(tmp, sizeof(tmp), "%s", path); + for (char* p = tmp + 1; *p; p++) { + if (*p != '/') continue; + *p = '\0'; + mkdir(tmp, 0775); // EEXIST is fine + *p = '/'; + } +} + +class TileDownloader { +public: + enum State : uint8_t { IDLE, CONNECTING, RUNNING, DONE, FAILED, CANCELLED }; + static const uint32_t MAX_TILES = 40000; // ~1 GB; beyond that, the PC tool + + explicit TileDownloader(const char* root) : _root(root) {} + + State state() const { return _state; } + bool active() const { return _state == CONNECTING || _state == RUNNING; } + uint32_t total() const { return _total; } + uint32_t processed() const { return _done + _skipped + _failed; } + uint32_t downloaded() const { return _done; } + uint32_t failed() const { return _failed; } + const char* message() const { return _msg; } + const char* sourceHost() { loadSource(); return _host; } + + bool start(const TileArea& a, const char* ssid, const char* pass) { + if (active()) return false; + _area = a; + _total = countTiles(a); + _done = _skipped = _failed = _consec_fail = 0; + _placeholder_hash = 0; _placeholder_hits = 0; + _msg[0] = '\0'; + if (_total == 0 || _total > MAX_TILES) { fail("Area too large: use the PC tool"); return false; } + loadSource(); + writeAttribution(); + _z = a.zmin; + tileRange(a, _z, _x0, _y0, _x1, _y1); + _x = _x0; _y = _y0; + _fetching = false; + _state = CONNECTING; + _connect_started = millis(); + lvport::netBegin(ssid, pass); + return true; + } + + void cancel() { + if (!active()) return; + if (_fetching) { lvport::fetchAbandon(); _fetching = false; } + finish(CANCELLED, "Cancelled"); + } + + void loop() { + if (_state == CONNECTING) { + int ns = lvport::netState(); + if (ns == lvport::NET_UP) { _state = RUNNING; return; } + if (ns == lvport::NET_FAILED || millis() - _connect_started > 20000) finish(FAILED, "WiFi: can't connect"); + return; + } + if (_state != RUNNING) return; + + if (_fetching) { + int r = lvport::fetchPoll(); + if (r == 0) { // still in flight + if (millis() - _last_start < 60000) return; + lvport::fetchAbandon(); // hung past every timeout: give up on this tile + _fetching = false; + onFailure("Request hung (60 s)"); + if (_consec_fail >= 8) { finish(FAILED, _msg); return; } + advance(); + return; + } + _fetching = false; + if (r > 0) { + size_t len = 0; + 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 { _done++; _consec_fail = 0; } + } else { + const char* why = lvport::fetchError(); + onFailure(r == -403 || r == -401 ? "Server refused (403)" : why[0] ? why : "Download failed"); + } + lvport::fetchRelease(); + if (_consec_fail >= 8) { finish(FAILED, _msg[0] ? _msg : "Server not answering"); return; } + advance(); + return; + } + + // Skip tiles already on the card (a few stat()s per pass), then start the next GET. + for (int i = 0; i < 16 && _state == RUNNING; i++) { + if (_z > _area.zmax) { finish(DONE, "Done"); return; } + char path[96]; + tilePath(path, sizeof(path), _z, _x, _y); + struct stat st; + if (stat(path, &st) == 0 && st.st_size > 0) { _skipped++; advance(); continue; } + if (millis() - _last_start < 120) return; // be gentle with the server + char url[200]; + buildUrl(url, sizeof(url), _z, _x, _y); + int r = lvport::fetchStart(url); + if (r > 0) { _fetching = true; _last_start = millis(); } + else if (r < 0) finish(FAILED, "Out of memory (download task)"); + return; + } + } + +private: + const char* _root; + State _state = IDLE; + TileArea _area = {0, 0, 0, 0, 0, 0}; + uint32_t _total = 0, _done = 0, _skipped = 0, _failed = 0; + uint8_t _consec_fail = 0; + int _z = 0, _x = 0, _y = 0, _x0 = 0, _y0 = 0, _x1 = 0, _y1 = 0; + bool _fetching = false; + uint32_t _last_start = 0, _connect_started = 0; + uint32_t _placeholder_hash = 0; + uint8_t _placeholder_hits = 0; + char _url_tpl[160] = ""; + char _attr[96] = ""; + char _host[48] = ""; + char _msg[64] = ""; + + void fail(const char* m) { snprintf(_msg, sizeof(_msg), "%s", m); _state = FAILED; } + + void finish(State s, const char* m) { + snprintf(_msg, sizeof(_msg), "%s", m); + _state = s; + lvport::netEnd(); + } + + void onFailure(const char* m) { + _failed++; + _consec_fail++; + snprintf(_msg, sizeof(_msg), "%s", m); + } + + void advance() { + if (++_y <= _y1) return; + _y = _y0; + if (++_x <= _x1) return; + if (++_z > _area.zmax) return; // loop() finishes on the next pass + tileRange(_area, _z, _x0, _y0, _x1, _y1); + _x = _x0; _y = _y0; + } + + void tilePath(char* out, size_t n, int z, int x, int y) const { + snprintf(out, n, "%s/%d/%d/%d.png", _root, z, x, y); + } + + void buildUrl(char* out, size_t n, int z, int x, int y) const { + size_t o = 0; + for (const char* p = _url_tpl; *p && o + 12 < n; p++) { + if (p[0] == '{' && p[1] && p[2] == '}') { + int v = p[1] == 'z' ? z : p[1] == 'x' ? x : p[1] == 'y' ? y : -1; + if (v >= 0) { o += snprintf(out + o, n - o, "%d", v); p += 2; continue; } + } + out[o++] = *p; + } + out[o] = '\0'; + } + + void loadSource() { + snprintf(_url_tpl, sizeof(_url_tpl), "%s", DEFAULT_TILE_URL); + snprintf(_attr, sizeof(_attr), "%s", DEFAULT_TILE_ATTR); + char path[64]; + snprintf(path, sizeof(path), "%s/source.txt", _root); + if (FILE* f = fopen(path, "r")) { + char line[160]; + if (fgets(line, sizeof(line), f)) { trim(line); if (strstr(line, "{z}")) snprintf(_url_tpl, sizeof(_url_tpl), "%s", line); } + if (fgets(line, sizeof(line), f)) { trim(line); if (line[0]) snprintf(_attr, sizeof(_attr), "%s", line); } + fclose(f); + } + const char* h = strstr(_url_tpl, "://"); + h = h ? h + 3 : _url_tpl; + size_t n = strcspn(h, "/"); + if (n >= sizeof(_host)) n = sizeof(_host) - 1; + memcpy(_host, h, n); + _host[n] = '\0'; + } + + static void trim(char* s) { + size_t n = strlen(s); + while (n > 0 && (s[n - 1] == '\n' || s[n - 1] == '\r' || s[n - 1] == ' ')) s[--n] = '\0'; + } + + void writeAttribution() { + char path[64]; + snprintf(path, sizeof(path), "%s/attribution.txt", _root); + makeParents(path); + if (FILE* f = fopen(path, "w")) { fprintf(f, "%s\n", _attr); fclose(f); } + } + + // PNG only for now: RasterTileProvider has no JPEG decoder yet. + static bool looksLikeImage(const uint8_t* d, size_t n) { + return d && n >= 8 && d[0] == 0x89 && d[1] == 'P' && d[2] == 'N' && d[3] == 'G'; + } + + // A blocked / key-required server answers every tile with the same picture: + // four identical payloads among the first downloads stop the job. + bool isPlaceholder(const uint8_t* d, size_t n) { + if (_done >= 8) return false; + uint32_t h = 2166136261u; + for (size_t i = 0; i < n; i++) h = (h ^ d[i]) * 16777619u; + if (h == _placeholder_hash) return ++_placeholder_hits >= 3; + _placeholder_hash = h; + _placeholder_hits = 0; + return false; + } + + bool writeTile(const uint8_t* d, size_t n) { + char path[96], tmp[100]; + tilePath(path, sizeof(path), _z, _x, _y); + makeParents(path); + snprintf(tmp, sizeof(tmp), "%s.tmp", path); + FILE* f = fopen(tmp, "wb"); + if (!f) return false; + bool ok = fwrite(d, 1, n, f) == n; + ok = (fclose(f) == 0) && ok; + if (!ok) { remove(tmp); return false; } + remove(path); + return rename(tmp, path) == 0; + } +}; + +} // namespace mapview diff --git a/solo/wio-tracker-l2/platformio.ini b/solo/wio-tracker-l2/platformio.ini index 56b19b34..91a4c639 100644 --- a/solo/wio-tracker-l2/platformio.ini +++ b/solo/wio-tracker-l2/platformio.ini @@ -54,6 +54,7 @@ build_flags = -D FIRMWARE_SOLO_BUILD=1 -D MESHCORE_VERSION='"1.17.1"' -D AUTO_OFF_MILLIS=60000 + -D MESH_IN_PSRAM=1 ; frees ~120 KB internal RAM for WiFi/TLS map downloads build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + @@ -65,5 +66,9 @@ lib_deps = ${Wio_Tracker_L2.lib_deps} densaugeo/base64 @ ~1.4.0 lvgl/lvgl @ 9.2.2 ; pinned: bump deliberately, re-test on hardware - SD_MMC ; map tiles on the microSD (framework library) + SD_MMC ; map tiles on the microSD (framework libraries from here on) FS + WiFi ; on-device map download + HTTPClient + WiFiClientSecure + Preferences diff --git a/variants/sim/build_wasm.sh b/variants/sim/build_wasm.sh index c864628d..abb35372 100755 --- a/variants/sim/build_wasm.sh +++ b/variants/sim/build_wasm.sh @@ -172,6 +172,7 @@ fi OUT_NAME=meshcore_sim EXPORT_NAME=MeshCoreSim +LINK_EXTRA=() if [ "$SIM_UI" = "lvgl" ]; then OUT_NAME=meshcore_sim_lvgl EXPORT_NAME=MeshCoreSimLvgl # distinct factory: web/lvgl.html loads both @@ -183,6 +184,7 @@ if [ "$SIM_UI" = "lvgl" ]; then done DEFINES=("${kept[@]}" -DDISPLAY_CLASS=SimLcdDisplay -DMAX_GROUP_CHANNELS=40 -DUI_ZOOM=1 -DLV_CONF_INCLUDE_SIMPLE) INCLUDES+=("-I$LVGL_DIR") + LINK_EXTRA=(-sFETCH=1) # map downloads (LvglPort.h) go through emscripten_fetch fi # -funsigned-char: carried over from Phase 1 verbatim -- real ARM cores @@ -292,6 +294,7 @@ fi -sEXIT_RUNTIME=0 \ -sEXPORTED_RUNTIME_METHODS=FS,ccall,cwrap,HEAPU8,HEAPF32,HEAP32 \ -sEXPORTED_FUNCTIONS=_main,_malloc,_free \ + ${LINK_EXTRA[@]+"${LINK_EXTRA[@]}"} \ -o "$OUT_DIR/$OUT_NAME.js" echo ""