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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-25 11:26:44 +02:00
co-authored by Claude Opus 5.5
parent 64321bf12b
commit 0fa7966280
12 changed files with 1030 additions and 9 deletions
+4
View File
@@ -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
+14
View File
@@ -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 <new>
#include <esp_heap_caps.h>
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 */
+283 -3
View File
@@ -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 <SD_MMC.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <Preferences.h>
#include <esp_heap_caps.h>
#include <mbedtls/platform.h>
#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
#include <emscripten/fetch.h>
#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 <canvas>, 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
+218 -1
View File
@@ -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 <math.h>
#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(); }
}
+18 -2
View File
@@ -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;
+32 -1
View File
@@ -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;
};
@@ -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.");
}
@@ -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; } }
@@ -0,0 +1,280 @@
#pragma once
// On-device map download: fetches every tile of an area over a zoom range
// into <root>/{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 <root>/source.txt (line 1: URL template with {z} {x}
// {y}, line 2: attribution); without it, OpenTopoMap. Whatever the source,
// its attribution is written to <root>/attribution.txt for the map to show.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp only (after LvglPort.h).
#include <math.h>
#include <sys/stat.h>
#include <errno.h>
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