Enhanced the firmware versioning system by appending a build number suffix when available, allowing for better tracking of published builds. This change improves the OTA update process by providing clearer versioning information in the embedded firmware string.
329 lines
12 KiB
C++
329 lines
12 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;
|
|
}
|
|
|
|
// Split a version token "vMAJOR.MINOR.PATCH[.BUILD]" into its base
|
|
// ("vMAJOR.MINOR.PATCH") and build number (BUILD, or -1 if there's no 4th
|
|
// component). The base has exactly two dots; a third dot introduces the build.
|
|
static void ota_parseVersion(const char* ver, char* base_out, size_t base_sz, int* build_out) {
|
|
*build_out = -1;
|
|
if (base_sz) base_out[0] = 0;
|
|
if (!ver) return;
|
|
int dots = 0, third_dot = -1;
|
|
for (int j = 0; ver[j]; j++) {
|
|
if (ver[j] == '.' && ++dots == 3) { third_dot = j; break; }
|
|
}
|
|
if (third_dot >= 0) {
|
|
size_t n = (size_t)third_dot;
|
|
if (n >= base_sz) n = base_sz - 1;
|
|
memcpy(base_out, ver, n);
|
|
base_out[n] = 0;
|
|
*build_out = atoi(ver + third_dot + 1);
|
|
} else {
|
|
strncpy(base_out, ver, base_sz - 1);
|
|
base_out[base_sz - 1] = 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_BASE) || !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 this variant's slim manifest ----------------------------------
|
|
// <OTA_MANIFEST_BASE>/<OTA_VARIANT>.json — a ~180 byte per-variant file, NOT
|
|
// the full config.json. Keeping the body tiny is what lets `ota check` run with
|
|
// the MQTT bridge up on no-PSRAM boards: only the TLS handshake costs heap, with
|
|
// no large JSON document layered on top.
|
|
char murl[200];
|
|
snprintf(murl, sizeof(murl), "%s/%s.json", OTA_MANIFEST_BASE, OTA_VARIANT);
|
|
|
|
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, murl)) {
|
|
strcpy(reply, "ERR: manifest connect failed");
|
|
return false;
|
|
}
|
|
// Force HTTP/1.0: a CDN (e.g. Cloudflare) answers HTTP/1.1 with chunked encoding
|
|
// and no Content-Length; the raw chunked stream corrupts the parse. HTTP/1.0
|
|
// yields a Connection: close, unframed body.
|
|
http.useHTTP10(true);
|
|
http.setTimeout(20000);
|
|
int code = http.GET();
|
|
if (code != HTTP_CODE_OK) {
|
|
snprintf(reply, 160, "ERR: manifest HTTP %d", code);
|
|
http.end();
|
|
return false;
|
|
}
|
|
|
|
WiFiClient* stream = http.getStreamPtr();
|
|
stream->setTimeout(20000); // readBytes honours this, so a slow TLS link != EOF
|
|
|
|
JsonDocument doc;
|
|
DeserializationError err = deserializeJson(doc, *stream);
|
|
http.end();
|
|
if (err) {
|
|
snprintf(reply, 160, "ERR: manifest parse (%s)", err.c_str());
|
|
return false;
|
|
}
|
|
|
|
// Copy fields out before the document is reused/cleared.
|
|
char file_url[200] = {0}, avail_version[40] = {0}, avail_base[40] = {0}, avail_hash[24] = {0};
|
|
strncpy(file_url, doc["file"] | "", sizeof(file_url) - 1);
|
|
strncpy(avail_version, doc["version"] | "", sizeof(avail_version) - 1);
|
|
strncpy(avail_base, doc["baseVersion"] | "", sizeof(avail_base) - 1);
|
|
strncpy(avail_hash, doc["hash"] | "", sizeof(avail_hash) - 1);
|
|
int avail_build = doc["build"] | -1;
|
|
bool partition_change = doc["partitionChange"] | false;
|
|
doc.clear();
|
|
|
|
if (!file_url[0]) {
|
|
strcpy(reply, "ERR: manifest missing file");
|
|
return false;
|
|
}
|
|
|
|
// --- Determine current-vs-available --------------------------------------
|
|
// Our running version token (e.g. "v1.16.0.5"), i.e. current_ver up to the
|
|
// first '-' (which precedes "-observer-<hash>").
|
|
char own_version[40] = {0};
|
|
for (size_t i = 0; current_ver && current_ver[i] && current_ver[i] != '-' && i < sizeof(own_version) - 1; i++) {
|
|
own_version[i] = current_ver[i];
|
|
}
|
|
char own_base[40];
|
|
int own_build;
|
|
ota_parseVersion(own_version, own_base, sizeof(own_base), &own_build);
|
|
|
|
// Fallback identity by commit hash (handles pre-build-number / local images that
|
|
// carry no 4th version component). Shared-prefix compare absorbs the 7- vs 8-char
|
|
// git abbreviation difference.
|
|
char cur_hash[24];
|
|
ota_extractHash(current_ver, cur_hash, sizeof(cur_hash));
|
|
size_t lh = strlen(avail_hash), lc = strlen(cur_hash);
|
|
size_t m = (lh < lc) ? lh : lc;
|
|
bool hash_equal = (m >= 7 && strncmp(avail_hash, cur_hash, m) == 0);
|
|
|
|
bool same_base = (own_base[0] && avail_base[0] && strcmp(own_base, avail_base) == 0);
|
|
bool have_builds = (own_build >= 0 && avail_build >= 0);
|
|
bool diff_base = (own_base[0] && avail_base[0] && !same_base);
|
|
|
|
int behind = 0;
|
|
bool up_to_date;
|
|
if (same_base && have_builds) {
|
|
behind = avail_build - own_build;
|
|
up_to_date = (behind <= 0);
|
|
} else if (diff_base) {
|
|
up_to_date = false; // different base version is always an update
|
|
} else {
|
|
up_to_date = hash_equal; // unknown build numbers -> fall back to hash
|
|
}
|
|
|
|
const char* avail_disp = avail_version[0] ? avail_version : avail_hash;
|
|
const char* own_disp = own_version[0] ? own_version : cur_hash;
|
|
const char* pc_note = partition_change ? " [partition change: cable flash]" : "";
|
|
|
|
// --- Report (dry run / `ota check`) --------------------------------------
|
|
if (dry_run) {
|
|
if (up_to_date) {
|
|
snprintf(reply, 160, "up to date: %s", avail_disp);
|
|
} else if (same_base && have_builds) {
|
|
snprintf(reply, 160, "update available: %s -> %s (%d behind)%s", own_disp, avail_disp, behind, pc_note);
|
|
} else if (diff_base) {
|
|
snprintf(reply, 160, "update available: %s -> %s (new base)%s", own_disp, avail_disp, pc_note);
|
|
} else {
|
|
snprintf(reply, 160, "update available: %s -> %s%s", own_disp, avail_disp, pc_note);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// --- Gates (real `ota update`) -------------------------------------------
|
|
if (partition_change) {
|
|
snprintf(reply, 160, "ERR: %s needs cable flash (partition change)", avail_disp);
|
|
return false;
|
|
}
|
|
if (up_to_date) {
|
|
snprintf(reply, 160, "OK: already up to date (%s)", avail_disp);
|
|
return false;
|
|
}
|
|
|
|
// --- Stream the .bin (the manifest's full URL) into the inactive OTA slot -
|
|
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, file_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_BASE && 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
|