Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,10 @@ build_firmware() {
esac
EMBEDDED_VERSION_STRING="${FIRMWARE_VERSION}${VARIANT_TAG}-${COMMIT_HASH}"

# add firmware version info to end of existing platformio build flags in environment vars
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${EMBEDDED_VERSION_STRING}\"'"
# add firmware version info to end of existing platformio build flags in environment vars.
# OTA_VARIANT is the env name ($1) — it is exactly the asset-filename prefix used above, so the
# observer pull-OTA can match its own build in the web-flasher manifest (config.json).
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${EMBEDDED_VERSION_STRING}\"' -DOTA_VARIANT='\"$1\"'"

# disable debug flags if requested
disable_debug_flags
Expand Down
16 changes: 16 additions & 0 deletions examples/simple_repeater/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1445,6 +1445,22 @@ void MyMesh::loop() {
MESH_DEBUG_PRINTLN("Radio params restored");
}

#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_URL)
if (_ota_update_at && millisHasNowPassed(_ota_update_at)) { // deferred `ota update`
_ota_update_at = 0; // clear timer
// The "Beginning update..." reply has now gone out. Free the bridge for heap
// headroom, then flash: otaFromManifest reboots into the new image on success
// (so this never returns); on any abort (already up to date, partition change,
// download error) it returns and we resume the bridge.
setBridgeState(false);
char ota_reply[160];
if (!_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) {
MESH_DEBUG_PRINTLN("ota update aborted: %s", ota_reply);
setBridgeState(true);
}
}
#endif

// is pending dirty contacts write needed?
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
acl.save(_fs);
Expand Down
10 changes: 10 additions & 0 deletions examples/simple_repeater/MyMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
#endif
CayenneLPP telemetry;
unsigned long set_radio_at, revert_radio_at;
unsigned long _ota_update_at = 0; // deferred `ota update` fire time (0 = none scheduled)
float pending_freq;
float pending_bw;
uint8_t pending_sf;
Expand Down Expand Up @@ -306,6 +307,15 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks {
bridge->setSlotPreset(slot, _prefs.mqtt_slot_preset[slot]);
}

// Schedule the pull-OTA flash to run from loop() in ~2.5 s, leaving time for the
// "Beginning update..." CLI reply (CLI_REPLY_DELAY_MILLIS = 600 ms) to transmit
// before the flash blocks the loop and reboots.
bool beginDeferredOtaUpdate() override {
_ota_update_at = millis() + 2500;
if (_ota_update_at == 0) _ota_update_at = 1; // 0 means "none"
return true;
}

int getQueueSize() override {
return bridge ? bridge->getQueueSize() : 0;
}
Expand Down
4 changes: 4 additions & 0 deletions src/MeshCore.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ class MainBoard {
virtual uint8_t getStartupReason() const = 0;
virtual bool getBootloaderVersion(char* version, size_t max_len) { return false; }
virtual bool startOTAUpdate(const char* id, char reply[]) { return false; } // not supported
// Pull-based OTA: fetch the firmware build for this variant from a baked-in manifest and flash it.
// current_ver is the running firmware version string (used to skip if already up to date); when
// dry_run is true the build is only reported, not flashed. Observer (ESP32+WiFi) builds only.
virtual bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) { return false; }

// Power management interface (boards with power management override these)
virtual bool isExternalPowered() { return false; }
Expand Down
33 changes: 33 additions & 0 deletions src/helpers/CommonCLI.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -791,8 +791,41 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
}
#else
strcpy(reply, "ERR: unsupported on this platform");
#endif
} else if (memcmp(command, "ota check", 9) == 0 || memcmp(command, "ota update", 10) == 0) {
// Observer pull-OTA: fetch this variant's build from the baked-in manifest
// and flash it. Intentionally a separate command from "start ota" (the
// manual ElegantOTA web-upload SoftAP) so a remote/online update is never
// triggered by someone expecting to hand-upload a binary.
// ota check -> report available build, do not flash
// ota update -> download and flash, then reboot
#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_URL)
if (WiFi.status() != WL_CONNECTED) {
strcpy(reply, "ERR: WiFi not connected");
} else if (memcmp(command, "ota check", 9) == 0) {
// Check is synchronous so its result lands in this reply. Free the MQTT
// bridge first: on a no-PSRAM board only ~70 KB heap is free with the
// bridge up, and a third TLS connection (the manifest fetch) alongside the
// two live MQTT sessions drives free heap to a few hundred bytes, which
// truncates the read. The WiFi STA link survives end(); restore after.
_callbacks->setBridgeState(false);
_board->otaFromManifest(_callbacks->getFirmwareVer(), true, reply);
_callbacks->setBridgeState(true);
} else {
// Update is DEFERRED: the flash blocks the loop and then reboots, so it
// must run only AFTER this reply has gone out over the mesh — otherwise
// the requester never gets a confirmation. The app loop runs it shortly.
if (_callbacks->beginDeferredOtaUpdate()) {
strcpy(reply, "Beginning update... (node will reboot if successful)");
} else {
strcpy(reply, "ERR: online OTA not available");
}
}
#else
strcpy(reply, "ERR: online OTA not supported on this build");
#endif
} else if (memcmp(command, "start ota", 9) == 0) {
// Manual OTA: bring up the ElegantOTA SoftAP for a hand-uploaded binary.
if (!_board->startOTAUpdate(_prefs->node_name, reply)) {
strcpy(reply, "Error");
}
Expand Down
8 changes: 8 additions & 0 deletions src/helpers/CommonCLI.h
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,14 @@ class CommonCLICallbacks {
restartBridge();
};

// Schedule a pull-OTA firmware update to run shortly (from the app loop), after
// the "Beginning update..." CLI reply has been transmitted. Deferred because the
// flash blocks the loop and then reboots, so it can't run inline with the reply.
// Returns true if scheduled. Default: not supported.
virtual bool beginDeferredOtaUpdate() {
return false;
};

virtual int getQueueSize() {
return 0; // no op by default
};
Expand Down
259 changes: 257 additions & 2 deletions src/helpers/ESP32Board.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,19 @@

bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
inhibit_sleep = true; // prevent sleep during OTA
WiFi.softAP("MeshCore-OTA", NULL);

sprintf(reply, "Started: http://%s/update", WiFi.softAPIP().toString().c_str());
// 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];
Expand Down Expand Up @@ -44,4 +54,249 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[]) {
}
#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
Loading
Loading