mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-21 04:54:28 +00:00
Added support for deferred OTA updates in the MyMesh class, allowing the system to schedule firmware updates to occur after a confirmation reply is sent. This change improves the user experience by ensuring that the update process does not block the main application loop, allowing for smoother operation during firmware updates.
303 lines
11 KiB
C++
303 lines
11 KiB
C++
#ifdef ESP_PLATFORM
|
|
|
|
#include "ESP32Board.h"
|
|
|
|
#if defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only
|
|
#include <WiFi.h>
|
|
#include <AsyncTCP.h>
|
|
#include <ESPAsyncWebServer.h>
|
|
#include <AsyncElegantOTA.h>
|
|
|
|
#include <SPIFFS.h>
|
|
|
|
bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
|
|
inhibit_sleep = true; // prevent sleep during OTA
|
|
|
|
// If the device is already on a WiFi network (e.g. an observer joined in STA
|
|
// mode), serve ElegantOTA on the station IP so it's reachable from the LAN
|
|
// without joining a separate AP. Otherwise raise the MeshCore-OTA SoftAP.
|
|
IPAddress ip;
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
ip = WiFi.localIP();
|
|
} else {
|
|
WiFi.softAP("MeshCore-OTA", NULL);
|
|
ip = WiFi.softAPIP();
|
|
}
|
|
|
|
sprintf(reply, "Started: http://%s/update", ip.toString().c_str());
|
|
MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply);
|
|
|
|
static char id_buf[60];
|
|
sprintf(id_buf, "%s (%s)", id, getManufacturerName());
|
|
static char home_buf[90];
|
|
sprintf(home_buf, "<H2>Hi! I am a MeshCore Repeater. ID: %s</H2>", id);
|
|
|
|
AsyncWebServer* server = new AsyncWebServer(80);
|
|
|
|
server->on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
|
|
request->send(200, "text/html", home_buf);
|
|
});
|
|
server->on("/log", HTTP_GET, [](AsyncWebServerRequest *request) {
|
|
request->send(SPIFFS, "/packet_log", "text/plain");
|
|
});
|
|
|
|
AsyncElegantOTA.setID(id_buf);
|
|
AsyncElegantOTA.begin(server); // Start ElegantOTA
|
|
server->begin();
|
|
|
|
return true;
|
|
}
|
|
|
|
#else
|
|
bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
|
|
return false; // not supported
|
|
}
|
|
#endif
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Manifest-driven pull OTA (observer / MQTT-bridge builds only)
|
|
//
|
|
// The observer already holds a live WiFi station connection (for the MQTT
|
|
// bridge) and embeds a root-CA bundle, so it can fetch its own firmware. The
|
|
// caller (CommonCLI) stops the MQTT bridge first to free heap/TLS, then calls
|
|
// this. We read the web-flasher manifest (config.json), find the `flash-update`
|
|
// (app-only) build for our own variant, refuse partition-change releases (OTA
|
|
// can't rewrite the partition table), skip if already up to date, then stream
|
|
// the .bin straight into the inactive OTA slot via HTTPUpdate.
|
|
// ---------------------------------------------------------------------------
|
|
#if defined(WITH_MQTT_BRIDGE)
|
|
#include <WiFi.h>
|
|
#include <WiFiClientSecure.h>
|
|
#include <HTTPClient.h>
|
|
#include <HTTPUpdate.h>
|
|
#include <ArduinoJson.h>
|
|
#include <freertos/FreeRTOS.h>
|
|
#include <freertos/task.h>
|
|
|
|
// Embedded CA bundle (produced by board_build.embed_files). Weak so non-bundle
|
|
// builds still link; we check for presence at runtime.
|
|
extern const uint8_t rootca_crt_bundle_start[] asm("_binary_src_certs_x509_crt_bundle_bin_start") __attribute__((weak));
|
|
extern const uint8_t rootca_crt_bundle_end[] asm("_binary_src_certs_x509_crt_bundle_bin_end") __attribute__((weak));
|
|
|
|
// Extract the trailing build hash. For a filename we first drop a ".bin"
|
|
// suffix, then take the token after the last '-'. Works for both the manifest
|
|
// asset name ("...-v1.16.0-8b084d5.bin" -> "8b084d5") and the embedded
|
|
// FIRMWARE_VERSION ("v1.16.0-observer-8b084d5" -> "8b084d5").
|
|
static void ota_extractHash(const char* s, char* out, size_t out_sz) {
|
|
if (!s) { if (out_sz) out[0] = 0; return; }
|
|
size_t len = strlen(s);
|
|
if (len > 4 && strcmp(s + len - 4, ".bin") == 0) len -= 4;
|
|
size_t i = len;
|
|
while (i > 0 && s[i - 1] != '-') i--;
|
|
size_t n = len - i;
|
|
if (n >= out_sz) n = out_sz - 1;
|
|
memcpy(out, s + i, n);
|
|
out[n] = 0;
|
|
}
|
|
|
|
// Parameters handed to the worker task; lives on otaFromManifest()'s stack,
|
|
// which stays valid because that function blocks until the worker signals done.
|
|
struct OtaTaskArgs {
|
|
ESP32Board* self;
|
|
const char* current_ver;
|
|
bool dry_run;
|
|
char* reply;
|
|
volatile bool result;
|
|
volatile bool done;
|
|
};
|
|
|
|
static void ota_task_entry(void* param) {
|
|
OtaTaskArgs* a = static_cast<OtaTaskArgs*>(param);
|
|
a->result = a->self->otaFromManifestImpl(a->current_ver, a->dry_run, a->reply);
|
|
a->done = true; // on a successful `ota update` we reboot before reaching here
|
|
vTaskDelete(nullptr);
|
|
}
|
|
|
|
bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char reply[]) {
|
|
// The TLS handshake (cert-bundle verify) + JSON parse / HTTPUpdate use far more
|
|
// stack than the ~8 KB loop task offers — especially when reached via the deep
|
|
// mesh-receive call chain (it overflows the loopTask canary). Run the work in a
|
|
// dedicated 24 KB-stack task and block here until it finishes. The big stack is
|
|
// freed when the task exits; on a successful update the chip reboots inside it.
|
|
OtaTaskArgs args = { this, current_ver, dry_run, reply, false, false };
|
|
TaskHandle_t handle = nullptr;
|
|
BaseType_t ok = xTaskCreatePinnedToCore(ota_task_entry, "ota", 24576, &args, 5, &handle, 1);
|
|
if (ok != pdPASS) {
|
|
strcpy(reply, "ERR: OTA task spawn failed");
|
|
return false;
|
|
}
|
|
while (!args.done) {
|
|
delay(50); // Arduino delay() yields to other tasks
|
|
}
|
|
return args.result;
|
|
}
|
|
|
|
bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char reply[]) {
|
|
#if !defined(OTA_MANIFEST_URL) || !defined(OTA_VARIANT)
|
|
strcpy(reply, "ERR: OTA not configured (build via build.sh)");
|
|
return false;
|
|
#else
|
|
if (WiFi.status() != WL_CONNECTED) {
|
|
strcpy(reply, "ERR: WiFi not connected");
|
|
return false;
|
|
}
|
|
|
|
size_t bundle_len = 0;
|
|
if (rootca_crt_bundle_start != nullptr && rootca_crt_bundle_end != nullptr &&
|
|
rootca_crt_bundle_end > rootca_crt_bundle_start) {
|
|
bundle_len = (size_t)(rootca_crt_bundle_end - rootca_crt_bundle_start);
|
|
}
|
|
if (bundle_len == 0) {
|
|
strcpy(reply, "ERR: no embedded cert bundle");
|
|
return false;
|
|
}
|
|
|
|
// --- Fetch + filter-parse the manifest -----------------------------------
|
|
WiFiClientSecure mclient;
|
|
#if ESP_ARDUINO_VERSION_MAJOR >= 3
|
|
mclient.setCACertBundle(rootca_crt_bundle_start, bundle_len);
|
|
#else
|
|
mclient.setCACertBundle(rootca_crt_bundle_start);
|
|
#endif
|
|
mclient.setTimeout(15000);
|
|
|
|
HTTPClient http;
|
|
if (!http.begin(mclient, OTA_MANIFEST_URL)) {
|
|
strcpy(reply, "ERR: manifest connect failed");
|
|
return false;
|
|
}
|
|
// Force HTTP/1.0: a CDN (e.g. Cloudflare) answers HTTP/1.1 with
|
|
// Transfer-Encoding: chunked and no Content-Length, and the raw chunked
|
|
// stream can't be fed to the JSON parser (chunk-size frames corrupt it).
|
|
// HTTP/1.0 yields a Connection: close, unframed body we can stream-parse.
|
|
http.useHTTP10(true);
|
|
http.setTimeout(20000); // per-read timeout while streaming the body
|
|
int code = http.GET();
|
|
if (code != HTTP_CODE_OK) {
|
|
snprintf(reply, 160, "ERR: manifest HTTP %d", code);
|
|
http.end();
|
|
return false;
|
|
}
|
|
|
|
// Stream-parse straight from the network: the filter discards all but
|
|
// staticPath + each firmware entry's notice/version, so peak RAM is just the
|
|
// small kept subset (not the ~40 KB manifest). This matters for `ota check`,
|
|
// which runs with the MQTT bridge still up and holding heap. (The dynamic
|
|
// version key forces keeping its whole subtree, incl. release notes.)
|
|
// readBytes() honours the stream timeout, so a slow TLS link won't be
|
|
// mistaken for end-of-input.
|
|
WiFiClient* stream = http.getStreamPtr();
|
|
stream->setTimeout(20000);
|
|
|
|
JsonDocument filter;
|
|
filter["staticPath"] = true;
|
|
filter["device"][0]["firmware"][0]["notice"] = true;
|
|
filter["device"][0]["firmware"][0]["version"] = true;
|
|
|
|
JsonDocument doc;
|
|
DeserializationError err =
|
|
deserializeJson(doc, *stream, DeserializationOption::Filter(filter));
|
|
http.end();
|
|
if (err) {
|
|
snprintf(reply, 160, "ERR: manifest parse (%s)", err.c_str());
|
|
return false;
|
|
}
|
|
|
|
// Copy out of the document up front: doc gets cleared before these are used.
|
|
char base_url[128] = {0};
|
|
strncpy(base_url, doc["staticPath"] | "", sizeof(base_url) - 1);
|
|
if (!base_url[0]) {
|
|
strcpy(reply, "ERR: manifest missing staticPath");
|
|
return false;
|
|
}
|
|
|
|
// --- Locate the flash-update build for our variant -----------------------
|
|
const char* variant = OTA_VARIANT;
|
|
size_t vlen = strlen(variant);
|
|
char target_name[128] = {0};
|
|
bool partition_change = false;
|
|
bool found = false;
|
|
|
|
for (JsonObject dev : doc["device"].as<JsonArray>()) {
|
|
for (JsonObject fw : dev["firmware"].as<JsonArray>()) {
|
|
const char* notice = fw["notice"].is<const char*>() ? fw["notice"].as<const char*>() : nullptr;
|
|
for (JsonPair vp : fw["version"].as<JsonObject>()) {
|
|
for (JsonObject file : vp.value()["files"].as<JsonArray>()) {
|
|
const char* type = file["type"] | "";
|
|
const char* name = file["name"] | "";
|
|
if (strcmp(type, "flash-update") != 0) continue;
|
|
if (strncmp(name, variant, vlen) != 0 || name[vlen] != '-') continue;
|
|
strncpy(target_name, name, sizeof(target_name) - 1);
|
|
partition_change = (notice != nullptr && strcmp(notice, "partition-change") == 0);
|
|
found = true;
|
|
break;
|
|
}
|
|
if (found) break;
|
|
}
|
|
if (found) break;
|
|
}
|
|
if (found) break;
|
|
}
|
|
doc.clear();
|
|
|
|
if (!found) {
|
|
snprintf(reply, 160, "ERR: no build for %s in manifest", variant);
|
|
return false;
|
|
}
|
|
|
|
char avail_hash[24], cur_hash[24];
|
|
ota_extractHash(target_name, avail_hash, sizeof(avail_hash));
|
|
ota_extractHash(current_ver, cur_hash, sizeof(cur_hash));
|
|
// Compare by shared prefix: git abbreviates the same commit to 7 chars on a
|
|
// shallow CI clone but 8 locally, so an exact match would miss equal builds.
|
|
size_t la = strlen(avail_hash), lc = strlen(cur_hash);
|
|
size_t m = (la < lc) ? la : lc;
|
|
bool up_to_date = (m >= 7 && strncmp(avail_hash, cur_hash, m) == 0);
|
|
|
|
if (dry_run) {
|
|
snprintf(reply, 160, "%s: %s -> %s%s", up_to_date ? "up to date" : "update available",
|
|
cur_hash, avail_hash, partition_change ? " [partition change: cable flash]" : "");
|
|
return true;
|
|
}
|
|
if (partition_change) {
|
|
snprintf(reply, 160, "ERR: %s needs cable flash (partition change)", avail_hash);
|
|
return false;
|
|
}
|
|
if (up_to_date) {
|
|
snprintf(reply, 160, "OK: already up to date (%s)", cur_hash);
|
|
return false;
|
|
}
|
|
|
|
// --- Stream the .bin into the inactive OTA slot --------------------------
|
|
char url[256];
|
|
snprintf(url, sizeof(url), "%s/%s", base_url, target_name);
|
|
|
|
inhibit_sleep = true; // keep awake through the flash
|
|
|
|
WiFiClientSecure uclient;
|
|
#if ESP_ARDUINO_VERSION_MAJOR >= 3
|
|
uclient.setCACertBundle(rootca_crt_bundle_start, bundle_len);
|
|
#else
|
|
uclient.setCACertBundle(rootca_crt_bundle_start);
|
|
#endif
|
|
uclient.setTimeout(20000);
|
|
|
|
httpUpdate.rebootOnUpdate(true); // reboots into the new image on success
|
|
t_httpUpdate_return ret = httpUpdate.update(uclient, url);
|
|
|
|
// Only reached on failure (success reboots inside update()).
|
|
inhibit_sleep = false;
|
|
snprintf(reply, 160, "ERR: OTA failed (%d): %s", (int)ret,
|
|
httpUpdate.getLastErrorString().c_str());
|
|
return false;
|
|
#endif // OTA_MANIFEST_URL && OTA_VARIANT
|
|
}
|
|
#else
|
|
bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char reply[]) {
|
|
strcpy(reply, "ERR: not supported");
|
|
return false;
|
|
}
|
|
#endif // WITH_MQTT_BRIDGE
|
|
|
|
#endif
|