Files
MeshCore/src/helpers/ESP32Board.cpp
T
agessaman 730eb37dbd feat(ota): implement build number suffix for firmware versioning
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.
2026-06-23 12:55:07 -07:00

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