mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-12 00:22:02 +00:00
group() / settingRow / listRow / actionRow / choiceRow / groupNote: one card per section, hairlines between rows, hints that wrap. Dropdowns replaced by a centred picker list. Settings regrouped (Device, Connections, Map & data, System); Display & power split into Display, Power and Time on L2 only (the shared schema is unchanged), Keyboard and About get their own pages. Radio and Repeater share one LoRa parameter block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
13 KiB
C++
313 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 lv_obj_t* s_ota_latest = nullptr; // the release found, once checked
|
|
|
|
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();
|
|
}
|
|
|
|
// Something else holds the WiFi: a scan leaves it up.
|
|
bool UITask::wifiInUse() const { return mapview::s_dl.active() || otaBusy() || ota::s_state == ota::CONNECTING; }
|
|
|
|
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);
|
|
lv_obj_t* card = infoCard(body);
|
|
infoRow(card, "Installed", FIRMWARE_VERSION);
|
|
s_ota_latest = infoRow(card, "Latest release", "");
|
|
_ota_status = label(body, "", THEME_FONT_BODY, theme::TEXT);
|
|
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);
|
|
groupNote(body, "From github.com/" OTA_REPO " releases, over WiFi. About a minute; keep the device "
|
|
"charged. Settings, contacts and messages are kept.");
|
|
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);
|
|
if (s_ota_latest) infoSet(s_ota_latest, s_latest);
|
|
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();
|
|
}
|