fix(ui-lvgl): no watchdog reset during tile downloads, map download rides out a busy server

- Tile fetch and OTA tasks run at idle priority: HTTPClient's read loop
  only yields with delay(0), which at priority 1 starved IDLE0 until the
  task watchdog reset the device (decoded from the core dump)
- A failed tile is retried once; failures in a row pause 5 s ... 60 s on
  the same tile, and the download gives up only after ~5 min of nothing
- The source's last zoom level (OpenTopoMap 17, else 19, or line 3 of
  source.txt) caps the download range and live tiles; past it the map
  magnifies the parent tile
- OpenTopoMap's "no tile" picture is never saved, and copies saved by
  earlier builds are dropped when read
- The placeholder check only runs on a fresh area and for pictures with
  content: plain sea / empty land tiles are genuinely identical

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-26 19:46:36 +02:00
co-authored by Claude Opus 5.5
parent 277c0cddf1
commit 690df304d7
5 changed files with 78 additions and 20 deletions
+5 -2
View File
@@ -327,7 +327,10 @@ static int scanResults(char names[][33], int max) {
// TLS handshakes and slow servers would stall the mesh loop, so the request // 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 // 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 // between tiles (keep-alive). Tiles are public pictures, so the TLS peer
// isn't verified (no CA bundle in flash for this). // isn't verified (no CA bundle in flash for this). The task runs at the idle
// priority: HTTPClient's read loop only yields with delay(0), which never lets
// a lower-priority task in, so at priority 1 a slow transfer starved IDLE0
// until the task watchdog reset the device.
enum { F_IDLE, F_REQUESTED, F_BUSY, F_DONE, F_FAILED }; enum { F_IDLE, F_REQUESTED, F_BUSY, F_DONE, F_FAILED };
static volatile int s_fstate = F_IDLE; static volatile int s_fstate = F_IDLE;
static volatile int s_fcode = 0; static volatile int s_fcode = 0;
@@ -416,7 +419,7 @@ static void fetchTask(void*) {
// memory for the worker task. // memory for the worker task.
static int fetchStart(const char* url) { static int fetchStart(const char* url) {
if (s_fstate != F_IDLE) return 0; if (s_fstate != F_IDLE) return 0;
if (!s_ftask && xTaskCreatePinnedToCore(fetchTask, "tilefetch", 10240, nullptr, 1, &s_ftask, 0) != pdPASS) { if (!s_ftask && xTaskCreatePinnedToCore(fetchTask, "tilefetch", 10240, nullptr, tskIDLE_PRIORITY, &s_ftask, 0) != pdPASS) {
s_ftask = nullptr; s_ftask = nullptr;
return -1; return -1;
} }
+4 -2
View File
@@ -578,7 +578,9 @@ static int dlZmin(int z) { return z - 4 < 5 ? (z < 5 ? z : 5) : z - 4; } // a
void UITask::mapDownloadPopup() { void UITask::mapDownloadPopup() {
if (_dl_overlay) return; if (_dl_overlay) return;
if (_dl_zmax < _map_z) _dl_zmax = _map_z + 3 > 17 ? 17 : _map_z + 3; int src_max = mapview::s_dl.sourceMaxZ();
if (_dl_zmax < _map_z) _dl_zmax = _map_z + 3;
if (_dl_zmax > src_max) _dl_zmax = src_max; // the server has nothing finer
_dl_overlay = lv_obj_create(screen()); _dl_overlay = lv_obj_create(screen());
lv_obj_remove_style_all(_dl_overlay); lv_obj_remove_style_all(_dl_overlay);
@@ -666,7 +668,7 @@ void UITask::mapDownloadClose() {
void UITask::mapDownloadZmax(int delta) { void UITask::mapDownloadZmax(int delta) {
if (mapview::s_dl.active()) return; if (mapview::s_dl.active()) return;
int z = _dl_zmax + delta; int z = _dl_zmax + delta;
if (z < _map_z || z > mapview::MAX_Z) return; if (z < _map_z || z > mapview::MAX_Z || z > mapview::s_dl.sourceMaxZ()) return;
_dl_zmax = z; _dl_zmax = z;
refreshDownloadPopup(); refreshDownloadPopup();
} }
+3 -1
View File
@@ -153,7 +153,9 @@ static void task(void*) {
static bool startTask() { static bool startTask() {
s_state = s_job == 1 ? CHECKING : INSTALLING; s_state = s_job == 1 ? CHECKING : INSTALLING;
if (xTaskCreatePinnedToCore(task, "ota", 12288, nullptr, 1, &s_task, 0) != pdPASS) { // Idle priority, as the tile fetch (LvglPort.h): HTTPClient only yields
// with delay(0), which at priority 1 starves IDLE0 into a watchdog reset.
if (xTaskCreatePinnedToCore(task, "ota", 12288, nullptr, tskIDLE_PRIORITY, &s_task, 0) != pdPASS) {
s_task = nullptr; s_task = nullptr;
fail("Out of memory (update task)"); fail("Out of memory (update task)");
return false; return false;
@@ -13,7 +13,8 @@
// mesh keeps being serviced during a long download. // mesh keeps being serviced during a long download.
// //
// The server comes from <root>/source.txt (line 1: URL template with {z} {x} // The server comes from <root>/source.txt (line 1: URL template with {z} {x}
// {y}, line 2: attribution); without it, OpenTopoMap. Whatever the source, // {y}, line 2: attribution, optional line 3: the server's last zoom level,
// default 19 -- or 17 for OpenTopoMap); without it, OpenTopoMap. Whatever the source,
// its attribution is written to <root>/attribution.txt for the map to show. // its attribution is written to <root>/attribution.txt for the map to show.
// //
// Live tiles: while the map is open, single tiles it is missing are fetched // Live tiles: while the map is open, single tiles it is missing are fetched
@@ -86,6 +87,8 @@ public:
uint32_t failed() const { return _failed; } uint32_t failed() const { return _failed; }
const char* message() const { return _msg; } const char* message() const { return _msg; }
const char* sourceHost() { loadSource(); return _host; } const char* sourceHost() { loadSource(); return _host; }
// The server's last zoom level: past it, the map magnifies the parent tile.
int sourceMaxZ() { loadSource(); return _src_zmax; }
// Unfinished job from <root>/.job (read once, then tracked in memory). // Unfinished job from <root>/.job (read once, then tracked in memory).
bool savedJob(TileArea& a) { bool savedJob(TileArea& a) {
@@ -105,17 +108,20 @@ public:
if (active()) return false; if (active()) return false;
if (_lv_fetching) { lvport::fetchAbandon(); _lv_fetching = false; } // the job takes the fetcher if (_lv_fetching) { lvport::fetchAbandon(); _lv_fetching = false; } // the job takes the fetcher
_lv_state = LV_IDLE; _lv_state = LV_IDLE;
loadSource();
_area = a; _area = a;
_total = countTiles(a); if (_area.zmax > _src_zmax) _area.zmax = _src_zmax; // the server has nothing finer
if (_area.zmin > _area.zmax) { fail("The server has no finer zoom: zoom out"); return false; }
_total = countTiles(_area);
_done = _skipped = _failed = _consec_fail = 0; _done = _skipped = _failed = _consec_fail = 0;
_retried = false;
_placeholder_hash = 0; _placeholder_hits = 0; _placeholder_hash = 0; _placeholder_hits = 0;
_msg[0] = '\0'; _msg[0] = '\0';
if (_total == 0 || _total > MAX_TILES) { fail("Area too large: use the PC tool"); return false; } if (_total == 0 || _total > MAX_TILES) { fail("Area too large: use the PC tool"); return false; }
loadSource();
writeAttribution(); writeAttribution();
writeJob(a); writeJob(_area);
_z = a.zmin; _z = _area.zmin;
tileRange(a, _z, _x0, _y0, _x1, _y1); tileRange(_area, _z, _x0, _y0, _x1, _y1);
_x = _x0; _y = _y0; _x = _x0; _y = _y0;
_fetching = false; _fetching = false;
_state = CONNECTING; _state = CONNECTING;
@@ -151,7 +157,7 @@ public:
int liveQueued() const { return _live ? _lv_n + (_lv_fetching ? 1 : 0) : 0; } int liveQueued() const { return _live ? _lv_n + (_lv_fetching ? 1 : 0) : 0; }
// A tile the map is missing (duplicates, recent failures and a full queue ignored). // A tile the map is missing (duplicates, recent failures and a full queue ignored).
void liveRequest(int z, int x, int y) { void liveRequest(int z, int x, int y) {
if (!_live || active()) return; if (!_live || active() || z > _src_zmax) return; // past the server's zoom: the map magnifies
if (_lv_fetching && _lv_cur.z == z && _lv_cur.x == x && _lv_cur.y == y) return; if (_lv_fetching && _lv_cur.z == z && _lv_cur.x == x && _lv_cur.y == y) return;
for (int i = 0; i < _lv_n; i++) if (_lv_q[i].z == z && _lv_q[i].x == x && _lv_q[i].y == y) return; for (int i = 0; i < _lv_n; i++) if (_lv_q[i].z == z && _lv_q[i].x == x && _lv_q[i].y == y) return;
for (const LiveTile& f : _lv_failed) if (f.z == z && f.x == x && f.y == y) return; for (const LiveTile& f : _lv_failed) if (f.z == z && f.x == x && f.y == y) return;
@@ -184,8 +190,7 @@ public:
lvport::fetchAbandon(); // hung past every timeout: give up on this tile lvport::fetchAbandon(); // hung past every timeout: give up on this tile
_fetching = false; _fetching = false;
onFailure("Request hung (60 s)"); onFailure("Request hung (60 s)");
if (_consec_fail >= 8) { finish(FAILED, _msg); return; } afterFailure();
advance();
return; return;
} }
_fetching = false; _fetching = false;
@@ -193,16 +198,24 @@ public:
size_t len = 0; size_t len = 0;
const uint8_t* data = lvport::fetchData(len); const uint8_t* data = lvport::fetchData(len);
if (!looksLikeImage(data, len)) onFailure("Server didn't send a PNG tile"); if (!looksLikeImage(data, len)) onFailure("Server didn't send a PNG tile");
else if (isNoTilePicture(data, len)) { _skipped++; _consec_fail = 0; } // "no tile here": nothing to keep
else if (isPlaceholder(data, len)) { lvport::fetchRelease(); finish(FAILED, "Server sends a placeholder (blocked / key?)"); return; } else if (isPlaceholder(data, len)) { lvport::fetchRelease(); finish(FAILED, "Server sends a placeholder (blocked / key?)"); return; }
else if (!writeTile(_z, _x, _y, data, len)) { lvport::fetchRelease(); finish(FAILED, "Can't write to the SD card"); return; } else if (!writeTile(_z, _x, _y, data, len)) { lvport::fetchRelease(); finish(FAILED, "Can't write to the SD card"); return; }
else { _done++; _consec_fail = 0; } else { _done++; _consec_fail = 0; }
} else if (!_retried && r != -403 && r != -401 && r != -404) {
// One more try after a pause: a dropped keep-alive connection or a
// busy server usually answers the second time.
lvport::fetchRelease();
_retried = true;
_last_start = millis() + 1000;
return;
} else { } else {
const char* why = lvport::fetchError(); const char* why = lvport::fetchError();
onFailure(r == -403 || r == -401 ? "Server refused (403)" : why[0] ? why : "Download failed"); onFailure(r == -403 || r == -401 ? "Server refused (403)" : why[0] ? why : "Download failed");
} }
lvport::fetchRelease(); lvport::fetchRelease();
if (_consec_fail >= 8) { finish(FAILED, _msg[0] ? _msg : "Server not answering"); return; } if (_consec_fail) afterFailure();
advance(); else advance();
return; return;
} }
@@ -213,7 +226,7 @@ public:
tilePath(path, sizeof(path), _z, _x, _y); tilePath(path, sizeof(path), _z, _x, _y);
struct stat st; struct stat st;
if (stat(path, &st) == 0 && st.st_size > 0) { _skipped++; advance(); continue; } if (stat(path, &st) == 0 && st.st_size > 0) { _skipped++; advance(); continue; }
if (millis() - _last_start < 120) return; // be gentle with the server if ((int32_t)(millis() - _last_start) < 120) return; // be gentle with the server (a retry waits longer)
char url[200]; char url[200];
buildUrl(url, sizeof(url), _z, _x, _y); buildUrl(url, sizeof(url), _z, _x, _y);
int r = lvport::fetchStart(url); int r = lvport::fetchStart(url);
@@ -231,12 +244,14 @@ private:
uint8_t _consec_fail = 0; uint8_t _consec_fail = 0;
int _z = 0, _x = 0, _y = 0, _x0 = 0, _y0 = 0, _x1 = 0, _y1 = 0; int _z = 0, _x = 0, _y = 0, _x0 = 0, _y0 = 0, _x1 = 0, _y1 = 0;
bool _fetching = false; bool _fetching = false;
bool _retried = false; // the current tile already failed once
uint32_t _last_start = 0, _connect_started = 0; uint32_t _last_start = 0, _connect_started = 0;
uint32_t _placeholder_hash = 0; uint32_t _placeholder_hash = 0;
uint8_t _placeholder_hits = 0; uint8_t _placeholder_hits = 0;
char _url_tpl[160] = ""; char _url_tpl[160] = "";
char _attr[96] = ""; char _attr[96] = "";
char _host[48] = ""; char _host[48] = "";
int _src_zmax = 17;
char _msg[64] = ""; char _msg[64] = "";
TileArea _job = {0, 0, 0, 0, 0, 0}; TileArea _job = {0, 0, 0, 0, 0, 0};
bool _has_job = false, _job_checked = false; bool _has_job = false, _job_checked = false;
@@ -302,7 +317,7 @@ private:
_lv_fetching = false; _lv_fetching = false;
size_t len = 0; size_t len = 0;
const uint8_t* data = r > 0 ? lvport::fetchData(len) : nullptr; const uint8_t* data = r > 0 ? lvport::fetchData(len) : nullptr;
if (r > 0 && looksLikeImage(data, len) && writeTile(_lv_cur.z, _lv_cur.x, _lv_cur.y, data, len)) { if (r > 0 && looksLikeImage(data, len) && !isNoTilePicture(data, len) && writeTile(_lv_cur.z, _lv_cur.x, _lv_cur.y, data, len)) {
_lv_consec_fail = 0; _lv_consec_fail = 0;
if (_lv_done_n < LV_DONE) _lv_done[_lv_done_n++] = _lv_cur; if (_lv_done_n < LV_DONE) _lv_done[_lv_done_n++] = _lv_cur;
} else { } else {
@@ -358,6 +373,22 @@ private:
return true; return true;
} }
// A failed tile is skipped -- unless failures come in a row: then the server
// (OpenTopoMap is often overloaded) or the network is down, so the same tile
// waits 5 s, 10 s, 20 s ... up to a minute, and the download stops only after
// about five minutes without a single tile.
void afterFailure() {
if (_consec_fail >= 12) { finish(FAILED, _msg[0] ? _msg : "Server not answering"); return; }
if (_consec_fail < 3) { advance(); return; }
_failed--; // this tile gets tried again, it isn't lost yet
uint32_t wait_s = 5u << (_consec_fail - 3);
if (wait_s > 60) wait_s = 60;
_last_start = millis() + wait_s * 1000;
_retried = true; // no extra quick retry on top of the pause
size_t o = strlen(_msg);
snprintf(_msg + o, sizeof(_msg) - o, " - retry in %lus", (unsigned long)wait_s);
}
void onFailure(const char* m) { void onFailure(const char* m) {
_failed++; _failed++;
_consec_fail++; _consec_fail++;
@@ -365,6 +396,7 @@ private:
} }
void advance() { void advance() {
_retried = false;
if (++_y <= _y1) return; if (++_y <= _y1) return;
_y = _y0; _y = _y0;
if (++_x <= _x1) return; if (++_x <= _x1) return;
@@ -392,14 +424,17 @@ private:
void loadSource() { void loadSource() {
snprintf(_url_tpl, sizeof(_url_tpl), "%s", DEFAULT_TILE_URL); snprintf(_url_tpl, sizeof(_url_tpl), "%s", DEFAULT_TILE_URL);
snprintf(_attr, sizeof(_attr), "%s", DEFAULT_TILE_ATTR); snprintf(_attr, sizeof(_attr), "%s", DEFAULT_TILE_ATTR);
_src_zmax = -1;
char path[64]; char path[64];
snprintf(path, sizeof(path), "%s/source.txt", _root); snprintf(path, sizeof(path), "%s/source.txt", _root);
if (FILE* f = fopen(path, "r")) { if (FILE* f = fopen(path, "r")) {
char line[160]; 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 (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); } if (fgets(line, sizeof(line), f)) { trim(line); if (line[0]) snprintf(_attr, sizeof(_attr), "%s", line); }
if (fgets(line, sizeof(line), f)) { int z = atoi(line); if (z >= 1 && z <= 22) _src_zmax = z; }
fclose(f); fclose(f);
} }
if (_src_zmax < 0) _src_zmax = strstr(_url_tpl, "opentopomap.org") ? 17 : 19;
const char* h = strstr(_url_tpl, "://"); const char* h = strstr(_url_tpl, "://");
h = h ? h + 3 : _url_tpl; h = h ? h + 3 : _url_tpl;
size_t n = strcspn(h, "/"); size_t n = strcspn(h, "/");
@@ -426,9 +461,12 @@ private:
} }
// A blocked / key-required server answers every tile with the same picture: // A blocked / key-required server answers every tile with the same picture:
// four identical payloads among the first downloads stop the job. // four identical payloads among the first downloads stop the job. Only on a
// fresh area (nothing on the card yet -- a resumed one proves the server
// works) and only for pictures with some content: plain sea / empty land
// tiles are genuinely identical, and compress to a few hundred bytes.
bool isPlaceholder(const uint8_t* d, size_t n) { bool isPlaceholder(const uint8_t* d, size_t n) {
if (_done >= 8) return false; if (_done >= 8 || _skipped > 0 || n < 1500) return false;
uint32_t h = 2166136261u; uint32_t h = 2166136261u;
for (size_t i = 0; i < n; i++) h = (h ^ d[i]) * 16777619u; for (size_t i = 0; i < n; i++) h = (h ^ d[i]) * 16777619u;
if (h == _placeholder_hash) return ++_placeholder_hits >= 3; if (h == _placeholder_hash) return ++_placeholder_hits >= 3;
@@ -18,6 +18,18 @@ extern "C" {
namespace mapview { namespace mapview {
// OpenTopoMap answers every tile past its last zoom level (17) with the same
// "no tile" picture. Earlier builds saved it; the provider drops such a file
// (so the map magnifies the parent instead) and the downloader never writes it.
static const uint32_t OTM_NOTILE_LEN = 4343, OTM_NOTILE_FNV = 0x0B80D772;
static bool isNoTilePicture(const uint8_t* d, size_t n) {
if (n != OTM_NOTILE_LEN) return false;
uint32_t h = 2166136261u;
for (size_t i = 0; i < n; i++) h = (h ^ d[i]) * 16777619u;
return h == OTM_NOTILE_FNV;
}
static const int TILE_PX = 256; static const int TILE_PX = 256;
class TileProvider { class TileProvider {
@@ -101,6 +113,7 @@ private:
long sz = ftell(f); long sz = ftell(f);
uint8_t* buf = sz > 0 ? readRange(f, 0, (uint32_t)sz) : nullptr; uint8_t* buf = sz > 0 ? readRange(f, 0, (uint32_t)sz) : nullptr;
fclose(f); fclose(f);
if (buf && isNoTilePicture(buf, (size_t)sz)) { lv_free(buf); remove(path); return nullptr; }
if (buf) len = (uint32_t)sz; if (buf) len = (uint32_t)sz;
return buf; return buf;
} }