Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/OtaScreen.h
T
JakubandClaude Opus 5.5 690df304d7 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>
2026-09-26 19:46:36 +02:00

309 lines
13 KiB
C++

#pragma once
// Settings > System > Firmware update: one-button OTA from this fork's GitHub
// Releases. The latest release's app image for this board (the asset whose
// name ends in OTA_ASSET_SUFFIX, published by build-solo-firmwares.yml) is
// streamed over verified TLS (the framework's Mozilla CA bundle) straight into
// the idle OTA slot; the device then restarts into it. Settings, contacts and
// messages live outside the app slots and are kept.
//
// Needs Arduino-ESP32 3.x for the CA bundle: the 2.0.17 build and the sim say
// so instead of offering the update.
//
// Single-TU fragment: included at the end of ui-lvgl/UITask.cpp.
#ifndef OTA_REPO
#define OTA_REPO "MarekZegare4/MeshCore-Solo"
#endif
#if defined(ESP32) && defined(OTA_ASSET_SUFFIX) && defined(ESP_ARDUINO_VERSION_MAJOR) && ESP_ARDUINO_VERSION_MAJOR >= 3
#define OTA_SUPPORTED 1
#include <Update.h>
#include <NetworkClientSecure.h>
#include <HTTPClient.h>
extern const uint8_t ota_crt_bundle_start[] asm("_binary_x509_crt_bundle_start");
extern const uint8_t ota_crt_bundle_end[] asm("_binary_x509_crt_bundle_end");
#else
#define OTA_SUPPORTED 0
#endif
namespace ota {
enum State : uint8_t { IDLE, CONNECTING, CHECKING, CHECKED, INSTALLING, DONE, FAILED };
static volatile uint8_t s_state = IDLE;
static char s_msg[96] = ""; // why it failed / what was found
static char s_latest[24] = ""; // the release tag
static char s_url[256] = ""; // the asset's download URL
static volatile uint32_t s_size = 0, s_written = 0;
static uint32_t s_since = 0; // WiFi connect start / restart countdown
static bool s_newer = false; // the release is newer than this build
static uint8_t s_job = 0; // what the task does once the WiFi is up: 1 check, 2 install
// "v1.28" / "1.28.3" -> comparable number; 0 when it isn't a version (a dev build).
static uint32_t versionNum(const char* v) {
if (*v == 'v' || *v == 'V') v++;
unsigned a = 0, b = 0, c = 0;
int n = sscanf(v, "%u.%u.%u", &a, &b, &c);
return n >= 2 ? (a << 20) | (b << 10) | c : 0;
}
#if OTA_SUPPORTED
static TaskHandle_t s_task = nullptr;
static void fail(const char* m) { snprintf(s_msg, sizeof(s_msg), "%s", m); s_state = FAILED; }
// "key":"value" at or after `from`.
static bool jsonStr(const char* from, const char* key, char* out, size_t n) {
char pat[40];
snprintf(pat, sizeof(pat), "\"%s\":\"", key);
const char* p = strstr(from, pat);
if (!p) return false;
p += strlen(pat);
size_t i = 0;
while (*p && *p != '"' && i + 1 < n) out[i++] = *p++;
out[i] = '\0';
return *p == '"';
}
static void check(HTTPClient& http, NetworkClientSecure& tls) {
if (!http.begin(tls, "https://api.github.com/repos/" OTA_REPO "/releases/latest")) { fail("Bad release URL"); return; }
http.addHeader("Accept", "application/vnd.github+json");
int code = http.GET();
if (code != 200) {
if (code == 404) fail("No published release yet");
else if (code < 0) fail(HTTPClient::errorToString(code).c_str());
else snprintf(s_msg, sizeof(s_msg), "GitHub answered %d", code), s_state = FAILED;
http.end();
return;
}
String body = http.getString(); // tens of KB: lands in PSRAM (large mallocs do)
http.end();
const char* b = body.c_str();
if (!jsonStr(b, "tag_name", s_latest, sizeof(s_latest))) { fail("Unexpected answer from GitHub"); return; }
const char* a = strstr(b, OTA_ASSET_SUFFIX "\""); // the asset's "name" comes before its size / URL
if (!a) { snprintf(s_msg, sizeof(s_msg), "%s has no image for this device", s_latest); s_state = FAILED; return; }
const char* sz = strstr(a, "\"size\":");
s_size = sz ? (uint32_t)strtoul(sz + 7, nullptr, 10) : 0;
if (!jsonStr(a, "browser_download_url", s_url, sizeof(s_url)) || !s_size) { fail("Unexpected answer from GitHub"); return; }
uint32_t have = versionNum(FIRMWARE_VERSION), got = versionNum(s_latest);
s_newer = !have || got > have;
s_state = CHECKED;
}
static void install(HTTPClient& http, NetworkClientSecure& tls) {
s_written = 0;
if (!http.begin(tls, s_url)) { fail("Bad download URL"); return; }
int code = http.GET(); // github.com answers with a redirect to its download host
if (code != 200) {
if (code < 0) fail(HTTPClient::errorToString(code).c_str());
else snprintf(s_msg, sizeof(s_msg), "Download answered %d", code), s_state = FAILED;
http.end();
return;
}
int len = http.getSize();
if (len <= 0 || (uint32_t)len != s_size) { fail("Download size doesn't match the release"); http.end(); return; }
if (!Update.begin(len)) { snprintf(s_msg, sizeof(s_msg), "No room for the update: %s", Update.errorString()); s_state = FAILED; http.end(); return; }
uint8_t* buf = (uint8_t*)heap_caps_malloc(4096, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!buf) buf = (uint8_t*)malloc(4096);
NetworkClient* in = http.getStreamPtr();
uint32_t last_data = millis();
bool ok = buf != nullptr;
if (!ok) snprintf(s_msg, sizeof(s_msg), "Out of memory");
while (ok && s_written < (uint32_t)len) {
size_t avail = in->available();
if (!avail) {
if (!in->connected() || millis() - last_data > 20000) { snprintf(s_msg, sizeof(s_msg), "Download stalled"); ok = false; break; }
vTaskDelay(pdMS_TO_TICKS(5));
continue;
}
int n = in->readBytes(buf, avail > 4096 ? 4096 : avail);
if (n <= 0) continue;
last_data = millis();
// The image header: ESP magic, and the chip (offset 12, little endian) must be this one.
if (s_written == 0 && (n < 16 || buf[0] != 0xE9 || (buf[12] | (buf[13] << 8)) != CONFIG_IDF_FIRMWARE_CHIP_ID)) {
snprintf(s_msg, sizeof(s_msg), "Not a firmware image for this chip");
ok = false;
break;
}
if (Update.write(buf, n) != (size_t)n) { snprintf(s_msg, sizeof(s_msg), "Flash write failed: %s", Update.errorString()); ok = false; break; }
s_written += n;
}
free(buf);
http.end();
if (!ok) { Update.abort(); s_state = FAILED; return; }
if (!Update.end(true)) { snprintf(s_msg, sizeof(s_msg), "Image rejected: %s", Update.errorString()); s_state = FAILED; return; }
s_state = DONE;
}
static void task(void*) {
mbedtls_platform_set_calloc_free(lvport::tlsCalloc, heap_caps_free); // TLS buffers in PSRAM
{
NetworkClientSecure tls;
tls.setCACertBundle(ota_crt_bundle_start, ota_crt_bundle_end - ota_crt_bundle_start);
tls.setHandshakeTimeout(20);
HTTPClient http;
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.setConnectTimeout(10000);
http.setTimeout(20000);
http.setUserAgent("MeshCore-Solo-OTA");
if (s_job == 1) check(http, tls); else install(http, tls);
}
s_task = nullptr;
vTaskDelete(nullptr);
}
static bool startTask() {
s_state = s_job == 1 ? CHECKING : INSTALLING;
// 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;
fail("Out of memory (update task)");
return false;
}
return true;
}
#endif
} // namespace ota
static void onOpenOta(lv_event_t* e) { (void)e; s_ui->showOta(); }
static void onOtaButton(lv_event_t* e) { (void)e; s_ui->otaAction(); }
void UITask::showOta() {
_screen = SCR_OTA;
buildOta();
}
bool UITask::otaBusy() const { return ota::s_state == ota::CHECKING || ota::s_state == ota::INSTALLING || ota::s_state == ota::DONE; }
void UITask::buildOta() {
lv_obj_t* body = newScreen("Firmware update", true);
char t[96];
snprintf(t, sizeof(t), "Installed: %s", FIRMWARE_VERSION);
label(body, t, THEME_FONT_BODY, theme::TEXT);
_ota_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(_ota_status, LV_LABEL_LONG_WRAP);
lv_obj_set_width(_ota_status, LV_PCT(100));
_ota_bar = lv_bar_create(body);
lv_obj_set_size(_ota_bar, LV_PCT(100), 10);
lv_bar_set_range(_ota_bar, 0, 1000);
lv_obj_set_style_bg_color(_ota_bar, lv_color_hex(theme::SURFACE), LV_PART_MAIN);
lv_obj_set_style_bg_color(_ota_bar, lv_color_hex(theme::ACCENT), LV_PART_INDICATOR);
lv_obj_t* b = lv_button_create(body);
lv_obj_set_size(b, LV_PCT(100), 40);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::ACCENT_DIM), 0);
lv_obj_add_event_cb(b, onOtaButton, LV_EVENT_CLICKED, NULL);
_ota_btn = b;
_ota_btn_lbl = label(b, "", THEME_FONT_BODY, theme::TEXT);
lv_obj_center(_ota_btn_lbl);
stylePrimary(b);
lv_obj_t* h = label(body, "From github.com/" OTA_REPO " releases, over WiFi. About a minute; keep the device "
"charged. Settings, contacts and messages are kept.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(h, LV_LABEL_LONG_WRAP);
lv_obj_set_width(h, LV_PCT(100));
refreshOta();
}
void UITask::refreshOta() {
if (_screen != SCR_OTA || !_ota_status) return;
using namespace ota;
const char* btn = "Check for update";
bool bar = false, btn_on = true;
char st[128];
st[0] = '\0';
switch (s_state) {
case IDLE:
#if OTA_SUPPORTED
snprintf(st, sizeof(st), "Looks for a newer release on GitHub.");
#elif defined(SIM_PLATFORM)
snprintf(st, sizeof(st), "Not available in the simulator.");
btn_on = false;
#else
snprintf(st, sizeof(st), "Not available in this build (needs the Arduino-ESP32 3.x firmware line).");
btn_on = false;
#endif
break;
case CONNECTING: snprintf(st, sizeof(st), "Connecting to WiFi..."); btn_on = false; break;
case CHECKING: snprintf(st, sizeof(st), "Asking GitHub for the latest release..."); btn_on = false; break;
case CHECKED:
if (s_newer) {
snprintf(st, sizeof(st), "%s is available.", s_latest);
static char b[48];
snprintf(b, sizeof(b), LV_SYMBOL_DOWNLOAD " Install %s (%.1f MB)", s_latest, s_size / 1048576.0);
btn = b;
} else {
snprintf(st, sizeof(st), "Up to date - the latest release is %s.", s_latest);
btn = "Check again";
}
break;
case INSTALLING:
snprintf(st, sizeof(st), "Installing %s: %lu of %lu KB. Don't turn the device off.", s_latest,
(unsigned long)(s_written / 1024), (unsigned long)(s_size / 1024));
bar = true; btn_on = false;
if (s_size) lv_bar_set_value(_ota_bar, (int32_t)((uint64_t)s_written * 1000 / s_size), LV_ANIM_OFF);
break;
case DONE:
snprintf(st, sizeof(st), "Installed %s - restarting...", s_latest);
bar = true; btn_on = false;
lv_bar_set_value(_ota_bar, 1000, LV_ANIM_OFF);
break;
case FAILED:
snprintf(st, sizeof(st), "%s", s_msg);
btn = "Try again";
break;
}
lv_label_set_text(_ota_status, st);
lv_label_set_text(_ota_btn_lbl, btn);
if (bar) lv_obj_remove_flag(_ota_bar, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(_ota_bar, LV_OBJ_FLAG_HIDDEN);
if (btn_on) lv_obj_remove_flag(_ota_btn, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(_ota_btn, LV_OBJ_FLAG_HIDDEN);
}
// The button: check (after bringing the WiFi up), then install what was found.
void UITask::otaAction() {
#if OTA_SUPPORTED
using namespace ota;
if (otaBusy() || s_state == CONNECTING) return;
bool install = s_state == CHECKED && s_newer;
char ssid[33], pass[65];
if (!lvport::wifiAllowed()) { showToast("WiFi is off - Settings > WiFi"); return; }
if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) { showToast("Pick a WiFi network first - Settings > WiFi", 3000); return; }
if (mapview::s_dl.active()) { showToast("A map download is running - stop it first"); return; }
mapview::s_dl.liveEnd(); // the update owns the WiFi from here
s_job = install ? 2 : 1;
if (lvport::netState() == lvport::NET_UP) startTask();
else { lvport::netBegin(ssid, pass); s_state = CONNECTING; s_since = millis(); }
refreshOta();
#endif
}
// Every loop pass (the install runs on with the screen off).
void UITask::otaTick() {
using namespace ota;
static uint32_t next = 0;
if (s_state == IDLE || (int32_t)(millis() - next) < 0) return;
next = millis() + 250;
#if OTA_SUPPORTED
if (s_state == CONNECTING) {
int ns = lvport::netState();
if (ns == lvport::NET_UP) startTask();
else if (ns == lvport::NET_FAILED || millis() - s_since > 20000) { lvport::netEnd(); fail("WiFi: can't connect"); }
} else if (s_state == DONE) {
if (!s_since || s_job) { s_since = millis(); s_job = 0; }
else if (millis() - s_since > 2000) { the_mesh.savePrefs(); ESP.restart(); }
} else if (s_state == FAILED && _screen != SCR_OTA) {
lvport::netEnd();
s_state = IDLE;
}
#endif
refreshOta();
}
// Leaving the screen: the WiFi goes unless the update is running.
void UITask::otaLeave() {
if (otaBusy()) { showToast("Updating - wait for it to finish"); return; }
if (ota::s_state != ota::IDLE) { lvport::netEnd(); ota::s_state = ota::IDLE; }
showSettings();
}